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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+72
-18
@@ -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<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
|
||||
// `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<unsigned long long>(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
|
||||
|
||||
+25
-6
@@ -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()`.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+13
-1
@@ -140,8 +140,20 @@ void open_configurations(App& app) {
|
||||
[](gpointer p) { delete static_cast<ConfigDialog*>(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<App*>(user)->config_dialog = nullptr;
|
||||
return FALSE; // let the default handler destroy the window
|
||||
}),
|
||||
&app);
|
||||
app.config_dialog = window;
|
||||
|
||||
Reference in new issue
Block a user