Files
udpscope/docs/claude/invariants-gtk-and-chrome.md
T
Martino Ferrari 7a982da6c4 feat: make test-ui checks the UI files with blueprint-compiler
The interface is hand-written GtkBuilder XML and nothing in the build can see a mistake in it: a
property a class does not have, or a child a container does not accept, is a Gtk-ERROR when the
window is built -- and Gtk-ERROR aborts the process, so a typo in a .ui takes the application down
rather than failing to open a dialog. xml-stripblanks catches malformed XML and nothing else, and
the probes catch the rest only for the windows they open. Three of those aborts happened during
this refactor.

tools/check_ui.py round-trips each file through blueprint-compiler: decompile to blueprint, then
compile that, which type-checks every property and child type against the GIR. The XML stays the
source of truth and stays hand-written -- this is a check, not a compiler, and nothing here is
needed to build the application. Which is why the target is opt-in (no ALL) and the tool is looked
for at configure time rather than required: without it the target is absent, not silently passing.

    $ make -C build test-ui
    check_ui: 5 files in src/ui
      ok    help.ui ... source-form.ui
    check_ui: 5 ok, 0 failed

Injected the exact mistake that cost a run earlier (an AdwEntryRow subtitle) and the check caught
it, with the caret and the "did you mean title?" hint, exiting non-zero. --lint prints the linter's
advice separately: it is advice, and the linter has opinions this project does not share.
2026-10-12 10:41:25 +02:00

24 KiB

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. 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::<prop> 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 <file> 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): 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", "<Control>e", "<Control>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 AdwDialogs and not GtkWindows: 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 <file> 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 GtkPictures 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-CRITICALs 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). 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 (<property name="icon">foo</property> 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 GtkWindows 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 AdwActionRows 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 <child>, 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.