diff --git a/README.md b/README.md index 9eec928..2b9fb68 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,18 @@ the stream's own clock. The packet counter says exactly how many samples each ga reserved hole is a precise arithmetic range. In stream mode the same hole is estimated from the declared cadence, which is the existing glitch-bridge mechanism reused. +**The relative clock is a time, not a position times a rate.** The buffer plan is rebuilt whenever +the channel list changes — a CONFIG, an enable, a session's math channels — and the interval that +plan declares is the **1 kHz fallback** until a *streamed* channel declares a rate. A clock +recomputed as `position × interval` therefore jumped whenever the plan moved, and a plan that +*shortened* the interval put every later stamp behind the one already written, where the frame +loop's monotonic clamp held it for the rest of the run: `ring=[a..a]` standing still while `n` +climbed, `history` never reaching the pre-window, no latch, and a blank plot in every mode. The +clock is now accumulated in seconds — a plan change is a slope change — and a frame is only +stamped into a model that has a **streamed** channel, so the fallback interval can never become a +run's origin. The stall it used to cause is named in one line, once per connection: +`[udpscope] warning: the clock stalled at ... s: N samples stamped [...] are all behind it`. + The history cost of relative mode is measured rather than computed: | mode | retained span | diff --git a/docs/claude/invariants-data-path.md b/docs/claude/invariants-data-path.md index 0ab1c22..aa1e1c1 100644 --- a/docs/claude/invariants-data-path.md +++ b/docs/claude/invariants-data-path.md @@ -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 diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 12c6c29..552231b 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -116,11 +116,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear the `help:` 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 diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index b9fdd31..bf72173 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index bb3f070..19a76ba 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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/
` 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.
 
diff --git a/src/acquisition.cpp b/src/acquisition.cpp
index bd2f3e1..51c21a1 100644
--- a/src/acquisition.cpp
+++ b/src/acquisition.cpp
@@ -206,6 +206,7 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
   warned_phantom_gap_ = false;
   warned_bridge_ = false;
   warned_time_source_ = false;
+  warned_clock_stall_ = false;
   // The clock choice is per connection too: a reconnect re-derives it from the frames
   // the new session actually delivers, rather than trusting a decision made about a
   // session that dropped.
@@ -246,6 +247,8 @@ void Acquisition::reset_session_state() noexcept {
   arrival_prev_ = 0.0;
   have_prev_frame_recv_ = false;
   prev_frame_recv_ = 0.0;
+  // The clock origin is adopted again: the next session's first stamp is that session's epoch.
+  epoch_adopted_ = false;
 }
 
 void Acquisition::note_frame_arrival(double recv_time) noexcept {
@@ -310,7 +313,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
   stamps_.assign(n, 0.0);
   abs_stamps_.assign(n, 0.0);
 
-  // Samples per packet, needed in both modes: relative stamping must advance `slot_base_`
+  // Samples per packet, needed in both modes: relative stamping must advance its own clock
   // past the reserved hole before placing this frame's own stamps, and `on_data` must size
   // the hole before calling this function -- both operations require knowing how many samples
   // a missing packet represents, which is only knowable from the first frame that arrives.
@@ -338,27 +341,32 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
     }
   }
 
-  // Relative mode: the stamp is a pure function of position and the *declared* rate. Nothing
-  // below this block runs -- not the arrival-cadence override, not the degeneracy guard, not
-  // the glitch bridge, not the monotonic clamp's reason for existing -- because a position
-  // cannot jitter, cannot fail to advance and cannot jump.
+  // Relative mode: the stamp is the *declared* interval counted from the stream's own origin.
+  // Nothing below this block runs -- not the arrival-cadence override, not the degeneracy guard,
+  // not the glitch bridge, not the monotonic clamp's reason for existing -- because a counted
+  // clock cannot jitter, cannot fail to advance and cannot jump.
+  //
+  // The clock is **accumulated in seconds**, never recomputed as `position * interval`: see
+  // `Acquisition::rel_base_s_` for the defect that distinction cost. The interval is read from the
+  // plan, which is the stream's declared rate as soon as a streamed channel exists -- and
+  // `on_data` refuses a frame until one does, so the fallback interval can never be the origin.
   if (cfg_.relative_time) {
     double dt = model_.partition().base_dt;
     if (!(dt > 0.0)) dt = 1.0e-3;
-    // The reserved hole for the missing packets advances `slot_base_` so this frame's
-    // own stamps land *after* the hole rather than on top of it. The same conditions
-    // the reservation call uses: not late, gap present, batch known, within the cap --
-    // and not one the arrival clock or the ring refused, because the advance and the
-    // reservation are the *same* claim about the wire: believing a phantom gap here
-    // would stretch the timeline by exactly the loss that did not happen.
+    // The reserved hole for the missing packets advances the clock so this frame's own stamps land
+    // *after* the hole rather than on top of it. The same conditions the reservation call uses: not
+    // late, gap present, batch known, within the cap -- and not one the arrival clock or the ring
+    // refused, because the advance and the reservation are the *same* claim about the wire:
+    // believing a phantom gap here would stretch the timeline by exactly the loss that did not
+    // happen.
     if (!frame->late && frame->lost > 0 && batch_ > 0 && !counter_gap_refused &&
         double(frame->lost) <= kGlitchPackets) {
-      slot_base_ += u64(frame->lost) * batch_;
+      rel_base_s_ += double(frame->lost) * double(batch_) * dt;
     }
     for (std::uint32_t k = 0; k < n; ++k) {
-      stamps_[k] = double(slot_base_ + k) * dt;
+      stamps_[k] = rel_base_s_ + double(k) * dt;
     }
-    slot_base_ += n;
+    rel_base_s_ += double(n) * dt;
     // The absolute clock is still read, for export only. When the stream carries no usable
     // time signal there is no absolute clock to record and the relative stamp is the honest
     // answer -- an export then says what the counter says, which is all anyone had.
@@ -632,7 +640,23 @@ void Acquisition::on_data(const udps_frame_t* frame) {
 
   const Partition& part = model_.partition();
   const std::vector& channels = model_.channels();
-  if (part.time_capacity == 0 || channels.empty()) return;
+  // A frame is only stored into a model that has a **streamed** channel. A model holding only
+  // *derived* ones -- a session's math channels, applied before the stream's own have arrived, or
+  // a file's channels while the math is re-applied -- has nowhere to put a streamed sample, and its
+  // plan is built from the 1 kHz fallback interval, which is **not the stream's rate**. Accepting
+  // such a frame (which the `channels.empty()` test below did) stamps the timeline at that fallback,
+  // and the frame after the stream's own channels arrive makes every later stamp *behind* the one
+  // already written -- the monotonic clamp holds the clock there for the rest of the run, the
+  // trigger's `history` never fills and the plot draws nothing. The reading is the `trig engine:`
+  // line: `n` climbing while `ring=[a..a]` stands still.
+  bool streamed = false;
+  for (const ChannelInfo& c : channels) {
+    if (!c.math) {
+      streamed = true;
+      break;
+    }
+  }
+  if (part.time_capacity == 0 || channels.empty() || !streamed) return;
 
   // A frame delivered behind the sequence: the slots it owns were reserved as NaN by
   // `reserve_hole()`, so it writes values into them and does nothing else. It is deliberately
@@ -672,7 +696,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
     }
   }
   // The wire's own cadence, and the verdict on the counter's claim, both taken *before* the
-  // stamps: relative mode's `slot_base_` advance and the reservation below are the same claim
+  // stamps: relative mode's clock advance and the reservation below are the same claim
   // about the wire (see `build_stamps`), so they have to agree about it. The verdict is taken
   // before the cadence is updated, too: folding this frame's own arrival into the estimate would
   // let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage
@@ -687,7 +711,14 @@ void Acquisition::on_data(const udps_frame_t* frame) {
   const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
   if (n == 0) return;
 
-  if (model_.epoch() == 0.0) {
+  // The epoch -- the stream time the display's x = 0 means -- is the first frame's stamp, adopted
+  // **once per connection**. The test is a state and not a value: relative mode's first frame is
+  // stamped exactly 0, so an `epoch() == 0.0` test reads that as "no epoch yet" and lets the
+  // *second* frame move the origin, which stalls the timeline through that frame (the samples of
+  // one frame all land on one stamp) and shifts every later x by a frame's span. The flag is
+  // re-armed by `reset_session_state()`, because a new session's clock is a new clock.
+  if (!epoch_adopted_) {
+    epoch_adopted_ = true;
     model_.set_epoch_if_unset(stamps_[0]);
     std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
                  model_.epoch(), frame->recv_time, n);
@@ -717,7 +748,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
   // The generation is the signal rather than the stride/channel-count comparison below: only
   // a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
   // keeps it, history and all (see `ScopeModel::set_signals`).
-  // `slot_base_` is deliberately *not* reset -- it is relative mode's clock origin, samples
+  // `rel_base_s_` is deliberately *not* reset -- it is relative mode's clock origin, seconds
   // since the stream began, and relative time must not jump backwards when the user retypes
   // the window.
   if (const unsigned gen = model_.plan_generation(); gen != seen_plan_generation_) {
@@ -906,6 +937,29 @@ void Acquisition::on_data(const udps_frame_t* frame) {
     if (tc.mode == TriggerMode::Single) tc.armed = false;
   };
 
+  // The monotonic clamp, and the one state it must not be allowed to sit in. `max()` is for a
+  // stream clock that glitches, fails to advance for a sample or arrives out of order -- one
+  // sample, or a few, held back. A **whole frame** whose stamps are all behind the clock is not
+  // that: the timeline has stopped while the wire has not, and nothing downstream can recover. It
+  // is staged as a reading because the picture it produces is a *blank* plot beside a healthy
+  // status bar, which names no cause at all: the trigger's `history` never reaches its pre-window,
+  // so it never latches and the display sits in `Waiting`; the renderer's bound window is empty, so
+  // nothing is drawn even with the trigger off. `relative_time` with a session's math channel was
+  // how this was reached (see `rel_base_s_`); the guard is `build_stamps`' own accumulator.
+  if (!warned_clock_stall_ && n > 0 && stamps_[0] - epoch < last_stamp_ &&
+      stamps_[n - 1] - epoch < last_stamp_) {
+    warned_clock_stall_ = true;
+    const u64 held = model_.live().time().size();
+    std::fprintf(stderr,
+                 "[udpscope] warning: the clock stalled at %.6f s: %u samples stamped [%.6f..%.6f] 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 %.9g s now; "
+                 "ring n=%llu\n",
+                 last_stamp_, n, stamps_[0] - epoch, stamps_[n - 1] - epoch, part.base_dt,
+                 static_cast(held));
+    std::fflush(stderr);
+  }
+
   for (std::uint32_t k = 0; k < n; ++k) {
     const u64 tick = tick_++;
     // The shared clock's group boundary, read once and reused by the time ring, the channel
diff --git a/src/acquisition.hpp b/src/acquisition.hpp
index acc9b0f..a008bb6 100644
--- a/src/acquisition.hpp
+++ b/src/acquisition.hpp
@@ -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()`.
diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp
index aa44899..ee31ac6 100644
--- a/src/source_dialog.cpp
+++ b/src/source_dialog.cpp
@@ -506,7 +506,11 @@ void on_form_connect(GtkButton*, gpointer user) {
 /// settings -- the only difference is where the values start and what the button is called.
 void open_form(Chooser& c, int edit_index = -1) {
   if (c.form != nullptr) {
-    gtk_window_present(GTK_WINDOW(c.form));
+    // `adw_dialog_present()`, never `gtk_window_present()`: an `AdwDialog` is a `GtkWidget` and
+    // **not** a `GtkWindow`, so the old cast was a type assertion GTK logs and then does nothing
+    // with -- the form stayed behind whatever had been raised over it. The chooser presents it the
+    // same way below, and it is the only call that knows how to show a dialog on a window.
+    adw_dialog_present(c.form, GTK_WIDGET(c.window));
     return;
   }
   const KnownSource* src =
diff --git a/src/ui.cpp b/src/ui.cpp
index 43b3485..650aca3 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -140,8 +140,20 @@ void open_configurations(App& app) {
                          [](gpointer p) { delete static_cast(p); });
   // Cleared on close: the pointer is what makes a second open a present rather than a second
   // window, and a destroyed window must not leave it dangling.
-  g_signal_connect_swapped(window, "close-request", G_CALLBACK(+[](gpointer user) {
+  //
+  // The handler **returns FALSE**, and that return is the whole of "the dialog can be closed":
+  // `close-request`'s default handler is what destroys the window, and a handler that returns
+  // `TRUE` suppresses it. This one was a bare `void` lambda -- the value GTK read as its verdict
+  // was whatever the compiler happened to leave in the return register, and a build of exactly this
+  // shape (gtk4, -O2, one `void` lambda on `close-request`) leaves it non-zero -- so both the Close
+  // button and the window manager's button emitted
+  // `close-request`, were told "handled", and **the dialog stayed up**. It is the same shape the
+  // math dialog's and the chooser's handlers already carry, and it cannot be seen in a callback's
+  // signature: `G_CALLBACK` is a cast, so a `void(GtkWindow*, gpointer)` where GTK expects
+  // `gboolean(GtkWindow*, gpointer)` compiles without a warning.
+  g_signal_connect_swapped(window, "close-request", G_CALLBACK(+[](gpointer user) -> gboolean {
                              static_cast(user)->config_dialog = nullptr;
+                             return FALSE;  // let the default handler destroy the window
                            }),
                            &app);
   app.config_dialog = window;