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.
61 KiB
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: MITthen// SPDX-FileCopyrightText: 2026 Martino Ferrari. - The build must stay warning-clean under
-Wall -Wextra -Wpedantic(set for this target inCMakeLists.txt). Pre-existing warnings insrc/ui.cpp(unused variable 'output_built'at ~6044, two-Wmissing-field-initializersforMathNode::source, twobugprone-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 underxvfb-run. New checks extend that net; for code that runs before the GTK app exists, a one-off/tmp/check_*.cpplinked against the sources is this project's established practice (see/tmp/check_math.cpp,/tmp/check_icons.cin CLAUDE.md). - Run one
xvfb-run -aat a time. Two concurrent instances land on the same display and the second gets no GL context — it dumpslayout over 0 framesandvalid=-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 withpgrep -f 'mock_udpstream[e]r'.tools/mock_udpstreamer.pyoutlives its--durationand 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 emitclose-request. Any dialog whose state is cleared by aclose-requesthandler must be closed withgtk_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 toLayoutState) - 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::multicastandLayoutState::interface_addr, bothstd::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:
// 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
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:
/// 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:
// 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:
} 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
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
cmake --build build
Expected: no new warnings, no errors.
- Step 8: Commit
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(addKnownSource, two functions, beforelayout_path()) - Modify:
src/layout_store.cpp(includes at the top; implementations afterlayout_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:
// 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
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:
/// 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):
#include <algorithm>
#include <cstdlib>
#include <dirent.h>
Then, immediately after the layout_path() definition:
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
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
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
cmake --build build
Expected: no new warnings, no errors.
- Step 8: Commit
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 newAppmembers),src/ui.cpp:558-561(incollect_layout()),src/ui.cpp:7160-7161(inrun_ui())
Interfaces:
-
Consumes:
LayoutState::multicast,LayoutState::interface_addr(Task 1). -
Produces:
App::source_multicast,App::source_interface(bothstd::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:
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
Appmembers
In src/ui.cpp, after std::uint16_t source_port = 0; (line 268):
/// 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:
// 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:
// 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):
app.source_multicast = cfg.multicast;
app.source_interface = cfg.interface_addr;
- Step 5: Build
cmake --build build
Expected: no new warnings, no errors.
- Step 6: Run the test to verify it passes
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
rm -f ~/.config/udpscope/127.0.0.1_9999.toml
- Step 8: Commit
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(addserver_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, defaultfalse).bool udpscope::choose_source(Config& cfg);insrc/source_dialog.hpp—truemeanscfgnow names the source to open,falsemeans the user declined and the caller should exit 0.void apply_source(Config& cfg, const KnownSource& src);andvoid report_sources(const std::vector<KnownSource>& known);— both insource_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
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:
/// 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:
} 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:
// 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:
// 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:
#include "source_dialog.hpp"
and replace the parse_args block's closing brace through the ScopeModel line so it reads:
} 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:
src/source_dialog.cpp
- Step 7: Build
cmake --build build
Expected: no new warnings, no errors.
- Step 8: Run the test to verify it passes
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
--serverstill bypasses the chooser
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
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
Expected: stopped.
- Step 11: Commit
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 thegtk_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 Chooserandvoid chooser_populate(Chooser&), both in the anonymous namespace. Task 6 adds the form's widgets toChooserand callschooser_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:
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():
/// 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:
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
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
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
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
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
--serverrun is still untouched
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
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
Expected: stopped.
- Step 10: Commit
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 onChooser, the form, the[New host…]handler) - Test: a run with no
--serverdriving 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
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;:
/// 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():
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_probewith 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:
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:
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_form(*static_cast<Chooser*>(user));
}),
&c);
- Step 6: Build
cmake --build build
Expected: no new warnings, no errors.
- Step 7: Run the test to verify it passes
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
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:
pgrep -f 'build/udpscop[e]' || echo "stopped"
- Step 9: Verify a
--serverrun is still untouched
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
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
Expected: stopped.
- Step 11: Commit
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:
- `[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:
- **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:
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:
**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
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
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
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
/tmp/check_layout_rt && /tmp/check_sources
Expected: all ok from both.
- The discovery probe
UDPSCOPE_PROBE_SOURCES=1 ./build/udpscope
Expected: one [sources] N known: line, no probe-port entries, exit 0.
- The full pick-and-run
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
--serverregression run, which must be identical to before this work
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
pkill -f 'mock_udpstream[e]r'; sleep 1; pgrep -f 'mock_udpstream[e]r' || echo "stopped"
- Remove the task's throwaway session files
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