From 1f59cb433bcdad006c23df10496cb4709551c624 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 12 Oct 2026 07:36:29 +0200 Subject: [PATCH] docs(mosaic): preserve applied scene input probe evidence --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 13 + docs/KNOWN_LIMITATIONS.md | 15 + docs/README.md | 5 +- docs/ROADMAP.md | 12 + .../STEP20C_APPLIED_STATE_INPUT_PROBE.md | 285 +++++++++++ .../step20c-applied-state-input/README.md | 83 +++ .../step20c-applied-state-input/analyze.py | 128 +++++ .../applied-state-input.patch | 417 +++++++++++++++ .../step20c-applied-state-input/prepare.sh | 22 + .../probes/step20c-applied-state-input/run.py | 482 ++++++++++++++++++ 11 files changed, 1462 insertions(+), 1 deletion(-) create mode 100644 docs/design/STEP20C_APPLIED_STATE_INPUT_PROBE.md create mode 100644 tooling/probes/step20c-applied-state-input/README.md create mode 100644 tooling/probes/step20c-applied-state-input/analyze.py create mode 100644 tooling/probes/step20c-applied-state-input/applied-state-input.patch create mode 100755 tooling/probes/step20c-applied-state-input/prepare.sh create mode 100644 tooling/probes/step20c-applied-state-input/run.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c699545..db259bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### STEP20C experimental evidence +- Preserved a private applied-scene/input probe. A separate corrective patch scopes retained output buffers to their owner, samples three buffers through present, and logs partial applied-scene epochs at build and real pointer hits with client delivery kept separate. The tested matrix is PARTIALLY_FEASIBLE; STEP20C remains NEXT and multi-output Mosaic unsupported. - 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 689edba..6f41586 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -144,6 +144,19 @@ frame-specific input consistency, other effects/renderers and physical presentation remain unproven. The result is **PARTIALLY_FEASIBLE** and multi-output Mosaic stays unsupported. +The [applied state/input probe](design/STEP20C_APPLIED_STATE_INPUT_PROBE.md) +then corrected the private buffer hold to be output-scoped. A sampled +HEADLESS-2 repaint between a retained HEADLESS-1 commit and its present +did not release that reference. Three selected visible/hidden/returned +buffers matched their applied scene/clip, native commits and headless +presents, and were reread byte-identically while locked. A private +experimental epoch marks some popup and transaction arrangement sites and +appears in both build and executed hit-test traces. It is not a native +atomic transaction generation and does not cover every render/input +mutation. Client delivery under a real XDG grab was checked separately +from the scene hit. This **PARTIALLY_FEASIBLE** result does not change +production scene, transaction, input or SceneFX ABI contracts. + ## 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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 79eaf14..a694ab4 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -102,6 +102,21 @@ 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 subsequent private [applied state/input probe](design/STEP20C_APPLIED_STATE_INPUT_PROBE.md) +is **PARTIALLY_FEASIBLE** in the same fixed headless geometry. A +per-output hold survived an intervening repaint of the other output; +three selected buffers reached matching native presents and matched +visible/hidden/returned applied scene samples. Hit-test, seat-notification +and client-delivery streams were observed separately for visible, clipped +and fully hidden pointer cases. The experimental epoch covers only +instrumented popup and transaction arrangements; other scene, effect, +damage, animation, client-buffer and output mutations remain outside it. +Rejected-commit and output-destroy cleanup were not dynamically triggered. +Readback remains intrusive, and neither an atomic scene/render/input +generation nor physical presentation is proven. 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**. diff --git a/docs/README.md b/docs/README.md index 9863c3c..75cc882 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,7 +31,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use [Presentation source fixture](../tooling/probes/step20c-presentation-identity/README.md) rebuilds the private instrumentation. 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. +19. [STEP20C applied state/input probe](design/STEP20C_APPLIED_STATE_INPUT_PROBE.md): private per-output buffer-reference correction and partial applied-scene epoch tracing through render and pointer paths; no atomic generation is established. + [Applied-state source fixture](../tooling/probes/step20c-applied-state-input/README.md) rebuilds the disposable experiment. +20. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. ## Project @@ -73,6 +75,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use | [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 | +| [STEP20C applied state/input probe](design/STEP20C_APPLIED_STATE_INPUT_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; per-output holds survive cross-output repaint in the sampled matrix, and applied scene/hit/client streams are observed with a partial experimental epoch | A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 76c5dce..0b1b011 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -98,6 +98,18 @@ 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**. +The [applied state/input probe](design/STEP20C_APPLIED_STATE_INPUT_PROBE.md) +is **PARTIALLY_FEASIBLE** in its fixed headless matrix. A distinct private +patch prevented the historical cross-output buffer release; three +selected buffers were read and retained through native headless present, +while a partial experimental epoch and real pointer traces exposed +applied state at build and hit. Client delivery under grabs remained a +separate observation. Incomplete mutation coverage, untested error paths +and intrusive readback leave the temporal-consistency gate open. The +smallest next experiment must close the mutation boundary and use a +lower-impact observation before any production STEP20C decision. STEP20C +stays **NEXT**; multi-output remains **NOT YET SUPPORTED**. + ## 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. diff --git a/docs/design/STEP20C_APPLIED_STATE_INPUT_PROBE.md b/docs/design/STEP20C_APPLIED_STATE_INPUT_PROBE.md new file mode 100644 index 0000000..523b587 --- /dev/null +++ b/docs/design/STEP20C_APPLIED_STATE_INPUT_PROBE.md @@ -0,0 +1,285 @@ +# STEP20C applied scene / input probe + +**Status: EXPERIMENTAL / PARTIALLY_FEASIBLE.** The protocol was recorded +before the private patch and the bounded replay below. STEP20C +remains **NEXT**; production multi-output Mosaic is **NOT YET SUPPORTED**. +This protocol authorizes only disposable private instrumentation and fixed +headless geometry. It changes no production source, clipping policy, UX, +public ABI, package or installed dependency. + +## Question and hypotheses + +Can the scene actually applied at output-state construction be observed +alongside the scene used by a later real pointer hit test, without inventing +a native atomic transaction generation? The previous buffer probe identified +32 committed buffers and headless presents, but its global `bi_hold` released +IDs 3, 15, 21 and 27 when HEADLESS-2 repainted after HEADLESS-1 commits. +That private instrumentation defect prevents claiming those four buffers +were retained until present. The corrective patch is separate from the +historical fixture. + +Hypotheses: a per-output hold survives a different output's repaint; +sampled visible/hidden/returned applied scene and clip agree with selected +buffer pixels; hit testing sees the sampled applied clip; actual pointer +delivery may differ from the scene hit because of XDG grab semantics. A +logical camera target, IPC acceptance, `wlr_scene_node_at()` result and +headless `present` are different observations. + +## Ownership and scene audit boundary + +`wlr_scene_output_build_state()` can install a swapchain buffer in the +pending output state. The state owns its reference until +`wlr_output_state_finish()`. A private `wlr_buffer_lock()` taken while that +state is live owns a separate reference; the matching unlock must occur on +present, same-output replacement, rejection, timeout or output destruction. +The lock prevents swapchain slot reuse while held. No event-state or pixel +pointer may outlive its callback or access scope. Use output identity plus +native `commit_seq`, not an address alone or predicted sequence. Never give +a no-buffer commit an earlier buffer fingerprint. + +Sway's logical `scroll_x` is not itself an applied scene generation. +`transaction.c` applies current container/output scene positions and +visibility, including tiled translation; the private Mosaic/SceneFX +patches set tiled and owner-derived popup clips. XDG map/unmap and popup +hierarchy changes can mutate scene trees separately. Pending geometry, +current geometry, applied node position and logical camera are logged as +distinct fields. SceneFX clip propagation constrains render, damage and +`wlr_scene_node_at()` in the private build. The experimental epoch, if +used, advances only at explicitly instrumented mutations; it is **not** +a native SwayFX transaction generation. Mapping, effects, damage, direct +SceneFX mutations and uninstrumented upstream paths remain outside any +partial counter until proven otherwise. + +`sway/input/cursor.c` calls `node_at_coords()` and scene hit testing for +pointer paths, while `sway/input/seat.c` relays button notifications to +wlroots. XDG grabs can filter delivery and issue `popup_done`. Record the +requested and resulting global coordinate, hit node/surface and current +applied snapshot at the actual hit call, then separate seat notification +and client-observed events. Never infer delivery solely from node-at, and +never assign an input event a fictional frame number. + +## Private instrument and modes + +Use a distinct correction patch after the frozen presentation and buffer +patches. A per-output, single-slot hold is bounded to one selected +HEADLESS-1 buffer in this matrix. Other-output repaint/present cannot +evict it. A same-output commit with a *new* buffer may replace it; a +configuration-only commit cannot. Release is idempotent by clearing the +slot before unlock. A two-second post-commit timer bounds waiting for an +absent present, but does not bound a stalled GPU readback. Track lock, +release, timer and output identity in logs. Paths not safely triggered are +**NOT TESTED**. + +Mode 0 records scene, native commit/present and input with no new buffer +lock/read. Mode 1 reads at most three selected true buffers: visible 400, +hidden 1280, restored 400. Mode 2 additionally reads those buffers a +second time only while the owned lock remains live at matching present. +SceneFX FX texture readback can synchronize GPU/CPU internally. Compare +stage timings and event counts against mode 0; do not attribute total cost +solely to `glFinish()`. + +## Fixed matrix and correlation + +HEADLESS-1 `(-800,0)` and HEADLESS-2 `(0,0)` are each 800×600, scale 1, +normal transform. The client hierarchy is TOPLEVEL → POPUP_A → POPUP_B, +with real pointer-button serials for both grabs. Confirm outputs and +native commit/present events. Run stable 400 with a real B hit; 1280 with +the owner fully hidden; return to 400 with the same mapped A/B; then at +most three hide/return pairs. In distinct runs, click visible B, clipped +B on HEADLESS-2 and hidden owner; send six close alternating commands; +compare unmodified SwayFX and private `splith` controls without inventing +a Mosaic camera for them. + +Each record uses a monotonic timestamp where available. Preserve +PRE_BUILD, POST_BUILD, COMMIT_ACCEPTED and PRESENT_CALLBACK applied +snapshots and the native `(output, commit_seq)` link. Separately preserve +the applied snapshot at hit test, the wlroots seat notification and the +client delivery. A matching experimental epoch across observations is +limited evidence only for mutations covered by its increments; time +ordering is not an atomic identity. Preserve coalesced commands and +commits without observed present as such. + +## Acceptance and stop rules + +**FEASIBLE** requires same-output buffer lifetime through present in the +tested paths, observed applied scene at build and hit, actual pointer +delivery agreement, bounded cleanup and no blocking regression. +**PARTIALLY_FEASIBLE** covers useful scene/render/input observations with +incomplete mutation coverage, temporal identity or error-path tests. +**INCONCLUSIVE** applies when safe observability is unavailable. +**BLOCKED** requires a reproduced memory violation, crash, visual leak, +invisible hit or blocking regression. A clean unsupported readback is not +a compositor defect. Stop on a reproducible crash and retain evidence. + +Caps: three transition pairs, three selected readback buffers per run, +one held slot per tested output, 8 MiB per copied buffer, two-second +post-commit hold timeout, bounded captures/logs and explicit process +cleanup. No scaling, rotation, migration or hotplug is tested. Even a +successful fixed headless matrix does not authorize production STEP20C. + +## Measurements + +The primary private laboratory is +`/tmp/swayfx-mosaic-nested-grab-l2HvAkRe/`, rebuilt from the frozen buffer +fixture. Its corrected output, transaction, cursor and seat sources were +byte-identical to the independent rebuild in +`/tmp/swayfx-mosaic-nested-grab-8CiDjoij/`. The corrective patch has +SHA-256 `8a0766536fa728a6be6ef29aea3be67abbfad107f2fcd6048718097499a4d88f`. +Only these disposable copies were changed. The final-patch independent +one-pair run `private_mosaic-buffer_cycles-asi2-1791782683026614149` +again read three buffers and matched all three native commits and headless +presents, with three byte-identical second reads and one unlock per hold. +Its separate visible, clipped and hidden pointer runs repeated the +client-delivery results. The fixed geometry was confirmed by IPC in the +run snapshots; each A/B chain used two real pointer-button serials +(the three-pair run requested A=55 and B=60, each used unchanged). All +named runs exited without runner or XDG protocol failure. + +### Buffer ownership and pixels + +Mode 0 (`private_mosaic-buffer_cycles-asi0-1791781864140604493`) recorded +95 accepted native commits and 93 headless presents, with no probe lock or +read. Mode 1 (`...asi1-1791781908846694616`) recorded 87/85 and read three +selected buffers. Mode 2 (`...asi2-1791781917161051239`) recorded 88/86, +read those three selected states and read each again at its matching +present. The two unpaired accepted commits in each run were initial output +sequence 2, one per output: **UNEXPLAINED / NO PRESENT OBSERVED**. + +The selected HEADLESS-1 buffers in mode 2 have actual native sequences +23, 27 and 30, with experimental applied epochs 13, 15 and 17. All three +are 800×600 GBM XB24 source buffers, read as XRGB8888 with stride 3200 and +1,920,000 bytes per copy. The SHA-256 sequence is +`9e6d2f7b094fd5124c0207a30cb294acec3a01da15b90008d7f7449d27e1ee7c`, +`03e815da50d0f04b16660340db19c1a03225f31bac0824d490c2ffbab0ee165c`, +then the first hash again. A/B exact-color counts are 42,200/17,940 at +400, 0/0 at 1280, then 42,200/17,940 after return. Each second read is +byte-identical. Every selected buffer matched its synchronous commit +callback, accepted native sequence and `presented=true` headless event; +each extra lock was released **once**, on that event. No cross-output +release occurred. A HEADLESS-2 `POST_BUILD` and commit intervened between +the lock of buffer ID 3 and its matching HEADLESS-1 present; the buffer +remained locked through that interval. This is the direct positive test +against the historical scope defect. + +In one-pair private fault injection +`private_mosaic-buffer_cycles-asi1-1791781965826777786`, ID 1 was +intentionally retained after its present and released once when a later +accepted HEADLESS-1 buffer commit replaced it. The other two selected +buffers released on present. In separate run +`private_mosaic-buffer_cycles-asi1-1791781980485708932`, ID 1 released +once by the two-second timer (logged age 2,000,030,155 ns); the other +two released on present. The timer and slot were cleaned in both paths. +The rejected-commit and output-destroy branches compile but were **NOT +TESTED** by a controlled runtime trigger. A same-output configuration +commit without a selected new buffer was not a controlled lifetime test; +the code condition excludes eviction, but runtime qualification is +**NOT TESTED**. + +The historical 32-buffer readback used 61.44 MB of first copies; this +selective run used 5.76 MB, a **90.6% reduction in copied bytes and first +readback count**. Mode 2 adds 5.76 MB for its three second reads. Sequential +runs are not a controlled latency benchmark: median HEADLESS-1 +POST_BUILD→COMMIT_ACCEPTED over all frames was 0.083, 0.109 and 0.112 ms +in modes 0, 1 and 2, while the three selected readback commits alone took +about 4.255/1.621/1.082 ms in mode 1 and 3.287/1.004/1.025 ms in mode +2. Host scheduling, renderer work, copies and logs were not separated. +The readback is therefore still materially intrusive on sampled commits; +no reliable wall-clock speedup is claimed. + +### Applied scene and pointer + +The experimental epoch increments after `arrange_popups()` handles a +popup and after `transaction_progress()` completes `arrange_root()`. +It is visible in PRE_BUILD, POST_BUILD and COMMIT_ACCEPTED stage logs, +and at `node_at_coords()` hits and wlroots seat button notification. +The final patch also records the root-popup applied clip and logical camera +at each hit. The visible B hit had logical 400, applied tiled scene -400, +clip x=400 and a nonempty root-popup clip. With the owner hidden at 1280, +the root-popup clip was explicitly empty at the hit; the observed surface +under the pointer was an ordinary surface, not a popup. The clipped +HEADLESS-2 hit also found the ordinary surface while the root-popup clip +remained bounded to HEADLESS-1. Nested-popup mapping remains verified in +the client log, not atomically exported with the hit record. +The three selected buffers carried applied tiled scene/clip `-400/400`, +`-1280/1280`, `-400/400`, consistent with their decoded pixels. This is +an ordered observation, not an atomic frame/scene identity. + +In the three-pair mode-2 run, the same owner IPC ID 8 and one A/B map +persisted through all three hide/return pairs. Six further alternating +IPC camera requests were accepted and returned to 400; they are not +counted as six presented images. Before the final visible B button, the +last observed HEADLESS-1 headless present was native sequence 48, whose +COMMIT_ACCEPTED stage and subsequent scene hit both recorded epoch 37, +applied scene `-400` and clip x=400. No *instrumented* mutation occurred +between these observations. Buffer 48 was **not** among the three copied +buffers, and the partial epoch cannot exclude uninstrumented mutations. +It does not prove atomic frame-specific input agreement. + +Separate `run-negative-private_mosaic-{visible,b_cross,hidden_click}` +controls logged actual requested and obtained global coordinates, +`wlr_scene_node_at()` result, applied clip, seat notification and client +events. Visible B received its button. On HEADLESS-2's clipped B portion, +the hit and seat focus were the ordinary client, whose button was logged; +B received no button from that point and `popup_done` occurred B then A +under the XDG grab. With the owner fully hidden at 1280, the hit was an +ordinary surface under the pointer, no popup button was delivered, and +`popup_done` again occurred B then A. The private and unmodified `splith` +controls retained the ordinary crossing B button behavior. A seat's +focused-surface value is logged before wlroots notification; client logs +are the separate evidence of actual delivery. No pointer event is +assigned a native frame ID. + +The counter does **not** cover every SceneFX node mutation, client buffer +commit, damage, animation, effect/saved-buffer change, direct XDG scene +change, output configuration or internal grab decision. It also increments +for some arrangements that may leave sampled state unchanged. Consequently +matching epoch values provide only bounded negative evidence about the +instrumented sites. No visual leak, invisible popup hit, memory-lifetime +anomaly, crash or blocking standard-layout regression was reproduced in +the tested matrix. Scaling, rotation, migration, full effects/damage and +physical presentation remain **NOT TESTED**. + +**Verdict: PARTIALLY_FEASIBLE.** Per-output reference ownership fixes the +historical cross-output release in the tested path, and applied scene, +buffer, native present, scene hit and actual client delivery can be +observed separately. The partial epoch and intrusive readback cannot yet +establish a complete temporal scene/render/input generation. The next +minimal gate is to instrument or formally bound every applied scene and +hit-affecting mutation, then repeat a low-impact selected-buffer/input +test against that complete boundary. This is no production decision. + +## Proof manifests and validation + +A further fresh, bounded replay in +`/tmp/swayfx-mosaic-nested-grab-OWIvboDB/` rebuilt private SceneFX and +Mosaic from the preserved fixture chain. One visible/hidden/returned pair +produced 65 accepted commits and 63 headless presents in trace-only mode, +with no buffer lock, then 65/63 in second-read mode. All three selected +HEADLESS-1 buffers matched native presents, had byte-identical second +reads, and were unlocked once at their matching present. A HEADLESS-2 +build/commit occurred while the first HEADLESS-1 reference was retained. +The three states repeated clip x=400/1280/400 and the visible/hidden/ +visible color counts. Fresh visible, clipped and hidden pointer runs +reproduced the B delivery and XDG dismissal distinction; unmodified and +private standard `splith` controls delivered the crossing B button. +This is a targeted replay, not a new matrix qualification. + +The primary and independent labs retain structured `results-asi/`, raw +buffers, captures, event logs and source/artifact SHA-256 manifests. The +rebuild starts from published Mosaic `11c80a40f20240a2ca137d412f6e3b711c5d5f80`, +inherits private Mosaic `cd550382b311efdbfe3ed892f4e1d281d852e754` +and SceneFX `37ccd723bef49e6891156ffafce8f549f01446cc` through the +frozen fixture chain, and uses pinned SwayFX +`fd71a6bdc061bd633b488ae7b83e8a6981d22f86` with wlroots 0.20.2. +The five-source manifest in each +lab has SHA-256 +`0ef45e3e4d7623498d20db8a290ca1dd7bc6cdd6e69a2276e96a01ad1ae475cb` +and all five source checks pass. The primary lab's 729-artifact manifest +has SHA-256 +`f7cdefe8d90e2548c927951341b37f8c72b2ad3e64dee53fda6f43438f2b8ef9`; +the final-patch independent lab's 189-artifact manifest has SHA-256 +`d3f3782538646e37444fde3fd68a7cb494c50dfa190e6df60f176d5ddd1e47ce`. +All listed available artifacts passed `sha256sum -c`. The older main-lab +intermediate runs remain in the primary manifest as negative development +evidence; final-patch reproduction is distinguished by the independent +lab path and source-equivalence check. diff --git a/tooling/probes/step20c-applied-state-input/README.md b/tooling/probes/step20c-applied-state-input/README.md new file mode 100644 index 0000000..a021d4c --- /dev/null +++ b/tooling/probes/step20c-applied-state-input/README.md @@ -0,0 +1,83 @@ +# STEP20C applied state / input fixture — EXPERIMENTAL + +The [protocol](../../../docs/design/STEP20C_APPLIED_STATE_INPUT_PROBE.md) +was written before this fixture. This directory will retain only source +patches, preparation, runner and analysis code needed to reconstruct a +disposable private headless laboratory. Its inputs are the frozen +[buffer fixture](../step20c-buffer-identity/README.md), not production +Mosaic or installed SceneFX. Generated builds, buffers, PNGs, logs, +sockets and manifests remain in a named private `/tmp` laboratory. + +**Status: EXPERIMENTAL / PARTIALLY_FEASIBLE.** The distinct +[`applied-state-input.patch`](applied-state-input.patch) applies after the +frozen buffer patch and changes only private `output.c`, `transaction.c`, +`cursor.c` and `seat.c`. It leaves clipping and hit decisions untouched. +The patch's SHA-256 is +`8a0766536fa728a6be6ef29aea3be67abbfad107f2fcd6048718097499a4d88f`. + +## Rebuild and bounded replay + +```sh +prep=$(sh tooling/probes/step20c-applied-state-input/prepare.sh) +printf '%s\n' "$prep" +LAB=$(printf '%s\n' "$prep" | sed -n 's/^LAB=//p') +(cd "$LAB" && sha256sum -c asi-source-sha256.txt) +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-applied-state-input/run.py \ + "$LAB" private_mosaic buffer_cycles --cycles 3 --asi-mode 0 \ + --max-seconds 120 --max-captures 100 +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-applied-state-input/run.py \ + "$LAB" private_mosaic buffer_cycles --cycles 3 --asi-mode 1 \ + --max-seconds 120 --max-captures 100 +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-applied-state-input/run.py \ + "$LAB" private_mosaic buffer_cycles --cycles 3 --asi-mode 2 \ + --max-seconds 120 --max-captures 100 +``` + +Mode 0 logs without a probe lock/read. Mode 1 samples only three buffers +after both A/B have mapped, in order visible 400, hidden 1280, returned +400. Mode 2 reads these again only during their matching headless present +while the owned reference remains. The runner creates a private runtime, +Wayland display and IPC socket, bounds captures, then stops its process +groups. Standard and pointer controls reuse the frozen negative-origin +runner against the same private binary, with scenario `visible`, `b_cross` +or `hidden_click` and geometry `negative`. Use +`analyze.py LAB RUN` for each run; outputs go to `LAB/results-asi/RUN/`. + +For controlled lifetime paths, run one pair with mode 1 and `--test-hold`: +the first already-presented buffer stays held until a subsequent real +HEADLESS-1 buffer commit. Use `--test-timeout` instead to pause 2.3 seconds +and observe the two-second post-commit timer. These switches are private +fault injections; normal mode does neither. Rejected commits and output +destruction were not injected in the measured campaign. + +## Interpretation + +`asi_applied_epoch` is an experimental counter incremented after private +popup position/clip arrangement and after a transaction's root arrangement. +It can increment even if the sampled state does not change and misses +direct client-buffer, effect, animation, damage, output and other SceneFX +mutations. Matching values show only that these instrumented sites did not +run between observations; they are not a native atomic scene/input +generation. `ASI_HIT` records the executed scene node-at result; +`ASI_DELIVERY` records the seat's focused surface before wlroots button +notification. XDG grabs may alter delivery, so the client log is the +separate final observation. No input event is assigned a frame ID. + +The selected source buffer is GBM XB24. The successful read path uses FX +texture readback to XRGB8888; it can synchronize GPU/CPU. Each selected +copy is 1,920,000 bytes. The private hold is keyed by output and native +commit sequence. HEADLESS-2 repaint cannot release a HEADLESS-1 hold; +configuration-only commits do not replace it. The one-slot-per-output +model releases on matching present, accepted same-output replacement, +timeout or output destruction. Its two-second timer begins after readback +and commit; it cannot bound a stalled GPU readback call. + +## Measured lab + +`/tmp/swayfx-mosaic-nested-grab-l2HvAkRe/` contains trace-only, three- +buffer selective and second-read modes, one-pair replacement and timeout +injections, three Mosaic pointer scenarios and two `splith` controls. +The [report](../../../docs/design/STEP20C_APPLIED_STATE_INPUT_PROBE.md) +records exact runs, counts, hashes, limits and status. Generated evidence +is outside Git. STEP20C stays NEXT; multi-output Mosaic remains NOT YET +SUPPORTED. diff --git a/tooling/probes/step20c-applied-state-input/analyze.py b/tooling/probes/step20c-applied-state-input/analyze.py new file mode 100644 index 0000000..1f378f0 --- /dev/null +++ b/tooling/probes/step20c-applied-state-input/analyze.py @@ -0,0 +1,128 @@ +"""Parse private applied scene, buffer lifetime and input traces without invented frame IDs.""" +import argparse +from collections import Counter +import hashlib +import json +from pathlib import Path +import re +import statistics + +ap = argparse.ArgumentParser() +ap.add_argument('lab', type=Path) +ap.add_argument('run', type=Path) +args = ap.parse_args() +run = args.run.resolve() +lab = args.lab.resolve() +if lab not in run.parents or not (run / 'compositor.log').is_file(): + ap.error('run must be inside the prepared lab') +out = lab / 'results-asi' / run.name +out.mkdir(parents=True, exist_ok=True) +kinds = ('ASI_MUTATION', 'BI_STAGE', 'ASI_HIT', 'ASI_WARP', + 'ASI_BUTTON_REQUEST', 'ASI_DELIVERY', 'ASI_LOCKED', 'ASI_RELEASE', + 'PI_OUTPUT_COMMIT', 'PI_OUTPUT_PRESENT', 'BI_BUFFER', 'BI_READ', + 'BI_COMMIT', 'BI_EVENT_BUFFER', 'BI_PRESENT_READ', 'BI_PRESENT_HOLD') +records = {k: [] for k in kinds} +for number, line in enumerate((run / 'compositor.log').read_text(errors='replace').splitlines(), 1): + for kind in kinds: + marker = kind + ' ' + if marker not in line: + continue + payload = line.split(marker, 1)[1] + fields = dict(re.findall(r'(\w+)=([^ ]+)', payload)) + records[kind].append({'line': number, 'kind': kind, **fields}) + break + +mode = json.loads((run / 'asi-mode.json').read_text()) if (run / 'asi-mode.json').exists() else {'mode': 'pointer_control', 'readback_dir': None} +read_dir = Path(mode['readback_dir']) if mode['readback_dir'] else None +raw = {} +if read_dir: + for f in sorted(read_dir.glob('buffer-*.xrgb')): + raw[f.name] = {'bytes': f.stat().st_size, + 'sha256': hashlib.sha256(f.read_bytes()).hexdigest()} + +commits = {(x['output'], x['commit_seq']): x for x in records['PI_OUTPUT_COMMIT']} +presents = {(x['output'], x['commit_seq']): x for x in records['PI_OUTPUT_PRESENT']} +selected = records['BI_COMMIT'] +releases = records['ASI_RELEASE'] +release_count = Counter(x['id'] for x in releases) +selected_ids = {x['id'] for x in selected} +release_ids = set(release_count) +selected_pairs = {(x['output'], x['commit_seq']) for x in selected if x['accepted'] == '1'} +matched = selected_pairs & set(presents) +client = (run / 'client.log').read_text(errors='replace') if (run / 'client.log').exists() else '' +client_events = [line for line in client.splitlines() if any( + key in line for key in ('POPUP_A MAP', 'POPUP_B MAP', 'POPUP_DONE', + 'POINTER_BUTTON target=', 'GRAB_REQUEST'))] +summary = { + 'mode': mode['mode'], 'native_commits': len(commits), + 'native_presents': len(presents), 'selected_commits': len(selected_pairs), + 'selected_presents': len(matched), 'selected_buffers': len(records['BI_BUFFER']), + 'raw_snapshots': len([x for x in raw if '-present' not in x]), + 'present_snapshots': len([x for x in raw if '-present' in x]), + 'lock_count': len(records['ASI_LOCKED']), 'release_count': len(releases), + 'double_release_ids': sorted(k for k,v in release_count.items() if v != 1), + 'unreleased_ids': sorted(selected_ids - release_ids), + 'cross_output_release_count': sum(1 for x in releases if x.get('reason') == 'superseded'), + 'release_reasons': dict(Counter(x.get('reason') for x in releases)), + 'mutation_events': len(records['ASI_MUTATION']), + 'scene_stages': len(records['BI_STAGE']), + 'hit_tests': len(records['ASI_HIT']), + 'seat_notifications': len(records['ASI_DELIVERY']), + 'client_popup_maps': {'A': client.count('POPUP_A MAP'), 'B': client.count('POPUP_B MAP')}, + 'client_B_buttons': len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*state=1', client)), + 'client_popup_done': {'A': client.count('POPUP_A POPUP_DONE'), + 'B': client.count('POPUP_B POPUP_DONE')}, +} + +correlation = [] +pixel_analysis = [] +for item in selected: + key = (item['output'], item['commit_seq']) + earlier = [stage for stage in records['BI_STAGE'] + if stage.get('phase') == 'POST_BUILD' and + stage['output'] == item['output'] and + int(stage['ns']) <= int(item['ns'])] + stage = earlier[-1] if earlier else None + correlation.append({'id': item['id'], 'output': item['output'], + 'commit_seq': item['commit_seq'], + 'accepted': item['accepted'], + 'native_commit': key in commits, + 'headless_present': key in presents, + 'applied_stage': stage}) + if read_dir: + f = read_dir / ('buffer-' + item['id'] + '.xrgb') + if f.is_file() and f.stat().st_size == 800 * 600 * 4: + from PIL import Image + data = f.read_bytes() + image = Image.frombytes('RGB', (800, 600), data, 'raw', 'BGRX', 0, -1) + colors = {color: count for count, color in image.getcolors(480000)} + second = read_dir / ('buffer-' + item['id'] + '-present.xrgb') + pixel_analysis.append({'id': item['id'], 'commit_seq': item['commit_seq'], + 'epoch': stage.get('epoch') if stage else None, + 'scroll': stage.get('scroll') if stage else None, + 'tile_scene': stage.get('tile_scene') if stage else None, + 'clip': stage.get('clip') if stage else None, + 'A_yellow': colors.get((224, 192, 32), 0), + 'B_green': colors.get((32, 208, 160), 0), + 'raw_sha256': hashlib.sha256(data).hexdigest(), + 'present_read_equal': second.read_bytes() == data if second.is_file() else None}) +post_to_commit = [] +stages = records['BI_STAGE'] +for i, stage in enumerate(stages): + if stage.get('phase') != 'POST_BUILD' or stage['output'] != 'HEADLESS-1': + continue + later = next((x for x in stages[i + 1:] if x['output'] == stage['output'] + and x['phase'] in ('PRE_BUILD', 'COMMIT_ACCEPTED')), None) + if later and later['phase'] == 'COMMIT_ACCEPTED': + post_to_commit.append(int(later['ns']) - int(stage['ns'])) +timing = {'post_build_to_commit_median_ns': + statistics.median(post_to_commit) if post_to_commit else None, + 'sample_count': len(post_to_commit)} + +for name, obj in (('correlation', correlation), ('pixel-analysis', pixel_analysis), + ('timing', timing), ('scene-states', {'build': records['BI_STAGE'], + 'hits': records['ASI_HIT'], 'mutations': records['ASI_MUTATION']}), + ('records', records), ('raw-buffers', raw), + ('client-events', client_events), ('summary', summary)): + (out / (name + '.json')).write_text(json.dumps(obj, indent=2) + '\n') +print(json.dumps(summary, indent=2)) diff --git a/tooling/probes/step20c-applied-state-input/applied-state-input.patch b/tooling/probes/step20c-applied-state-input/applied-state-input.patch new file mode 100644 index 0000000..3e2c27a --- /dev/null +++ b/tooling/probes/step20c-applied-state-input/applied-state-input.patch @@ -0,0 +1,417 @@ +--- a/sway/desktop/output.c ++++ b/sway/desktop/output.c +@@ -444,7 +444,7 @@ + // 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 bi_hold { + struct wlr_buffer *buffer; + struct wlr_output *output; + struct wl_event_source *timer; +@@ -452,23 +452,53 @@ + uint64_t id; + uint64_t locked_ns; + int scroll; +-} bi_hold; ++}; ++// WHY: a different output must never evict this output's probe reference. ++// CONTRACT: at most one owned slot for each fixed headless output. ++// INVARIANT: each non-NULL slot has exactly one matching unlock. ++static struct bi_hold bi_holds[2]; ++static unsigned bi_select_phase; + 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 uint64_t asi_applied_epoch; ++ ++// WHY: distinguish observed applied arrangements from newer logical state. ++// CONTRACT: this experimental counter covers only explicit call sites; ++// equal values never prove an exhaustive or native transaction generation. ++// INVARIANT: increment and read occur on the compositor event-loop thread. ++uint64_t asi_epoch(void) { return asi_applied_epoch; } ++void asi_mark(const char *reason) { ++ struct timespec now; ++ clock_gettime(CLOCK_MONOTONIC, &now); ++ ++asi_applied_epoch; ++ sway_log(SWAY_INFO, "ASI_MUTATION epoch=%" PRIu64 " reason=%s ns=%" PRIu64, ++ asi_applied_epoch, reason, ++ (uint64_t)now.tv_sec * UINT64_C(1000000000) + now.tv_nsec); ++} ++ ++static struct bi_hold *bi_slot(struct wlr_output *output) { ++ if (!output) return NULL; ++ if (strcmp(output->name, "HEADLESS-1") == 0) return &bi_holds[0]; ++ if (strcmp(output->name, "HEADLESS-2") == 0) return &bi_holds[1]; ++ return NULL; ++} ++ ++static void bi_release_hold(struct bi_hold *hold, const char *reason) { ++ if (!hold || !hold->buffer) return; ++ struct wlr_buffer *buffer = hold->buffer; ++ uint64_t id = hold->id; ++ uint64_t age = presentation_probe_now_ns() - hold->locked_ns; ++ struct wl_event_source *timer = hold->timer; ++ memset(hold, 0, sizeof(*hold)); ++ if (timer) wl_event_source_remove(timer); ++ sway_log(SWAY_INFO, "ASI_RELEASE id=%" PRIu64 " reason=%s age_ns=%" PRIu64, ++ id, reason, age); ++ wlr_buffer_unlock(buffer); + } + + static int bi_timeout_hold(void *data) { +- bi_release_hold("timeout"); ++ bi_release_hold(data, "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); +@@ -509,14 +539,15 @@ + } + struct sway_workspace *ws = output->current.active_workspace; + if (!ws || ws->current.layout != L_MOSAIC || !ws->mosaic || +- !ws->mosaic->rows->length || bi_snapshots >= 80) { ++ !ws->mosaic->rows->length || bi_snapshots >= 3) { + 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) { ++ int target = bi_select_phase == 1 ? 1280 : 400; ++ char trigger[PATH_MAX]; ++ int tn = snprintf(trigger, sizeof(trigger), "%s/enable", dir); ++ if (bi_select_phase >= 3 || tn < 0 || (size_t)tn >= sizeof(trigger) || ++ access(trigger, F_OK) != 0 || row->state.scroll_x != target) { + return NULL; + } + struct wlr_buffer *buffer = pending->buffer; +@@ -612,13 +643,13 @@ + } + 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", ++ " clip_valid=%d clip=%d,%d,%d,%d has_buffer=%d epoch=%" PRIu64, + 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); ++ state && (state->committed & WLR_OUTPUT_STATE_BUFFER) && state->buffer, asi_epoch()); + } + + static int output_repaint_timer_handler(void *data) { +@@ -659,8 +690,10 @@ + } + } + +- // INVARIANT: release an older probe lock before taking a new one. +- bi_release_hold("superseded"); ++ // CONTRACT: observe first; only an accepted new-buffer commit on this ++ // same output may replace its older hold. Other-output and no-buffer ++ // commits leave the owned slot untouched. ++ struct bi_hold *hold = bi_slot(output->wlr_output); + uint64_t bi_id = 0; + struct wlr_buffer *bi_locked = bi_observe_buffer(output, &pending, &bi_id); + bi_expected_buffer = bi_locked; +@@ -678,25 +711,27 @@ + wlr_buffer_unlock(bi_locked); + sway_log(SWAY_INFO, "BI_RELEASE id=%" PRIu64 " reason=commit_rejected", bi_id); + } else { ++ if (hold && hold->buffer) bi_release_hold(hold, "same_output_replaced"); + struct wl_event_source *timer = wl_event_loop_add_timer( +- output->wlr_output->event_loop, bi_timeout_hold, NULL); ++ output->wlr_output->event_loop, bi_timeout_hold, hold); + 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(); ++ hold->buffer = bi_locked; ++ hold->output = output->wlr_output; ++ hold->timer = timer; ++ hold->seq = output->wlr_output->commit_seq; ++ hold->id = bi_id; ++ hold->locked_ns = presentation_probe_now_ns(); ++ ++bi_select_phase; + 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); ++ hold->scroll = row ? row->state.scroll_x : -1; ++ sway_log(SWAY_INFO, "ASI_LOCKED id=%" PRIu64 " output=%s commit_seq=%" PRIu32, ++ bi_id, output->wlr_output->name, hold->seq); + } + } + } +@@ -834,8 +869,9 @@ + } + + static void begin_destroy(struct sway_output *output) { +- if (bi_hold.buffer && bi_hold.output == output->wlr_output) { +- bi_release_hold("output_destroy"); ++ struct bi_hold *hold = bi_slot(output->wlr_output); ++ if (hold && hold->buffer && hold->output == output->wlr_output) { ++ bi_release_hold(hold, "output_destroy"); + } + + wl_list_remove(&output->layout_destroy.link); +@@ -887,23 +923,22 @@ + 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) { ++ struct bi_hold *hold = bi_slot(output->wlr_output); ++ if (hold && hold->buffer && hold->output == output->wlr_output && ++ 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); ++ hold->id, 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) { ++ bool reread = bi_reread_phase < 3; ++ if (reread && getenv("ASI_MODE") && strcmp(getenv("ASI_MODE"), "2") == 0) { + 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; ++ output->wlr_output->renderer, hold->buffer) : NULL; + bool read_ok = false; + if (tex) { + struct wlr_texture_read_pixels_options opts = { +@@ -915,7 +950,7 @@ + } + char path[PATH_MAX]; + int n = dir ? snprintf(path, sizeof(path), +- "%s/buffer-%" PRIu64 "-present.xrgb", dir, bi_hold.id) : -1; ++ "%s/buffer-%" PRIu64 "-present.xrgb", dir, hold->id) : -1; + size_t off = 0; + int fd = -1; + if (read_ok && n >= 0 && (size_t)n < sizeof(path)) { +@@ -931,12 +966,20 @@ + } + } + sway_log(SWAY_INFO, "BI_PRESENT_READ id=%" PRIu64 +- " status=%s bytes=%zu phase=%u", bi_hold.id, ++ " status=%s bytes=%zu phase=%u", hold->id, + off == bytes ? "READ" : "UNAVAILABLE", off, bi_reread_phase); + if (off == bytes) bi_reread_phase++; + free(pixels); + } +- bi_release_hold("present"); ++ // CONTRACT: private fault injection retains one already-presented ++ // buffer until a later same-output commit or the two-second timeout. ++ // It exercises replacement ownership without changing render output. ++ if (getenv("ASI_TEST_HOLD_AFTER_PRESENT") && hold->id == 1) { ++ sway_log(SWAY_INFO, "ASI_TEST_HOLD id=%" PRIu64 " output=%s", ++ hold->id, output->wlr_output->name); ++ } else { ++ bi_release_hold(hold, "present"); ++ } + } + + if (!output->enabled || !output_event->presented) { +--- a/sway/desktop/transaction.c ++++ b/sway/desktop/transaction.c +@@ -23,6 +23,8 @@ + #include "sway/tree/workspace.h" + #include "list.h" + #include "log.h" ++ ++extern void asi_mark(const char *reason); + + #define POPIN_FACTOR 0.8f + +@@ -1117,6 +1119,9 @@ + wlr_scene_node_coords(popup->relative, &lx, &ly); + wlr_scene_node_set_position(node, lx, ly); + probe_clip_popup(node, popup); ++ // CONTRACT: mark only this applied popup arrangement site; ++ // other SceneFX and XDG mutations remain uncounted. ++ asi_mark("popup_position_clip_observed"); + } + } + } +@@ -1244,6 +1249,7 @@ + } + transaction_apply(server.queued_transaction); + arrange_root(root); ++ asi_mark("transaction_arrange_root_complete"); + cursor_rebase_all(); + transaction_destroy(server.queued_transaction); + start_animations(); +--- a/sway/input/cursor.c ++++ b/sway/input/cursor.c +@@ -4,6 +4,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -27,10 +28,59 @@ + #include "sway/scene_descriptor.h" + #include "sway/server.h" + #include "sway/tree/container.h" ++#include "sway/tree/mosaic.h" + #include "sway/tree/root.h" + #include "sway/tree/view.h" + #include "sway/tree/workspace.h" + #include "wlr-layer-shell-unstable-v1-protocol.h" ++ ++extern uint64_t asi_epoch(void); ++ ++// WHY: observe the executed scene hit, independently of grab delivery. ++// CONTRACT: pointers are logged only within the hit call; epoch covers only ++// the instrumented mutation sites and is not a native transaction ID. ++static void asi_hit(double lx, double ly, struct wlr_scene_node *node, ++ struct wlr_surface *surface) { ++ struct timespec now; ++ clock_gettime(CLOCK_MONOTONIC, &now); ++ struct wlr_output *wlr_output = wlr_output_layout_output_at(root->output_layout, lx, ly); ++ struct sway_output *output = wlr_output ? wlr_output->data : NULL; ++ struct sway_workspace *ws = output ? output->current.active_workspace : NULL; ++ struct wlr_box clip = {0}; ++ bool has_clip = ws && wlr_scene_tree_get_clip(ws->layers.tiling, &clip); ++ int logical_scroll = -1; ++ if (ws && ws->current.layout == L_MOSAIC && ws->mosaic && ++ ws->mosaic->rows->length) { ++ struct mosaic_row *row = ws->mosaic->rows->items[0]; ++ logical_scroll = row->state.scroll_x; ++ } ++ struct wlr_box popup_clip = {0}; ++ bool popup_clip_valid = false; ++ bool root_popup_found = false; ++ struct wlr_scene_node *popup_node; ++ wl_list_for_each(popup_node, &root->layers.popup->children, link) { ++ if (scene_descriptor_try_get(popup_node, SWAY_SCENE_DESC_POPUP)) { ++ root_popup_found = true; ++ popup_clip_valid = wlr_scene_tree_get_clip( ++ wlr_scene_tree_from_node(popup_node), &popup_clip); ++ break; ++ } ++ } ++ sway_log(SWAY_INFO, "ASI_HIT ns=%" PRIu64 " epoch=%" PRIu64 ++ " output=%s lx=%.2f ly=%.2f node=%p type=%d surface=%p" ++ " workspace=%zu logical_scroll=%d tile_scene=%d,%d" ++ " clip=%d,%d,%d,%d clip_valid=%d popup_root=%d" ++ " popup_clip=%d,%d,%d,%d popup_clip_valid=%d", ++ (uint64_t)now.tv_sec * UINT64_C(1000000000) + now.tv_nsec, ++ asi_epoch(), wlr_output ? wlr_output->name : "none", lx, ly, ++ (void *)node, node ? (int)node->type : -1, (void *)surface, ++ ws ? ws->node.id : 0, logical_scroll, ++ ws ? ws->layers.tiling->node.x : 0, ++ ws ? ws->layers.tiling->node.y : 0, ++ clip.x, clip.y, clip.width, clip.height, has_clip, root_popup_found, ++ popup_clip.x, popup_clip.y, popup_clip.width, popup_clip.height, ++ popup_clip_valid); ++} + + /** + * Returns the node at the cursor's position. If there is a surface at that +@@ -40,6 +90,7 @@ + struct sway_seat *seat, double lx, double ly, + struct wlr_surface **surface, double *sx, double *sy) { + struct wlr_scene_node *scene_node = NULL; ++ struct wlr_surface *asi_surface = NULL; + + struct wlr_scene_node *node; + wl_list_for_each_reverse(node, &root->layer_tree->children, link) { +@@ -67,9 +118,11 @@ + + if (scene_surface) { + *surface = scene_surface->surface; ++ asi_surface = scene_surface->surface; + } + } + ++ asi_hit(lx, ly, scene_node, asi_surface); + // determine what container we clicked on + struct wlr_scene_node *current = scene_node; + while (true) { +@@ -116,6 +169,7 @@ + } + } + ++ if (!scene_node) asi_hit(lx, ly, NULL, NULL); + // if we aren't on a container, determine what workspace we are on + struct wlr_output *wlr_output = wlr_output_layout_output_at( + root->output_layout, lx, ly); +@@ -347,6 +401,8 @@ + + pointer_motion(cursor, event->time_msec, &event->pointer->base, dx, dy, + dx, dy); ++ sway_log(SWAY_INFO, "ASI_WARP requested=%.6f,%.6f layout=%.2f,%.2f actual=%.2f,%.2f epoch=%" PRIu64, ++ event->x, event->y, lx, ly, cursor->cursor->x, cursor->cursor->y, asi_epoch()); + } + + void dispatch_cursor_button(struct sway_cursor *cursor, +@@ -356,6 +412,8 @@ + time_msec = get_current_time_in_msec(); + } + ++ sway_log(SWAY_INFO, "ASI_BUTTON_REQUEST epoch=%" PRIu64 " x=%.2f y=%.2f button=%u state=%d", ++ asi_epoch(), cursor->cursor->x, cursor->cursor->y, button, (int)state); + seatop_button(cursor->seat, time_msec, device, button, state); + } + +--- a/sway/input/seat.c ++++ b/sway/input/seat.c +@@ -3,6 +3,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -1540,10 +1541,22 @@ + return NULL; + } + ++extern uint64_t asi_epoch(void); ++ + void seat_pointer_notify_button(struct sway_seat *seat, uint32_t time_msec, + uint32_t button, enum wl_pointer_button_state state) { ++ // WHY: an XDG grab can change final delivery independently of node-at. ++ // CONTRACT: this is focus before wlroots notification; the client log ++ // remains the evidence of actual receipt. ++ struct wlr_surface *focused = seat->wlr_seat->pointer_state.focused_surface; + seat->last_button_serial = wlr_seat_pointer_notify_button(seat->wlr_seat, + time_msec, button, state); ++ struct timespec now; ++ clock_gettime(CLOCK_MONOTONIC, &now); ++ sway_log(SWAY_INFO, "ASI_DELIVERY ns=%" PRIu64 " epoch=%" PRIu64 ++ " surface=%p serial=%u button=%u state=%d", ++ (uint64_t)now.tv_sec * UINT64_C(1000000000) + now.tv_nsec, ++ asi_epoch(), (void *)focused, seat->last_button_serial, button, (int)state); + } + + void seat_consider_warp_to_focus(struct sway_seat *seat) { diff --git a/tooling/probes/step20c-applied-state-input/prepare.sh b/tooling/probes/step20c-applied-state-input/prepare.sh new file mode 100755 index 0000000..a4aa1bd --- /dev/null +++ b/tooling/probes/step20c-applied-state-input/prepare.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# EXPERIMENTAL: rebuild only in a disposable private laboratory. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-applied-state-input" +prep_log=$(mktemp /tmp/asi-prepare-XXXXXXXX) +if ! sh "$repo/tooling/probes/step20c-buffer-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 +(cd "$lab/mosaic" && git apply --check "$fixture/applied-state-input.patch" && + git apply "$fixture/applied-state-input.patch") +meson compile -C "$lab/mosaic-build" -j 8 >> "$prep_log" 2>&1 +sha256sum "$fixture/applied-state-input.patch" "$fixture/prepare.sh" \ + "$fixture/run.py" "$fixture/analyze.py" "$fixture/README.md" \ + > "$lab/asi-source-sha256.txt" +trap - 0 +printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$prep_log" diff --git a/tooling/probes/step20c-applied-state-input/run.py b/tooling/probes/step20c-applied-state-input/run.py new file mode 100644 index 0000000..bc1f38e --- /dev/null +++ b/tooling/probes/step20c-applied-state-input/run.py @@ -0,0 +1,482 @@ +"""EXPERIMENTAL applied-state/input 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=3) +parser.add_argument('--asi-mode', type=int, choices=(0, 1, 2), default=0) +parser.add_argument('--test-hold', action='store_true') +parser.add_argument('--test-timeout', action='store_true') +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}-asi{args.asi_mode}-{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() +compositor_env['ASI_MODE'] = str(args.asi_mode) +if args.test_hold or args.test_timeout: + compositor_env['ASI_TEST_HOLD_AFTER_PRESENT'] = '1' +readback_dir = None +if args.asi_mode: + readback_dir = lab / 'buffers' / run.name + readback_dir.mkdir(parents=True, mode=0o700) + compositor_env['BI_OUTPUT_DIR'] = str(readback_dir) +(run / 'asi-mode.json').write_text(json.dumps({'mode': args.asi_mode, + 'readback_dir': str(readback_dir) if readback_dir else None}) + '\n') +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) + if readback_dir: + (readback_dir / 'enable').touch() + 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') + if args.test_timeout: + time.sleep(2.3) + 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')) + # Six accepted IPC requests are observations, not six promised frames. + for i in range(6): + target = 1280 if i % 2 == 0 else 400 + record('asi_rapid_request', index=i, target=target) + scroll(target) + snapshot('asi-rapid-return') + o = owner() + a_cfg, b_cfg = configured('POPUP_A'), configured('POPUP_B') + b_x = int(o['rect']['x'] - workspace()['mosaic']['rows'][0]['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) + 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('asi_B_delivered', global_point=[b_x, b_y]) + 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)