runtime probe
The probe is a read-only C shim (containers/broadway/probe.c) LD_PRELOADed
into the unmodified packaged GNOME app inside the Broadway capture container.
After the first stable mapped frame it serializes the live widget tree to one
JSON document. A host-side matcher joins those records to the imported source
graph and merges the result into the Broadway comparison artifact as
origin: "native:<field>" facts at the top native confidence tier of the
Phase 2 scale (src/utils/nodeGeometry.ts) β GTK's own answer, one rung above
declared. Design record: docs/adr/0001-runtime-probe-and-remaining-roadmap.md.
Containment guaranteesβ
- The shim is fully inert unless
$PROBE_OUTPUTis set, andentrypoint.shsets it only when a/probevolume is mounted. Existing captures without the mount are byte-identical in behavior. - If GTK never initializes, the polling thread idles and nothing is written.
Every consumer treats a missing/malformed
probe.jsonas "no evidence", never as a failure. - Read-only: the shim calls only widget getters; its sole write is its own output file, published atomically (tmp + rename).
- All widget access happens on the GTK main thread. The constructor's pthread
only polls
gdk_display_get_default()and hands off viag_idle_add. - The C artifact is built and exercised only inside the capture containers; the web app never ships it.
Running a probed capture locallyβ
# 1. Build the runner (the probe compiles against the image's own GTK).
podman build -f containers/broadway/Dockerfile.fedora \
--build-arg APP_PACKAGE=gnome-calculator --build-arg APP_COMMAND=gnome-calculator \
-t protota-broadway-probe containers/broadway
# 2. Start it with a /probe volume β the opt-in that arms the probe.
mkdir -p .probe-local
podman run -d --rm -p 8085:8085 -v "$PWD/.probe-local:/probe:Z" protota-broadway-probe
# .probe-local/probe.json appears once the app's first frame settles.
# 3. Clone the version-matched source and run the comparison spec.
git clone --depth 1 --branch 49.2 \
https://gitlab.gnome.org/GNOME/gnome-calculator.git .gnome-source
BROADWAY_URL=http://127.0.0.1:8085/ BROADWAY_APP_ID=calculator \
BROADWAY_PRESET_ID=calculator \
BROADWAY_SOURCE_ROOT="$PWD/.gnome-source/src" BROADWAY_SOURCE_ENTRY=math-window.blp \
BROADWAY_PROBE_FILE="$PWD/.probe-local/probe.json" \
npx playwright test tests/broadway-reference.spec.ts
The comparison artifact (comparison-<app>.json) then contains a
runtimeProfile block, the raw dump is attached as probe-<app>.json, and
matched unresolved boundaries carry native:* facts. The standalone matcher
gives the same join without a capture:
npx tsx scripts/match-runtime-profile.mjs calculator \
--probe .probe-local/probe.json \
--source-root .gnome-source/src --entry math-window.blp
scripts/fidelity-report.mjs passes the environment through, so setting
BROADWAY_PROBE_FILE enriches its per-app artifacts the same way. In CI the
Broadway workflow mounts .probe-local automatically.
Per-app container recipes (Wave 3 findings, 2026-07-31)β
Some apps need more than the stock podman run to produce an honest capture;
these are measured facts from the fleet sweep, not guesses:
- gnome-software and gnome-control-center refuse or crash without a
connectable system bus and a non-root user. Run with
--userns=keep-id --user 1000and overrideAPP_COMMAND='export DBUS_SYSTEM_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS; β¦'(PackageKit/fwupd/NetworkManager are absent; plugins degrade gracefully). Settings additionally needsXDG_CURRENT_DESKTOP=GNOMEand a writableHOME(export HOME=/tmp). - nautilus refuses to run as root: same
--userns=keep-id --user 1000. - gnome-calendar starts maximized, which defeats the capture's
inset-window detection. Prepend
gsettings set org.gnome.calendar window-maximized false;toAPP_COMMAND. - Ear Tag is not packaged at its pinned version by either runner distro;
containers/broadway/Dockerfile.fedora-eartagmeson-installs the pinned source tag into the pinnedfedora:43GTK stack. This is the template for probing any Circle app the distros do not package. - weather compares against the generated preset (no
BROADWAY_SOURCE_ROOT): its GJS composite template omits the window parent class, which only the finishing file restores.
Probe output schemaβ
{
"probeVersion": 1,
"app": "gnome-calculator", // g_get_prgname()
"settleTicks": 5, // stability criterion used (PROBE_SETTLE_TICKS)
"widgets": [
{
"gtype": "MathButtons", // G_OBJECT_TYPE_NAME
"buildableId": "_buttons", // gtk_buildable_get_buildable_id, or null
"indexPath": [0, 2], // [toplevel index, sibling indicesβ¦]
"mapped": true, "visible": true,
"bounds": { "x": 0, "y": 356, "width": 360, "height": 260 },
// gtk_widget_compute_bounds vs. the toplevel,
// i.e. the same surface the screenshots use
"halign": "fill", "valign": "fill",
"hexpand": false, "vexpand": true,
"hexpandSet": false, "vexpandSet": true, // set-state, for Phase 2 geometry
"marginStart": 0, "marginEnd": 0, "marginTop": 0, "marginBottom": 0,
"widthRequest": -1, "heightRequest": -1,
"cssClasses": [],
"visibleChildName": null // GtkStack / AdwViewStack active page
}
]
}
Stability criterion: a mapped toplevel exists and its frame clock has ticked
N times (default 5, PROBE_SETTLE_TICKS overrides) with no toplevel
allocation change β mirroring the capture's data-protota-ready settle
logic, so screenshot and dump describe the same frame.
Matching rules (src/utils/runtimeProfile.ts)β
- Buildable ID first. A runtime widget whose buildable id equals a source-declared object id is that node; template children carry their XML ids through GtkBuilder. Position-independent.
- Structural fallback. Within an already-matched parent (every id-matched pair seeds alignment of its own subtree), children align by canonical gtype in order β the k-th source child of a class meets the k-th runtime child of it. GTK interposes runtime-only containers (AdwDialogHost, viewports), so alignment tolerates unknown siblings and looks exactly one level deeper, never more: an unbounded descent would fabricate joins. GtkStackPage wrappers are transparent on the source side because pages are not runtime widgets.
- Never pixels. Bounds are output evidence, never a matching key.
The report records matchRate (matched / matchable source nodes) plus
by-id and structural counts, so a weak join is visible in the artifact
rather than silently wrong. Matched nodes gain facts such as
{ property: "bounds", origin: "native:bounds", confidence: "native" };
an unresolved boundary that matches also records runtimeMatch
(matchedBy, buildableId, gtype, bounds) and its
geometryConfidence becomes native.
Consuming the evidence (#55, ADR 0001 consumers)β
applyRuntimeEvidence (src/utils/runtimeProfile.ts) wires a join into the
document the comparison renders, before the screenshot is taken:
- Suppression. A matched node the probe saw unmapped (or invisible) is
hidden at
native:visibleorigin. This is what turns Calculator'sconverter_boxdivergence from a hand guess into GTK's own answer β the runtime-invisible converter subtree stops crowding its siblings. - Boundary allocation. An unresolved boundary (childless
custom-widget) that matched a mapped widget takes exactly the probe-measured bounds (runtimeEvidenceon the node): the measurement was taken on the very surface the comparison renders and already contains GTK's expansion and sibling pressure, so the renderer applies it as a fixed region β no flex growth past it, no squeeze below it. The DOM marker and comparison artifact then carrynative:*facts andnativeconfidence, withruntimeMatchrecording GTK's own rect beside the rendered one.
Resolved nodes keep their statically imported geometry: the probe is evidence for what static import cannot settle, not a pixel overlay.
Probe-generated finishing entriesβ
A finishing override derived from a probe dump carries probeEvidence
(see scripts/generate-preset.mjs):
{ "id": "_converter", "set": { "visible": false },
"probeEvidence": { "probeVersion": 1, "buildableId": "_converter",
"expect": { "mapped": false } },
"why": "Native probe: _converter is unmapped in the default mode." }
The dump it came from is committed as presets-src/<app>.probe.json.
expect states what that dump must still say about buildableId
(checkable fields: mapped, visible, visibleChildName); on every
generation run validateProbeEvidence re-checks it and a stale entry β
missing dump, vanished id, or a dump that no longer says what the entry
claims β aborts generation loudly, exactly like a manual override whose
node id no longer matches the source. Fields the probe does not record
(e.g. label text) are rejected as probe evidence and stay hand-written.