# Invariants: GTK traps, the status bar, the icons and the help The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages. Part of [CLAUDE.md](../../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 status line's per-second fields are measured once a second, not per frame.** `App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same tick as the channel dump; the status line only formats what is already there. The badge's markup is likewise rewritten on a change of phase (twice a second), never on all sixty frames. - **A `notify::` handler takes three parameters, a `value-changed` handler two.** `GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while `GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns. That is why the trigger panel's spin buttons and its dropdowns have separate thin entry points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one `publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard. - **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it returns**, so the `updating_panels` flag — set around a programmatic write and cleared when it returns — does *not* suppress it. Where the write is part of a readout the handler reads back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading into the window entry and picks the unit for it, and the late notify had `on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into 499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection equal to it is ours, since GTK only notifies on a change. Any new control that both writes and reads a `GtkDropDown` needs the same shape; the flag alone is not enough. - **The header bar's buttons are icon-only**, so headless probes must reach them through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via `find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`, `measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into an internal box), and the cursors pane owns its own **Reset** button rather than the overflow menu owning one for it. The dump prints `panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and one of them with reality): it is computed on the first *tick*, because the dump runs after the window is destroyed and asking GTK about a widget then is four criticals, which is how the first version of that line earned them. What stays in the menu is what acts on the *data* (Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. - **Seven of this app's icons are its own, and they are a `GResource` compiled into the binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a measurement ruler, and the stock names those buttons used to carry said something else entirely: a *record dot* for the trigger and three different **magnifiers** for cursors, measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math,events}-symbolic.svg` are the five action icons; beside them in `src/icons/scalable/apps/` are the two **application** icons, `org.udpscope.app.svg` (128px colour artwork) and `org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an application's own icon and is where the **in-process** window icon resolves from; it is also the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell cannot read a GResource compiled into the binary). All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs `glib-compile-resources`) and reached through **one** `gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon, set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place ahead of both. `adw_init()` calls `gtk_init()`, which is what makes `gdk_display_get_default()` valid there. A resource rather than files beside the binary because udpscope is run straight out of the build tree and has no install step. Five authoring rules, each of which fails quietly: * **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is therefore a filled `path`** — including anything that wants to look stroked, which is drawn as a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the other. * **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the first draft placed features on half-pixels and the whole icon was a grey smear at 1x. * `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in. * Overlapping subpaths under the default nonzero rule must be **wound the same way** or they cancel instead of uniting. All of them run clockwise. * **A malformed SVG is a build error, and that is the only place it can be caught.** Every `` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which is illegal and which librsvg refuses the whole file for, fails the build with a file, a line and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen within comment`). It has to be here because **no runtime check can see it**: librsvg refuses the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and `gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it ships with libxml2, which GTK already requires. `gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see `toolbar icons:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an error, it draws `image-missing`. - **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them with `gtk_accelerator_parse()` — so they are `"space"`, `"e"`, `"slash"`, `"plus minus"` (a space-separated pair is the "either of these" form the label draws as `+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is the one field that is free text. A row added with a parse error is otherwise silent — the overview still opens and the row is simply blank — so read the probe run's stderr for `Failed to parse` after touching either table. - **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`. Those two used to read the *label*, and the label no longer carries `packets`, `gaps`, `plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce every documented `mode …:` assertion to four fields. The glance line's state field is **one word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`) for the layout reason above; a word **wider than the widest already there**, or a field whose width tracks the stream, breaks the row the plot is laid out under. The set may grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and sits **ahead of every other branch**, including the display mode's: with no signal list, every other word claims something that does not exist yet. The `mode` prose branches the same way for the same reason, which is what keeps the two tiers from disagreeing. - **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the tick updates it, so a rebuild is a per-frame relayout of a widget on screen. `update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in the dump is the assertion. - **The About dialog, the help window and the shortcut overview are opened by `show_about()`, `show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is exactly **one `GtkWindow`** among the three — the paged help window. It closes with `gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the math dialog fell into exactly this trap, and the pointer then points at a dead widget until the next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`) and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**: they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request` on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is the shape that prevents the double-open defect across all three. - **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at configure time (so `add_executable` can name the file) and again on every build through `udpscope_build_info` (so the commit and the timestamp belong to the binary that is running). `git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the `[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the probe reads `config_dir()`, which is the path the session file is written to. - **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and `'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y` tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`. - **A help page is one `.txt`, one `` line in the GResource manifest, and one `kPages` row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from `parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`. A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources). They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height at all inside a box, so without the request it is allocated nothing and the page renders its text with a silent gap where its screenshots should be — which is what the first version did. - **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.** Putting the events pane in a paned is what makes its width the user's to drag -- but with the position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before* zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is still needed, or the minimum would clamp even a zero position. - **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child = FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a 310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel window. Every vertical revealer in the side panel hides this, because there the leftover goes down instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane *is* open. The allocated width is how this was found -- the revealer's own measure said 0. - **A list box outlives the children of the pane it sits in, and the teardown order is not yours to choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four `Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is *not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a widget. - **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.** A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it out of the row -- the box gives a non-expanding child its natural size and grows it further when the expanding child has nothing left to take. Give the picture a **frame** whose size request equals the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather than 300. - **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's picture across `refresh_events_pane()`, which removes every row first: the next reader of that pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it. - **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the picture keeps a *paint-able* and takes an allocation, so a probe that checks `gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says so. A picture that must be on screen is also a picture with a **height request**: `can_shrink` alone asks for no height at all in a box. - **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore defers the unparent by one idle and holds a reference across it (`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the emission that dismissed it. - **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once Caps Lock is on. - **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live in `src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`, `prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`. `preprocess="xml-stripblanks"` catches a *malformed* file at build time -- which is why it is on every file in every bundle -- and that includes an XML rule easy to break by hand: **a comment may not contain a double hyphen**, so prose written the way every C++ comment in this project is written (` -- like this -- `) fails the build with a caret under the offending character. Use an em dash in a UI file. What it cannot catch is a file that is valid XML but wrong for GTK, and that is what `cmake --build build --target test-ui` is for: it round-trips every file through `blueprint-compiler`, which type-checks properties and child types against the GIR -- for *every* file, rather than for the windows a probe happens to open (see [build-and-run.md](build-and-run.md)). Without that check, `GtkBuilder` reports such a file with a **`Gtk-ERROR`, which aborts the process** by default, so a window built from bad markup takes the application down rather than failing to appear. Two more paid for: a `GIcon` property takes an **object**, not a name (`foo` is read as a reference to another object and fails with "Object with ID foo not found" -- the icon is a nested `GThemedIcon`); and a `.ui` big enough to exceed 4095 characters needs `-Wno-overlength-strings` on its generated source, because glib-compile-resources writes each file as one C string literal and the warning fires on generated *data*. - **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again. - **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a `GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an `AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`, and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test `GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a cancellable will do. - **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse. - **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()` on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar` stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar` draws them only as a titlebar. - **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An `AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and `AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" -- therefore belongs in the *group's* `description`, which is the better place for it anyway: a placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has **no `content` property**: its child is a plain ``, where an `AdwToolbarView` does take `content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by the probe that opens every window rather than by anything at build time. - **A dialog reachable from two windows cannot own its state in either.** The preferences open from the main window's menu *and* from the source chooser, which exists before `App` does (`main()` chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it means different things on the two sides. Both are plain members of their owners, on the same stack frame, so nothing can outlive the frame that built it. - **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window whose handlers point into a returned stack frame is how this project already earned one use-after-free.