From 642c61aaafa168329e3371ff6300db540999c302 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 12 Oct 2026 18:02:08 +0200 Subject: [PATCH] docs(mosaic): preserve saved transaction buffer probe evidence --- .gitignore | 4 + CHANGELOG.md | 2 + docs/ARCHITECTURE.md | 9 + docs/KNOWN_LIMITATIONS.md | 10 + docs/README.md | 15 +- docs/ROADMAP.md | 10 + .../STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md | 240 ++++++++++++++++ .../README.md | 37 +++ .../analyze.py | 188 +++++++++++++ .../budget.py | 35 +++ .../prepare.sh | 27 ++ .../step20c-saved-transaction-buffer/run.py | 258 ++++++++++++++++++ .../saved-client.c | 215 +++++++++++++++ .../scenefx-saved-trace.patch | 23 ++ .../sway-saved-trace.patch | 60 ++++ .../test-budget.py | 46 ++++ 16 files changed, 1178 insertions(+), 1 deletion(-) create mode 100644 docs/design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md create mode 100644 tooling/probes/step20c-saved-transaction-buffer/README.md create mode 100644 tooling/probes/step20c-saved-transaction-buffer/analyze.py create mode 100644 tooling/probes/step20c-saved-transaction-buffer/budget.py create mode 100644 tooling/probes/step20c-saved-transaction-buffer/prepare.sh create mode 100644 tooling/probes/step20c-saved-transaction-buffer/run.py create mode 100644 tooling/probes/step20c-saved-transaction-buffer/saved-client.c create mode 100644 tooling/probes/step20c-saved-transaction-buffer/scenefx-saved-trace.patch create mode 100644 tooling/probes/step20c-saved-transaction-buffer/sway-saved-trace.patch create mode 100644 tooling/probes/step20c-saved-transaction-buffer/test-budget.py diff --git a/.gitignore b/.gitignore index a037e1e..f8151c5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ opt/ *.pkg.tar.* .SRCINFO.tmp .SRCINFO + +# Generated Python bytecode from experimental fixtures +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index abdbb1b..cab7724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### STEP20C experimental evidence +- Preserved saved-transaction-buffer probe: real held XDG configures produced nonempty saved scene buffers; private saved nodes entered rendering and sampled ACK/timeout paths removed their trees. Mosaic's target was outside H1 after the applied camera reached 1760, so partial-boundary clipping is NOT EXERCISED and the verdict is INCONCLUSIVE. Three distinct executed runner copies and the private-standard fixture timeout are retained; no production change. + - Local static-R90 partial-popup probe: a legal nested B popup crossed the Mosaic viewport by 72 logical pixels; 46,455 B pixels and a real button survived on H1, while H2 was pixel-identical to its reference and delivered no B button. Both standard controls showed 35,360 B pixels and a B button on H2. A crossed visually, but its forbidden-side input was not independently clicked. PARTIALLY_FEASIBLE for the tested matrix; no production change or phase-B commit/push. - Preserved rotated-output probe: actual 90° output keeps an 800×600 logical viewport while the physical mode becomes 750×1000 and `grim` remains logical-oriented 1000×750. Private standard shows 15,860 B-popup pixels on H2; private Mosaic shows zero, but its B popup ends before the boundary, so partial clipping is unchallenged. Fully hidden owner, pointer, measured physical damage and R0/standard returns pass sampled checks. Pristine R90 capture stopped on a fixture IPC-direction assertion; no replay. PARTIALLY_FEASIBLE, architectural conclusion UNDECIDED; no production change. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d9b54d6..2e4aa4e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -279,3 +279,12 @@ viewport in the sampled state; pristine and private-standard controls both drew and handled the crossed portion. Result **PARTIALLY_FEASIBLE** because the clipped-away part of A was not independently clicked and no atomic render/input generation was proved. The architecture remains experimental. + +The local [saved transaction buffer probe](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md) +found `saved_surface_tree` under `view->scene_tree` as a sibling of live +`content_tree`; SceneFX retains the source buffer at creation and a private +trace observed saved render entries and normal removal after ACK/timeout. +The experimental Mosaic tiled clip is an ancestor of the view tree, so a +saved tree has a plausible inherited path. The measured target was outside +H1 after the applied camera reached 1760, preventing a boundary-clipping +conclusion. This remains INCONCLUSIVE, with no production architecture change. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 232ae64..0003f31 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -219,3 +219,13 @@ but independent input on its forbidden portion remains **NOT TESTED**. Saved buffers, optimized blur caches, inner shadows, intermediate frames, atomic generation and SceneFX/wlroots ABI/package remain unqualified. Multi-output stays **NOT YET SUPPORTED**. + +The [saved transaction buffer probe](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md) +is **INCONCLUSIVE** for the intended partial-overflow gate. The target +snapshot was outside H1 because its applied camera differed from the +fixture's assumed initial position. Other saved nodes rendered in the +private scene; ACK and natural timeout removed sampled trees. Target pixels, +clipped input, transaction-scoped damage, destruction while pending and +reference lifetime after texture conversion remain unqualified. One +private-standard episode failed through fixture timing. STEP20C remains +**NEXT** and production multi-output **NOT YET SUPPORTED**. diff --git a/docs/README.md b/docs/README.md index 9ade9cc..9147c7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,11 @@ The [root README](../README.md) is the public summary. For a specific topic, use [Pending-input source fixture](../tooling/probes/step20c-event-loop-pending-input/README.md) replays the disposable matrix. 23. [STEP20C effects/damage probe](design/STEP20C_EFFECTS_DAMAGE_PROBE.md): real translucent blur and shadow-overflow pixels compared across standard and private Mosaic layouts; sampled damage and camera returns PARTIALLY_FEASIBLE, optimized blur cache unqualified. [Effects/damage source fixture](../tooling/probes/step20c-effects-damage/README.md) rebuilds the disposable matrix. -24. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. +24. [STEP20C static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md): sampled genuine B crossing and input with a completed pristine control; broader STEP20C gates remain open. + [R90 source fixture](../tooling/probes/step20c-rotated-partial-popup/README.md) preserves the published experimental inputs. +25. [STEP20C saved transaction buffer probe](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md): real saved nodes and lifetime evidence, but target partial-overflow geometry NOT EXERCISED; result INCONCLUSIVE. + [Saved-buffer source fixture](../tooling/probes/step20c-saved-transaction-buffer/README.md) preserves the experimental inputs. +26. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. ## Project @@ -88,6 +92,8 @@ The [root README](../README.md) is the public summary. For a specific topic, use | [STEP20C commit/present/input ordering probe](design/STEP20C_COMMIT_PRESENT_INPUT_ORDERING_PROBE.md) | Experimental, independently replayed evidence | INCONCLUSIVE for the discriminating temporal window; headless input-region commits and hits agree in observed states, but none of 20 applied mutations occurred between a native output commit and its present | | [STEP20C event-loop pending-input probe](design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md) | Experimental, independently replayed evidence | INCONCLUSIVE / NOT EXERCISED for natural commit→input mutation→present despite decoupled client submission; the temporal line is closed for this backend/matrix | | [STEP20C effects/damage probe](design/STEP20C_EFFECTS_DAMAGE_PROBE.md) | Experimental, independent private replay | PARTIALLY_FEASIBLE for visible blur, excluded sampled shadow overflow, settled camera restoration and sampled damage; shadow interior, optimized cache, popup effects and saved buffers remain unqualified | +| [STEP20C static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; sampled B crossing clips under R90, independent A input remains untested | +| [STEP20C saved transaction buffer probe](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md) | Experimental, local evidence | INCONCLUSIVE; real saved nodes rendered, target partial crossing not exercised | A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation. @@ -128,3 +134,10 @@ its H1 pixels and real button remained, while H2 matched its reference and delivered no B button. Both standard controls drew and handled the overflow. Independent clipped-away A input and saved buffers remain open. STEP20C is NEXT; production multi-output is NOT YET SUPPORTED. + +[Saved transaction buffer probe](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md) +and [source fixture](../tooling/probes/step20c-saved-transaction-buffer/README.md): +EXPERIMENTAL / INCONCLUSIVE. Real held XDG configures created nonempty saved +scene buffers; private render entries and ACK/timeout removal were observed. +The Mosaic target was wholly off H1 after an applied relative camera scroll, +so the partial-boundary clipping gate was NOT EXERCISED. STEP20C stays NEXT. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f233496..c253bf0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -221,3 +221,13 @@ temporal/effect cases remain open. Next prioritize saved transaction buffers against the same owner-derived viewport before optimized blur cache, interior shadow and ABI/package maintenance. STEP20C remains **NEXT**; production multi-output remains **NOT YET SUPPORTED**. + +The subsequent [saved transaction buffer campaign](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md) +observed real held XDG configure transactions, reused saved buffers, private +render entries and sampled ACK/timeout removal. Its target never crossed the +H1/H2 boundary after the applied Mosaic camera settled at 1760. Overall +**INCONCLUSIVE**; all three run reservations are consumed. A new, separately +authorized saved-buffer experiment must calibrate the applied camera and +target crossing before the first transaction. Optimized blur/cache and +SceneFX ABI/package remain separate unqualified gates. STEP20C stays +**NEXT**, multi-output **NOT YET SUPPORTED**. diff --git a/docs/design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md b/docs/design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md new file mode 100644 index 0000000..59bba8d --- /dev/null +++ b/docs/design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md @@ -0,0 +1,240 @@ +# STEP20C saved transaction buffer clipping and lifetime probe + +**EXPERIMENTAL / INCONCLUSIVE, preserved after execution.** STEP20C remains NEXT and +multi-output Mosaic production is NOT YET SUPPORTED. This campaign follows +the [static R90 popup evidence](STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md) +without changing production Mosaic, SceneFX algorithms, UX or packaging. + +## Source contract and ownership + +Pinned inputs: SwayFX 0.6 `fd71a6bdc061bd633b488ae7b83e8a6981d22f86`, +private Mosaic `cd550382b311efdbfe3ed892f4e1d281d852e754`, SceneFX 0.5 +`37ccd723bef49e6891156ffafce8f549f01446cc`, wlroots 0.20.2. The +private source tree is disposable; installed libraries remain untouched. + +`transaction_commit()` configures a visible changed-size view when +`should_configure()` sees different current/instruction content dimensions, +then calls `view_save_buffer()` for every visible view instruction without an +existing saved tree. `view_save_buffer()` creates `saved_surface_tree` under +`view->scene_tree`, places it below `output_handler`, initially disables it, +iterates enabled buffers of `content_tree`, creates new scene buffer nodes, +and calls `wlr_scene_buffer_set_buffer(sbuf, buffer->buffer)`. This reuses a +referenced source buffer; it does not copy pixels to a new SHM image. +SceneFX's setter calls `wlr_buffer_lock()` on a non-null buffer and unlocks +the previously owned buffer when replacing it; its texture conversion path +may release that lock after obtaining a texture. Node destruction releases +its owned buffer/texture through the same scene lifetime. Pointer equality at +creation is therefore evidence of reuse, not proof that one particular buffer +lock remains held throughout every rendered frame. +The live content tree is then disabled and the saved tree enabled. + +The saved tree is a **sibling** of `content_tree`, not its descendant. +`apply_container_state()` normally removes it via `view_remove_saved_buffer()`: +destroy saved scene nodes and enable live content. The destroying-container +branch skips this immediate removal; close animation and container teardown +must be audited separately. XDG commit uses the actual current configure +serial to notify transaction readiness; a nonmatching commit can send frame +done to a saved view. `transaction_progress()` applies when all waiting +instructions are ready. Its timeout callback sets waiting to zero and uses +the same progress path. A new pending transaction cannot commit while a +queued transaction exists. `-Dtxn-timeout=1200` sets the private test policy; +the code default is 200 ms. No `-Dtxn-wait` or `-Dnoatomic` is permitted. + +`view->scene_tree` is normally reparented under tiled container +`content_tree`. The private Mosaic clip is set on the owning workspace's +`layers.tiling`; SceneFX's `scene_node_intersect_clip()` walks all tree +ancestors for render visibility and damage, and `scene_node_point_inside_clip()` +guards node-at. Thus inheritance is plausible for the saved sibling, but +runtime parentage, enabled state, actual buffer, render contribution and pixels +must be observed. The `SWAY_SCENE_DESC_SAVED_BUFFER` descriptor marks saved +nodes. `SWAY_SCENE_DESC_VIEW` marks the view tree; container descriptors +identify ownership. `output_handler` is an input-rejecting buffer above the +saved tree and tracks output updates. A saved scene buffer is not itself a +Wayland surface: node-at, surface lookup, seat notification and client delivery +are separate observations. An output buffer and an optimized blur cache are +different objects with different lifetimes. + +For destruction, XDG unmap calls `view_unmap()`, which starts container +destruction. `container_destroy()` asserts zero transaction references before +detaching the view and destroying its scene tree; `view_destroy()` also asserts +the view is unmapped, marked destroying and detached. The destroying branch +of `apply_container_state()` deliberately skips immediate saved-tree removal. +`transaction_destroy()` can defer final container deletion through a close +animation, whose completion callback removes the saved tree before destroying +the container. The observed ACK/timeout episodes did not exercise mapped-view +destruction while a transaction was waiting; that path remains NOT TESTED. + +## Controlled fixture and evidence gates + +Use one C17 XDG SHM client in all modes. It maps a deterministic initial +buffer, logs configure serial/size/monotonic time, keeps dispatching pings and +input, and accepts `hold_next_configure` / `release_configure` over a private +control channel. Release ACKs the latest valid serial and commits the +corresponding size; it never invents a serial. A normal geometry command +(candidate: `gaps inner` after source and IPC calibration) must create a real +visible view instruction, a nonempty saved tree, live-content disablement +and a waiting transaction. A camera change alone is insufficient. A source +trace without an active nonempty saved buffer does not pass S1. + +Use H1 `800×600`, scale 1, normal, origin `(-800,0)`, and H2 `800×600`, +scale 1, normal, origin `(0,0)`. H1 hosts four tiled cells and a Mosaic camera +near 400; H2 hosts an ordinary reference surface. Recalculate owner, scene, +snapshot and viewport boxes from applied IPC and trace. The clipping gate +requires `saved_buffer_left < 0 < saved_buffer_right`; otherwise partial +exclusion is NOT EXERCISED. Capture a full H2 reference before transaction. + +One run each: C0 pristine `splith`, C1 private `splith`, C2 private `mosaic`. +Controls validate ordinary snapshot/ACK/timeout/lifetime behavior, though +standard tiling need not overflow. S0 calibrates outputs, renderer, PNGs, +view/owner, viewport, pointer and ordinary input. S1 retains a real configure +and proves creation, active buffer and wait. S2 captures active snapshot, +render entries, ancestor clip, H1/H2 pixels, damage and commits/presents. +S3 samples allowed, forbidden and ordinary H2 hit points, separating node-at +from client delivery. S4 releases and observes ACK, commit, apply, saved-tree +destruction, restored content, pixels and damage. S5 holds a second configure +past the real timeout with the client alive and checks cleanup. S6 may use +one camera hide/return pair only if S1–S4 show actual saved rendering and +source/runtime prove overlap with the active snapshot. A queued camera +request alone proves nothing. Mapped-view destruction is audited; unsafe +runtime destruction remains NOT TESTED. + +Passive private traces may record tree/buffer creation and destruction, +source buffer identity, parent chain, clip, enabled state, render entry, +damage, transaction wait/ready/timeout/apply, node-at and output commit/present. +Use CLOCK_MONOTONIC and ephemeral identities scoped to lifetime. Logging must +not add a buffer lock or change clipping, render, scheduler or ABI behavior. +Compare whole PNGs and measured damage in their actual coordinates. A pair of +captures cannot qualify every intermediate frame, and grim does not identify +the native presented buffer. + +## Budget and classification + +The single persistent locked ledger reserves before execution: at most three +main runs, one per mode; two targeted transaction episodes per mode (ACK and +natural timeout); one Mosaic camera hide/return pair; 20 captures per run, +60 overall; 150 seconds per run unless explicitly justified before launch. +Failed attempts consume reservations. No reset, refund, clone bypass or extra +run to repair placement. Historical probe ledgers remain unchanged. + +FEASIBLE requires an actual rendered saved buffer crossing x=0 under Mosaic, +correct H1/H2 pixels and hits, ACK and timeout removal, no blocking standard +regression and supporting measured damage. PARTIALLY_FEASIBLE means a real +saved tree works but lifetime, input or pixels remain unqualified. INCONCLUSIVE +means the snapshot, boundary or live/saved distinction cannot be exercised. +BLOCKED requires a reproducible forbidden pixel/hit, reference defect, crash +or blocking standard regression. Stop the affected scenario for a protocol +error, uncalibrated geometry or proven leak. Do not patch algorithms to pass. + +All builds, sockets, logs, PNGs and results stay outside Git under one private +LAB. Every headless run uses a private 0700 runtime, WAYLAND_DISPLAY and +explicit SWAYSOCK. The source fixture is under +[tooling/probes/step20c-saved-transaction-buffer](../../tooling/probes/step20c-saved-transaction-buffer/README.md). +Saved buffers do not qualify optimized blur, all effects/animations, all +transforms/scales/backends, complete intermediate frames, SceneFX ABI/package +or production STEP20C. + +## Measured campaign + +**INCONCLUSIVE / measured 2026-10-12.** One headless run was reserved per +mode. The pristine standard control completed held-configure/ACK and natural +timeout episodes with `-Dtxn-timeout=1200`. Four nonempty saved buffers were +created per target episode, with source/saved buffer pointers equal and +`content_enabled=0 saved_enabled=1`; all four trees were destroyed about +56 ms after ACK in episode one and about 1200 ms after creation in episode +two. The pristine SceneFX installation did not carry the private render-entry +trace, so its exact saved-node render contribution is unqualified. H2 stayed +identical through both held captures; the final timeout H2 capture changed +253 pixels in box `[4,35,15,58)`, not yet causally attributed; the Mosaic +timeout capture has the same count and box. + +The private-standard ACK episode created four saved buffers and four of their +nodes reached `SB_RENDER`, but the fixture injected six slow pointer actions +during the 1200 ms hold. New XDG serials superseded the first retained serial; +the client correctly ACKed the latest serial after the compositor's natural +timeout. The runner then failed waiting for a post-ACK destruction that had +already occurred. Its single run and ACK reservation remain consumed; its +timeout episode is NOT EXERCISED. This is a fixture timing incident, not a +demonstrated compositor regression. + +The private Mosaic run completed both episodes. Each created four saved +buffers; two saved nodes reached the private render-entry trace before each +held capture, and all four saved trees were destroyed after ACK or natural +timeout. The `layout mosaic scroll 400` command was relative to the existing +camera: applied IPC/scene state settled at scroll **1760**, with H1's tiled +scene at `-1760` and clip `(1760,0,800,600)`. The target owner's IPC rect +was `[-160,480)` and its camera-adjusted box was `[-1920,-1280)`, wholly +outside H1. Its deterministic SHM colors had zero pixels on both H1 and H2 +through the Mosaic captures. Consequently, the target snapshot did not +exercise `saved_buffer_left < 0 < saved_buffer_right`, and the central partial +clipping test is **NOT EXERCISED**. Other visible saved nodes rendered; the +trace does not identify them as the target. H2 was identical to reference +through the held/ACK captures, then changed 253 pixels in the final timeout +capture, in the same `[4,35,15,58)` box as the pristine control. This is +unqualified without a full causal frame trace. No forbidden target +pixel or crash was demonstrated. Input, node-at, damage coverage, camera hide +while a target snapshot is active and mapped-view destruction remain NOT TESTED. + +The campaign budget ended at 3/3 runs and 5/6 targeted episodes: control +ACK+timeout, private standard ACK attempt, Mosaic ACK+timeout. Captures were +10/20, 4/20 and 10/20 respectively, 24/60 total. No camera hide/return pair +was used. Failed reservations were not refunded. The private LAB is +`/tmp/swayfx-mosaic-nested-grab-KYJ2pj1o`; `results/` contains raw-derived +JSON and source/evidence manifests. No additional run is authorized by this +campaign. Verdict: +`SWAYFX_MOSAIC_SAVED_TRANSACTION_BUFFER_PROBE=INCONCLUSIVE` and architectural +conclusion `UNDECIDED`. The next saved-buffer gate needs a new explicitly +authorized campaign with a pre-run applied camera/owner crossing calibration; +this campaign gives no production STEP20C authorization. + +The final source manifest contains **906 entries**, SHA-256 +`e11a0800b84f6a7b12a383faa87d9d04bf8d53232fb83e6d12574b8569cc384c`. +The evidence manifest contains **90 entries**, SHA-256 +`5c00b78b19fcffacb3922bdb600300188972e85b83fe8356aebbc65280dc7e30`. +Both passed entry-by-entry verification. The passive Sway trace patch is +`106544aade58e05e7245601ee380e763ac19521f7d51e1ada410b4e1e7a19997`; +the SceneFX trace patch is +`82ebdd4fb003be1729ecef443a92190b010f65b62ed858c546c019743998037e`. +These hashes identify the local source fixture; no phase-B commit or push +was made. + +## Executed runner provenance and camera correction + +The exact runner used for each run is retained in its `runs/*/fixture-source.py`, +covered by the evidence manifest. Their SHA-256 values are: pristine +`bc041eb7be89da9e198bb9ca2e25cfe86467c264e3687e6d9d3f3836e82ce40c`, +private standard `67aa3fb94901739bea0b1d0e0cf881081d2388a02256dddc45282d7a4bc86b25`, +and private Mosaic `cdece69cf956e550779bd992b0d38234629ca1e611dfb3fb0348c04841c47f59`. +The checked-in `run.py` matches only the final private Mosaic copy. The +pristine copy had no pointer injector. The private-standard copy added the +pointer daemon and six move/right actions during the retained ACK episode. +The Mosaic copy retained the daemon but removed those six actions after they +consumed the 1200 ms window in the private-standard run. The last standard +ACK used a real serial, but followed the natural timeout; the fixture then +waited for an already completed saved-tree destruction. This is a fixture +failure, not a demonstrated SwayFX regression. The corresponding raw run +source and timeline must be used for historical reconstruction. + +`sway/commands/layout.c` computes `next = row->state.scroll_x + delta`. +Consequently `layout mosaic scroll 400` added 400 to the existing camera; +the observed applied `scroll_x` was 1760. The target IPC owner box +`[-160,480)` would translate to `[-1920,-1280)`, wholly outside the viewport. +This owner estimate is not a measurement of the source buffer's scene box. +A new campaign must measure that buffer, calculate one signed correction from +the applied camera, and reject the run before any transaction reservation if +the buffer itself does not cross `x=0`. + +The final timeout H2 images of pristine and Mosaic both differ from their +references by 253 pixels in `[4,35,15,58)`. The common extent does not +identify the cause. A cursor or reference artifact remains a hypothesis; +the captures were not altered and no Mosaic attribution follows. + +The historic ledger is exhausted at 3/3 runs, 5/6 reserved episodes, +24/60 captures and 0/1 camera pairs. The unused episode cannot replay an +old run. Saved-tree removal in completed episodes establishes node cleanup, +not exhaustive GPU texture/reference release. Transactional input, damage, +mapped-view destruction and distinct effects/caches remain NOT TESTED. +The private Mosaic trace retained 57 output commits and 55 headless presents +over the whole run; private standard retained 100/98. These are run totals, +including startup, capture and teardown activity, and do not identify the +exact native buffer content of a particular screenshot. The pristine +control lacked the private commit/present trace. diff --git a/tooling/probes/step20c-saved-transaction-buffer/README.md b/tooling/probes/step20c-saved-transaction-buffer/README.md new file mode 100644 index 0000000..cbd0286 --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/README.md @@ -0,0 +1,37 @@ +# Saved transaction buffer source fixture + +**EXPERIMENTAL / INCONCLUSIVE.** The protocol and source audit are in +[the report](../../../docs/design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md). +This directory preserves only reviewed source. It does not add a production +dependency or authorize a SceneFX merge. Generated builds, sockets, captures, +large logs and results belong under a private LAB outside Git. + +`prepare.sh` reconstructs pinned disposable sources and compiles the C17 +controlled XDG client. `run.py LAB MODE` reserves a locked persistent budget +before a headless run; modes are `control`, `private_standard` and +`private_mosaic`, once each. `analyze.py LAB` derives a bounded matrix from +the raw event/PNG files and writes manifests. `test-budget.py` exercises +the ledger without launching a compositor. No command may use the daily IPC +socket; runner IPC always passes `swaymsg -s` with the private socket. + +For the completed campaign, use each run's manifest-covered +`runs/*/fixture-source.py` to reconstruct the exact executed code. The current +`run.py` matches the final private Mosaic copy (SHA-256 `cdece69cf956e550779bd992b0d38234629ca1e611dfb3fb0348c04841c47f59`). +The pristine copy (`bc041eb7be89da9e198bb9ca2e25cfe86467c264e3687e6d9d3f3836e82ce40c`) +had no pointer injector. The private-standard copy +(`67aa3fb94901739bea0b1d0e0cf881081d2388a02256dddc45282d7a4bc86b25`) +added six retained-window pointer actions; they delayed its valid ACK past +timeout. The final copy removed those actions. Do not rerun this exhausted +campaign or treat the current source as identical to all historical runs. + +The default transaction timeout is 200 ms; the fixture requests +`-Dtxn-timeout=1200` for each comparable mode. A source trace, configure +hold and a screenshot alone do not establish saved-buffer rendering: the +saved node must be nonempty, enabled, in the render list, and geometrically +cross the tested boundary. Missing observations are NOT EXERCISED or +INCONCLUSIVE. Historical ledgers are separate and never reset. The completed +LAB is `/tmp/swayfx-mosaic-nested-grab-KYJ2pj1o`; its ledger has one run +per mode. **Do not rerun a mode or reset this ledger.** The private-standard +ACK episode timed out during slow fixture input, and the Mosaic target camera +settled at 1760, leaving its snapshot outside H1. The partial-overflow gate +is NOT EXERCISED; see the report for exact limits. diff --git a/tooling/probes/step20c-saved-transaction-buffer/analyze.py b/tooling/probes/step20c-saved-transaction-buffer/analyze.py new file mode 100644 index 0000000..900413a --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/analyze.py @@ -0,0 +1,188 @@ +"""Offline analysis; preserves raw files and never launches a compositor.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +from PIL import Image, ImageChops + +parser = argparse.ArgumentParser() +parser.add_argument('lab', type=Path) +args = parser.parse_args() +lab = args.lab.resolve() +repo = Path(__file__).resolve().parents[3] +results = lab / 'results' +results.mkdir(exist_ok=True) + + +def write(name, value): + (results / name).write_text(json.dumps(value, indent=2, sort_keys=True) + '\n') + + +def fields(line): + return dict(re.findall(r'(\w+)=([^\s]+)', line)) + + +def ns(line): + match = re.search(r'\bns=(\d+)', line) + return int(match.group(1)) if match else None + + +def count_changed(left, right): + a = Image.open(left).convert('RGB') + b = Image.open(right).convert('RGB') + diff = ImageChops.difference(a, b) + box = diff.getbbox() + n = sum(any(px) for px in diff.get_flattened_data()) + return {'pixels': n, 'box': box, 'dimensions': a.size} + + +def color_count(path, color): + im = Image.open(path).convert('RGB') + return sum(px == color for px in im.get_flattened_data()) + + +matrix = {} +lifecycle = {} +timeline = {} +identities = {} +buffers = {} +parentage = {} +clips = {} +renders = {} +hits = {} +delivery = {} +commits = {} +presents = {} +damage = {} +pixels = {} +timeouts = {} +for mode in ('control', 'private_standard', 'private_mosaic'): + paths = sorted((lab / 'runs').glob(f'saved-{mode}-*')) + if len(paths) != 1: + raise RuntimeError(f'{mode}: expected exactly one run, found {len(paths)}') + run = paths[0] + events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()] + lines = (run / 'compositor.log').read_text().splitlines() + client = (run / 'client.log').read_text().splitlines() + exit_state = json.loads((run / 'exit-status.json').read_text()) + identity = next((x for x in events if x['kind'] == 'identity'), None) + identities[mode] = {'owner': identity['owner'] if identity else None, + 'workspace': identity['workspace'] if identity else None} + tree_events = [{'event': line.split()[0], **fields(line)} for line in lines + if line.startswith(('SB_TREE_CREATE', 'SB_TREE_DESTROY', 'SB_ENABLE'))] + buffer_events = [fields(line) for line in lines if line.startswith('SB_BUFFER_CREATE')] + render_events = [fields(line) for line in lines if line.startswith('SB_RENDER')] + lifecycle[mode] = tree_events + buffers[mode] = buffer_events + parentage[mode] = [{'tree': x.get('tree'), 'parent': x.get('parent'), + 'content': x.get('content'), 'view': x.get('view'), + 'container': x.get('con'), 'ns': x.get('ns')} + for x in tree_events if x['event'] == 'SB_TREE_CREATE'] + clips[mode] = [line for line in lines if 'PI_SCENE output=HEADLESS-1' in line] + renders[mode] = render_events + hits[mode] = [x for x in events if x['kind'] in ('pointer', 'hit_sample')] + delivery[mode] = [line for line in client if line.startswith(('ENTER', 'LEAVE', 'MOTION', 'BUTTON'))] + commits[mode] = [line for line in lines if 'PI_OUTPUT_COMMIT ' in line] + presents[mode] = [line for line in lines if 'PI_OUTPUT_PRESENT ' in line] + damage[mode] = {'status': 'NOT TESTED', 'reason': 'no transaction-scoped physical damage extents captured'} + timeouts[mode] = [line for line in lines if 'timed out' in line] + timeline[mode] = { + 'client': [line for line in client if line.startswith(('XDG_CONFIGURE', 'HELD', 'ACK', 'COMMIT', 'PONG', 'EXIT'))], + 'episodes': [x for x in events if x['kind'] in ('episode_start', 'held', 'timeout_observed', 'failure')], + 'transactions': [line for line in lines if 'Transaction ' in line and + any(word in line for word in ('committing', 'ready', 'timed out'))], + } + px = {} + for side in ('left', 'right'): + reference = run / f'{side}-reference.png' + px[side] = {} + for tag in ('ack-held', 'ack-applied', 'timeout-held', 'timeout-applied'): + path = run / f'{side}-{tag}.png' + if path.exists(): + px[side][tag] = { + 'full_reference_diff': count_changed(reference, path), + 'initial_client_color_pixels': color_count(path, (35, 68, 204)), + 'alternate_client_color_pixels': color_count(path, (32, 208, 160)), + } + pixels[mode] = px + episode_data = {} + for kind in ('ack', 'timeout'): + start = next((x['ns'] for x in events if x['kind'] == 'episode_start' and x['episode'] == kind), None) + capture = next((x['after_ns'] for x in events if x['kind'] == 'capture' + and x['tag'] == f'{kind}-held' and x['output'] == 'HEADLESS-2'), None) + created = [x for x in buffer_events if start and capture and + start < int(x['ns']) < capture] + nodes = {x['node'] for x in created} + rendered = [x for x in render_events if start and capture and + start < int(x['ns']) < capture and x.get('node') in nodes] + episode_data[kind] = {'start_ns': start, 'held_capture_end_ns': capture, + 'saved_buffers_created': len(created), + 'created_nodes_rendered': len({x['node'] for x in rendered}), + 'render_entries': len(rendered), + 'created_nodes': sorted(nodes), + 'rendered_nodes': sorted({x['node'] for x in rendered})} + owner = identities[mode]['owner'] or {} + workspace = identities[mode]['workspace'] or {} + scroll = workspace.get('mosaic', {}).get('rows', [{}])[0].get('scroll_x', 0) + # IPC rect is the logical owner; applied tile_scene in PI_SCENE is the + # private camera translation. Actual render entries remain the authority. + predicted_left = owner.get('rect', {}).get('x', 0) - scroll + predicted_right = predicted_left + owner.get('rect', {}).get('width', 0) + boundary = predicted_left < 0 < predicted_right + matrix[mode] = {'exit': exit_state, 'episodes': episode_data, + 'owner_id': owner.get('id'), 'workspace_id': workspace.get('id'), + 'layout': workspace.get('layout'), 'observed_scroll': scroll, + 'owner_camera_box_estimate': [predicted_left, predicted_right], + 'target_boundary_estimate': boundary, + 'target_boundary_gate': 'NOT EXERCISED' if mode == 'private_mosaic' and not boundary else 'CONTROL_OR_CANDIDATE', + 'input_during_snapshot': 'NOT TESTED' if not hits[mode] else 'PARTIAL', + 'damage_coverage': 'NOT TESTED'} + +budget = json.loads((lab / 'saved-budget.json').read_text()) +write('saved-buffer-lifecycle.json', lifecycle) +write('transaction-timeline.json', timeline) +write('view-identities.json', identities) +write('buffer-identities.json', buffers) +write('scene-parentage.json', parentage) +write('clip-observations.json', clips) +write('render-entries.json', renders) +write('pointer-hits.json', hits) +write('client-delivery.json', delivery) +write('output-commits.json', commits) +write('presentations.json', presents) +write('damage-analysis.json', damage) +write('pixel-analysis.json', pixels) +write('timeout-analysis.json', timeouts) +write('budget.json', budget) +write('matrix.json', matrix) +summary = {'result': 'INCONCLUSIVE', 'architectural_conclusion': 'UNDECIDED', + 'reason': 'Mosaic target snapshot did not cross x=0; target was outside H1 after relative scroll applied to existing camera', + 'standard_fixture_incident': 'pointer sampling overran the 1200 ms ACK window; its reserved ACK episode failed and no second standard run was made', + 'runs': {mode: matrix[mode]['exit']['failure'] for mode in matrix}, + 'budget': budget, + 'unexercised': ['target partial-boundary clipping', 'saved-target pixels on H1', + 'node-at/input during Mosaic target snapshot', 'camera hide while target snapshot active', + 'mapped-view destruction', 'transaction-scoped damage coverage', + 'native output buffer identity', 'all intermediate frames']} +write('summary.json', summary) + + +def manifest(paths, destination): + with destination.open('w') as out: + for path in sorted(set(paths)): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + out.write(f'{digest} {path}\n') + + +source_files = [p for root in ('standard', 'mosaic', 'scenefx') + for p in (lab / root).rglob('*') if p.is_file() and '.git' not in p.parts] +source_files += [p for p in (repo / 'tooling/probes/step20c-saved-transaction-buffer').iterdir() + if p.is_file()] +manifest(source_files, results / 'source-sha256.txt') +evidence_files = [p for p in (lab / 'runs').rglob('*') if p.is_file()] +evidence_files += [p for p in results.iterdir() if p.is_file() and p.name not in + ('source-sha256.txt', 'evidence-sha256.txt')] +evidence_files += [lab / 'saved-budget.json', lab / 'saved-build-log.txt'] +manifest(evidence_files, results / 'evidence-sha256.txt') +print(f'RESULT=INCONCLUSIVE RESULTS={results}') diff --git a/tooling/probes/step20c-saved-transaction-buffer/budget.py b/tooling/probes/step20c-saved-transaction-buffer/budget.py new file mode 100644 index 0000000..0bb44dd --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/budget.py @@ -0,0 +1,35 @@ +"""Persistent campaign reservations. Failed attempts are never refunded.""" +import fcntl +import json +import os +from pathlib import Path + +MODES = ('control', 'private_standard', 'private_mosaic') + + +def reserve(lab: Path, mode: str, episode: str | None = None) -> dict: + if mode not in MODES or episode not in (None, 'ack', 'timeout'): + raise ValueError('invalid reservation') + path = lab / 'saved-budget.json' + with path.open('a+') as file: + fcntl.flock(file, fcntl.LOCK_EX) + file.seek(0) + raw = file.read() + state = json.loads(raw) if raw else {'runs': [], 'episodes': []} + file.seek(0) + if episode is None: + if mode in state['runs'] or len(state['runs']) >= 3: + raise RuntimeError('run budget exhausted') + state['runs'].append(mode) + else: + key = [mode, episode] + if mode not in state['runs'] or key in state['episodes'] or len(state['episodes']) >= 6: + raise RuntimeError('episode budget exhausted') + state['episodes'].append(key) + file.seek(0) + file.truncate() + json.dump(state, file, sort_keys=True) + file.write('\n') + file.flush() + os.fsync(file.fileno()) + return state diff --git a/tooling/probes/step20c-saved-transaction-buffer/prepare.sh b/tooling/probes/step20c-saved-transaction-buffer/prepare.sh new file mode 100644 index 0000000..4ef0ca6 --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/prepare.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# EXPERIMENTAL source-only reconstruction. Never launches a compositor. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-saved-transaction-buffer" +log=$(mktemp /tmp/saved-buffer-prepare-XXXXXXXX) +if ! sh "$repo/tooling/probes/step20c-rotated-partial-popup/prepare.sh" > "$log" 2>&1; then + printf 'BUILD_FAILED log=%s\n' "$log" >&2 + tail -40 "$log" >&2 + exit 1 +fi +lab=$(sed -n 's/^LAB=//p' "$log" | head -1) +test -n "$lab" && test -d "$lab" +for source in standard mosaic; do + git -C "$lab/$source" apply --check "$fixture/sway-saved-trace.patch" + git -C "$lab/$source" apply "$fixture/sway-saved-trace.patch" +done +git -C "$lab/scenefx" apply --check "$fixture/scenefx-saved-trace.patch" +git -C "$lab/scenefx" apply "$fixture/scenefx-saved-trace.patch" +meson compile -C "$lab/scenefx-build" -j 8 >> "$log" 2>&1 +meson compile -C "$lab/standard-build" -j 8 >> "$log" 2>&1 +meson compile -C "$lab/mosaic-build" -j 8 >> "$log" 2>&1 +cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \ + "$fixture/saved-client.c" "$lab/xdg-shell-protocol.c" -o "$lab/saved-client" \ + $(pkg-config --cflags --libs wayland-client) >> "$log" 2>&1 +cp "$log" "$lab/saved-build-log.txt" +printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log" diff --git a/tooling/probes/step20c-saved-transaction-buffer/run.py b/tooling/probes/step20c-saved-transaction-buffer/run.py new file mode 100644 index 0000000..06a45ab --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/run.py @@ -0,0 +1,258 @@ +"""One reserved headless run with a real held XDG configure per episode.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import tempfile +import time +from PIL import Image +from budget import reserve + +parser = argparse.ArgumentParser() +parser.add_argument('lab', type=Path) +parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic')) +args = parser.parse_args() +lab = args.lab.resolve() +if not (lab / 'saved-client').is_file(): + parser.error('prepared LAB required') +state = reserve(lab, args.mode) +run = lab / 'runs' / f'saved-{args.mode}-{time.time_ns()}' +run.mkdir(parents=True, mode=0o700) +(run / 'fixture-source.py').write_text(Path(__file__).read_text()) +runtime = Path(tempfile.mkdtemp(prefix='mst-', dir='/tmp')) +(run / 'runtime-path').write_text(str(runtime) + '\n') +(run / 'config').write_text( + 'output HEADLESS-1 resolution 800x600 scale 1 transform normal position -800 0\n' + 'output HEADLESS-2 resolution 800x600 scale 1 transform normal position 0 0\n' + 'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n' + 'focus_follows_mouse no\n') +env = os.environ.copy() +for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'): + env.pop(key, None) +env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless', + WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman', SAVED_TRACE='1') +binary = lab / ('standard-build/sway/sway' if args.mode == 'control' + else 'mosaic-build/sway/swayfx-mosaic') +if args.mode != 'control': + env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build') +started = time.monotonic() +captures = 0 +events = [] +processes = [] +logs = [] +failure = None +compositor = None + + +def record(kind, **kw): + event = {'ns': time.monotonic_ns(), 'kind': kind, **kw} + events.append(event) + with (run / 'events.jsonl').open('a') as file: + file.write(json.dumps(event) + '\n') + + +def spawn(argv, name, *, stdin=False, use_env=None): + file = (run / name).open('w') + logs.append(file) + proc = subprocess.Popen(argv, env=use_env or env, stdout=file, + stderr=subprocess.STDOUT, start_new_session=True, + stdin=subprocess.PIPE if stdin else None, text=stdin) + processes.append(proc) + return proc + + +def wait(predicate, seconds=8): + limit = time.monotonic() + seconds + while time.monotonic() < limit: + if time.monotonic() - started > 150: + raise RuntimeError('150 second run bound exceeded') + value = predicate() + if value: + return value + if compositor and compositor.poll() is not None: + raise RuntimeError(f'compositor exit {compositor.returncode}') + time.sleep(.025) + raise RuntimeError('wait timed out') + + +def ipc(*argv): + result = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env, + capture_output=True, text=True, timeout=5, check=True) + value = json.loads(result.stdout) + record('ipc', argv=list(argv), result=value) + return value + + +def command(value): + result = ipc(value) + if not result or not all(x.get('success') for x in result): + raise RuntimeError(f'IPC 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', []): + match = find(child, predicate) + if match: + return match + return None + + +def client_log(): + return (run / 'client.log').read_text() + + +def compositor_log(): + return (run / 'compositor.log').read_text() + + +def client_command(value): + marker = f'COMMAND {value} DONE' + before = client_log().count(marker) + client.stdin.write(value + '\n') + client.stdin.flush() + wait(lambda: client_log().count(marker) > before) + record('client_command', value=value) + + +def inject(action, x, y): + if not -800 <= x < 800 or not 0 <= y < 600: + raise ValueError('pointer point outside applied outputs') + marker = f'INJECT_DONE {action} {x + 800},{y}' + before = (run / 'pointer.log').read_text().count(marker) + pointer.stdin.write(f'{action} {x + 800} {y} 1600 600\n') + pointer.stdin.flush() + wait(lambda: (run / 'pointer.log').read_text().count(marker) > before) + record('pointer', action=action, global_point=[x, y], + client_tail=client_log().splitlines()[-5:]) + + +def snapshot(tag): + global captures + outputs = ipc('-t', 'get_outputs') + actual = {o['name']: (o['rect']['x'], o['rect']['y'], + o['rect']['width'], o['rect']['height']) for o in outputs if o['active']} + if actual != {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)}: + raise RuntimeError(f'output geometry invalid: {actual}') + for name, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): + if captures >= 20: + raise RuntimeError('capture budget exceeded') + path = run / f'{side}-{tag}.png' + before = time.monotonic_ns() + subprocess.run(['grim', '-s', '1', '-o', name, str(path)], env=env, + check=True, timeout=5, stdout=subprocess.DEVNULL) + after = time.monotonic_ns() + captures += 1 + size = Image.open(path).size + if size != (800, 600): + raise RuntimeError(f'PNG dimensions invalid: {size}') + record('capture', tag=tag, output=name, path=path.name, + before_ns=before, after_ns=after, size=size) + (run / f'tree-{tag}.json').write_text(json.dumps(ipc('-t', 'get_tree'), indent=2) + '\n') + + +def episode(kind, gaps): + reserve(lab, args.mode, kind) + record('episode_start', episode=kind, gaps=gaps) + client_command('hold_next_configure') + old_held = client_log().count('HELD ns=') + old_tree = compositor_log().count('SB_TREE_CREATE') + command(f'gaps inner current set {gaps}') + wait(lambda: client_log().count('HELD ns=') > old_held, 3) + wait(lambda: compositor_log().count('SB_TREE_CREATE') > old_tree, 3) + record('held', client=client_log().splitlines()[-12:], + saved=compositor_log().splitlines()[-30:]) + snapshot(f'{kind}-held') + # The private-standard ACK episode exhausted its 1200 ms window during + # six pointer commands. Preserve that incident and prioritize the actual + # Mosaic snapshot lifetime; no unmeasured input claim follows. + if kind == 'ack': + old_destroy = compositor_log().count('SB_TREE_DESTROY') + client_command('release_configure') + wait(lambda: compositor_log().count('SB_TREE_DESTROY') > old_destroy, 3) + snapshot('ack-applied') + else: + wait(lambda: 'timed out' in compositor_log().split('SB_TREE_CREATE')[-1], 3) + record('timeout_observed') + snapshot('timeout-applied') + + +try: + file = (run / 'compositor.log').open('w') + logs.append(file) + compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), + '-d', '-Dtxn-timeout=1200'], env=env, + stdout=file, 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) + if args.mode != 'control': + 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', '/usr/bin/sleep', '900'], 'A.log') + wait(lambda: len(find(ipc('-t', 'get_tree'), + lambda n: n.get('type') == 'workspace' and n.get('name') == '1')['nodes']) >= 1) + client = spawn([str(lab / 'saved-client')], 'client.log', stdin=True) + wait(lambda: 'COMMIT ns=' in client_log()) + for name in ('C', 'D'): + spawn(['kitty', '--class', name, '/usr/bin/sleep', '900'], f'{name}.log') + time.sleep(.2) + command('workspace 2') + spawn(['kitty', '--class', 'REFERENCE', '-o', 'background=#303038', + '/usr/bin/sleep', '900'], 'reference.log') + wait(lambda: bool(find(ipc('-t', 'get_tree'), + lambda n: n.get('type') == 'workspace' and n.get('name') == '2')['nodes'])) + command('workspace 1') + if args.mode == 'private_mosaic': + command('layout mosaic scroll 400') + time.sleep(.2) + tree = ipc('-t', 'get_tree') + owner = find(tree, lambda n: n.get('app_id') == 'org.mosaic.SavedBufferProbe') + workspace = find(tree, lambda n: n.get('type') == 'workspace' and n.get('name') == '1') + if not owner or not workspace: + raise RuntimeError('owner/workspace calibration failed') + record('identity', owner=owner, workspace=workspace) + snapshot('reference') + episode('ack', 30) + episode('timeout', 50) +except Exception as exc: + failure = repr(exc) + record('failure', message=failure) +finally: + for proc in processes + ([compositor] if compositor else []): + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + for proc in processes + ([compositor] if compositor else []): + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait(timeout=5) + for file in logs: + file.close() + (run / 'exit-status.json').write_text(json.dumps({ + 'failure': failure, 'captures': captures, + 'duration_seconds': time.monotonic() - started, + 'compositor': compositor.returncode if compositor else None, + 'children': [{'args': p.args, 'code': p.returncode} for p in processes]}, + indent=2) + '\n') + shutil.rmtree(runtime) + record('cleanup', runtime_removed=not runtime.exists()) +print(f'RUN={run} FAILURE={failure} CAPTURES={captures}') +if failure: + raise SystemExit(1) diff --git a/tooling/probes/step20c-saved-transaction-buffer/saved-client.c b/tooling/probes/step20c-saved-transaction-buffer/saved-client.c new file mode 100644 index 0000000..803c4af --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/saved-client.c @@ -0,0 +1,215 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "xdg-shell-client.h" + +/* WHY: the compositor must wait on a real, responsive XDG client. + * CONTRACT: hold only a received configure; ACK the latest received serial. + * INVARIANT: Wayland dispatch and ping replies continue while ACK is held. */ +static struct wl_display *display; +static struct wl_compositor *compositor; +static struct wl_shm *shm; +static struct xdg_wm_base *wm; +static struct wl_surface *surface; +static struct xdg_surface *xdg; +static struct xdg_toplevel *toplevel; +static struct wl_seat *seat; +static struct wl_pointer *pointer; +static volatile sig_atomic_t running = 1; +static bool mapped, hold_next, held; +static uint32_t latest_serial; +static int32_t width = 420, height = 320, configured_width = 420, configured_height = 320; +static unsigned generation; + +static long long stamp(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec; +} + +static void draw(void) { + if (configured_width > 0) width = configured_width; + if (configured_height > 0) height = configured_height; + if (width <= 0 || height <= 0 || width > 4096 || height > 4096) { + fprintf(stderr, "INVALID_SIZE %d %d\n", width, height); + exit(2); + } + size_t bytes = (size_t)width * (size_t)height * 4; + char name[] = "/tmp/mosaic-saved-shm-XXXXXX"; + int fd = mkstemp(name); + if (fd < 0 || ftruncate(fd, (off_t)bytes) < 0) { perror("shm"); exit(2); } + unlink(name); + uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (pixels == MAP_FAILED) { perror("mmap"); exit(2); } + uint32_t color = (generation++ % 2) ? 0x0020d0a0 : 0x002344cc; + for (size_t i = 0; i < bytes / 4; ++i) pixels[i] = color; + struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, (int)bytes); + struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, width, height, + width * 4, WL_SHM_FORMAT_XRGB8888); + wl_surface_attach(surface, buffer, 0, 0); + wl_surface_damage_buffer(surface, 0, 0, width, height); + wl_surface_commit(surface); + fprintf(stderr, "COMMIT ns=%lld serial=%u size=%dx%d color=%08x\n", + stamp(), latest_serial, width, height, color); + wl_buffer_destroy(buffer); + wl_shm_pool_destroy(pool); + munmap(pixels, bytes); + close(fd); + mapped = true; +} + +static void ack_and_commit(void) { + if (!latest_serial) return; + xdg_surface_ack_configure(xdg, latest_serial); + fprintf(stderr, "ACK ns=%lld serial=%u size=%dx%d\n", + stamp(), latest_serial, configured_width, configured_height); + draw(); + held = false; +} + +static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) { + xdg_wm_base_pong(base, serial); + fprintf(stderr, "PONG ns=%lld serial=%u\n", stamp(), serial); +} +static const struct xdg_wm_base_listener wm_listener = {.ping = ping}; + +static void role_configure(void *data, struct xdg_toplevel *role, + int32_t w, int32_t h, struct wl_array *states) { + if (w > 0) configured_width = w; + if (h > 0) configured_height = h; + fprintf(stderr, "ROLE_CONFIGURE ns=%lld size=%dx%d\n", stamp(), w, h); +} +static void role_close(void *data, struct xdg_toplevel *role) { running = 0; } +static const struct xdg_toplevel_listener role_listener = { + .configure = role_configure, .close = role_close, +}; +static void configure(void *data, struct xdg_surface *s, uint32_t serial) { + latest_serial = serial; + fprintf(stderr, "XDG_CONFIGURE ns=%lld serial=%u size=%dx%d hold=%d previous=%d\n", + stamp(), serial, configured_width, configured_height, hold_next || held, held); + if (hold_next || held) { + held = true; + hold_next = false; + fprintf(stderr, "HELD ns=%lld serial=%u\n", stamp(), serial); + } else { + ack_and_commit(); + } +} +static const struct xdg_surface_listener surface_listener = {.configure = configure}; +static void pointer_enter(void *data, struct wl_pointer *p, uint32_t serial, + struct wl_surface *s, wl_fixed_t x, wl_fixed_t y) { + fprintf(stderr, "ENTER ns=%lld x=%.2f y=%.2f\n", stamp(), + wl_fixed_to_double(x), wl_fixed_to_double(y)); +} +static void pointer_leave(void *data, struct wl_pointer *p, uint32_t serial, + struct wl_surface *s) { fprintf(stderr, "LEAVE ns=%lld\n", stamp()); } +static void pointer_motion(void *data, struct wl_pointer *p, uint32_t time, + wl_fixed_t x, wl_fixed_t y) { + fprintf(stderr, "MOTION ns=%lld x=%.2f y=%.2f\n", stamp(), + wl_fixed_to_double(x), wl_fixed_to_double(y)); +} +static void pointer_button(void *data, struct wl_pointer *p, uint32_t serial, + uint32_t time, uint32_t button, uint32_t state) { + fprintf(stderr, "BUTTON ns=%lld serial=%u button=%u state=%u\n", + stamp(), serial, button, state); +} +static void pointer_axis(void *data, struct wl_pointer *p, uint32_t t, + uint32_t axis, wl_fixed_t value) {} +static void pointer_frame(void *data, struct wl_pointer *p) {} +static const struct wl_pointer_listener pointer_listener = { + .enter = pointer_enter, .leave = pointer_leave, .motion = pointer_motion, + .button = pointer_button, .axis = pointer_axis, .frame = pointer_frame, +}; +static void capabilities(void *data, struct wl_seat *s, uint32_t caps) { + if ((caps & WL_SEAT_CAPABILITY_POINTER) && !pointer) { + pointer = wl_seat_get_pointer(s); + wl_pointer_add_listener(pointer, &pointer_listener, NULL); + } +} +static void seat_name(void *data, struct wl_seat *s, const char *name) {} +static const struct wl_seat_listener seat_listener = { + .capabilities = capabilities, .name = seat_name, +}; +static void global(void *data, struct wl_registry *registry, uint32_t name, + const char *interface, uint32_t version) { + if (!strcmp(interface, wl_compositor_interface.name)) + compositor = wl_registry_bind(registry, name, &wl_compositor_interface, version < 4 ? version : 4); + else if (!strcmp(interface, wl_shm_interface.name)) + shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); + else if (!strcmp(interface, xdg_wm_base_interface.name)) + wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, version < 3 ? version : 3); + else if (!strcmp(interface, wl_seat_interface.name)) { + seat = wl_registry_bind(registry, name, &wl_seat_interface, version < 5 ? version : 5); + wl_seat_add_listener(seat, &seat_listener, NULL); + } +} +static void global_remove(void *data, struct wl_registry *registry, uint32_t name) {} +static const struct wl_registry_listener registry_listener = { + .global = global, .global_remove = global_remove, +}; +static void stop(int signum) { running = 0; } + +int main(void) { + signal(SIGTERM, stop); + display = wl_display_connect(NULL); + if (!display) { fprintf(stderr, "CONNECT_FAILED\n"); return 1; } + struct wl_registry *registry = wl_display_get_registry(display); + wl_registry_add_listener(registry, ®istry_listener, NULL); + wl_display_roundtrip(display); + wl_display_roundtrip(display); + if (!compositor || !shm || !wm) { fprintf(stderr, "MISSING_GLOBAL\n"); return 2; } + xdg_wm_base_add_listener(wm, &wm_listener, NULL); + surface = wl_compositor_create_surface(compositor); + xdg = xdg_wm_base_get_xdg_surface(wm, surface); + xdg_surface_add_listener(xdg, &surface_listener, NULL); + toplevel = xdg_surface_get_toplevel(xdg); + xdg_toplevel_add_listener(toplevel, &role_listener, NULL); + xdg_toplevel_set_title(toplevel, "Mosaic Saved Buffer Probe"); + xdg_toplevel_set_app_id(toplevel, "org.mosaic.SavedBufferProbe"); + wl_surface_commit(surface); + fprintf(stderr, "READY ns=%lld\n", stamp()); + while (running) { + wl_display_flush(display); + struct pollfd fds[2] = { + {.fd = wl_display_get_fd(display), .events = POLLIN}, + {.fd = STDIN_FILENO, .events = POLLIN}, + }; + int ready = poll(fds, 2, 100); + if (ready < 0) { if (errno == EINTR) continue; perror("poll"); break; } + if (fds[0].revents & (POLLIN | POLLERR | POLLHUP)) { + if (wl_display_dispatch(display) < 0) break; + } + if (fds[1].revents & POLLIN) { + char cmd[64]; + if (!fgets(cmd, sizeof(cmd), stdin)) break; + if (!strncmp(cmd, "hold_next_configure", 19)) { + hold_next = true; + fprintf(stderr, "COMMAND hold_next_configure DONE ns=%lld\n", stamp()); + } else if (!strncmp(cmd, "release_configure", 17)) { + if (held) ack_and_commit(); + fprintf(stderr, "COMMAND release_configure DONE ns=%lld\n", stamp()); + } else if (!strncmp(cmd, "close", 5)) { + running = 0; + fprintf(stderr, "COMMAND close DONE ns=%lld\n", stamp()); + } + } + } + fprintf(stderr, "EXIT ns=%lld mapped=%d held=%d error=%d\n", + stamp(), mapped, held, wl_display_get_error(display)); + xdg_toplevel_destroy(toplevel); + xdg_surface_destroy(xdg); + wl_surface_destroy(surface); + wl_display_flush(display); + wl_display_disconnect(display); + return 0; +} diff --git a/tooling/probes/step20c-saved-transaction-buffer/scenefx-saved-trace.patch b/tooling/probes/step20c-saved-transaction-buffer/scenefx-saved-trace.patch new file mode 100644 index 0000000..66430d2 --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/scenefx-saved-trace.patch @@ -0,0 +1,23 @@ +--- a/types/scene/wlr_scene.c ++++ b/types/scene/wlr_scene.c +@@ -2108,6 +2108,20 @@ + return; + } + ++ if (node->type == WLR_SCENE_NODE_BUFFER && getenv("SAVED_TRACE")) { ++ struct timespec ts; ++ clock_gettime(CLOCK_MONOTONIC, &ts); ++ struct wlr_scene_buffer *sb = wlr_scene_buffer_from_node(node); ++ struct pixman_box32 *box = pixman_region32_extents(&render_region); ++ fprintf(stderr, "SB_RENDER ns=%lld node=%p buffer=%p parent=%p visible=%d,%d,%d,%d damage=%d,%d,%d,%d\n", ++ (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec, ++ (void *)node, (void *)sb->buffer, (void *)node->parent, ++ pixman_region32_extents(&node->visible)->x1, ++ pixman_region32_extents(&node->visible)->y1, ++ pixman_region32_extents(&node->visible)->x2, ++ pixman_region32_extents(&node->visible)->y2, ++ box->x1, box->y1, box->x2, box->y2); ++ } + int x = entry->x - data->logical.x; + int y = entry->y - data->logical.y; + diff --git a/tooling/probes/step20c-saved-transaction-buffer/sway-saved-trace.patch b/tooling/probes/step20c-saved-transaction-buffer/sway-saved-trace.patch new file mode 100644 index 0000000..03d14e6 --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/sway-saved-trace.patch @@ -0,0 +1,60 @@ +--- a/sway/tree/view.c ++++ b/sway/tree/view.c +@@ -1,4 +1,6 @@ + #include ++#include ++#include + #include + #include + #include +@@ -1276,7 +1278,18 @@ + return view->urgent.tv_sec || view->urgent.tv_nsec; + } + ++/* WHY: passive private evidence identifies actual saved nodes and lifetime. */ ++static long long saved_ns(void) { ++ struct timespec ts; ++ clock_gettime(CLOCK_MONOTONIC, &ts); ++ return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec; ++} ++ + void view_remove_saved_buffer(struct sway_view *view) { ++ fprintf(stderr, "SB_TREE_DESTROY ns=%lld view=%p con=%p tree=%p content=%p\n", ++ saved_ns(), (void *)view, (void *)view->container, ++ (void *)view->saved_surface_tree, (void *)view->content_tree); ++ + if (!sway_assert(view->saved_surface_tree, "Expected a saved buffer")) { + return; + } +@@ -1309,6 +1322,10 @@ + wlr_scene_buffer_set_buffer(sbuf, buffer->buffer); + wlr_scene_buffer_set_corner_radii(sbuf, buffer->corners); + scene_descriptor_assign(&sbuf->node, SWAY_SCENE_DESC_SAVED_BUFFER, tree); ++ fprintf(stderr, "SB_BUFFER_CREATE ns=%lld node=%p source_node=%p source_buffer=%p buffer=%p size=%dx%d pos=%d,%d parent=%p\n", ++ saved_ns(), (void *)&sbuf->node, (void *)&buffer->node, ++ (void *)buffer->buffer, (void *)sbuf->buffer, buffer->buffer ? buffer->buffer->width : 0, ++ buffer->buffer ? buffer->buffer->height : 0, sx, sy, (void *)sbuf->node.parent); + } + + void view_save_buffer(struct sway_view *view) { +@@ -1317,6 +1334,10 @@ + } + + view->saved_surface_tree = wlr_scene_tree_create(view->scene_tree); ++ fprintf(stderr, "SB_TREE_CREATE ns=%lld view=%p con=%p tree=%p parent=%p content=%p\n", ++ saved_ns(), (void *)view, (void *)view->container, ++ (void *)view->saved_surface_tree, (void *)view->scene_tree, ++ (void *)view->content_tree); + if (!view->saved_surface_tree) { + sway_log(SWAY_ERROR, "Could not allocate a scene tree node when saving a surface"); + return; +@@ -1333,6 +1354,9 @@ + + wlr_scene_node_set_enabled(&view->content_tree->node, false); + wlr_scene_node_set_enabled(&view->saved_surface_tree->node, true); ++ fprintf(stderr, "SB_ENABLE ns=%lld view=%p tree=%p content_enabled=%d saved_enabled=%d\n", ++ saved_ns(), (void *)view, (void *)view->saved_surface_tree, ++ view->content_tree->node.enabled, view->saved_surface_tree->node.enabled); + } + + bool view_is_transient_for(struct sway_view *child, diff --git a/tooling/probes/step20c-saved-transaction-buffer/test-budget.py b/tooling/probes/step20c-saved-transaction-buffer/test-budget.py new file mode 100644 index 0000000..babf78d --- /dev/null +++ b/tooling/probes/step20c-saved-transaction-buffer/test-budget.py @@ -0,0 +1,46 @@ +"""Offline exclusive-ledger regression; never touches a campaign LAB.""" +import json +import multiprocessing +import tempfile +from pathlib import Path +from budget import reserve + +with tempfile.TemporaryDirectory() as root: + lab = Path(root) + reserve(lab, 'control') + reserve(lab, 'control', 'ack') + reserve(lab, 'control', 'timeout') + for episode in (None, 'ack', 'timeout'): + try: + reserve(lab, 'control', episode) + raise AssertionError('duplicate accepted') + except RuntimeError: + pass + for mode in ('private_standard', 'private_mosaic'): + reserve(lab, mode) + reserve(lab, mode, 'ack') + reserve(lab, mode, 'timeout') + assert len(json.loads((lab / 'saved-budget.json').read_text())['episodes']) == 6 +print('PASS persistent run/episode limits') + + +def contender(path, queue): + try: + reserve(Path(path), 'control') + queue.put('accepted') + except RuntimeError: + queue.put('rejected') + + +with tempfile.TemporaryDirectory() as root: + context = multiprocessing.get_context('fork') + queue = context.Queue() + children = [context.Process(target=contender, args=(root, queue)) + for _ in range(2)] + for child in children: + child.start() + for child in children: + child.join(5) + assert child.exitcode == 0 + assert sorted(queue.get() for _ in children) == ['accepted', 'rejected'] +print('PASS exclusive reservation')