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:
1 parent
27c9b34277
commit
074cb611bc
9 files changed
+210
-34
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user