docs(mosaic): preserve applied scene input probe evidence

This commit is contained in:
fy59 2026-10-12 07:36:29 +02:00
parent 11c80a40f2
commit 1f59cb433b
11 changed files with 1462 additions and 1 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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**.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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))

View file

@ -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 <linux/input-event-codes.h>
#include <errno.h>
#include <time.h>
+#include <inttypes.h>
#include <strings.h>
#include <wlr/types/wlr_cursor.h>
#include <wlr/types/wlr_cursor_shape_v1.h>
@@ -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 <string.h>
#include <strings.h>
#include <time.h>
+#include <inttypes.h>
#include <wlr/config.h>
#include <wlr/types/wlr_cursor.h>
#include <wlr/types/wlr_data_device.h>
@@ -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) {

View file

@ -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"

View file

@ -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)