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

+12
View File
@@ -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 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. 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: The history cost of relative mode is measured rather than computed:
| mode | retained span | | mode | retained span |
+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.** - **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 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 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: 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, - **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 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 `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 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 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_` reconnect with an identical CONFIG rebuilds the store leaving both unchanged.
is **not** reset — it is relative mode's clock origin, samples since the stream began, and - **The relative clock is accumulated in *seconds*, and a frame is only stamped into a model that has a
relative time must not jump backwards when the user retypes the window. 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 - **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 — 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 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 per connection (`time_source_ = 0` on CONFIG) for the same reason: a session that dropped may
come back with a different answer. come back with a different answer.
Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's Relative time's clock (`Acquisition::rel_base_s_`, seconds accumulated at the declared interval)
chosen origin, and must not jump backwards because the link blipped. 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 ## 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 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 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 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 `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 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 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 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 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 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 — `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 all three, or the marker, the cut and the gate would disagree with each other by the width of
a pulse. 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 ## 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 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 `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. 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 - **`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. handed the samples, which is a trigger-path question and not a clock one.
+72 -18
View File
@@ -206,6 +206,7 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
warned_phantom_gap_ = false; warned_phantom_gap_ = false;
warned_bridge_ = false; warned_bridge_ = false;
warned_time_source_ = 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 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 // the new session actually delivers, rather than trusting a decision made about a
// session that dropped. // session that dropped.
@@ -246,6 +247,8 @@ void Acquisition::reset_session_state() noexcept {
arrival_prev_ = 0.0; arrival_prev_ = 0.0;
have_prev_frame_recv_ = false; have_prev_frame_recv_ = false;
prev_frame_recv_ = 0.0; 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 { 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); stamps_.assign(n, 0.0);
abs_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 // 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 // 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. // 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 // Relative mode: the stamp is the *declared* interval counted from the stream's own origin.
// below this block runs -- not the arrival-cadence override, not the degeneracy guard, not // Nothing below this block runs -- not the arrival-cadence override, not the degeneracy guard,
// the glitch bridge, not the monotonic clamp's reason for existing -- because a position // not the glitch bridge, not the monotonic clamp's reason for existing -- because a counted
// cannot jitter, cannot fail to advance and cannot jump. // 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) { if (cfg_.relative_time) {
double dt = model_.partition().base_dt; double dt = model_.partition().base_dt;
if (!(dt > 0.0)) dt = 1.0e-3; if (!(dt > 0.0)) dt = 1.0e-3;
// The reserved hole for the missing packets advances `slot_base_` so this frame's // The reserved hole for the missing packets advances the clock so this frame's own stamps land
// own stamps land *after* the hole rather than on top of it. The same conditions // *after* the hole rather than on top of it. The same conditions the reservation call uses: not
// the reservation call uses: not late, gap present, batch known, within the cap -- // late, gap present, batch known, within the cap -- and not one the arrival clock or the ring
// and not one the arrival clock or the ring refused, because the advance and the // refused, because the advance and the reservation are the *same* claim about the wire:
// reservation are the *same* claim about the wire: believing a phantom gap here // believing a phantom gap here would stretch the timeline by exactly the loss that did not
// would stretch the timeline by exactly the loss that did not happen. // happen.
if (!frame->late && frame->lost > 0 && batch_ > 0 && !counter_gap_refused && if (!frame->late && frame->lost > 0 && batch_ > 0 && !counter_gap_refused &&
double(frame->lost) <= kGlitchPackets) { 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) { 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 // 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 // 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. // 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 Partition& part = model_.partition();
const std::vector<ChannelInfo>& channels = model_.channels(); const std::vector<ChannelInfo>& 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 // 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 // `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 // 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 // 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 // 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 // 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); const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
if (n == 0) return; 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]); model_.set_epoch_if_unset(stamps_[0]);
std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n", std::fprintf(stderr, "[udpscope] time base: epoch=%.3f, first frame recv=%.3f, n=%u\n",
model_.epoch(), frame->recv_time, 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 // 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 // a CONFIG that *changes* the stream re-plans the store -- a reconnect's identical CONFIG
// keeps it, history and all (see `ScopeModel::set_signals`). // 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 // since the stream began, and relative time must not jump backwards when the user retypes
// the window. // the window.
if (const unsigned gen = model_.plan_generation(); gen != seen_plan_generation_) { 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; 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<unsigned long long>(held));
std::fflush(stderr);
}
for (std::uint32_t k = 0; k < n; ++k) { for (std::uint32_t k = 0; k < n; ++k) {
const u64 tick = tick_++; const u64 tick = tick_++;
// The shared clock's group boundary, read once and reused by the time ring, the channel // The shared clock's group boundary, read once and reused by the time ring, the channel
+25 -6
View File
@@ -155,12 +155,22 @@ class Acquisition {
/// signal carrying volt-like values, others from the arrival clock -- which is a /// signal carrying volt-like values, others from the arrival clock -- which is a
/// time base no reader can make sense of (see `build_stamps`). /// time base no reader can make sense of (see `build_stamps`).
int time_source_ = 0; int time_source_ = 0;
/// Relative-time mode's position counter: samples seen since the stream began, which is /// Relative-time mode's clock: seconds since the stream began, accumulated at the interval
/// what the counter names. Advanced by `batch_` for every packet the counter reports /// the plan declares. Advanced by `lost * batch_` samples' worth for every packet the counter
/// missing (before the frame's own samples) and then by the frame's own `n`, so it is /// 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 /// exactly `(counter * batch + sample_index) * dt` for a constant batch and degrades
/// when a frame's length disagrees. /// gracefully when a frame's length disagrees.
std::uint64_t slot_base_ = 0; ///
/// 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 /// 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. /// *absent*: a delivered frame carries its own length. 0 until the first frame.
std::uint32_t batch_ = 0; 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 /// Warn-once latch for the declared time signal failing for good, so the arrival clock is
/// taken instead. /// taken instead.
bool warned_time_source_ = false; 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 /// 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 /// model rebuilds it, and the state tied to the old store (`tick_` and the accumulators) is
/// reset when this falls behind `ScopeModel::plan_generation()`. /// reset when this falls behind `ScopeModel::plan_generation()`.
+5 -1
View File
@@ -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. /// settings -- the only difference is where the values start and what the button is called.
void open_form(Chooser& c, int edit_index = -1) { void open_form(Chooser& c, int edit_index = -1) {
if (c.form != nullptr) { 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; return;
} }
const KnownSource* src = const KnownSource* src =
+13 -1
View File
@@ -140,8 +140,20 @@ void open_configurations(App& app) {
[](gpointer p) { delete static_cast<ConfigDialog*>(p); }); [](gpointer p) { delete static_cast<ConfigDialog*>(p); });
// Cleared on close: the pointer is what makes a second open a present rather than a second // 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. // 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<App*>(user)->config_dialog = nullptr; static_cast<App*>(user)->config_dialog = nullptr;
return FALSE; // let the default handler destroy the window
}), }),
&app); &app);
app.config_dialog = window; app.config_dialog = window;