fix: a restarted client is a new stream, so its history and its monotonic mark go

Going live from a file view left the display frozen for seconds: the per-second line showed
`ring=[2.5200..2.5200] n=567000 -> n=1131000 -> n=1689000` — the sample count climbing at the stream's rate
while the retained span was one value, the file view's last stamp. The status bar was green because
everything about the *connection* was fine.

It is the poisoning `invariants-data-path.md` already describes, entered through a different door. The
new client's stream clock sits *behind* the stamps the old stream left in `Acquisition::last_stamp_` (the
file view's samples are still in the store), so the advance test rejects every packet of a perfectly good
stream, four rejections abandon the declared time signal, and the arrival-clock fallback derives its epoch
from the surviving mark — every sample stamped flat at the old value. The guard did exactly what it was
built to do; the *stream* was the thing that had changed and nothing had told it.

So `Acquisition::start()` — which is the one place a client (re)starts — now says so:
`ScopeModel::reset_stream()` drops the previous stream's history (`store_->reset()`, which the data-path
invariants require to leave nothing behind) and moves `plan_generation_`, which is what the producer
already watches to drop the state tied to the old store; and `last_stamp_` is re-based to `-infinity` so
the monotonic guard re-bases on the new clock instead of pinning it. The declaration is kept: the first
packet of the new stream passes, and monotonicity holds from there.

One self-inflicted crash on the way, fixed before this: `reset_stream()` dereferenced `store_`, which is
**null until the first CONFIG has planned anything** — at start-up there is none — so the app died with
SIGSEGV and no output at all. Caught by running it plainly rather than trusting the change.

Verified with the events probe (a replay and a group, then *Live*):

    live restored (after replay): … pending=0
    trig engine: … history=0.4377/0.4000 s now_t=0.298 ring=[-0.1392..0.2985] n=569000
    trig engine: … history=0.8723/0.4000 s now_t=0.733 ring=[-0.1392..0.7331] n=1134000   (pending=1: it latched)
    trig engine: … history=1.2954/0.4000 s now_t=1.156 ring=[-0.1392..1.1562] n=1684000

`ring` moves within a round-trip, `history` fills, and the trigger latches the frame after the restore;
`make test-ui` 14 files, zero `Gtk-CRITICAL`, and the plain run is clean. Docs: the restart's own door into
the poisoning in `invariants-data-path.md`, and the `ring=` reading's entry in `probe-readings.md` now says
what it must do after *Go live*.
This commit is contained in:
Martino Ferrari committed 2026-10-14 15:38:00 +02:00
1 parent 62b5f14504
commit 761615c2fe
4 files changed
+39 -1

No files matched your search

+12
View File
@@ -94,6 +94,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
comparison is off by 1e9, so every packet would read as an implausible jump. The guard is comparison is off by 1e9, so every packet would read as an implausible jump. The guard is
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see `tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
[verification.md](verification.md)). [verification.md](verification.md)).
- **A *restarted* client is the other way into the same poisoning, and it is not a glitch.** Going live
from a file view starts a new `udps_client`, and the new stream's stamps can sit **behind** the ones the
old stream left in `last_stamp_` — the file view's samples are still in the store. The advance test then
rejects every packet of a perfectly good stream, four rejections abandon the declared time signal, and
the arrival clock derives its epoch from the stale mark: every sample is stamped **flat at the old
value**. `trig engine: … ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000` is what that looks
like — the count climbing at the stream's rate while the retained span is one value — until some later
rebuild resets the store, which is where the "few seconds of nothing" came from.
`Acquisition::start()` therefore calls `ScopeModel::reset_stream()` and re-bases `last_stamp_` to
`-infinity`: a started or restarted client is a *new stream*, so the previous one's history goes (which
is what `reset()` is for) and the monotonic guard re-bases on the new clock instead of pinning it.
`ring` moving within a stream round-trip of *Go live* is the assertion.
- **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from - **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from
`last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two `last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two
timelines share a zero by construction — the recorded stamps are therefore still on one timelines share a zero by construction — the recorded stamps are therefore still on one
+5 -1
View File
@@ -1500,7 +1500,11 @@ which of two failures a stalled display is.** `n` counts every stored sample, so
rate whenever data arrives at all: rate whenever data arrives at all:
- **`ring` standing still while `n` climbs** is the *clock*: samples are stored but every one carries the - **`ring` standing still while `n` climbs** is the *clock*: samples are stored but every one carries the
same stamp, so the axis cannot advance and the trigger's `history` never fills. Measured after going live same stamp, so the axis cannot advance and the trigger's `history` never fills. The stamps are pinned at
the file view's last value because a restarted client's stream clock sits behind it: the advance test
rejects the new packets, four rejections abandon the declared time signal, and the arrival-clock fallback
derives its epoch from the surviving mark. `Acquisition::start()` now resets the store and re-bases that
mark, so `ring` must **move** within a stream round-trip of *Go live*. Measured after going live
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.
+8
View File
@@ -3,6 +3,8 @@
#include "acquisition.hpp" #include "acquisition.hpp"
#include <limits>
#include <udps_client.h> #include <udps_client.h>
#include "layout_store.hpp" #include "layout_store.hpp"
@@ -50,6 +52,12 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model),
Acquisition::~Acquisition() { stop(); } Acquisition::~Acquisition() { stop(); }
void Acquisition::start() { void Acquisition::start() {
// A started -- or *restarted* -- client is a new stream. Going live from a file view is the restart that
// matters: the store still holds the event's samples, and the new stream's clock can sit behind their
// stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing
// was the symptom -- every packet pinned flat at the file view's last stamp.
model_.reset_stream();
last_stamp_ = -std::numeric_limits<double>::infinity();
udps_client_config_t c; udps_client_config_t c;
udps_client_config_init(&c); udps_client_config_init(&c);
c.server_addr = cfg_.server.c_str(); c.server_addr = cfg_.server.c_str();
+14
View File
@@ -632,6 +632,20 @@ class ScopeModel {
/// `mutex()` and the producer holds that lock for all of `on_data`, so a plain read under the /// `mutex()` and the producer holds that lock for all of `on_data`, so a plain read under the
/// lock is correct, and a reconnect with an identical CONFIG is a re-plan this sees and the /// lock is correct, and a reconnect with an identical CONFIG is a re-plan this sees and the
/// stride/channel-count comparison does not. /// stride/channel-count comparison does not.
/// A restarted client is a **new stream**, and the retained history belongs to the old one: its clock may
/// sit behind the stamps the previous stream wrote, and the producer's monotonic guard would then pin
/// every later packet flat at the old value -- the `ring=[x..x]` a display that never moves shows.
/// `reset()` is what the data-path invariants require of a rebuild (nothing of the old history behind),
/// and `plan_generation_` moving is what tells the producer to drop the state tied to the old store.
void reset_stream() {
std::lock_guard<std::mutex> lock(mutex_);
// Null before the first CONFIG has planned anything: `Acquisition::start()` calls this, and at start-up
// there is no store yet. Dereferencing it there was a SIGSEGV with no output at all.
if (store_ != nullptr) store_->reset();
++plan_generation_;
++generation_;
}
unsigned plan_generation() const noexcept { return plan_generation_; } unsigned plan_generation() const noexcept { return plan_generation_; }
private: private: