docs(mosaic): preserve output buffer identity probe evidence

This commit is contained in:
fy59 2026-10-12 07:06:55 +02:00
parent 006adf69d7
commit 11c80a40f2
11 changed files with 1406 additions and 4 deletions

View file

@ -4,6 +4,7 @@
### STEP20C experimental evidence
- Preserved a private output-buffer identity fixture and protocol. In fixed headless geometry, 32 GBM buffers were read through SceneFX FX and linked to native commits and presents; three second reads matched. Four references were released early by a cross-output scope defect in the private instrumentation, not by demonstrated same-output buffer replacement. Readback changed timing, so transaction/input coherence and production readiness remain unproven. STEP20C stays NEXT and multi-output Mosaic unsupported.
- 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.

View file

@ -125,6 +125,25 @@ Pointer events remain a separate time-ordered stream. The result is
**PARTIALLY_FEASIBLE** and does not define a production transaction or
SceneFX ABI contract.
The subsequent private [output buffer identity probe](design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md)
read sampled actual GBM output-state buffers through SceneFX FX texture
readback, after an audited wlroots 0.20.2 buffer lock. In the fixed
negative-origin headless matrix, 32 selected buffers matched their native
commit callback, `commit_seq` and headless present; their sampled A/B pixels
agreed with the applied tiled scene and clip at 400/1280. Three buffers were
read again while locked at present with identical bytes. Four other
selected locks were released before their later present events because a
HEADLESS-2 repaint reached a global release in the private probe. This is
an instrumentation scope defect, not a demonstrated replacement by a new
HEADLESS-1 submission. The initial
logical-1760/applied-1120 state also had an identified buffer and headless
present, without a demonstrated pixel or hit defect. This does not create a
production buffer API or transaction-generation contract. FX readback uses
an internal GPU synchronization and measurably delays the commit path;
frame-specific input consistency, other effects/renderers and physical
presentation remain unproven. The result is **PARTIALLY_FEASIBLE** and
multi-output Mosaic stays unsupported.
## 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

@ -84,6 +84,24 @@ 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 private [output buffer identity probe](design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md)
is **PARTIALLY_FEASIBLE** at the fixed negative-origin headless geometry.
Thirty-two actual output-state buffers were read through SceneFX FX texture
readback, paired with native commits and headless present events, and
released on present in 28 cases; four were released early when a
HEADLESS-2 repaint reached the probe's global release, before their later
present events. This private instrumentation scope defect is not evidence
of same-output buffer replacement. Three second
reads matched their earlier bytes.
The direct CPU access method was unavailable for the GBM buffers. Readback
added measurable GPU/CPU synchronization and file-copy delay; 29 buffers
were not read a second time at present. The observed transient logical
camera 1760 versus applied scene/clip 1120 had an identified buffer but
no demonstrated wrong pixel or hit. There is still no atomic applied
scene/input generation contract, broad effects/damage proof, physical
presentation proof or SceneFX ABI/package qualification. 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

@ -29,7 +29,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use
[Temporal source fixture](../tooling/probes/step20c-temporal-damage/README.md) rebuilds the private campaign.
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.
18. [STEP20C output buffer identity probe](design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md): private GBM/FX buffer readback tied to native commit and headless present, with measured timing perturbation and remaining temporal-coherence gate.
[Buffer source fixture](../tooling/probes/step20c-buffer-identity/README.md) reconstructs the disposable experiment; it is not production code.
19. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence.
## Project
@ -70,6 +72,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use
| [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 |
| [STEP20C output buffer identity probe](design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; sampled true buffers were read and paired with headless presents; four early releases came from a private cross-output instrumentation defect, while readback perturbs timing and atomic scene/input coherence remains unproven |
A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation.

View file

@ -75,12 +75,29 @@ 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,
does not enter the production build. Its then-open buffer identity gate was
examined by the subsequent private probe below. Scaling, rotation,
migration/resize, complete effects/damage and SceneFX maintenance remain
open. STEP20C stays **NEXT**.
The [output buffer identity probe](design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md)
is **PARTIALLY_FEASIBLE** in this fixed matrix: 32 real GBM output-state
buffers were read through FX texture APIs, correlated with native commits
and headless presents. Twenty-eight were released on present and four
early because HEADLESS-2 repaint reached a global release in the private
instrumentation; the latter were not retained through present. Three
second reads were byte-identical. Sampled
popup pixels matched the applied scene/clip across
three pairs with the same A/B popup objects; the initial logical/applied
camera lag also had an identified buffer but no proven visual defect.
Readback materially changed timing, and neither frame-specific pointer
identity nor an atomic scene/input generation is proven. Its
[private fixture](../tooling/probes/step20c-buffer-identity/README.md) is
preserved experimental source, not a production dependency. The smallest
next gate is a lower-impact buffer observation with an explicit applied
scene/input generation, then separate effects/damage and SceneFX maintenance
qualification. 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,274 @@
# STEP20C output buffer identity probe
**Status: EXPERIMENTAL / PARTIALLY_FEASIBLE.** STEP20C
remains **NEXT** and production multi-output Mosaic is **NOT YET SUPPORTED**.
This probe changes no production source, public ABI, package, clipping rule,
transaction rule or UX policy.
## Question and prior evidence
The [presentation probe](STEP20C_PRESENTATION_IDENTITY_PROBE.md) paired 470
headless `present` events with 482 accepted native output commits by
`(output, commit_seq)`. Twelve initial commits had no observed present. It
did not identify the committed buffer or its pixels. Five initial Mosaic
commits had logical camera 1760 with applied scene -1120 and clip x=1120;
this was a transient logical/applied distinction, not a proven pixel or hit
defect. A `grim` image and an IPC reply cannot establish buffer identity.
The fixed experiment uses HEADLESS-1 `(-800,0)` and HEADLESS-2 `(0,0)`, each
800×600, scale 1, normal transform, with the genuine grabbed XDG chain
TOPLEVEL → POPUP_A → POPUP_B. No output reconfiguration is part of this probe.
## Audited API and lifetime model
The installed wlroots is 0.20.2; the source audit uses upstream tag commit
`d783533489e1f75d6886c2ab5c5960090ef268f8`, especially
`types/output/{state,output,render}.c`, `types/buffer/buffer.c`,
`render/{swapchain,allocator/gbm,wlr_texture}.c`, and
`backend/headless/output.c`. Private SceneFX base is
`37ccd723bef49e6891156ffafce8f549f01446cc`; its modified scene build
and FX texture implementation were inspected. The existing private runner
requests `WLR_RENDERER=pixman`, but the actual log shows SceneFX FX renderer,
OpenGL ES 3.2 on AMD Radeon 780M, with 800×600 GBM buffers and AB24/XB24
DRM formats. The requested environment setting is not evidence of the
renderer actually used.
`wlr_scene_output_build_state()` may return without a new buffer when no
render is needed. On the render path it acquires a swapchain buffer, submits
the render pass, calls `wlr_output_state_set_buffer()`, then unlocks its
temporary acquisition. `set_buffer()` locks the buffer for the output state.
`wlr_output_state_finish()` unlocks that state reference and nulls its field.
The compositor must never keep the state pointer after this call.
`wlr_output_commit_state()` shallow-copies and may filter unchanged fields,
test, fail or succeed. The headless backend accepts the state and defers a
`presented=true` event for an enabled output; it does not supply buffer
bytes in that event. On success wlroots increments the output's native
`uint32_t commit_seq` and synchronously emits `events.commit`, whose `state`
pointer is borrowed for the callback. On failure the sequence is not
advanced. A no-buffer configuration commit must not be assigned old pixels.
The headless deferred event can be absent, for example after output
destruction or allocation failure. Its `(output, commit_seq)` is the only
native link to the accepted commit; the counter can wrap, so the probe must
bound its run and preserve output lifetime/epoch in the key.
`wlr_buffer_lock()` increments a consumer lock. Its matching unlock emits
`release` when the last consumer leaves. The swapchain's release listener
marks its slot reusable only then. A probe-owned lock taken while the
output state is live therefore preserves the buffer object and prevents
swapchain reuse until release. It must be unlocked on rejection, present,
timeout and output destruction. Addresses alone are not durable identities:
assign a probe-local monotonically increasing ID while the owned lock is
held. Bound retained buffers to two per output, each to 800×600 and at most
8 MiB of copied bytes; release by 2 seconds even without `present`. Never
retain `event->state` or a data pointer beyond its callback/access scope.
The direct `wlr_buffer_begin_data_ptr_access(READ)` method may return false.
The GBM allocator in this renderer exposes DMA-BUF, not a data-pointer
implementation; no CPU mapping is assumed. On success only the returned
pointer, format and stride are valid until `end_data_ptr_access`, which is
mandatory. The fallback `wlr_texture_from_buffer()` and
`wlr_texture_read_pixels()` must be called only with a valid FX renderer and
texture on the compositor thread. SceneFX FX implements texture readback
using an EGL context, an internal `glFinish()` and `glReadPixels`; this is a
real synchronization and timing perturbation, not a free observation. The
probe must measure it against the same private compositor without readback.
No direct framebuffer read or extra `glFinish()` is permitted. If the
texture cannot be constructed or read safely, content remains UNOBSERVED.
## Protocol and safety gate
Before readback, verify the exact source/ABI and active renderer, then log
PRE_BUILD, POST_BUILD, COMMIT_ACCEPTED and PRESENT_CALLBACK separately.
POST_BUILD may expose `pending.buffer` when `WLR_OUTPUT_STATE_BUFFER` is set.
Take one additional lock only for such a live buffer, copy dimensions,
format, crop/destination box and state flags, and release on every path.
Do not predict success from `commit_seq+1`; record the native value only
after accepted commit. A commit callback may be used to check the actual
state's buffer identity while its borrowed pointer is valid. Distinguish
rejected commits, observed but uncommitted buffers, configuration commits,
accepted commits without present and `presented=false`.
Try CPU data-pointer access first. If unavailable, audit and try the FX
texture API. Read at most 8 MiB into a private copy, SHA-256 the raw bytes,
record format/stride/width/height and padding separately, and decode only
known DRM formats. Transform, source crop and destination box govern ROI
mapping. A second read at present is allowed only while the owned lock is
still valid; otherwise byte stability until present is UNPROVEN. Never infer
that `present` cryptographically attests to pixels. Compare applied scene
and clip at build with buffer pixels, while preserving any PRE_BUILD to
COMMIT_ACCEPTED differences. Pointer deliveries are a separate event stream.
Run one stable 400 case first. If ownership or synchronization fails, stop
readback and conclude INCONCLUSIVE. Otherwise run 400 → 1280 → 400, at most
three pairs, separate pointer targeting runs and unmodified/private `splith`
controls. Keep source fixtures under `tooling/probes/step20c-buffer-identity/`
and generated builds, logs, sockets and snapshots in one private `/tmp` lab.
Bound trace size, process duration and locks; reap only owned processes.
Preserve negative results and label fixture failures separately.
## Verdict criteria
- **FEASIBLE:** safe true-buffer readback, format-aware pixels matching the
sampled applied scene/clip, native commit/present pairing, bounded cleanup
and no blocking regression in this fixed matrix.
- **PARTIALLY_FEASIBLE:** true buffer access exists, but content decoding,
retention through present or some correlation remains unproven.
- **INCONCLUSIVE:** no safe supported readback or insufficient identity data;
a clean access failure is a capability result, not a compositor bug.
- **BLOCKED:** reproducible memory violation, crash, confirmed scene/buffer
inconsistency or blocking renderer regression.
An accepted headless commit is not a physical presentation. Any scaling,
rotation, migration, complete effects/damage or SceneFX ABI qualification is
**NOT TESTED** here. No verdict authorizes STEP20C production work.
## Measured private laboratory
The disposable laboratory is `/tmp/swayfx-mosaic-nested-grab-QwCf4FLm/`.
The private patch is preserved at
[`tooling/probes/step20c-buffer-identity/buffer-identity.patch`](../../tooling/probes/step20c-buffer-identity/buffer-identity.patch),
SHA-256 `d62e8c3a166e60a6eb394de58f51da1e37c3e7ac49671a361bd106ac64e2b427`.
It was applied after the published presentation fixture in a fresh private
checkout, compiled, and compared byte-for-byte with the separately edited
laboratory source. The final fixture uses a stricter cap than the protocol:
one extra buffer lock on HEADLESS-1, 80 snapshots maximum of 1,920,000 bytes
each, and a two-second timer. Each snapshot is written to a private file
after an FX texture read. The source comment, release log and native event
trace document every lock/unlock path. The patch changes no clip/hit rule.
The final three-pair run `private_mosaic-buffer_cycles-1791774958990683685`
kept owner IPC ID 8 and exactly one A/B map through all three 400 → 1280 →
400 pairs. It observed 80 accepted native output commits and 78 headless
present events. Both unmatched commits were initial sequence 2, one per
output, before workspace assignment: **UNEXPLAINED / NO PRESENT OBSERVED**.
The logged `BI_RELEASE age_ns` starts when the post-commit hold record is
installed; it excludes the earlier probe-owned lock time spent in readback
and commit. The two-second timeout likewise starts after that work. It
bounds waiting for an absent `present`, not a stalled GPU readback call.
Thirty-two selected HEADLESS-1 output-state buffers were GBM DMA-BUFs at
800×600, source format XB24 (`0x34325258`), with default full-buffer source
crop and destination. Direct CPU data-pointer access failed cleanly for all
32. SceneFX FX's `wlr_texture_from_buffer()` and
`wlr_texture_read_pixels()` read all 32 into XRGB8888, stride 3200,
1,920,000 bytes, with no row padding. Raw and top-down normalized RGB
SHA-256 are recorded for every selected buffer in `results/`.
For all 32, the native synchronous commit callback contained that same
live buffer, the commit was accepted with its actual `commit_seq`, a matching
headless `presented=true` event arrived, and 28 extra locks were released on
that event. Four other selected locks, IDs 3, 15, 21 and 27, were released
early by a **private instrumentation scope defect**: after their HEADLESS-1
commits, a HEADLESS-2 repaint reached the unconditional global
`bi_release_hold("superseded")` immediately after `POST_BUILD`. The
HEADLESS-2 build had a buffer but the probe did not select it, and no new
HEADLESS-1 submission caused those four releases. Their native presents
arrived later, after the probe had relinquished its references; this does
not prove byte stability through presentation. The old `superseded` log
reason is the instrumentation's label, not a demonstrated same-output
buffer replacement. No sample needed timeout or
output-destroy release. Only three buffers were read a second time while
still locked at `present`; all three raw SHA-256 values matched their
precommit values. Byte stability through `present` for the other 29 is
**NOT TESTED**; four of those were not retained until presentation. The headless event does not itself attest to bytes.
Three representative identified buffers are:
| Probe ID / native commit | Applied scene and clip | Exact A/B pixels | Raw SHA-256 | Present reread |
| --- | --- | --- | --- | --- |
| 8 / HEADLESS-1:23 | `-400,0` / `400,0,800,600` | 42,200 / 17,940 | `9e6d2f7b094fd5124c0207a30cb294acec3a01da15b90008d7f7449d27e1ee7c` | equal |
| 12 / HEADLESS-1:27 | `-1280,0` / `1280,0,800,600` | 0 / 0 | `03e815da50d0f04b16660340db19c1a03225f31bac0824d490c2ffbab0ee165c` | equal |
| 16 / HEADLESS-1:31 | `-400,0` / `400,0,800,600` | 42,200 / 17,940 | `9e6d2f7b094fd5124c0207a30cb294acec3a01da15b90008d7f7449d27e1ee7c` | equal |
All nine selected 1280 snapshots across the three pairs had zero exact A/B
pixels. All 32 sampled commit scenes agreed with their applied clip at 400
or 1280. The 400 snapshots before A/B mapped or after their explicit closure
are not classified as missing popups; the runner event stream preserves
those lifecycle states. PNG captures remain supplementary and are never
assigned a buffer sequence solely by timestamp.
The separate transient run
`private_mosaic-buffer_cycles-1791775118486687664` observed PRE_BUILD,
POST_BUILD and COMMIT_ACCEPTED at logical `scroll_x=1760`, applied tiled
scene `-1120,0` and applied clip `1120,0,800,600`. Its actual HEADLESS-1
buffer at native sequence 10 was read (raw SHA-256
`9a8c1cc62f439f812ce501fe49b99c883c9839f377bdd7f1caaecc05ca5a4c86`),
presented in headless, and released. A/B were not yet in the settled
state; the read does not show an incorrect pixel or invisible hit. The
applied scene/clip, rather than the newer logical camera, are the valid
state to compare with this buffer.
The private stage trace records PRE_BUILD, POST_BUILD and COMMIT_ACCEPTED
workspace, logical camera, applied tiled scene and tiled clip. The native
commit callback additionally records current owner geometry and root-popup
clip; the client trace records A/B mapping and real grab serials. The
owner-visibility guard's Boolean decision and nested-popup mapping are not
atomically exported at each stage and remain **UNOBSERVED** there. The
runner's requested camera commands and IPC replies are kept separately.
No stage record is treated as an atomic rendering or input snapshot.
Separate fixed-geometry pointer runs against the instrumented private
binary used two valid XDG button serials. The visible case delivered a B
button. The clipped B-crossing click delivered an ordinary client button
and child-first B/A `popup_done`, with no B button from that clipped point.
The hidden-owner click delivered no popup button and dismissed B then A.
These events have no native frame ID and are not attributed to a buffer.
Both unmodified SwayFX and private `splith` controls completed their
ordinary sampled cycle without runner failure; they have no Mosaic camera.
### Instrumentation impact and limits
Sequential three-pair runs with the same private instrumentation family,
with and without `BI_OUTPUT_DIR`, observed 80/78 native commits/presents
in both. Median build time was 0.089/0.085 ms; median POST_BUILD to
COMMIT_ACCEPTED was 1.542/0.141 ms; median commit callback to present
callback was 0.384/0.317 ms. The no-readback control used the same stage
logging; the final lock-selection correction only executes with readback.
Total run duration was about 5.5/5.35 s. These are
small samples with host scheduling noise, but the readback path clearly
perturbs timing. SceneFX's internal `glFinish()` is a plausible contributor;
the experiment does not isolate its cost from copying, file I/O and logs.
No crash, renderer error, rejected sampled commit, protocol failure or
repeatable pixel/input inconsistency appeared. Headless presentation is not
physical presentation. The current test does not prove input and pixel
coherence for every transaction generation, all effects/saved buffers,
other renderers or all 80 commits.
**Verdict: PARTIALLY_FEASIBLE.** The actual submitted buffer and sampled
pixels can be read safely through the FX texture API, then tied to a native
commit and headless present while an owned lock prevents swapchain reuse.
The readback's timing cost, four cross-output early releases, three-of-32 second-read
coverage and lack of an atomic scene/input generation prevent a general
temporal-coherence claim.
The next smallest experiment is a bounded lower-impact readback method or
explicit render/transaction generation tag, validated against the same
native buffer identity and pointer event stream. Do not integrate this
private patch into production.
The five-file `buffer-source-sha256.txt` in the lab has SHA-256
`0fed9458f3793d05018af6ce9bf4a674208c700829726c1a1b8e0e434088bfef`;
all five entries passed. The main lab's `buffer-artifacts-sha256.txt` has
501 entries and SHA-256
`94a3097686d47dc3cd06b6c0b24f630544bff5e7b798ccc9934461799310339b`;
all 501 passed. It covers selected raw/derived buffers, the main and
transient results, controls, pointer runs and source manifests. The
historical five-source manifest was verified before the scientific wording
in the fixture README was corrected. Its original README had SHA-256
`add56c3de06671fd14bbb504456f1339eaaa7d0209f11444a9cb940f3435aa59`
and is retained as `buffer-fixture-README-before-rectification.md` in the
private lab. The corrected README has a new hash; re-running the old
manifest directly against the current repository README must flag that
intentional documentation change. The experimental patch, runner,
analyzer and preparation script retain their historical hashes.
fixed patch was independently rebuilt in
`/tmp/swayfx-mosaic-nested-grab-I3RluIc1/`; its instrumented compositor
source was byte-identical and a one-pair run read and committed 20 selected
buffers, paired all 20 with headless present, released 18 on present and
two earlier from the same cross-output scope defect, with three equal second reads. Its separate
`buffer-artifacts-sha256.txt` covers the final-patch pointer and `splith`
controls, one-pair replay and raw buffers: 379 entries, SHA-256
`1ae1b391fc4a0664d852830141d8d012e6e55966a7e38ce6362b4337eb51d5af`;
all 379 passed. The main lab's earlier pointer/control runs were made during
patch development; the independent lab repeats them against the final
fixture. Neither set assigns input to a native commit. These are private
experimental artifacts, not a permanent library dependency.

View file

@ -0,0 +1,99 @@
# STEP20C buffer identity fixture — EXPERIMENTAL
The [protocol](../../../docs/design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md)
records the wlroots 0.20.2 and SceneFX 0.5 lifetime audit before this private
instrumentation. Reconstruct only in a disposable lab from the published
[presentation fixture](../step20c-presentation-identity/README.md). Its
`prepare.sh` starts from published Mosaic commit
`006adf69d7364181917cfc266863794b6d13a65b`, using pinned SwayFX
`fd71a6bdc061bd633b488ae7b83e8a6981d22f86`, private Mosaic
`cd550382b311efdbfe3ed892f4e1d281d852e754`, private SceneFX
`37ccd723bef49e6891156ffafce8f549f01446cc`, and the historical
patches. The new [`buffer-identity.patch`](buffer-identity.patch), SHA-256
`d62e8c3a166e60a6eb394de58f51da1e37c3e7ac49671a361bd106ac64e2b427`,
applies after `presentation-events.patch` to private
`sway/desktop/output.c` only. It changes no renderer, clipping decision,
public ABI or installed library.
## Rebuild and replay
Requires the presentation fixture's dependencies plus Pillow for RGB
normalization. `BI_OUTPUT_DIR` must be a new private directory. The runner
creates its own runtime, Wayland socket and explicit IPC socket. It never
uses the daily session. The buffer patch caps snapshots at 80 × 1,920,000
bytes, holds at most one extra HEADLESS-1 buffer lock, and releases it on
matching present, any subsequent repaint, rejected commit, timeout or output teardown.
The timeout is two seconds. A second read is limited to three buffers that
actually reach `present` while locked: visible, hidden and visible after
return. Four other selected buffers in the final run were released by an
instrumentation scope defect: a HEADLESS-2 repaint called global
`bi_release_hold("superseded")` while it held a HEADLESS-1 buffer. Their
later native `present` events do not establish retention or byte stability
through presentation. The historical patch and traces preserve this
negative result.
The historical five-source manifest includes the README before this
scientific rectification, SHA-256
`add56c3de06671fd14bbb504456f1339eaaa7d0209f11444a9cb940f3435aa59`.
That version is retained in the private lab as
`buffer-fixture-README-before-rectification.md`. A fresh `prepare.sh`
creates a manifest for the current corrected README; do not replace the
historical manifest or patch hash.
```sh
prep=$(sh tooling/probes/step20c-buffer-identity/prepare.sh)
printf '%s\n' "$prep"
LAB=$(printf '%s\n' "$prep" | sed -n 's/^LAB=//p')
(cd "$LAB" && sha256sum -c presentation-source-sha256.txt && \
sha256sum -c buffer-source-sha256.txt)
mkdir "$LAB/buffers-final"
BI_OUTPUT_DIR="$LAB/buffers-final" PYTHONDONTWRITEBYTECODE=1 \
python tooling/probes/step20c-buffer-identity/run.py \
"$LAB" private_mosaic buffer_cycles --cycles 3
RUN=$(find "$LAB/runs" -maxdepth 1 -type d -name 'private_mosaic-buffer_cycles-*' | sort | tail -1)
PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-buffer-identity/analyze.py \
"$LAB" "$RUN" "$LAB/buffers-final"
```
For a timing control, repeat the same `buffer_cycles` invocation without
`BI_OUTPUT_DIR`, sequentially, using the same private binary. The probe
still logs stages but performs no buffer readback. For the initial
logical/applied camera lag, run a separate one-pair case with
`BI_INCLUDE_TRANSIENT=1` and a new output directory. Pointer `visible`,
`b_cross` and `hidden_click` scenarios can be run separately with the
[negative-origin fixture](../step20c-negative-origin/README.md) against this
same private lab. Standard `splith` controls use the ordinary presentation
runner, once each for `control` and `private_standard`.
The analyzer writes `LAB/results/{commits,presentations,buffer-lifetime,
buffer-fingerprints,scene-states,correlation,pixel-analysis,matrix,summary}.json`.
The raw files are XRGB8888 readback copies with stride 3200. It preserves
raw SHA-256 and a top-down RGB SHA-256. The actual output-state source is a
GBM DMA-BUF; the CPU data-pointer attempt returned false in the measured
renderer, so the successful read is through SceneFX FX texture APIs. The
pixel comparison uses the declared XRGB8888 read format and actual
source/destination defaults, not an assumed source GBM format. The private
patch's texture read calls SceneFX's internal `glFinish()`; the result is
timing-sensitive and cannot be treated as a neutral observation.
## Measured laboratory
`/tmp/swayfx-mosaic-nested-grab-QwCf4FLm/` retains the final three-pair
run, no-readback control, transient-camera run, three separate pointer runs,
two `splith` controls, private build and copied buffers. The final three-pair
run `private_mosaic-buffer_cycles-1791774958990683685` had 80 native commits,
78 native presents and two initial sequence-2 commits with no present
observed. All 32 selected true output buffers were read, confirmed in the
synchronous native commit callback and paired to a native headless present.
Twenty-eight extra locks were released on that event; four were released
early by HEADLESS-2 repaint before their later `present`. The same owner ID 8
and one A/B map persisted
through all three pairs. The three second reads (visible, hidden, visible)
matched their precommit raw bytes. The transient run separately read the
buffer for native HEADLESS-1 sequence 10 at logical 1760/applied -1120.
The [report](../../../docs/design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md)
records the limited **PARTIALLY_FEASIBLE** result and measured readback
timing impact. Generated builds, logs, PNGs, sockets and copied buffers
remain outside Git. STEP20C is NEXT; multi-output Mosaic is NOT YET
SUPPORTED.

View file

@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Analyze one private fixed-geometry buffer run without assigning a frame to input."""
import hashlib
import json
import pathlib
import re
import statistics
import sys
from PIL import Image
if len(sys.argv) != 4:
raise SystemExit("usage: analyze.py LAB RUN_DIRECTORY BUFFER_DIRECTORY")
lab, run, buffers = map(pathlib.Path, sys.argv[1:])
lines = (run / "compositor.log").read_text(errors="replace").splitlines()
out = lab / "results"
out.mkdir(exist_ok=True)
def records(kind):
found = []
for line in lines:
if kind not in line:
continue
tail = line.split(kind, 1)[1]
values = dict(re.findall(r"([a-z_]+)=([^ ]+)", tail))
values["kind"] = kind
found.append(values)
return found
def save(name, value):
(out / (name + ".json")).write_text(json.dumps(value, indent=2) + "\n")
observed = records("BI_BUFFER")
direct = {int(x["id"]): x for x in records("BI_DIRECT")}
reads = {int(x["id"]): x for x in records("BI_READ")}
commits = {int(x["id"]): x for x in records("BI_COMMIT")}
event_buffers = {int(x["id"]): x for x in records("BI_EVENT_BUFFER")}
locked = {int(x["id"]): x for x in records("BI_LOCKED")}
held_present = {int(x["id"]): x for x in records("BI_PRESENT_HOLD")}
released = {int(x["id"]): x for x in records("BI_RELEASE")}
second_reads = {int(x["id"]): x for x in records("BI_PRESENT_READ")}
native_commits = records("PI_OUTPUT_COMMIT")
native_presents = records("PI_OUTPUT_PRESENT")
stages = records("BI_STAGE")
# The synchronous commit callback logs its scene observation immediately
# after the native event line. Do not attach a later logical IPC state.
scene = {}
for i, line in enumerate(lines):
if "PI_OUTPUT_COMMIT" not in line:
continue
v = dict(re.findall(r"([a-z_]+)=([^ ]+)", line.split("PI_OUTPUT_COMMIT", 1)[1]))
if i + 1 < len(lines) and "PI_SCENE" in lines[i + 1]:
z = dict(re.findall(r"([a-z_]+)=([^ ]+)", lines[i + 1].split("PI_SCENE", 1)[1]))
if z.get("output") == v.get("output"):
scene[(v["output"], v["commit_seq"])] = z
presents = {(v["output"], v["commit_seq"]): v for v in native_presents}
fingerprints = []
lifetimes = []
correlations = []
pixels = []
scene_states = []
for entry in observed:
ident = int(entry["id"])
commit = commits.get(ident, {})
key = (commit.get("output"), commit.get("commit_seq"))
current_scene = scene.get(key, {})
path = buffers / f"buffer-{ident}.xrgb"
readable = reads.get(ident, {}).get("status") == "READ" and path.is_file()
fp = {"id": ident, "read_status": reads.get(ident, {}).get("status", "UNOBSERVED"),
"direct_available": direct.get(ident, {}).get("available"),
"source_format": entry.get("dmabuf_format"), "read_format": "DRM_FORMAT_XRGB8888",
"width": 800, "height": 600, "stride": 3200,
"state_flags": entry.get("state"), "output_transform": "normal (fixed runner)",
"source_crop": entry.get("src"), "destination": entry.get("dst")}
if readable:
raw = path.read_bytes()
if len(raw) != 800 * 600 * 4:
raise ValueError(f"invalid snapshot length for {ident}: {len(raw)}")
# SceneFX FX glReadPixels rows start at the bottom. DRM XRGB8888 on
# this little-endian host stores B,G,R,X; normalize top-down RGB.
rgb = Image.frombytes("RGB", (800, 600), raw, "raw", "BGRX", 0, -1)
colors = dict((tuple(color), count) for count, color in rgb.getcolors(480001))
counts = {"A_yellow": colors.get((224, 192, 32), 0),
"B_green": colors.get((32, 208, 160), 0)}
fp.update(raw_sha256=hashlib.sha256(raw).hexdigest(),
rgb_sha256=hashlib.sha256(rgb.tobytes()).hexdigest(),
byte_count=len(raw), padding_bytes_per_row=0)
pixels.append({"id": ident, "output": key[0], "commit_seq": key[1],
"roi_output_pixels": [0, 0, 800, 600],
"scroll": current_scene.get("scroll"),
"tile_scene": current_scene.get("tile_scene"),
"clip": current_scene.get("clip"), "counts": counts})
later = buffers / f"buffer-{ident}-present.xrgb"
if later.exists():
later_raw = later.read_bytes()
fp["present_read_sha256"] = hashlib.sha256(later_raw).hexdigest()
fp["present_read_equal"] = later_raw == raw
fingerprints.append(fp)
lifetimes.append({"id": ident, "locked": ident in locked,
"present_while_locked": ident in held_present,
"release_reason": released.get(ident, {}).get("reason"),
"lock_age_ns": released.get(ident, {}).get("age_ns")})
correlations.append({"id": ident, "output": key[0], "commit_seq": key[1],
"accepted": commit.get("accepted") == "1",
"same_buffer_in_native_callback": event_buffers.get(ident, {}).get("same") == "1",
"native_present": key in presents,
"presented": presents.get(key, {}).get("presented"),
"read_status": fp["read_status"]})
scene_states.append({"id": ident, "output": key[0], "commit_seq": key[1], **current_scene})
summary = {"observed_buffers": len(observed), "read_buffers": sum(x["read_status"] == "READ" for x in fingerprints),
"direct_available": sum(x["direct_available"] == "1" for x in fingerprints),
"native_commits": len(native_commits), "native_presents": len(native_presents),
"accepted_sampled": sum(x["accepted"] for x in correlations),
"same_native_state_buffer": sum(x["same_buffer_in_native_callback"] for x in correlations),
"sampled_with_present": sum(x["native_present"] for x in correlations),
"sampled_released": sum(x["release_reason"] is not None for x in lifetimes),
"released_on_present": sum(x["release_reason"] == "present" for x in lifetimes),
"superseded_before_present": sum(x["release_reason"] == "superseded" for x in lifetimes),
"present_while_locked": sum(x["present_while_locked"] for x in lifetimes),
"second_reads": len(second_reads),
"second_reads_equal": sum(x.get("present_read_equal") is True for x in fingerprints),
"runner_failure": json.loads((run / "exit-status.json").read_text()).get("failure")}
build_ns, post_build_to_commit_ns, present_delay_ns = [], [], []
per_output = {}
for stage in stages:
name, phase = stage.get("output"), stage.get("phase")
now = int(stage["ns"])
if phase == "PRE_BUILD":
per_output[name] = {"pre": now}
elif phase == "POST_BUILD" and "pre" in per_output.get(name, {}):
per_output[name]["post"] = now
build_ns.append(now - per_output[name]["pre"])
elif phase == "COMMIT_ACCEPTED" and "post" in per_output.get(name, {}):
post_build_to_commit_ns.append(now - per_output[name]["post"])
for c in native_commits:
p = presents.get((c["output"], c["commit_seq"]))
if p:
present_delay_ns.append(int(p["ns"]) - int(c["ns"]))
summary["timing_median_ns"] = {
"build": int(statistics.median(build_ns)) if build_ns else None,
"post_build_to_commit": int(statistics.median(post_build_to_commit_ns)) if post_build_to_commit_ns else None,
"commit_to_present": int(statistics.median(present_delay_ns)) if present_delay_ns else None,
}
save("commits", native_commits)
save("presentations", native_presents)
save("buffer-lifetime", lifetimes)
save("buffer-fingerprints", fingerprints)
save("scene-states", {"stages": stages, "sampled_commits": scene_states})
save("correlation", correlations)
save("pixel-analysis", pixels)
save("matrix", {"fixed_geometry": "HEADLESS-1 (-800,0), HEADLESS-2 (0,0); 800x600; scale=1; normal",
"run": run.name, "sampled_snapshots": len(fingerprints),
"pointer_to_commit_identity": "UNOBSERVED", "physical_presentation": "NOT TESTED"})
save("summary", summary)
print(json.dumps(summary, indent=2))

View file

@ -0,0 +1,338 @@
--- a/sway/desktop/output.c
+++ b/sway/desktop/output.c
@@ -1,4 +1,9 @@
#include <assert.h>
+#include <drm_fourcc.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdio.h>
+#include <unistd.h>
#include <inttypes.h>
#include <stdlib.h>
#include <strings.h>
@@ -8,6 +13,7 @@
#include <wlr/backend/headless.h>
#include <wlr/render/swapchain.h>
#include <wlr/render/wlr_renderer.h>
+#include <wlr/render/wlr_texture.h>
#include <wlr/types/wlr_buffer.h>
#include <wlr/types/wlr_alpha_modifier_v1.h>
#include <wlr/types/wlr_gamma_control_v1.h>
@@ -431,6 +437,39 @@
}
}
+static struct wlr_buffer *bi_expected_buffer;
+static uint64_t bi_expected_id;
+
+// WHY: a headless present may arrive after output_state_finish.
+// CONTRACT: one lab-owned reference at most; timeout and output teardown
+// release even when no present event arrives. The lock prevents slot reuse.
+// INVARIANT: bi_hold.buffer is non-NULL exactly while the probe owns a lock.
+static struct {
+ struct wlr_buffer *buffer;
+ struct wlr_output *output;
+ struct wl_event_source *timer;
+ uint32_t seq;
+ uint64_t id;
+ uint64_t locked_ns;
+ int scroll;
+} bi_hold;
+static unsigned bi_reread_phase;
+
+static void bi_release_hold(const char *reason) {
+ if (!bi_hold.buffer) return;
+ sway_log(SWAY_INFO, "BI_RELEASE id=%" PRIu64 " reason=%s age_ns=%" PRIu64,
+ bi_hold.id, reason, presentation_probe_now_ns() - bi_hold.locked_ns);
+ if (bi_hold.timer) wl_event_source_remove(bi_hold.timer);
+ wlr_buffer_unlock(bi_hold.buffer);
+ memset(&bi_hold, 0, sizeof(bi_hold));
+}
+
+static int bi_timeout_hold(void *data) {
+ bi_release_hold("timeout");
+ return 0;
+}
+
+
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;
@@ -441,6 +480,145 @@
(uint64_t)event->when.tv_sec * UINT64_C(1000000000) + event->when.tv_nsec,
!!(event->state->committed & WLR_OUTPUT_STATE_BUFFER));
presentation_probe_scene(output);
+ if (bi_expected_buffer) {
+ sway_log(SWAY_INFO, "BI_EVENT_BUFFER id=%" PRIu64
+ " output=%s commit_seq=%" PRIu32 " same=%d has_buffer=%d",
+ bi_expected_id, output->wlr_output->name,
+ output->wlr_output->commit_seq,
+ event->state->buffer == bi_expected_buffer,
+ !!(event->state->committed & WLR_OUTPUT_STATE_BUFFER));
+ }
+}
+
+// WHY: identify bytes in the actual output-state buffer, not in a screenshot.
+// CONTRACT: only the private headless lab sets BI_OUTPUT_DIR; all copies and
+// accesses end before the state is finished. The caller owns one extra lock
+// until the commit result has been recorded.
+// INVARIANT: no data pointer or texture survives this function; snapshots
+// are limited to 80 * 1.92 MiB, and failed reads never become fingerprints.
+// The commit event is synchronous inside wlr_output_commit_state(); these
+// borrowed comparison fields are cleared before the repaint call returns.
+static uint64_t bi_next_id;
+static unsigned bi_snapshots;
+static struct wlr_buffer *bi_observe_buffer(struct sway_output *output,
+ const struct wlr_output_state *pending, uint64_t *id) {
+ const char *dir = getenv("BI_OUTPUT_DIR");
+ if (!dir || strcmp(output->wlr_output->name, "HEADLESS-1") != 0 ||
+ !(pending->committed & WLR_OUTPUT_STATE_BUFFER) || !pending->buffer) {
+ return NULL;
+ }
+ struct sway_workspace *ws = output->current.active_workspace;
+ if (!ws || ws->current.layout != L_MOSAIC || !ws->mosaic ||
+ !ws->mosaic->rows->length || bi_snapshots >= 80) {
+ return NULL;
+ }
+ struct mosaic_row *row = ws->mosaic->rows->items[0];
+ bool transient = getenv("BI_INCLUDE_TRANSIENT") &&
+ row->state.scroll_x == 1760 && ws->layers.tiling->node.x == -1120;
+ if (row->state.scroll_x != 400 && row->state.scroll_x != 1280 &&
+ !transient) {
+ return NULL;
+ }
+ struct wlr_buffer *buffer = pending->buffer;
+ if (buffer->width != 800 || buffer->height != 600) {
+ return NULL;
+ }
+ *id = ++bi_next_id;
+ bi_snapshots++;
+ wlr_buffer_lock(buffer);
+ uint64_t start = presentation_probe_now_ns();
+ struct wlr_dmabuf_attributes dmabuf = {0};
+ bool has_dmabuf = wlr_buffer_get_dmabuf(buffer, &dmabuf);
+ sway_log(SWAY_INFO, "BI_BUFFER id=%" PRIu64 " output=%s ns=%" PRIu64
+ " width=%d height=%d state=0x%x dmabuf=%d dmabuf_format=0x%x"
+ " src=%.1f,%.1f,%.1f,%.1f dst=%d,%d,%d,%d",
+ *id, output->wlr_output->name, start, buffer->width, buffer->height,
+ pending->committed, has_dmabuf, has_dmabuf ? dmabuf.format : 0,
+ pending->buffer_src_box.x, pending->buffer_src_box.y,
+ pending->buffer_src_box.width, pending->buffer_src_box.height,
+ pending->buffer_dst_box.x, pending->buffer_dst_box.y,
+ pending->buffer_dst_box.width, pending->buffer_dst_box.height);
+ void *direct = NULL;
+ uint32_t direct_fmt = 0;
+ size_t direct_stride = 0;
+ bool direct_ok = wlr_buffer_begin_data_ptr_access(buffer,
+ WLR_BUFFER_DATA_PTR_ACCESS_READ, &direct, &direct_fmt, &direct_stride);
+ sway_log(SWAY_INFO, "BI_DIRECT id=%" PRIu64 " available=%d format=0x%x stride=%zu",
+ *id, direct_ok, direct_fmt, direct_stride);
+ if (direct_ok) {
+ wlr_buffer_end_data_ptr_access(buffer);
+ }
+ const size_t bytes = 800u * 600u * 4u;
+ unsigned char *pixels = malloc(bytes);
+ if (!pixels) {
+ sway_log(SWAY_INFO, "BI_READ id=%" PRIu64 " status=ALLOC_FAILED", *id);
+ return buffer;
+ }
+ struct wlr_texture *texture = wlr_texture_from_buffer(output->wlr_output->renderer, buffer);
+ if (!texture) {
+ sway_log(SWAY_INFO, "BI_READ id=%" PRIu64 " status=TEXTURE_UNAVAILABLE", *id);
+ free(pixels);
+ return buffer;
+ }
+ struct wlr_texture_read_pixels_options opts = {
+ .data = pixels, .format = DRM_FORMAT_XRGB8888, .stride = 800u * 4u,
+ };
+ bool read_ok = wlr_texture_read_pixels(texture, &opts);
+ wlr_texture_destroy(texture);
+ if (!read_ok) {
+ sway_log(SWAY_INFO, "BI_READ id=%" PRIu64 " status=READ_FAILED", *id);
+ free(pixels);
+ return buffer;
+ }
+ char path[PATH_MAX];
+ int n = snprintf(path, sizeof(path), "%s/buffer-%" PRIu64 ".xrgb", dir, *id);
+ if (n < 0 || (size_t)n >= sizeof(path)) {
+ sway_log(SWAY_INFO, "BI_READ id=%" PRIu64 " status=PATH_TOO_LONG", *id);
+ free(pixels);
+ return buffer;
+ }
+ int fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+ size_t off = 0;
+ if (fd >= 0) {
+ while (off < bytes) {
+ ssize_t written = write(fd, pixels + off, bytes - off);
+ if (written <= 0) break;
+ off += (size_t)written;
+ }
+ close(fd);
+ }
+ if (off != bytes && fd >= 0) unlink(path);
+ sway_log(SWAY_INFO, "BI_READ id=%" PRIu64 " status=%s bytes=%zu"
+ " format=0x%x stride=%u duration_ns=%" PRIu64,
+ *id, off == bytes ? "READ" : "WRITE_FAILED", off,
+ DRM_FORMAT_XRGB8888, 800u * 4u, presentation_probe_now_ns() - start);
+ free(pixels);
+ return buffer;
+}
+
+// WHY: retain the scene state on each side of build and commit separately.
+// CONTRACT: these are ordered main-thread observations, not an atomic
+// transaction snapshot or proof that the later buffer contains these pixels.
+static void bi_stage(struct sway_output *output, const char *phase,
+ const struct wlr_output_state *state) {
+ struct sway_workspace *ws = output->current.active_workspace;
+ struct wlr_box clip = {0};
+ bool clip_valid = ws && wlr_scene_tree_get_clip(ws->layers.tiling, &clip);
+ double scroll = -1;
+ if (ws && 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, "BI_STAGE phase=%s ns=%" PRIu64
+ " output=%s workspace=%zu layout=%d scroll=%.0f tile_scene=%d,%d"
+ " clip_valid=%d clip=%d,%d,%d,%d has_buffer=%d",
+ phase, presentation_probe_now_ns(), output->wlr_output->name,
+ ws ? ws->node.id : 0, ws ? (int)ws->current.layout : -1, scroll,
+ ws ? ws->layers.tiling->node.x : 0,
+ ws ? ws->layers.tiling->node.y : 0, clip_valid,
+ clip.x, clip.y, clip.width, clip.height,
+ state && (state->committed & WLR_OUTPUT_STATE_BUFFER) && state->buffer);
}
static int output_repaint_timer_handler(void *data) {
@@ -462,6 +640,7 @@
return 0;
}
+ bi_stage(output, "PRE_BUILD", NULL);
struct wlr_output_state pending;
wlr_output_state_init(&pending);
if (!wlr_scene_output_build_state(output->scene_output, &pending, &opts)) {
@@ -469,6 +648,7 @@
return 0;
}
+ bi_stage(output, "POST_BUILD", &pending);
if (output_can_tear(output)) {
pending.tearing_page_flip = true;
@@ -479,7 +659,47 @@
}
}
+ // INVARIANT: release an older probe lock before taking a new one.
+ bi_release_hold("superseded");
+ uint64_t bi_id = 0;
+ struct wlr_buffer *bi_locked = bi_observe_buffer(output, &pending, &bi_id);
+ bi_expected_buffer = bi_locked;
+ bi_expected_id = bi_id;
bool commit_ok = wlr_output_commit_state(output->wlr_output, &pending);
+ bi_expected_buffer = NULL;
+ bi_expected_id = 0;
+ if (commit_ok) bi_stage(output, "COMMIT_ACCEPTED", &pending);
+ if (bi_locked) {
+ sway_log(SWAY_INFO, "BI_COMMIT id=%" PRIu64 " accepted=%d output=%s"
+ " commit_seq=%" PRIu32 " ns=%" PRIu64, bi_id, commit_ok,
+ output->wlr_output->name, output->wlr_output->commit_seq,
+ presentation_probe_now_ns());
+ if (!commit_ok) {
+ wlr_buffer_unlock(bi_locked);
+ sway_log(SWAY_INFO, "BI_RELEASE id=%" PRIu64 " reason=commit_rejected", bi_id);
+ } else {
+ struct wl_event_source *timer = wl_event_loop_add_timer(
+ output->wlr_output->event_loop, bi_timeout_hold, NULL);
+ if (!timer || wl_event_source_timer_update(timer, 2000) < 0) {
+ if (timer) wl_event_source_remove(timer);
+ wlr_buffer_unlock(bi_locked);
+ sway_log(SWAY_INFO, "BI_RELEASE id=%" PRIu64 " reason=timer_unavailable", bi_id);
+ } else {
+ bi_hold.buffer = bi_locked;
+ bi_hold.output = output->wlr_output;
+ bi_hold.timer = timer;
+ bi_hold.seq = output->wlr_output->commit_seq;
+ bi_hold.id = bi_id;
+ bi_hold.locked_ns = presentation_probe_now_ns();
+ struct sway_workspace *ws = output->current.active_workspace;
+ struct mosaic_row *row = ws && ws->mosaic && ws->mosaic->rows->length ?
+ ws->mosaic->rows->items[0] : NULL;
+ bi_hold.scroll = row ? row->state.scroll_x : -1;
+ sway_log(SWAY_INFO, "BI_LOCKED id=%" PRIu64 " commit_seq=%" PRIu32,
+ bi_id, bi_hold.seq);
+ }
+ }
+ }
struct timespec temporal_now;
clock_gettime(CLOCK_MONOTONIC, &temporal_now);
sway_log(SWAY_INFO, "TEMPORAL_OUTPUT_COMMIT ordinal=%" PRIu64
@@ -614,6 +834,9 @@
}
static void begin_destroy(struct sway_output *output) {
+ if (bi_hold.buffer && bi_hold.output == output->wlr_output) {
+ bi_release_hold("output_destroy");
+ }
wl_list_remove(&output->layout_destroy.link);
wl_list_remove(&output->destroy.link);
@@ -664,6 +887,57 @@
output_event->when.tv_nsec,
output_event->presented, output_event->seq,
output_event->refresh, output_event->flags);
+ if (bi_hold.buffer && bi_hold.output == output->wlr_output &&
+ bi_hold.seq == output_event->commit_seq) {
+ sway_log(SWAY_INFO, "BI_PRESENT_HOLD id=%" PRIu64
+ " commit_seq=%" PRIu32 " presented=%d valid=1",
+ bi_hold.id, bi_hold.seq, output_event->presented);
+ // CONTRACT: the owned lock is still held here. A second read is
+ // limited to visible, hidden, returned buffers that actually reach
+ // present while locked. A superseded buffer does not advance phase.
+ bool reread = (bi_reread_phase == 0 && bi_hold.id >= 8 && bi_hold.scroll == 400) ||
+ (bi_reread_phase == 1 && bi_hold.scroll == 1280) ||
+ (bi_reread_phase == 2 && bi_hold.scroll == 400);
+ if (reread) {
+ const char *dir = getenv("BI_OUTPUT_DIR");
+ const size_t bytes = 800u * 600u * 4u;
+ unsigned char *pixels = malloc(bytes);
+ struct wlr_texture *tex = pixels ? wlr_texture_from_buffer(
+ output->wlr_output->renderer, bi_hold.buffer) : NULL;
+ bool read_ok = false;
+ if (tex) {
+ struct wlr_texture_read_pixels_options opts = {
+ .data = pixels, .format = DRM_FORMAT_XRGB8888,
+ .stride = 800u * 4u,
+ };
+ read_ok = wlr_texture_read_pixels(tex, &opts);
+ wlr_texture_destroy(tex);
+ }
+ char path[PATH_MAX];
+ int n = dir ? snprintf(path, sizeof(path),
+ "%s/buffer-%" PRIu64 "-present.xrgb", dir, bi_hold.id) : -1;
+ size_t off = 0;
+ int fd = -1;
+ if (read_ok && n >= 0 && (size_t)n < sizeof(path)) {
+ fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
+ if (fd >= 0) {
+ while (off < bytes) {
+ ssize_t written = write(fd, pixels + off, bytes - off);
+ if (written <= 0) break;
+ off += (size_t)written;
+ }
+ close(fd);
+ if (off != bytes) unlink(path);
+ }
+ }
+ sway_log(SWAY_INFO, "BI_PRESENT_READ id=%" PRIu64
+ " status=%s bytes=%zu phase=%u", bi_hold.id,
+ off == bytes ? "READ" : "UNAVAILABLE", off, bi_reread_phase);
+ if (off == bytes) bi_reread_phase++;
+ free(pixels);
+ }
+ bi_release_hold("present");
+ }
if (!output->enabled || !output_event->presented) {
return;

View file

@ -0,0 +1,23 @@
#!/bin/sh
# EXPERIMENTAL: reconstruct buffer readback in a disposable private lab.
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
fixture="$repo/tooling/probes/step20c-buffer-identity"
prep_log=$(mktemp /tmp/mbi-prepare-XXXXXXXX)
if ! sh "$repo/tooling/probes/step20c-presentation-identity/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: only the source in the just-created private lab is patched.
(cd "$lab/mosaic" && git apply --check "$fixture/buffer-identity.patch" &&
git apply "$fixture/buffer-identity.patch")
meson compile -C "$lab/mosaic-build" -j 8 >> "$prep_log" 2>&1
sha256sum "$fixture/buffer-identity.patch" "$fixture/prepare.sh" \
"$fixture/run.py" "$fixture/analyze.py" "$fixture/README.md" \
> "$lab/buffer-source-sha256.txt"
trap - 0
printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$prep_log"

View file

@ -0,0 +1,447 @@
"""EXPERIMENTAL buffer-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', 'buffer_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', 'buffer_cycles', '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 == 'buffer_cycles':
if args.cycles > 3:
parser.error('buffer probe permits at most three pairs')
open_chain('buffer')
snapshot('buffer-visible-start')
for i in range(args.cycles):
record('buffer_pair_start', pair=i + 1, owner_id=owner()['id'])
scroll(1280)
snapshot(f'buffer-hidden-{i+1}-immediate')
time.sleep(.18)
snapshot(f'buffer-hidden-{i+1}-stable')
scroll(400)
snapshot(f'buffer-return-{i+1}-immediate')
time.sleep(.18)
snapshot(f'buffer-return-{i+1}-stable')
record('buffer_pair_end', pair=i + 1, owner_id=owner()['id'],
a_maps=count('POPUP_A MAP'), b_maps=count('POPUP_B MAP'))
client_command('close_b')
client_command('close_a')
snapshot('buffer-closed')
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)