fix: a recompute that renumbered its ring, and readouts frozen under a zoom

Three defects, all of them a window describing data that is not there.

A math edit refilled the derived ring with `clear()`, which renumbers it
from 0, leaving `produced()` short by the inputs' retained base. Since
`live_window_locked()` clamps against every channel's `produced()`, that
one lagging channel answered `n = 0` for the live view, every pause view
and every trigger capture until a whole ring of fresh data had gone by --
the reported freeze, invisible until the ring first wraps, hence its
intermittence. It now restarts with `clear_to(base)`, and
`ChannelBuffer::republish_from(base)` puts the published window back:
`acquire()` is incremental, so a rewrite in place published nothing and
the pyramid went on describing the samples the edit replaced.

The trigger's channel is an index, re-resolved by name after a renumbering
-- but only in `rebuild_enabled_locked()`, so a math add or delete went
through `set_math_channels()` with no restore, the index was clamped to
-1, and `process()` returned at once while the panel still named the
channel. The restore moves into `rebuild_channels()`, which *is* the
renumbering, so no caller can forget it.

And `update_measures()` suppressed its "the window moved" test while an
event was held -- the one mode where a gesture is the only thing that can
move it. Zooming a latched event left the pane describing the whole
captured event. A pinned window needs no exemption: it lands on the range
it already measured and fails the 2 % test by itself.

The checks that missed all three: `UDPSCOPE_PROBE_MATHWRAP_MS` edits a
graph whose ring has already wrapped, which `math_probe` structurally
cannot reach; `[measure-trace]` now prints the mode, the window on screen
and the window the numbers describe; and the dump's `marks_in_window`
counts against the window on screen instead of against the one the marks
were computed over, where it was a tautology.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-15 16:30:48 +02:00
1 parent 074cb611bc
commit d91957a2fe
15 files changed
+374 -45

No files matched your search

+12
View File
@@ -209,6 +209,18 @@ 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.
`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](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
+22
View File
@@ -323,6 +323,28 @@ the assertion: `finite` must be about `M` minus the hole share, and `finite=0` b
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
while the edit runs -- and never 0.
- **A recompute rewrites a ring *in place*, and both of the ring's counters have to survive it.**
`recompute_math_locked()` replays the inputs' **retained** window, which begins at the time ring's
`base()` and not at zero, so an emptied derived ring is restarted with `clear_to(base)` and never
`clear()`. With `clear()` the refill renumbered the channel from 0 and left its `produced()` short by
exactly `base` -- and `live_window_locked()` clamps its window against *every* channel's `produced()`,
so that one lagging channel answered `n = 0` for the live view, every pause view **and** every trigger
capture, until a whole ring of fresh data had gone by. That is the "a math edit freezes acquisition,
and the trigger stops firing on a channel that is not even derived" report, and it is **invisible
before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads
as intermittent and uptime-dependent. The dump's `math '<name>': N samples vs M time slots` is the
reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose
ring has wrapped: give it a small `--buffer-mb`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state:
every edit that probe makes follows a structural change, which replans the buffer and restarts every
ring at absolute 0, where both halves of this are identities.
The published window is the second counter. `acquire()` is incremental, so a rewrite that leaves
`produced()` where it was publishes **nothing** and the pyramid -- what the renderer and every
measurement read -- goes on describing the samples the edit replaced. `ChannelBuffer::republish_from(base)`
is what puts it back, and it takes the ring's base rather than 0 because `invalidate()` would make the
next `acquire()` walk every absolute index ever produced (an hour at 1 MS/s is 3.6 G appends) to publish
one window.
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
+11
View File
@@ -20,6 +20,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
the window (the flat-level invariant below). **Adding a type must not add a pass** -- that is
what `compute_measure()`'s `stats` argument is for.
**The "it moved" test is asked in every mode, a held event included.** Suppressing it while
held (`moved = ... && !held`) is the shape the pinned-window fix first took, and it is wrong
for the one mode where a *gesture* is the only thing that can move the window: a latched event
zoomed forty times over left the pane describing the whole 2 s event with `passes` frozen,
which is the reported "measurements move when zooming or panning on data" -- the numbers stand
still while the data under them moves. Nothing is needed for the pinned case anyway, because a
window that is merely re-pinned satisfies `swin == measure_window` exactly and fails the move
test on its own; a pause is the standing proof, since `display_mode()` reports `Paused` rather
than `Held` for one and the statistics probe already requires `n` to advance by at most one
across a two-second pause. The held/live state is still a recompute of its own, so a mode
change is not missed.
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
@@ -52,8 +52,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
`ScopeModel::set_signal_enabled()` re-resolves the trigger by name and writes the *new* index
back. Keying the reset on the index therefore made any such rebuild abandon the capture **and
**`ScopeModel::rebuild_channels()` re-resolves the trigger by name and writes the *new* index
back, for every caller.** It lives there and not at the call sites because the function *is* the
renumbering, and a caller that forgot is a trigger that silently stops: the name-based restore
used to be in `rebuild_enabled_locked()` alone, so adding or removing a math channel went through
`set_math_channels()`'s structural path with no restore at all, `rebuild_locked()` clamped the
now-wrong index to `-1`, and `process()` returns at once on a channel of `-1` while the panel went
on naming the channel. That is the second half of the "it sometimes does not trigger, even with
the trigger on a non-math channel" report. Keying the reset on the index made any such rebuild
abandon the capture **and
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
and no user action beyond enabling a channel. The name changes when the source genuinely
+47 -4
View File
@@ -259,13 +259,27 @@ Two lines of the self-check output are assertions in disguise:
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
would read no drift whatever the data did.
would read no drift whatever the data did. `win` is the window as of the last **tick**
(`App::measure_view_window`) and is deliberately not read back here: the dump runs from the
destroy handler, by which point the exit probe has switched the trigger on, and
`stream_at_x0()` then answers for a mode no frame was ever drawn in -- which read as a 45000 %
drift on a run whose every frame was exact. And the count is against `win`, not against
`measure_window`, which contains every mark by construction: read that way it was a tautology
that printed `2/2` while the pane described a window a forty-fold zoom away from the one
displayed. On a window **shorter than the refresh interval** the count rides the same sawtooth
`drift` does -- the 50 ms window the zoom sweep leaves behind can lag far enough that *every*
time mark is off screen, so `0/K` there is read beside `drift` and not alone. The strict
reading belongs to the documented 2 s run, and to a **held** window in any run: a latched
event does not scroll, so `[measure-trace]` must show `for=` equal to `swin=` exactly for as
long as the mode stays `held`, zoom and pan included.
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window
at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a
50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
counts **twice** and the top of the sawtooth is `2 × (refresh + one frame) / window` -- the
frame term is not noise under `xvfb`, where a frame costs 30--70 ms: ~10 % on a 2 s window at
the 100 ms cadence with instant frames, and **15.8 %** measured on the documented run, which is
one interval plus one 58 ms frame. On a window shorter than the interval it is a few *hundred*
percent (a 50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
for the window the run actually used before calling a figure large. A `0.0000%`
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
@@ -1947,6 +1961,35 @@ channels and an edit does not move it, so the store survives: the count *grows*
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
## `[mathwrap-probe]`
```
[mathwrap-probe] installed 'math wrap' on 'sine': channel=1
[mathwrap-probe] wrapped: base=864 slots=66400 (want base > 0)
[mathwrap-probe] after edit: produced=72400 slots=72400 (want equal) y=[-0.5000,0.5000] (want +-0.5, was +-2.0000)
```
`UDPSCOPE_PROBE_MATHWRAP_MS`, and it must be run with a **small `--buffer-mb`** and given time to
saturate it: `--buffer-mb 1` against `tools/mock_udpstreamer.py --rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`, reads the lines above. It installs one
`Gain` graph of 2 over the mock's unit sine, polls (100 ms) until the ring has **wrapped**, and only then
edits the gain to 0.5 — the same channel list with one node changed, which is the in-place recompute and
not a replan.
`base > 0` on the second line is the precondition and not a result: at `base == 0` both readings below are
identities, so **`base=0` means the run tested nothing** — it was too short, or the buffer too large. This
is also why `math_probe` cannot stand in for this one: every edit it makes follows a structural change,
which replans the buffer and restarts every ring at absolute 0.
The third line is one reading per half of `recompute_math_locked()`. `produced` against `slots` is the
derived ring's absolute count: short by exactly `base` is the renumbering `clear_to()` exists to prevent,
and `live_window_locked()` clamps against *every* channel's `produced()`, so that one lagging channel is
`n = 0` for the live view, every pause view and every trigger capture — the reported freeze. `y` is the
**pyramid's**, so it describes the published window rather than the samples, and the edit *shrinks* the
gain for that reading to exist: a pyramid left unpublished still describes the replaced ±2 samples, a
window holding both quotes the larger, and a *growing* gain would therefore read correct either way.
`y=[-2,2]` here is `republish_from()` missing.
## `[noise-probe]`
```
+35 -12
View File
@@ -96,11 +96,20 @@ precedent, not an exception.
overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for
each, and the cost of the histogram are in
[invariants-measurements.md](invariants-measurements.md).
`[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 print `marks=0` while measurements with `show_on_plot` are on and the
data is there -- that was the rulers blinking off at a mode change.
`[measure-trace] mode=M view=[..] s0=S swin=[..] for=[..] passes=N <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. `swin` is `view` moved onto the stream's clock by
`s0` (`stream_at_x0()`), and `for` is the window the printed numbers were computed over, in the
same clock -- so a reading that moved while `view` stood still is a change of `s0` (a new
snapshot under the same mode) and not a stale pass, and only these three fields tell the two
apart. `passes` beside them says whether a recompute answered the move. **While `mode=held`,
`for` must equal `swin` exactly**, through any number of zooms and pans: a latched event does
not scroll, so there is nothing for a recompute to be late for. That reading is where
the measurements standing still under a gesture was found -- `for=[0.47..2.47]` against a
`swin=[1.417..1.467]` with `passes` frozen, the pane describing the whole 2 s event while a 50 ms
slice of it was on screen. The line must also never print `marks=0` while measurements with
`show_on_plot` are 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 sample` follows it: **read it against a run with
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
@@ -283,9 +292,14 @@ as distinct from the fault injection below, which exercises ingest:
conditions for the first four: the mock at `--rate 10000 --batch 100 --freq 5`, udpscope with
no extra options, under `xvfb-run` with
`UDPSCOPE_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.toml` first: a
session left by an earlier run restores its math channel, and five channels instead of four
change `channels=`, `span=` and every count derived from them.
UDPSCOPE_PROBE_RESEQ_MS=5000`. Move `~/.config/udpscope/127.0.0.1_8000/` aside first (the
per-endpoint session is a *directory* now -- `active` plus one `.toml` per named configuration;
a bare `127.0.0.1_8000.toml` is a pre-migration leftover): a session left by an earlier run
restores its math channel, and five channels instead of four change `channels=`, `span=` and
every count derived from them. It restores the **window and the measurements** too, which is
the subtler trap: a run after a zoom-sweep probe starts at `window_s = 0.05` with the previous
runs' measurements still in the pane, so `measures window:` then reports a short-window
sawtooth (144 %, marks off screen) on a run that asked for none of it.
| case | mock extra flags | key reading |
| --- | --- | --- |
@@ -328,6 +342,14 @@ as distinct from the fault injection below, which exercises ingest:
and **`finite=0` beside a non-zero `hole_slots` is the defect** (a biquad that integrated the
hole's NaN, permanently). The filters' `sd` must stay near the no-drop value; run the same
command without `--drop-every` as the control, where every `finite` reads 4000.
- **A math edit on a ring that has already wrapped.** `UDPSCOPE_PROBE_MATHWRAP_MS` — installs one
`Gain` graph over the mock's sine, waits for the ring to **saturate**, and only then edits the gain,
which is the one state `UDPSCOPE_PROBE_MATH_MS` cannot reach (each of its edits follows a structural
change, and a replan restarts every ring at absolute 0). It therefore needs a **small buffer and a long
run**: `--buffer-mb 1` against `--rate 20000 --batch 200`, with
`UDPSCOPE_PROBE_MATHWRAP_MS=1500 UDPSCOPE_EXIT_AFTER_MS=25000`. `base > 0` on its second line is the
precondition — **`base=0` means the run tested nothing** — and the third line's `produced == slots` and
`y=±0.5` are the two assertions; both are in [probe-readings.md](probe-readings.md).
- `UDPSCOPE_PROBE_CONFIGS_MS` — saves the running session as a configuration named `probe` with a
note, through `save_configuration_as()` (the same entry point the dialog's button uses), then
reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the
@@ -615,7 +637,7 @@ Four readings, all off the once-a-second `[udpscope] trig engine:` line:
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.
`~/.config/udpscope/<host>_<port>/` 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
@@ -759,9 +781,10 @@ have run. `--spike-every`/`--spike-amp`/
The three lines and what each must read are in `probe-readings.md`.
`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS`, `UDPSCOPE_PROBE_MEASURES_MS`,
`UDPSCOPE_PROBE_BANDAVG_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
in the same run. The noise, measurement, measurements and band-average probes all push onto
`UDPSCOPE_PROBE_BANDAVG_MS`, `UDPSCOPE_PROBE_MATH_MS` and `UDPSCOPE_PROBE_MATHWRAP_MS` must **not** be
scheduled in the same run. The noise, measurement, measurements and band-average probes all 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
the earlier one's rows as its own; the noise, math and math-wrap probes all 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.