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

+25 -6
View File
@@ -155,12 +155,22 @@ class Acquisition {
/// signal carrying volt-like values, others from the arrival clock -- which is a
/// time base no reader can make sense of (see `build_stamps`).
int time_source_ = 0;
/// Relative-time mode's position counter: samples seen since the stream began, which is
/// what the counter names. Advanced by `batch_` for every packet the counter reports
/// missing (before the frame's own samples) and then by the frame's own `n`, so it is
/// exactly `counter * batch + sample_index` for a constant batch and degrades gracefully
/// when a frame's length disagrees.
std::uint64_t slot_base_ = 0;
/// Relative-time mode's clock: seconds since the stream began, accumulated at the interval
/// the plan declares. Advanced by `lost * batch_` samples' worth for every packet the counter
/// reports missing (before the frame's own samples) and then by the frame's own `n`, so it is
/// exactly `(counter * batch + sample_index) * dt` for a constant batch and degrades
/// gracefully when a frame's length disagrees.
///
/// It is a **time**, not a position multiplied by the current plan's interval, and the
/// difference is the whole of one defect: a plan is rebuilt whenever the channel list changes
/// (a CONFIG, an enable, a session's math channel) and `Partition::base_dt` -- which is the
/// 1 kHz fallback until a *streamed* channel declares a rate -- can therefore change under a
/// running stream. With `position * dt` a plan that shortened the interval made every later
/// stamp *behind* the one already written, which the frame loop's monotonic clamp then held
/// for the rest of the run: the timeline stood still, the trigger's `history` never reached
/// its pre-window, and nothing was drawn (see `on_data`). Accumulated, a plan change is what
/// it is -- a slope change -- and the clock cannot go backwards.
double rel_base_s_ = 0.0;
/// Samples per DATA packet, fixed by the first frame. Only ever needed to size what is
/// *absent*: a delivered frame carries its own length. 0 until the first frame.
std::uint32_t batch_ = 0;
@@ -186,6 +196,15 @@ class Acquisition {
/// Warn-once latch for the declared time signal failing for good, so the arrival clock is
/// taken instead.
bool warned_time_source_ = false;
/// Warn-once latch for a frame whose every stamp was held back by the frame loop's monotonic
/// clamp. The clamp exists for a stream clock that glitches; a *whole frame* behind the clock is
/// the timeline standing still, and while it lasts nothing is drawn and no trigger can latch.
bool warned_clock_stall_ = false;
/// Whether this connection has adopted its clock origin (`ScopeModel::set_epoch_if_unset()`).
/// A state and not `epoch() == 0.0`, which re-fires in relative mode -- where the first frame's
/// stamp *is* zero -- and moves the origin under the timeline. Per connection, like every other
/// piece of session state here: a new session's clock is a new clock.
bool epoch_adopted_ = false;
/// The re-plan generation this object last reacted to. The buffer is re-planned whenever the
/// model rebuilds it, and the state tied to the old store (`tick_` and the accumulators) is
/// reset when this falls behind `ScopeModel::plan_generation()`.