fix: a clock glitch is bridged and a channel is known by name, so a capture keeps its t0

Two defects behind one report ("a trigger during a capture overrides the first,
and the hold-off does not apply"); the second is also what made a spike in one
channel look like the acquisition resetting.

The trigger engine keyed its invalidation on the trigger channel's *index*, which
every rebuild of the channel list renumbers -- enabling a signal that sorts before
it is enough. Any rebuild therefore abandoned an open capture and set no hold-off,
so the next edge inside the post window became t0. It keys on the channel's name
now, which changes only when the source genuinely does.

A packet whose stamps jump more than 256 packet spans ahead is now rejected as a
fault and *bridged* at the declared cadence, instead of poisoning the monotonic
mark (which pinned every later packet flat at the glitched value) and condemning
the next good packet. Only four consecutive failures abandon the declared time
signal, and that fallback no longer wipes the rings: the arrival clock is anchored
to continue from the monotonic mark, so the history is still on one timeline --
the wipe is what made one glitching packet look like the stream restarting from 0.
The declared unit is resolved before those checks, since they weigh the stamps
against a cadence in seconds.

Guards: UDPSCOPE_PROBE_TRIGRENUM_MS prints t0_held=1 through the real widgets,
and the mock's --time-glitch-at/--time-glitch-by injects the clock fault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-07 10:29:05 +02:00
1 parent 4003084648
commit f179fbbb44
13 files changed
+406 -96

No files matched your search

+57 -7
View File
@@ -488,6 +488,37 @@ fallback when the stream provides no usable time signal (it is then the sample's
via the arrival estimate); it is never used when the stream's own time signal
carries a span.
**One bad packet is bridged; only a run of them changes the clock.** A real device's
clock glitches, and a packet whose stamps jump far ahead of the previous packet's last
is a fault rather than a gap — so a jump of more than **256 packet spans** is rejected
alongside the three checks that were always there (a stamp that is not finite, a packet
with no span at all, a packet that does not advance past the previous one). A rejected
packet is **bridged**: its stamps are replaced by a continuation of the accepted timeline
at the declared cadence, so the next good packet is weighed against an un-poisoned
anchor and is accepted normally. Only **four consecutive** rejected packets abandon the
declared time signal for the arrival clock, because that is what a signal which is not a
time base at all looks like. The declared unit (`ns`, `us`, `ms`) is resolved *before*
these checks rather than after, since they weigh the stamps against a cadence in seconds.
Believing a glitch instead is what produced *"a big spike in one channel and the
acquisition seems to reset"*. The stamps pass through a monotonic clamp, so a glitched
value poisoned the mark and pinned every later packet flat at it — the stream froze at one
x and never recovered — and the glitch also condemned the *next*, perfectly good packet
(now below the poisoned anchor, so it failed the advance test), which threw the whole
stream onto the arrival clock. `tools/mock_udpstreamer.py --time-glitch-at <s>
--time-glitch-by <s>` injects the fault; with a 10 s hold-off the two latches either side
of it are 11.2 s apart, where before the fix they were 1.2 s apart.
**Switching to the arrival clock does not drop history.** `arrival_epoch_` is derived from
the monotonic mark, so the arrival clock *continues* from it and the two timelines share a
zero by construction — the recorded stamps are therefore still on one timeline and are
kept. They used to be wiped on the reasoning that the old stamps belonged to a dead clock;
they do not, and that wipe is what made a single glitching packet look like *"the live
stream restarts from 0 with no change from the user"*. The same continuity is why
`TriggerEngine` needs no rebase across the switch: it holds four absolute stamps
(hold-off, pending edge, last trigger, last sample) and they stay valid. Anything that
makes the clock genuinely *discontinuous* would break that and would have to reset them.
**Trigger: what freezes is the view, not acquisition.** `TriggerEngine` runs the
edge/hold-off/normal-single state machine on the selected channel, and once the
requested post-trigger share of the *event span* (the event-window control, not the
@@ -577,6 +608,23 @@ after a rebuild — a CONFIG, enabling the signal, or a calibration change all d
history — which is exactly when the first event used to latch on an almost empty pre
window and draw half a plot.
**The engine identifies its trigger channel by name, never by index**, and that is what
makes the hold-off hold. `TriggerEngine::configure()` drops an open capture when the
trigger moves to a different channel, and it is re-called on *every* packet. The config's
channel field is an index into the channel list, and that index is not stable: every
rebuild of the list renumbers it — enabling a signal that sorts before the trigger's own
is enough — and the model then 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 set no hold-off**, so the next edge inside the post window became t0: with a 1 s
window, a 10 % pre share and a 10 s hold-off, an edge 0.6 s into a capture stole it. That
needed no glitch and no user action beyond enabling a channel. A deliberate re-point still
resets, because the name changes with the source. Since the rebuild also replaces the
buffer, the surviving capture's pre side is short and the snapshot reports its own `pre_s`
— which may be **negative** when the new ring starts after the edge. That is a faithful
reading rather than a defect: `[-pre_s, post_s]` still describes the samples the snapshot
holds. `UDPSCOPE_PROBE_TRIGRENUM_MS` drives the whole sequence through the real widgets
and prints `t0_held=1` as the assertion.
The event is published on the sample that latched it, but only after every channel of
that sample has reached its ring: the cut is clamped to the shortest value ring, so
publishing from the middle of the channel loop dropped the newest sample — the very one
@@ -1833,13 +1881,15 @@ now — they are listed so they are not mistaken for anything else:
- **The time reference is the first *enabled* channel's signal.** A CONFIG whose
time signal belongs to a signal that is disabled contributes no stream time, and
the app falls back to arrival-based timing (with a warning on stderr).
- **The choice of time source is sticky, and a change of source empties the rings.**
It is decided once (and re-decided on a CONFIG) and then kept: choosing per frame
let a stream whose declared time signal fails one check and passes the next
alternate between two timelines, so one ring held stamps from both and the x axis,
the crosshair and an export read nonsense — a plateau of one timestamp, then a jump
to another epoch. On the switch the recorded history is dropped rather than left
half on each clock. The stamps themselves are spaced by the stream's *measured*
- **The choice of time source is sticky.** It is decided once (and re-decided on a
CONFIG) and then kept: choosing per frame let a stream whose declared time signal
fails one check and passes the next alternate between two timelines, so one ring held
stamps from both and the x axis, the crosshair and an export read nonsense — a plateau
of one timestamp, then a jump to another epoch. The switch keeps the recorded history,
because the arrival clock is anchored to continue from the monotonic mark and the two
timelines therefore share a zero (see "Switching to the arrival clock does not drop
history" above); it used to empty the rings, which is a worse reading of the same
concern. The stamps themselves are spaced by the stream's *measured*
arrival cadence whenever the declared rate disagrees with it, because a CONFIG's
rate is a claim the stream may not keep (one that declared 1 MS/s and delivered a
hundred samples every fifth of a second made every frame span microseconds while