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
+11 -2
View File
@@ -116,11 +116,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the `help:<document>` URI to the desktop help viewer (`GtkUriLauncher`, i.e. Yelp) — a process of
its own that cannot be closed from here — because the manual is a Mallard set installed for the
freedesktop `help:` URI, not a GResource in this binary (see the help bullet below). So there is
exactly **one `GtkWindow`** among what the app opens: the shortcut overview. It closes with
exactly **one `GtkWindow`** among *that pair*: the shortcut overview. It closes with
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
next attempt to open the same window. The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
next attempt to open the same window. **A `close-request` handler's return value is load-bearing
too, and its absence is silent**: the signal is `gboolean`, `TRUE` means "handled" and suppresses
the default handler that destroys the window, and `G_CALLBACK` is a cast — so a `void` lambda in
the slot compiles without a warning and GTK reads whatever the compiler left in the return
register as the verdict. The Configurations dialog (`open_configurations()`) was written that
way, and the built binary returns non-zero from the lambda, so **both its Close button and the
window manager's close button emitted `close-request`, were told "handled", and the dialog stayed
up** — "the configurations dialog cannot be closed", which no reading of the callback's type can
see. Every `close-request` handler therefore returns `FALSE` explicitly (the chooser's
`on_chooser_close()`, the overview's `on_window_close()`, the math dialog's, and this one). The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being
no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About
pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler
@@ -197,6 +197,24 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
all three, or the marker, the cut and the gate would disagree with each other by the width of
a pulse.
- **The trigger's whole input is the stored clock, so a clock that stops is a trigger that never
fires — and *that* is what a blank trigger-mode display is.** `Acquisition` feeds
`TriggerEngine::process(value, rel)` with the same `rel` it stamps the ring with, and the engine
has no other clock: the pre-window gate measures the event against the retained history
(`event_t - oldest_s_ >= pre_seconds_`), the post window counts *stream seconds* past the edge,
and `holdoff_until_s_` is set from `now_s` at the arm. A timeline that stands still therefore
reads as `history=0.0000/1.0000 s` for ever, on every line of `trig engine:`, no matter how many
samples arrive — and with no latch, `display_mode()` never leaves `Waiting`, whose bind is
*nothing* by design, so the plot is blank and the other traces are blank with it (the renderer's
bound window is empty too, so the same emptiness shows with the trigger off). Nothing about that
picture says "clock": it is "in trigger mode, with a math channel enabled, the data disappears",
which is how it reached a user. The reading is the `trig engine:` line's own pair — **`n` climbing
while `ring=[a..a]` stands still** — and the one-shot
`[udpscope] warning: the clock stalled ...` that `on_data()` prints at the first whole frame whose
stamps are all behind the clock. See
[invariants-data-path.md](invariants-data-path.md) for the two entries (a plan whose interval
moved under the stream; a frame accepted into a model with no streamed channel) and for why the
relative clock is accumulated in seconds.
## A latch is also a file
+21
View File
@@ -1886,6 +1886,27 @@ rate whenever data arrives at all:
from an event view: `ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000`, one second apart, with
`now_t` and `holdoff_until` both pinned at `2.520` — the file view's last stamp. The producer's clock was
frozen, not lagging, and the display stayed blank until something re-planned the store.
**It has a second entry, and one of its own readings names it**: under `--relative-time` a re-plan that
*shortened* the plan's interval put every later stamp behind the clock the run had already reached, and
the frame loop's clamp held it there (see the relative-clock invariant in
[invariants-data-path.md](invariants-data-path.md)). The line below is printed once per connection the
first time that happens, so a run that reads it has its whole diagnosis on one line — and the *absence*
of it beside a `ring` that advances is the reading that a re-plan no longer moves the clock:
```
[udpscope] warning: the clock stalled at 0.000384 s: 500 samples stamped [0.000000..0.000384] s are all
behind it, so the timeline stands still while frames keep arriving -- nothing can be drawn and no trigger
can latch. The plan's interval is 7.69230769e-07 s now; ring n=500
```
`history=0.0000/<pre>` on every `trig engine:` line beside a `n` that climbs is the same state seen from
the engine's side, and it is what makes a *trigger-mode* display blank: with no latch the display never
leaves `Waiting`, whose bind is nothing by design. Measured on the mock at 1.3 MS/s with a session that
defines a LowPass channel, in single mode: before the fix `ring=[5.4990..5.4990]` for the whole run,
`latches=0`, `display=nothing`, and the dump read `traceless paints: 1519 (1519 after the first bind)`;
after it, `ring` runs `[0.0058..0.2446] → [0.0058..1.9969]`, `history` reaches the pre-window,
`latches=1` and the frozen event reads `n=2599999 data=[-1.0000..1.0000]`. The same run with the math
channel disabled never showed it at all — which is the control that names the entry, not the filter.
- **`ring` advancing while `now_t` does not** is the *feed*: the store is moving and the engine is not being
handed the samples, which is a trigger-path question and not a clock one.