Files
udpscope/docs/claude/invariants-session-and-export.md
T
Martino FerrariandClaude Opus 4.6 a7702acb63 docs: CLAUDE.md is a goal and a table of contents, so a context pays for what it reads
The file was 1967 lines and is auto-loaded into every context, so every
session paid for the whole of it to reach one invariant. It now carries the
project's goal, the three facts that are always relevant, and a table of
contents; the body is fourteen files under docs/claude/ reached by link
rather than by import, so nothing is loaded until it is asked for.

The content is moved verbatim -- the only edits are each file's own header
and the cross-references that became cross-file ("see the invariant below"
is now a link to the file that owns it).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 01:49:28 +02:00

4.2 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 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 math channels with their graphs and the measurements pane. 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".
  • 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.