fix code-review findings in the source-chooser feature

Seven correctness and documentation fixes found in the final review:

- `known_sources()`: check the strtol end pointer and add a round-trip
  guard so a filename that layout_path() could not have produced is
  rejected; without it `foo_8000x.toml` listed as `foo:8000` whose `x`
  would unlink `foo_8000.toml`, a different configured session.
- `chooser_finish()`: destroy the form window before the chooser window;
  a row click or Escape while the form was up left a window whose handlers
  pointed at a dead stack frame (latent use-after-free).
- `choose_source()`: keep the pick_probe timeout id and g_source_remove
  it after the loop; a row picked before the delay elapsed left an armed
  source on the main context pointing at the gone frame.
- `chooser_populate()`: replace a false comment ("not a GtkListBoxRow")
  with the truth — GTK4 auto-wraps, but the wrapped row carries no
  source-index data so the bounds check refuses it.
- `pick_probe()`: collapse the duplicate `known.empty()` / `row==nullptr`
  guards; the second was unreachable and two identical lines for one cause
  makes probe output ambiguous.
- `save_layout()`: state the TOML rule directly in the multicast/interface
  comment rather than cross-referencing the adjacent enabled comment.
- `save_layout_now()`: refuse to write when signals() is empty; a run
  against a dead port receives no CONFIG, so every slot name is empty and
  the save would overwrite a configured session with signals=["",...],
  which the chooser filter refuses, silently removing that source from
  the only list it can be picked from.
- Fix include order in layout_store.cpp (<cstdio> before <cstdlib>).
- Update CLAUDE.md and README.md to document the empty-signals guard and
  fix the probe-output quote to match the emitted text exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Sonnet 4.6 committed 2026-10-04 10:03:48 +02:00
1 parent b8d0ce8625
commit ffec797462
6 files changed
+151 -16

No files matched your search

+14 -5
View File
@@ -5,8 +5,8 @@
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <cstdio>
#include <cstdlib>
#include <dirent.h>
#include <fstream>
#include <sstream>
@@ -134,15 +134,22 @@ std::vector<KnownSource> known_sources() {
// 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;
char* end = nullptr;
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
const std::string host = stem.substr(0, cut);
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
// any host that already came back out of a filename, so this is also true for hosts whose names
// contain `_` from the sanitizer.
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
LayoutState st;
const std::string path = dir + "/" + name;
if (!load_layout(path, st)) continue;
KnownSource s;
s.host = stem.substr(0, cut);
s.host = host;
s.port = static_cast<std::uint16_t>(port);
s.multicast = st.multicast;
s.interface_addr = st.interface_addr;
@@ -385,7 +392,9 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "# udpscope session layout (written by the app; safe to edit)\n";
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
o << "plots = " << state.rows.size() << "\n";
// Top level, before any table, for the same reason as `enabled` below.
// Top level, before any table. A bare key written after a `[[table]]` header belongs to that
// table in TOML, which is exactly how `enabled` was once parsed into the last `[[calibration]]`
// record and silently lost.
if (!state.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
if (!state.interface_addr.empty()) {
o << "interface = " << toml_escape(state.interface_addr) << "\n";
+19 -8
View File
@@ -64,6 +64,16 @@ struct Chooser {
void chooser_finish(Chooser& c, bool chosen) {
c.chosen = chosen;
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
// window returns having done nothing.
if (c.form != nullptr) {
GtkWidget* w = c.form;
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
gtk_window_destroy(GTK_WINDOW(w));
}
if (c.window != nullptr) {
GtkWidget* w = c.window;
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
@@ -104,7 +114,9 @@ void chooser_populate(Chooser& c) {
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.
// GTK4 auto-wraps any non-row widget appended to a `GtkListBox` in a row, so this is
// activatable -- but the wrapped row carries no `"source-index"` data, `g_object_get_data()`
// returns null, the index reads 0, and `on_row_activated`'s bounds check refuses it.
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;
@@ -310,12 +322,6 @@ gboolean pick_probe(gpointer user) {
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;
}
// `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).
@@ -388,14 +394,19 @@ bool choose_source(Config& cfg) {
gtk_window_present(GTK_WINDOW(c.window));
// Keep the timeout id so it can be cancelled if the chooser finishes before it fires. A row
// clicked before UDPSCOPE_PROBE_PICK_MS elapses would otherwise leave an armed source on the
// default main context pointing at the now-gone stack frame.
guint pick_probe_id = 0;
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
pick_probe_id = 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;
if (pick_probe_id != 0) g_source_remove(pick_probe_id);
return c.chosen;
}
+6
View File
@@ -573,6 +573,12 @@ LayoutState collect_layout(App& app) {
/// stderr; success is silent -- a save per click must not narrate.
void save_layout_now(App& app) {
if (app.source_port == 0) return;
// Nothing is known about this source yet -- no CONFIG ever arrived -- so every slot name is
// empty and this would overwrite a configured session with `signals = ["", ...]`. The startup
// chooser filters such a file out of its list, so a source whose streamer was down during one
// run would silently vanish from the only place it can be picked. Keeping the old file loses
// nothing: this run configured nothing.
if (app.model->signals().empty()) return;
if (!save_layout(layout_path(app.source_host, app.source_port), collect_layout(app))) {
std::fprintf(stderr, "[udpscope] failed to write the layout file\n");
std::fflush(stderr);