Files
udpscope/docs/superpowers/plans/2026-10-03-source-chooser.md
Martino Ferrari a066d48a3a plan: source chooser at startup
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.
2026-10-04 00:00:42 +02:00

1541 lines
61 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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
```