docs(mosaic): preserve presentation identity probe evidence

This commit is contained in:
fy59 2026-10-12 04:56:07 +02:00
parent d19b15bfde
commit 006adf69d7
11 changed files with 1149 additions and 4 deletions

View file

@ -4,6 +4,7 @@
### STEP20C experimental evidence
- Added a source-only private presentation-identity probe at fixed negative-origin headless geometry. Native wlroots output present events paired with 470 accepted commits by output and `commit_seq`; applied scene/clip and sampled input agreed at the target states, but exact commit-buffer pixels remain unproven. STEP20C stays NEXT and production multi-output Mosaic unsupported.
- Added a source-only temporal damage fixture at the fixed negative-origin geometry. Ten XDG open/close cycles in each of three modes and sampled camera transitions restored popup regions without a detected ghost pixel; private damage/commit traces remain insufficient for a presented-frame guarantee. STEP20C remains NEXT and multi-output Mosaic unsupported.
- Added a source-only negative-origin nested-grab probe. Two outputs at `x=-800/0` verified signed pointer conversion, real standard popup crossing, sampled private Mosaic clipping, hidden-owner return, standard-layout return and a positive horizontal regression. Production STEP20C support remains open.
- Added a source-only stacked-output nested valid-grab probe. Both standard controls showed real popup overflow and pointer delivery below `y=600`; the private guarded Mosaic build clipped A/B at sampled lower points, preserved hidden-owner and standard-layout return, and reproduced the horizontal control. STEP20C production support remains open.

View file

@ -109,6 +109,22 @@ native transaction-generation contract binding applied scene, clip and hit
testing. The temporal evidence is **PARTIALLY_FEASIBLE**, not a production
rendering or ABI guarantee.
The [presentation identity probe](design/STEP20C_PRESENTATION_IDENTITY_PROBE.md)
then observed native wlroots 0.20.2 headless output commit and deferred
present callbacks in a separate private compositor. Each observed present
paired with its accepted native `(output, commit_seq)`, and a scene snapshot
inside each commit callback recorded the applied tiled position and clip.
Ten settled target transitions showed matching applied scene/clip values at
400 and 1280, while some faster command intervals had no commit. During
initial layout setup, a presented commit exposed logical camera 1760 with
applied scene and clip still at 1120; this reinforces the distinction between
requested and applied state. The applied scene and clip themselves agreed.
Headless present is an idle-delivered simulated event, and neither it nor
sampled `grim` images identify the pixels of that exact output-state buffer.
Pointer events remain a separate time-ordered stream. The result is
**PARTIALLY_FEASIBLE** and does not define a production transaction or
SceneFX ABI contract.
## IPC
On a Mosaic workspace, `get_tree` reports `layout: "mosaic"` and adds a `mosaic` object with `active_row` and `rows`. Each row entry contains ordered child node IDs, `scroll_x`, and focused node ID or null. Existing Sway fields retain their shapes; normal workspace/window events and `get_workspaces` paths remain in use. This is an extension to the Sway layout vocabulary: strict IPC consumers with a closed layout enum must recognize `mosaic`. Additional rows in the model are not a promise of usable multi-row IPC behavior.

View file

@ -70,6 +70,20 @@ cannot prove per-frame rendering, input/scene synchronization or damage
minimality. Popup effects, saved buffers and the SceneFX ABI/package gate
remain unqualified. Production multi-output Mosaic is **NOT YET SUPPORTED**.
The [presentation identity probe](design/STEP20C_PRESENTATION_IDENTITY_PROBE.md)
is **PARTIALLY_FEASIBLE** in the same fixed headless geometry. A private
listener paired 470 headless present callbacks with accepted native output
commits; 12 other accepted commits had no observed present and are not
classified as presented. Applied scene and clip snapshots at the commit
callback covered the measured target transitions, but no exact buffer
readback tied those values to the pixels of a particular commit. `grim`
captures, damage extents and pointer delivery cannot fill that missing
identity. Headless events are simulated, not evidence for physical DRM
presentation. A transient logical/applied camera lag was observed during
initial layout setup; no wrong applied clip or invisible hit was reproduced
in the targeted matrix. Production multi-output Mosaic remains
**NOT YET SUPPORTED**.
The [isolated feasibility probe](design/STEP20C_FEASIBILITY_PROBE.md) is **PARTIALLY FEASIBLE**, not a release qualification. On two horizontal headless outputs, real Mosaic content leaked visually into an empty adjacent output; that earlier disposable candidate also delivered pointer enter from a partly visible client outside its viewport. Negative-origin, scaled and rotated arrangements showed visual leakage in that candidate. Popups, effects, saved buffers and complete damage correctness remain unqualified across the full matrix. The later probes above narrow individual cases without qualifying multi-output Mosaic, which remains **NOT YET SUPPORTED**.
The later [SceneFX subtree clipping spike](design/STEP20C_SCENEFX_CLIPPING_SPIKE.md) is also **PARTIALLY FEASIBLE**. Its private inherited tree clip stopped tested tiled pixels and pointer hits from crossing the output boundary, including a fully hidden cell, but a real GTK XDG popup rendered and received input on the neighboring output because its scene tree lives in the global popup layer. Dedicated subsurface, saved-buffer, nested-popup and complete effect/damage validation remain open. The spike changed no production library or compositor source; multi-output Mosaic remains **NOT YET SUPPORTED**.

View file

@ -27,7 +27,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use
[Negative-origin source fixture](../tooling/probes/step20c-negative-origin/README.md) replays it in a disposable laboratory.
16. [STEP20C temporal damage probe](design/STEP20C_TEMPORAL_DAMAGE_PROBE.md): repeated XDG cycles, camera transitions, sampled pixel restoration and timed private damage/commit traces at the same negative-origin geometry; frame presentation remains unproven.
[Temporal source fixture](../tooling/probes/step20c-temporal-damage/README.md) rebuilds the private campaign.
17. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence.
17. [STEP20C presentation identity probe](design/STEP20C_PRESENTATION_IDENTITY_PROBE.md): native headless commit/present correlation and sampled applied camera/clip/input evidence; exact committed buffer content remains unproven.
[Presentation source fixture](../tooling/probes/step20c-presentation-identity/README.md) rebuilds the private instrumentation.
18. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence.
## Project
@ -67,6 +69,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use
| [STEP20C vertical-output nested-grab probe](design/STEP20C_VERTICAL_OUTPUT_NESTED_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases across the horizontal output boundary; no production qualification |
| [STEP20C negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases with HEADLESS-1 at `x=-800`; no production qualification |
| [STEP20C temporal damage probe](design/STEP20C_TEMPORAL_DAMAGE_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE for sampled repeated lifecycle/camera states; damage-to-presented-frame and transaction/input synchronization remain open |
| [STEP20C presentation identity probe](design/STEP20C_PRESENTATION_IDENTITY_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; native headless events pair with commits, but buffer pixels and per-frame input identity remain unproven |
A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation.

View file

@ -63,11 +63,24 @@ sampled private Mosaic pixels and standard controls, and time-ordered private
damage extents covered changed pixels between sampled captures. It did not
identify every presented frame or bind camera/clip/input to a native applied
transaction generation. Its [source fixture](../tooling/probes/step20c-temporal-damage/README.md)
remains experimental. The next bounded gate is a headless presentation-event
trace for one hide/return transition, with applied camera/clip and pointer
target. Scaling, rotation, resize/migration, complete effects and dependency
remains experimental. Its proposed headless presentation-event gate was
examined by the subsequent probe below. Scaling, rotation,
resize/migration, complete effects and dependency
maintenance remain open. STEP20C stays **NEXT**.
The [presentation identity probe](design/STEP20C_PRESENTATION_IDENTITY_PROBE.md)
is **PARTIALLY_FEASIBLE**: native wlroots 0.20.2 headless present events
were paired to commits by `(output, commit_seq)` and applied scene/clip
snapshots were taken at the commit callback. Settled target states passed
sampled clipping and input checks; faster commands could coalesce or defer.
The actual buffer content of each commit and frame-specific pointer relation
remain unproven. Its [private source fixture](../tooling/probes/step20c-presentation-identity/README.md)
does not enter the production build. The smallest remaining identity gate is
a bounded fingerprint/readback of the same retained output-state buffer at
commit and headless present, with safe buffer lifetime. Scaling, rotation,
migration/resize, complete effects/damage and SceneFX maintenance remain
open. STEP20C stays **NEXT**.
## STEP20D — multi-row Spatial Canvas · PLANNED
**Goal:** connect rows to runtime tree, transactions, scene, input, and IPC. **Planned deliverables:** active-row navigation, row-local scroll/focus, deterministic insertion/reflow, in-row reorder, and between-row movement. **Exit criteria:** real tiled containers preserve identity and order across row changes; focus and camera restore correctly; headless multi-row tests pass. **Docs first:** settle exact interactions and row invariants in UX and Architecture. Unset key combinations remain TBD.

View file

@ -0,0 +1,221 @@
# STEP20C presentation identity probe
**Status: EXPERIMENTAL / PARTIALLY_FEASIBLE.** STEP20C
remains **NEXT**; production multi-output Mosaic remains **NOT YET SUPPORTED**.
No production compositor, public ABI, package, patch or UX contract changes.
## Question and fixed scope
Can a private headless trace correlate an accepted output commit with its
backend `present` event, the applied Mosaic camera/clip state observed while
building that output state, and later pointer delivery? Repeat only
`scroll_x 400 → 1280 → 400` with the same mapped, genuinely grabbed nested
XDG popups A/B. HEADLESS-1 is `(-800,0,800,600)` and HEADLESS-2 is
`(0,0,800,600)`, scale 1, normal transform. Controls are unmodified SwayFX
`splith`, private SwayFX `splith`, and private Mosaic `mosaic`. Standard
controls have no synthetic camera action.
## Audited signal path
The installed `wlroots-0.20` package reports 0.20.2. Its installed
`wlr_output.h` exposes a per-output `uint32_t commit_seq`, `events.commit`,
`events.present`, and a present event with `uint32_t commit_seq`, `bool
presented`, `timespec when`, `unsigned seq`, `int refresh` in nanoseconds and
`uint32_t flags`. The sequence may wrap. The pinned upstream wlroots 0.20.2
source is tag commit `d783533489e1f75d6886c2ab5c5960090ef268f8`.
`wlr_output_commit_state` increments the native sequence only after a
successful backend commit, then emits `events.commit`. Its commit event has
a monotonic `when` and a borrowed state pointer valid during the callback.
The 0.20.2 headless backend creates a `presented=true` event with
`commit_seq = wlr_output->commit_seq + 1` for a commit leaving the output
enabled, then defers it to an event-loop idle callback. The core fills a
zero `when` with `CLOCK_MONOTONIC` at delivery. Headless initializes neither
hardware refresh sequence nor hardware presentation flags, and it has no
physical display. Allocation failure can suppress a deferred event. An
accepted commit, an idle `present`, a rendered buffer, a `grim` screenshot
and photons are distinct observations. The header alone cannot establish
buffer content or physical timing. Source references:
[`headless/output.c`](https://gitlab.freedesktop.org/wlroots/wlroots/-/blob/0.20.2/backend/headless/output.c),
[`types/output/output.c`](https://gitlab.freedesktop.org/wlroots/wlroots/-/blob/0.20.2/types/output/output.c).
SwayFX `sway/desktop/output.c` calls `output_configure_scene`, tests
`wlr_scene_output_needs_frame`, builds the output state, and calls
`wlr_output_commit_state` in `output_repaint_timer_handler`. Its existing
`present` listener updates refresh prediction but ignores unpresented
events. The private SceneFX subtree clip and owner visibility guard govern
render and hit testing; prior `TEMPORAL_DAMAGE` lines are output-local
bounding extents, not pixman region decompositions. Sway's logical
`scroll_x`, current container geometry and applied scene nodes are separate.
## Instrumentation contract
Apply a distinct, hash-checked patch only in a pinned private compositor
copy. Log native output identity and `commit_seq` from `events.commit` and
`events.present`, the monotonic callback time, present `when`, `presented`,
`seq`, `refresh`, `flags`, and the result of the repaint commit call. A
per-output listener must be removed before its output is destroyed. The
trace must not change commit, clip, damage, hit-test or transaction decisions.
Logging itself may affect timing and requires a control comparison.
At the last safely observable point before output-state build, record the
active workspace, Mosaic logical camera, owner identity and current geometry,
scene node position, and any accessible applied clip/owner visibility. Label
unavailable fields `UNOBSERVED`; never replace them with pending values.
Record a native sequence only when the callback or accepted commit provides
it. A scene snapshot taken before build is an ordered observation, not proof
of the pixels within the committed buffer. Event data and object pointers
must not be retained beyond callback/object lifetime.
Keep `TEMPORAL_DAMAGE`, `SPIKE_DAMAGE`, client XDG logs, real virtual-pointer
requests, IPC snapshots, and `grim` PNGs. The runner records capture start
and finish monotonic times, output, SHA-256, ROI and A/B color counts. Only a
native `(output, commit_seq)` match may pair commit and present; timestamp
order alone cannot do so. An accepted commit lacking present stays
unpresented in the observed trace. Multiple commands can coalesce before a
commit. Pointer enter/button and `popup_done` belong to their own timed
event stream and are never assigned a fictional frame number.
## Bounded matrix
1. Validate listener registration, native sequences, timestamps, output
separation and removal in one private headless run before repetition.
2. Open A and B with fresh real button serials; capture settled scene, IPC,
clips, XDG mapping, last commit/present and both output PNGs.
3. From 400, scroll +880; observe full hiding without a click. Scroll -880;
check same owner and popup identities, applied clips and restored pixels.
4. Repeat the settled pair five times, with per-pair event and capture IDs.
5. Send twelve controlled faster alternating requests; preserve command
times, coalesced or missing presentations and unobserved intervals.
6. In separate runs test B visible, a clipped portion, fully hidden owner
and return without prior popup closure. An outside click may dismiss the
grab; child-first `popup_done` is not an invisible-popup hit.
7. Compare the private standard layout with unmodified SwayFX, then compare
private builds with and without new presentation listeners at settled
positions. Preserve all failures, timeouts and protocol errors.
## Verdict and permanent limits
`FEASIBLE` requires real headless present events paired by native sequence
and demonstrated association of necessary applied state without a repeatable
regression. `PARTIALLY_FEASIBLE` covers partial event/state correlation with
unproven buffer identity, input relation or transitions. `BLOCKED` requires
a reproducible leak, invisible hit, lifecycle fault, crash or synchronization
violation. `INCONCLUSIVE` applies when the tested backend/instrumentation
cannot supply the needed signal or measurements cannot decide. Untested
cells remain **NOT TESTED**. Even `FEASIBLE` would not prove every presented
pixel, DRM behavior, production readiness, effects, saved buffers, scaling,
rotation, migration or SceneFX ABI maintenance.
## Measured private headless result
The private laboratory `/tmp/swayfx-mosaic-nested-grab-FQLULvot/` contains
two identity runs (the second adds a B click after camera return), one
complete A/B cycle per configuration, separate Mosaic hidden and outside
clicks, and four fixed negative-origin input controls. An earlier listener
availability pilot remains at `/tmp/swayfx-mosaic-nested-grab-TSp2JWyA/`.
The structured seven-run presentation analysis reports **482 native accepted commits**, **470 headless present
events**, **470 pairs by `(output, commit_seq)`**, **12 commits without an
observed present**, no orphan present and no rejected repaint call. All
present events have `presented=true`, `seq=0`, `refresh=0`, `flags=0`.
Every native commit callback captured a scene snapshot. The unmodified SwayFX
control has no private event listeners and is correctly **NOT TESTED** for
native event correlation. None of the 12 unpaired commits is labelled
presented. Both HEADLESS-1 and HEADLESS-2 emitted paired events; no paired
present callback or monotonic event timestamp preceded its own commit.
The 12 unpaired accepted commits are exactly native sequence 2 on both
outputs in six instrumented runs (Mosaic cycles, hidden click, outside click,
two identity runs, and private standard cycles). Each reports a new buffer and
`workspace=none` during initial output setup. Their cause is not established:
**UNEXPLAINED / NO PRESENT OBSERVED**. They are neither counted as presented
nor declared lost. The unmodified control has no native listener.
In the final identity run, ten settled transitions form five complete
400 → 1280 → 400 pairs. At the target-valued HEADLESS-1 commits, the
applied tiled scene translation is `-scroll_x` and its local clip starts at
`scroll_x` (56/56 target-valued commits, all with a paired headless present).
With A/B mapped, the root popup clip is nonempty at 400 and
empty at 1280. Nested B inherits that root clip; its own mapping identity
comes from client logs, not a separate root scene tree. All ten stable left
captures alternate zero A/B pixels when hidden and 42,200 A-yellow / 17,940
B-green pixels on return; right output has no sampled popup color. The same
owner IPC ID 8 and single A/B map each persist through the identity run.
A fresh B button after the final return was delivered to B before explicit
closure.
Each identity run issued twelve faster alternating commands. Four command
intervals in the pilot and three in the final run contained no HEADLESS-1
commit or present, while later intervals could contain more than one. This
is observable coalescence or deferral, not evidence that a command's target
was displayed. The final run had 125 commits and 123 associated presents;
the pilot had 121 and 119. Of 220 sampled captures across the seven-run
analysis, 200 overlapped one present callback during the capture call and
20 overlapped none. Even a single overlapping event does not identify the
buffer read by `grim`.
During initial Mosaic layout setup, five private Mosaic runs observed one
presented commit with logical `scroll_x=1760` while the applied scene was
translated by `-1120` and its clip began at 1120. This is a reproducible
logical-to-applied lag before the target experiment. The applied scene and
clip agreed with each other; the record does not establish a wrong rendered
pixel or hit target. No mismatch of those applied values was found in the
target 400/1280 commits. It illustrates why logical camera cannot be used
as a substitute for an applied scene snapshot.
Separate fixed-geometry input controls used real XDG button serials. Both
standard configurations delivered B buttons on the crossing portion of
HEADLESS-2 and had 15,860 B-green pixels there. Private Mosaic delivered B
inside the viewport; a click on the clipped crossing portion delivered no
B button and dismissed B then A under the grab. The separate hidden-owner
click likewise delivered no popup button and dismissed in child-first
order. Client event lines lack a common monotonic timestamp, so only runner
request times, callback times, event order and observed delivery are known;
the compositor's internal hit-test result at repaint time is **NOT TESTED**.
No pointer event is assigned to a specific frame. These clicks are not a
universal click-through UX decision.
An instrumentation-impact check compared one no-new-listener temporal cycle
with the new private cycle. Popup color counts at visible, hidden, returned
and closed sampled states, right-output absence, owner IPC ID/geometry/focus,
two valid grabs, popup mapping and absence of `popup_done` all agreed. This
does not exclude timing perturbations between samples. No crash, protocol
error, failed grab or reproducible invisible popup hit appeared in the
tested matrix.
**Verdict: PARTIALLY_FEASIBLE.** Native headless `present` is available and
can be paired with native output commits. Applied scene state can be read
at the commit callback, after output-state construction. Neither callback
proves which pixels occupy that commit's buffer; `grim` captures lack an
exact commit identity. The smallest next experiment is a private, bounded
buffer fingerprint tied to the actual output-state buffer at build/commit
and retained safely until its matching headless present callback, then
compared with a capture from that same buffer. It must establish buffer
lifetime and readback semantics first, without changing the production
renderer or SceneFX ABI.
The retained `presentation-results/presentation-artifacts-sha256.txt` lists
653 raw and derived files and has SHA-256
`d37dd26a95b895a115518c9b40b288822f71bac41dd08a2fda59cd30e0013e03`.
All 653 entries passed `sha256sum -c`. The five-input
`presentation-source-sha256.txt` has SHA-256
`40477a991f294b85c2828a8124e24379fba985695a583435a11de91a9005e92f`
and passed its five source checks. The distinct instrumentation patch has
SHA-256 `dbf1f1d11c9c741b641916d25ac22bcb13c3455ee3002e1f534aa6794cd8a538`.
The lab retains its private build log and Meson test log. Generated binaries,
PNG, sockets and logs remain outside Git.
## Protocol history
- 2026-10-12: API/backend/repaint source audit and protocol recorded before
a new private patch or experimental run. Measurements follow below.
- 2026-10-12: private event listener and applied-scene trace passed the
bounded headless matrix above. The result remains PARTIALLY_FEASIBLE.
- 2026-10-12: independently rebuilt the pinned private stack in
`/tmp/swayfx-mosaic-nested-grab-tMVZBojD/` and ran one 400 → 1280 → 400
cycle with A/B in private Mosaic, plus unmodified and private `splith`
controls. The three-run analysis found 127 accepted native commits, 123
matching headless presents, zero orphan presents, four initial sequence-2
commits without observed present (two per instrumented run), 70 captures,
and zero runner or protocol failures. The model Meson test passed. This
bounded replay confirms event correlation and settled sampled behavior;
it does not identify committed buffer pixels.

View file

@ -0,0 +1,119 @@
# STEP20C presentation identity fixture — EXPERIMENTAL
The [protocol](../../../docs/design/STEP20C_PRESENTATION_IDENTITY_PROBE.md)
precedes the private instrumentation. Only fixed two-output headless geometry
`HEADLESS-1 (-800,0)` and `HEADLESS-2 (0,0)`, both 800×600 at scale 1 and
normal transform, is in scope. STEP20C remains NEXT and production
multi-output Mosaic is NOT YET SUPPORTED.
The base is the published temporal fixture at Mosaic commit
`d19b15bfdeaa49ce81317c88a1baa250220a9998`: pinned SwayFX
`fd71a6bdc061bd633b488ae7b83e8a6981d22f86`, private Mosaic
`cd550382b311efdbfe3ed892f4e1d281d852e754`, and private SceneFX
`37ccd723bef49e6891156ffafce8f549f01446cc`, plus the historical
clip, popup guard and timing patches named in the
[temporal README](../step20c-temporal-damage/README.md). Installed wlroots
0.20.2 is audited against upstream tag commit
`d783533489e1f75d6886c2ab5c5960090ef268f8`.
The new [`presentation-events.patch`](presentation-events.patch), SHA-256
`dbf1f1d11c9c741b641916d25ac22bcb13c3455ee3002e1f534aa6794cd8a538`,
is applied only after the temporal private Mosaic patch in a disposable lab.
It adds a listener to the private compositor's internal output object,
logs native `commit_seq` and headless present event fields, and reads scene
position and clip in the accepted commit callback. The listener is removed
before output destruction. No public SceneFX ABI, headless backend,
production build, cumulative patch or package is changed. Neither a commit
callback nor a `grim` screenshot proves the pixels in a specific commit
buffer. Logging may perturb timing.
## Rebuild and replay
Requires the temporal fixture's Git/Meson/Ninja/C17, Wayland, Kitty, `grim`,
`wev`, `swaymsg` and Pillow dependencies. Run from a full clone containing
the pinned commits and this uncommitted phase-B fixture directory; when
starting from published `main`, copy this directory into that clone first.
The source manifest verifies the copied inputs. Preparation checks patch applicability with
`git apply --check`, builds private SceneFX and Mosaic, compiles private C17
clients and prints the laboratory and build log. All runtime sockets are
private; only the private compositor loads the private SceneFX library.
```sh
prep=$(sh tooling/probes/step20c-presentation-identity/prepare.sh)
printf '%s\n' "$prep"
LAB=$(printf '%s\n' "$prep" | sed -n 's/^LAB=//p')
(cd "$LAB" && sha256sum -c temporal-source-sha256.txt && \
sha256sum -c presentation-source-sha256.txt)
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-presentation-identity/run.py \
"$LAB" private_mosaic identity
for mode in control private_standard private_mosaic; do
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-presentation-identity/run.py \
"$LAB" "$mode" cycles --cycles 1
done
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-presentation-identity/run.py \
"$LAB" private_mosaic hidden_click
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-presentation-identity/run.py \
"$LAB" private_mosaic outside
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-presentation-identity/analyze.py "$LAB"
```
The `identity` run creates A/B once with genuine button serials, executes
five settled hide/return pairs and then twelve faster alternating requests.
After the last return, it sends a fresh B button without closing or
recreating either popup. `cycles` exercises explicit B then A closure;
`hidden_click` and `outside` are separate grab-dismissal runs. The runner
limits duration, captures and bytes, records exit codes, reaps only its own
process groups, and removes only its private runtime. Do not use the daily
Wayland or Sway IPC socket.
The [negative-origin input fixture](../step20c-negative-origin/README.md)
can also run `b_cross` for all three configurations and `visible` for private
Mosaic in the prepared lab. These runs show pointer delivery at the clipped
boundary, with their own `analysis.json`; they are not silently merged into
the seven-run presentation summary.
```sh
for mode in control private_standard private_mosaic; do
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-negative-origin/run.py \
"$LAB" "$mode" b_cross negative
done
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-negative-origin/run.py \
"$LAB" private_mosaic visible negative
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-negative-origin/analyze.py "$LAB"
```
## Correlation and retained evidence
`LAB/presentation-results/` contains `events.jsonl`, `commits.json`,
`presentations.json`, `camera-states.json`, `damage-analysis.json`,
`input-analysis.json`, `pixel-analysis.json`, `transitions.json`,
`matrix.json` and `summary.json`. The analyzer pairs only an actual native
`(output, commit_seq)`. The scene, owner and root-popup clip lines logged
inside that synchronous commit callback are associated with that callback.
It never assigns a `grim` PNG, pointer event or preceding damage extent to
a commit without proof. PNG records contain monotonic capture bounds,
output, SHA-256, fixed ROI and exact A/B color counts. Client event lines
lack a common monotonic timestamp; this is an explicit input limitation.
The measured laboratory is `/tmp/swayfx-mosaic-nested-grab-FQLULvot/`.
It retains a pilot identity run, the final identity run, five further
presentation runs, four separate crossing/visible input controls, private
builds and raw logs. The [report](../../../docs/design/STEP20C_PRESENTATION_IDENTITY_PROBE.md)
records the limited PARTIALLY_FEASIBLE verdict. The source manifest is
`presentation-source-sha256.txt`; the retained artifact manifest is
`presentation-artifacts-sha256.txt` under `presentation-results/`.
## Validation and cleanup
```sh
sh -n tooling/probes/step20c-presentation-identity/prepare.sh
PYTHONDONTWRITEBYTECODE=1 python -c 'import ast,pathlib; [ast.parse(p.read_text()) for p in pathlib.Path("tooling/probes/step20c-presentation-identity").glob("*.py")]'
meson test -C "$LAB/mosaic-build" --print-errorlogs
(cd "$LAB" && sha256sum -c presentation-source-sha256.txt)
git diff --check
```
Inspect `exit-status.json`, native event logs, XDG client traces, IPC trees,
PNG hashes and matrix assertions before a verdict. Keep failed runs. If
cleaning later, remove only the printed `LAB` and `BUILD_LOG` paths after
preserving their evidence; never clean all `/tmp` or alter the daily session.

View file

@ -0,0 +1,165 @@
"""Correlate native headless output events without inventing frame identity."""
import hashlib
import json
from pathlib import Path
import re
import sys
from PIL import Image
if len(sys.argv) != 2:
raise SystemExit('usage: analyze.py LAB')
lab = Path(sys.argv[1]).resolve()
results = lab / 'presentation-results'
results.mkdir(exist_ok=True)
def fields(line):
return {k: v for k, v in re.findall(r'([a-z_]+)=([^ ]+)', line)}
def save(name, value):
(results / name).write_text(json.dumps(value, indent=2) + '\n')
events, commits, presents, scenes, inputs, pixels, damage, matrix, transitions = [], [], [], [], [], [], [], [], []
repaint_results = []
for run in sorted((lab / 'runs').glob('*')):
status_path = run / 'exit-status.json'
if not status_path.exists():
continue
run_id = run.name
status = json.loads(status_path.read_text())
output_by_seq = {}
active_commit = None
for line in (run / 'compositor.log').read_text().splitlines():
match = re.search(r'\b(PI_OUTPUT_COMMIT|PI_OUTPUT_PRESENT|PI_SCENE|PI_OWNER|PI_POPUP|TEMPORAL_DAMAGE|SPIKE_DAMAGE|TEMPORAL_OUTPUT_COMMIT)\b(.*)', line)
if not match:
continue
kind = match.group(1)
data = {'run': run_id, 'kind': kind, **fields(match.group(2))}
events.append(data)
if kind == 'PI_OUTPUT_COMMIT':
key = (data['output'], int(data['commit_seq']))
if key in output_by_seq:
raise RuntimeError(f'duplicate native commit sequence in {run_id}: {key}')
data['commit_seq'] = key[1]
data['present_count'] = 0
data['scene'] = None
data['owners'] = []
data['popups'] = []
commits.append(data)
output_by_seq[key] = data
active_commit = data
elif kind == 'PI_OUTPUT_PRESENT':
data['commit_seq'] = int(data['commit_seq'])
data['matched'] = (data['output'], data['commit_seq']) in output_by_seq
if data['matched']:
output_by_seq[(data['output'], data['commit_seq'])]['present_count'] += 1
presents.append(data)
active_commit = None
elif kind in ('PI_SCENE', 'PI_OWNER', 'PI_POPUP'):
# CONTRACT: these synchronous callback lines belong to the
# immediately preceding commit callback, never to a later frame.
if active_commit is not None and data['output'] == active_commit['output']:
if kind == 'PI_SCENE':
active_commit['scene'] = data
scenes.append({'run': run_id, 'output': data['output'],
'commit_seq': active_commit['commit_seq'], **data})
elif kind == 'PI_OWNER':
active_commit['owners'].append(data)
else:
active_commit['popups'].append(data)
else:
if kind in ('TEMPORAL_DAMAGE', 'SPIKE_DAMAGE'):
damage.append(data)
if kind == 'TEMPORAL_OUTPUT_COMMIT':
repaint_results.append(data)
active_commit = None
runner_events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()]
starts = [e for e in runner_events if e['kind'] == 'transition_start']
for index, start in enumerate(starts):
begin = start['monotonic_ns']
end = starts[index + 1]['monotonic_ns'] if index + 1 < len(starts) else None
own_commits = [c for c in commits if c['run'] == run_id and
c['output'] == 'HEADLESS-1' and int(c['ns']) >= begin and
(end is None or int(c['ns']) < end)]
own_presents = [p for p in presents if p['run'] == run_id and
p['output'] == 'HEADLESS-1' and int(p['ns']) >= begin and
(end is None or int(p['ns']) < end)]
transitions.append({'run': run_id, 'series': start['series'],
'index': start['index'], 'requested_scroll': start['target'],
'start_ns': begin, 'next_command_ns': end,
'commit_count_in_interval': len(own_commits),
'present_count_in_interval': len(own_presents),
'observed_commit_scrolls': [c['scene'].get('scroll')
if c['scene'] else None for c in own_commits],
'command_to_frame_identity': 'UNPROVEN'})
for event in runner_events:
if event['kind'] in ('pointer_request', 'camera_observed', 'ipc', 'client_request'):
inputs.append({'run': run_id, **event})
if event['kind'] != 'capture':
continue
path = run / event['path']
image = Image.open(path).convert('RGB')
counts = {name: image.get_flattened_data().count(rgb)
for name, rgb in {'A_yellow': (224, 192, 32),
'B_green': (32, 208, 160)}.items()}
before, after = event['before_ns'], event['after_ns']
nearby = [p['commit_seq'] for p in presents if p['run'] == run_id and
p['output'] == event['output'] and before <= int(p['ns']) <= after]
pixels.append({'run': run_id, 'output': event['output'], 'tag': event['tag'],
'path': event['path'], 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'before_ns': before, 'after_ns': after, 'size': list(image.size),
'roi': [400, 90, 800, 310] if event['output'] == 'HEADLESS-1'
else [0, 90, 200, 310], 'exact_color_pixels': counts,
'present_events_during_capture': nearby,
'commit_seq_of_captured_buffer': None})
client = (run / 'client.log').read_text() if (run / 'client.log').exists() else ''
compositor_text = (run / 'compositor.log').read_text()
protocol_error = bool(re.search(r'protocol error|invalid serial',
compositor_text + client, re.I))
grabs = re.findall(r'POPUP_[AB] GRAB_REQUEST origin=wl_pointer.button '
r'target=\w+ serial=(\d+) used=(\d+)', client)
button_serials = set(re.findall(r'POINTER_BUTTON target=\w+ serial=(\d+) '
r'button=272 state=1', client))
valid_grabs = len(grabs) >= 2 and all(
serial == used and serial in button_serials for serial, used in grabs)
for line in client.splitlines():
if any(token in line for token in ('GRAB_REQUEST', 'POINTER_ENTER', 'POINTER_BUTTON',
'POPUP_DONE', 'MAP', 'DESTROY')):
inputs.append({'run': run_id, 'kind': 'client_line', 'line': line})
own_commits = [c for c in commits if c['run'] == run_id]
own_presents = [p for p in presents if p['run'] == run_id]
matrix.append({'run': run_id, 'runner': 'FAIL' if status['failure'] else 'PASS',
'accepted_commits': len(own_commits), 'present_events': len(own_presents),
'matched_present_events': sum(p['matched'] for p in own_presents),
'commits_without_present': sum(c['present_count'] == 0 for c in own_commits),
'scene_snapshots': sum(c['scene'] is not None for c in own_commits),
'valid_button_serials': 'PASS' if valid_grabs else 'INCONCLUSIVE',
'protocol_error': protocol_error,
'capture_count': sum(p['run'] == run_id for p in pixels),
'failure': status['failure']})
for name, value in [('commits.json', commits), ('presentations.json', presents),
('camera-states.json', scenes), ('damage-analysis.json', damage),
('input-analysis.json', inputs), ('pixel-analysis.json', pixels),
('matrix.json', matrix), ('transitions.json', transitions)]:
save(name, value)
(results / 'events.jsonl').write_text(''.join(json.dumps(e) + '\n' for e in events))
summary = {'accepted_commits': len(commits), 'present_events': len(presents),
'matched_present_events': sum(p['matched'] for p in presents),
'unmatched_present_events': sum(not p['matched'] for p in presents),
'commits_without_present': sum(c['present_count'] == 0 for c in commits),
'headless_presented_true': sum(p['presented'] == '1' for p in presents),
'presented_false': sum(p['presented'] == '0' for p in presents),
'repaint_commit_calls_rejected': sum(r['accepted'] == '0' for r in repaint_results),
'scene_snapshots': len(scenes), 'capture_count': len(pixels),
'captures_with_present_during_capture': sum(bool(p['present_events_during_capture'])
for p in pixels),
'runs': len(matrix), 'runner_failures': sum(x['runner'] == 'FAIL' for x in matrix),
'invalid_or_unproven_grabs': sum(x['valid_button_serials'] != 'PASS' for x in matrix),
'protocol_error_runs': sum(x['protocol_error'] for x in matrix),
'buffer_content_identity': 'UNPROVEN'}
save('summary.json', summary)
print(json.dumps(summary))

View file

@ -0,0 +1,24 @@
#!/bin/sh
# EXPERIMENTAL: build a private compositor with native commit/present tracing.
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
fixture="$repo/tooling/probes/step20c-presentation-identity"
prep_log=$(mktemp /tmp/mpi-prepare-XXXXXXXX)
if ! sh "$repo/tooling/probes/step20c-temporal-damage/prepare.sh" > "$prep_log" 2>&1; then
printf 'PRIVATE_BUILD_FAILED log=%s\n' "$prep_log" >&2
tail -40 "$prep_log" >&2
exit 1
fi
lab=$(sed -n 's/^LAB=//p' "$prep_log")
test -n "$lab" && test -d "$lab"
trap 'rm -rf -- "$lab"' 0
# CONTRACT: apply only to the already-patched private Mosaic source;
# never to this repository's production compositor or installed libraries.
(cd "$lab/mosaic" && git apply --check "$fixture/presentation-events.patch" &&
git apply "$fixture/presentation-events.patch")
meson compile -C "$lab/mosaic-build" -j 8 >> "$prep_log" 2>&1
sha256sum "$fixture/presentation-events.patch" "$fixture/prepare.sh" \
"$fixture/run.py" "$fixture/analyze.py" "$fixture/README.md" \
> "$lab/presentation-source-sha256.txt"
trap - 0
printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$prep_log"

View file

@ -0,0 +1,142 @@
diff --git a/include/sway/output.h b/include/sway/output.h
index 3d1d1ad1..34aa33e1 100644
--- a/include/sway/output.h
+++ b/include/sway/output.h
@@ -64,6 +64,8 @@ struct sway_output {
struct wl_listener layout_destroy;
struct wl_listener destroy;
struct wl_listener present;
+ // Private STEP20C trace: removed before output destruction.
+ struct wl_listener presentation_probe_commit;
struct wl_listener frame;
struct wl_listener request_state;
diff --git a/sway/desktop/output.c b/sway/desktop/output.c
index cd5eed21..fdf93421 100644
--- a/sway/desktop/output.c
+++ b/sway/desktop/output.c
@@ -37,6 +37,7 @@
#include "sway/tree/root.h"
#include "sway/tree/view.h"
#include "sway/tree/workspace.h"
+#include "sway/tree/mosaic.h"
#if WLR_HAS_DRM_BACKEND
#include <wlr/backend/drm.h>
@@ -365,6 +366,83 @@ static bool output_can_tear(struct sway_output *output) {
// INVARIANT: repaint and commit decisions are unchanged by the trace.
static uint64_t temporal_output_commit_ordinal;
+// WHY: the private headless experiment needs native output event identity.
+// CONTRACT: all scene values are read while their owning output is alive;
+// this trace never changes render, damage, clipping or commit decisions.
+// INVARIANT: event.commit sees the accepted native commit_seq, while a
+// later present event can only be paired by (output, commit_seq).
+static uint64_t presentation_probe_now_ns(void) {
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ return (uint64_t)now.tv_sec * UINT64_C(1000000000) + now.tv_nsec;
+}
+
+static void presentation_probe_scene(struct sway_output *output) {
+ struct sway_workspace *ws = output->current.active_workspace;
+ if (!ws) {
+ sway_log(SWAY_INFO, "PI_SCENE output=%s workspace=none ns=%" PRIu64,
+ output->wlr_output->name, presentation_probe_now_ns());
+ return;
+ }
+ struct wlr_box clip = {0};
+ bool has_clip = wlr_scene_tree_get_clip(ws->layers.tiling, &clip);
+ double scroll = -1;
+ if (ws->current.layout == L_MOSAIC && ws->mosaic && ws->mosaic->rows->length) {
+ struct mosaic_row *row = ws->mosaic->rows->items[0];
+ scroll = row->state.scroll_x;
+ }
+ sway_log(SWAY_INFO, "PI_SCENE output=%s ns=%" PRIu64
+ " workspace=%zu layout=%d scroll=%.0f tile_scene=%d,%d"
+ " clip_valid=%d clip=%d,%d,%d,%d",
+ output->wlr_output->name, presentation_probe_now_ns(), ws->node.id,
+ ws->current.layout, scroll, ws->layers.tiling->node.x,
+ ws->layers.tiling->node.y, has_clip,
+ clip.x, clip.y, clip.width, clip.height);
+ if (ws->current.tiling) {
+ for (int i = 0; i < ws->current.tiling->length; i++) {
+ struct sway_container *con = ws->current.tiling->items[i];
+ int x = 0, y = 0;
+ bool coords = con->scene_tree &&
+ wlr_scene_node_coords(&con->scene_tree->node, &x, &y);
+ sway_log(SWAY_INFO, "PI_OWNER output=%s ns=%" PRIu64
+ " id=%zu current=%.0f,%.0f,%.0f,%.0f"
+ " scene_valid=%d scene=%d,%d applied_size=%d,%d",
+ output->wlr_output->name, presentation_probe_now_ns(),
+ con->node.id, con->current.x, con->current.y,
+ con->current.width, con->current.height, coords, x, y,
+ con->animation_state.current_width,
+ con->animation_state.current_height);
+ }
+ }
+ if (root->layers.popup) {
+ struct wlr_scene_node *node;
+ unsigned popup_index = 0;
+ wl_list_for_each(node, &root->layers.popup->children, link) {
+ if (!scene_descriptor_try_get(node, SWAY_SCENE_DESC_POPUP)) continue;
+ struct wlr_scene_tree *tree = wlr_scene_tree_from_node(node);
+ struct wlr_box box = {0};
+ bool valid = wlr_scene_tree_get_clip(tree, &box);
+ sway_log(SWAY_INFO, "PI_POPUP output=%s ns=%" PRIu64
+ " local_index=%u tree=%p enabled=%d clip_valid=%d clip=%d,%d,%d,%d",
+ output->wlr_output->name, presentation_probe_now_ns(),
+ ++popup_index, (void *)tree, node->enabled, valid,
+ box.x, box.y, box.width, box.height);
+ }
+ }
+}
+
+static void presentation_probe_handle_commit(struct wl_listener *listener, void *data) {
+ struct sway_output *output = wl_container_of(listener, output, presentation_probe_commit);
+ const struct wlr_output_event_commit *event = data;
+ sway_log(SWAY_INFO, "PI_OUTPUT_COMMIT output=%s commit_seq=%" PRIu32
+ " ns=%" PRIu64 " event_ns=%" PRIu64 " has_buffer=%d",
+ output->wlr_output->name, output->wlr_output->commit_seq,
+ presentation_probe_now_ns(),
+ (uint64_t)event->when.tv_sec * UINT64_C(1000000000) + event->when.tv_nsec,
+ !!(event->state->committed & WLR_OUTPUT_STATE_BUFFER));
+ presentation_probe_scene(output);
+}
+
static int output_repaint_timer_handler(void *data) {
struct sway_output *output = data;
@@ -540,6 +618,7 @@ static void begin_destroy(struct sway_output *output) {
wl_list_remove(&output->layout_destroy.link);
wl_list_remove(&output->destroy.link);
wl_list_remove(&output->present.link);
+ wl_list_remove(&output->presentation_probe_commit.link);
wl_list_remove(&output->frame.link);
wl_list_remove(&output->request_state.link);
@@ -576,6 +655,15 @@ static void handle_layout_destroy(struct wl_listener *listener, void *data) {
static void handle_present(struct wl_listener *listener, void *data) {
struct sway_output *output = wl_container_of(listener, output, present);
struct wlr_output_event_present *output_event = data;
+ sway_log(SWAY_INFO, "PI_OUTPUT_PRESENT output=%s commit_seq=%" PRIu32
+ " ns=%" PRIu64 " event_ns=%" PRIu64
+ " presented=%d seq=%u refresh=%d flags=%" PRIu32,
+ output->wlr_output->name, output_event->commit_seq,
+ presentation_probe_now_ns(),
+ (uint64_t)output_event->when.tv_sec * UINT64_C(1000000000) +
+ output_event->when.tv_nsec,
+ output_event->presented, output_event->seq,
+ output_event->refresh, output_event->flags);
if (!output->enabled || !output_event->presented) {
return;
@@ -690,6 +778,8 @@ void handle_new_output(struct wl_listener *listener, void *data) {
output->layout_destroy.notify = handle_layout_destroy;
wl_signal_add(&wlr_output->events.destroy, &output->destroy);
output->destroy.notify = handle_destroy;
+ wl_signal_add(&wlr_output->events.commit, &output->presentation_probe_commit);
+ output->presentation_probe_commit.notify = presentation_probe_handle_commit;
wl_signal_add(&wlr_output->events.present, &output->present);
output->present.notify = handle_present;
wl_signal_add(&wlr_output->events.frame, &output->frame);

View file

@ -0,0 +1,427 @@
"""EXPERIMENTAL presentation-identity headless campaign. See README."""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('lab', type=Path)
parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic'))
parser.add_argument('kind', choices=('cycles', 'identity', 'outside', 'hidden_click', 'toggle'))
parser.add_argument('--cycles', type=int, default=10)
parser.add_argument('--moves', type=int, default=18)
parser.add_argument('--max-seconds', type=int, default=900)
parser.add_argument('--max-captures', type=int, default=400)
parser.add_argument('--max-bytes', type=int, default=250_000_000)
args = parser.parse_args()
if not 1 <= args.cycles <= 30 or not 1 <= args.moves <= 40:
parser.error('cycle/move bounds exceeded')
if args.kind in ('identity', 'hidden_click', 'toggle') and args.mode != 'private_mosaic':
parser.error('camera/toggle requires private_mosaic')
lab = args.lab.resolve()
if not (lab / 'nested-client').is_file():
parser.error('prepared LAB required')
run = lab / 'runs' / f'{args.mode}-{args.kind}-{time.time_ns()}'
run.mkdir(parents=True, mode=0o700)
runtime = Path(tempfile.mkdtemp(prefix='mtd-', dir='/tmp'))
if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108:
raise RuntimeError('IPC socket path exceeds sockaddr_un')
(run / 'runtime-path').write_text(str(runtime) + '\n')
(run / 'config').write_text(
'output HEADLESS-1 resolution 800x600 position -800 0\n'
'output HEADLESS-2 resolution 800x600 position 0 0\n'
'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n'
'focus_follows_mouse no\n')
env = os.environ.copy()
env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless',
WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman')
for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'):
env.pop(key, None)
compositor_env = env.copy()
binary = Path('/usr/bin/sway') if args.mode == 'control' else lab / 'mosaic-build/sway/swayfx-mosaic'
if args.mode != 'control':
compositor_env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build')
files, children, events = [], [], []
started = time.monotonic()
capture_count = 0
capture_bytes = 0
def record(kind, **fields):
entry = {'monotonic_ns': time.monotonic_ns(), 'kind': kind, **fields}
events.append(entry)
with (run / 'events.jsonl').open('a') as out:
out.write(json.dumps(entry) + '\n')
return entry
def spawn(argv, log_name, *, stdin=False, debug=False):
log = (run / log_name).open('w')
files.append(log)
child_env = env.copy()
if debug:
child_env['WAYLAND_DEBUG'] = '1'
proc = subprocess.Popen(argv, env=child_env, stdin=subprocess.PIPE if stdin else None,
stdout=log, stderr=subprocess.STDOUT,
start_new_session=True, text=stdin)
children.append(proc)
return proc
def wait(predicate, seconds=12):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if time.monotonic() - started > args.max_seconds:
raise RuntimeError('run duration bound exceeded')
result = predicate()
if result:
return result
if compositor.poll() is not None:
raise RuntimeError(f'compositor exited {compositor.returncode}')
if 'pointer' in globals() and pointer.poll() is not None:
raise RuntimeError(f'pointer exited {pointer.returncode}')
if 'client' in globals() and client.poll() is not None:
raise RuntimeError(f'client exited {client.returncode}')
time.sleep(.04)
raise RuntimeError('timeout waiting for event')
def ipc(*argv):
completed = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env,
text=True, capture_output=True, check=True, timeout=8)
result = json.loads(completed.stdout)
record('ipc', argv=list(argv), result=result)
return result
def command(value):
result = ipc(value)
if not isinstance(result, list) or not result or not all(x.get('success') for x in result):
raise RuntimeError(f'IPC command rejected: {value}: {result}')
def find(node, predicate):
if isinstance(node, dict):
if predicate(node):
return node
for child in node.get('nodes', []) + node.get('floating_nodes', []):
found = find(child, predicate)
if found:
return found
return None
def tree():
return ipc('-t', 'get_tree')
def workspace():
return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1')
def owner():
return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe')
def client_text():
return (run / 'client.log').read_text()
def count(marker):
return client_text().count(marker)
def configured(name):
matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)',
client_text())
if not matches:
raise RuntimeError(f'no {name} role configure')
return tuple(map(int, matches[-1]))
def snapshot(tag):
global capture_count, capture_bytes
t = tree()
outputs = ipc('-t', 'get_outputs')
(run / f'tree-{tag}.json').write_text(json.dumps(t, indent=2) + '\n')
(run / f'outputs-{tag}.json').write_text(json.dumps(outputs, indent=2) + '\n')
expected = {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)}
actual = {}
for output in outputs:
if output.get('name') in expected and output.get('active'):
rect = output['rect']
if output.get('scale') != 1.0 or output.get('transform') != 'normal':
raise RuntimeError('output scale/transform mismatch')
actual[output['name']] = (rect['x'], rect['y'], rect['width'], rect['height'])
if actual != expected:
raise RuntimeError(f'output geometry mismatch: {actual}')
for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
if capture_count >= args.max_captures:
raise RuntimeError('capture count bound exceeded')
path = run / f'{side}-{tag}.png'
before = time.monotonic_ns()
subprocess.run(['grim', '-o', output, str(path)], env=env, check=True,
stdout=subprocess.DEVNULL, timeout=10)
after = time.monotonic_ns()
capture_count += 1
capture_bytes += path.stat().st_size
if capture_bytes > args.max_bytes:
raise RuntimeError('capture byte bound exceeded')
record('capture', tag=tag, output=output, path=path.name,
before_ns=before, after_ns=after, size=path.stat().st_size)
def inject(action, x, y):
if not (-800 <= x < 800 and 0 <= y < 600):
raise ValueError('global pointer out of bounds')
sent = x + 800
marker = f'INJECT_DONE {action} {sent},{y}'
before = (run / 'pointer.log').read_text().count(marker)
record('pointer_request', action=action, global_point=[x, y], sent=[sent, y],
extent=[1600, 600], output='HEADLESS-1' if x < 0 else 'HEADLESS-2')
pointer.stdin.write(f'{action} {sent} {y}\n')
pointer.stdin.flush()
wait(lambda: (run / 'pointer.log').read_text().count(marker) > before)
def client_command(value):
marker = f'COMMAND {value} DONE'
before = count(marker)
record('client_request', value=value)
client.stdin.write(value + '\n')
client.stdin.flush()
wait(lambda: count(marker) > before)
def scroll(target):
current = workspace()['mosaic']['rows'][0]['scroll_x']
command(f'layout mosaic scroll {target-current:g}')
wait(lambda: workspace()['mosaic']['rows'][0]['scroll_x'] == target)
record('camera_observed', target=target, owner_id=owner()['id'])
def open_chain(cycle):
a_before, b_before = count('POPUP_A MAP'), count('POPUP_B MAP')
o = owner()
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
top_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + 100)
inject('move', top_x, 150)
inject('left', top_x, 150)
wait(lambda: count('POPUP_A MAP') > a_before)
snapshot(f'{cycle}-S1-A')
a_cfg = configured('POPUP_A')
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 40)
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 35)
inject('move', a_x, a_y)
inject('left', a_x, a_y)
wait(lambda: count('POPUP_B MAP') > b_before)
snapshot(f'{cycle}-S2-B')
b_cfg = configured('POPUP_B')
b_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35)
b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30)
inject('move', b_x, b_y)
inject('right', b_x, b_y)
time.sleep(.12)
snapshot(f'{cycle}-S3-visible')
record('chain', cycle=cycle, owner_id=o['id'], scroll_x=scroll_x,
a_configure=a_cfg, b_configure=b_cfg,
top_global=[top_x, 150], a_global=[a_x, a_y], b_global=[b_x, b_y])
def cycle_once(number):
label = f'cycle-{number:03d}'
record('cycle_start', cycle=number)
snapshot(f'{label}-S0-reference')
open_chain(label)
if args.mode == 'private_mosaic':
scroll(1280)
snapshot(f'{label}-S4-hidden-immediate')
time.sleep(.18)
snapshot(f'{label}-S4-hidden-stable')
inject('move', -700, 190)
time.sleep(.1)
snapshot(f'{label}-S4-hidden-hover')
scroll(400)
snapshot(f'{label}-S5-return-immediate')
time.sleep(.18)
snapshot(f'{label}-S5-return-stable')
client_command('close_b')
snapshot(f'{label}-S6-close-B-immediate')
time.sleep(.18)
snapshot(f'{label}-S6-close-B-stable')
a_cfg = configured('POPUP_A')
o = owner()
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 20)
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 20)
before_a = len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text()))
inject('move', a_x, a_y)
inject('right', a_x, a_y)
wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1',
client_text())) > before_a)
snapshot(f'{label}-S6-A-interaction')
client_command('close_a')
snapshot(f'{label}-S7-close-A-immediate')
time.sleep(.18)
snapshot(f'{label}-S7-close-A-stable')
record('cycle_end', cycle=number, owner_id=owner()['id'], client_alive=client.poll() is None)
compositor_log = (run / 'compositor.log').open('w')
files.append(compositor_log)
compositor = None
failure = None
try:
compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'],
env=compositor_env, stdout=compositor_log,
stderr=subprocess.STDOUT, start_new_session=True)
def sockets():
s = next(iter(runtime.glob('sway-ipc.*.sock')), None)
w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None)
return (s, w) if s and w else None
sock, way = wait(sockets)
env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name)
snapshot('startup')
pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], 'pointer.log', stdin=True)
wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text())
command('workspace 1')
command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith')
spawn(['kitty', '--class', 'A', '-o', 'cursor_blink_interval=0',
'-o', 'background=#aa2222', '/usr/bin/sleep', '900'], 'A.log')
wait(lambda: len(workspace()['nodes']) >= 1)
client = spawn([str(lab / 'nested-client'), 'b_cross'], 'client.log', stdin=True, debug=True)
wait(lambda: owner() and 'TOPLEVEL MAP' in client_text())
for name in ('C', 'D'):
spawn(['kitty', '--class', name, '-o', 'cursor_blink_interval=0',
'/usr/bin/sleep', '900'], f'{name}.log')
time.sleep(.25)
command('workspace 2')
spawn(['stdbuf', '-oL', 'wev'], 'wev.log')
wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev'))
command('workspace 1')
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
if args.mode == 'private_mosaic':
scroll(400)
time.sleep(.25)
if args.kind == 'cycles':
for i in range(1, args.cycles + 1):
cycle_once(i)
elif args.kind == 'identity':
# CONTRACT: ten settled changes are five complete 400/1280 pairs.
# Twelve following requests omit intervening captures; acceptance is
# recorded, but coalesced intermediate presentations remain possible.
snapshot('identity-S0-reference')
open_chain('identity')
for i in range(10):
target = 1280 if i % 2 == 0 else 400
record('transition_start', series='settled', index=i, target=target)
scroll(target)
snapshot(f'identity-settled-{i:02d}-immediate')
time.sleep(.18)
snapshot(f'identity-settled-{i:02d}-stable')
for i in range(12):
target = 1280 if i % 2 == 0 else 400
record('transition_start', series='rapid', index=i, target=target)
scroll(target)
if i % 4 == 3:
snapshot(f'identity-rapid-{i:02d}')
snapshot('identity-rapid-final')
# CONTRACT: this input check follows camera return without closing or
# recreating either XDG popup; a fresh button is delivered to B.
o = owner()
a_cfg, b_cfg = configured('POPUP_A'), configured('POPUP_B')
current_scroll = workspace()['mosaic']['rows'][0]['scroll_x']
b_x = int(o['rect']['x'] - current_scroll + o['window_rect']['x'] +
a_cfg[0] + b_cfg[0] + 35)
b_y = int(o['rect']['y'] + o['window_rect']['y'] +
a_cfg[1] + b_cfg[1] + 30)
before_b = len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*button=273 state=1',
client_text()))
inject('move', b_x, b_y)
inject('right', b_x, b_y)
wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*button=273 state=1',
client_text())) > before_b)
record('post_return_B_button', global_point=[b_x, b_y], owner_id=o['id'])
snapshot('identity-post-return-B-button')
client_command('close_b')
client_command('close_a')
snapshot('identity-closed')
elif args.kind == 'outside':
snapshot('outside-S0-reference')
open_chain('outside')
inject('move', 100, 350)
inject('left', 100, 350)
time.sleep(.18)
snapshot('outside-dismissed')
elif args.kind == 'hidden_click':
snapshot('hidden-click-S0-reference')
open_chain('hidden-click')
scroll(1280)
snapshot('hidden-click-hidden')
before_buttons = len(re.findall(r'POINTER_BUTTON target=POPUP_[AB][^\n]*state=1',
client_text()))
inject('move', -700, 190)
inject('right', -700, 190)
wait(lambda: 'POPUP_B POPUP_DONE' in client_text() and
'POPUP_A POPUP_DONE' in client_text())
after_buttons = len(re.findall(r'POINTER_BUTTON target=POPUP_[AB][^\n]*state=1',
client_text()))
record('hidden_click_target', popup_buttons_before=before_buttons,
popup_buttons_after=after_buttons)
snapshot('hidden-click-dismissed')
scroll(400)
snapshot('hidden-click-returned')
else:
snapshot('toggle-S0-reference')
open_chain('toggle')
command('layout splith')
snapshot('toggle-standard-immediate')
time.sleep(.4)
snapshot('toggle-standard-stable')
o = owner()
a_cfg, b_cfg = configured('POPUP_A'), configured('POPUP_B')
b_x = int(max(20, o['rect']['x'] + o['window_rect']['x'] +
a_cfg[0] + b_cfg[0] + 20))
b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30)
if b_x < 0:
raise RuntimeError('standard B sample does not cross x=0')
inject('move', b_x, b_y)
inject('right', b_x, b_y)
snapshot('toggle-standard-click')
client_command('close_b')
client_command('close_a')
snapshot('toggle-closed')
except Exception as exc:
failure = repr(exc)
record('failure', error=failure)
finally:
for proc in children + ([compositor] if compositor is not None else []):
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
for proc in children + ([compositor] if compositor is not None else []):
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=5)
(run / 'exit-status.json').write_text(json.dumps({
'failure': failure, 'compositor': None if compositor is None else compositor.returncode,
'children': [{'argv': p.args, 'returncode': p.returncode} for p in children],
'captures': capture_count, 'capture_bytes': capture_bytes,
'duration_seconds': time.monotonic() - started}, indent=2) + '\n')
for file in files:
file.close()
shutil.rmtree(runtime)
record('cleanup', runtime_removed=not runtime.exists())
print(f'RUN={run} FAILURE={failure} CAPTURES={capture_count}')
if failure:
raise SystemExit(1)