The previous Measure glyph was two icons sharing a box: a heavy closed ruler frame on the right and two light caps on the left, with nothing relating them. It is now one instrument — two cursors *standing on* the scale they read against, each marker centred on one of the scale's marks, so the pair reads the way the pane works: bracket a window, read it against the axis. Three things the render said, each fixed: - **The closed frame was most of the ink.** Two lines and two end caps cost about a fifth of the glyph's ink and say nothing a ruler's marks do not, so the scale is now one band with alternating marks — long, short, long — hanging above it. - **The caps were dashes, not grips.** 5x2 at 32px read as a horizontal stroke; they are 5x3, like the caps on the toolbar's cursors icon. - **The ticks were finer than everything else.** At 1px they were thinner than the 2px weight every icon here uses, so they are 2px, and every mark alternates in height the way a ruler's do. The Markers' shafts are dashed and meet the band rather than hovering over it, and the whole glyph is inset two pixels like the others, so nothing is flush with an edge that a button's padding or a crop takes a pixel off. Ink: 27%, inside the range the icons above it set. Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`; `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present" (the plain ruler it uses is untouched); `make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' blob is 12624 bytes, still over the C99 limit, so the clean-build warning count stays four.
50 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' (514660 bytes) and the icons', whose blob is
12624 against C99's 4095-byte string-literal limit with the seven action icons and the two app icons
that remain (a note about a generated file, which GCC itself supports) -- re-measured when
the tab work changed the set (that icon left, the two tab glyphs arrived, and this file's
help page was rewritten), which did not move the count; re-measure by touching each *.gresource.xml
under src/ and rebuilding. The fifth warning that used to appear here was an unused
output_built in math_probe(); it was deleted as dead code — redundant with the
output_after_add already printed on the same line — and that deletion is what dropped the baseline
from five to four. Nothing started reading it. And an exit-dump run emits no Gtk-CRITICAL at
all. A fifth warning, or any critical, is a finding.
An incremental build reads two of those four: the generated GResource .c files are not
recompiled when only src/ changed, so their -Woverlength-strings do not reappear.
A third reading every run offers for free: how many [udpscope] buffer allocated: lines it
printed. The line is not behind a probe — it is in every build — and one line is one real change
of buffer plan, each of which blocks the main thread for the time it takes to touch the whole
--buffer-mb. A normal run prints two or three. Fifteen at startup is the defect it was added for;
see probe-readings.md.
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_TRIGRENUM_MS— the trigger's capture must survive a renumbering of its own channel. It sets the reported geometry through the real widgets (1 s window, 10 % pre share, hold-off from--holdoff) on the mock's square alone, polls for a capture to open, and then enables the sine — which sorts before the square and so moves the trigger channel's index under the engine. Its three lines are in probe-readings.md;t0_held=1is the assertion. Run it with--holdoff 10, which makes the stolen-t0 reading unmistakable: a second latch barely a second after the first cannot be a hold-off that applied. -
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=[..] skips=N 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).skips=is the assertion for the render gate (the end oftick()), cumulative over the session: theliveline must read 0 and thepausedline must read more than 0. A frozen view is bound to an immutable snapshot on a pinned axis, so a frame whose picture cannot have changed is not drawn again — and this is the only line that can say whether that happened, since a skipped frame is invisible in every other reading by construction. Non-zero on theliveline is the gate's condition inverted, which would cost dropped samples' worth of picture; 0 on thepausedline is the gate not engaging and the saving not being made. The gate cannot stall the picture (kStaticRenderEveryrenders every few ticks regardless), so the renderer's own baselines below are unchanged by it: a measured run readskips=0, then 33, then 45, withcolumns == graded,render blank frames: 0, and the layout floor still converged (y-gutter 104..104). -
UDPSCOPE_PROBE_COLOR_MS— recolour one plotted signal and read the colour back out of the trace and the chip, round-trip it through the session writer, then clear it. ScheduleUDPSCOPE_ENABLE_ALL_MSbefore it: with nothing bound it has no trace to recolour and asserts nothing. Its four lines are in probe-readings.md. -
UDPSCOPE_EXPORT_MS(+ optionalUDPSCOPE_EXPORT_PATH, default/tmp/udpscope-export) — collect the export table and write it both ways, skipping only the file chooser. -
UDPSCOPE_PROBE_CONDITION_MS— arms a qualified trigger against the mock and reports the latchedt0, the measured pulse or band width, and sample values for the window condition. Schedules its own arm and report stages.UDPSCOPE_PROBE_CONDITION_SHAPEpicks the leg:0pulse width<(the glitch, the default, needs--glitch-at/--glitch-width),1pulse width>(uses a 1.0 s window and 50 % pre so the opening edge of a 0.2 s pulse is captured),2window (usessinewith a 0.2 s window and 10 % pre),3dropout (needs--quiet-at; usessinewith a 1.0 s window and 50 % pre so the last crossing before the outage is captured). -
UDPSCOPE_PROBE_STATS_MS— reports every measurement's accumulator (n, mean, sd, lo, hi), pauses, reports again withdn=per measurement, then resets and reports a third time. Three phases:phase=0,phase=1(after pause) andphase=2(after Reset). NeedsUDPSCOPE_ENABLE_ALL_MSahead of it andUDPSCOPE_PROBE_MEASURE_MSto add measurements first. -
UDPSCOPE_PROBE_SIDE_TAB_MS=3000withUDPSCOPE_EXIT_AFTER_MS=11000— the measurements on the Measure tab, a forced pass, then the tab switched away and read again two seconds later.advanced=4is the assertion (every accumulator gained passes while its page was off screen);advanced=0is the defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. It enables and plots its own signal, so it needs no other probe ahead of it. The reading's own entry is inprobe-readings.md. -
UDPSCOPE_PROBE_SEARCH_MS— runs a buffer search for the armed condition, cross-checks the live trigger's ownt0against the nearest hit (agree=1is the load-bearing reading), and jumps to hit 0. Reports the resultingmode=,axis=andmarks placed=.
The following mock waveform features are what the qualified-condition probes are checked against, as distinct from the fault injection below, which exercises ingest:
-
--glitch-at S/--glitch-width Winvert thesquaresignal forWseconds at the start of every square period from stream secondSonwards — a runt pulse of a known width, which is what a<pulse-width trigger and a glitch search are checked against. -
--quiet-at Sfreezessineat its held value from stream secondSonwards, so it stops crossing zero: the dropout trigger's input. -
UDPSCOPE_PROBE_RESEQ_MS— one mid-run reading of the resequencing path. Five cases, run each as its own process — five back to back starve the mock past its 2 s silence timeout and every case then reads a reconnect instead of what it was testing. Common conditions for the first four: the mock at--rate 10000 --batch 100 --freq 5, udpscope with no extra options, underxvfb-runwithUDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 UDPSCOPE_EXIT_DUMP=1 UDPSCOPE_PROBE_RESEQ_MS=5000. Delete~/.config/udpscope/127.0.0.1_8000.tomlfirst: a session left by an earlier run restores its math channel, and five channels instead of four changechannels=,span=and every count derived from them.case mock extra flags key reading base (none) every counter 0; proves the machinery costs nothing reorder --reorder-every 10reseq=51(mid-run) /102(dump);holes=0; resequencing inside the window leaves no reservationlate --reorder-every 10 --reorder-delay-ms 50bands_repairedone short ofbandswithnan_slots=100; the band still solid is the one hole whose late packet is in flight, and it is the only unrepaired onemixed --drop-every 20 --reorder-every 10 --reorder-delay-ms 50bands=43 bands_repaired=20 nan_slots=2200; all three band states at oncere-plan --rate 100000with themixedflags, andUDPSCOPE_PROBE_WINDOW_MS=4000 UDPSCOPE_PROBE_RESEQ_MS=6000 UDPSCOPE_EXIT_AFTER_MS=9000four buffer rebuiltlines before the reading, thenbands=64 bands_repaired=16 nan_slots=7800: a band is still only marked repaired when its own slots hold values. Abands_repaired == bandsbeside a non-zeronan_slotshere is the hole table and the producer'stick_surviving a re-plan that resetproduced()— a band marked settled over samples that are all still NaN. The non-zerooverflowsis this case too: 100 kS/s with a 50 ms reorder delay keeps about 50 packets in flight against 64 slotsThe re-plan case needs
--rate 100000, not the other four's 10 000: at 10 kS/s the ring -
UDPSCOPE_PROBE_CAPTURE_MS— toggles the packet capture through the same handler the status-bar button uses, waits, then reads the file back off disk and prints one line:enabled= path= slots= stored= resident= cursor= torn= truncated= header_ok= first_seq= last_seq= payload= file= open_ms= t0=.storedis the datagrams-since-open counter and must equal the dump's datagram count from the toggle's instant;residentis what the file holds, which is the slot count once the ring has wrapped;fileis the reserved size64 + slot_count * slot_bytesand is a constant for a given N, so a mismatch against the file on disk is a reading. Two cases, run each as its own process:- enable at runtime — the mock at
--rate 20000 --batch 200, udpscope withUDPSCOPE_ENABLE_ALL_MS=700 UDPSCOPE_PROBE_CAPTURE_MS=1500 UDPSCOPE_EXIT_AFTER_MS=9000and no capture flag:enabled=1 header_ok=1 torn=0 truncated=0 first_seq=1,storedequal to the datagrams since the toggle,open_msa few ms. - wrap — the same with
--capture-packets=16andUDPSCOPE_PROBE_CAPTURE_MS=7000 UDPSCOPE_EXIT_AFTER_MS=10000:stored(870 in the reference run) exceedsslot_count,resident=16,last_seq - first_seq == 15,torn=0, andcursoragrees withseq(counter mod slot count). A wrap that loses ordering, or acursorthat disagrees, shows here and nowhere else.
Then the converter over the wrap case's file:
tools/capture_to_pcap.py <capture> out.pcapmust exit 0, print a record count equal toslot_countwithtorn0, and the pcap's first and last record timestamps must fall inside the run's wall-clock interval — both checked by astruct.unpackof the pcap headers, nothing outside the standard library. - enable at runtime — the mock at
-
A gap must not kill a filtered trace.
--relative-timewith the mock dropping (--drop-every 20) andUDPSCOPE_PROBE_MATH_MS=2500 UDPSCOPE_ENABLE_ALL_MS=600 UDPSCOPE_EXIT_AFTER_MS=9000: every derived channel's dump linemath '<name>': finite=N/4000must read about 3800 — the tail minus the reserved holes — andfinite=0beside a non-zerohole_slotsis the defect (a biquad that integrated the hole's NaN, permanently). The filters'sdmust stay near the no-drop value; run the same command without--drop-everyas the control, where everyfinitereads 4000. -
UDPSCOPE_PROBE_CONFIGS_MS— saves the running session as a configuration namedprobewith a note, throughsave_configuration_as()(the same entry point the dialog's button uses), then reads it back off disk. Reading:save-as=1;names=gainsprobe;active=probe; and the round trip must carry the note and the startup settings —comment='written by the configs probe' buffer_mb=8 relative=0 capture=0. Abuffer_mb=0there is the per-source buffer size not being written, which is the one setting a user notices only after a capture has been sized wrong. Run it with a throwawayXDG_CONFIG_HOME; the probe deletes its own configuration at the end (after forget names=…). -
UDPSCOPE_PROBE_ROWMENU_MS— gives row 0 a trace, opens the plot's context menu withopen_row_menu()(the one entry pointon_right_click()wraps) and walks its two views one step at a time (main→ click Remove signal →remove→ click Back →after-back→ click Remove signal again →second-open→ click a trace), measuring each view's rows:[rowmenu] <view> N '<label>' h=18 label_x=26 accel_right=R/W ok=1per row and onerows=/bad=/labels=line per view, then[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1and[rowmenu] remove-clicked 'sine' trace_freed=1. Everybadmust be 0 (the same compact row style as the app's own menu: one label column, a shortcut flush right, 18 px rows — the Back row's icon indents its label, which every menu does, so itslabel_xis 52 against the others' 26) and the three booleans must all read 1: the remove view replaces the main one (remove_row_gone,remove_row_in_main), and it can be entered again after leaving it (can_open_again) — that last one is the case aGtkMenuButton-owned submenu failed, its row going dead on the second click. Each view is measured a step after the click that swapped it in, because a popover allocates its new child on the next layout pass. -
UDPSCOPE_PROBE_DEFAULTS_MS— opens Calibrate and reads every spin button in it and in the trigger panel:[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01. AGtkSpinButtonstarts at its range's minimum, so a-1e12here is an unseeded button the dialog's own reader would then apply — which is exactly what the calibration offset did. It then calibrates signal 0 throughset_calibration()and reopens the dialog:[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25— the dialog must show the signal's stored calibration, and the two halves must match. -
The chooser, display-free:
UDPSCOPE_PROBE_SOURCES=1prints[sources] N known:with one entry per configuration (host:port/name … buffer= relative= note='…'), andUDPSCOPE_PROBE_PICK_MS=800opens the chooser, picks the first row and prints[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'— the settings the configuration carried, into theConfigthe model is built from. A legacy file (<host>_<port>.toml) produceslayout loaded from …/<host>_<port>/default.toml, anactiveholdingdefault, and the old file renamed.imported. -
The loading window covers the gap, and only on the path that asked. The same
UDPSCOPE_PROBE_PICK_MSrun must print[sources] loading: title='Connecting to 127.0.0.1:<port>' detail='planning the N MB sample buffer'between thepickedline and the stream'sCONFIG:, and[sources] loading: closedafter it — the window is raised before the buffer is planned and taken down only once the real window has been presented, which is the whole of what it exists for.UDPSCOPE_PROBE_WAVE=<path>is the file leg of it (title='Opening <basename>' detail='reading the recording', beforewaveform loaded:). A run that names its source with--serverprints noloading:line at all: every other probe here is on that path and must stay on it. -
The event cache round-trips, and only where it is available.
UDPSCOPE_PROBE_EVENTS_MSreads the pane's availability first, in the state the app starts in --[events] live: button_visible=0 pane=0 recording=1, no pane and no button without trigger mode -- then turns trigger mode on through the real toggle ([events] after trigger on: button_visible=1 pane=0: the button appears, the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays the newest event through the pane's own row:[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1, then[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1(the size is what says the picture is on screen: a paint-able alone is not),[events] replayed: channels=4 samples=20001 replaying=1 waveform=1and[events] live again: replaying=0 waveform=0. It ends on the pane's availability, through the trigger panel's switch:[events] recording off, trigger offand[events] recording off, trigger on-- once the cache holds events (the probe's own run has written some by this point) both readbutton_visible=1 pane=1 recording=0, because a button that shows the cache must not go away with the switch that wrote it; against an empty cache the same two lines readbutton_visible=0 pane=0, which is the empty column the item exists to prevent.samplesmust equal the export's row count for the same window (20001for a 2 s event at 10 kS/s),thumb_bytesmust be a real BMP (54 + 480*160*3-ish, i.e. hundreds of kB, not 0),thumbnailmust be 1 (the row loaded the picture) and the last line must readreplaying=0-- a replay with no way back is a one-way door. It then waits for a second latch (bounded: twelve retries) and drives the two group layouts through the buttons:[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1(the buttons read the selection),[events] halve…[events] halves a_first=20001 a_second=0 b_first=0 b_second=20001-- the two halves of the ring, and the reading that says a comparison's gap is a gap: the first event's channel is finite only in the first half and the second's only in the second --[events] compare: kind=1 rows=4 channels=8 samples0=40002 traces0=2 traces1=2 window=4.000 recording=1 pane=1(samples0must be exactly the stamps pushed, not more: a live producer left running pushes its own four signals into the group's eight channels),[events] compare plots: area_b=1 rows_a=1 rows_b=1 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1(the second plot exists, is on screen, mirrors the row count, and both are on one x window), then the sync -- the probe sets a y range nobody would arrive at by accident on the first plot and reads the second a few frames later:[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1-- and[events] accumulate: kind=2 rows=1 channels=8 samples0=33045 selected=2 traces0=8 window=2.000(traces0=8: the four signals the layout holds plus one layer each for the second event, in the same row, which is what an accumulation means). Run it with a throwawayXDG_CACHE_HOMEandUDPSCOPE_EXIT_AFTER_MSlong enough for the second latch (20 s). -
A waveform round-trips. Export from a live run (
UDPSCOPE_EXPORT_MSwithUDPSCOPE_EXPORT_PATH, against the mock at 10 kS/s), then open that file with--openand an exit dump. The load must print[udpscope] waveform loaded: … signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]— the same row count, column count, span and time range the export probe reported (rows=20001 cols=4 time=[-2.000000..0.000000]) — and the dump must showchannels=4,frames=0 packets=0(no acquisition) and, per channel,20001 samples vs 20001 time slots … y=[-1.0000, 1.0000]. A file that loads with fewer samples than it was written with, or withNaNslots, is the reader mis-slicing its records. -
A recording that does not fit its buffer is filtered into it, not truncated. The case needs a file bigger than the budget, which the mock cannot export in one window, so write one with NumPy — the dtype is the exporter's own,
[('time','<f8'), ('abs_seconds','<f8'), (<signal>,'<f4'), …]— and open it against a small buffer:python3 -c "import numpy as np; n=1000000; t=-4.0+np.arange(n)/50000.0; \ a=np.zeros(n, dtype=[('time','<f8'),('abs_seconds','<f8'),('a','<f4'),('b','<f4'),('c','<f4'),('d','<f4')]); \ a['time']=t; a['abs_seconds']=1000.0+t; a['d']=np.linspace(-1,1,n); np.save('/tmp/long.npy', a)" xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --open /tmp/long.npy --buffer-mb 1must read
samples=31250 rows=1000000 decim=32 span=19.999980s time=[-4.000000..15.999980]andstored=31250 … published=31250per channel — the whole span, at a thirty-second of the rate. The ramp column is the assertion that the head is there:y=[-0.999969, 0.999969]is the file end to end, where the defect this exists for reads a fraction of it. With every row pushed regardless of the plan, this file arrived as its last 0.9 s and nothing in the log said so. -
A loaded file can be plotted.
UDPSCOPE_PROBE_DROP_MSdrives the drop handler the way a drag from the signal panel does, on a file opened with--open: it must read[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||] view=[-3.999690..15.999670] repins=2—rowsequal to the column count, one trace per row — and each drop must return1and add its trace (dropped signal 1 -> 1 slots=[sine|sine|square|]).rows=1 slots0=[sine|square|ramp|noise]with every drop returning0is the defect this exists for: rows sized four signals to one row fill the only row, so the app reads as one that cannot plot anything. A file-mode run must also write no session for the default127.0.0.1:8000(App::source_portis 0), or it overwrites that source's saved layout. -
A file view states its pin once.
repinson the line above must read 2 whenever the probe is scheduled — at one second or at seven — andviewmust be the file's whole span. A file's extent does not change while it is open, so the tick refreshes the pin's numbers and writes the group's range only when the view first becomes an event. Reading it against the decimated file above (/tmp/long.npy, stamps[-4..16]) is the case that matters: with the pin written as a pre/post split where a range belonged it readview=[3.999690..15.999670] repins=116at three seconds — the axis alternating between the file's span and a corrupted pin every frame, which on screen is a flicker in which no pan, zoom or click survives to the next frame. A file whose stamps are all positive, or which ends at 0 (an export's own window), hides it: the corrupted pin is then wider than the data, or empty, and the clamp never fires.UDPSCOPE_PROBE_PAN_MSon the same run is the other half —allowed=1and aviewthat is still the zoomed range when the drop probe reads it later is interaction actually taking. -
The chooser's Waveforms page:
UDPSCOPE_PROBE_WAVEFORMS=1prints[sources] waveforms: N recent <path>(ok|missing) … page=waveforms, andUDPSCOPE_PROBE_WAVE=<path>sets the field the file dialog's callback sets and prints[sources] picked waveform <path>— then the load line above, with no socket involved. -
A
--serverlaunch reads the stored configuration. With a throwawayXDG_CONFIG_HOMEholding127.0.0.1_8101/bench.toml(buffer_mb = 64,relative_time = true,capture_packets = 500,active=bench),./build/udpscope --server 127.0.0.1 --port 8101must print[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500, therelative time:line, and aspan=of about 209 s (64 MB at 10 kS/s) — not the 200 MB default's ~655 s. The same command with--buffer-mb 8must readbuffer=8andspan=26.214s: an explicit flag wins over the file. Without this, the settings a host was edited to have were ignored whenever the address came from the command line. -
The chooser's form, with
UDPSCOPE_PROBE_PICK_MSscheduling it:UDPSCOPE_PROBE_NEWHOST=host:portfills it and prints[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1, then[sources] configuration created: host:port/probe-host … buffer=32 relative=1and apicked … config=probe-host; a followingUDPSCOPE_PROBE_SOURCES=1must list it (buffer=32 relative=1 note='…') — a configuration the form wrote that the chooser does not list cannot be edited next time.UDPSCOPE_PROBE_EDITHOST=host:portopens the same form on that configuration and prints[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'— the pre-fill is the assertion: an edit form that opens empty cannot edit anything — then saves and reports the new values.UDPSCOPE_PROBE_EDITNAME=new-namerenames it as well: oneknown:row afterwards, with the old file gone. -
UDPSCOPE_PROBE_RESEQ_MS's neighbour for reconnect: the mock gains an EMI case,--blackout-at S --blackout D— silent from second S for D seconds (D must exceed the client's 2 s silence timeout), resuming from now so the link's backlog is what the link ate. One run (--blackout-at 4 --blackout 3.5,UDPSCOPE_EXIT_AFTER_MS=15000) has to read: every reconnect'sCONFIG:line followed byCONFIG unchanged: keeping the buffer and its history(a reconnect that re-plans wipes history, which is the defect this case exists for), nobuffer rebuiltline after the first CONFIG,framesclimbing again after the blackout withgaps=0, and a trigger armed after recovery (UDPSCOPE_PROBE_KEYS_MSpast the blackout) latching — the dump'sCAPTURING/EVENT HELDandpublish cost: N eventswith N ≥ 1 are that proof. already covers 838 s, so no window the control can reach movestime_stride,set_target_span()returns without rebuilding, and the window probe reportsdecim=1throughout — it re-plans nothing. At 100 kS/s the ring covers 83.9 s and the probe's 500 ms → 500 s → 120 s → 600 s → 60 s walk crosses the boundary four times.Full measured readings (probe line first, then exit-dump):
=== base mock: none [reseq-probe] reseq=0 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 open_holes=0 bands=0 bands_repaired=0 nan_slots=0 scanned=44200 trigger_in_hole=0 [udpscope] frames=1032 packets=1033 gaps=0 channels=4 span=838.861s stride=1 reseq=0 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 [udpscope] channel 0 'sine' stored=95700 published=95400 === reorder mock: --reorder-every 10 [reseq-probe] reseq=51 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 open_holes=0 bands=0 bands_repaired=0 nan_slots=0 scanned=44200 trigger_in_hole=0 [udpscope] frames=1027 packets=1028 gaps=0 channels=4 span=838.861s stride=1 reseq=102 late=0 stale=0 overflows=0 holes=0 hole_slots=0 backfilled=0 [udpscope] channel 0 'sine' stored=95300 published=95100 === late mock: --reorder-every 10 --reorder-delay-ms 50 [reseq-probe] reseq=157 late=46 stale=0 overflows=0 holes=40 hole_slots=4000 backfilled=15600 open_holes=40 bands=40 bands_repaired=39 nan_slots=100 scanned=44000 trigger_in_hole=0 [udpscope] frames=1027 packets=1028 gaps=98 channels=4 span=838.861s stride=1 reseq=296 late=98 stale=0 overflows=0 holes=91 hole_slots=9100 backfilled=36400 [udpscope] channel 0 'sine' stored=95300 published=95000 === mixed mock: --drop-every 20 --reorder-every 10 --reorder-delay-ms 50 [reseq-probe] reseq=159 late=25 stale=0 overflows=0 holes=43 hole_slots=4300 backfilled=8400 open_holes=43 bands=43 bands_repaired=20 nan_slots=2200 scanned=44100 trigger_in_hole=0 [udpscope] frames=978 packets=981 gaps=101 channels=4 span=838.861s stride=1 reseq=309 late=50 stale=0 overflows=0 holes=94 hole_slots=9400 backfilled=18400 [udpscope] channel 0 'sine' stored=95400 published=95400 === re-plan mock: --rate 100000 --drop-every 20 --reorder-every 10 --reorder-delay-ms 50 [reseq-probe] reseq=4374 late=72 stale=0 overflows=36 holes=315 hole_slots=31136 backfilled=26800 open_holes=64 bands=64 bands_repaired=16 nan_slots=7800 scanned=156600 trigger_in_hole=0 [udpscope] frames=8949 packets=8953 gaps=614 channels=4 span=83.886s stride=1 reseq=8028 late=144 stale=0 overflows=36 holes=603 hole_slots=59936 backfilled=55600 [udpscope] channel 0 'sine' stored=588600 published=584600The
latecase'sbands_repairedis one short ofbandswithnan_slots=100— exactly one packet's worth of slots at stride 1 — because the probe fires 50 ms-delayed releases into a window that always has one in flight. An earlier recording of this case readbands_repaired=42 nan_slots=0; both are healthy, and which one a run gets is where the probe's instant falls relative to the newest release. Abands_repairedabove the count of holes whose slots hold values is the defect, in either direction.holesfar aboveopen_holesin the re-plan case is the two bounds working: a re-plan clears the table (the old entries address a buffer that is gone) andkMaxHolescaps what survives at 64. Themixedcase'sopen_holes=43againstbands=43has no such spread because nothing re-planned. The per-field reference is in probe-readings.md.
The mock also carries three fault injections that no probe schedules, because what they exercise
is the ingest rather than the UI. The first: --time-glitch-at <stream seconds> --time-glitch-by <seconds> makes
one packet's time signal jump ahead and then carries on from where the true timeline was, which
is what a device whose clock glitches puts on the wire. Run it with the trigger armed, e.g.
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=400 UDPSCOPE_ARM_TRIGGER_MS=700 UDPSCOPE_EXIT_AFTER_MS=22000 \
./build/udpscope --server 127.0.0.1 --port 8000 --holdoff 10
# mock: --rate 10000 --batch 100 --freq 5 --time-glitch-at 6.0 --time-glitch-by 10.0
Four readings, all off the once-a-second [udpscope] trig engine: line:
- exactly one
warning: ... jumped implausibly far ahead of the previous packet (16.01..16.0199 against a previous 6.0099); bridging that packet ...— the glitch was caught; - no
timing samples by arrival instead— one bad packet must not change the time source; now_tcrosses the glitch continuously (5.040 → 6.050 → 7.050), andhistorykeeps growing (1.01 … 23.15) instead of collapsing — the ring was not wiped;- the latches stay a hold-off apart:
edge at 1.060 / latched at 2.060 / holdoff_until=12.060, thenedge at 12.260 / latched at 13.260 / holdoff_until=23.260— 11.2 s for a 10 s hold-off plus a 0.9 s post window.
Before the fix the same run read a fallback warning, history collapsing 4.03 → 0.95, now_t
jumping 5.040 → 16.040, and two latches 1.2 s apart. Note the window here comes from the saved
session for that port; the figures above are the default 2 s window (50 % pre), so move
~/.config/udpscope/<host>_<port>.toml aside to reproduce them.
The other two are packet loss (--drop-every N, --drop-burst K) and wire inversion
(--reorder-every N). Loss withholds a datagram the counter and the stream clock have already
advanced over; the client counts the forward jump as counter_gaps. An inversion withholds a
packet until the one behind it is sent, so the receiver sees a later counter first; a packet that
arrives within the 20 ms reorder window is resequenced with no hole, and one that arrives past it
leaves a committed hole that it then backfills (UDPSCOPE_PROBE_RESEQ_MS exercises both paths).
There is no interpolation anywhere below the ring.
All of the readings below are off UDPSCOPE_EXIT_DUMP's [udpscope] frames= packets= gaps= line,
over an 8 s run against --rate 10000 --batch 100 --freq 5 (100 packets a second) except where the
burst needs the faster stream. gaps is the exact reading and repeats to the packet; frames
and packets move by one or two with the run's own timing. Run each of these on its own — four of
them back to back in one script starved the mock past the 2 s silence timeout and every case,
including the clean one, then read a reconnect instead of what it was testing:
- clean, as the reference:
frames=1029 packets=1030 gaps=0, no warning. --drop-every 10:frames=925 packets=926 gaps=102. Every tenth packet never reaches the wire, sopacketsfalls withframesandgapsis the count of them. No warning: a one-packet hole is a one-packet jump in the stamps, far inside the glitch guard.--reorder-every 10:frames=1028 packets=1029 gaps=0. The inverted packet lands inside the 20 ms reorder window and is resequenced: every datagram arrives andgapsis 0. The pre-resequencing signature wasframes=925 packets=1028 gaps=102— an inversion cost a counter gap and a discarded packet, which is exactly what resequencing removes. Adding--reorder-delay-ms 50pushes the arrival past the window and is the case theUDPSCOPE_PROBE_RESEQ_MSreadings above cover; it is not this row.--drop-every 1000 --drop-burst 200at--rate 100000 --batch 100(1000 packets a second):frames=7000 packets=7001 gaps=1600, eight bursts of 200, and still no warning.--drop-every 1000 --drop-burst 300, same stream:frames=6203 packets=6204 gaps=2400and the guard fires —warning: ... jumped implausibly far ahead of the previous packet (1.299..1.29999 against a previous 0.99899), thentiming samples by arrival instead. 300 packet spans is pastkGlitchPackets(256), so real loss is read as a clock glitch; and since each bridge advances the anchor by one packet span while the true timeline is 300 ahead, the next four packets fail too andkFailureLimitthrows the stream onto the arrival clock. A burst between 256 packets and the 2 ssilence_timeout_sis the only window in which this is reachable, and it is a finding, not a baseline.
Keep a burst under 2 s of wire time whatever the rate: past that the client reconnects, the mock
resets its counter and clock on the new CONNECT, and the run reads gaps=0 with a backwards stamp
warning (does not advance across packets ... 0..0.0099 against a previous 0.3899) — the reconnect
path, not the loss path.
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.
The noise probe needs a spiked mock
Every other probe runs against the plain mock. The noise probe reads what a band and an HF reject do to
impulsive interference, so its mock must have some: an alternating-sign impulse train of 2 samples at
amplitude 0.5 every 5 ms, added to sine alone (so a spiked channel can be read against a clean one in the
same run).
cd /home/martino/Projects/tools/udpscope
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 &
sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
UDPSCOPE_PROBE_NOISE_MS=4000 \
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
UDPSCOPE_PROBE_NOISE_MS is not the time its readings are taken. It times the probe's first stage, which
installs a math graph through apply_math_channels() -- and that is a structural rebuild that replaces the
buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
happen seconds before them, and the chain is what the number is really choosing: 4000 puts the graph in at
4 s, noise_probe at 9 s (it enables the signals it measures itself and waits 2.5 s for the ring to refill
after that change), the legs and the five measurements at about 11.5 s, the de-noise channel enabled at
14 s, and the de-noise reading at 16.5 s -- hence the 18 s exit and the 20 s mock. Collapsing the two
stages into one is the failure mode: a probe that rebuilt the buffer at 11.5 s and then scanned it
immediately would read bare=0 on every search leg, which looks exactly like a broken comparator.
Why 4 s and not 3. enable_all_probe's last act is a timeout of its own, ENABLE_ALL_MS + 2000 -- 3 s
under this recipe -- which walks signal_list for the front channel's acquire tick and clicks it. A probe
that installs a math graph at that same instant rebuilds the signal panel under it, and the off-step then
emits active on a freed GtkCheckButton: GLib-GObject-CRITICAL: instance with invalid (NULL) class pointer, with the backtrace in enable_all_probe()::{lambda}. It is a real fragility of that probe's lookup
and not of the feature, and it is why every documented probe time is offset from ENABLE_ALL_MS + 2000, not
merely from each other.
The de-noise channel is installed disabled and enabled only for its own reading (stage 3). An acquired
channel is a share of the ring, so a fifth one shortens every channel's retained span -- and the six leg
counts and pkpk bare all scale with that span. The readings above the de-noise line belong to the older
feature and are the proof it still works, so the new channel stays out of the acquisition plan until they
have run. --spike-every/--spike-amp/
--spike-width are the mock's own; --batch must stay at or below 1000 (2000 samples x 5 signals x f64 is
80 kB, past UDP's ~65507-byte payload, and the mock dies on its first sendto while the run still exits 0).
The three lines and what each must read are in probe-readings.md.
UDPSCOPE_PROBE_NOISE_MS, UDPSCOPE_PROBE_MEASURE_MS and UDPSCOPE_PROBE_MATH_MS must not be scheduled
in the same run. The noise and measurement probes both push onto app.measures, and the later one would read
the earlier one's rows as its own; the noise and math probes both write app.math, so the later one's graph
lands on top of the earlier one's and both sets of readings describe a state neither probe set up. The noise probe carries
its own measurement legs (see probe-readings.md), so nothing is lost by keeping them apart.