Files
udpscope/docs/claude/architecture.md
T

273 lines
22 KiB
Markdown

# Architecture: threads, the data path and the layers
The two threads and what they share, the four display modes, the display clock, and the layers to keep straight.
Part of [CLAUDE.md](../../CLAUDE.md).
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
including deliberate deviations from the spec and known limitations — read it before
concluding something is a bug.
### Threads
Exactly two: a producer thread in `Acquisition` owns a `DataSource` and calls
`source->poll(50)`; the GTK main thread owns every widget and GL context. They share
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_`
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
capture open sees which edge it belongs to.
### Sources
What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
- The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly --
there is no static-initialiser registration, which a linker may drop -- and a future `dlopen`
loader would only call `register_kind()`.
- A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is
source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept.
- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained
at the top of `poll()`. A panel never calls a source directly.
- `UdpsSource` wraps `deps/udpsclient` unchanged; `udps_*` names appear only in
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and
the data path together; do not bend one frame to carry two clocks.
- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-<id>`. A disconnected source
leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`).
- Session file naming for a non-UDPS kind: the pseudo-host is `<kind>-<identity>` (characters outside
`[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is
stored as `source_kind` and its settings as `source_opt_<key>`. A UDPS file keeps `<host>_<port>` and
has neither key, so a legacy session is read as UDPS and nothing existing moves.
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is
`Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main
thread, the source's lookup goes through `discover_broadcast`);
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
never reaches the UI files.
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital
input/counter poll and the waveform tick (every `1 / gen_rate` s, 20 ms by default; one synchronous DAC write per running channel, so a slow board lowers the rate it achieves, which the Analog out panel reports). There is no second thread. A connect attempt (discovery,
handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each
step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end
the producer thread, and `drain_commands()` runs inside the same `try` for that reason.
- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not
whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the
first one's. Digital lines and the counter are held across a block (a step trace).
- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes
and a tick callback throttled to 100 ms reads; a panel never touches the source's internals, and writes
only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`,
so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per
retry); that line is where a user sees why a connection failed.
- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec,
the validity rules and the decoders, with no GTK and no sockets (the probe round-trips it);
`flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form, a signal-row
editor (a name, a type, a count and, while the count is above 1, a "Vector as" drop-down per row). The
datagram has no description, so a list that is wrong is invisible except as dropped datagrams: the size
check is the only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per
size.
- A vector row is shown one of two ways, chosen per row (`Row::wave`, the `w` suffix in the text). As
**channels** it is `count` scalar channels of one sample per cycle, so the list batches like an
all-scalar one. As a **waveform** it is one channel with `rows = count` and
`sampling_rate = rate * count`, one datagram per `Frame` (`num_samples = 1`). The two cannot share a
list because the model spans a frame by its largest element block and `Frame::value` is 0 past a scalar's
one value: a scalar beside a vector block would be zero-padded, so `problem()` demands that a list with a
waveform row be waveform rows of one count, and the form shows that message in the subtitle. The channel
total is capped at 256.
- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the
sample count varies): `round(rate / 100)` datagrams for a list with no waveform row, a partial block
flushed after 100 ms. `rate` is required because `sampling_rate = 0` makes the model size the ring and
the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping
path that ignores the declared cadence.
- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets
`Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the
history survives (`CONFIG` is sent once, from the first `poll()`).
- The form's row editor removes a row from inside its own button's `clicked` emission (GTK keeps the emitter
alive to the end of the emission, and the handler copies the form and the row out of its context first);
every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have
destroyed the sibling widgets the handlers reach by raw pointer.
### The window's chrome: plot, slide-over, badge bar, dock
The main window is a plot with three things around it, each a unit of `src/ui_*.cpp` and none of them
touching the data path.
- **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second
child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is
a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its
own and registered under its id (`trigger`, `source-<id>`, `signals` (the signal list) and
`signal:<name>` (one signal's page, `src/ui_signal_page.cpp`, created on demand by `signal_page_ensure()`
and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with), `cursors` (the cursors
panel, unchanged) and `measure` (the measurements panel, unchanged; `m` toggles it); `events` (the full events list, `events-pane.ui`'s `events_box`: the Live, Compare and Accumulate
controls and the multi-select list with its thumbnails; open it only through `open_events_page()`, which
refreshes the list first, because the slide-over has no on-open hook). They are registered whether or not
they are open (a closed page's widgets exist, unmapped); `cursors` and `measure` are insensitive until the
stream has signals, by the same `update_signal_availability()` call that sets the dock's sensitivity, and
`events` follows `sync_events_availability()` (below). One page is open at a time and `App::slideover.page`
is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the
badge and a click on the plot close it; the click-away is a capture-phase observer (see
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an
`AdwToggleGroup` under the header is the segmented switch between them.
- **`BadgeBar`** (`src/ui_badges.cpp`) is the fixed-height row between the plot and the status line
(`main-window.ui` `badge_bar`, two slots: signals, left, a scroller holding one badge per enabled signal
and the `+`, or a single "N signals" badge past six; system, right, holding the **Trigger** and
**Source** badges). `sync_signal_badges()` rebuilds the signal badges (from `rebuild_signal_panels()`);
their order is `App::badge_order`, in memory only. A badge is a flat button whose `page` is the slide-over page it
toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a
file) leaves it **hidden** (the user asked for hide here, not the usual disable): `sync_badge_bar()` sets its visibility,
pick and focus from `slideover.source_pages` being non-empty every tick, so anything that indexes the badge
(the tour's `badges` step targets the whole bar, `[badges]`) must tolerate it being absent.
- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`), **280 px**
and fixed. It holds two things, the **Readouts** box and, under a separator, the **Events** block (below). The Readouts box (`src/ui_dock.cpp`, `build_readouts_dock()`): a
**Cursors** block (heading, an **Edit** button that opens the `cursors` page, a `GtkGrid` of a header row
`A B delta` and one line per page line) and a **Measurements** block (heading, **Edit** opening the
`measure` page, one line per measurement: chip, type, value; a click on a line opens that measurement's
dialog; no measurements shows one dim "no measurements" line). It is read-only and **owns no formatting**:
`update_cursors_pane()` and `update_measures()` hand the string they wrote to the page's label to
`dock_cursor_cell()` / `dock_measure_value()` as well, and `rebuild_cursors_pane()` /
`rebuild_measures_pane()` end by rebuilding the dock's lines to match (`dock_rebuild_cursors()`,
`dock_rebuild_measures()`). It holds no tab switcher, no revealer and none of the full panels, nor a source
kind's panels (those are slide-over pages). The window's measurement editor is a dialog
(`open_measure_dialog()`), not part of the `measure` page.
**The readouts strip** (`readouts_strip` in `main-window.ui`, between `plot_overlay` and `badge_bar` in
`left_column`; filled by `ui_dock.cpp`) carries the same readouts when the dock is not beside the plot: a flat
**Cursors** button and one chip (`who` + delta) per cursor line, then a **Measures** button and one chip per
measurement. `sync_readouts_strip()` shows it exactly when `!adw_overlay_split_view_get_show_sidebar() ||
adw_overlay_split_view_get_collapsed()` -- the Dock button put the dock away, or the window is collapsed and
the dock is an overlay on the plot (the strip stays on screen even while that overlay is open). The tick
calls it right after re-asserting `sidebar_wanted` and *before* `set_collapsed()` (so the split view's own
collapse-time `show-sidebar` write is never read); that means the `collapsed` it reads is the previous
frame's, and the strip follows a collapse or a widening one tick late, never through a wrong state. Its labels (`strip_cursor_who`, `strip_cursor_deltas`, `strip_measure_labels`) are rebuilt by
`dock_rebuild_cursors()` / `dock_rebuild_measures()` and written by `dock_cursor_cell()` /
`dock_measure_value()` from the same argument as the dock's, so there is no second formatter;
`dock_set_cursors_sensitive()` is the one writer of the cursor readouts' sensitivity (the dock's grid and
the strip's chip box).
The **Events** block (`build_events_dock()`, also `ui_dock.cpp`) is a heading with a **Manage** button
(`open_events_page()`) over a short list (`dock_events_list`, 112 px, scrolled, no thumbnails): one compact
row per `EventRecord`, newest first, capped at `kDockEventsMax` (24) with a dim "... N more, see Manage"
row after them. A row's cells are a one-character marker (filled = the event on screen; hollow = a member
of the group on screen -- dock only, the page's rows do not mark members; blank otherwise), the title
(`event_row_title`, ellipsized, full text in the tooltip) and `#<seq>`; **one click replays** it
(`replay_event()`, the body of the page's double-click handler). Both views are rebuilt from one
`EventStore::list()` by `refresh_events_pane()`, but on different cadences: **the dock follows every
latch** (`record_event()` refreshes it unconditionally), while the page's list is refreshed per latch only
while the page is open and by `open_events_page()` otherwise. `mark_events_on_screen()` updates titles and
markers in place and never rebuilds either list. The block, and the page, are insensitive (never hidden)
unless `sync_events_availability()` says `event_recording || replaying || events_cached` -- a different
rule from the spec's (see [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)).
There is no events pane, divider or header toggle any more: the page is reached from Manage (or by a
caller of `open_events_page()`), and the plot is never squeezed by events.
**When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour
overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over
too); the trigger form is registered as
a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and
`build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page.
Per-tick state (the Trigger word, the Source name, the open marks) is written by
`sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label
only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer
thread.
### Data path
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
value ring per channel, sized by `plan_partition()`: `--buffer-samples` ticks (rounded down to a power of two) per ring, halved
until the real bytes fit `--memory-limit-mb` (`Partition::memory_limited` records a clip), and the shared
clock decimated until `--max-window` seconds fit. The producer always writes it, whatever the trigger is doing —
that is what guarantees a full pre-trigger history on the *first* latch after arming.
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
immutable copy of the captured window; see README for why two live rings were dropped.
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
`RowSet::bind()` takes it as an argument rather than recomputing it:
- `Live` — no trigger: the live ring, on the clock of the **newest sample**
(`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age.
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
window is still filling: the live ring, on the coming event's own clock
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
post side fills in view and the marker sits at x = 0 throughout.
- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative,
so the display epoch is 0.
- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound,
but the axis is still a live one).
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
cuts the window at the newest sample and that partial snapshot becomes the held event, so
the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears
`pending_`, which drops the display out of `Capturing`, and with nothing published the view
fell back to the *previous* event — or to blank on a first capture. No event fired, so
`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published
snapshots instead, and the frame tick keys its event framing on *that*. Keep the two
distinct: the latch count is what the probes assert "how many events fired" with.
`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not
be refreshed till next event"), asked for explicitly; that reading is what the old "never
the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds
the snapshot *only* — a held event is never quietly replaced by live data — and that the
*axis* does not move during a capture: it is the event's window from the edge onwards,
never a sliding live view.
`display_mode()` must be read **once per frame** and passed around (the tick does this,
and hands it to `bind()`). The producer can open or close a capture between any two
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
different clocks.
**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only
conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in
one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the
picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the
two clocks goes through that one accessor rather than picking the branch itself — the export's
`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the
measurement marks — because there are four modes and picking the branch by hand gets one of them
wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard.
Three things about the live epoch specifically:
- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest
sample the *traces* can report, and asking before the acquire is one frame of production
stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis.
- **There is no feedback loop**, even though the newest stamp is read through the epoch that is
about to change: `RowSet::live_edge()` reports in the epoch currently in force, so
`display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one
frame rather than converged on.
- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and
an epoch of 0 there would make the axis jump on the frame a capture closes.
**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move
in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit
where the user put them while the waveform rolls under, and a measurement's mark names the sample
it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in
**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in
display coordinates would crawl forward at one window per window. `App::measure_window` is
stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the
window moved by 2 % of its span", and in display coordinates a live window is a fixed
`[-window, 0]` — it never moves, so the measurements would never be recomputed again.
Layers to keep straight:
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
to either a `ChannelBuffer` or an `EventSeries`.
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
and removed one at a time by the layout editor), each a
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
everything the rows have to agree on: the x window (one x link group every row joins),
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
samples, never the LOD pyramid's buckets.
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
the `tick()` frame function that binds, updates the trigger state and rewrites the
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
far the largest file.