The user's real host reported "connected and live" with one missing-data band over the whole window
and no trace in it. The counter is the only thing that claims a packet existed, and a reservation
was booked on its word alone: a source whose DATA counter counts samples, or frames of an inner
layer, advances by more than 1 per datagram, the client cannot check that claim (it has no clock of
its own to compare against), so it holds each packet, saturates its reorder ring, delivers it in
order and reports the skipped counters as lost. Every frame therefore reported a phantom gap, and
`reserve_hole()` wrote `lost * batch_` NaN samples with no bound but the 256-PACKET cap -- the
sample form of that rule ("the number to weigh against the ring is 256 * batch_, not the 256") was
never implemented. With relative time on -- which that session carries -- each phantom gap is
materialised as NaN slots by construction: the window filled with reservations, there was never
anything missing to repair them, and the gaps also stretched the relative clock, which is why the
status bar read live at ten times the true rate.
`Acquisition::gap_plausible()` now gates every reservation on two independent tests: the arrival
clock must corroborate the claim (that many missing packets would take that many frame intervals;
deliberately permissive, because a false refusal costs a shading and never data), and the ring must
be able to hold the hole. The verdict is taken before the stamps, and relative mode's slot advance
is skipped with it, since the advance and the reservation are the same claim. Refusals are counted
in `StreamStats::gaps_refused` and said once per connection with the numbers, naming the wire shape
they describe.
tools/mock_udpstreamer.py gains `--counter-step N`: a counter that is not a packet counter on an
intact link, which is the one shape the mock could not produce and the shape the report came from.
Verified in both directions. A counter stepping 1000 per datagram reads `holes=0 hole_slots=0
backfilled=0` with the traces present (3 traces, 1.4 M samples) against 7.9 M claimed gaps, and the
warning above names the cause. Every other packet withheld -- a real loss -- still reads
`holes=3579`, so genuine gaps are still reserved: a fix that stopped believing real losses would
have traded one wrong picture for another. The user's control read as "the math channel is the
problem" and was confounded -- disabling it is a structural change, which rebuilds the buffer and
clears the accumulated NaN.
346 lines
29 KiB
Markdown
346 lines
29 KiB
Markdown
# Invariants: the data path — buffers, decimation and the rings
|
||
|
||
What rebuilds a buffer, why decimation is an average, and the two rules the time ring's precision and its first stamp impose.
|
||
|
||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||
|
||
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
|
||
enable/disable, a calibration change, or a **change of display window that moves the
|
||
decimation factor**. Calibration is applied at ingest, which is why it re-partitions.
|
||
`ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when
|
||
`time_stride` actually changes, so walking the window inside one decade is free; it asks
|
||
`plan_partition()` rather than recomputing the factor, because two expressions for that
|
||
decision is how the buffer and the view end up disagreeing about the stored rate. It
|
||
takes `mutex()` itself — do not call it holding the lock.
|
||
- **A rebuild that would plan the *same* storage clears the store instead of allocating one.**
|
||
`rebuild_locked()` compares the new `Partition` against the current one with
|
||
`Partition::same_storage_as()` — every field that decides an allocation: `time_capacity`,
|
||
`time_stride`, `abs_time`, `base_dt` and the per-channel `stride`/`value_capacity` vectors — and
|
||
on a match calls `BufferStore::reset()` rather than constructing a new one. `base_dt` is in the
|
||
comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples
|
||
under a changed `base_dt` would re-date the whole ring.
|
||
**Construction is the cost.** A `RingBuffer<T>` holds a `std::vector<std::atomic<T>>`, which is
|
||
value-initialised, so making a store touches every byte of `--buffer-mb` — about a second per
|
||
gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an
|
||
`invalidate()` and an epoch back to zero. It must leave nothing of the old history behind, which
|
||
is why it clears the time ring, the absolute-time ring, every value ring, and both the published
|
||
window and the pyramid of every channel: a reused store that kept a stale published window would
|
||
hand the renderer samples the new plan does not describe.
|
||
`reset()` runs on the consumer thread under `ScopeModel::mutex()`, which is what makes a
|
||
`RingBuffer::clear()` safe here at all — the producer holds that same mutex for the whole of
|
||
`on_data()`.
|
||
Every allocation that *is* made says so: `[udpscope] buffer allocated: N channels, S s span,
|
||
T ms` on stderr. A start used to emit fifteen of those; two is a real start, and a count that
|
||
climbs again is this rule having stopped applying.
|
||
- **Enables are applied in one batch, because each one re-plans.** `set_signals_enabled()` takes
|
||
the mutex once, moves every signal in the list, and re-plans and bumps the generation **once** —
|
||
and not at all when nothing actually moved, which is the common case for a restored session
|
||
re-enabling what is already on. Calling `set_signal_enabled()` in a loop is a buffer rebuild per
|
||
signal; `apply_layout()` and the waveform loader collect their wanted signals into one vector for
|
||
that reason, and `apply_layout()` records the session's span **first**, with no channels bound, so
|
||
the `apply_time_window()` that follows re-plans nothing.
|
||
- **The shared clock is decimated to fit the requested window; channels never are.** A
|
||
window too long to store at the wire rate used to be refused (the control was capped to
|
||
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
|
||
`plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has
|
||
bought all the ring the budget allows — deciding it earlier over-decimates by whatever
|
||
factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of
|
||
`round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's
|
||
own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the
|
||
same phase and the channel plots flat.
|
||
- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads
|
||
and accumulates *every* sample and pushes the mean of each group of `time_stride`
|
||
(`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is
|
||
at the stored rate, so content that would fold back is attenuated instead of reappearing
|
||
as a false low frequency — which is the whole reason decimation was once dropped
|
||
entirely. Skipping the read for unstored samples re-introduces the aliasing; the
|
||
`UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (the anti-alias check in
|
||
[probe-readings.md](probe-readings.md)).
|
||
The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's
|
||
group delay puts the value, and stamping at the end of the group shifts every trace half
|
||
a stored sample late. The accumulators are reset whenever `time_stride` or the channel
|
||
count changes, so a re-plan starts a fresh group rather than finishing one at the wrong
|
||
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
|
||
narrower than a group is now filtered away rather than aliased, which is the correct
|
||
trade and is why the status line's `rate` field is worth reading.
|
||
- **A file is ingested too, so the loader decimates by the same factor.** `load_waveform_into_model()`
|
||
sets the target span to the recording's own length and enables every column, and the plan that
|
||
comes back may well decimate: 20 s of 10 channels at 500 kS/s is 480 MB of rings, and the budget is
|
||
`--buffer-mb`. The loader therefore reads `partition().time_stride` **after** the enables and pushes
|
||
one slot per that many rows, carrying the group's mean at the group's mean stamp — the packet path's
|
||
filter, written a second time because a file does not arrive in packets. Pushing every row instead
|
||
overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event
|
||
opened as its last 4 s, `stored=10000000` against `published=2097152` in the channel dump, and a
|
||
trace that looks like a short recording rather than a truncated one. The `waveform loaded:` line
|
||
carries `rows=` and `decim=` beside `samples=` for exactly this reason — `samples * decim == rows`
|
||
is the arithmetic that says the whole file is in the buffer.
|
||
- **One bad packet is bridged; only a run of them changes the time source.** The stream-time
|
||
checks in `build_stamps()` reject three things: stamps that are not finite, a packet with no
|
||
span at all, and a packet whose stamps do not advance past the previous packet's last. A
|
||
fourth was added because a real device's clock glitches: a packet whose stamps jump **more
|
||
than 256 packet spans** ahead is a fault, not a gap, and must be rejected — letting it
|
||
through poisons `last_stamp_`, and the monotonic clamp (`max(stamps[k] - epoch,
|
||
last_stamp_)`) then pins every later packet flat at the glitched value, so the whole stream
|
||
freezes at one x and never recovers. A rejected packet is **bridged**: its stamps are
|
||
replaced by a continuation of the accepted timeline at the declared cadence, so neither the
|
||
monotonic mark nor `last_stream_anchor_` is poisoned and the next good packet is accepted
|
||
normally. Believing the glitch instead is what condemned the *next*, perfectly good packet
|
||
(it now sits below the poisoned anchor, so it fails the advance test) and threw the whole
|
||
stream onto the arrival clock. Only **four consecutive** rejected packets abandon the
|
||
declared time signal, because that is what a signal which is not a time base at all looks
|
||
like; a first packet with nothing to bridge from still falls back immediately. The unit
|
||
scale ('ns', 'us', 'ms') is therefore resolved **before** these checks rather than after:
|
||
they weigh the stamps against a cadence in seconds, and on a 'ns' stream an unscaled
|
||
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
|
||
[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
|
||
`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
|
||
timeline and are kept. The fallback used to call `reset_live_history_locked()` on the
|
||
reasoning that the old stamps belonged to a dead clock; they do not, and that wipe is what
|
||
made a single glitching packet look like "the live stream restarted from 0 with no action
|
||
from the user". The continuity is also what makes a rebase of `TriggerEngine` unnecessary:
|
||
the engine holds four absolute stamps (`holdoff_until_s_`, `pending_start_s_`,
|
||
`last_trigger_s_`, `last_t_s_`) and they stay valid across the change. Anything that makes
|
||
the clock *discontinuous* breaks that and has to reset them.
|
||
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
|
||
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
|
||
is not in the buffer would be cut from data that does not contain it.
|
||
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
|
||
A value's precision is a property of the signal (24 bits is plenty for a sample); a
|
||
timestamp's is a property of *how long the scope has been running*, because the stamps are
|
||
stored relative to an epoch fixed at the first sample ever seen and so grow without bound
|
||
while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s
|
||
of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two
|
||
to four consecutive samples shared a stamp after half a minute, eight after a minute, and
|
||
the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where
|
||
the waveform should be. That is "zoom in to ~10 µs and three to five points share a
|
||
timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are
|
||
the memory budget, and nothing about them is magnitude-bound this way. Three consequences:
|
||
`plan_partition()` can no longer count slots in its growth loop (a time slot and a value
|
||
sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the
|
||
clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds
|
||
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
|
||
type with **no rounding step**, the old `f32` round-trip having existed only so that a
|
||
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
|
||
`EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same
|
||
units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's
|
||
stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the
|
||
ring is, and the width is for the cut rather than for the coordinate). `RingBuffer<T>` stores
|
||
slots as `std::atomic<T>` behind a `static_assert(is_always_lock_free)`, which `double`
|
||
satisfies on every supported target. The guard is the **export**: over a multi-million-row
|
||
`.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see
|
||
the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one
|
||
value). Three steps at 2, 2.5 and
|
||
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||
- **Append is not the only write.** A backfill amends slots that a reservation wrote as NaN, and
|
||
the amend is split across threads for a reason:
|
||
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)` is a single relaxed
|
||
store, bounds-checked against `[produced_ - capacity, produced_)`. The same race class as a
|
||
push, and an `f32` cannot tear. Measurements and export read the ring directly, so they see
|
||
a repair immediately and no second notification is needed.
|
||
- **Pyramid amend — consumer (GTK main thread), inside `ChannelBuffer::acquire()`.** The
|
||
producer collects amended values in a plain `std::vector<Amend>` member and the consumer
|
||
drains it after its own append pass. `acquire()` is called under `model.mutex()`, and the
|
||
producer holds that same mutex for all of `on_data`, so the vector is correctly serialised
|
||
with no lock-free queue. The reason the pyramid amend cannot also be on the producer: a
|
||
bucket is a 32-byte non-atomic aggregate that the renderer reads **unlocked at paint time**,
|
||
so a producer writing it could be caught mid-bucket — a `count` that does not match its
|
||
`sum`, producing a wrong mean from a structure whose readers trust `count == 0` to mean
|
||
*absent*. On the consumer that cannot happen.
|
||
- **A hole is stamped at reservation; backfill changes values only.** The f64 time ring is
|
||
never amended. `neighbours()' binary search requires a monotonic array, and `window_x0_`
|
||
must remain valid; pre-stamping the hole at the declared cadence (exact in relative mode,
|
||
the bridge mechanism in stream mode) is what keeps both invariants, and it is why a late
|
||
packet's counter, not its arrival stamp, is what addresses the hole. The reservation cap
|
||
(`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost
|
||
is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100,
|
||
2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256.
|
||
- **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
|
||
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
|
||
the socket clock jitters and a drain can compress two frames' arrival times -- and a refused
|
||
gap costs a *shading*, never data, so the test is deliberately permissive.
|
||
- **The ring.** `missing × batch_` samples become `missing × batch_ / time_stride` slots, and the
|
||
hole may not lap `time_capacity`. This is the *sample* form of the rule the 256-packet cap
|
||
states in packets -- the cap's own comment says 256 packet spans in one go is an outage, and
|
||
the invariant above says the number to weigh against the ring is `256 × batch_` and not the
|
||
256 -- and it has to exist in samples because a hole that laps the ring erases the history it
|
||
is meant to annotate, can never be backfilled (its slots are already overwritten) and is never
|
||
forgotten (`forget_holes_before()` waits for slots the ring has passed), so it leaves a
|
||
*permanent* band over a buffer that is all NaN.
|
||
What its absence looks like, which is why it is written down: a device whose counter counts
|
||
*samples*, or frames of an inner layer, reports a forward jump on every datagram while the wire
|
||
delivers on time. Every frame then reserves, and with `relative_time` every frame books NaN
|
||
slots by construction -- one band over the whole window with the samples washed out,
|
||
`backfilled=0` (no packet was ever missing, so nothing can repair it), and the status bar reading
|
||
`live` at the right rate the whole time. The refusal is counted in `StreamStats::gaps_refused`
|
||
and said once per connection with the numbers, because the status bar's `gaps` climbs either way
|
||
and the state is otherwise invisible; `tools/mock_udpstreamer.py --counter-step N` is the mock
|
||
shape (its counter is otherwise always +1, so this is the one wire shape it could not produce),
|
||
and `gaps_refused` rising while `nan_slots` stays 0 is the reading. A refused gap is not a silent
|
||
one: the timeline is not stretched over loss that did not happen, so it shows as the x
|
||
discontinuity it is.
|
||
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
|
||
the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin`
|
||
describe a buffer that is gone; it therefore clears `holes_` and bumps
|
||
`ScopeModel::plan_generation()`, which the producer compares against the value it last saw
|
||
and resets `tick_` on. That is what keeps `tick_ / period == produced()`, the relation
|
||
`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.
|
||
- **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
|
||
of a saturated window routinely point at time slots the producer has already
|
||
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
|
||
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
|
||
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
|
||
ratio of elapsed stream time to the retained window and clipped the older part of
|
||
every live view. The library's `StreamSeries` is immune for a reason worth copying:
|
||
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
|
||
*y* value, never the geometry. Clamping buys the same bargain here.
|
||
- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The
|
||
clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and
|
||
`absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so
|
||
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
||
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
||
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
||
paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
|
||
point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
|
||
at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
||
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
||
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
||
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
||
a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
|
||
invariant), a local of the renderer's, so the second and third of those are structurally
|
||
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
|
||
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
|
||
that reason; do not read the slot at paint time because two of its three symptoms are gone.
|
||
That is the "with measurements and cursors active the plot flickers as
|
||
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
||
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
||
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
||
`mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets
|
||
at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the
|
||
producer's pushes hold — and the window is pinned on the line above, so that is the one point in
|
||
the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather
|
||
than merely a stable one (it is the stamp sample `window_base` actually carries, where a
|
||
paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served
|
||
from it too, for the same reason, and `invalidate()` clears it with the window. The guard is
|
||
`trace ink:` / `[ink]` (see [probe-readings.md](probe-readings.md)). The same torn read still exists for a small `i > 0`
|
||
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
|
||
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
|
||
symptom and deliberately not fixed here.
|
||
|
||
## The packet capture sits at `recv()`, and its file is reserved, not sparse
|
||
|
||
Every wire behaviour a forensic capture exists to answer — a counter that skips, a datagram that
|
||
arrives twice, a fragment that never completes, a CONFIG whose header does not parse — is
|
||
invisible by the time a `udps_frame_t` reaches `Acquisition::on_data`: the client has already
|
||
validated, reassembled, resequenced and dropped. The capture therefore hooks
|
||
`udps_client_set_datagram_cb()`, which the client fires immediately after the UDP `recv()`
|
||
returns and before anything reads out of `rxbuf`, and it stores the bytes without interpreting
|
||
them. The capture is of the *UDP socket*: in multicast mode CONFIG arrives over TCP and is out
|
||
of reach.
|
||
|
||
The ring file is reserved with `posix_fallocate` rather than left sparse because the stores are
|
||
`memcpy`s into an `mmap`: a store into a page the filesystem cannot back raises **SIGBUS**, which
|
||
kills udpscope outright. An out-of-space condition has to be an error at the moment the user
|
||
toggles — reported in the popover row, the button back off — not a crash ten minutes into the
|
||
run the capture was switched on for. Within the reserved file, a slot's trailing `seq` word is
|
||
published with a release store *after* its bytes are down, so a slot carrying a non-zero `seq`
|
||
is complete and no `fsync` is needed for the file to be readable after a crash: the kernel's
|
||
dirty pages are the file, and a half-copied slot still reads `seq == 0`.
|
||
|
||
## A reconnect with an unchanged CONFIG keeps the buffer
|
||
|
||
A disconnect -- EMI makes it a silence timeout, not a polite DISCONNECT -- must not cost the
|
||
acquisition. What historically did was the reconnect's CONFIG: `ScopeModel::set_signals()` rebuilt
|
||
the store on *every* CONFIG, and a rebuild is what drops history, resets `tick_`, retires the
|
||
holes and invalidates a held event. Now the incoming CONFIG is compared against the stream the
|
||
model already has -- names, shapes, rates, ranges, time modes, time-signal indices -- and an
|
||
**identical CONFIG returns early**: the ring keeps its history, the trigger keeps its state and
|
||
its held event, and the outage shows as the gap in the data that it was. Only a CONFIG that
|
||
*changes* the stream re-plans.
|
||
|
||
The producer's own state is per *connection*, and carrying it across a drop makes recovery read
|
||
as failure: `Acquisition::reset_session_state()` (on DISCONNECTED, and again on the next CONFIG)
|
||
clears the counter mark, the stream-time anchor and the smoothed arrival interval, so the first
|
||
frame of the new session re-anchors instead of reading as late, as a glitch or as an outage. The
|
||
arrival clock's *anchor* deliberately stays -- stamps are small seconds the ring's timeline is
|
||
already scaled against, and re-anchoring mid-stream would move x. The clock *choice* is re-derived
|
||
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.
|
||
|
||
## There may be no store at all
|
||
|
||
`rebuild_locked()` calls `store_.reset()` for a stream with **no channels** — the model's state until the
|
||
first CONFIG arrives — so `store_` is legitimately null, and `ScopeModel::live()` dereferences it. Every
|
||
path reachable without a stream has to ask first: **pausing a scope that is not connected** is one (the
|
||
button works, and freezing a view means taking a window from the rings), and it crashed inside
|
||
`live_window_locked()` on a null store. The guards belong at the entry points the UI can reach
|
||
(`live_window_locked()`, `publish_pause_view()`), not in `live()`, which cannot express "nothing there"
|
||
through a reference. The paths that only run for a *bound* trace — the series' gather, the SlotSeries'
|
||
`shared_channel()` — are safe by construction: a slot with no channel binds no series at all.
|
||
`UDPSCOPE_PROBE_PAUSE_MS` against a port nothing is listening on is the check.
|
||
|
||
## A waveform is a source that never arrives
|
||
|
||
`--open FILE` (and the chooser's Waveforms page) puts an exported `.npy` into the model instead of a
|
||
stream: one signal and channel per column, the samples pushed through `push_loaded_slot()` — the
|
||
producer's own order (values, the shared clock, the absolute clock, then the derived channels), under
|
||
the model's lock — and the display epoch pinned to the file's last stamp so the axis reads
|
||
`[-window, 0]` exactly as the export did. Everything downstream is unchanged, which is the point:
|
||
cursors, measurements, math channels and another export work on a file because they work on the
|
||
rings, not on the wire.
|
||
|
||
Three things follow, and each is a place the live assumptions would otherwise leak:
|
||
|
||
- **Nothing is ever starved.** There is no socket and no source, so the starvation notice is skipped
|
||
while a waveform is open, and the popover's Stream row names the file rather than reporting a
|
||
connection that has not answered.
|
||
- **The loader must not hold the model's mutex.** Every setter it needs (`set_signals`,
|
||
`set_signal_enabled`, `set_target_span`) takes that same non-recursive lock; the first version
|
||
wrapped the whole load in it and deadlocked on the first call. The per-slot push is a model method
|
||
for the same reason.
|
||
- **The signal names come from the file.** A `.npy` written by `write_npy()` is a structured array
|
||
whose dtype names its fields, so an export is self-describing and there is no sidecar to lose.
|
||
`.csv` is not read: it carries the names too, but as text, and the item asked for the binary one.
|
||
- **A waveform has no source, and the app must not invent one.** The address the command line
|
||
*defaults* to (`127.0.0.1:8000`) is not one: leaving it in place made a file-mode run load — and
|
||
then *save* — that source's session, which overwrote the arrangement the file was opened with
|
||
(every drop then refused, the row full) and could clobber a real session. `App::source_port` is 0
|
||
in file mode, which is what makes `save_layout_now()` refuse and the first-CONFIG layout load skip
|
||
— the same two things that make a source-less run safe.
|
||
- **One row per column.** Four columns to one row fills the default single row and then refuses
|
||
every drop ("plot 1 already holds four signals"), which is a loaded file the user cannot plot
|
||
anything *else* on or rearrange. One per row (sized in `main.cpp`, before the UI exists, as
|
||
`--plots` does) leaves three free slots each, shows a four-signal file as four stacked traces, and
|
||
keeps the rest of the columns draggable. Sizing the rows from inside the activation instead leaves
|
||
the panels built for the old count and the app hangs on the first relayout.
|