294 lines
24 KiB
Markdown
294 lines
24 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.
|
||
- **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)).
|
||
- **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 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.
|