fix: a clock that stalled on a re-plan, and a dialog that could not close

**The clock.** Trigger mode showed a blank plot and never latched when the session defined a math
channel, because such a session gives the model a plan *before* the stream's own signals are in it:
one derived channel, declared rate 0, so `plan_partition()`'s 1 kHz fallback is the interval those
early frames are stamped at -- 1300x too fast for a 1.3 MS/s stream -- and they have nowhere to go,
since the value loop skips derived channels. Relative mode then stamped a *position* times the plan's
*current* interval, so when the CONFIG's channels landed and the plan took the real rate (769 ns) the
same positions produced times 1300x *smaller* than what was already in the ring, and the monotonic
clamp held the clock at the old value for the rest of the run: one repeated stamp, `history=0`, no
latch, and a window the renderer cannot draw. The same family as the restarted-client poisoning
fixed in 620473a, through a different door. The filter's own output was finite and correct
throughout, and disabling the math channel only appeared to cure it because that is a structural
change which rebuilds the store -- the third time that channel has looked guilty while being a
bystander.

Relative mode now accumulates *seconds* (`rel_base_s_ += n * dt`), so a re-plan is a slope change and
the clock cannot jump backwards by construction; a frame is only stamped into a model that has a
*streamed* channel; and the epoch is adopted once per connection, with "unset" a state rather than
the value zero. A one-shot warning names the family if it returns: `the clock stalled at ...: N
samples stamped [a..b] are all behind it`.

**The dialog.** The Configurations window's Close button and the window-manager close both did
nothing: `close-request` was connected to a bare `void` lambda, and that signal is `gboolean` -- TRUE
means "handled" and suppresses the default handler that destroys the window, while `G_CALLBACK` is a
cast, so GTK read whatever the compiler left in the return register. A standalone GTK program of that
shape reproduces it: `void` handler -> `window_alive=1` after `gtk_window_close()`, `gboolean FALSE`
-> 0. The same audit found `gtk_window_present(GTK_WINDOW(...))` on an `AdwDialog`, a type assertion
and a no-op; it is `adw_dialog_present()` now. The menu row stays: it is the only way to switch or
save a configuration without restarting, and what made it look useless was that it could not close.

Verified: trigger mode with that session's graph reads `ring=[0.0058..0.2446] -> [0.0058..1.9969]`,
`history 0.237 -> 2.0 s`, `edge at 1.120 s, latched at 2.120 s`, `display=event(frozen) latches=1`,
`traceless paints 266` against 1519 before the fix. Baselines hold: 0 errors, the documented three
warnings, test-ui 13 ok, test-help green, `gtk warnings: 0`.
This commit is contained in:
Martino Ferrari committed 2026-10-15 10:50:09 +02:00
1 parent 27c9b34277
commit 074cb611bc
9 files changed
+210 -34

No files matched your search

+33 -6
View File
@@ -172,7 +172,7 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **A reservation is weighed against the wire, and never booked on the counter's word alone.**
The counter is a *claim* about the stream; the arrival clock is a *measurement* of it, and only
the two together justify writing NaN into the ring (`Acquisition::gap_plausible()`, called
before anything is booked *and* before relative mode's `slot_base_` advance, because the advance
before anything is booked *and* before relative mode's own clock advance, because the advance
and the reservation are the same claim). Two tests, either one refusing is enough:
- **The arrival clock.** `missing` packets would have taken `missing` frame intervals to arrive,
so a gap whose elapsed arrival time is under half of that is refused. Half and not one because
@@ -206,9 +206,36 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`backfill_from()` derives a slot index from — without it the amend falls outside the ring
and repairs nothing while the hole is still marked repaired, a band saying the opposite of
the truth. The generation is the signal and the stride/channel-count comparison is not: a
reconnect with an identical CONFIG rebuilds the store leaving both unchanged. `slot_base_`
is **not** reset — it is relative mode's clock origin, samples since the stream began, and
relative time must not jump backwards when the user retypes the window.
reconnect with an identical CONFIG rebuilds the store leaving both unchanged.
- **The relative clock is accumulated in *seconds*, and a frame is only stamped into a model that has a
streamed channel.** Relative time's stamp is the declared interval counted from the stream's own
origin — `Acquisition::rel_base_s_`, which is advanced by the frame's own samples and by the samples a
reported gap spans. It is deliberately **not** `position × Partition::base_dt`, and the difference is
one defect: a plan is rebuilt whenever the channel list changes (a CONFIG, an enable/disable, a
session's math channels), and `base_dt` is the **1 kHz fallback** until a *streamed* channel declares a
rate. Recomputing from a position made a plan that *shortened* the interval put every later stamp
behind the one already written, and the frame loop's monotonic clamp then held the clock there for the
rest of the run. What that looks like: `ring=[a..a]` standing still while `n` climbs,
`history` never reaching the pre-window (so a trigger never latches and the display stays in `Waiting`,
which is blank), and — the reading that names it — `[udpscope] warning: the clock stalled ...`, printed
once per connection the first time a whole frame's stamps are all behind the clock. Accumulated, a plan
change is only a slope change and the clock cannot go backwards. The entry is the *other* half: the
ingest used to accept a frame as soon as `channels()` was non-empty, and a session's math channel
applied before the stream's own signals arrive is exactly one channel with a fallback plan. Such a
frame has nowhere to go (the value loop skips every derived channel) and its plan's interval is not the
stream's rate, so `on_data()` now requires a **streamed** channel and drops the frame otherwise, the
same way it always did for an empty list. `rel_base_s_` is **not** reset across a reconnect — the
origin survives, it is relative mode's clock origin — while `base_dt`'s *use* is what this rule took
away: it is read to *advance* the clock, never to recompute where the clock already was.
- **The stream's epoch is adopted *once per connection*, and "unset" is a state, not the value zero.**
`epoch` is the stream time the display's x = 0 means, taken from the first frame's stamp. The test for
"not adopted yet" used to be `model.epoch() == 0.0`, which relative mode breaks by construction: its
first frame is stamped exactly `0`, so the *second* frame adopted the origin — and with
`rel = max(stamp - epoch, last_stamp_)` that put a whole frame's samples on one stamp and shifted every
later x by a frame's span. `Acquisition::epoch_adopted_` is the flag, re-armed by
`reset_session_state()` because a new session's clock is a new clock; the warning line above is what
caught it.
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
the published window to the value ring's base and both rings keep sliding afterwards —
their capacities are equal, so there is no slack — which means the oldest few samples
@@ -294,8 +321,8 @@ already scaled against, and re-anchoring mid-stream would move x. The clock *cho
per connection (`time_source_ = 0` on CONFIG) for the same reason: a session that dropped may
come back with a different answer.
Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's
chosen origin, and must not jump backwards because the link blipped.
Relative time's clock (`Acquisition::rel_base_s_`, seconds accumulated at the declared interval)
also survives: it is the user's chosen origin, and must not jump backwards because the link blipped.
## There may be no store at all