Files
udpscope/docs/superpowers/plans/2026-10-03-source-chooser.md
T
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

61 KiB
Raw Blame History

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:

// 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 (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:

// 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 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:

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):

  /// 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 (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

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 --server still 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 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:

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 --server run 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 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

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_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:

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 --server run 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 --server regression 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