Files
udpscope/docs/claude/invariants-session-and-export.md
T
Martino Ferrari 121abbefae feat: locked panes for a returning user, and the trigger is persisted
Two panes, two modes. **Locked** is what a returning user gets: the trigger pane shows one line saying
what it is set to, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — each heading has an `[edit]` button (the Adwaita `emblem-system-symbolic`,
swapping to `emblem-ok-symbolic` in configuration mode) that flips its pane. A **first run** — no session
file for that source — never reaches `apply_layout()` and comes up in configuration mode, because
configuring the host is what a first run is for. The mode is a mode and not a setting, so it is
deliberately not persisted.

The locked signal list drops the rows of every signal that is off, which is the whole of "show active
signals": the list already filters on nothing, so the lock is one `continue`. With the rows go the acquire
tick, the overflow menu (Information, Calibrate…, Delete, rename) and the `MATH` divider's `+` — and the
colour chip stops being clickable, because choosing a colour is configuration too. Dragging a signal onto
a plot stays: that is which view shows what, not what the scope is set up to do.

**The trigger is persisted, settings and state both** — the settings *even while it is off*, which is the
point: a trigger configured once and switched off is still the trigger you come back to. `LayoutState`
gains fifteen flat `trigger_*` keys; the channel is stored by *name* (every rebuild renumbers the
channels, the model's own rule) and the two enumerations as **the words the panel's dropdowns show**,
because an index ties the file to a declaration order — the lesson `math_op_at()` already carries. The
words are shared with the panel now (`trigger_type_text()` and friends build the dropdown arrays), so the
two cannot drift.

Two ordering traps, both found by running it:

- **The channel name resolves twice over.** `rebuild_trigger_signals()` picks up
  `App::pending_trigger_channel` when the stream arrives *after* the load, and `apply_trigger_layout()`
  resolves it itself when the channels are already there — without the second path the name stayed
  pending past the one rebuild that would have used it, and the trigger silently sat on channel 0
  (`summary='pulse width · sine …'` where the session said `square`).
- **The trigger's state is applied by pressing the control, not by setting a flag**: the toggle is set
  *outside* the `updating_panels` window `apply_layout()` uses, because a toggle set inside it emits
  `toggled` into a handler that returns at once and nothing would happen — no arming, no events button,
  no sensitivity.

Verified, in both modes and both directions:

    panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.3…0.6 · 20 % pre'
    panes: trigger=config signals=config body_visible=1 summary=''
    signals panel: labels=2 list=1 rows=7 signals=2 math=4 divider=1

The locked trigger pane is 42 px tall against 580 unlocked. A hand-written session round-trips: every
value it named came back in the panel's own controls, `trigger=1/1` (the state arrived and the pane's
switch followed it), and the file the app wrote back carried all fifteen keys with the untouched ones at
their defaults. `[side-tab-probe] … advanced=4` on its documented recipe; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon.

Docs: the `panes:` reading with what each field means, the corrected `labels=` note (the panel's frame is
counted above the list now, because the heading sits in a heading row), the invariants for both the
persistence and the lock, the README's deviation note, and the removal of its claim that the trigger has
never been persisted — it is now, and the locked view is built from exactly that.
2026-10-14 14:31:46 +02:00

10 KiB

Invariants: the session file, the export and the source chooser

What a session persists and in what order, when a save is refused, and when an export is collected.

Part of CLAUDE.md. These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.

  • The trigger is persisted, settings and state both. Its condition, its channel (by name), its pre-trigger share, hold-off and mode, and whether trigger mode was on — written even while it is off, because a trigger configured once and switched off is still the trigger you come back to. The two enumerations are stored as the words the panel's dropdowns show, not as their indexes: an index ties the file to a declaration order, which is the lesson math_op_at() carries, and the words are shared with the panel so the two cannot drift. The channel resolves when the acquired channels exist (rebuild_trigger_signals() picks up App::pending_trigger_channel, or apply_trigger_layout() resolves it itself when they are already there) — a name that resolved to nothing would silently move the trigger to channel 0.
  • A session that loaded is what locks the two panes. Locked is the returning user's mode: the trigger pane shows one compact line, the signal list only the signals that are on, and neither offers a control that changes the configuration. A first run — no session file for that source — never reaches apply_layout() and stays in configuration mode, which is what a first run is for. The mode is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts where a returning user expects to start.
  • The session is the layout. LayoutState persists what makes a reopened scope look like the one that was closed: the window, the rows and their slot signals, the calibration, the enabled signals, the chosen trace colours ([[color]], name + rgb = "#RRGGBB" — only the overrides, so a stream's new signals still take the positional palette and a saved session does not freeze a colour that is meant to follow the signal list), the math channels with their graphs, the measurements pane, and measure_stats — the measurements statistics toggle; written as a bare top-level boolean (measure_stats = true/false), defaults to false when absent. Signal identity is the name throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the model built -- an enabled signal is exactly one that produced a channel -- and apply_layout() installs the graphs before assigning the rows, since a row may hold a math channel's name and that name is only a signal once its graph is in. Two format rules, both learned the hard way: write an op or a measure type as its index through math_op_at()/measure_type_at(), never as the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so writing one and reading the other silently renames operations. And a top-level key written after a [[table]] belongs to that table in TOML: enabled = [...] emitted after [[calibration]] was parsed into the last calibration record and the enable state was lost without a word. mkdirs() used to create only components with a trailing slash, so ~/.config/udpscope was never made and the first save failed silently -- worth knowing when a layout change "does not stick".
  • Measurement options persist under named keys -- noise and hf -- so a file written before them loads at the defaults, which are exactly the behaviour it was written against: noise = 10 is the hysteresis band the app has always counted with, and hf = 0 is no filter.
  • The math node string is thirteen pipe fields, and the reader accepts twelve or thirteen. med_n is the thirteenth, after y; a twelve-field node is a file written before the de-noise node existed, and its med_n stays at the struct's default -- which is what such a file meant. Field 0 is the op as a math_op_at() index, which is why MathOp may only be appended to.
  • The export is collected at click time, not in the file-dialog callback. The live view keeps scrolling while the chooser is up, so a late collection would write a window the user never saw. The writer is picked by the chosen path's extension.
  • The chooser runs before ScopeModel exists, and --server is what decides. Config carries server_specified, set only by the --server branch, and main() asks only when it is false — so declining costs no buffer and no socket, and ScopeModel, Acquisition, RowSet and run_ui() need no notion of "no source yet". It is --server alone and not "no arguments": --multicast still needs a server to fetch CONFIG from. Every documented probe passes --server, which is what makes the feature invisible to the regression net — a probe added without it would reach the chooser and hang. gtk_init() plus a nested GMainLoop, and then run_ui()'s GtkApplication afterwards in the same process, is a verified combination (gtk_init() is idempotent; running your own loop is the documented GTK4 replacement for the removed gtk_dialog_run()).
  • A session file's name cannot say how its source was reached, so the file says it. <host>_<port>.toml carries neither the multicast group nor the interface, so a chooser row could not reconnect to a multicast source at all. LayoutState::multicast/interface_addr are written in the top block, before the first [[table]] — a bare key after a table header is that table's key in TOML, which is exactly how enabled was parsed into the last [[calibration]] record and lost without a word. A file from an older build has neither key and loads as unicast on the default route. The chooser's list filters to sessions that name at least one signal, because a probe run leaves a file per throwaway port and all of them hold signals = ["", "", "", ""]; known_sources() splits the filename at the last _, since sanitize() turns anything unsafe in a host into one.
  • save_layout_now() refuses to write while signals() is empty. A run against a source whose streamer was down receives no CONFIG, so every slot name stays empty and the save would overwrite a configured session with signals = ["", ...]. The chooser then filters that file 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.
  • It refuses again while replaying is set: a file view is not the source's session. Same argument, different cause — this view configured nothing about the stream. A plain replay leaves the rows where the user put them, so its save would merely be pointless; a group rebinds every slot to its own renamed channels and the save wrote signals = ["square (A)", …] into the file. Nothing noticed until the way back: Go live re-applies the session, those names match no live signal, so nothing was enabled and the live view came back with no channels at all — on the window the poisoned file carried, which is also how it swallowed the window restore below it. The reading is [events] live restored (after a group): the symptom was a stuck pending=1 beside channels=0, because the restore waits for a channel list that was never going to arrive. The explicit Save as is deliberately not guarded: that one is a user's own answer to "save this view", and its dialog reports the outcome.

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.