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

+8
View File
@@ -3,6 +3,8 @@
#include "acquisition.hpp"
#include <limits>
#include <udps_client.h>
#include "layout_store.hpp"
@@ -50,6 +52,12 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model),
Acquisition::~Acquisition() { stop(); }
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_init(&c);
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
/// lock is correct, and a reconnect with an identical CONFIG is a re-plan this sees and the
/// 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_; }
private: