Files
Martino FerrariandClaude Opus 4.6 39f82ade31 udpsclient: size the TCP receive buffer from max_packet_bytes so large MARTe frames are not dropped
A TCP frame is never fragmented; a real TCPStreamer cycle (240 KB) exceeded the UDP datagram
limit and made the client reconnect forever, seeing CONFIG but never DATA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-06 14:07:54 +01:00

28 KiB
Raw Permalink Blame History

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
./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000   # same as --server/--port

--help lists the registered source kinds.

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.

The interface files can be checked without building anything or opening a window:

cmake --build build --target test-ui      # or: make -C build test-ui

That round-trips every .ui through blueprint-compiler — decompile turns it into blueprint, and compiling that type-checks every property and child type against the GIR — printing one line per file. It exists because the alternative is what happens otherwise: a property a class does not have is a Gtk-ERROR when the window is built, and Gtk-ERROR aborts the process, so a typo in a .ui takes the application down rather than failing to open a dialog. preprocess="xml-stripblanks" catches malformed XML and nothing else, and the probes catch the rest only for the windows they open.

The XML stays the source of truth and stays hand-written: this is a check, not a compiler, and nothing here is needed to build the application. blueprint-compiler and python3 are found at configure time; without them the target is absent rather than silently passing. tools/check_ui.py --lint also prints the linter's advice, which is advice and deliberately not a gate.

The manual has its own check, the same shape:

cmake --build build --target test-help

That runs yelp-check's four offline subcommands over help/C/<id>/ (links, media, orphans, ids) and renders the whole set with yelp-build html into a temp directory, so the manual is checked the way the .ui files are. yelp-check validate is deliberately not used: it fetches the Mallard RelaxNG schema over the network, which is absent on a build box, so it would fail for that reason and not for a fault in the pages. yelp-check/yelp-build are found at configure time; without them the target is absent rather than silently passing.

The manual is a Mallard set under help/C/<id>/, installed by an install() rule to <datadir>/help/C/<id>/ — where the freedesktop Help System Specification's help: URI resolves help:<id> — so it is read from a datadir by another process even though the binary itself has no install step. F1 opens it in Yelp through that URI. The document identifier is the desktop file's basename (data/org.udpscope.app.desktop.in → org.udpscope.app), so it is the .desktop name, the GApplication id and the install directory at once. The figures are regenerated by tools/make-help-shots.sh, which drives the app headlessly against the mock and draws the annotation boxes with ImageMagick; Mallard is translatable with itstool, but only the C set is authored.

Because the manual is the only thing udpscope installs, it can be installed alone — where help: finds it, and without the cmake --install of a whole project — by either of two opt-in targets. Neither is built by default and cmake --build build never touches either:

cmake --build build --target install-help-user   # <data-home>/help/C/<id>/, no root
cmake --build build --target install-help         # <CMAKE_INSTALL_PREFIX>/share/help/C/<id>/

install-help-user writes to $XDG_DATA_HOME when it is set and to $HOME/.local/share when it is not — the XDG Base Directory Specification's own default for the variable, and the fallback help_installed_path() uses — so it needs no root and lands the set exactly where F1 and the alert look for it. install-help goes through the install machinery into the configured CMAKE_INSTALL_PREFIX, so a packager or a system-wide user points it at /usr with -DCMAKE_INSTALL_PREFIX at configure time. Neither target can install system-wide by itself: a build target has no more privilege than the shell that ran it, so install-help writes to the configured prefix and a system-wide caller wraps it in sudo. Both print where they wrote. Both land the same tree the install() rule does — the user-local copy applies that rule's file filter, and the prefix-wide target is that rule, reached through the Help component (cmake --install --component Help) — and the ordinary cmake --install still installs every component, so its behaviour is unchanged.

Options: --source NAME and the repeatable --source-opt k=v (the source kind and its settings; --server, --port, --multicast and --interface populate the UDPS entries of the same options), --server, --port, --multicast, --interface, --buffer-samples (samples per acquired signal, default 10M, k/M/G suffixes, rounded down to a power of two), --memory-limit-mb (hard ceiling on the ring bytes, default 8192; also a Preferences row), --max-window, --holdoff, --recv-buffer, --plots (the row count to start with, 1..RowSet::kMaxRows; the layout editor owns it afterwards), --capture-packets N (the raw-datagram ring of README's "Packet capture"; 1..100000, the value is required, and the popover button uses 1000 when the flag was absent). The flag is not persisted in the session file: persistence of the enable state belongs with todo.md item 3, alongside the relative-time toggle. --renderer cairo|gl|vulkan exports GSK_RENDERER at the top of main() — before choose_source()'s adw_init() and run_ui()'s, because GSK reads the variable when it creates its renderer and setting it later is a silent no-op. cairo is the software preset and also exports LIBGL_ALWAYS_SOFTWARE=1: the plot is a GtkGLArea, so a display with no usable GPU fails twice, and this is the pair tools/make-help-shots.sh already sets. Nothing is exported when the option is absent, so GTK's own choice stands.

--source e1608 selects the MCC E-1608 Ethernet DAQ; its settings are --source-opt keys, all optional (address or mac+subnet, port 54211, code 0, timeout_ms 2000, scan_rate 1000 per channel, ext_clock, trigger none|rising|falling|high|low, auto_restart 1, calibrate 1, poll_rate 10, gen_rate 50 (Hz, 1 to 200: the waveform generator's DAC update rate), mode se|diff, ai0..ai7 BIP10V|BIP5V|BIP2V|BIP1V (an absent key = off, and any aiN key at all drops the default: only a run with no aiN key gets ai0=BIP10V, so ai1=BIP5V alone is channel 1 alone, and a value that is not a range name is a channel that is off), dir0..dir7 i|o, plot0..plot7, plot_counter, do_init, ao0_init, ao1_init). Either address or mac is required; a MAC with no address is looked up by a broadcast sent on every interface (Scan), or by a sweep of subnet (a.b.c). The enabled channels times scan_rate must not exceed 250 kS/s. tools/mock_e1608.py is a protocol mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine per channel, calibration memory, --port, --mac, --log FILE, --drop-after S and --overrun-after S switches, --code N); the mock stands in for the board (a real board has been run once, with one channel; see the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as [e1608] ..., once per distinct message.

python3 tools/mock_e1608.py --port 54711 &
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
  --source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1

--source flatudp receives the MARTe2 UDPSender datasource (one datagram per cycle: the signals concatenated in declaration order, no header, no padding). The datagram carries no description, so the settings are the whole contract: port 44488, bind, multicast and iface (a group to join and the local interface for it), rate (the sender's cycle rate in Hz; required, it sizes the ring and the ingest decimation and spaces the samples within a frame), endian little|big, and signals name:type[count][w],... with types uint8/16/32/64, int8/16/32/64, float32, float64, e.g. Trigger:uint8,Ia:float32,Wave:float32[16]. A count above 1 makes a vector signal, shown one of two ways: by default as count channels Wave[0]..Wave[15] of one sample per cycle each (batched like any scalar list); with a trailing w (Wave:float32[16]w) as ONE waveform channel receiving count consecutive samples per cycle at rate * count, as a UDPStreamer block signal. A waveform row needs a count above 1, a list with one holds only waveform rows of one count (no zero-padding of scalars), and a list may make at most 256 channels (a scalar 1, a channels row count, a waveform 1). A datagram of any other size than the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as [flatudp] .... Loss is never reported: the stream has no counter. tools/mock_flatudp.py is a sender for a signal spec (--rate, --signals, --big-endian, --gap S,D, --bad-every S, --multicast G --mcast-if A, --log FILE).

python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals 'Trigger:uint8,Ia:float32,Wave:int16[4]' &
./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \
  --source-opt 'signals=Trigger:uint8,Ia:float32,Wave:int16[4]'

(Quote the signals value: zsh globs the [4]. The mock takes the same text with or without the trailing w; the datagram is the same, w only says how the app shows it.)

--source tcps receives a MARTe2 TCPStreamer (standalone) or TCPStreamService (named stream) over TCP: server (default 127.0.0.1), port 44500, and stream (optional; a SUBSCRIBE frame is sent only when it is set). tools/mock_tcpstreamer.py serves one client with the mock_udpstreamer.py signals (--port, --stream to require a SUBSCRIBE, --rate, --batch, --freq, --close-after S, --drop-every N).

python3 tools/mock_tcpstreamer.py --port 44500 --stream fast &
./build/udpscope --source tcps --source-opt port=44500 --source-opt stream=fast

tools/mock_udpstreamer.py has waveform-injection options used by the probes:

  • --square-harmonics N shapes square: N odd harmonics of the ideal step's Fourier series, so each edge overshoots by the Gibbs constant (about 9 % of the 2 V swing at the default 30, i.e. y = ±1.179) and then rings — a band-limited edge, which is what a crossing count or a pulse-width test has to survive. The series is deterministic and the odd harmonics keep the ideal wave's edge instants and its polarity, so the level-0 trigger, the search and the glitch's inverted pulse still read where they did on the step; the extremes are what move. 0 (or negative) is the ideal ±1 step, which is what a probe asserting an exact extreme or an exact width asks for — the noise probe's group-delay leg is the one that does, because its want is the analytic tau*ln2 of a step (see verification.md). It costs the producer about 0.5 ms per 2000-sample packet on top of the ideal branch, so at the fast mock's 1.3 MS/s the mock delivers at roughly nine tenths of its declared pace — a reading that scales with the retained span reads that much lower, and nothing else does.
  • --glitch-at S / --glitch-width W invert the square signal for W seconds at the start of every square period from stream second S onwards — a runt pulse of a known width, which is what a < pulse-width trigger and a glitch search are checked against.
  • --quiet-at S freezes sine at its held value from stream second S onwards, so it stops crossing zero: the dropout trigger's input.
  • --spike-every S / --spike-amp A / --spike-width N inject an alternating-sign impulse train onto sine only — the other four signals (time, square, ramp, noise) are untouched, which is what lets a probe compare a spiked channel against a clean one in the same run. Every S seconds a spike of N samples at amplitude A is added to sine; the sign alternates per period because a one-signed train is a DC offset a median would partly track rather than reject. N is in samples, not seconds, so its duration follows --rate. 0 (the default for both S and A) injects nothing, so every existing invocation is unchanged. --batch must stay at or below 1000 with or without these options: 2000 samples × 5 signals × f64 is 80 kB and exceeds UDP's ~65507-byte payload limit, and the mock dies on its first sendto with OSError: [Errno 90] Message too long while the run still exits 0.

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. deps/udpsclient gained a TCP transport mode (item 6 below) and one additive entry point for the packet capture — udps_client_set_datagram_cb(), firing inside udps_client_poll() immediately after the UDP recv() returns, before the magic check or reassembly — plus the public UDPS_MAX_DATAGRAM_BYTES the capture sizes its slots from. A separate setter, not a fourth parameter on udps_client_set_callbacks(): that signature has call sites, and a capture hook is no reason to change them.

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. Six things were changed:

  1. 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 third clamp_deadline() call on the select() budget (udps_client.c:1145-1152) so a quiet stream does not wait for the next arrival.
  2. 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 late rather than discarded with stats.stale_packets++; return 0. That flag is what makes backfill possible at all; without it the samples are gone before udpscope can act.
  3. 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.
  4. reorder_window_s = 0 as the switch. udps_client_config_t::reorder_window_s, defaulted to UDPS_REORDER_WINDOW_S_DEFAULT (0.020) in udps_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.
  5. 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 advance next_counter past 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 its lost, and never flagged late: a packet ahead of the sequence has no reserved hole and Acquisition::backfill_from() silently ignores a late packet it cannot place, so flagging it would be the same silent loss by another route. stats.reorder_overflows counts 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 as overflows=.
  6. A TCP transport mode. udps_client_config_t::use_tcp / stream_name select a TCP connection to a MARTe2 TCPStreamer or TCPStreamService: connect_tcp() (a 3 s connect bound through SO_SNDTIMEO, a 1 s receive bound, an optional SUBSCRIBE frame, UDPS_PKT_SUBSCRIBE = 5, sent only when stream_name is set), the existing read_tcp_frame() for the frames, the reorder window forced to 0 (TCP is ordered, so a counter gap is a server-side drop and is reported at once), multicast forced off, no DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), no keepalive ACK and no datagram tap. The receive buffer is max_packet_bytes + header (1 MiB by default, matching the server's frame ceiling) in TCP mode, since a TCP frame is never fragmented and a MARTe cycle of large arrays is one frame; it is UDPS_MAX_DATAGRAM_BYTES only for UDP. A stream name over 255 bytes is refused at create.

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.

A figure row can hold several plots side by side, which the host's horizontal split needs and the library had no concept of: Figure(std::span<const u32> cells_per_row) builds one, with plot_count(), cells_in_row(), first_plot_of_row() and row_of_plot() to move between rows and the flattened plot index, set_cell_weights() and set_col_gap() to size the cells, and FigureLayout::cells (one rect per plot, flattened) and FigureLayout::col_gap to read the result back. Figure(u32 rows) still means one cell per row, so every row-only caller and test is unchanged, and row_count() stays the number of figure rows (the host's RowSet::row_count() is the plot count, figure_row_count() the rows). plot(i), link_x() and the crosshair's hovered index all take the flattened plot index. Cell widths use the height scheme: a floor of 64 px logical per cell, the weights share the rest, the last cell closes the row exactly, and a figure too narrow for the floors shrinks the cells proportionally and drops the column gap. The x labels and title follow the column structure: a row hides its x strip (XStrip::None) only when the row directly below has the same columns (same cell count, same normalised cell weights within 1e-4, empty weights = equal), so [1][2]/[3][4] has an axis under 3 and 4 only while [1][2]/[3] has one under 1, 2 and 3; the rule is structural, transitive down a chain and decided before the measurement pass (same_columns() in figure.cpp); set_x_labels_only_on_bottom(false) labels every row. The left y gutter stays one maximum over the whole figure.

The cells of a figure row can share one y range, symmetric to the x link (docs/superpowers/specs/2026-11-03-split-shared-y-design.md):

  • Figure::set_share_row_y(bool), default off so every existing figure and tests/test_figure.cpp read as before; the host turns it on in RowSet::rebuild_all() before the cells are populated. When on, the cells of each figure row form an implicit y link group, rebuilt with the cells.
  • Link semantics: layout() runs a sync_y_links() that adopts a member's changed y_range as the group's and pushes it to the others, so a wheel, pan, box zoom or axis drag (all set_y_range) needs no controller change; a manual set_y_range on a member clears the group's auto flag. A member's own autoscale is suppressed and the group owns the flag: it fits to the union of its members' visible traces (cells with no trace are ignored; a log axis takes the union in axis space), and fit_all and a double-click fit the group, not one cell. The group only follows on a laid-out frame, so a caller that sets a range or a fit and reads it back needs one layout() in between.
  • YStrip {Both, None} and Plot::set_y_strip_mode, mirroring XStrip: None forces the y label width, title width and reserved floor to 0. Figure::layout sets None on every cell but the first of its row, structurally from the column index and never from content, and skips None cells in the common-gutter maximum. A y axis drag can therefore start only in the leftmost cell.
  • Cell placement is by plot width: the row's y gutter comes off the row width first, then the cells share the rest by the existing floor + extra * w / total rule, the floors applying to plot widths. Equal weights give equal plot areas; the first cell's rect is gutter plus its plot width and the rects still tile the row exactly.
  • The right gutter belongs to the last cell. In a shared-y split row only the last cell gets set_right_gutter(); it is taken off the row beside the y gutter (capped so both fit in half the row and never beyond the figure's right gutter) and the last cell's rect grows right by it. The gap between two plot frames is then exactly col_gap rather than col_gap plus a gutter; unsplit rows and rows with sharing off lay out as before (figure_split_row_gap_*, figure_row_gutter_without_sharing_is_the_old_layout).

In the GTK4 and GLFW adapters the pointer is routed by cell rect (row_in(rows, x, y): the containing cell, else the nearest by squared distance), one input::Controller per plot. The tests are the figure_cell* cases in tests/test_figure.cpp and glfw_host_routes_by_cell.

plot::data::RingBuffer carries two writes a plain SPSC ring has no use for and this app does: amend(absolute, v) rewrites a retained slot when a late packet's data finally arrives, and clear_to(absolute) empties a ring and resumes its count at a given origin. The second exists because a math channel's ring is recomputed from its inputs' retained window: clear() would restart it at 0 and leave every absolute index — base(), produced(), copy_from() — short by that window's base, which is the desynchronisation live_window_locked() then reads as an empty window (see invariants-math-channels.md). Its guards are ring_clear_to_resumes_the_absolute_count — a derived ring refilled from a source's retained window must come back with the source's own produced()/base() — and ring_clear_to_zero_is_clear.

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 box-zoom selection was the one gesture with no feedback at all: the controller tracked the rect and nothing drew it. The drawing went upstream with the rest — Plot::set_selection_rect() (the plot carries a rect the host hands it and learns nothing about pointers), Controller::selection_preview() (the mask decides which axis is not zoomed, and the preview is widened along it, because the swept sliver of a horizontal Ctrl drag is the gesture and the band across the plot is what the release will apply) and Renderer::draw_selection_frame() — and it carries three tests: a_ctrl_box_zoom_previews_the_band_it_will_zoom, the_selection_frame_is_carried_and_taken_off and the_box_zoom_frame_lands_exactly_where_it_says, the last of which reads the headless framebuffer back and compares the same pixels with and without the frame. 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"