docs(mosaic): preserve output buffer identity probe evidence
This commit is contained in:
parent
006adf69d7
commit
11c80a40f2
11 changed files with 1406 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
274
docs/design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md
Normal file
274
docs/design/STEP20C_OUTPUT_BUFFER_IDENTITY_PROBE.md
Normal 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.
|
||||
99
tooling/probes/step20c-buffer-identity/README.md
Normal file
99
tooling/probes/step20c-buffer-identity/README.md
Normal 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.
|
||||
163
tooling/probes/step20c-buffer-identity/analyze.py
Normal file
163
tooling/probes/step20c-buffer-identity/analyze.py
Normal 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))
|
||||
338
tooling/probes/step20c-buffer-identity/buffer-identity.patch
Normal file
338
tooling/probes/step20c-buffer-identity/buffer-identity.patch
Normal 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;
|
||||
23
tooling/probes/step20c-buffer-identity/prepare.sh
Normal file
23
tooling/probes/step20c-buffer-identity/prepare.sh
Normal 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"
|
||||
447
tooling/probes/step20c-buffer-identity/run.py
Normal file
447
tooling/probes/step20c-buffer-identity/run.py
Normal 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)
|
||||
Loading…
Reference in a new issue