Converts every --buffer-mb / buffer_mb mention to --buffer-samples / buffer_samples, records the unit change as a deviation (README, SPECS, CLAUDE.md), documents the buffer: and planner: dump lines and the new Preferences row, and updates the help pages and the help-shot script. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
31 KiB
Markdown
373 lines
31 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 the planned rings — 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 10 M samples a channel, and the budget is
|
||
`--buffer-samples` (rounded down to a power of two, 8.39 M by default). 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 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
|
||
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.
|
||
- **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
|
||
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 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
|
||
|
||
`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.
|