279 lines
22 KiB
Markdown
279 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())` **and** (the cursors toggle is active **or** `app.measures` is
|
|
non-empty) -- 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), and there is something to read. Inside, the
|
|
Cursors group (`strip_cursor_group`) is shown with the cursors on, the Measures group (`strip_measure_group`)
|
|
with measurements, the separator (`strip_sep`) with both; with neither the strip is hidden. Besides the tick,
|
|
the cursors toggle handler and `rebuild_measures_pane()` call it, so a toggle or an add/delete shows at once. 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` **plots** (rows are added
|
|
and removed one at a time by the layout editor, and a row can be split into up to
|
|
`kMaxCellsPerRow` cells side by side), each plot a
|
|
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots; `row(i)` indexes the plots row-major,
|
|
`figure_row_count()` counts the rows. The figure owns
|
|
everything the plots have to agree on: the x window (one x link group every plot 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.
|