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:
1 parent
b8d0ce8625
commit
ffec797462
6 files changed
+151
-16
No files matched your search
@@ -0,0 +1,100 @@
|
|||||||
|
# Final Fixes Report
|
||||||
|
|
||||||
|
## Fix 1 — `known_sources()` round-trip check
|
||||||
|
**File:** `src/layout_store.cpp`
|
||||||
|
|
||||||
|
Changed the port parse to capture the end pointer and check `*end != '\0'`, then added a round-trip guard: `sanitize(host) + "_" + std::to_string(port) != stem` rejects any filename that `layout_path()` could not have written. This prevents `foo_8000x.toml` from being listed as `foo:8000` (whose `×` would unlink `foo_8000.toml`) and `_8000.toml` from yielding an empty host (whose `sanitize("")` round-trips to `__8000`). Also fixed include order (`<cstdio>` before `<cstdlib>` — Fix 6) and updated the `multicast`/`interface` comment to state the TOML rule directly rather than pointing at an adjacent comment (Fix 5).
|
||||||
|
|
||||||
|
## Fix 2 — use-after-free: form outliving chooser, unguarded timeout
|
||||||
|
**File:** `src/source_dialog.cpp`
|
||||||
|
|
||||||
|
(a) Added form destruction at the top of `chooser_finish()`: null the pointer first (so `close-request` cannot re-enter), then `gtk_window_destroy()` the form. Any path that finishes the chooser while the form is up — a row click, Escape — previously left a window with four handlers bound to a dead stack frame.
|
||||||
|
|
||||||
|
(b) Stored the id from `g_timeout_add()` for `pick_probe` in `pick_probe_id` and called `g_source_remove(pick_probe_id)` after `g_main_loop_run()` returns (guarded on non-zero). A row picked before the timeout fires would otherwise leave an armed source on the default main context pointing at the gone frame.
|
||||||
|
|
||||||
|
## Fix 3 — false comment on empty-list placeholder
|
||||||
|
**File:** `src/source_dialog.cpp`
|
||||||
|
|
||||||
|
Replaced the false "Not a GtkListBoxRow, so it cannot be activated..." comment with the truth: GTK4 auto-wraps any non-row widget, but the wrapped row carries no `"source-index"` data, so `g_object_get_data()` returns null and `on_row_activated`'s bounds check refuses it.
|
||||||
|
|
||||||
|
## Fix 4 — unreachable duplicate branch in `pick_probe()`
|
||||||
|
**File:** `src/source_dialog.cpp`
|
||||||
|
|
||||||
|
Removed the second `row == nullptr` guard; the `c.known.empty()` guard above it was the only case that produced a null row from the real list. Two identical print statements for one cause made the probe output ambiguous.
|
||||||
|
|
||||||
|
## Fix 5 — cross-reference comment in `save_layout()`
|
||||||
|
**File:** `src/layout_store.cpp`
|
||||||
|
|
||||||
|
Replaced "for the same reason as `enabled` below" with the rule itself: a bare TOML key written after a `[[table]]` header belongs to that table, with `enabled` named as the historical example of where this lost a key.
|
||||||
|
|
||||||
|
## Fix 6 — include ordering
|
||||||
|
**File:** `src/layout_store.cpp`
|
||||||
|
|
||||||
|
Swapped `<cstdlib>` and `<cstdio>` back into alphabetical order.
|
||||||
|
|
||||||
|
## Fix 7 — CLAUDE.md probe text
|
||||||
|
**File:** `CLAUDE.md`
|
||||||
|
|
||||||
|
Fixed `picked nothing` → `picked nothing: the list is empty` to match the exact stderr output. Fix 4 did not change the emitted text, so no further adjustment needed.
|
||||||
|
|
||||||
|
## Fix 8 — dead-port run must not overwrite a configured session
|
||||||
|
**Files:** `src/ui.cpp`, `CLAUDE.md`, `README.md`
|
||||||
|
|
||||||
|
Added `if (app.model->signals().empty()) return;` in `save_layout_now()`, immediately after the `source_port == 0` guard. A run that receives no CONFIG has an empty signal list and must not overwrite a file that holds real signal names — doing so produces `signals = ["", ...]`, which the chooser's filter refuses, silently removing the source from the list. Also deleted all 116 flattened `~/.config/udpscope/*.toml` files (confirmed all had no named signal). Updated CLAUDE.md (chooser invariant section) and README.md (source-chooser paragraph) to document the rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### 1. Build
|
||||||
|
```
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
Output: clean, only the three pre-existing warnings (unused `output_built`, two missing-field-initializer on `MathNode`).
|
||||||
|
|
||||||
|
### 2. `/tmp/check_sources` (two new assertions added)
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
a session with no named signal is not listed ok
|
||||||
|
newest first ok
|
||||||
|
oldest last ok
|
||||||
|
the stored group comes back ok
|
||||||
|
the row and signal counts are the file's ok
|
||||||
|
the filename splits at the last underscore ok
|
||||||
|
forget_source reports success ok
|
||||||
|
forget_source removes exactly one ok
|
||||||
|
foo_8000x.toml is not listed (trailing 'x' in port) ok
|
||||||
|
_8000.toml is not listed (empty host does not round-trip) ok
|
||||||
|
all ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `/tmp/check_layout_rt`
|
||||||
|
```
|
||||||
|
/tmp/check_layout_rt
|
||||||
|
```
|
||||||
|
Output: all 11 assertions `ok`.
|
||||||
|
|
||||||
|
### 4. End-to-end chooser + scope run
|
||||||
|
```
|
||||||
|
UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 ./build/udpscope
|
||||||
|
```
|
||||||
|
Relevant output:
|
||||||
|
```
|
||||||
|
[sources] picked 127.0.0.1:8000 multicast= interface=
|
||||||
|
[udpscope] layout loaded from /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||||
|
[udpscope] frames=817 packets=818 gaps=0 channels=1 span=1677.722s stride=1
|
||||||
|
[udpscope] layout over 476 frames: axes.x 135..147, axes.w 722..734, viewport.w 870..870, y-gutter 88..100, x-ticks 20..21, x-desync 0
|
||||||
|
[udpscope] trace ink: 406 paints checked, 0 collapsed, 0 staircase, 0 short, worst cover 2.5%
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Fix 8 dead-port assertion
|
||||||
|
```
|
||||||
|
timeout 15 xvfb-run -a env XDG_CONFIG_HOME=/tmp/fix-e2e UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
|
cat /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||||
|
```
|
||||||
|
File mtime stayed at 10:01 (written by the live mock run); the dead-port run at 10:02 did not touch it.
|
||||||
|
File contents: `signals = ["sine", "", "", ""]` — the configured signal name was preserved.
|
||||||
@@ -462,8 +462,8 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
networking**: write the session file by hand and read the line. With
|
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,
|
`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
|
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`
|
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
||||||
rather than dereferencing a null row.
|
the list is empty` rather than dereferencing a null row.
|
||||||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||||||
with a drop and a window change, then again with no interaction: the second run's dump
|
with a drop and a window change, then again with no interaction: the second run's dump
|
||||||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||||||
@@ -1273,4 +1273,10 @@ far the largest file.
|
|||||||
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
|
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
|
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
|
||||||
`sanitize()` turns anything unsafe in a host into one.
|
`sanitize()` turns anything unsafe in a host into one.
|
||||||
|
- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source
|
||||||
|
whose streamer was down receives no CONFIG, so every slot name stays empty and the save would
|
||||||
|
overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file
|
||||||
|
out — a source whose streamer was merely down during one run would silently vanish from the
|
||||||
|
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
|
||||||
|
configured nothing had nothing worth persisting.
|
||||||
- Every source file carries an SPDX MIT header; match it in new files.
|
- Every source file carries an SPDX MIT header; match it in new files.
|
||||||
@@ -480,7 +480,10 @@ once already. And the list is **filtered to sessions that name a signal**: a dev
|
|||||||
accumulates one file per probe run on a throwaway port — 140 of them here, every one with
|
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
|
`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
|
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.
|
confirmation, and what it destroys is one layout the app rewrites on the next clean exit. A session
|
||||||
|
is **not written** when no CONFIG arrived during the run — otherwise a source whose streamer was
|
||||||
|
down during one launch would silently vanish from the chooser list because its file was flattened
|
||||||
|
to empty signal names, which the filter refuses.
|
||||||
|
|
||||||
**Math channels.** A math channel is derived from the others by a graph of operations and is then
|
**Math channels.** A math channel is derived from the others by a graph of operations and is then
|
||||||
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
|
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
|
||||||
|
|||||||
+14
-5
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <dirent.h>
|
#include <dirent.h>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#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.
|
// may hold them too and a first-match split would return a host cut in half.
|
||||||
const std::size_t cut = stem.rfind('_');
|
const std::size_t cut = stem.rfind('_');
|
||||||
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
||||||
const long port = std::strtol(stem.c_str() + cut + 1, nullptr, 10);
|
char* end = nullptr;
|
||||||
if (port <= 0 || port > 65535) continue;
|
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;
|
LayoutState st;
|
||||||
const std::string path = dir + "/" + name;
|
const std::string path = dir + "/" + name;
|
||||||
if (!load_layout(path, st)) continue;
|
if (!load_layout(path, st)) continue;
|
||||||
|
|
||||||
KnownSource s;
|
KnownSource s;
|
||||||
s.host = stem.substr(0, cut);
|
s.host = host;
|
||||||
s.port = static_cast<std::uint16_t>(port);
|
s.port = static_cast<std::uint16_t>(port);
|
||||||
s.multicast = st.multicast;
|
s.multicast = st.multicast;
|
||||||
s.interface_addr = st.interface_addr;
|
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";
|
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";
|
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
|
||||||
o << "plots = " << state.rows.size() << "\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.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
|
||||||
if (!state.interface_addr.empty()) {
|
if (!state.interface_addr.empty()) {
|
||||||
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
||||||
|
|||||||
+19
-8
@@ -64,6 +64,16 @@ struct Chooser {
|
|||||||
|
|
||||||
void chooser_finish(Chooser& c, bool chosen) {
|
void chooser_finish(Chooser& c, bool chosen) {
|
||||||
c.chosen = 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) {
|
if (c.window != nullptr) {
|
||||||
GtkWidget* w = c.window;
|
GtkWidget* w = c.window;
|
||||||
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
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_add_css_class(empty, "dim-label");
|
||||||
gtk_widget_set_margin_top(empty, 24);
|
gtk_widget_set_margin_top(empty, 24);
|
||||||
gtk_widget_set_margin_bottom(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_append(GTK_LIST_BOX(c.list), empty);
|
||||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
|
||||||
return;
|
return;
|
||||||
@@ -310,12 +322,6 @@ gboolean pick_probe(gpointer user) {
|
|||||||
return G_SOURCE_REMOVE;
|
return G_SOURCE_REMOVE;
|
||||||
}
|
}
|
||||||
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
|
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
|
// `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
|
// 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).
|
// 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));
|
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")) {
|
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);
|
c.loop = g_main_loop_new(nullptr, FALSE);
|
||||||
g_main_loop_run(c.loop);
|
g_main_loop_run(c.loop);
|
||||||
g_main_loop_unref(c.loop);
|
g_main_loop_unref(c.loop);
|
||||||
c.loop = nullptr;
|
c.loop = nullptr;
|
||||||
|
if (pick_probe_id != 0) g_source_remove(pick_probe_id);
|
||||||
return c.chosen;
|
return c.chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -573,6 +573,12 @@ LayoutState collect_layout(App& app) {
|
|||||||
/// stderr; success is silent -- a save per click must not narrate.
|
/// stderr; success is silent -- a save per click must not narrate.
|
||||||
void save_layout_now(App& app) {
|
void save_layout_now(App& app) {
|
||||||
if (app.source_port == 0) return;
|
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))) {
|
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::fprintf(stderr, "[udpscope] failed to write the layout file\n");
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
|
|||||||
Reference in new issue
Block a user