Seven tasks, each ending in a probe assertion rather than a unit test -- this project's regression net is the headless probes, not a suite. The spec's multicast round trip is split across three tasks so nothing in the plan needs a group this machine can join.
1541 lines
61 KiB
Markdown
1541 lines
61 KiB
Markdown
<!-- SPDX-License-Identifier: MIT -->
|
||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||
|
||
# Source Chooser Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Launched with no `--server`, udpscope opens a dialog listing the sources it has a stored session for, plus a form for a new one, instead of silently connecting to `127.0.0.1:8000`.
|
||
|
||
**Architecture:** A pre-flight dialog. `main()` calls `choose_source(cfg)` before `ScopeModel` exists, so a cancel costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and `run_ui()` are untouched. The list is built from the session files `layout_store` already owns, filtered to those naming at least one signal; the session format gains `multicast`/`interface` keys so a row can reconnect exactly.
|
||
|
||
**Tech Stack:** C++20, GTK4 (`gtk_init()` + a nested `GMainLoop`), CMake + Ninja. No new dependencies.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-03-source-chooser-design.md` — read it before Task 1.
|
||
|
||
**One deliberate departure from the spec's Verification section.** The spec asks for the multicast
|
||
round trip as a single serial run pair (`--server`/`--multicast` to write the file, then no
|
||
`--server` to read it back). That needs a group this machine can actually join, which a headless
|
||
run cannot be relied on to have, so the same property is proved in three cheaper pieces instead:
|
||
Task 1's one-off proves the two keys round-trip through the file *and* land in the top block, Task 3
|
||
proves the running app writes what it was launched with (unicast plus `--interface 127.0.0.1`, which
|
||
needs no join), and Task 5 proves a stored multicast row reconnects — from a session file written by
|
||
hand. No multicast networking is required anywhere in this plan.
|
||
|
||
## Global Constraints
|
||
|
||
- **Every source file carries an SPDX MIT header**, exactly: `// SPDX-License-Identifier: MIT` then `// SPDX-FileCopyrightText: 2026 Martino Ferrari`.
|
||
- **The build must stay warning-clean** under `-Wall -Wextra -Wpedantic` (set for this target in `CMakeLists.txt`). Pre-existing warnings in `src/ui.cpp` (`unused variable 'output_built'` at ~6044, two `-Wmissing-field-initializers` for `MathNode::source`, two `bugprone-integer-division`) are **not yours** — do not fix them in this work.
|
||
- **There is no unit test suite.** The project's regression net is the headless probes in `src/ui.cpp`, driven by env vars under `xvfb-run`. New checks extend that net; for code that runs before the GTK app exists, a one-off `/tmp/check_*.cpp` linked against the sources is this project's established practice (see `/tmp/check_math.cpp`, `/tmp/check_icons.c` in CLAUDE.md).
|
||
- **Run one `xvfb-run -a` at a time.** Two concurrent instances land on the same display and the second gets no GL context — it dumps `layout over 0 frames` and `valid=-1`, which looks like a pipeline failure and is not one.
|
||
- **Kill the mock after every run that started one**, in its own command, with a bracketed pattern: `pkill -f 'mock_udpstream[e]r'` then confirm with `pgrep -f 'mock_udpstream[e]r'`. `tools/mock_udpstreamer.py` outlives its `--duration` and leftovers hold ports.
|
||
- **Comment style:** this codebase's comments say *why*, and name the defect a rule exists to prevent. Match it. Do not add comments to code you did not change.
|
||
- **`gtk_window_destroy()` does not emit `close-request`.** Any dialog whose state is cleared by a `close-request` handler must be closed with `gtk_window_close()`. This cost this project a use-after-free in the math dialog already.
|
||
- **Do not reformat, refactor or "improve" code outside the lines each task names.**
|
||
|
||
---
|
||
|
||
### Task 1: The session file stores how the source was reached
|
||
|
||
A session file is named `<host>_<port>.toml`, so its name cannot say whether that source was multicast or which interface it joined on. Without these keys a chooser row cannot reconnect to a multicast source at all.
|
||
|
||
**Files:**
|
||
- Modify: `src/layout_store.hpp:38-48` (add two fields to `LayoutState`)
|
||
- Modify: `src/layout_store.cpp:227-245` (parse them), `src/layout_store.cpp:325-338` (write them)
|
||
- Test: `/tmp/check_layout_rt.cpp` (one-off, not added to the build)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: `LayoutState::multicast` and `LayoutState::interface_addr`, both `std::string`, empty meaning unicast and default-route respectively. Tasks 2 and 3 read and write them.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `/tmp/check_layout_rt.cpp`:
|
||
|
||
```cpp
|
||
// Round-trips a LayoutState through save_layout()/load_layout() and checks where the
|
||
// connection keys land in the file. One-off; not part of the build.
|
||
#include "layout_store.hpp"
|
||
|
||
#include <cstdio>
|
||
#include <fstream>
|
||
#include <sstream>
|
||
#include <string>
|
||
|
||
static int failures = 0;
|
||
|
||
static void check(bool ok, const char* what) {
|
||
std::printf("%-58s %s\n", what, ok ? "ok" : "FAIL");
|
||
if (!ok) ++failures;
|
||
}
|
||
|
||
static std::string slurp(const char* path) {
|
||
std::ifstream in(path);
|
||
std::ostringstream o;
|
||
o << in.rdbuf();
|
||
return o.str();
|
||
}
|
||
|
||
int main() {
|
||
const char* path = "/tmp/check_layout_rt.toml";
|
||
|
||
udpscope::LayoutState st;
|
||
st.window_s = 2.0;
|
||
st.rows.push_back({{"sine", "", "", ""}});
|
||
st.enabled.push_back("sine");
|
||
st.calibration.push_back({"sine", 2.0, 0.5});
|
||
st.multicast = "239.1.2.3";
|
||
st.interface_addr = "10.0.0.9";
|
||
check(udpscope::save_layout(path, st), "save_layout");
|
||
|
||
udpscope::LayoutState back;
|
||
check(udpscope::load_layout(path, back), "load_layout");
|
||
check(back.multicast == "239.1.2.3", "multicast survives the round trip");
|
||
check(back.interface_addr == "10.0.0.9", "interface survives the round trip");
|
||
check(back.window_s == 2.0, "window_s still survives");
|
||
check(back.enabled.size() == 1, "enabled still survives");
|
||
check(back.calibration.size() == 1, "calibration still survives");
|
||
|
||
// The rule that cost this project the `enabled` key once: in TOML a bare key written after a
|
||
// table header belongs to *that table*, so a connection key emitted after `[[plot]]` would be
|
||
// parsed into the plot and silently lost. Assert the position, not just the value -- a reader
|
||
// and a writer that agree with each other can still both be wrong.
|
||
const std::string text = slurp(path);
|
||
const auto first_table = text.find("[[");
|
||
const auto mc = text.find("multicast = ");
|
||
const auto ifc = text.find("interface = ");
|
||
check(mc != std::string::npos && mc < first_table, "multicast is in the top block");
|
||
check(ifc != std::string::npos && ifc < first_table, "interface is in the top block");
|
||
|
||
// A file written by an older build has neither key and must load as unicast on the default
|
||
// route rather than as something unset.
|
||
{
|
||
std::ofstream out("/tmp/check_layout_old.toml");
|
||
out << "window_s = 1\nplots = 1\n\n[[plot]]\nsignals = [\"sine\", \"\", \"\", \"\"]\n";
|
||
}
|
||
udpscope::LayoutState old;
|
||
check(udpscope::load_layout("/tmp/check_layout_old.toml", old), "load an older file");
|
||
check(old.multicast.empty() && old.interface_addr.empty(), "an older file reads as unicast");
|
||
|
||
std::printf("%s\n", failures == 0 ? "all ok" : "FAILURES");
|
||
return failures == 0 ? 0 : 1;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to make sure it fails**
|
||
|
||
```bash
|
||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_layout_rt.cpp \
|
||
src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_layout_rt
|
||
```
|
||
|
||
Expected: **compile error** — `'struct udpscope::LayoutState' has no member named 'multicast'`.
|
||
|
||
- [ ] **Step 3: Add the fields**
|
||
|
||
In `src/layout_store.hpp`, inside `struct LayoutState`, after the `enabled` member and before `math`:
|
||
|
||
```cpp
|
||
/// How this source was reached, so the startup chooser can reconnect to it exactly. The file is
|
||
/// named `<host>_<port>.toml` and that name can say neither of these. Empty `multicast` is
|
||
/// unicast; empty `interface_addr` is the default route.
|
||
std::string multicast;
|
||
std::string interface_addr;
|
||
```
|
||
|
||
- [ ] **Step 4: Write them**
|
||
|
||
In `src/layout_store.cpp`, in `save_layout()`, immediately after the `o << "plots = " ...` line and **before** the `enabled` block:
|
||
|
||
```cpp
|
||
// Top level, before any table, for the same reason as `enabled` below.
|
||
if (!state.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
|
||
if (!state.interface_addr.empty()) {
|
||
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Parse them**
|
||
|
||
In `src/layout_store.cpp`, in the `if (section == Section::Top)` chain, after the `plots` branch:
|
||
|
||
```cpp
|
||
} else if (key == "multicast") {
|
||
st.multicast = strip_quote(value);
|
||
} else if (key == "interface") {
|
||
st.interface_addr = strip_quote(value);
|
||
```
|
||
|
||
- [ ] **Step 6: Run the test to verify it passes**
|
||
|
||
```bash
|
||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_layout_rt.cpp \
|
||
src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_layout_rt \
|
||
&& /tmp/check_layout_rt
|
||
```
|
||
|
||
Expected: every line `ok`, last line `all ok`, exit 0.
|
||
|
||
- [ ] **Step 7: Verify the whole app still builds**
|
||
|
||
```bash
|
||
cmake --build build
|
||
```
|
||
|
||
Expected: no new warnings, no errors.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/layout_store.hpp src/layout_store.cpp
|
||
git commit -m "session file records how the source was reached
|
||
|
||
A session is named <host>_<port>.toml, which cannot say whether that source
|
||
was multicast or which interface it joined on, so a chooser row could not
|
||
reconnect to one. Both go in the top block: a bare key after a [[table]]
|
||
header belongs to that table, which is how the enabled key was lost once."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `known_sources()` and `forget_source()`
|
||
|
||
The chooser needs the list of sources with a stored session. It belongs in `layout_store` because that file already owns `config_dir()`, the `<host>_<port>.toml` scheme and the parser; a chooser that re-derived any of the three would be a second expression of the same decision.
|
||
|
||
**Files:**
|
||
- Modify: `src/layout_store.hpp` (add `KnownSource`, two functions, before `layout_path()`)
|
||
- Modify: `src/layout_store.cpp` (includes at the top; implementations after `layout_path()`)
|
||
- Test: `/tmp/check_sources.cpp` (one-off)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LayoutState::multicast`, `LayoutState::interface_addr` (Task 1).
|
||
- Produces:
|
||
- `struct KnownSource { std::string host; std::uint16_t port; std::string multicast; std::string interface_addr; int rows; int signals; std::int64_t mtime; };`
|
||
- `std::vector<KnownSource> known_sources();`
|
||
- `bool forget_source(const KnownSource& src);`
|
||
|
||
Tasks 4, 5 and 6 use all three.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `/tmp/check_sources.cpp`:
|
||
|
||
```cpp
|
||
// known_sources() against a throwaway config directory: the filter, the ordering, the
|
||
// filename split and forget_source(). One-off; not part of the build.
|
||
#include "layout_store.hpp"
|
||
|
||
#include <cstdio>
|
||
#include <cstdlib>
|
||
#include <ctime>
|
||
#include <string>
|
||
#include <sys/stat.h>
|
||
#include <unistd.h>
|
||
#include <utime.h>
|
||
#include <vector>
|
||
|
||
static int failures = 0;
|
||
|
||
static void check(bool ok, const char* what) {
|
||
std::printf("%-58s %s\n", what, ok ? "ok" : "FAIL");
|
||
if (!ok) ++failures;
|
||
}
|
||
|
||
/// Writes one session file and backdates it, so the ordering assertion does not depend on how
|
||
/// fast this program runs -- three files written in one millisecond have one mtime.
|
||
static void write_source(const std::string& host, std::uint16_t port, const char* signal,
|
||
const char* multicast, int age_s) {
|
||
udpscope::LayoutState st;
|
||
st.window_s = 1.0;
|
||
st.rows.push_back({{signal, "", "", ""}});
|
||
if (multicast != nullptr) st.multicast = multicast;
|
||
const std::string path = udpscope::layout_path(host, port);
|
||
udpscope::save_layout(path, st);
|
||
const ::time_t now = ::time(nullptr);
|
||
const ::utimbuf t{now - age_s, now - age_s};
|
||
::utime(path.c_str(), &t);
|
||
}
|
||
|
||
int main() {
|
||
// Its own directory, so the developer's real ~/.config/udpscope is neither read nor written.
|
||
::setenv("XDG_CONFIG_HOME", "/tmp/check-sources-home", 1);
|
||
std::system("rm -rf /tmp/check-sources-home");
|
||
|
||
write_source("127.0.0.1", 8000, "sine", nullptr, 300); // oldest
|
||
write_source("10.0.0.5", 8001, "square", "239.1.2.3", 60);
|
||
write_source("10.0.0.6", 8002, "saw", nullptr, 1); // newest
|
||
write_source("127.0.0.1", 9561, "", nullptr, 30); // a probe leftover: no named signal
|
||
|
||
std::vector<udpscope::KnownSource> got = udpscope::known_sources();
|
||
for (const auto& s : got) {
|
||
std::printf(" %s:%u rows=%d signals=%d multicast=%s\n", s.host.c_str(), s.port, s.rows,
|
||
s.signals, s.multicast.c_str());
|
||
}
|
||
|
||
check(got.size() == 3, "a session with no named signal is not listed");
|
||
if (got.size() == 3) {
|
||
check(got[0].host == "10.0.0.6" && got[0].port == 8002, "newest first");
|
||
check(got[2].host == "127.0.0.1" && got[2].port == 8000, "oldest last");
|
||
check(got[1].multicast == "239.1.2.3", "the stored group comes back");
|
||
check(got[1].signals == 1 && got[1].rows == 1, "the row and signal counts are the file's");
|
||
}
|
||
|
||
// The host part may itself hold '_', because `sanitize()` turns anything unsafe into one, so
|
||
// the split must be at the *last* '_' or a host comes back cut in half.
|
||
write_source("a_b.example", 7000, "sine", nullptr, 10);
|
||
got = udpscope::known_sources();
|
||
bool split_ok = false;
|
||
for (const auto& s : got) {
|
||
if (s.host == "a_b.example" && s.port == 7000) split_ok = true;
|
||
}
|
||
check(split_ok, "the filename splits at the last underscore");
|
||
|
||
// Forgetting one removes its file and nothing else.
|
||
const std::size_t before = got.size();
|
||
udpscope::KnownSource doomed;
|
||
doomed.host = "10.0.0.5";
|
||
doomed.port = 8001;
|
||
check(udpscope::forget_source(doomed), "forget_source reports success");
|
||
check(udpscope::known_sources().size() == before - 1, "forget_source removes exactly one");
|
||
|
||
std::printf("%s\n", failures == 0 ? "all ok" : "FAILURES");
|
||
return failures == 0 ? 0 : 1;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to make sure it fails**
|
||
|
||
```bash
|
||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp \
|
||
src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources
|
||
```
|
||
|
||
Expected: **compile error** — `'known_sources' is not a member of 'udpscope'`.
|
||
|
||
- [ ] **Step 3: Declare the interface**
|
||
|
||
In `src/layout_store.hpp`, immediately **before** the `layout_path()` declaration:
|
||
|
||
```cpp
|
||
/// A data source udpscope has a stored session for: one row of the startup chooser.
|
||
struct KnownSource {
|
||
std::string host; ///< the CONFIG server, even for a multicast session
|
||
std::uint16_t port = 0;
|
||
std::string multicast; ///< empty = unicast
|
||
std::string interface_addr; ///< empty = the default route
|
||
int rows = 0; ///< plots the session had
|
||
int signals = 0; ///< named slots across those plots
|
||
std::int64_t mtime = 0; ///< orders the list; never shown
|
||
};
|
||
|
||
/// Every session file that names at least one signal, newest first.
|
||
///
|
||
/// The filter is what makes the list usable: a development machine accumulates one session file
|
||
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
|
||
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
|
||
std::vector<KnownSource> known_sources();
|
||
|
||
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
|
||
/// could not be removed, which costs the caller nothing -- there is no state to roll back.
|
||
bool forget_source(const KnownSource& src);
|
||
```
|
||
|
||
- [ ] **Step 4: Implement them**
|
||
|
||
In `src/layout_store.cpp`, add to the includes at the top (keep them sorted as the file has them):
|
||
|
||
```cpp
|
||
#include <algorithm>
|
||
#include <cstdlib>
|
||
#include <dirent.h>
|
||
```
|
||
|
||
Then, immediately **after** the `layout_path()` definition:
|
||
|
||
```cpp
|
||
std::vector<KnownSource> known_sources() {
|
||
std::vector<KnownSource> out;
|
||
const std::string dir = config_dir();
|
||
DIR* d = ::opendir(dir.c_str());
|
||
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
||
if (d == nullptr) return out;
|
||
while (const dirent* e = ::readdir(d)) {
|
||
const std::string name = e->d_name;
|
||
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
|
||
const std::string stem = name.substr(0, name.size() - 5);
|
||
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
|
||
// may hold them too and a first-match split would return a host cut in half.
|
||
const std::size_t cut = stem.rfind('_');
|
||
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
||
const long port = std::strtol(stem.c_str() + cut + 1, nullptr, 10);
|
||
if (port <= 0 || port > 65535) continue;
|
||
|
||
LayoutState st;
|
||
const std::string path = dir + "/" + name;
|
||
if (!load_layout(path, st)) continue;
|
||
|
||
KnownSource s;
|
||
s.host = stem.substr(0, cut);
|
||
s.port = static_cast<std::uint16_t>(port);
|
||
s.multicast = st.multicast;
|
||
s.interface_addr = st.interface_addr;
|
||
s.rows = static_cast<int>(st.rows.size());
|
||
for (const LayoutState::Row& row : st.rows) {
|
||
for (const std::string& sig : row.signals) {
|
||
if (!sig.empty()) ++s.signals;
|
||
}
|
||
}
|
||
if (s.signals == 0) continue; // see the header: a session nobody configured
|
||
|
||
struct stat sb {};
|
||
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
|
||
out.push_back(std::move(s));
|
||
}
|
||
::closedir(d);
|
||
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
||
// swapping between runs.
|
||
std::stable_sort(out.begin(), out.end(),
|
||
[](const KnownSource& a, const KnownSource& b) { return a.mtime > b.mtime; });
|
||
return out;
|
||
}
|
||
|
||
bool forget_source(const KnownSource& src) {
|
||
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
|
||
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
||
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
||
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
```bash
|
||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp \
|
||
src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources \
|
||
&& /tmp/check_sources
|
||
```
|
||
|
||
Expected: every line `ok`, last line `all ok`, exit 0. `utimbuf` comes from the test's own
|
||
`<utime.h>`; do not add that include to `layout_store.cpp`, which has no business backdating a file.
|
||
|
||
- [ ] **Step 6: Confirm the developer's own config directory was untouched**
|
||
|
||
```bash
|
||
ls -1 ~/.config/udpscope/ | wc -l
|
||
```
|
||
|
||
Expected: unchanged from before the task (the test sets `XDG_CONFIG_HOME`). If this dropped, `forget_source()` is deleting outside the configured directory — stop and fix it.
|
||
|
||
- [ ] **Step 7: Verify the app still builds**
|
||
|
||
```bash
|
||
cmake --build build
|
||
```
|
||
|
||
Expected: no new warnings, no errors.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/layout_store.hpp src/layout_store.cpp
|
||
git commit -m "list the sources with a stored session
|
||
|
||
known_sources() reads the session files the layout store already owns, keeping
|
||
only those that name a signal: a development machine accumulates one file per
|
||
probe run on a throwaway port, and all 140 of them here hold empty slots."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: The app writes its own connection into the session file
|
||
|
||
Task 1 taught the file to carry the connection; nothing fills it in yet. `collect_layout()` is where a session's state is read back out of the running app.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui.cpp:267-268` (two new `App` members), `src/ui.cpp:558-561` (in `collect_layout()`), `src/ui.cpp:7160-7161` (in `run_ui()`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LayoutState::multicast`, `LayoutState::interface_addr` (Task 1).
|
||
- Produces: `App::source_multicast`, `App::source_interface` (both `std::string`). Nothing later depends on them; this closes Task 1's loop.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
There is no unit harness for `App`; the check is a real run. Record the expectation first by running it against the current build:
|
||
|
||
```bash
|
||
rm -f ~/.config/udpscope/127.0.0.1_9999.toml
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 UDPSCOPE_PROBE_WINDOW_MS=1500 \
|
||
./build/udpscope --server 127.0.0.1 --port 9999 --interface 127.0.0.1 >/dev/null 2>&1
|
||
grep -nE '^(multicast|interface) =' ~/.config/udpscope/127.0.0.1_9999.toml; echo "grep=$?"
|
||
```
|
||
|
||
Expected: `grep=1` — no such key. (No mock and no listener on 9999 on purpose: nothing needs to connect. `UDPSCOPE_PROBE_WINDOW_MS` drives the window control, which schedules a layout save at `src/ui.cpp:1798`, and the `destroy` handler at `src/ui.cpp:5587` flushes it — that is what writes the file with no data ever arriving.)
|
||
|
||
- [ ] **Step 2: Add the `App` members**
|
||
|
||
In `src/ui.cpp`, after `std::uint16_t source_port = 0;` (line 268):
|
||
|
||
```cpp
|
||
/// How that source was reached. Unused at runtime -- `Acquisition` has its own copy -- but
|
||
/// written into the session file, because `<host>_<port>.toml` can say neither, and the startup
|
||
/// chooser needs both to reconnect to a multicast source from a stored row.
|
||
std::string source_multicast;
|
||
std::string source_interface;
|
||
```
|
||
|
||
- [ ] **Step 3: Collect them**
|
||
|
||
In `collect_layout()`, replace the two lines at `src/ui.cpp:558-560`:
|
||
|
||
```cpp
|
||
// The app's own lists are the pane's source of truth for both of these.
|
||
st.math = app.math;
|
||
st.measures = app.measures;
|
||
```
|
||
|
||
with:
|
||
|
||
```cpp
|
||
// The app's own lists are the pane's source of truth for both of these.
|
||
st.math = app.math;
|
||
st.measures = app.measures;
|
||
// The connection is the session's too: the file's name carries only host and port.
|
||
st.multicast = app.source_multicast;
|
||
st.interface_addr = app.source_interface;
|
||
```
|
||
|
||
- [ ] **Step 4: Fill them in**
|
||
|
||
In `run_ui()`, after `app.source_port = cfg.port;` (line 7161):
|
||
|
||
```cpp
|
||
app.source_multicast = cfg.multicast;
|
||
app.source_interface = cfg.interface_addr;
|
||
```
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
```bash
|
||
cmake --build build
|
||
```
|
||
|
||
Expected: no new warnings, no errors.
|
||
|
||
- [ ] **Step 6: Run the test to verify it passes**
|
||
|
||
```bash
|
||
rm -f ~/.config/udpscope/127.0.0.1_9999.toml
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 UDPSCOPE_PROBE_WINDOW_MS=1500 \
|
||
./build/udpscope --server 127.0.0.1 --port 9999 --interface 127.0.0.1 >/dev/null 2>&1
|
||
grep -nE '^(interface|multicast) =|^\[\[' ~/.config/udpscope/127.0.0.1_9999.toml
|
||
```
|
||
|
||
Expected: `interface = "127.0.0.1"` on a **lower line number** than any `[[` table, and no `multicast` key (this run was unicast, and an empty value is not written).
|
||
|
||
- [ ] **Step 7: Clean up the throwaway session file**
|
||
|
||
```bash
|
||
rm -f ~/.config/udpscope/127.0.0.1_9999.toml
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/ui.cpp
|
||
git commit -m "save the connection with the session
|
||
|
||
collect_layout() now reports the multicast group and interface the app was
|
||
launched with, so a stored session says how to reach its source and not just
|
||
which host and port it was."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `--server` absent runs the chooser, and the chooser can list without a display
|
||
|
||
The smallest end-to-end slice: the flag, the new translation unit, the wiring in `main()`, and the list-and-exit probe. No widgets yet — the probe returns before `gtk_init()`, so it needs no display and proves the discovery path from inside the real binary.
|
||
|
||
**Files:**
|
||
- Modify: `src/config.hpp:13-33` (add `server_specified`), `src/config.cpp:68-70` (set it)
|
||
- Create: `src/source_dialog.hpp`, `src/source_dialog.cpp`
|
||
- Modify: `src/main.cpp:13-22` (call the chooser)
|
||
- Modify: `CMakeLists.txt:55-69` (add the source)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `known_sources()`, `KnownSource` (Task 2).
|
||
- Produces:
|
||
- `Config::server_specified` (`bool`, default `false`).
|
||
- `bool udpscope::choose_source(Config& cfg);` in `src/source_dialog.hpp` — `true` means `cfg` now names the source to open, `false` means the user declined and the caller should exit 0.
|
||
- `void apply_source(Config& cfg, const KnownSource& src);` and `void report_sources(const std::vector<KnownSource>& known);` — both in `source_dialog.cpp`'s **anonymous namespace**, never in the header: nothing outside this translation unit calls either, and Tasks 5 and 6 add their callers to the same file.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```bash
|
||
UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope; echo "exit=$?"
|
||
```
|
||
|
||
Expected: the app **connects to 127.0.0.1:8000 and opens a window** (or fails on the display) — it ignores the variable entirely, because nothing reads it. That is the current behaviour this task replaces.
|
||
|
||
- [ ] **Step 2: Add the flag**
|
||
|
||
In `src/config.hpp`, inside `struct Config`, after the `interface_addr` member:
|
||
|
||
```cpp
|
||
/// Whether `--server` was given. Absent, the application asks which source to open rather
|
||
/// than silently taking the default above -- so this is "did the user choose", not "is
|
||
/// `server` valid". The default stays a usable value for every other path.
|
||
bool server_specified = false;
|
||
```
|
||
|
||
In `src/config.cpp`, in the `--server` branch:
|
||
|
||
```cpp
|
||
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
||
c.server = value_of(argc, argv, i, "--server");
|
||
c.server_specified = true;
|
||
```
|
||
|
||
- [ ] **Step 3: Create the header**
|
||
|
||
`src/source_dialog.hpp`:
|
||
|
||
```cpp
|
||
// SPDX-License-Identifier: MIT
|
||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||
|
||
#pragma once
|
||
|
||
#include "config.hpp"
|
||
|
||
namespace udpscope {
|
||
|
||
/// Asks which data source to open: a list of the sources with a stored session, and a form for a
|
||
/// new one. Returns true with `cfg`'s connection fields filled in, or false if the user declined
|
||
/// -- in which case `cfg` is untouched and the caller should exit.
|
||
///
|
||
/// Runs *before* `ScopeModel` exists, so declining costs no buffer and no socket. It drives its
|
||
/// own `gtk_init()` and nested `GMainLoop`; `run_ui()`'s `GtkApplication` comes afterwards in the
|
||
/// same process, which works because `gtk_init()` is idempotent.
|
||
bool choose_source(Config& cfg);
|
||
|
||
} // namespace udpscope
|
||
```
|
||
|
||
- [ ] **Step 4: Create the implementation**
|
||
|
||
`src/source_dialog.cpp`:
|
||
|
||
```cpp
|
||
// SPDX-License-Identifier: MIT
|
||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||
|
||
#include "source_dialog.hpp"
|
||
|
||
#include "layout_store.hpp"
|
||
|
||
#include <gtk/gtk.h>
|
||
|
||
#include <cstdio>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
namespace udpscope {
|
||
namespace {
|
||
|
||
/// A chooser row's source becomes the connection to open. The interface comes along with the
|
||
/// group: it is the local address the join happens on, so storing one without the other would
|
||
/// reconnect to the right group on the wrong path.
|
||
void apply_source(Config& cfg, const KnownSource& src) {
|
||
cfg.server = src.host;
|
||
cfg.port = src.port;
|
||
cfg.multicast = src.multicast;
|
||
cfg.interface_addr = src.interface_addr;
|
||
}
|
||
|
||
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
|
||
void report_sources(const std::vector<KnownSource>& known) {
|
||
std::fprintf(stderr, "[sources] %zu known:", known.size());
|
||
for (const KnownSource& s : known) {
|
||
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
|
||
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
|
||
if (!s.interface_addr.empty()) {
|
||
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
|
||
}
|
||
std::fprintf(stderr, " |");
|
||
}
|
||
std::fprintf(stderr, "\n");
|
||
std::fflush(stderr);
|
||
}
|
||
|
||
} // namespace
|
||
|
||
bool choose_source(Config& cfg) {
|
||
const std::vector<KnownSource> known = known_sources();
|
||
|
||
// The discovery half, checkable with no display at all: this returns before `gtk_init()`, so
|
||
// it asserts what the list found without needing an X server or a GL context. False means the
|
||
// caller exits 0, which is what "list and exit" wants.
|
||
if (g_getenv("UDPSCOPE_PROBE_SOURCES") != nullptr) {
|
||
report_sources(known);
|
||
return false;
|
||
}
|
||
|
||
gtk_init();
|
||
(void)cfg;
|
||
return false;
|
||
}
|
||
|
||
} // namespace udpscope
|
||
```
|
||
|
||
- [ ] **Step 5: Wire it into `main()`**
|
||
|
||
In `src/main.cpp`, add the include beside the others:
|
||
|
||
```cpp
|
||
#include "source_dialog.hpp"
|
||
```
|
||
|
||
and replace the `parse_args` block's closing brace through the `ScopeModel` line so it reads:
|
||
|
||
```cpp
|
||
} catch (const std::exception& e) {
|
||
std::fprintf(stderr, "udpscope: %s\n", e.what());
|
||
return 2;
|
||
}
|
||
|
||
// No `--server` means the user has not said which source to open, so ask. Before the model:
|
||
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
|
||
if (!cfg.server_specified && !choose_source(cfg)) return 0;
|
||
|
||
udpscope::ScopeModel model(cfg);
|
||
```
|
||
|
||
- [ ] **Step 6: Add the source to the build**
|
||
|
||
In `CMakeLists.txt`, in `add_executable(udpscope ...)`, after `src/layout_store.cpp`:
|
||
|
||
```cmake
|
||
src/source_dialog.cpp
|
||
```
|
||
|
||
- [ ] **Step 7: Build**
|
||
|
||
```bash
|
||
cmake --build build
|
||
```
|
||
|
||
Expected: no new warnings, no errors.
|
||
|
||
- [ ] **Step 8: Run the test to verify it passes**
|
||
|
||
```bash
|
||
UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope; echo "exit=$?"
|
||
```
|
||
|
||
Expected: one `[sources] N known: ...` line, `exit=0`, **no window and no X server needed**. The listed entries must be sources with configured signals only — none of the `9561`-style probe ports.
|
||
|
||
- [ ] **Step 9: Verify `--server` still bypasses the chooser**
|
||
|
||
```bash
|
||
python3 tools/mock_udpstreamer.py --port 9701 --duration 20 >/dev/null 2>&1 &
|
||
sleep 1
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
./build/udpscope --server 127.0.0.1 --port 9701 2>&1 | grep -E 'frames=|gaps=|x-desync'
|
||
```
|
||
|
||
Expected: the usual dump, `gaps=0`, `x-desync 0` — a run with `--server` must not notice this feature exists.
|
||
|
||
- [ ] **Step 10: Kill the mock**
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
|
||
```
|
||
|
||
Expected: `stopped`.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add src/config.hpp src/config.cpp src/source_dialog.hpp src/source_dialog.cpp \
|
||
src/main.cpp CMakeLists.txt
|
||
git commit -m "ask which source to open when --server is absent
|
||
|
||
The chooser runs before ScopeModel exists, so declining costs no buffer and no
|
||
socket. UDPSCOPE_PROBE_SOURCES=1 prints what the discovery found and exits,
|
||
before gtk_init(), so the list is checkable with no display."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: The chooser window
|
||
|
||
**Files:**
|
||
- Modify: `src/source_dialog.cpp` (replace the `gtk_init(); (void)cfg; return false;` stub)
|
||
- Test: a hand-written session file plus a run with no `--server`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `apply_source()`, `report_sources()`, `known_sources()`, `forget_source()`.
|
||
- Produces: `struct Chooser` and `void chooser_populate(Chooser&)`, both in the anonymous namespace. Task 6 adds the form's widgets to `Chooser` and calls `chooser_finish(c, true)` from it.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create a known source by hand — a stored multicast session, which is the case no live network is needed to check:
|
||
|
||
```bash
|
||
mkdir -p ~/.config/udpscope
|
||
cat > ~/.config/udpscope/10.0.0.5_8001.toml <<'EOF'
|
||
# udpscope session layout (written by the app; safe to edit)
|
||
window_s = 2
|
||
plots = 1
|
||
multicast = "239.1.2.3"
|
||
interface = "10.0.0.9"
|
||
|
||
[[plot]]
|
||
signals = ["sine", "", "", ""]
|
||
EOF
|
||
xvfb-run -a env UDPSCOPE_PROBE_PICK_MS=800 ./build/udpscope 2>&1 | grep '\[sources\]'
|
||
echo "exit=$?"
|
||
```
|
||
|
||
Expected: **no `[sources] picked` line** — nothing reads `UDPSCOPE_PROBE_PICK_MS`, the stub returns false and the process exits 0 having shown nothing.
|
||
|
||
- [ ] **Step 2: Replace the stub with the chooser**
|
||
|
||
In `src/source_dialog.cpp`, inside the anonymous namespace, after `report_sources()`:
|
||
|
||
```cpp
|
||
/// The chooser's own state for the life of its nested loop. A plain local in `choose_source()`:
|
||
/// the loop runs inside that call, so nothing here outlives the stack frame and there is nothing
|
||
/// to free on a handler path.
|
||
struct Chooser {
|
||
Config* cfg = nullptr;
|
||
GMainLoop* loop = nullptr;
|
||
bool chosen = false;
|
||
GtkWidget* window = nullptr;
|
||
GtkWidget* list = nullptr;
|
||
std::vector<KnownSource> known;
|
||
};
|
||
|
||
void chooser_finish(Chooser& c, bool chosen) {
|
||
c.chosen = chosen;
|
||
if (c.window != nullptr) {
|
||
GtkWidget* w = c.window;
|
||
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
||
gtk_window_destroy(GTK_WINDOW(w));
|
||
}
|
||
if (c.loop != nullptr) g_main_loop_quit(c.loop);
|
||
}
|
||
|
||
void chooser_populate(Chooser& c);
|
||
|
||
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||
Chooser& c = *static_cast<Chooser*>(user);
|
||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
|
||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
|
||
chooser_finish(c, true);
|
||
}
|
||
|
||
void on_forget_clicked(GtkButton* button, gpointer user) {
|
||
Chooser& c = *static_cast<Chooser*>(user);
|
||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||
forget_source(c.known[static_cast<std::size_t>(idx)]);
|
||
// Repopulate rather than remove one row: the rows carry their index into `known`, and dropping
|
||
// an entry renumbers every later one. Re-reading is also the honest answer to "did it go".
|
||
chooser_populate(c);
|
||
}
|
||
|
||
/// Fills the list box from `known_sources()`. Called again after a forget.
|
||
void chooser_populate(Chooser& c) {
|
||
while (GtkWidget* child = gtk_widget_get_first_child(c.list)) {
|
||
gtk_list_box_remove(GTK_LIST_BOX(c.list), child);
|
||
}
|
||
c.known = known_sources();
|
||
|
||
if (c.known.empty()) {
|
||
GtkWidget* empty = gtk_label_new("No saved sources yet.");
|
||
gtk_widget_add_css_class(empty, "dim-label");
|
||
gtk_widget_set_margin_top(empty, 24);
|
||
gtk_widget_set_margin_bottom(empty, 24);
|
||
// Not a `GtkListBoxRow`, so it cannot be activated into a source that does not exist.
|
||
gtk_list_box_append(GTK_LIST_BOX(c.list), empty);
|
||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
|
||
return;
|
||
}
|
||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_SINGLE);
|
||
|
||
for (std::size_t i = 0; i < c.known.size(); ++i) {
|
||
const KnownSource& s = c.known[i];
|
||
GtkWidget* row = gtk_list_box_row_new();
|
||
g_object_set_data(G_OBJECT(row), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||
|
||
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
||
gtk_widget_set_margin_start(line, 12);
|
||
gtk_widget_set_margin_end(line, 6);
|
||
gtk_widget_set_margin_top(line, 6);
|
||
gtk_widget_set_margin_bottom(line, 6);
|
||
|
||
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||
gtk_widget_set_hexpand(text, TRUE);
|
||
|
||
// The *server*, even for a multicast session: that is the host the file is named after and
|
||
// the one CONFIG is fetched from. Titling the row by the group would name it after something
|
||
// that is not its identity.
|
||
const std::string title = s.host + ":" + std::to_string(s.port);
|
||
GtkWidget* title_label = gtk_label_new(title.c_str());
|
||
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(text), title_label);
|
||
|
||
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
|
||
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
|
||
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
|
||
GtkWidget* sub_label = gtk_label_new(sub.c_str());
|
||
gtk_widget_add_css_class(sub_label, "dim-label");
|
||
gtk_widget_set_halign(sub_label, GTK_ALIGN_START);
|
||
gtk_box_append(GTK_BOX(text), sub_label);
|
||
gtk_box_append(GTK_BOX(line), text);
|
||
|
||
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
||
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
|
||
gtk_widget_add_css_class(forget, "flat");
|
||
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
||
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||
g_signal_connect(forget, "clicked", G_CALLBACK(on_forget_clicked), &c);
|
||
gtk_box_append(GTK_BOX(line), forget);
|
||
|
||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||
gtk_list_box_append(GTK_LIST_BOX(c.list), row);
|
||
}
|
||
}
|
||
|
||
gboolean on_chooser_close(GtkWindow*, gpointer user) {
|
||
Chooser& c = *static_cast<Chooser*>(user);
|
||
c.window = nullptr; // GTK destroys it for us on the default handler
|
||
chooser_finish(c, false);
|
||
return FALSE;
|
||
}
|
||
|
||
gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType,
|
||
gpointer user) {
|
||
if (keyval != GDK_KEY_Escape) return FALSE;
|
||
chooser_finish(*static_cast<Chooser*>(user), false);
|
||
return TRUE;
|
||
}
|
||
|
||
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||
gboolean pick_probe(gpointer user) {
|
||
Chooser& c = *static_cast<Chooser*>(user);
|
||
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
|
||
if (row == nullptr) {
|
||
std::fprintf(stderr, "[sources] picked nothing: the list is empty\n");
|
||
std::fflush(stderr);
|
||
chooser_finish(c, false);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
// `row-activated`, not `gtk_widget_activate()`: this project has already learned that
|
||
// activating a widget takes a path a real click does not (a `GtkButton` holds for its 250 ms
|
||
// ACTIVATE_TIMEOUT and then emits into a widget a rebuild has destroyed).
|
||
g_signal_emit_by_name(c.list, "row-activated", row);
|
||
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n",
|
||
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
|
||
c.cfg->interface_addr.c_str());
|
||
std::fflush(stderr);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build the window in `choose_source()`**
|
||
|
||
Replace `gtk_init(); (void)cfg; return false;` with:
|
||
|
||
```cpp
|
||
gtk_init();
|
||
|
||
Chooser c;
|
||
c.cfg = &cfg;
|
||
c.window = gtk_window_new();
|
||
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
|
||
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
|
||
|
||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||
gtk_window_set_child(GTK_WINDOW(c.window), box);
|
||
|
||
GtkWidget* scroller = gtk_scrolled_window_new();
|
||
gtk_widget_set_vexpand(scroller, TRUE);
|
||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
|
||
GTK_POLICY_AUTOMATIC);
|
||
c.list = gtk_list_box_new();
|
||
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
|
||
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
|
||
gtk_box_append(GTK_BOX(box), scroller);
|
||
|
||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
gtk_widget_set_margin_start(actions, 12);
|
||
gtk_widget_set_margin_end(actions, 12);
|
||
gtk_widget_set_margin_top(actions, 12);
|
||
gtk_widget_set_margin_bottom(actions, 12);
|
||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
|
||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
chooser_finish(*static_cast<Chooser*>(user), false);
|
||
}),
|
||
&c);
|
||
gtk_box_append(GTK_BOX(actions), new_host);
|
||
gtk_box_append(GTK_BOX(actions), cancel);
|
||
gtk_box_append(GTK_BOX(box), actions);
|
||
|
||
chooser_populate(c);
|
||
if (c.known.empty()) gtk_widget_grab_focus(new_host);
|
||
|
||
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
|
||
GtkEventController* keys = gtk_event_controller_key_new();
|
||
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_chooser_key), &c);
|
||
gtk_widget_add_controller(c.window, keys);
|
||
|
||
gtk_window_present(GTK_WINDOW(c.window));
|
||
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
|
||
}
|
||
|
||
c.loop = g_main_loop_new(nullptr, FALSE);
|
||
g_main_loop_run(c.loop);
|
||
g_main_loop_unref(c.loop);
|
||
c.loop = nullptr;
|
||
return c.chosen;
|
||
```
|
||
|
||
Add `#include <cstdlib>` to the includes for `std::atoi`.
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```bash
|
||
cmake --build build
|
||
```
|
||
|
||
Expected: no new warnings, no errors. If `+[](GtkButton*, gpointer)` does not convert, the lambda has captured something — it must not.
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes**
|
||
|
||
```bash
|
||
xvfb-run -a env UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=4000 \
|
||
./build/udpscope 2>&1 | grep -E '\[sources\]|layout loaded'
|
||
```
|
||
|
||
Expected: `[sources] picked 10.0.0.5:8001 multicast=239.1.2.3 interface=10.0.0.9` — the stored group and interface came back, which is the assertion that a multicast row reconnects without any multicast networking in the test. The run then continues into the real application (it will not receive data; that is fine and not what this checks).
|
||
|
||
- [ ] **Step 6: Check the empty-list path**
|
||
|
||
```bash
|
||
XDG_CONFIG_HOME=/tmp/no-such-config xvfb-run -a env UDPSCOPE_PROBE_PICK_MS=600 \
|
||
./build/udpscope 2>&1 | grep '\[sources\]'; echo "exit=$?"
|
||
```
|
||
|
||
Expected: `[sources] picked nothing: the list is empty`, and the process exits 0 — an empty list must not crash on `gtk_list_box_get_row_at_index(.., 0)` returning null.
|
||
|
||
- [ ] **Step 7: Check the forget path**
|
||
|
||
```bash
|
||
ls ~/.config/udpscope/10.0.0.5_8001.toml && \
|
||
UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope 2>&1 | grep -c '10.0.0.5:8001'
|
||
```
|
||
|
||
Expected: the file exists and the count is `1`. Then confirm `forget_source()` is reachable and correct by deleting through it in the one-off from Task 2 (`/tmp/check_sources`), which already asserts it; **do not** add a probe that deletes a real session file.
|
||
|
||
- [ ] **Step 8: Verify a `--server` run is still untouched**
|
||
|
||
```bash
|
||
python3 tools/mock_udpstreamer.py --port 9702 --duration 20 >/dev/null 2>&1 &
|
||
sleep 1
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
./build/udpscope --server 127.0.0.1 --port 9702 2>&1 \
|
||
| grep -E 'frames=|gaps=|x-desync|trace ink'
|
||
```
|
||
|
||
Expected: the usual dump, `gaps=0`, `x-desync 0`, `0 collapsed, 0 staircase`.
|
||
|
||
- [ ] **Step 9: Kill the mock**
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
|
||
```
|
||
|
||
Expected: `stopped`.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/source_dialog.cpp
|
||
git commit -m "the source chooser's list
|
||
|
||
One row per stored session, titled by its server -- the host the file is named
|
||
after and the one CONFIG comes from -- with the group as subtitle. A forget
|
||
repopulates rather than removing one row, because the rows carry their index
|
||
into the list and dropping one renumbers the rest."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: The connection form
|
||
|
||
**Files:**
|
||
- Modify: `src/source_dialog.cpp` (fields on `Chooser`, the form, the `[New host…]` handler)
|
||
- Test: a run with no `--server` driving the form instead of a row
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Chooser`, `chooser_finish()` (Task 5).
|
||
- Produces: nothing used elsewhere. This completes the feature.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```bash
|
||
xvfb-run -a env UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_PROBE_NEWHOST=10.0.0.7:8123 \
|
||
./build/udpscope 2>&1 | grep '\[sources\]'
|
||
```
|
||
|
||
Expected: `[sources] picked 10.0.0.5:8001 ...` — the variable is ignored and row 0 is taken instead.
|
||
|
||
- [ ] **Step 2: Add the form's widgets to `Chooser`**
|
||
|
||
In `struct Chooser`, after `std::vector<KnownSource> known;`:
|
||
|
||
```cpp
|
||
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
|
||
/// the chooser outlives the form by construction, so there is nothing to free on a close path
|
||
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
|
||
/// -free in the math editor.
|
||
GtkWidget* form = nullptr;
|
||
GtkWidget* form_server = nullptr;
|
||
GtkWidget* form_port = nullptr;
|
||
GtkWidget* form_group = nullptr;
|
||
GtkWidget* form_iface = nullptr;
|
||
GtkWidget* form_connect = nullptr;
|
||
```
|
||
|
||
- [ ] **Step 3: Add the form's handlers**
|
||
|
||
In the anonymous namespace, after `chooser_populate()`:
|
||
|
||
```cpp
|
||
void form_close(Chooser& c) {
|
||
if (c.form == nullptr) return;
|
||
GtkWidget* w = c.form;
|
||
c.form = nullptr; // before the close, so the handler cannot re-enter
|
||
gtk_window_close(GTK_WINDOW(w));
|
||
}
|
||
|
||
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||
Chooser& c = *static_cast<Chooser*>(user);
|
||
if (c.form_connect == nullptr) return;
|
||
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
||
// already does, and the starvation notice covers a source that takes the socket and says
|
||
// nothing -- so checking it twice would be two expressions of one rule.
|
||
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
|
||
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
|
||
}
|
||
|
||
void on_form_connect(GtkButton*, gpointer user) {
|
||
Chooser& c = *static_cast<Chooser*>(user);
|
||
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
||
if (server == nullptr || server[0] == '\0') return;
|
||
c.cfg->server = server;
|
||
c.cfg->port = static_cast<std::uint16_t>(
|
||
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
|
||
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
||
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
||
form_close(c);
|
||
chooser_finish(c, true);
|
||
}
|
||
|
||
/// A labelled row for the form's grid.
|
||
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
|
||
GtkWidget* l = gtk_label_new(label);
|
||
gtk_widget_set_halign(l, GTK_ALIGN_END);
|
||
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
|
||
gtk_widget_set_hexpand(widget, TRUE);
|
||
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
|
||
return widget;
|
||
}
|
||
|
||
void open_form(Chooser& c) {
|
||
if (c.form != nullptr) {
|
||
gtk_window_present(GTK_WINDOW(c.form));
|
||
return;
|
||
}
|
||
c.form = gtk_window_new();
|
||
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
|
||
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
|
||
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
|
||
|
||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
||
gtk_widget_set_margin_start(box, 12);
|
||
gtk_widget_set_margin_end(box, 12);
|
||
gtk_widget_set_margin_top(box, 12);
|
||
gtk_widget_set_margin_bottom(box, 12);
|
||
gtk_window_set_child(GTK_WINDOW(c.form), box);
|
||
|
||
GtkWidget* grid = gtk_grid_new();
|
||
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
|
||
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
|
||
gtk_box_append(GTK_BOX(box), grid);
|
||
|
||
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
|
||
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
|
||
// A spin button holds no decimals by default, which is right for a port and wrong for every
|
||
// other numeric field in this project -- see the math dialog's fields.
|
||
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
|
||
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
|
||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
|
||
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
|
||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
|
||
|
||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
form_close(*static_cast<Chooser*>(user));
|
||
}),
|
||
&c);
|
||
c.form_connect = gtk_button_new_with_label("Connect");
|
||
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
|
||
gtk_box_append(GTK_BOX(actions), cancel);
|
||
gtk_box_append(GTK_BOX(actions), c.form_connect);
|
||
gtk_box_append(GTK_BOX(box), actions);
|
||
|
||
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
|
||
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
|
||
// not, which is how the math dialog once kept presenting a finalized window.
|
||
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
|
||
static_cast<Chooser*>(user)->form = nullptr;
|
||
return FALSE;
|
||
}),
|
||
&c);
|
||
|
||
gtk_window_present(GTK_WINDOW(c.form));
|
||
gtk_widget_grab_focus(c.form_server);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Replace the `pick_probe` with one that can drive either path**
|
||
|
||
Replace the whole `pick_probe()` body's `row == nullptr` branch and add the new-host branch, so the function reads:
|
||
|
||
```cpp
|
||
gboolean pick_probe(gpointer user) {
|
||
Chooser& c = *static_cast<Chooser*>(user);
|
||
|
||
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
||
// dialog built, filled and destroyed is as much the point as the values, the same way
|
||
// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
||
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
|
||
open_form(c);
|
||
const std::string s = spec;
|
||
const std::size_t colon = s.rfind(':');
|
||
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
||
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
||
if (colon != std::string::npos) {
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
|
||
}
|
||
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
|
||
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
||
g_signal_emit_by_name(c.form_connect, "clicked");
|
||
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n",
|
||
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
|
||
c.cfg->interface_addr.c_str());
|
||
std::fflush(stderr);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
|
||
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
|
||
if (row == nullptr) {
|
||
std::fprintf(stderr, "[sources] picked nothing: the list is empty\n");
|
||
std::fflush(stderr);
|
||
chooser_finish(c, false);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
g_signal_emit_by_name(c.list, "row-activated", row);
|
||
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n",
|
||
c.cfg->server.c_str(), c.cfg->port, c.cfg->multicast.c_str(),
|
||
c.cfg->interface_addr.c_str());
|
||
std::fflush(stderr);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
```
|
||
|
||
`pick_probe()` now calls `open_form()`, so `open_form()` must be defined above it — it is, if you added Step 3's block after `chooser_populate()` and `pick_probe()` stays last.
|
||
|
||
- [ ] **Step 5: Wire `[New host…]`**
|
||
|
||
In `choose_source()`, after creating `new_host` and before appending it:
|
||
|
||
```cpp
|
||
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||
open_form(*static_cast<Chooser*>(user));
|
||
}),
|
||
&c);
|
||
```
|
||
|
||
- [ ] **Step 6: Build**
|
||
|
||
```bash
|
||
cmake --build build
|
||
```
|
||
|
||
Expected: no new warnings, no errors.
|
||
|
||
- [ ] **Step 7: Run the test to verify it passes**
|
||
|
||
```bash
|
||
xvfb-run -a env UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_PROBE_NEWHOST=10.0.0.7:8123 \
|
||
./build/udpscope 2>&1 | grep '\[sources\]'
|
||
```
|
||
|
||
Expected:
|
||
|
||
```
|
||
[sources] form open, connect_sensitive=1
|
||
[sources] picked 10.0.0.7:8123 multicast= interface=
|
||
```
|
||
|
||
and no SIGSEGV. `connect_sensitive=1` is the assertion that a non-empty server enables `[Connect]`.
|
||
|
||
- [ ] **Step 8: Check the empty-server refusal**
|
||
|
||
```bash
|
||
timeout 10 xvfb-run -a env UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_PROBE_NEWHOST= \
|
||
./build/udpscope 2>&1 | grep '\[sources\]'
|
||
```
|
||
|
||
Expected:
|
||
|
||
```
|
||
[sources] form open, connect_sensitive=0
|
||
[sources] picked 127.0.0.1:8000 multicast= interface=
|
||
```
|
||
|
||
`UDPSCOPE_PROBE_NEWHOST=` is an empty string, which `g_getenv` still returns, so the form opens with the server cleared — `connect_sensitive=0`, and the `picked` line reports the **default** `127.0.0.1:8000` unchanged because `on_form_connect()` returned early on an empty server. Nothing chose a source, so the chooser stays up and `timeout 10` is what ends the run; that is the expected outcome here, not a hang. Confirm nothing survived it:
|
||
|
||
```bash
|
||
pgrep -f 'build/udpscop[e]' || echo "stopped"
|
||
```
|
||
|
||
- [ ] **Step 9: Verify a `--server` run is still untouched**
|
||
|
||
```bash
|
||
python3 tools/mock_udpstreamer.py --port 9703 --duration 20 >/dev/null 2>&1 &
|
||
sleep 1
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
./build/udpscope --server 127.0.0.1 --port 9703 2>&1 \
|
||
| grep -E 'frames=|gaps=|x-desync|trace ink|toolbar icons'
|
||
```
|
||
|
||
Expected: the usual dump, `gaps=0`, `x-desync 0`, `0 collapsed, 0 staircase`, every icon `(ok)`.
|
||
|
||
- [ ] **Step 10: Kill the mock**
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
|
||
```
|
||
|
||
Expected: `stopped`.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add src/source_dialog.cpp
|
||
git commit -m "the new-host connection form
|
||
|
||
Server, port, multicast group and interface, and nothing else: --buffer-mb and
|
||
friends are tuning rather than which source, and a form that asks for them is
|
||
one a user has to read first. The form's widgets live on the chooser, which
|
||
outlives them, so no close path frees a live context."
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Documentation
|
||
|
||
This project documents a feature in **both** `README.md` (what was built and why) and `CLAUDE.md` (the verification entry and the invariant). A change that is not in both is not finished here.
|
||
|
||
**Files:**
|
||
- Modify: `CLAUDE.md` (the Verification list, the Invariants list, the Build-and-run options line)
|
||
- Modify: `README.md` (a new passage near the session-persistence one at ~line 450)
|
||
|
||
**Interfaces:** none.
|
||
|
||
- [ ] **Step 1: Add the verification entry**
|
||
|
||
In `CLAUDE.md`, in the Verification list, immediately after the `toolbar icons:` entry:
|
||
|
||
```markdown
|
||
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||
signal may appear**: a development machine accumulates one session file per probe run on a
|
||
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
|
||
what keeps the list the sources a user configured. Order is newest first by file mtime.
|
||
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
|
||
shows the chooser, then activates row 0 the way a click does and continues into the real
|
||
application, so a full run with **no `--server`** must reach the scope and emit every other
|
||
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
|
||
assertion that a multicast session reconnects to its group, and it needs **no multicast
|
||
networking**: write the session file by hand and read the line. With
|
||
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
|
||
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
||
dialog built and destroyed as it is a state check. An empty list must print `picked nothing`
|
||
rather than dereferencing a null row.
|
||
```
|
||
|
||
- [ ] **Step 2: Add the invariant**
|
||
|
||
In `CLAUDE.md`, in the Invariants list, immediately **before** the `Every source file carries an SPDX MIT header` bullet:
|
||
|
||
```markdown
|
||
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
||
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
||
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
||
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
|
||
`--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes
|
||
`--server`**, which is what makes the feature invisible to the regression net — a probe added
|
||
without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then
|
||
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
|
||
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
|
||
removed `gtk_dialog_run()`).
|
||
- **A session file's name cannot say how its source was reached, so the file says it.**
|
||
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
|
||
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
|
||
written in the **top block, before the first `[[table]]`** — a bare key after a table header is
|
||
that table's key in TOML, which is exactly how `enabled` was parsed into the last
|
||
`[[calibration]]` record and lost without a word. A file from an older build has neither key and
|
||
loads as unicast on the default route. The chooser's list filters to sessions that **name at
|
||
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
|
||
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
|
||
`sanitize()` turns anything unsafe in a host into one.
|
||
```
|
||
|
||
- [ ] **Step 3: Note it in Build and run**
|
||
|
||
In `CLAUDE.md`, in the Build-and-run section, after the `Options:` paragraph:
|
||
|
||
```markdown
|
||
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
||
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
||
```
|
||
|
||
- [ ] **Step 4: Write the README passage**
|
||
|
||
In `README.md`, immediately **after** the paragraph beginning `**The session is saved with the layout**`, add:
|
||
|
||
```markdown
|
||
**Launched with no `--server`, udpscope asks which source to open.** It used to connect to
|
||
`127.0.0.1:8000` because that is the default in `Config` — fine for one streamer, useless for
|
||
several, and there was no way to see which sources the app already knew about. The chooser lists
|
||
one row per stored session: the server and port it was named after, how many plots and signals it
|
||
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
|
||
server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning
|
||
rather than "which source", and a form that asks for them is a form you have to read before using
|
||
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
|
||
failure.
|
||
|
||
It runs **before the model exists**, which is the whole reason it is cheap: no buffer, no socket
|
||
and no window until a source is known, and `ScopeModel`, `Acquisition` and `run_ui()` are
|
||
untouched by the feature. Every headless probe passes `--server`, so none of them reaches it.
|
||
|
||
Two things the list had to get right. A session file is named `<host>_<port>.toml` and that name
|
||
cannot say whether the source was multicast or which interface it joined on, so the file now
|
||
records both — in the top block, before the first table, because a bare key written after a
|
||
`[[table]]` header belongs to that table in TOML, which is how the `enabled` key was silently lost
|
||
once already. And the list is **filtered to sessions that name a signal**: a development machine
|
||
accumulates one file per probe run on a throwaway port — 140 of them here, every one with
|
||
`signals = ["", "", "", ""]` — so an unfiltered chooser opens as a wall of ports nobody chose. A
|
||
row's `×` forgets a source by deleting its session file, with no confirmation: the gesture is the
|
||
confirmation, and what it destroys is one layout the app rewrites on the next clean exit.
|
||
```
|
||
|
||
- [ ] **Step 5: Check both files read correctly**
|
||
|
||
```bash
|
||
grep -n 'sources\]' CLAUDE.md | head
|
||
grep -n 'no .--server., udpscope asks' README.md
|
||
```
|
||
|
||
Expected: the two Verification entries and the README passage are present.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add CLAUDE.md README.md
|
||
git commit -m "document the source chooser
|
||
|
||
Both files, per this project's rule: the probe entries and the two invariants
|
||
in CLAUDE.md, the feature and why the list is filtered in README."
|
||
```
|
||
|
||
---
|
||
|
||
## Final verification
|
||
|
||
Run these in order, one at a time, after Task 7.
|
||
|
||
- [ ] **A clean build from scratch**
|
||
|
||
```bash
|
||
cmake -S . -B /tmp/udpscope-clean -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||
&& cmake --build /tmp/udpscope-clean 2>&1 | grep -iE 'warning|error' | grep -v 'output_built\|missing-field-initializers' ; echo "new warnings above (none expected)"
|
||
```
|
||
|
||
- [ ] **The two one-offs still pass**
|
||
|
||
```bash
|
||
/tmp/check_layout_rt && /tmp/check_sources
|
||
```
|
||
|
||
Expected: `all ok` from both.
|
||
|
||
- [ ] **The discovery probe**
|
||
|
||
```bash
|
||
UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope
|
||
```
|
||
|
||
Expected: one `[sources] N known:` line, no probe-port entries, exit 0.
|
||
|
||
- [ ] **The full pick-and-run**
|
||
|
||
```bash
|
||
xvfb-run -a env UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 \
|
||
./build/udpscope 2>&1 | grep -E '\[sources\]|layout loaded|frames='
|
||
```
|
||
|
||
- [ ] **A `--server` regression run, which must be identical to before this work**
|
||
|
||
```bash
|
||
python3 tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --duration 30 --port 9704 >/dev/null 2>&1 &
|
||
sleep 1
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=12000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500 \
|
||
./build/udpscope --server 127.0.0.1 --port 9704 --buffer-mb 64 2>&1 \
|
||
| grep -E 'frames=|gaps=|x-desync|trace ink|toolbar icons|badge frames'
|
||
```
|
||
|
||
Expected: `gaps=0`, `x-desync 0`, `0 collapsed, 0 staircase`, every icon `(ok)`, both `lit` and `dim` non-zero.
|
||
|
||
- [ ] **Kill the mock**
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
|
||
```
|
||
|
||
- [ ] **Remove the task's throwaway session files**
|
||
|
||
```bash
|
||
rm -f ~/.config/udpscope/10.0.0.5_8001.toml ~/.config/udpscope/127.0.0.1_970*.toml \
|
||
~/.config/udpscope/127.0.0.1_9999.toml
|
||
rm -rf /tmp/udpscope-clean /tmp/check-sources-home
|
||
```
|