Each invariant records what went wrong rather than only the rule: a port hit that lost its node, a hit test at the identity view that asserts nothing, a `Delete` that must not fall through to the selected node, and a column pitch too narrow for a wire's own tread. The warning baseline drops to four.
13 KiB
Verification
How to run the headless self-check, the two baselines every run is read against, and the environment variables that schedule each probe.
Part of CLAUDE.md.
There is no unit test suite. The only automated check is the app's own headless
self-check, driven by env vars and run under a display (xvfb-run, llvmpipe) against
a live UDPStreamer source:
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000
Two baselines every run is read against. A clean build emits four warnings and no more: two
-Wmissing-field-initializers on MathNode::source and -Woverlength-strings on both
generated GResource .c files — the help pages' and the icons', whose blob crossed C99's
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
generated file, which GCC itself supports). The fifth warning that used to appear here was an
unused output_built in on_math_apply() — Task 6's live validation check reads that value, so
the variable is no longer unused. And an exit-dump run emits no Gtk-CRITICAL at all. A
fifth warning, or any critical, is a finding.
That second baseline used to be "exactly two, gtk_widget_get_height and gtk_widget_get_width,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
runs from the destroy handler, so the widget pointer is freed memory, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first —
UDPSCOPE_PROBE_HELP_MS, which opens three — has had that memory reused, and the same two calls
then SIGSEGV in the middle of the dump, which reads as a truncated dump rather than as a
crash in a debug line. Every size the dump prints is therefore captured on the tick, while the
window is alive (App::dbg_*), and no gtk_widget_* call may appear in the dump — the
panel controls: line was already written that way for the same reason, and that comment is the
precedent, not an exception.
-
UDPSCOPE_EXIT_AFTER_MS— run the trigger-on → go-live probe, close the window, then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted frames, trace bindings, panel sizes, status line in both states). -
UDPSCOPE_ENABLE_ALL_MS,UDPSCOPE_ARM_TRIGGER_MS,UDPSCOPE_PROBE_DISARM_MS,UDPSCOPE_TRIG_TRACE_MS— schedule individual probes.UDPSCOPE_PROBE_DISARM_MSonly starts its sequence: it switches the trigger on and then polls for a capture to open, so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall. -
UDPSCOPE_PROBE_FIELDS_MS— change the trigger panel's edge and Normal/Single dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant below), so time it inside a capture:[fields-probe] ... capturing=1followed by no SIGSEGV is the assertion. -
UDPSCOPE_PROBE_WINDOW_MS— drive the window control every way a user can: type a number, change the unit under it, step a decade either way. -
UDPSCOPE_PROBE_LAYOUT_MS— drive the layout editor: switch it on, insert above the first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms apart on purpose — the buttons are placed from the rects the last frame was laid out with, so a synchronous sequence would read every position one action stale. It clicks by emitting::clicked, nevergtk_widget_activate(), which holds aGtkButtonfor its 250 msACTIVATE_TIMEOUTand then emits into a button the rebuild has already destroyed. -
[measure-probe] N measurements on 'X'(stderr,UDPSCOPE_PROBE_MEASURE_MS) — adds one measurement of each statistical type (average, top, peak to peak, amplitude) on the first channel and forces a recompute. It must name a channel, notsignals().front(): the first signal of a stream is its timestamp, which is not acquired, and naming it adds four measurements that all read--and makes a performance run look free (each bails before collecting anything -- that is how the first baseline of the stall below read 0.4 ms). The values are in the exit dump asmeasurements: <type>=<value>; on the fast mock (tools/mock_udpstreamer.py --rate 1300000 --batch 2000) a unit sine must readpeak to peak=2,amplitude=1,average=0andtop=0.636620-- 2/pi, the half-wave mean of a sine, which is the check that the threshold types are not merely plausible.[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...is a repeating line (250 ms) from the probe: the pane's numbers beside the current window and the marks actually placed, which is the only way a stale result or a mark that should not be drawn shows up as a number. It must never printmarks=0while measurements withshow_on_plotare on and the data is there -- that was the rulers blinking off at a mode change.measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B raw=R (F%), N ns/covered samplefollows it: read it against a run withUDPSCOPE_PROBE_MEASURE_MSunset, because a frame mean cannot attribute a cost. The last three fields are the assertion, and they are a ratio, not a time:coveredis the samples the window spans,bucketsthe LOD buckets the two walks answered from, andrawthe samples they still had to read.rawmust stay at a fraction of a percent ofcoveredon a clean waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the measurement-cost invariant in invariants-measurements.md). Arawnearcoveredmeans the walks went back to reading the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is legitimately higher for a window shorter than a bucket (64 samples) or a signal noisy enough to cross a threshold everywhere, so read it on the fast mock (tools/mock_udpstreamer.py --rate 1300000 --batch 2000), where a window is ~2 M samples.--noise 0.1there reads 2.0% at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and that is the interferer's own cost rather than a regression -- the figure to compare a change against is the clean one. Note the finalmeasurements:line and[measure-trace]'s last lines may read--:UDPSCOPE_EXIT_AFTER_MSarms the trigger before it dumps, so the dump can land inWaitingwith nothing bound and the axis at its default. The race-free reading is an earlier[measure-trace]line, which carries its ownview=; those lines are also where the level-mark fallback is visible (an invalid pass must still print itslevel:marks). -
[pan-probe] asked=[..] was=[..],[pan-probe] after=[..] held=D asked=[..], then[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L(stderr,UDPSCOPE_PROBE_PAN_MS) —heldmust be 1: a gesture must not move a window that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a gesture does — on the member axis, whichFigure::layout()adopts into the group — and reports the axis the frame after it produced. Time it inside one pinned state: a pan during a capture whose check lands after the capture has ended is re-framed by the mode change, not by the pin, and readsheld=1with the hold removed (that is how the first version of this probe fooled me). The pause probe makes a stable pin, so run them together:UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200puts the pan and its check inside the pause. Withhold_pinned_window()disabled it readsheld=0and the axis sits at the asked range. It then asks for a zoom-in (a window a quarter of what is on screen) and reportsgroup=/member=beside it:allowedmust be 1, with both equal to the ask -- that is the single-trigger-mode case, where the wheel has to keep working, and where reading the wrong one of the two made a working clamp look broken. All three verdicts are read from the tick, one frame after the ask: ag_timeoutwas the first version and it drifted past the paused/resumed boundary under load. The probe then asks for a zoom-out ten times the current span and reports the span the next frame produced:limitedmust be 1, i.e.span <= window. Run it with a ring that holds more than the window (--buffer-mb 64past the window's own seconds) — that is the state the oldmax(retained, window_s_)bound leaked in, and it showed up here asspan=2.502550 window=2.000000 limited=0againstspan=2.000000after the fix. -
[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..](stderr,UDPSCOPE_PROBE_EVENTZOOM_MS) — arms the trigger in single mode, polls for a held event (a latch happens when the stream says so; a sweep on a timer has nothing to zoom into), then zooms in eight doublings twice:ges/writes the row's own axis the way a gesture does,win/drives the window control (apply_time_window, which unpins and re-arms the live follow before the pin is restated). Each step's reading is taken in the tick, after that frame's pin and clamp — ag_timeoutwould report the ask. It recomputes whatRenderer::draw_tracesis handed, through the same calls:neighbours()foridx, the 64-spp gate forlevel, thengather().gap_pxandend_pxmust stay within a pixel or two at every step — they are how much of the left and right margin no geometry reaches, and agap_pxthat doubles per step is the "zooming into a held event clips it more and more from the left" defect (374 px of a 716 px axis byges/x128), whose cause is a constant offset in data seconds (see theneighbours()invariant in invariants-rendering.md).kept/backare the renderer's own increasing-suffix filter counted rather than applied:keptfar belowptsis a staircase, and a non-zerobackis a non-monotonic gather. Run it with--clock-jitter(tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4): the ideal mock's stamps sit on a perfect grid, which is exactly what hides this class of bug, and three runs against it reportedgap_px=0.0throughout while the defect was live. The jitter varies each packet's spacing rather than offsetting its start, because the stream's clock has to stay strictly increasing. Thewin/sweep bottoms out atspan=0.05(RowSet::set_time_window()'s floor) and walks to the event's post side, sinceset_time_window()frames the newest span before the pin clamps it — both are expected. -
UDPSCOPE_PROBE_PAUSE_MS— pause acquisition mid-run so layout numbers are measured on a still picture. It now also asserts what a pause is ([pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..] glance=[..]on stderr): it presses the button, notAcquisition::set_paused(), because pausing is a display state as well as an acquisition one and the view snapshot and axis pin live in the handler. Across the three linesring_producedmust grow (the producer never stops — it fills the live ring exactly as it does with the trigger off),event_sizemust be non-zero exactly while paused (the plot is bound to the frozen snapshot, not the ring) andfrozen_xmust not move between the pause and the resume; after the resumeevent_sizeis 0 and the window is back on the newest data. Pausing used to stop the producer, which is why the counts grew by nothing and the resume left a hole.frozen_xis the rebased snapshot — the pause view's own stamps are relative to the freeze instant now, so the range reads[-window..0]-ish rather than stream seconds, and afrozen_xin the thousands is the rebase gone.glance=is the assertion for the paused status field, and this is the only line that can make it: the exit dump'sstatus bar:is read live and its twomode …lines are the live and triggered states. While paused it must readpaused · <rate> · <window> · for <elapsed>, with the elapsed figure growing between the two paused lines and absent on the resume line. It is wall time (g_get_monotonic_time()), not stream time, because the producer never stops while paused and "how long have I been looking at this" is a question about the user, not about the stream; it is written throughformat_elapsed()so the field steps to a coarser unit instead of growing digits (see the layout rule). -
UDPSCOPE_EXPORT_MS(+ optionalUDPSCOPE_EXPORT_PATH, default/tmp/udpscope-export) — collect the export table and write it both ways, skipping only the file chooser.
Run one self-check at a time. Two concurrent xvfb-run -a instances end up 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. tools/mock_udpstreamer.py
also outlives its --duration, so kill it after each run or the leftovers pile up and
hold ports.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in src/ui.cpp rather than adding a separate harness.