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

+12 -2
View File
@@ -36,14 +36,24 @@ file's basename is that same string, which is what associates a running window w
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
`--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000,
the value is required, and the popover button uses 1000 when the flag was absent).
The flag is **not** persisted in the session file: persistence of the enable state
belongs with `todo.md` item 3, alongside the relative-time toggle.
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
form for a new host. Every probe below passes `--server`, so none of them sees it.
`deps/plotter` is built from source with only the GTK4 adapter enabled
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
vendored C99 source pair compiled into a static lib.
vendored C99 source pair compiled into a static lib. `deps/udpsclient` gained one
additive entry point for the packet capture — `udps_client_set_datagram_cb()`, firing
inside `udps_client_poll()` immediately after the UDP `recv()` returns, before the
magic check or reassembly — plus the public `UDPS_MAX_DATAGRAM_BYTES` the capture
sizes its slots from. A separate setter, not a fourth parameter on
`udps_client_set_callbacks()`: that signature has call sites, and a capture hook is no
reason to change them.
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
+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.
+54 -3
View File
@@ -42,12 +42,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
how the first version of that line earned them. What stays in the menu is what acts on the *data*
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
measurement ruler, and the stock names those buttons used to carry said something else
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
are the four action icons; beside them in `src/icons/scalable/apps/` are the two
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math,events}-symbolic.svg`
are the five action icons; beside them in `src/icons/scalable/apps/` are the two
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
application's own icon and is where the **in-process** window icon resolves from; it is also
@@ -156,6 +156,57 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
at all inside a box, so without the request it is allocated nothing and the page renders its text
with a silent gap where its screenshots should be — which is what the first version did.
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
Putting the events pane in a paned is what makes its width the user's to drag -- but with the
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that
says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before*
zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is
still needed, or the minimum would clamp even a zero position.
- **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
window. Every vertical revealer in the side panel hides this, because there the leftover goes down
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the
list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler
that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a
widget.
- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.**
A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it
out of the row -- the box gives a non-expanding child its natural size and grows it further when
the expanding child has nothing left to take. Give the picture a **frame** whose size request equals
the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and
scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather
than 300.
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it.
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
picture keeps a *paint-able* and takes an allocation, so a probe that checks
`gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
alone asks for no height at all in a box.
- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go
through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there
drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed
memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu
entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore
defers the unparent by one idle and holds a reference across it
(`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the
emission that dismissed it.
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
Caps Lock is on.
+16
View File
@@ -233,3 +233,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
argument to pay at this size.
## A gap is a gap in a filter's output, never the end of its state
A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2`
for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never
came back. Relative time made it systematic — a counter gap reserves NaN slots by construction,
where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and
the reading was unambiguous: the memoryless channels held 95% finite samples while every
`LowPass`/`HighPass`/`BandPass` channel held `finite=0`.
`biquad_step()` therefore returns NaN **without touching the state words** when its input is not
finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the
filter's history remains the last *real* samples it saw, and the trace resumes after the hole
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
`hole_slots` is this defect returned.
@@ -52,3 +52,37 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
out — a source whose streamer was merely down during one run would silently vanish from the
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
configured nothing had nothing worth persisting.
## A configuration is a named session, and a source may hold several
One source, several arrangements: the same streamer serves different experiments, and retyping a
layout to look at the second one is how a session gets lost. Storage is therefore a **directory
per source** — `~/.config/udpscope/<host>_<port>/` — holding one `<name>.toml` per configuration
plus an `active` file naming the one the source is on. `configuration_path()` is the only
expression of the scheme, and the name is `sanitize()`d exactly as the host is, so a name typed
with a slash cannot escape the directory.
A configuration carries the whole session *and* the settings that can only be decided before the
first socket: `buffer_mb`, `relative_time`, `capture_packets`. The startup chooser applies them
(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed
— the one moment a buffer size can be applied at all. Switching configurations inside a running
app applies what can change live and says in the status line that the rest takes effect on the
next start; pretending otherwise would be a configuration that half-applies.
A configuration's *startup* settings reach the model from two directions, and both are needed:
`apply_source()` for a source picked in the chooser, and `apply_stored_settings()` for a source
named with `--server`, which never reaches the chooser at all. Without the second, a saved host's
buffer size, clock and capture ring were silently ignored — the layout still applied, because the
app loads that separately once a CONFIG names the signals — so an edit to any of the three read as
one that never took effect. **A flag the user typed wins over the file** (`--buffer-mb`,
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
`Config::buffer_mb_specified` is what tells "not asked" from "asked for the default".
The `active` file is written on every save and by the chooser's pick, so "which configuration was
I on" survives a restart. A session written before configurations existed is **imported once** as
the configuration named `default`, and the old file is renamed to `<host>_<port>.toml.imported`
rather than deleted: an import that cannot be undone by hand is a migration nobody can back out
of.
Nothing is lost by switching: the dialog saves the current session *before* loading the picked
one, which is the only ordering in which a switch cannot lose what it switches from.
@@ -144,3 +144,134 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
all three, or the marker, the cut and the gate would disagree with each other by the width of
a pulse.
## A latch is also a file
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are
collapsed until it is asked for, because a column of stale events beside a live plot is what a user
who has stopped looking at events does not want. What makes it *available* -- (recording is on) or
(an event is on screen) -- is decided in one place, `sync_events_availability()`, so the button and
the pane cannot disagree; a disabled cache with nothing being examined takes the button away *and*
collapses the pane, because a visible empty column is what "off" must not look like. Trigger mode is
deliberately **not** one of the conditions: a stored event being examined is a file, which has no
trigger, and tying the pane to the trigger would close the list under the click that opened an event.
The recording switch lives in the trigger panel, not in the pane it empties.
## A comparison is two plots of one model
*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a
comparison is **two plots** over it, and which plot draws what is only a matter of which channel *names*
its rows bind. `App::rows` is the primary (`RowSet`, area, gestures); `App::rows_b` is the second, with
its own `RowSet` and its own hidden `PlotArea` in a paned, deliberately without the drop target, the
right-click menu or the frame counter: those act on the primary's rows, and a menu on the second plot
that rearranged the first plot's rows would be a menu lying about its subject.
Rules that cost a defect each:
- **The layout is the user's and is mirrored, never reinvented.** The group build must not call
`set_row_count()`; it reads the rows' slot names and re-names the same slots for each layer
(`… (A)`, `… (B)` for a comparison; a bare name for layer 0 and `… #2`, `… #3` for the extra layers of
an accumulation). An *accumulation* puts its extra layers into free slots -- the row that holds the
signal first, then any row with room -- so the user's traces never move. The default layout is four
signals in one row, which is why `kSlotsPerRow` is 8: with four, "accumulate these two events" had
nowhere to put the second copy and silently did nothing.
- **A layer is not a signal.** A comparison's model holds one channel per (signal, layer), and the
*panel* must not treat the copies as signals of their own: the signal list shows one row per signal
(`layer_base_name()`, the shared rule that takes `(A)`, `(B)` and `#N` back off), `signal_color_index()`
counts *signals* and not channels -- counting channels spread one signal's colour across the palette
-- and both plots of a comparison bind their signal's layers to the *same* colour index, because two
colours would say they were different signals. The reading is `list=4` in `[events] compare plots`
for a two-event comparison of four signals.
- **A slot name carries the last group's suffix, so it must be stripped before it is re-derived**:
after a comparison the rows are named `sine (A)`, and a following accumulation that mapped that name
again bound to no channel at all (`traces0=0`, a plot with nothing in it). `base_name()` takes
`(A)`, `(B)` and `#N` back off.
- **The axes are mirrored from the ranges, once per frame.** The plotter owns its window and a gesture
reaches it through the plot's own setter with nothing announcing it, so the frame compares each row's
signature with the previous frame's: whichever side moved is the side the user touched, and the other
is set to match. The signature is `{span, y.min, y.max}` when the x axis has a **live edge**
(`x_live_edge()`, not merely an armed follow: an armed axis with nothing streaming keeps a *stored*
window, whose edges a pan moves and whose span never changes) and `{x.min, x.max, y.min, y.max}`
otherwise. Comparing the edges under a live edge would read as "both sides moved" on every frame --
the window is recomputed as a distance behind the newest sample -- and a zoom in one plot would be
overwritten by the other's drift. When both moved the first plot is the reference, which is also what
makes the frame after a build converge.
- **`set_y_range()` clears that axis' auto-scale**, by design: a mirrored zoom is a user decision, and
*Fit* re-arms it.
## A group of events is one model, built once
*Compare* and *accumulate* are the same machinery: several event files in **one** model, because the
buffers cannot hold two independent loads -- `load_waveform_into_model` calls `set_signals()`, which
rebuilds them, so a second load takes the first with it. So the group is built by hand: one channel per
(signal, layer) pair, `set_target_span()` to cover the whole group *before* `set_signals()` (or the
oldest samples are written past the ring's end), then all of the events pushed in one pass. A row is
one **signal** and its slots are the events, which is what makes a comparison like for like and an
accumulation a density; the trace alpha (0.35 while accumulating, 1 otherwise) is what turns agreement
into brightness.
Two things every group must do, both of which cost a defect to learn:
- **Stop the acquisition first.** A live producer keeps pushing into the model it was started with --
and it pushes *four* stream signals into the group's *eight* channels, so the group's own samples are
the ones that go missing. The reading that catches it is the samples the busy channel holds
(`samples0` in the `[events] compare` line): it must be exactly the number of stamps pushed, not
that plus whatever the stream added.
- **Free the slots the group does not fill.** A row carries the previous view's binding, and a trace
left in slot 2 of a two-layer row is drawn as if it belonged to the group.
*Compare* shifts the first event so its span *ends* where the second's *begins*, and fills the half a
sample does not belong to with **NaN** -- the plotter excludes non-finite samples from every series and
from every pyramid bucket, so a NaN is a gap and not a repeat, and no line is drawn across the join.
**A replay changes the data and frames it once; it never rearranges the layout.** A row plots a signal
*name*, and an event's export carries the same names the stream does, so loading the event into the
model leaves every row, slot, colour and the row count pointing at the right signal by itself. So
`replay_event()` must **not** rebind the rows (`bind_channels_to_rows()`, which is what a freshly opened
*file* needs) -- that is the user's work, and a click on a list is not a request to rearrange it. The
window is restated **once, on open** (`apply_time_window(span)`, the only path allowed to change the
scale), so the whole event is framed the moment it is opened and the user's own zooms are left alone
afterwards; never per frame. The measurements, the cursors and the readouts then report the stored
event, because they read the model. What a replay *does* change is the model's contents and the trigger:
it leaves trigger mode, without which the display stays `Held` and draws the old snapshot.
**A file view has nothing to acquire.** The signal list's acquire tick is *hidden* -- not disabled --
whenever a file is on screen (`App::waveform_name` non-empty: a replay, a comparison, a `--open`run).
The samples are stored, "acquire this signal" is a stream's question, and a greyed tick invites the user
to look for a reason there is none for. Going live rebuilds the panel, so the ticks come back.
**"The same length" is the same count at the same rate, not the same span to the sample.** Two events
this app recorded itself differ in span by the fraction of a sample the acquisition's window happened to
fill to (0.9999 s of a 1 s pre-window is normal). Demanding one sample interval made a comparison of two
just-recorded events refuse to open; the check allows four intervals or a millisecond, whichever is
larger, and the merged stamp grid carries the difference as a gap.
**A replay must leave trigger mode.** `display_mode()` answers `Held` whenever the model holds a
snapshot, and a held display draws *the snapshot*, not the live channels -- so replaying an event with
trigger mode still armed loaded the file into the model and left the old capture on screen: a click
that looks like it did nothing, with the event sitting in the ring unread. The replay also frames the
view with `apply_time_window(report.span_s)`, so the click shows the whole event rather than the part
of it that fell inside the live window.
The switch that decides recording belongs to the **trigger panel**, not to the pane it empties: it
is a trigger option (it decides whether a latch is written at all), and a pane must not be the only
place to reach the control that stops it.
Every published snapshot is written to the event cache while recording is on: its table (the *export*
format, so `--open` reads it back), a sidecar of what the list shows, and a preview drawn from the
samples. Three rules keep that honest:
- **The record happens on the GTK thread, at the end of the tick that framed the snapshot**, not on
the producer. The snapshot is taken by `shared_ptr` (a latch landing mid-write cannot pull the data
away), and a `record_pending` flag defers the write to the end of the frame so the rows are already
bound to the event when the picture is drawn. Recording from the producer would put file I/O
between two `recv()` calls, which is the one thing the packet capture's design also refuses.
- **Nothing a latch does may be expensive.** The write is a `write_npy` of the event's own window and
a 480x160 raster; a 2 s, four-channel event is ~230 kB of picture and ~600 kB of samples. The pane
is refreshed, not rebuilt, and a failure to write is a line on stderr -- a cache that cannot be
written must not stop the scope.
- **The preview is drawn from the data, never captured from the window.** The plot is a `GtkGLArea`:
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
class GTK-free, which is what makes the cache testable without a window.
+179 -1
View File
@@ -334,7 +334,7 @@ Two lines of the self-check output are assertions in disguise:
floor are the two refusals, and because the probe emits `::clicked` straight at the
widget it reaches the handler even when the button is insensitive, so `add above 0` there
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be
- `header: window_button_w=W bar_w=B` (stderr, once a second) — **`W` must be
a small constant and must not track `B`**: the window control is a header-bar child,
and an expanding one is measured into the title's centre box, so it stretched from the
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
@@ -801,3 +801,181 @@ Measured output (port 8419):
disarmed the trigger — so `placed=0` there is legitimate and asserts nothing about the search.
The load-bearing `placed=` is the `[search-probe] marks` line, emitted a frame after the jump,
which quotes the window the scope has actually moved to.
## `[capture-probe]`
`UDPSCOPE_PROBE_CAPTURE_MS` toggles the packet capture through the status-bar button's own
handler, then reads the ring file back off disk and prints one line:
```
[capture-probe] enabled=1 read_ok=1 path=/home/m/.cache/udpscope/127.0.0.1-9102/capture.bin slots=1000 stored=253 resident=253 cursor=253 torn=0 truncated=0 header_ok=1 first_seq=1 last_seq=253 payload=2031337 file=65576064 open_ms=2.9 t0=1791524811.925
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `enabled` | `1` after the probe's toggle; `0` when the open failed, with the reason on stderr | a toggle that sets a flag nobody acts on, or an open that failed silently |
| `read_ok`, `header_ok` | `1` | a file whose header does not round-trip — the format changed without the version bump |
| `stored` | datagrams since the toggle; equal to the exit dump's datagram count over the same interval | the producer storing more or fewer datagrams than the client handed it — a copy in the capture path, which the design forbids |
| `resident` | `min(stored, slots)`; equal to `slots` once wrapped | the ring reusing a slot before its `seq` was published — a torn slot that reads as a hole in history |
| `cursor` | `stored mod slots` | a cursor maintained out of step with `seq`; the file's hint would mislead the `xxd` user it exists for |
| `torn` | `0` | a slot whose bytes landed but whose `seq` never did *without* a crash — the release-store ordering is wrong |
| `truncated` | `0` | a datagram longer than `UDPS_MAX_DATAGRAM_BYTES` — the slot sizing disagrees with the client's receive buffer |
| `first_seq` | `1` on a fresh ring | the ordinal not starting where the file starts — a leftover file being appended to instead of renamed |
| `last_seq - first_seq` | `slots - 1` on a wrapped ring | ordering lost across the wrap: the newest slot is not the largest `seq` |
| `payload` | the bytes the wire delivered over the interval | a capture storing headers and not payloads, or truncating silently |
| `file` | `64 + slots * slot_bytes`, a constant for a given N | the reservation is short — which with `mmap` is the SIGBUS the design forbids |
| `open_ms` | single-digit ms on a local filesystem | a blocking filesystem between two `recv()` calls — the producer stall the popover's toggle costs |
With a **failing** open (the cache path colliding with a plain file, a full disk), the line reads
`enabled=0 read_ok=0` with every count 0, the toggle returns to off on the next tick, and the
popover row reads `failed: <strerror>` — one stderr line, no retry storm against a full disk.
## `[defaults]` and `[configs]`
```
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_mb=8 relative=0 capture=0 plots=1 signals=4 measures=0 math=0
[configs] after forget names=default
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `[defaults] calibrate:` | `1 0 model=1 0` | an unseeded spin button: `gtk_spin_button_new_with_range()` leaves the value at the range minimum, so the offset opened at -1e12 and Apply wrote it |
| `[defaults] calibrated:` | `2.5 -1.25 model=2.5 -1.25` — the dialog and the model agreeing after a calibration was set | a dialog that opens at the identity while the trace is drawn through a gain: pressing Apply "confirms" the calibration away. The two halves are printed so the assertion is that they *match*, not that either is a particular number |
| `[defaults] trigger:` | `0.001 0.002 0 0.001 0 50 0.01` | the same, per trigger field; a level of `-1e12` is a number nobody chose |
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_mb` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
## `[rowmenu]`
```
[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1
[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal]
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1
[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot]
[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
[rowmenu] remove-clicked 'sine' trace_freed=1
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `h` | 18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (`popover.udpscope-menu`) |
| `label_x` | 26 for every row, 52 for **Back** | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for |
| `accel_right`/`W` | equal when the row has a shortcut (`Fit all` → `F`), `-1` when it has none | the shortcut not flush right, or shown where the action has none |
| `remove_rows` | 1 per occupied trace slot, plus `All traces on this plot` when there is more than one, plus the **Back** row | a remove view built from the wrong row, or missing its way out |
| `remove_row_gone`, `remove_row_in_main` | 1 | a submenu that floats beside the menu instead of replacing it |
| `can_open_again` | 1 | the second click not reopening: the `GtkMenuButton`-owned submenu that collapsed and left its row dead |
| `bad` | 0 for every view | any row outside the compact style. Each view is measured a step after the click that swapped it in — in the same call the new child is unallocated (`h=0`), which reads like a style defect and is not one |
| `trace_freed` | 1 | a remove entry that dismisses the menu without taking the trace off the row |
## `[sources]`
```
[sources] form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 connect_sensitive=1
[sources] configuration created: 9.9.9.9:7000/probe-host note='made by the new-host probe' buffer=32 relative=1
[sources] picked 9.9.9.9:7000 multicast= interface= buffer=32 relative=1 capture=0 config=probe-host note='made by the new-host probe'
[sources] edit form: name='probe-host' note='made by the new-host probe' buffer=32 relative=1 button='Save'
[sources] configuration saved: 9.9.9.9:7000/bench note='edited by the probe' buffer=16 relative=0
[sources] 1 known: 9.9.9.9:7000/bench plots=0 signals=0 buffer=16 relative=0 capture=0 note='edited by the probe' |
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `form:` | the name, note, buffer and clock the form was given | a form field that is not wired: the four things a host could not be given before |
| `connect_sensitive` | 1 once the server is typed | the button's validation not following the entry |
| `configuration created`/`saved` | the identity the form held, with the settings | a save that writes somewhere other than what the form shows |
| `edit form:` … `button='Save'` | the **existing** configuration's values, pre-filled | the edit path not reading the file — an edit form that opens empty edits nothing |
| `known:` after a rename | one row, under the new name | a rename that leaves the old file, so the chooser lists the same session twice |
| `known:` for a settings-only configuration | a row, though `signals=0` | the listing filter treating "no signals yet" as "nobody configured this", which hides every host the form set up |
## `[sources] settings from configuration`
```
[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500
```
Printed by `apply_stored_settings()` when a source is named on the command line: the stored
configuration's startup settings, filled in where the command line was silent. A `buffer=` here
that disagrees with `--buffer-mb` is a flag being overruled by the file; a *missing* line, with a
configuration that carries settings, is the defect this exists for — a saved host opened with
`--server` ran on the defaults, so an edit to its buffer or clock never took effect.
## `[sources] waveforms` and the waveform load
```
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=1
[sources] picked waveform /tmp/rt.npy
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 span=2.000000s rate=10000 time=[-2.000000..0.000000]
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=1` | the Waveforms page not built, or a stale path offered as if it were openable |
| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads |
| `signals=`, `samples=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels |
| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
| `frames=0 packets=0` beside `channels=N` | 0 — no acquisition | a socket opened for a file, which would also make the starvation notice live |
## `[drop-probe]`
```
[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||]
[drop-probe] dropped signal 0 -> 1 slots=[sine|sine||]
[drop-probe] dropped signal 1 -> 1 slots=[sine|sine|square|]
```
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `rows` | the column count, capped at `RowSet::kMaxRows` — one row per trace | rows sized four signals to one row: the only row fills and *every* drop is refused ("plot 1 already holds four signals"), which reads as a scope that cannot plot anything |
| `dropped … -> 1` | 1, and the row gains that trace | a drop that cannot find a free slot, or a signal with no channel to bind |
| `channels` | one per signal | a file whose columns did not become channels, so nothing can be plotted at all |
## `[events]`
```
[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] dir=/tmp/k5/cache/udpscope/127.0.0.1-21023/events n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=1.000100 signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1
[events] activated row0: replaying=1 pane=1 pane_w=240 tick=0 window=2.000 thumbnail=1 thumb_size=126x42 thumb_visible=1
[events] replayed: channels=4 samples=20001 replaying=1 waveform=1
[events] live again: replaying=0 waveform=0
[events] recording off, trigger off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] recording off, trigger on: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1
[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1
[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1
[events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000
[events] group left: replaying=0 kind=0 waveform=0
```
`thumb_size` is the built size of the row's picture (96x32 logical, plus the CSS border), so it moves with
the font and the display's scale factor; what it must never be is `0x0`.
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `n`, `stem`, `seq` | one entry per latch, named by its time and the run's counter | two events in one second overwriting each other, or a cache that is never written |
| `dur` | `pre + post` for the event's window | the snapshot's own window not recorded, so the list disagrees with the picture |
| `signals`, `samples` | the event's channels and rows | a table built from the *rows* instead of the snapshot: only what happened to be plotted |
| `thumb_bytes` | a real 24-bit BMP (`54 + 480*160*3`) | a preview that is empty or missing -- the widget snapshot of a `GtkGLArea`, which is NULL |
| `recorded` | 1 while the trigger panel's switch is on | the switch not wired, or recording defaulting off (it must be on: the cache is for the event you did not expect) |
| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane |
| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot |
| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode |
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=0 pane=0 recording=0` in both | the pane surviving its own switch being turned off -- the empty column the item exists to prevent -- or the button coming back with trigger mode while the cache is disabled |
| `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both |
| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread |
| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
| `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored |
| `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once |
| `compare buttons` | `selected=2` and both buttons sensitive | the buttons reading something other than the selection, or staying insensitive with a valid pair selected |
| `compare` | `kind=1 rows=1 channels=8 samples0=33045 traces0=4`, `pane=1` | `samples0` above the stamps pushed -- a live producer left running, pushing four stream signals into the group's eight channels; `rows=1` because the layout is the user's and must not be re-derived; `traces0=4` because the four slots the layout holds are renamed, not replaced |
| `compare plots` | `area_b=1 rows_a=1 rows_b=1 list=4 same_x=1` | no second plot on screen, a second plot with a different row count, two plots not on one x window, or `list` above the signal count -- the panel listing each event's copy of a signal as a signal of its own |
| `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees |
| `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds |
| `group left` | `replaying=0 kind=0 waveform=0` | the group surviving *Live*: a file view with no file behind it |
+153
View File
@@ -220,6 +220,159 @@ as distinct from the fault injection below, which exercises ingest:
| **re-plan** | `--rate 100000` with the `mixed` flags, and `UDPSCOPE_PROBE_WINDOW_MS=4000 UDPSCOPE_PROBE_RESEQ_MS=6000 UDPSCOPE_EXIT_AFTER_MS=9000` | four `buffer rebuilt` lines before the reading, then `bands=64 bands_repaired=16 nan_slots=7800`: **a band is still only marked repaired when its own slots hold values**. A `bands_repaired == bands` beside a non-zero `nan_slots` here is the hole table and the producer's `tick_` surviving a re-plan that reset `produced()` — a band marked settled over samples that are all still NaN. The non-zero `overflows` is this case too: 100 kS/s with a 50 ms reorder delay keeps about 50 packets in flight against 64 slots |
The re-plan case needs `--rate 100000`, not the other four's 10 000: at 10 kS/s the ring
- `UDPSCOPE_PROBE_CAPTURE_MS` — toggles the packet capture **through the same handler the
status-bar button uses**, waits, then reads the file back off disk and prints one line:
`enabled= path= slots= stored= resident= cursor= torn= truncated= header_ok= first_seq=
last_seq= payload= file= open_ms= t0=`. `stored` is the datagrams-since-open counter and must
equal the dump's datagram count from the toggle's instant; `resident` is what the file holds,
which is the slot count once the ring has wrapped; `file` is the reserved size
`64 + slot_count * slot_bytes` and is a constant for a given N, so a mismatch against the file
on disk is a reading. Two cases, **run each as its own process**:
1. **enable at runtime** — the mock at `--rate 20000 --batch 200`, udpscope with
`UDPSCOPE_ENABLE_ALL_MS=700 UDPSCOPE_PROBE_CAPTURE_MS=1500 UDPSCOPE_EXIT_AFTER_MS=9000`
and no capture flag: `enabled=1 header_ok=1 torn=0 truncated=0 first_seq=1`, `stored` equal
to the datagrams since the toggle, `open_ms` a few ms.
2. **wrap** — the same with `--capture-packets=16` and
`UDPSCOPE_PROBE_CAPTURE_MS=7000 UDPSCOPE_EXIT_AFTER_MS=10000`: `stored` (870 in the
reference run) exceeds `slot_count`, `resident=16`, `last_seq - first_seq == 15`,
`torn=0`, and `cursor` agrees with `seq` (counter mod slot count). A wrap that loses
ordering, or a `cursor` that disagrees, shows here and nowhere else.
Then the converter over the wrap case's file: `tools/capture_to_pcap.py <capture> out.pcap`
must exit 0, print a record count equal to `slot_count` with `torn` 0, and the pcap's first
and last record timestamps must fall inside the run's wall-clock interval — both checked by a
`struct.unpack` of the pcap headers, nothing outside the standard library.
- **A gap must not kill a filtered trace.** `--relative-time` with the mock dropping
(`--drop-every 20`) and `UDPSCOPE_PROBE_MATH_MS=2500 UDPSCOPE_ENABLE_ALL_MS=600
UDPSCOPE_EXIT_AFTER_MS=9000`: every derived channel's dump line
`math '<name>': finite=N/4000` must read about 3800 — the tail minus the reserved holes —
and **`finite=0` beside a non-zero `hole_slots` is the defect** (a biquad that integrated the
hole's NaN, permanently). The filters' `sd` must stay near the no-drop value; run the same
command without `--drop-every` as the control, where every `finite` reads 4000.
- `UDPSCOPE_PROBE_CONFIGS_MS` — saves the running session as a configuration named `probe` with a
note, through `save_configuration_as()` (the same entry point the dialog's button uses), then
reads it back off disk. Reading: `save-as=1`; `names=` gains `probe`; `active=probe`; and the
round trip must carry the note **and** the startup settings —
`comment='written by the configs probe' buffer_mb=8 relative=0 capture=0`. A `buffer_mb=0` there
is the per-source buffer size not being written, which is the one setting a user notices only
after a capture has been sized wrong. Run it with a throwaway `XDG_CONFIG_HOME`; the probe
deletes its own configuration at the end (`after forget names=…`).
- `UDPSCOPE_PROBE_ROWMENU_MS` — gives row 0 a trace, opens the plot's context menu with
`open_row_menu()` (the one entry point `on_right_click()` wraps) and walks its two views one step
at a time (`main` → click **Remove signal** → `remove` → click **Back** → `after-back` → click
**Remove signal** again → `second-open` → click a trace), measuring each view's rows:
`[rowmenu] <view> N '<label>' h=18 label_x=26 accel_right=R/W ok=1` per row and one
`rows=/bad=/labels=` line per view, then `[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6
remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1
remove_row_gone=1 can_open_again=1` and `[rowmenu] remove-clicked 'sine' trace_freed=1`.
**Every `bad` must be 0** (the same compact row style as the app's own menu: one label column, a
shortcut flush right, 18 px rows — the **Back** row's icon indents its label, which every menu
does, so its `label_x` is 52 against the others' 26) and the three booleans must all read 1: the
remove view *replaces* the main one (`remove_row_gone`, `remove_row_in_main`), and it can be
entered again after leaving it (`can_open_again`) — that last one is the case a
`GtkMenuButton`-owned submenu failed, its row going dead on the second click. Each view is
measured a step after the click that swapped it in, because a popover allocates its new child on
the next layout pass.
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
`GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
dialog's own reader would then apply — which is exactly what the calibration offset did. It then
calibrates signal 0 through `set_calibration()` and reopens the dialog:
`[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25` — **the dialog must show the signal's stored
calibration**, and the two halves must match.
- The **chooser**, display-free: `UDPSCOPE_PROBE_SOURCES=1` prints `[sources] N known:` with one
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
`[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
configuration carried, into the `Config` the model is built from. A legacy file
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
holding `default`, and the old file renamed `.imported`.
- **The event cache round-trips, and only where it is available.** `UDPSCOPE_PROBE_EVENTS_MS` reads
the pane's availability first, in the state the app starts in -- `[events] live: button_visible=0
pane=0 recording=1`, **no pane and no button without trigger mode** -- then turns trigger mode on
through the real toggle (`[events] after trigger on: button_visible=1 pane=0`: the button appears,
the pane stays collapsed) and clicks the button, waits for a latch, then reads the cache and replays
the newest event through the pane's own row:
`[events] dir=… n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=… signals=4 samples=20001
rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1`, then
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
the pane's other precondition, through the trigger panel's switch: `[events] recording off, trigger
off` and `[events] recording off, trigger on` must **both** read `button_visible=0 pane=0
recording=0` -- the pane goes with its switch, in either trigger state. **`samples` must equal the
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
door. It then **waits for a second latch** (bounded: twelve retries) and drives the two group layouts
through the buttons: `[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1` (the
buttons read the selection), `[events] halve`… `[events] halves a_first=20001 a_second=0 b_first=0
b_second=20001` -- **the two halves of the ring, and the reading that says a comparison's gap is a
gap: the first event's channel is finite only in the first half and the second's only in the second**
-- `[events] compare: kind=1 rows=4 channels=8 samples0=40002 traces0=2 traces1=2 window=4.000
recording=1 pane=1` (`samples0` must be exactly the stamps pushed, **not more**: a live producer left
running pushes its own four signals into the group's eight channels), `[events] compare plots:
area_b=1 rows_a=1 rows_b=1 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1` (the second plot exists, is
on screen, mirrors the row count, and both are on one x window), then the sync -- the probe sets a y
range nobody would arrive at by accident on the *first* plot and reads the second a few frames later:
`[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1` -- and `[events] accumulate: kind=2 rows=1
channels=8 samples0=33045 selected=2 traces0=8 window=2.000` (`traces0=8`: the four signals the layout
holds plus one layer each for the second event, **in the same row**, which is what an accumulation
means). Run it with a throwaway
`XDG_CACHE_HOME` and `UDPSCOPE_EXIT_AFTER_MS` long enough for the second latch (20 s).
- **A waveform round-trips.** Export from a live run (`UDPSCOPE_EXPORT_MS` with
`UDPSCOPE_EXPORT_PATH`, against the mock at 10 kS/s), then open that file with `--open` and an
exit dump. The load must print
`[udpscope] waveform loaded: … signals=4 samples=20001 span=2.000000s rate=10000 time=[-2.000000..0.000000]`
— **the same row count, column count, span and time range the export probe reported**
(`rows=20001 cols=4 time=[-2.000000..0.000000]`) — and the dump must show `channels=4`,
`frames=0 packets=0` (no acquisition) and, per channel,
`20001 samples vs 20001 time slots … y=[-1.0000, 1.0000]`. A file that loads with fewer samples
than it was written with, or with `NaN` slots, is the reader mis-slicing its records.
- **A loaded file can be plotted.** `UDPSCOPE_PROBE_DROP_MS` drives the drop handler the way a drag
from the signal panel does, on a file opened with `--open`: it must read
`[drop-probe] signals=4 channels=4 rows=4 slots0=[sine|||]` — **`rows` equal to the column count**,
one trace per row — and each drop must return `1` and add its trace (`dropped signal 1 -> 1
slots=[sine|sine|square|]`). `rows=1 slots0=[sine|square|ramp|noise]` with every drop returning `0`
is the defect this exists for: rows sized four signals to one row fill the only row, so the app
reads as one that cannot plot anything. A file-mode run must also write **no session** for the
default `127.0.0.1:8000` (`App::source_port` is 0), or it overwrites that source's saved layout.
- **The chooser's Waveforms page**: `UDPSCOPE_PROBE_WAVEFORMS=1` prints
`[sources] waveforms: N recent <path>(ok|missing) … page=1`, and `UDPSCOPE_PROBE_WAVE=<path>` sets
the field the file dialog's callback sets and prints `[sources] picked waveform <path>` — then the
load line above, with no socket involved.
- **A `--server` launch reads the stored configuration.** With a throwaway `XDG_CONFIG_HOME`
holding `127.0.0.1_8101/bench.toml` (`buffer_mb = 64`, `relative_time = true`,
`capture_packets = 500`, `active` = `bench`), `./build/udpscope --server 127.0.0.1 --port 8101`
must print `[sources] settings from configuration 'bench': buffer=64 relative=1 capture=500`, the
`relative time:` line, and a `span=` of about 209 s (64 MB at 10 kS/s) — **not** the 200 MB
default's ~655 s. The same command with `--buffer-mb 8` must read `buffer=8` and `span=26.214s`:
**an explicit flag wins over the file**. Without this, the settings a host was edited to have were
ignored whenever the address came from the command line.
- The chooser's **form**, with `UDPSCOPE_PROBE_PICK_MS` scheduling it:
`UDPSCOPE_PROBE_NEWHOST=host:port` fills it and prints
`[sources] form: name='probe-host' note='…' buffer=32 relative=1 connect_sensitive=1`, then
`[sources] configuration created: host:port/probe-host … buffer=32 relative=1` and a
`picked … config=probe-host`; a following `UDPSCOPE_PROBE_SOURCES=1` must list it
(`buffer=32 relative=1 note='…'`) — a configuration the form wrote that the chooser does not
list cannot be edited next time. `UDPSCOPE_PROBE_EDITHOST=host:port` opens the same form on that
configuration and prints `[sources] edit form: name='…' note='…' buffer=… relative=… button='Save'`
— **the pre-fill is the assertion**: an edit form that opens empty cannot edit anything — then
saves and reports the new values. `UDPSCOPE_PROBE_EDITNAME=new-name` renames it as well: one
`known:` row afterwards, with the old file gone.
- `UDPSCOPE_PROBE_RESEQ_MS`'s neighbour for **reconnect**: the mock gains an EMI case,
`--blackout-at S --blackout D` — silent from second S for D seconds (D must exceed the
client's 2 s silence timeout), resuming from *now* so the link's backlog is what the link
ate. One run (`--blackout-at 4 --blackout 3.5`, `UDPSCOPE_EXIT_AFTER_MS=15000`) has to read:
every reconnect's `CONFIG:` line followed by `CONFIG unchanged: keeping the buffer and its
history` (a reconnect that re-plans wipes history, which is the defect this case exists for),
no `buffer rebuilt` line after the first CONFIG, `frames` climbing again after the blackout
with `gaps=0`, and a trigger armed *after* recovery (`UDPSCOPE_PROBE_KEYS_MS` past the
blackout) latching — the dump's `CAPTURING`/`EVENT HELD` and `publish cost: N events` with
N ≥ 1 are that proof.
already covers 838 s, so no window the control can reach moves `time_stride`,
`set_target_span()` returns without rebuilding, and the window probe reports `decim=1`
throughout — it re-plans nothing. At 100 kS/s the ring covers 83.9 s and the probe's