diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1efb1..03e4f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### STEP20C experimental evidence +- Added a source-only temporal damage fixture at the fixed negative-origin geometry. Ten XDG open/close cycles in each of three modes and sampled camera transitions restored popup regions without a detected ghost pixel; private damage/commit traces remain insufficient for a presented-frame guarantee. STEP20C remains NEXT and multi-output Mosaic unsupported. - Added a source-only negative-origin nested-grab probe. Two outputs at `x=-800/0` verified signed pointer conversion, real standard popup crossing, sampled private Mosaic clipping, hidden-owner return, standard-layout return and a positive horizontal regression. Production STEP20C support remains open. - Added a source-only stacked-output nested valid-grab probe. Both standard controls showed real popup overflow and pointer delivery below `y=600`; the private guarded Mosaic build clipped A/B at sampled lower points, preserved hidden-owner and standard-layout return, and reproduced the horizontal control. STEP20C production support remains open. - Added a source-only nested XDG grab fixture and a comparative headless probe. Two real button serials produced an interactive toplevel → popup A → popup B chain; sampled guarded Mosaic clipping, child-first dismissal, hidden-owner camera return and standard-layout return were feasible. STEP20C production support remains open. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 95b97b4..62ad621 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,6 +97,18 @@ crossing and popup buttons. A fully hidden applied owner and return to production viewport clipping, transaction consistency, continuous damage coverage and SceneFX ABI ownership are not settled. +The later [temporal damage probe](design/STEP20C_TEMPORAL_DAMAGE_PROBE.md) +kept that same negative-origin geometry and repeated genuine nested XDG +open/close cycles, camera hide/return and outside dismissal. Sampled private +Mosaic captures restored former popup regions without ghost pixels; the +private standard layout repeated control behavior. Timed SceneFX damage +extents and private SwayFX output-commit records covered changed pixels in +sampled capture intervals, but neither the extents nor accepted commits +identify all presented frames. The current Mosaic `scroll_x` still lacks a +native transaction-generation contract binding applied scene, clip and hit +testing. The temporal evidence is **PARTIALLY_FEASIBLE**, not a production +rendering or ABI guarantee. + ## IPC On a Mosaic workspace, `get_tree` reports `layout: "mosaic"` and adds a `mosaic` object with `active_row` and `rows`. Each row entry contains ordered child node IDs, `scroll_x`, and focused node ID or null. Existing Sway fields retain their shapes; normal workspace/window events and `get_workspaces` paths remain in use. This is an extension to the Sway layout vocabulary: strict IPC consumers with a closed layout enum must recognize `mosaic`. Additional rows in the model are not a promise of usable multi-row IPC behavior. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 534f32d..ac27cc0 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -58,6 +58,18 @@ transforms, migration/resize, complete damage/effects, transaction generations or SceneFX ABI/package maintenance. Production adjacent-output Mosaic remains **NOT YET SUPPORTED**. +The [temporal damage probe](design/STEP20C_TEMPORAL_DAMAGE_PROBE.md) is +**PARTIALLY_FEASIBLE** at the same fixed negative-origin geometry. Ten +open/close cycles per configuration and sampled camera transitions found no +residual popup pixels or blocking standard-layout regression. The cycles also +delivered a real A button after B closed; one separate hidden-owner click +missed the popup surfaces and dismissed them in child-first order. Its timed +damage extents covered changed pixels between sampled PNGs, but screenshots +did not enumerate presented frames; broad extents and accepted output commits +cannot prove per-frame rendering, input/scene synchronization or damage +minimality. Popup effects, saved buffers and the SceneFX ABI/package gate +remain unqualified. Production multi-output Mosaic is **NOT YET SUPPORTED**. + The [isolated feasibility probe](design/STEP20C_FEASIBILITY_PROBE.md) is **PARTIALLY FEASIBLE**, not a release qualification. On two horizontal headless outputs, real Mosaic content leaked visually into an empty adjacent output; that earlier disposable candidate also delivered pointer enter from a partly visible client outside its viewport. Negative-origin, scaled and rotated arrangements showed visual leakage in that candidate. Popups, effects, saved buffers and complete damage correctness remain unqualified across the full matrix. The later probes above narrow individual cases without qualifying multi-output Mosaic, which remains **NOT YET SUPPORTED**. The later [SceneFX subtree clipping spike](design/STEP20C_SCENEFX_CLIPPING_SPIKE.md) is also **PARTIALLY FEASIBLE**. Its private inherited tree clip stopped tested tiled pixels and pointer hits from crossing the output boundary, including a fully hidden cell, but a real GTK XDG popup rendered and received input on the neighboring output because its scene tree lives in the global popup layer. Dedicated subsurface, saved-buffer, nested-popup and complete effect/damage validation remain open. The spike changed no production library or compositor source; multi-output Mosaic remains **NOT YET SUPPORTED**. diff --git a/docs/README.md b/docs/README.md index 1082010..cc0e29c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use [Vertical source fixture](../tooling/probes/step20c-vertical-grab/README.md) replays it in a disposable laboratory. 15. [STEP20C negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md): sampled signed-coordinate pointer, popup clipping, hidden-owner and standard-layout evidence with real `x=-800` headless origin; production gates remain open. [Negative-origin source fixture](../tooling/probes/step20c-negative-origin/README.md) replays it in a disposable laboratory. -16. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. +16. [STEP20C temporal damage probe](design/STEP20C_TEMPORAL_DAMAGE_PROBE.md): repeated XDG cycles, camera transitions, sampled pixel restoration and timed private damage/commit traces at the same negative-origin geometry; frame presentation remains unproven. + [Temporal source fixture](../tooling/probes/step20c-temporal-damage/README.md) rebuilds the private campaign. +17. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. ## Project @@ -64,6 +66,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use | [STEP20C nested valid-grab probe](design/STEP20C_NESTED_VALID_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases on horizontal headless outputs; no production qualification | | [STEP20C vertical-output nested-grab probe](design/STEP20C_VERTICAL_OUTPUT_NESTED_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases across the horizontal output boundary; no production qualification | | [STEP20C negative-origin nested-grab probe](design/STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md) | Experimental evidence | FEASIBLE for sampled two-level grabbed XDG popup cases with HEADLESS-1 at `x=-800`; no production qualification | +| [STEP20C temporal damage probe](design/STEP20C_TEMPORAL_DAMAGE_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE for sampled repeated lifecycle/camera states; damage-to-presented-frame and transaction/input synchronization remain open | A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 339b2b6..a5334e8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,6 +57,17 @@ preserves replay. Scaling, rotation, resize/migration, transaction generations, complete damage/effects and dependency maintenance remain open. STEP20C stays **NEXT**. +The [temporal damage probe](design/STEP20C_TEMPORAL_DAMAGE_PROBE.md) is +**PARTIALLY_FEASIBLE**: repeated XDG lifecycle and camera transitions restored +sampled private Mosaic pixels and standard controls, and time-ordered private +damage extents covered changed pixels between sampled captures. It did not +identify every presented frame or bind camera/clip/input to a native applied +transaction generation. Its [source fixture](../tooling/probes/step20c-temporal-damage/README.md) +remains experimental. The next bounded gate is a headless presentation-event +trace for one hide/return transition, with applied camera/clip and pointer +target. Scaling, rotation, resize/migration, complete effects and dependency +maintenance remain open. STEP20C stays **NEXT**. + ## STEP20D — multi-row Spatial Canvas · PLANNED **Goal:** connect rows to runtime tree, transactions, scene, input, and IPC. **Planned deliverables:** active-row navigation, row-local scroll/focus, deterministic insertion/reflow, in-row reorder, and between-row movement. **Exit criteria:** real tiled containers preserve identity and order across row changes; focus and camera restore correctly; headless multi-row tests pass. **Docs first:** settle exact interactions and row invariants in UX and Architecture. Unset key combinations remain TBD. diff --git a/docs/design/STEP20C_TEMPORAL_DAMAGE_PROBE.md b/docs/design/STEP20C_TEMPORAL_DAMAGE_PROBE.md new file mode 100644 index 0000000..9c3ffb5 --- /dev/null +++ b/docs/design/STEP20C_TEMPORAL_DAMAGE_PROBE.md @@ -0,0 +1,297 @@ +# STEP20C temporal rendering and damage probe + +**Status: EXPERIMENTAL / PROTOCOL RECORDED BEFORE FIXTURE.** This probe uses +private headless binaries only. STEP20C remains **NEXT** and multi-output +Mosaic remains **NOT YET SUPPORTED**. No UX, production ABI, package or +transaction contract is changed here. + +**Measured verdict: PARTIALLY_FEASIBLE for the sampled temporal matrix.** +The stabilized visual and lifecycle assertions passed in the runs below; +frame-by-frame presentation, full input/render synchronization and native +transaction generation remain unproven. + +## Question and prior evidence + +The [negative-origin probe](STEP20C_NEGATIVE_ORIGIN_NESTED_GRAB_PROBE.md) +found sampled popup clipping and hit testing feasible at stable positions. +It did not capture every presented frame or prove SceneFX damage sufficiency. +This probe asks whether repeated XDG lifecycle and camera transitions expose +stale pixels, wrong input targets or scene/IPC disagreement. A successful +settled screenshot cannot establish correctness during the transition. + +## Fixed geometry and three configurations + +HEADLESS-1 is `(-800,0,800,600)`, HEADLESS-2 is `(0,0,800,600)`, scale 1, +transform normal, global bounds `[-800,800) × [0,600)`. PNG coordinates are +output-local: HEADLESS-1 local X is global X plus 800, HEADLESS-2 local X is +global X. The virtual pointer receives `sent_x=global_x+800` with extent +`1600×600`; client enter/button events validate actual targeting. + +| Mode | Binary | Layout | Camera | +| --- | --- | --- | --- | +| A | unmodified `/usr/bin/sway` | `splith` | N/A | +| B | private Mosaic with private SceneFX | `splith` | N/A | +| C | same private binary, clip and owner guard | `mosaic` | 400 ↔ 1280 | + +Each run uses its own mode-0700 runtime, discovered private Wayland display, +explicit `swaymsg -s` socket, bounded children and two-output captures. The +daily session socket is excluded from the subprocess environment. The +ordinary layout controls compare common XDG creation, input and destruction; +they have no invented camera counterpart. + +## Hypotheses and XDG lifecycle + +H1: hiding the fully off-viewport tiled owner removes its popup pixels. +H2: camera return restores the same mapped A/B objects without old-position +pixels. H3: after B then A are destroyed, their former regions show the +underlying surfaces. H4: applied scene, clip, IPC and pointer targets agree +at observed transitions. H5: SceneFX invalidates the regions actually changed. +H6: private `splith` does not regress against unmodified SwayFX for common +XDG transitions. H7: repetition produces no crash, protocol error, surface +accumulation or ghost pixels. Each hypothesis receives its own verdict. + +TOPLEVEL → POPUP_A → POPUP_B is a real parent chain with distinct `wl_surface`, +`xdg_surface` and `xdg_popup` objects. Opening A uses a fresh TOPLEVEL button +serial; opening B uses a fresh A button serial. Every popup gets role +configure, `xdg_surface` ack, then buffer map. Explicit close destroys B +first; A is destroyed after B. An outside click may deliver `popup_done` +B then A and has a different expected outcome from camera hiding without a +click. Serial values are per run, never constants. + +## State and observation boundaries + +The logical Mosaic `scroll_x` in IPC is distinct from Sway pending/current +container geometry and from the applied scene position. The owner applied +rectangle, clip log and sampled PNGs must corroborate scene placement. An IPC +success reply proves command acceptance, not presentation. The current +private probe exposes no native transaction generation. `SPIKE_DAMAGE` +records an output-local damage **bounding extent** before scheduling a frame +and adding to the damage ring; it is neither an exact region decomposition nor +proof of a presented frame. Any experimental counter must be labelled local. +The run preserves monotonic action/capture times, IPC trees, pointer/client +events, compositor logs and PNG hashes. `grim` produces sampled screenshots; +it cannot guarantee capture of every presented frame or a fixed achieved +frequency. Damage-to-pixel matching is **PARTIAL** unless records can be +associated with the actual presented frame and capture. + +## Predefined scenarios and observations + +The initial target is ten complete stable cycles per mode, bounded by a +per-run duration and capture/byte limit. Each cycle gets a unique ID and: + +| Phase | Action and expected evidence | +| --- | --- | +| S0 | no popup, verified outputs, static reference, IPC, two PNGs | +| S1 | real TOPLEVEL click, A grab/configure/ack/map, two PNGs | +| S2 | real A click, B grab/configure/ack/map, two PNGs | +| S3 | visible A/B, configured/applied positions, interaction without dismissal | +| S4 | C only: `scroll_x` 400 → 1280, fully hidden owner, A/B still mapped, no popup hit | +| S5 | C only: 1280 → 400, same XDG objects, visible pixels restored | +| S6 | explicit B close, A retained, formerly covered region restored | +| S7 | explicit A close or controlled outside dismissal, underlying rendering restored | +| S8 | no old popup objects, owner/client alive, prepare fresh serials | + +Separate runs cover controlled outside-click dismissal and Mosaic → `splith` +with open popups. After stable cycles pass, target 15–20 camera back-and-forth +transitions with A/B open, first with observed stabilization and then with +rapid IPC requests. Record both request and observed states; coalescing is +not automatically a failure. A reproducible crash stops the loop for minimal +reproduction. Failed and inconclusive cases remain in the evidence. + +## Image and damage analysis protocol + +Use the nested client's solid SHM A-yellow `(224,192,32)` and B-green +`(32,208,160)` surfaces as known foreground. Establish stable reference +images of the background and formerly covered A/B rectangles before scoring. +Avoid dynamic `wev` text regions when comparing full pixels; any mask must be +defined before looking at anomalies. Cursor, decorations, text, effects, +capture compression and IPC/capture timing can create unrelated differences. +Record each PNG's dimensions, monotonic capture timestamp, SHA-256, phase, +output-local region of interest, exact popup-color count, whole-region diff +count, diff bounding box and anomalous local coordinates. Compare formerly +covered regions with the appropriate uncovered reference, not just the +decrease of an exact color. Keep signed global conversion separate. + +For each transition, compare observed changed regions with time-ordered +`SPIKE_DAMAGE` extents for that output, then with the rendered result. A broad +damage extent alone is not a correctness failure; missing or late invalidation +requires synchronized evidence. When frame association is absent, mark H5 +**PARTIAL** even if stabilized images are correct. + +## Verdict rules and limits + +Each assertion is `PASS` only with the required geometry, XDG serial and +lifecycle traces, mapped identities, client events, PNG pixels and process +status. `FAIL` requires a demonstrated invariant violation. `PARTIAL` means +some required observation exists but coverage or synchronization is limited. +`INCONCLUSIVE` means the fixture cannot identify the behavior; `NOT TESTED` +means it was not run. A zero process exit alone is never `PASS`. + +The aggregate is **FEASIBLE for the sampled temporal matrix** only when all +executed required visual/input transitions pass, controls have no blocking +regression, and damage traces support only the precise claims made. Positive +stabilized images with incomplete frame/damage association are +**PARTIALLY_FEASIBLE**. A reproducible leak, protocol violation, destruction +defect or crash is **BLOCKED**. Unusable fixture or captures are +**INCONCLUSIVE**. Untested cells stay **NOT TESTED**. Scaling, rotation, +resize, migration, complete effects and ABI/package maintenance are outside +this experiment. No result authorizes production STEP20C or daily use. + +## Measured campaign and retained evidence + +The final instrumented lab is `/tmp/swayfx-mosaic-nested-grab-RbwDblky/` +with build log `/tmp/mtd-prepare-Rv5kR9sm`. It contains ten runs: ten +complete cycles each in unmodified SwayFX `splith`, private `splith`, and +private Mosaic; one extra private Mosaic pilot cycle; one camera run; outside +click runs in all three configurations; a distinct hidden-owner click; and a +private Mosaic → `splith` return. The planned 30 cycles all completed. +There are **994 sampled PNGs** +on two outputs. None is labelled a complete presented-frame sequence. +The popup owner and A/B are solid SHM surfaces; supporting Kitty windows ran +with cursor blinking disabled. The predefined HEADLESS-1 ROI +`(400,90,800,310)` was empirically stable across S0 and post-close captures +in the completed cycles. `wev` on HEADLESS-2 can redraw its text, so its +whole image was not treated as a static underlay; right-output popup colors +and events were checked separately. The ROI and this exclusion were fixed +before scoring the campaign. + +| Observation | Unmodified `splith` | Private `splith` | Private Mosaic | +| --- | --- | --- | --- | +| Ten A/B open → close B → click A → close A cycles | PASS sampled | PASS sampled | PASS sampled | +| A right-button delivery after B destruction | 10/10 | 10/10 | 10/10 | +| Former A/B left-output ROI restored | 10/10 exact equalities | 10/10 | 10/10 | +| B crossing color at right, cycles 1 and 10 | 15,860 green | 15,860 green | 0 green | +| Fully hidden A/B colors and camera return | N/A | N/A | 10/10 sampled: 0/0 hidden, A/B restored with equal ROI | +| Outside-click `popup_done` and child-first destruction | PASS | PASS | PASS | +| Hidden-owner click: no popup button, B/A dismissal | N/A | N/A | PASS sampled | +| Stable `splith` return with B open | N/A | N/A | 15,860 B pixels and actual B button | +| Damage/frame presentation proof | NOT TESTED | PARTIAL | PARTIAL | + +The camera run requested 18 outward/return pairs in each of three pacing +modes: stabilization after every change, capture between accepted commands, +and a four-command burst without intervening captures. The owner stayed IPC +ID 8, A/B were created once each, no `popup_done` occurred before explicit +closure, and the sampled hidden/visible colors followed the observed target +values. All 109 `camera_observed` records, including initial positioning, +reported targets 400 or 1280. No sampled right-output A/B color appeared. +The runner's IPC checks do not prove which intermediate target values were +actually presented. The burst may coalesce them. Input/render consistency +during an unobserved frame is therefore **PARTIAL**, even though the sampled +pointer and pixel outcomes show no reproducible divergence. + +The separate hidden-click run sent a right button to a point on HEADLESS-1 +after `scroll_x=1280`. The count of popup button presses did not increase; +`popup_done` arrived B then A, and neither popup reappeared after return to +400. This is one controlled hidden point, not a universal input claim. + +The private SceneFX `TEMPORAL_DAMAGE` log recorded **4,361** output-local +damage extents across the eight private runs, and the actual SwayFX repaint +path logged **1,432** accepted output commits (zero rejected). The analyzer +compared each pair of sampled captures per output against extents whose +monotonic timestamps fell in that capture interval. It found **zero changed +pixels outside their union** and zero changed intervals without an overlapping +extent. This is interval evidence only: broad bounding extents may hide +over-invalidation, and no screenshot is tied to a unique presented frame. +H5 is **PARTIAL**. The unmodified control has no private damage trace and is +**NOT TESTED** for H5. + +An independent audit of the retained PNGs compared all three RGB channels, +rather than relying on the analyzer's grayscale difference. Across 784 +private same-output capture intervals, 237 contained changed pixels +(57,451,528 changed pixel positions in total); zero changed positions fell +outside the union of their time-bounded logged damage extents. This confirms +the narrowly stated interval observation despite the grayscale conversion +used by the fixture analyzer. It does not establish minimal damage, exact +pixman regions, a damage-to-commit mapping or intermediate frame correctness. + +`results/artifacts-sha256.txt` lists **2,133** raw and derived files, with +manifest SHA-256 +`362ba5dc399383ecd7c071b56c144b8bdf9f4dc17fad53b60238a14c750bdf7c`. +The final 12-input source manifest is `temporal-source-sha256.txt`, SHA-256 +`ef5596c587372388efedd9319ee0062cf1143e2a7c203ed4c579fc6346753702`. +The preceding instrumented lab +`/tmp/swayfx-mosaic-nested-grab-1u4Q8mE9/` retains its original +`temporal-source-sha256-initial.txt`: after those runs, its analyzer gained +a pixel-wise union check and final matrix. The final lab was rebuilt after +the A-input and hidden-click refinement and carries a single matching source +manifest. `sha256sum -c` passed for all 2,133 +listed artifacts and all 12 final source inputs. + +A fresh private lab at `/tmp/swayfx-mosaic-nested-grab-u5ZlcX0A/` rebuilt +the pinned source and patches and passed its 12-input source manifest. A +targeted replay completed one A/B open → explicit B/A close cycle in each of +the three configurations, plus private Mosaic hide/click/return, one +two-move camera run and a separate outside-click dismissal. All six runs had +no runner failure, valid XDG button serials and no failing targeted +assertions. Their 124 sampled PNGs, 1,457 private timed damage extents and +289 accepted private output commits reproduce the sampled outcome; they do +not add frame identity. This fresh lab is retained outside Git. + +The independent uninstrumented lab +`/tmp/swayfx-mosaic-nested-grab-36Cbn4JP/` had the same ten-cycle sampled +outcomes in each configuration. The first instrumented lab +`/tmp/swayfx-mosaic-nested-grab-M5uSSO6U/` and the later +`/tmp/swayfx-mosaic-nested-grab-1u4Q8mE9/` also repeated them. No +probe-induced visual difference was observed in these sampled runs; timing +perturbation outside them remains possible. No compositor crash, Wayland +protocol error, failed grab, stale popup object or residual popup-colored +pixel was detected in the completed final matrix. The initial toggle +sampling failure and its correction are recorded in the history below. + +This result does not settle popup UX while an owner is hidden, full +transaction-generation consistency, all presented frames, effects/decorations, +saved buffers, scaling/rotation, output migration/resize or SceneFX ABI/package +maintenance. The next bounded STEP20C experiment should instrument an actual +headless output presentation event together with applied camera/clip state +and pointer target for one repeated hide/return transition. It should test +the unobserved interval directly; it does not authorize a production fix. + +## Protocol history + +- 2026-10-11: protocol written before the temporal fixture; targets and + verdict rules above are prospective. Subsequent changes and measured + results must be recorded here with their date and run paths. +- 2026-10-11: first private campaign in + `/tmp/swayfx-mosaic-nested-grab-36Cbn4JP/` completed ten cycles in each + configuration, plus 18 camera transitions per mode, outside clicks and a + layout-return pilot. The original runner/analyzer sources are retained in + that lab's `source-snapshot-v1/`. Its immediate `layout splith` capture + contained no right-output B pixels; the protocol now adds a stabilized + capture and B click before classification. The camera runner also adds a + separate command burst without captures between every accepted scroll. + These changes affect measurement timing, not the private clip algorithm. +- 2026-10-11: `SPIKE_DAMAGE` lacks monotonic timestamps and an output commit + marker. A separate private SceneFX logging patch is therefore planned for + the next lab. It may log `CLOCK_MONOTONIC` nanoseconds, the damage extent, + and a **local experimental** output-commit ordinal/result. It will not + expose a native transaction generation, alter public headers or prove + presentation by itself. Compare the instrumented result with the + uninstrumented campaign for probe-induced changes. +- 2026-10-11: the first instrumented lab + `/tmp/swayfx-mosaic-nested-grab-M5uSSO6U/` retained 10 cycles per mode, + 18 camera round trips in each of three pacing modes, outside clicks and a + successful stable layout-return check. It logged damage but zero SceneFX + commit-hook calls. Source audit found the actual call in private SwayFX + `sway/desktop/output.c`; a separate logging-only Mosaic patch is now added + to the next private lab. The initial toggle runner failure in + `/tmp/swayfx-mosaic-nested-grab-pX0BTADM/` was a left-side fixture sample + after stable right-side pixels had already been captured; it was not a + compositor or XDG failure. Its logs and exit code are retained. +- 2026-10-11: the private SwayFX commit-hook patch logged the repaint path + actually used. Pixel-wise unions and `matrix.json` were added to the + analyzer after the first complete instrumented campaign; its original + analyzer hash was retained in `temporal-source-sha256-initial.txt` under + that lab. Its own source and artifact manifests remain available. +- 2026-10-11: a final protocol refinement adds an explicit A right-button + interaction after B's destruction in each cycle, and a separate hidden-owner + outside-click run. Earlier cycles demonstrated A mapping and B creation via + an A click, but did not directly recheck A input after B closed. The new + run must not erase or relabel earlier evidence; its own manifest and run IDs + will distinguish this stronger input check from the previous matrix. +- 2026-10-11: the refined campaign in + `/tmp/swayfx-mosaic-nested-grab-RbwDblky/` completed the 30-cycle matrix, + camera modes and both outside-click contexts. Ten post-B A interactions per + configuration and the separate hidden-click non-hit assertion passed. Its + final source and artifact manifests validate the finished analysis. The + verdict remains **PARTIALLY_FEASIBLE** because frames and native transaction + generations remain unobserved. diff --git a/tooling/probes/step20c-temporal-damage/README.md b/tooling/probes/step20c-temporal-damage/README.md new file mode 100644 index 0000000..817ceb0 --- /dev/null +++ b/tooling/probes/step20c-temporal-damage/README.md @@ -0,0 +1,129 @@ +# STEP20C temporal damage fixture — EXPERIMENTAL + +This fixture tests repeated popup lifecycle and camera transitions only at +HEADLESS-1 `(-800,0,800,600)` and HEADLESS-2 `(0,0,800,600)`, scale 1, +normal transform. The [protocol and verdict rules](../../../docs/design/STEP20C_TEMPORAL_DAMAGE_PROBE.md) +were recorded before this fixture. It does not alter the production build, +package, session or SceneFX ABI. It produces sampled PNGs, not every +presented frame. + +## Inputs and preparation + +Start from a full Git clone containing Mosaic `cd550382` and the preserved +STEP20C fixtures. The wrapper invokes the negative-origin `prepare.sh`, which +clones pinned SceneFX `37ccd723bef49e6891156ffafce8f549f01446cc` and +Mosaic `cd550382b311efdbfe3ed892f4e1d281d852e754` into a new temporary +lab, applies the unchanged private SceneFX/Mosaic/hidden-owner patches, +builds both privately, generates protocol headers and compiles the pointer. +`client-cycle.patch` is the only new C adaptation: it permits explicit A +closure and fresh configure/map on later cycles. It is applied to a private +copy of the preserved nested client and compiled as C17. No installed library +or compositor is modified. The preparation output names both the lab and +build log; the lab's `temporal-source-sha256.txt` records all private patch, +client and runner inputs. Compare its hashes before a replay. +A full clone containing this fixture can run `prepare.sh` directly. The source +manifest verifies the scripts and patches used by each private lab. Do not +copy any generated lab files into Git. + +The separate `scenefx-timing.patch` applies after the preserved SceneFX clip +patch and adds only monotonic damage timestamps plus a local output-commit +ordinal/result. It touches no public header, rendering buffer or clip rule. +An accepted output commit is not proof of a presented frame. The initial +uninstrumented campaign remains in its own laboratory for comparison. +SwayFX bypasses SceneFX's `wlr_scene_output_commit` in its normal repaint +path, so that SceneFX hook emits no commit records here. The independent +`mosaic-output-timing.patch` logs the actual private SwayFX +`wlr_output_commit_state` call, with the same monotonic clock and an +experimental process-local ordinal. It changes neither the decision to +commit nor the output state. + +| New private patch | SHA-256 | +| --- | --- | +| `client-cycle.patch` | `119fa0b61135bb49bf84c42499799f1c75cccbf5842ea5acac9eaaba6ab7b9ca` | +| `scenefx-timing.patch` | `7a7ef1875b559f65a3240a0016592da1da6a8537929d0e96b8aaa2d20ecd46fa` | +| `mosaic-output-timing.patch` | `4419059231e038675f50a6321889f27ba981c5d8df2779ccbc4a61e02f1d6668` | + +The reused SceneFX, Mosaic and hidden-owner patch hashes are recorded in the +[negative-origin fixture](../step20c-negative-origin/README.md). The fresh +lab's source manifest verifies their exact bytes again. + +Dependencies are those of the [negative-origin fixture](../step20c-negative-origin/README.md), +plus Pillow for analysis. Current measured environment: SwayFX 0.6, +wlroots 0.20.2, SceneFX 0.5.0, Wayland client 1.26.0. Other versions require +separate validation. + +```sh +prep=$(sh tooling/probes/step20c-temporal-damage/prepare.sh) +printf '%s\n' "$prep" +LAB=$(printf '%s\n' "$prep" | sed -n 's/^LAB=//p') +(cd "$LAB" && sha256sum -c temporal-source-sha256.txt) +``` + +## Bounded campaign + +Every invocation creates a unique `LAB/runs/` directory. The three modes +are `control` (unmodified `/usr/bin/sway` in `splith`), `private_standard` +(private Mosaic binary in `splith`) and `private_mosaic` (same binary in +`mosaic`). Each has its own mode-0700 `/tmp/mtd-*` runtime and private sockets. +The runner removes only its own process groups and runtime, including on +failure. It never inherits the daily `SWAYSOCK` or `WAYLAND_DISPLAY`; all IPC +commands use the discovered `swaymsg -s` path. + +```sh +for mode in control private_standard private_mosaic; do + PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-temporal-damage/run.py \ + "$LAB" "$mode" cycles --cycles 10 +done +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-temporal-damage/run.py \ + "$LAB" private_mosaic camera --moves 36 +for mode in control private_standard private_mosaic; do + PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-temporal-damage/run.py \ + "$LAB" "$mode" outside +done +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-temporal-damage/run.py \ + "$LAB" private_mosaic hidden_click +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-temporal-damage/run.py \ + "$LAB" private_mosaic toggle +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-temporal-damage/analyze.py "$LAB" +``` + +The command above requests 36 individual camera transitions per mode, or +18 complete outward/return pairs. Defaults cap one run at 900 seconds, 400 +captures, 250 MB of PNGs, 30 cycles and 40 camera transitions per mode. +`--max-seconds`, `--max-captures` and `--max-bytes` +can lower the resource limits. Each run retains config, IPC snapshots, PNGs +on both outputs, monotonic action/capture records, XDG/Wayland client log, +pointer/wev/compositor logs, exit status and structured analysis. `SPIKE_DAMAGE` +lines are extracted to `damage.log`; they are output extents, not frame +identities. `TEMPORAL_DAMAGE` and `TEMPORAL_OUTPUT_COMMIT` add monotonic order without +converting sampled PNGs into presented-frame evidence. The analyzer writes +per-run `damage-analysis.json` and summaries under `LAB/results/`. Full raw +logs and captures remain under each run for independent recomputation. + +`S0`/`S1`/`S2`/`S3`/`S4`/`S5`/`S6`/`S7` phase names match the protocol. +S6 includes an actual A right-button event after B is destroyed. The +`hidden_click` run is separate from camera hiding without clicks: it expects +child-first `popup_done` and no popup button at the hidden point. +Immediate and later `grim` captures are separately named. Camera mode records +settled transitions, capture-paced transitions, and a separate burst with no +capture between consecutive commands (a capture after each fourth command). +They are sampled observations; IPC acceptance and process exit zero alone do not prove render +correctness. The analyzer's fixed left ROI `(400,90,800,310)` and right ROI +`(0,90,200,310)` are output-local and were chosen before inspecting temporal +results. Dynamic `wev` text on HEADLESS-2 prevents whole-image equality there; +exact popup colors and client events are evaluated separately. No region is +masked after observing an anomaly. + +## Validation and cleanup + +```sh +sh -n tooling/probes/step20c-temporal-damage/prepare.sh +PYTHONDONTWRITEBYTECODE=1 python -c 'import ast,pathlib; [ast.parse(p.read_text()) for p in pathlib.Path("tooling/probes/step20c-temporal-damage").glob("*.py")]' +(cd "$LAB" && sha256sum -c temporal-source-sha256.txt) +git diff --check +``` + +Inspect each `analysis.json`, `exit-status.json`, raw log and sample PNG before +accepting its verdict. Keep failed cases and their exact run path. Remove a +finished laboratory only by its printed path after preserving needed proof; +never perform global `/tmp` cleanup or touch historical labs. diff --git a/tooling/probes/step20c-temporal-damage/analyze.py b/tooling/probes/step20c-temporal-damage/analyze.py new file mode 100644 index 0000000..1e3e9d9 --- /dev/null +++ b/tooling/probes/step20c-temporal-damage/analyze.py @@ -0,0 +1,361 @@ +"""Analyze sampled temporal PNGs and XDG/IPC evidence; never infer all frames.""" +import hashlib +import json +from pathlib import Path +import re +import sys + +from PIL import Image, ImageChops, ImageDraw + +if len(sys.argv) != 2: + raise SystemExit('usage: analyze.py LAB') +lab = Path(sys.argv[1]).resolve() +colors = {'A_yellow': (224, 192, 32), 'B_green': (32, 208, 160)} +results = [] + + +def image_metrics(path, reference=None, roi=None): + image = Image.open(path).convert('RGB') + data = image.get_flattened_data() + result = {'path': path.name, 'size': list(image.size), + 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), + 'exact_color_pixels': {name: data.count(rgb) for name, rgb in colors.items()}} + if reference is not None and reference.exists(): + other = Image.open(reference).convert('RGB') + if other.size == image.size: + box = roi or (0, 0, image.width, image.height) + diff = ImageChops.difference(image.crop(box), other.crop(box)) + changed = diff.convert('L').point(lambda v: 255 if v else 0) + coords = changed.getbbox() + result['comparison'] = {'reference': reference.name, 'roi_local': list(box), + 'changed_pixels': sum(v != 0 for v in changed.get_flattened_data()), + 'difference_bbox_local': None if coords is None else + [coords[0] + box[0], coords[1] + box[1], + coords[2] + box[0], coords[3] + box[1]]} + return result + + +for run in sorted((lab / 'runs').glob('*')): + if not (run / 'exit-status.json').exists(): + continue + name = run.name + mode = next((m for m in ('control', 'private_standard', 'private_mosaic') + if name.startswith(m + '-')), 'unknown') + kind = name[len(mode) + 1:].split('-')[0] + status = json.loads((run / 'exit-status.json').read_text()) + events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()] + client = (run / 'client.log').read_text() if (run / 'client.log').exists() else '' + compositor = (run / 'compositor.log').read_text() + pointer = (run / 'pointer.log').read_text() if (run / 'pointer.log').exists() else '' + damage = re.findall(r'SPIKE_DAMAGE output=(\S+) extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)', + compositor) + timed_damage = [dict(ordinal=int(n), ns=int(ns), output=output, + extent=[int(x1), int(y1), int(x2), int(y2)]) + for n, ns, output, x1, y1, x2, y2 in re.findall( + r'TEMPORAL_DAMAGE ordinal=(\d+) ns=(\d+) output=(\S+) extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)', + compositor)] + timed_commits = [dict(ordinal=int(n), ns=int(ns), output=output, accepted=bool(int(ok))) + for n, ns, output, ok in re.findall( + r'TEMPORAL_OUTPUT_COMMIT ordinal=(\d+) ns=(\d+) output=(\S+) accepted=(\d+)', + compositor)] + (run / 'damage.log').write_text('\n'.join(line for line in compositor.splitlines() + if 'SPIKE_DAMAGE' in line) + '\n') + grabs = re.findall(r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button target=(\w+) serial=(\d+) used=(\d+)', client) + button_serials = set(re.findall(r'POINTER_BUTTON target=\w+ serial=(\d+) button=272 state=1', client)) + grab_valid = bool(grabs) and all(serial == used and serial in button_serials + for _, _, serial, used in grabs) + captures = {} + for event in events: + if event['kind'] != 'capture': + continue + path = run / event['path'] + tag = event['tag'] + side = 'left' if event['output'] == 'HEADLESS-1' else 'right' + cycle_prefix = tag.split('-S')[0] + reference = run / f'{side}-{cycle_prefix}-S0-reference.png' + # This fixed ROI covers the measured b_cross A/B footprint on the + # left output; it was declared before looking at captured anomalies. + roi = (400, 90, 800, 310) if side == 'left' else (0, 90, 200, 310) + metrics = image_metrics(path, reference if path != reference else None, roi) + metrics.update(output=event['output'], tag=tag, before_ns=event['before_ns'], + after_ns=event['after_ns']) + captures[path.name] = metrics + damage_intervals = [] + previous = {} + for event in events: + if event['kind'] != 'capture': + continue + output = event['output'] + earlier = previous.get(output) + previous[output] = event + if earlier is None: + continue + first = Image.open(run / earlier['path']).convert('RGB') + second = Image.open(run / event['path']).convert('RGB') + image_diff = ImageChops.difference(first, second) + changed = image_diff.convert('L').point(lambda v: 255 if v else 0) + box = changed.getbbox() + records = [d for d in timed_damage if d['output'] == output and + earlier['after_ns'] <= d['ns'] <= event['after_ns']] + commits = [c for c in timed_commits if c['output'] == output and + earlier['after_ns'] <= c['ns'] <= event['after_ns']] + overlap = None if box is None else any( + not (box[2] <= d['extent'][0] or d['extent'][2] <= box[0] or + box[3] <= d['extent'][1] or d['extent'][3] <= box[1]) + for d in records) + uncovered = None + if timed_damage: + # CONTRACT: union only the output-local extents falling between + # these sampled captures. This is an interval comparison, not an + # association with every presented frame. + mask = Image.new('L', first.size, 0) + draw = ImageDraw.Draw(mask) + for d in records: + x1, y1, x2, y2 = d['extent'] + if x2 > x1 and y2 > y1: + draw.rectangle((x1, y1, x2 - 1, y2 - 1), fill=255) + uncovered = sum(v != 0 for v in ImageChops.subtract(changed, mask).get_flattened_data()) + damage_intervals.append({'output': output, 'from': earlier['path'], + 'to': event['path'], 'from_after_ns': earlier['after_ns'], + 'to_after_ns': event['after_ns'], 'changed_pixels': + sum(v != 0 for v in changed.get_flattened_data()), + 'change_bbox_local': None if box is None else list(box), + 'damage_ordinals': [d['ordinal'] for d in records], + 'commit_ordinals': [c['ordinal'] for c in commits], + 'damage_bbox_overlaps_change': overlap, + 'changed_pixels_outside_logged_damage_extents': uncovered}) + (run / 'damage-analysis.json').write_text(json.dumps({ + 'status': 'PARTIAL' if timed_damage else 'NOT TESTED', + 'reason': 'Sampled grim captures have no presented-frame identity; ' + 'damage extents and accepted commits cannot establish every presentation.', + 'timed_damage': timed_damage, 'timed_commits': timed_commits, + 'sample_intervals': damage_intervals}, indent=2) + '\n') + assertions = {} + assertions['run_completed'] = 'FAIL' if status['failure'] else 'PASS' + assertions['private_runtime_removed'] = ('PASS' if any(e['kind'] == 'cleanup' and + e.get('runtime_removed') for e in events) else 'FAIL') + assertions['protocol_log'] = ('FAIL' if re.search(r'protocol error|invalid serial', + compositor + client, re.I) else 'PASS') + assertions['valid_button_serials'] = 'PASS' if grab_valid else 'INCONCLUSIVE' + assertions['two_level_chain'] = ('PASS' if 'POPUP_A CREATE' in client and + 'POPUP_B CREATE' in client and 'parent=TOPLEVEL' in client and + 'parent=POPUP_A' in client else 'INCONCLUSIVE') + completed_cycles = sum(e['kind'] == 'cycle_end' for e in events) + if kind == 'cycles': + assertions['cycles_complete'] = 'PASS' if completed_cycles and not status['failure'] else 'FAIL' + a_right_presses = len(re.findall( + r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client)) + assertions['A_interactive_after_B_close'] = ('PASS' if completed_cycles and + a_right_presses >= completed_cycles and all( + f'left-cycle-{i:03d}-S6-A-interaction.png' in captures + for i in range(1, completed_cycles + 1)) else 'INCONCLUSIVE') + assertions['object_lifecycle'] = ('PASS' if all( + client.count(p + ' CREATE') == completed_cycles and + client.count(p + ' DESTROY') == completed_cycles + for p in ('POPUP_A', 'POPUP_B')) else 'FAIL') + visual = [] + uncovered = [] + restored = [] + for i in range(1, completed_cycles + 1): + prefix = f'cycle-{i:03d}' + def equal_roi(before, after): + first, second = (run / f'left-{prefix}-{before}.png', + run / f'left-{prefix}-{after}.png') + if not first.exists() or not second.exists(): + return None + box = (400, 90, 800, 310) + left = Image.open(first).convert('RGB').crop(box) + right = Image.open(second).convert('RGB').crop(box) + return ImageChops.difference(left, right).getbbox() is None + def pixels(side, tag, color): + item = captures.get(f'{side}-{prefix}-{tag}.png') + return None if item is None else item['exact_color_pixels'][color] + vals = [pixels('left', 'S3-visible', 'A_yellow'), + pixels('left', 'S3-visible', 'B_green'), + pixels('left', 'S6-close-B-stable', 'B_green'), + pixels('left', 'S6-close-B-stable', 'A_yellow'), + pixels('left', 'S7-close-A-stable', 'A_yellow'), + pixels('left', 'S7-close-A-stable', 'B_green')] + if None in vals: + visual.append('INCONCLUSIVE') + elif vals[0] > 0 and vals[1] > 0 and vals[2] == 0 and vals[3] > 0 and vals[4] == vals[5] == 0: + visual.append('PASS') + else: + visual.append('FAIL') + if mode == 'private_mosaic': + hidden = [pixels('left', 'S4-hidden-stable', c) for c in colors] + returned = [pixels('left', 'S5-return-stable', c) for c in colors] + visual.append('INCONCLUSIVE' if None in hidden + returned else + 'PASS' if hidden == [0, 0] and returned[0] > 0 and returned[1] > 0 else 'FAIL') + restored.append(equal_roi('S3-visible', 'S5-return-stable')) + uncovered.extend((equal_roi('S1-A', 'S6-close-B-stable'), + equal_roi('S0-reference', 'S7-close-A-stable'))) + assertions['sampled_visual_lifecycle'] = ('FAIL' if 'FAIL' in visual else + 'INCONCLUSIVE' if 'INCONCLUSIVE' in visual or not visual else 'PASS') + assertions['uncovered_region_equality'] = ('INCONCLUSIVE' if None in uncovered or + not uncovered else 'PASS' if all(uncovered) else 'FAIL') + assertions['camera_return_region_equality'] = ('NOT TESTED' if mode != 'private_mosaic' + else 'INCONCLUSIVE' if None in restored or not restored else + 'PASS' if all(restored) else 'FAIL') + else: + assertions['cycles_complete'] = 'NOT TESTED' + assertions['A_interactive_after_B_close'] = 'NOT TESTED' + assertions['sampled_visual_lifecycle'] = 'NOT TESTED' + assertions['object_lifecycle'] = 'NOT TESTED' + assertions['uncovered_region_equality'] = 'NOT TESTED' + assertions['camera_return_region_equality'] = 'NOT TESTED' + if kind == 'camera': + camera_events = [e for e in events if e['kind'] == 'camera_observed'] + assertions['camera_ipc_identity'] = ('PASS' if len(camera_events) >= 1 and + len({e['owner_id'] for e in camera_events}) == 1 and + all(e['target'] in (400, 1280) for e in camera_events) else 'INCONCLUSIVE') + sampled = [] + for path, item in captures.items(): + match = re.match(r'left-camera-(?:stable|fast|burst)-(\d+)', path) + if not match: + continue + expected_hidden = int(match.group(1)) % 2 == 0 + counts = item['exact_color_pixels'] + sampled.append(all(v == 0 for v in counts.values()) if expected_hidden else + all(v > 0 for v in counts.values())) + assertions['sampled_camera_pixels'] = ('INCONCLUSIVE' if not sampled else + 'PASS' if all(sampled) else 'FAIL') + assertions['popup_survives_camera'] = ('PASS' if client.count('POPUP_A CREATE') == 1 + and client.count('POPUP_B CREATE') == 1 and 'POPUP_DONE' not in client else 'FAIL') + else: + assertions['camera_ipc_identity'] = 'NOT TESTED' + assertions['sampled_camera_pixels'] = 'NOT TESTED' + assertions['popup_survives_camera'] = 'NOT TESTED' + if kind == 'outside': + done = re.findall(r'(POPUP_[AB]) POPUP_DONE', client) + destroyed = re.findall(r'(POPUP_[AB]) DESTROY', client) + left = captures.get('left-outside-dismissed.png', {}).get('exact_color_pixels') + right = captures.get('right-outside-dismissed.png', {}).get('exact_color_pixels') + assertions['outside_dismissal'] = ('INCONCLUSIVE' if left is None or right is None + else 'PASS' if done == destroyed == ['POPUP_B', 'POPUP_A'] and + all(v == 0 for v in list(left.values()) + list(right.values())) + else 'FAIL') + else: + assertions['outside_dismissal'] = 'NOT TESTED' + if kind == 'hidden_click': + targets = [e for e in events if e['kind'] == 'hidden_click_target'] + done = re.findall(r'(POPUP_[AB]) POPUP_DONE', client) + returned = captures.get('left-hidden-click-returned.png', {}).get('exact_color_pixels') + assertions['hidden_click_no_popup_hit'] = ('INCONCLUSIVE' if not targets or + returned is None else 'PASS' if + targets[-1]['popup_buttons_before'] == targets[-1]['popup_buttons_after'] and + done == ['POPUP_B', 'POPUP_A'] and all(v == 0 for v in returned.values()) + else 'FAIL') + else: + assertions['hidden_click_no_popup_hit'] = 'NOT TESTED' + if kind == 'toggle': + right = captures.get('right-toggle-standard-stable.png', {}).get('exact_color_pixels') + assertions['standard_return'] = ('INCONCLUSIVE' if right is None else + 'PASS' if right['B_green'] > 0 and 'POPUP_DONE' not in client and + len(re.findall(r'POINTER_BUTTON target=POPUP_B[^\n]*state=1', client)) >= 2 + else 'FAIL') + else: + assertions['standard_return'] = 'NOT TESTED' + assertions['damage_frame_association'] = 'PARTIAL' if timed_damage else 'NOT TESTED' + if status['failure'] or 'FAIL' in assertions.values(): + verdict = 'FAIL' + elif 'INCONCLUSIVE' in assertions.values(): + verdict = 'INCONCLUSIVE' + else: + verdict = 'PARTIAL' # sampled captures and damage extents lack frame identity + result = {'run': name, 'mode': mode, 'kind': kind, 'verdict': verdict, + 'assertions': assertions, 'completed_cycles': completed_cycles, + 'capture_count': len(captures), 'damage_record_count': len(damage), + 'timed_damage_record_count': len(timed_damage), + 'timed_commit_record_count': len(timed_commits), + 'sample_interval_count': len(damage_intervals), + 'changed_intervals_without_overlapping_damage_extent': sum( + item['changed_pixels'] > 0 and item['damage_bbox_overlaps_change'] is False + for item in damage_intervals) if timed_damage else None, + 'changed_pixels_outside_logged_damage_extents': sum( + item['changed_pixels_outside_logged_damage_extents'] or 0 + for item in damage_intervals) if timed_damage else None, + 'damage_by_output': {output: sum(d[0] == output for d in damage) + for output in ('HEADLESS-1', 'HEADLESS-2')}, + 'grab_requests': grabs, 'popup_create_count': {p: client.count(p + ' CREATE') + for p in ('POPUP_A', 'POPUP_B')}, + 'popup_destroy_count': {p: client.count(p + ' DESTROY') + for p in ('POPUP_A', 'POPUP_B')}, + 'capture_metrics': captures, 'exit_status': status, + 'pointer_ack_count': pointer.count('INJECT_DONE')} + (run / 'analysis.json').write_text(json.dumps(result, indent=2) + '\n') + results.append({k: v for k, v in result.items() if k != 'capture_metrics'}) + print(name, verdict, 'cycles', completed_cycles, 'captures', len(captures), + 'damage', len(damage), flush=True) +(lab / 'results').mkdir(exist_ok=True) +(lab / 'results/summary.json').write_text(json.dumps(results, indent=2) + '\n') +(lab / 'results/damage-analysis.json').write_text(json.dumps([ + {'run': r['run'], 'timed_damage_record_count': r['timed_damage_record_count'], + 'timed_commit_record_count': r['timed_commit_record_count'], + 'sample_interval_count': r['sample_interval_count'], + 'changed_intervals_without_overlapping_damage_extent': + r['changed_intervals_without_overlapping_damage_extent'], + 'changed_pixels_outside_logged_damage_extents': + r['changed_pixels_outside_logged_damage_extents']} + for r in results], indent=2) + '\n') +(lab / 'results/pixel-analysis.json').write_text(json.dumps([ + {'run': r['run'], 'capture_count': r['capture_count'], + 'sampled_visual_lifecycle': r['assertions']['sampled_visual_lifecycle'], + 'uncovered_region_equality': r['assertions']['uncovered_region_equality'], + 'A_interactive_after_B_close': r['assertions']['A_interactive_after_B_close'], + 'camera_return_region_equality': r['assertions']['camera_return_region_equality'], + 'sampled_camera_pixels': r['assertions']['sampled_camera_pixels']} + for r in results], indent=2) + '\n') + + +def selected(mode, kind): + found = [r for r in results if r['mode'] == mode and r['kind'] == kind] + return max(found, key=lambda r: r['completed_cycles']) if found else None + + +matrix = {} +for label, assertion, kind in ( + ('repeated_open_A_B', 'two_level_chain', 'cycles'), + ('close_B_reveals_A', 'uncovered_region_equality', 'cycles'), + ('close_A_reveals_background', 'uncovered_region_equality', 'cycles'), + ('outside_click', 'outside_dismissal', 'outside'), + ('ghost_pixels_sampled', 'uncovered_region_equality', 'cycles'), + ('A_after_B_input', 'A_interactive_after_B_close', 'cycles'), + ('crash_or_protocol_error', 'protocol_log', 'cycles')): + matrix[label] = {} + for mode in ('control', 'private_standard', 'private_mosaic'): + run = selected(mode, kind) + matrix[label][mode] = {'status': run['assertions'][assertion] if run else 'NOT TESTED', + 'run': run['run'] if run else None} +for label, assertion, kind in ( + ('camera_hide', 'sampled_visual_lifecycle', 'cycles'), + ('camera_return', 'camera_return_region_equality', 'cycles'), + ('camera_fast', 'sampled_camera_pixels', 'camera'), + ('standard_layout_return', 'standard_return', 'toggle')): + matrix[label] = {} + for mode in ('control', 'private_standard', 'private_mosaic'): + run = selected(mode, kind) if mode == 'private_mosaic' else None + matrix[label][mode] = {'status': run['assertions'][assertion] if run else 'N/A', + 'run': run['run'] if run else None} +matrix['hidden_click_no_popup_hit'] = { + mode: {'status': selected(mode, 'hidden_click')['assertions']['hidden_click_no_popup_hit'] + if mode == 'private_mosaic' and selected(mode, 'hidden_click') else 'N/A', + 'run': selected(mode, 'hidden_click')['run'] if mode == 'private_mosaic' + and selected(mode, 'hidden_click') else None} + for mode in ('control', 'private_standard', 'private_mosaic')} +matrix['damage_observed'] = { + mode: {'status': 'PARTIAL' if selected(mode, 'cycles') and + selected(mode, 'cycles')['timed_damage_record_count'] else 'NOT TESTED', + 'run': selected(mode, 'cycles')['run'] if selected(mode, 'cycles') else None} + for mode in ('control', 'private_standard', 'private_mosaic')} +matrix['input_render_consistency'] = { + mode: {'status': 'PARTIAL' if selected(mode, 'cycles') else 'NOT TESTED', + 'run': selected(mode, 'cycles')['run'] if selected(mode, 'cycles') else None} + for mode in ('control', 'private_standard', 'private_mosaic')} +for mode in ('control', 'private_standard', 'private_mosaic'): + run = selected(mode, 'cycles') + if run: + matrix['crash_or_protocol_error'][mode]['status'] = ('PASS' if + run['assertions']['protocol_log'] == run['assertions']['run_completed'] == 'PASS' + else 'FAIL') +(lab / 'results/matrix.json').write_text(json.dumps(matrix, indent=2) + '\n') diff --git a/tooling/probes/step20c-temporal-damage/client-cycle.patch b/tooling/probes/step20c-temporal-damage/client-cycle.patch new file mode 100644 index 0000000..3f0209b --- /dev/null +++ b/tooling/probes/step20c-temporal-damage/client-cycle.patch @@ -0,0 +1,20 @@ +--- a/nested-client.c ++++ b/nested-client.c +@@ -119,6 +119,9 @@ + } + static void open_popup(struct surface *s, struct surface *parent, uint32_t serial) { + if (s->popup || !parent->mapped || !serial) return; ++ /* WHY: each repeated cycle uses a newly created XDG role after the old ++ * role was destroyed; its configure may map the replacement. */ ++ s->done=false; + s->wl=wl_compositor_create_surface(compositor); + s->xdg=xdg_wm_base_get_xdg_surface(wm,s->wl); + xdg_surface_add_listener(s->xdg,&xdg_listener,s); +@@ -250,6 +253,7 @@ + char cmd[64]; + if (fgets(cmd,sizeof(cmd),stdin)) { + if (!strncmp(cmd,"close_b",7)) { destroy_popup(&b); fprintf(stderr,"COMMAND close_b DONE\n"); } ++ else if (!strncmp(cmd,"close_a",7)) { destroy_popup(&b); destroy_popup(&a); fprintf(stderr,"COMMAND close_a DONE\n"); } + else if (!strncmp(cmd,"close_owner",11)) { fprintf(stderr,"COMMAND close_owner\n"); running=0; } + } + } diff --git a/tooling/probes/step20c-temporal-damage/mosaic-output-timing.patch b/tooling/probes/step20c-temporal-damage/mosaic-output-timing.patch new file mode 100644 index 0000000..b96fd3d --- /dev/null +++ b/tooling/probes/step20c-temporal-damage/mosaic-output-timing.patch @@ -0,0 +1,39 @@ +--- a/sway/desktop/output.c ++++ b/sway/desktop/output.c +@@ -1,4 +1,5 @@ + #include ++#include + #include + #include + #include +@@ -358,6 +359,12 @@ + return false; + } + ++// WHY: SceneFX's wlr_scene_output_commit is bypassed by SwayFX's repaint ++// path. This private ordinal timestamps the actual accepted output commit. ++// CONTRACT: it is a local probe counter, not a native transaction/frame ID. ++// INVARIANT: repaint and commit decisions are unchanged by the trace. ++static uint64_t temporal_output_commit_ordinal; ++ + static int output_repaint_timer_handler(void *data) { + struct sway_output *output = data; + +@@ -394,7 +401,16 @@ + } + } + +- if (!wlr_output_commit_state(output->wlr_output, &pending)) { ++ bool commit_ok = wlr_output_commit_state(output->wlr_output, &pending); ++ struct timespec temporal_now; ++ clock_gettime(CLOCK_MONOTONIC, &temporal_now); ++ sway_log(SWAY_INFO, "TEMPORAL_OUTPUT_COMMIT ordinal=%" PRIu64 ++ " ns=%" PRIu64 " output=%s accepted=%d", ++ ++temporal_output_commit_ordinal, ++ (uint64_t)temporal_now.tv_sec * UINT64_C(1000000000) + ++ (uint64_t)temporal_now.tv_nsec, ++ output->wlr_output->name, commit_ok); ++ if (!commit_ok) { + sway_log(SWAY_ERROR, "Page-flip failed on output %s", output->wlr_output->name); + } + wlr_output_state_finish(&pending); diff --git a/tooling/probes/step20c-temporal-damage/prepare.sh b/tooling/probes/step20c-temporal-damage/prepare.sh new file mode 100644 index 0000000..384b099 --- /dev/null +++ b/tooling/probes/step20c-temporal-damage/prepare.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# EXPERIMENTAL: private rebuild, with a separate lifecycle-only client patch. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-temporal-damage" +prep_log=$(mktemp /tmp/mtd-prepare-XXXXXXXX) +if ! sh "$repo/tooling/probes/step20c-negative-origin/prepare.sh" > "$prep_log" 2>&1; then + printf 'PRIVATE_BUILD_FAILED log=%s\n' "$prep_log" >&2 + tail -40 "$prep_log" >&2 + exit 1 +fi +lab=$(sed -n 's/^LAB=//p' "$prep_log") +test -n "$lab" && test -d "$lab" +trap 'rm -rf -- "$lab"' 0 +# CONTRACT: the timing trace is applied only after the preserved private clip +# patch, in this disposable SceneFX checkout; it changes no public header. +(cd "$lab/scenefx" && git apply --check "$fixture/scenefx-timing.patch" && + git apply "$fixture/scenefx-timing.patch") +meson compile -C "$lab/scenefx-build" -j 8 >> "$prep_log" 2>&1 +(cd "$lab/mosaic" && git apply --check "$fixture/mosaic-output-timing.patch" && + git apply "$fixture/mosaic-output-timing.patch") +meson compile -C "$lab/mosaic-build" -j 8 >> "$prep_log" 2>&1 +cp "$repo/tooling/probes/step20c-nested-grab/nested-client.c" "$lab/nested-client.c" +(cd "$lab" && git apply --check "$fixture/client-cycle.patch" && git apply "$fixture/client-cycle.patch") +cc -std=c17 -Wall -Wextra -Wno-unused-parameter -I"$lab" \ + "$lab/nested-client.c" "$lab/xdg-shell-protocol.c" -o "$lab/nested-client" \ + $(pkg-config --cflags --libs wayland-client) +sha256sum "$repo/tooling/probes/step20c-nested-grab/nested-client.c" \ + "$repo/tooling/probes/step20c-vertical-grab/pointer-extents.patch" \ + "$repo/tooling/probes/step20c-valid-grab/scenefx-popup-plus-base.patch" \ + "$repo/tooling/probes/step20c-valid-grab/mosaic-popup-plus-base.patch" \ + "$repo/tooling/probes/step20c-valid-grab/hidden-guard.patch" \ + "$fixture/scenefx-timing.patch" \ + "$fixture/mosaic-output-timing.patch" \ + "$fixture/client-cycle.patch" "$lab/nested-client.c" \ + "$fixture/prepare.sh" "$fixture/run.py" "$fixture/analyze.py" \ + > "$lab/temporal-source-sha256.txt" +trap - 0 +printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$prep_log" diff --git a/tooling/probes/step20c-temporal-damage/run.py b/tooling/probes/step20c-temporal-damage/run.py new file mode 100644 index 0000000..c750d31 --- /dev/null +++ b/tooling/probes/step20c-temporal-damage/run.py @@ -0,0 +1,412 @@ +"""EXPERIMENTAL temporal headless campaign. See README for exact invocation.""" +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', 'camera', 'outside', 'hidden_click', 'toggle')) +parser.add_argument('--cycles', type=int, default=10) +parser.add_argument('--moves', type=int, default=18) +parser.add_argument('--max-seconds', type=int, default=900) +parser.add_argument('--max-captures', type=int, default=400) +parser.add_argument('--max-bytes', type=int, default=250_000_000) +args = parser.parse_args() +if not 1 <= args.cycles <= 30 or not 1 <= args.moves <= 40: + parser.error('cycle/move bounds exceeded') +if args.kind in ('camera', 'hidden_click', 'toggle') and args.mode != 'private_mosaic': + parser.error('camera/toggle requires private_mosaic') +lab = args.lab.resolve() +if not (lab / 'nested-client').is_file(): + parser.error('prepared LAB required') +run = lab / 'runs' / f'{args.mode}-{args.kind}-{time.time_ns()}' +run.mkdir(parents=True, mode=0o700) +runtime = Path(tempfile.mkdtemp(prefix='mtd-', dir='/tmp')) +if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108: + raise RuntimeError('IPC socket path exceeds sockaddr_un') +(run / 'runtime-path').write_text(str(runtime) + '\n') +(run / 'config').write_text( + 'output HEADLESS-1 resolution 800x600 position -800 0\n' + 'output HEADLESS-2 resolution 800x600 position 0 0\n' + 'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n' + 'focus_follows_mouse no\n') +env = os.environ.copy() +env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless', + WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman') +for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'): + env.pop(key, None) +compositor_env = env.copy() +binary = Path('/usr/bin/sway') if args.mode == 'control' else lab / 'mosaic-build/sway/swayfx-mosaic' +if args.mode != 'control': + compositor_env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build') +files, children, events = [], [], [] +started = time.monotonic() +capture_count = 0 +capture_bytes = 0 + + +def record(kind, **fields): + entry = {'monotonic_ns': time.monotonic_ns(), 'kind': kind, **fields} + events.append(entry) + with (run / 'events.jsonl').open('a') as out: + out.write(json.dumps(entry) + '\n') + return entry + + +def spawn(argv, log_name, *, stdin=False, debug=False): + log = (run / log_name).open('w') + files.append(log) + child_env = env.copy() + if debug: + child_env['WAYLAND_DEBUG'] = '1' + proc = subprocess.Popen(argv, env=child_env, stdin=subprocess.PIPE if stdin else None, + stdout=log, stderr=subprocess.STDOUT, + start_new_session=True, text=stdin) + children.append(proc) + return proc + + +def wait(predicate, seconds=12): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if time.monotonic() - started > args.max_seconds: + raise RuntimeError('run duration bound exceeded') + result = predicate() + if result: + return result + if compositor.poll() is not None: + raise RuntimeError(f'compositor exited {compositor.returncode}') + if 'pointer' in globals() and pointer.poll() is not None: + raise RuntimeError(f'pointer exited {pointer.returncode}') + if 'client' in globals() and client.poll() is not None: + raise RuntimeError(f'client exited {client.returncode}') + time.sleep(.04) + raise RuntimeError('timeout waiting for event') + + +def ipc(*argv): + completed = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env, + text=True, capture_output=True, check=True, timeout=8) + result = json.loads(completed.stdout) + record('ipc', argv=list(argv), result=result) + return result + + +def command(value): + result = ipc(value) + if not isinstance(result, list) or not result or not all(x.get('success') for x in result): + raise RuntimeError(f'IPC command rejected: {value}: {result}') + + +def find(node, predicate): + if isinstance(node, dict): + if predicate(node): + return node + for child in node.get('nodes', []) + node.get('floating_nodes', []): + found = find(child, predicate) + if found: + return found + return None + + +def tree(): + return ipc('-t', 'get_tree') + + +def workspace(): + return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1') + + +def owner(): + return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe') + + +def client_text(): + return (run / 'client.log').read_text() + + +def count(marker): + return client_text().count(marker) + + +def configured(name): + matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)', + client_text()) + if not matches: + raise RuntimeError(f'no {name} role configure') + return tuple(map(int, matches[-1])) + + +def snapshot(tag): + global capture_count, capture_bytes + t = tree() + outputs = ipc('-t', 'get_outputs') + (run / f'tree-{tag}.json').write_text(json.dumps(t, indent=2) + '\n') + (run / f'outputs-{tag}.json').write_text(json.dumps(outputs, indent=2) + '\n') + expected = {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)} + actual = {} + for output in outputs: + if output.get('name') in expected and output.get('active'): + rect = output['rect'] + if output.get('scale') != 1.0 or output.get('transform') != 'normal': + raise RuntimeError('output scale/transform mismatch') + actual[output['name']] = (rect['x'], rect['y'], rect['width'], rect['height']) + if actual != expected: + raise RuntimeError(f'output geometry mismatch: {actual}') + for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): + if capture_count >= args.max_captures: + raise RuntimeError('capture count bound exceeded') + path = run / f'{side}-{tag}.png' + before = time.monotonic_ns() + subprocess.run(['grim', '-o', output, str(path)], env=env, check=True, + stdout=subprocess.DEVNULL, timeout=10) + after = time.monotonic_ns() + capture_count += 1 + capture_bytes += path.stat().st_size + if capture_bytes > args.max_bytes: + raise RuntimeError('capture byte bound exceeded') + record('capture', tag=tag, output=output, path=path.name, + before_ns=before, after_ns=after, size=path.stat().st_size) + + +def inject(action, x, y): + if not (-800 <= x < 800 and 0 <= y < 600): + raise ValueError('global pointer out of bounds') + sent = x + 800 + marker = f'INJECT_DONE {action} {sent},{y}' + before = (run / 'pointer.log').read_text().count(marker) + record('pointer_request', action=action, global_point=[x, y], sent=[sent, y], + extent=[1600, 600], output='HEADLESS-1' if x < 0 else 'HEADLESS-2') + pointer.stdin.write(f'{action} {sent} {y}\n') + pointer.stdin.flush() + wait(lambda: (run / 'pointer.log').read_text().count(marker) > before) + + +def client_command(value): + marker = f'COMMAND {value} DONE' + before = count(marker) + record('client_request', value=value) + client.stdin.write(value + '\n') + client.stdin.flush() + wait(lambda: count(marker) > before) + + +def scroll(target): + current = workspace()['mosaic']['rows'][0]['scroll_x'] + command(f'layout mosaic scroll {target-current:g}') + wait(lambda: workspace()['mosaic']['rows'][0]['scroll_x'] == target) + record('camera_observed', target=target, owner_id=owner()['id']) + + +def open_chain(cycle): + a_before, b_before = count('POPUP_A MAP'), count('POPUP_B MAP') + o = owner() + scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0 + top_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + 100) + inject('move', top_x, 150) + inject('left', top_x, 150) + wait(lambda: count('POPUP_A MAP') > a_before) + snapshot(f'{cycle}-S1-A') + a_cfg = configured('POPUP_A') + a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 40) + a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 35) + inject('move', a_x, a_y) + inject('left', a_x, a_y) + wait(lambda: count('POPUP_B MAP') > b_before) + snapshot(f'{cycle}-S2-B') + b_cfg = configured('POPUP_B') + b_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35) + b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30) + inject('move', b_x, b_y) + inject('right', b_x, b_y) + time.sleep(.12) + snapshot(f'{cycle}-S3-visible') + record('chain', cycle=cycle, owner_id=o['id'], scroll_x=scroll_x, + a_configure=a_cfg, b_configure=b_cfg, + top_global=[top_x, 150], a_global=[a_x, a_y], b_global=[b_x, b_y]) + + +def cycle_once(number): + label = f'cycle-{number:03d}' + record('cycle_start', cycle=number) + snapshot(f'{label}-S0-reference') + open_chain(label) + if args.mode == 'private_mosaic': + scroll(1280) + snapshot(f'{label}-S4-hidden-immediate') + time.sleep(.18) + snapshot(f'{label}-S4-hidden-stable') + inject('move', -700, 190) + time.sleep(.1) + snapshot(f'{label}-S4-hidden-hover') + scroll(400) + snapshot(f'{label}-S5-return-immediate') + time.sleep(.18) + snapshot(f'{label}-S5-return-stable') + client_command('close_b') + snapshot(f'{label}-S6-close-B-immediate') + time.sleep(.18) + snapshot(f'{label}-S6-close-B-stable') + a_cfg = configured('POPUP_A') + o = owner() + scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0 + a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 20) + a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 20) + before_a = len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text())) + inject('move', a_x, a_y) + inject('right', a_x, a_y) + wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', + client_text())) > before_a) + snapshot(f'{label}-S6-A-interaction') + client_command('close_a') + snapshot(f'{label}-S7-close-A-immediate') + time.sleep(.18) + snapshot(f'{label}-S7-close-A-stable') + record('cycle_end', cycle=number, owner_id=owner()['id'], client_alive=client.poll() is None) + + +compositor_log = (run / 'compositor.log').open('w') +files.append(compositor_log) +compositor = None +failure = None +try: + compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'], + env=compositor_env, stdout=compositor_log, + stderr=subprocess.STDOUT, start_new_session=True) + def sockets(): + s = next(iter(runtime.glob('sway-ipc.*.sock')), None) + w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None) + return (s, w) if s and w else None + sock, way = wait(sockets) + env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name) + snapshot('startup') + pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], 'pointer.log', stdin=True) + wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text()) + command('workspace 1') + command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith') + spawn(['kitty', '--class', 'A', '-o', 'cursor_blink_interval=0', + '-o', 'background=#aa2222', '/usr/bin/sleep', '900'], 'A.log') + wait(lambda: len(workspace()['nodes']) >= 1) + client = spawn([str(lab / 'nested-client'), 'b_cross'], 'client.log', stdin=True, debug=True) + wait(lambda: owner() and 'TOPLEVEL MAP' in client_text()) + for name in ('C', 'D'): + spawn(['kitty', '--class', name, '-o', 'cursor_blink_interval=0', + '/usr/bin/sleep', '900'], f'{name}.log') + time.sleep(.25) + command('workspace 2') + spawn(['stdbuf', '-oL', 'wev'], 'wev.log') + wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev')) + command('workspace 1') + command('[app_id="org.mosaic.NestedGrabProbe"] focus') + if args.mode == 'private_mosaic': + scroll(400) + time.sleep(.25) + if args.kind == 'cycles': + for i in range(1, args.cycles + 1): + cycle_once(i) + elif args.kind == 'camera': + snapshot('camera-S0-reference') + open_chain('camera') + for i in range(args.moves): + target = 1280 if i % 2 == 0 else 400 + scroll(target) + snapshot(f'camera-stable-{i:02d}-immediate') + time.sleep(.18) + snapshot(f'camera-stable-{i:02d}-settled') + for i in range(args.moves): + target = 1280 if i % 2 == 0 else 400 + scroll(target) + snapshot(f'camera-fast-{i:02d}') + # WHY: the capture-per-command sequence above can itself pace IPC. + # This additional burst has no capture or artificial sleep between + # accepted scroll commands; it may coalesce presented states. + for i in range(args.moves): + target = 1280 if i % 2 == 0 else 400 + scroll(target) + if i % 4 == 3: + snapshot(f'camera-burst-{i:02d}') + snapshot('camera-burst-final') + client_command('close_b') + client_command('close_a') + snapshot('camera-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) diff --git a/tooling/probes/step20c-temporal-damage/scenefx-timing.patch b/tooling/probes/step20c-temporal-damage/scenefx-timing.patch new file mode 100644 index 0000000..ed072f3 --- /dev/null +++ b/tooling/probes/step20c-temporal-damage/scenefx-timing.patch @@ -0,0 +1,50 @@ +--- a/types/scene/wlr_scene.c ++++ b/types/scene/wlr_scene.c +@@ -1,4 +1,6 @@ + #include ++#include ++#include + #include + #include + #include +@@ -50,6 +52,18 @@ + }; + static struct scene_tree_clip *scene_tree_clips; + ++// WHY: this disposable trace aligns compositor damage and output commits with ++// runner CLOCK_MONOTONIC actions; it does not identify presented frames. ++// CONTRACT: counters are local to this private process and have no ABI/API use. ++// INVARIANT: logging does not change the clip, damage ring or output state. ++static uint64_t temporal_damage_ordinal; ++static uint64_t temporal_commit_ordinal; ++static uint64_t temporal_now_ns(void) { ++ struct timespec ts; ++ clock_gettime(CLOCK_MONOTONIC, &ts); ++ return (uint64_t)ts.tv_sec * UINT64_C(1000000000) + (uint64_t)ts.tv_nsec; ++} ++ + static struct scene_tree_clip *scene_tree_clip_find(struct wlr_scene_tree *tree) { + for (struct scene_tree_clip *it = scene_tree_clips; it; it = it->next) { + if (it->tree == tree) return it; +@@ -510,6 +524,10 @@ + const pixman_box32_t *extent = pixman_region32_extents(&clipped); + wlr_log(WLR_INFO, "SPIKE_DAMAGE output=%s extent=%d,%d..%d,%d", + output->name, extent->x1, extent->y1, extent->x2, extent->y2); ++ wlr_log(WLR_INFO, "TEMPORAL_DAMAGE ordinal=%" PRIu64 " ns=%" PRIu64 ++ " output=%s extent=%d,%d..%d,%d", ++ ++temporal_damage_ordinal, temporal_now_ns(), output->name, ++ extent->x1, extent->y1, extent->x2, extent->y2); + wlr_output_schedule_frame(scene_output->output); + wlr_damage_ring_add(&scene_output->damage_ring, &clipped); + +@@ -3077,6 +3095,10 @@ + } + + ok = wlr_output_commit_state(scene_output->output, &state); ++ wlr_log(WLR_INFO, "TEMPORAL_COMMIT ordinal=%" PRIu64 " ns=%" PRIu64 ++ " output=%s accepted=%d", ++ ++temporal_commit_ordinal, temporal_now_ns(), ++ scene_output->output->name, ok); + if (!ok) { + goto out; + }