working on todo.md features

This commit is contained in:
Martino Ferrari committed 2026-10-10 22:52:12 +02:00
1 parent 210d124814
commit 8f54841c2c
110 files changed
+15932 -6576

No files matched your search

+78
View File
@@ -174,3 +174,81 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
— 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.
## 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.