Files
udpscope/docs/claude/architecture.md
T
2026-11-03 10:47:11 +01:00

21 KiB

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.

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). 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 on screen: 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() is false; 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, and the strip does not flash on a narrowing). 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). 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.