A packet that needed a slot and found none was neither held nor delivered: no counter recorded it and the consumer reserved a hole nothing could ever backfill. It now commits the gap to free a slot and takes the ordering decision once more against the sequence that commit left behind, bounded to one retry; past that it is delivered in order with the counters it skipped as its `lost`, never flagged late, because a packet ahead of the sequence has no hole to be looked up by. A duplicate can now replace its slot at a full ring, as hold_packet's own comment already promised, and the 20 ms default window is a named constant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.7 KiB
Build and run
Configuring, building and launching udpscope, the desktop entry target, the command-line options and the two vendored dependencies.
Part of CLAUDE.md.
Build and run
git submodule update --init --recursive # deps/plotter is a submodule
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
./build/udpscope --server 127.0.0.1 --port 8000
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
The app can be launched from the shell rather than only from a terminal, but only on request:
cmake --build build --target desktop-entry
That writes org.udpscope.app.desktop plus both app icons under $XDG_DATA_HOME (default
~/.local/share), runs desktop-file-validate as a hard failure (when it is on PATH;
absent, it says so and installs anyway), refreshes update-desktop-database and
gtk4-update-icon-cache when they are on PATH, and prints the three paths it wrote — which is
the uninstall instructions, since there is no uninstall target. cmake --build build alone never
touches $XDG_DATA_HOME.
The entry's Exec is the built binary's absolute path in the build tree, so moving or
deleting the build directory breaks it. That is the price of having no install step and it is
deliberate: the entry then always launches exactly the binary that was last built. No
StartupWMClass is needed — GTK takes the WM class from the application ID, and the desktop
file's basename is that same string, which is what associates a running window with the entry.
Options: --server, --port, --multicast, --interface, --buffer-mb (per
buffer, default 200), --max-window, --holdoff, --recv-buffer, --plots (the row
count to start with, 1..RowSet::kMaxRows; the layout editor owns it afterwards).
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.
deps/plotter is built from source with only the GTK4 adapter enabled
(PLOT_BUILD_GTK4); its tests and examples are forced off. deps/udpsclient is a
vendored C99 source pair compiled into a static lib.
libadwaita ≥ 1.8 is a system dependency (pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)),
for two widgets: the shortcut overview (AdwShortcutsDialog) and the About dialog
(AdwAboutDialog). GTK4's own GtkShortcutsWindow is deprecated as of 4.18 and
AdwShortcutsDialog is its replacement; GtkAboutDialog still exists but
adw_about_window_new() is deprecated in favour of AdwAboutDialog, which shows the
application icon as the page's own header. Two consequences worth knowing before editing.
adw_init() is called — in run_ui() and, since the chooser runs first and must be styled
the same, in choose_source() in place of its gtk_init() (both are idempotent) — so
the whole app is drawn with libadwaita's stylesheet, not GTK's: a metric change there
reaches every widget, and layout over N frames / axes.w changes is what says whether
the layout still settles. And an AdwDialog is not a GtkWindow: both show_about()
and show_shortcuts() keep an AdwDialog*, cleared on ::closed (there is no
::close-request) and closed with adw_dialog_close(). They share one on_dialog_closed()
handler keyed by a pointer-to-pointer (gpointer slot cast to AdwDialog**), so one
handler clears either pointer without knowing which dialog it is. Presenting either on this
app's plain GtkApplicationWindow is supported and warning-free; it was checked.
deps/udpsclient is a vendored C99 source pair and its diff from the upstream source
is intentional. Five things were changed:
- A reorder ring of reassembled payloads. When a packet arrives with a higher counter
than expected, the raw datagram is held in a fixed-size ring rather than decoded immediately.
A deadline (
now + reorder_window_s, default 0.020 s) is set for the gap; the deadline is served by a thirdclamp_deadline()call on theselect()budget (udps_client.c:1145-1152) so a quiet stream does not wait for the next arrival. - Late delivery replacing the outright discard. A packet that arrives past the window — or
when the ring is drained by the deadline — is delivered to the callback flagged
laterather than discarded withstats.stale_packets++; return 0. That flag is what makes backfill possible at all; without it the samples are gone before udpscope can act. - The third
clamp_deadline(). The existing pair handles the keepalive and the silence timeout; the reorder deadline is a third call to the same helper, which already understood the pattern. reorder_window_s = 0as the switch.udps_client_config_t::reorder_window_s, defaulted toUDPS_REORDER_WINDOW_S_DEFAULT(0.020) inudps_client_config_init(), is the only knob. Setting it to zero disables resequencing and restores the previous discard-on-late behaviour exactly — so a reported regression is bisectable without reverting the source. There is no command-line flag for the reorder window. It is not surfaced on udpscope's command line; do not document a--reorder-window-ms.- The full ring never drops the arriving packet, and says so in a counter. A packet that
needs a slot and finds none commits the current gap (which frees at least one, since
commit_gap()always delivers) and the ordering decision is taken once more against the sequence that commit left behind — a bounded retry, because the commit can advancenext_counterpast the arriving counter and turn an ahead-of-sequence packet into a behind- of-sequence one. Past the retry it is delivered in order, with the counters it skipped as itslost, and never flaggedlate: a packet ahead of the sequence has no reserved hole andAcquisition::backfill_from()silently ignores a late packet it cannot place, so flagging it would be the same silent loss by another route.stats.reorder_overflowscounts the event, so an undersized ring is a reading rather than a mystery; it is surfaced in the exit dump and the[reseq-probe]line asoverflows=.
deps/plotter is upstream's own repository and this app's figure support was added
there rather than worked around here: Figure::set_min_y_gutter() (a floor under the
common left gutter), Figure::set_right_gutter() / Plot::set_right_gutter() (an empty
band on the other side, which the layout had no concept of — axes reached the
viewport's right edge, so the rightmost x tick label was centred on a line it had half of
itself outside of; the guard is figure_rows_share_one_right_gutter), and in the GTK4
adapter PlotArea's figure mode —
set_figure(), one input::Controller per row, nearest-row event routing, a
figure-wide crosshair — plus PlotArea::row_at() for naming the row a pointer is over.
The render-quality work went there for the same reason: kTextFS now filters the
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
it, and LineProgram::set_snap() / u_snap rounds an axis-aligned segment's ink onto
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
drawn at a data position. generate_ticks() also stores the tick at the origin as
exactly 0.0 — the walk starts at ceil(min/stride)*stride and so never lands on zero
when the stride is not a power of two, which labelled it -139a. format_time() picks its
unit rung by the rounded mantissa (t < u.scale * 0.9995, and the same slack on the
whole-second branch) — the >= 999.5 spill guard format_si() in the same file always had —
because a tick a hair under a rung's top rounds to 1000 at three significant figures and
%.3g writes that 1e+03, which is how a one-second tick read 1e+3ms. All four carry
upstream tests — the_atlas_is_filtered_not_point_sampled,
grid_hairlines_land_on_whole_pixels, the_tick_at_the_origin_reads_zero and
format_time_never_lets_a_field_reach_its_radix. The minor
grid's look is set there too, not here: grid_minor carries 50% alpha, grid_minor_width is
0.75 (sub-pixel, expressed as coverage at 1x) and grid_minor_dash_on/_off are 3/3 — a
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
grid instead of as a second set of ticks (the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major).
README records what they cured.
Changing any of this means committing in the submodule and moving its pin, and running the library's own suite as well as the udpscope probes (see verification.md):
cmake --build deps/plotter/build/debug
xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed"