From 52174ceb6f2676d6011d3218335f1ed6c44fd318 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 11 Oct 2026 09:32:09 +0200 Subject: [PATCH] docs(mosaic): preserve nested XDG valid-grab probe --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 10 + docs/KNOWN_LIMITATIONS.md | 14 +- docs/README.md | 5 +- docs/ROADMAP.md | 12 +- .../design/STEP20C_NESTED_VALID_GRAB_PROBE.md | 253 +++++++++++++++++ tooling/probes/step20c-nested-grab/README.md | 97 +++++++ .../step20c-nested-grab/nested-client.c | 263 ++++++++++++++++++ tooling/probes/step20c-nested-grab/prepare.sh | 35 +++ tooling/probes/step20c-nested-grab/run.py | 263 ++++++++++++++++++ 10 files changed, 950 insertions(+), 3 deletions(-) create mode 100644 docs/design/STEP20C_NESTED_VALID_GRAB_PROBE.md create mode 100644 tooling/probes/step20c-nested-grab/README.md create mode 100644 tooling/probes/step20c-nested-grab/nested-client.c create mode 100644 tooling/probes/step20c-nested-grab/prepare.sh create mode 100644 tooling/probes/step20c-nested-grab/run.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f20a6a..ebe5615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### STEP20C experimental evidence +- 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. - Preserved the valid-serial XDG grab / hidden-owner probe's original C and Python sources, private patches and protocol XML with hashes and a disposable headless replay procedure. Its result remains **PARTIALLY_FEASIBLE**; no production source, package or UX policy changed. ### Patch export tooling diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b394d6f..fffe96e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,6 +65,16 @@ The [valid-grab and hidden-owner probe](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_P The [preserved source fixture](../tooling/probes/step20c-valid-grab/README.md) reconstructs the private guard from pinned bases and patches. It supplies reproducible evidence, not a compositor-owned STEP20C implementation. +The subsequent [nested valid-grab probe](design/STEP20C_NESTED_VALID_GRAB_PROBE.md) +used two real pointer-button serials and a true toplevel → popup A → popup B +XDG parent chain. In a disposable guarded binary, both popup levels were +interactive within the horizontal two-output Mosaic viewport, absent from +sampled clipped visual/input positions, and restored after a fully hidden +owner returned without popup re-creation. The unmodified SwayFX and private +standard-layout controls preserved ordinary sampled popup behavior. This +narrows the feasibility gap for nested grabs but does not define a production +transaction, damage, output-geometry or SceneFX ABI contract. + ## 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 2082b1b..6da9a68 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -20,10 +20,22 @@ The [STEP20C viewport proposal](design/STEP20C_VIEWPORT_DESIGN.md) identifies an The later [popup ownership probe](design/STEP20C_POPUP_OWNERSHIP_PROBE.md) is **PARTIALLY_FEASIBLE**. A private owner-derived root-popup clip kept tested root and nested GTK popup pixels and pointer hits inside the Mosaic viewport on two horizontal headless outputs. Valid-serial grabs and fully hidden owners were untested in that probe; other output arrangements, complete damage and effects also remained unqualified. This does not change the current **NOT YET SUPPORTED** multi-output status. -The [valid-grab and hidden-owner follow-up](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md) is also **PARTIALLY_FEASIBLE**. A genuine pointer-button serial produced an interactive grabbed XDG popup in unmodified SwayFX, private standard layout and private Mosaic. The previous owner-derived viewport clip leaked popup pixels and button hits when the owner was fully hidden. A further disposable owner-visibility guard removed that sampled leak while the popup stayed mapped and returned with the camera. Valid-grab nested popups, other output arrangements, transaction-generation consistency, complete damage/effect coverage and ABI/package maintenance remain unqualified. Multi-output Mosaic is still **NOT YET SUPPORTED**. +The [valid-grab and hidden-owner follow-up](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md) is also **PARTIALLY_FEASIBLE**. A genuine pointer-button serial produced an interactive grabbed XDG popup in unmodified SwayFX, private standard layout and private Mosaic. The previous owner-derived viewport clip leaked popup pixels and button hits when the owner was fully hidden. A further disposable owner-visibility guard removed that sampled leak while the popup stayed mapped and returned with the camera. This earlier probe did not qualify nested valid grabs; the separate nested-grab probe below covers only a sampled horizontal matrix. Other output arrangements, transaction-generation consistency, complete damage/effect coverage and ABI/package maintenance remain unqualified. Multi-output Mosaic is still **NOT YET SUPPORTED**. The [retained source fixture](../tooling/probes/step20c-valid-grab/README.md) permits bounded replay; it does not close these qualification gaps. +The [nested valid-grab probe](design/STEP20C_NESTED_VALID_GRAB_PROBE.md) is +**FEASIBLE for its sampled matrix only**. A separate client used real pointer +button serials to grab A and then B as A's XDG child. In the disposable +guarded compositor, both levels were visible and interactive inside the +Mosaic viewport, absent at sampled pixels and hits beyond it, and suppressed +while their tiled owner was fully hidden; the mapped surfaces returned with +the camera. Closing B left A interactive, and outside clicks dismissed B +then A. Control and private `splith` retained ordinary sampled behavior. +Other output geometries, migration/resize, transaction generations, complete +damage/effects and dependency/ABI maintenance remain **NOT TESTED**. Adjacent +output Mosaic remains **NOT YET SUPPORTED** in production. + 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; the disposable candidate also delivered pointer enter from a partly visible client outside its viewport. Negative-origin, scaled and rotated arrangements showed visual leakage too. Popups, effects, saved buffers, vertical Y-crossing behavior, pointer behavior in those additional arrangements and complete damage correctness remain unqualified. Multi-output Mosaic 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 67ede0f..6e95a90 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use 11. [STEP20C popup ownership probe](design/STEP20C_POPUP_OWNERSHIP_PROBE.md): isolated owner-derived root/nested popup evidence and remaining grab, damage and packaging gates. 12. [STEP20C valid-grab and hidden-owner probe](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md): isolated valid-serial XDG grab comparison, fully hidden owner leak and private guard evidence; production gates remain open. [Preserved source fixtures](../tooling/probes/step20c-valid-grab/README.md) record exact inputs and a disposable replay procedure. -13. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. +13. [STEP20C nested valid-grab probe](design/STEP20C_NESTED_VALID_GRAB_PROBE.md): two genuine nested XDG grabs and sampled clipping, dismissal, hidden-owner and standard-layout return evidence; remaining STEP20C gates stay open. + [Nested-grab source fixture](../tooling/probes/step20c-nested-grab/README.md) reconstructs the same disposable experiment. +14. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. ## Project @@ -55,6 +57,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use | [STEP20C SceneFX subtree spike](design/STEP20C_SCENEFX_CLIPPING_SPIKE.md) | Experimental evidence | PARTIALLY FEASIBLE; tiled clip worked privately, global popup leaked pixels and input | | [STEP20C popup ownership probe](design/STEP20C_POPUP_OWNERSHIP_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; owner-derived root/nested popup clipping worked in a horizontal private headless case, with grab/damage/output gates open | | [STEP20C valid-grab and hidden-owner probe](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; valid-serial popup grab measured, hidden-owner leak found and closed only in a private guard; broader STEP20C gates remain open | +| [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 | 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 fa7c6db..7e996e1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -22,10 +22,20 @@ This is the canonical order of work, not proof that a feature exists. **A step i ## STEP20C — viewport correctness · NEXT -**Goal:** make the single-row viewport correct at workspace and output boundaries. **Planned deliverables:** clipping or an equivalent rendering strategy, matching hit testing, adjacent-output tests, resize/migration tests, and transaction-safe camera animation. **Exit criteria:** no cross-output visual/input leakage in the tested scenarios; camera and IPC remain consistent across transactions; standard layouts still pass. **Docs first:** review the [STEP20C viewport proposal](design/STEP20C_VIEWPORT_DESIGN.md), resolve the visual/input gaps found by the [PARTIALLY FEASIBLE isolated probe](design/STEP20C_FEASIBILITY_PROBE.md) and the [PARTIALLY FEASIBLE SceneFX subtree spike](design/STEP20C_SCENEFX_CLIPPING_SPIKE.md), then settle the resulting contract in Architecture and UX and update Known Limitations before implementation. The subtree spike proves a narrow tiled path but exposes a popup visual/input leak and unresolved damage, effect and package-maintenance gates. The [PARTIALLY_FEASIBLE popup ownership probe](design/STEP20C_POPUP_OWNERSHIP_PROBE.md) closes the sampled root/nested popup leak in an isolated build. The later [PARTIALLY_FEASIBLE valid-grab and hidden-owner probe](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md) measures a real XDG grab and exposes a hidden-owner leak in that initial private clip; a further private guard closes the sampled leak. Valid-grab nested popups, other output geometries, transaction/output migration, complete damage/effects and packaging remain unresolved. None of these probes authorizes production STEP20C development or general multi-output support. +**Goal:** make the single-row viewport correct at workspace and output boundaries. **Planned deliverables:** clipping or an equivalent rendering strategy, matching hit testing, adjacent-output tests, resize/migration tests, and transaction-safe camera animation. **Exit criteria:** no cross-output visual/input leakage in the tested scenarios; camera and IPC remain consistent across transactions; standard layouts still pass. **Docs first:** review the [STEP20C viewport proposal](design/STEP20C_VIEWPORT_DESIGN.md), resolve the visual/input gaps found by the [PARTIALLY FEASIBLE isolated probe](design/STEP20C_FEASIBILITY_PROBE.md) and the [PARTIALLY FEASIBLE SceneFX subtree spike](design/STEP20C_SCENEFX_CLIPPING_SPIKE.md), then settle the resulting contract in Architecture and UX and update Known Limitations before implementation. The subtree spike proves a narrow tiled path but exposes a popup visual/input leak and unresolved damage, effect and package-maintenance gates. The [PARTIALLY_FEASIBLE popup ownership probe](design/STEP20C_POPUP_OWNERSHIP_PROBE.md) closes the sampled root/nested popup leak in an isolated build. The later [PARTIALLY_FEASIBLE valid-grab and hidden-owner probe](design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md) measures a real XDG grab and exposes a hidden-owner leak in that initial private clip; a further private guard closes the sampled leak. The later nested valid-grab probe closes only its sampled horizontal matrix. Other output geometries, transaction/output migration, complete damage/effects and packaging remain unresolved. None of these probes authorizes production STEP20C development or general multi-output support. The [source fixture for the valid-grab probe](../tooling/probes/step20c-valid-grab/README.md) preserves a bounded replay path. Its existence does not change STEP20C's **NEXT** status or any exit criterion. +The [nested valid-grab probe](design/STEP20C_NESTED_VALID_GRAB_PROBE.md) is +**FEASIBLE within its sampled horizontal headless matrix**: distinct valid +grabs for parent and child popups, visual/input clipping, outside dismissal, +hidden-owner camera return and standard-layout return were measured in a +disposable guarded binary. Its [source fixture](../tooling/probes/step20c-nested-grab/README.md) +is reproducible. The next bounded feasibility test should change output +geometry with the same nested grabbed hierarchy and direct control comparison. +Transaction, resize/migration, damage/effects and ABI/package gates remain +open; this result does not change STEP20C's **NEXT** status. + ## 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_NESTED_VALID_GRAB_PROBE.md b/docs/design/STEP20C_NESTED_VALID_GRAB_PROBE.md new file mode 100644 index 0000000..5801b2c --- /dev/null +++ b/docs/design/STEP20C_NESTED_VALID_GRAB_PROBE.md @@ -0,0 +1,253 @@ +# STEP20C nested valid-grab probe + +**Status: EXPERIMENTAL / FEASIBLE for the sampled nested-grab matrix.** This document records a bounded headless +feasibility experiment, not a production STEP20C implementation or UX decision. +The [architecture](../ARCHITECTURE.md), [UX](../UX.md), [limitations](../KNOWN_LIMITATIONS.md) +and [roadmap](../ROADMAP.md) retain authority. + +## Hypothesis and protocol contract (before fixture implementation) + +A real `xdg_toplevel → xdg_popup A → xdg_popup B` chain can keep both +popups mapped under two valid explicit grabs. The disposable SceneFX/Mosaic +clip should constrain **both** popup levels to the applied viewport, including +when their tiled owner is fully hidden by camera translation. The unmodified +SwayFX control establishes ordinary grab dismissal and pointer routing. + +The installed `xdg-shell.xml` declares version 7; the prior fixture binds +`xdg_wm_base` at version 3. Its `xdg_popup.grab` text requires a user action +serial, a grabbing parent for nested grabbing popups, a mapped parent, grab +before mapping, and reverse destruction order. It permits a compositor to +dismiss the whole nested stack, topmost first. The initial buffer for each +popup follows `xdg_popup.configure`, `xdg_surface.configure`, and +`xdg_surface.ack_configure`. A denied grab immediately dismisses the popup. +The client must log the originating `wl_pointer.button` target, serial and +the matching `xdg_popup.grab` request for A and B separately. Nonzero serial +alone is insufficient proof. + +## Fixture architecture and geometry + +The toplevel has its own `wl_surface`, `xdg_surface` and `xdg_toplevel`. +Popup A has its own `wl_surface`, `xdg_surface` and `xdg_popup` with the +toplevel `xdg_surface` as parent. Popup B has distinct objects and uses A's +`xdg_surface` as its actual XDG parent. Toplevel, A and B use distinct solid +colors and explicit `TOPLEVEL`, `POPUP_A`, `POPUP_B` log labels. Surface object +identity and configure traces, rather than color alone, establish hierarchy. + +A left button press on the mapped toplevel opens A with exactly that event's +serial. After A is configured, acknowledged and mapped, a **new** left press +on A opens B with that second event's serial. Each popup uses an independent +positioner anchored inside its parent window geometry. Positioner offsets +will provide a fully visible placement and one or two viewport-crossing +placements. The runner records actual configure geometry, owner IPC rectangle, +Mosaic `scroll_x`, scene clip log, output positions and injection coordinates; +it must not infer rendered position from logical IPC `rect.x` alone. + +Normal lifecycle: B role → B `xdg_surface` → B `wl_surface`, then A role → A +`xdg_surface` → A `wl_surface`, then toplevel role/surface. On compositor +`popup_done`, log event order separately from client destruction order and +destroy topmost first. Explicit B closure must leave A mapped and interactive. +Owner destruction is tested separately from normal popup closure; compositor +`xdg_toplevel.close` and process teardown are distinct observations. + +The probe uses two private 800×600 headless outputs at `(0,0)` and `(800,0)`. +Configuration A is unmodified `/usr/bin/sway` in `splith`; B is private +SceneFX/Mosaic in `splith`; C is the private guarded binary in `mosaic`. +Every run has private `XDG_RUNTIME_DIR`, Wayland/IPC sockets, persistent +virtual pointer, compositor log, `WAYLAND_DEBUG=1` client log, pointer log, +IPC JSON and screenshots of both outputs. Private SceneFX is loaded only by +the private compositor. No command addresses the desktop session. + +## Scenario acceptance matrix + +`PASS` requires matching protocol, pixels and pointer traces at named sample +points. A popup closing before a hidden-owner sample makes that sample +`INCONCLUSIVE`; a client merely surviving is never proof. `FAIL` records an +observed violated expectation. `PARTIAL` means only part of the stated +evidence exists. Unexecuted cells remain `NOT TESTED`. + +| Scenario | A control | B private splith | C private mosaic | Required evidence | +| --- | --- | --- | --- | --- | +| 1. Both popups visible and interactive, two valid grabs | PASS | PASS | PASS | Distinct button serials; ordered XDG configure/ack/map; A/B pixels and B right-button events | +| 2. B crosses viewport | PASS | PASS | PASS | B pixels on each output in standard layouts; Mosaic B pixels left only, no B hit right; normal right-output target receives click | +| 3. A and B cross viewport | PASS | PASS | PASS | Both colors right in standard layouts, neither right in Mosaic; distinct A and B button targets left | +| 4. Explicit B close, A remains | PASS | PASS | PASS | B disappears; A receives right press/release; no B re-creation or ghost at sampled point | +| 5. Outside click | PASS | PASS | PASS | `popup_done` B then A; client destroys B then A; normal right-output client gets left press/release | +| 6. Tiled owner fully hidden, then camera return | N/A | N/A | PASS | Same owner and popup IDs, both mapped; no A/B pixels or hits while hidden; both return without recreation | +| 6b. Hidden owner with separate click | N/A | N/A | PASS | No hidden popup button; grab dismisses B then A; no return after dismissal | +| 7. Toplevel destruction with A/B open | PASS | PASS | PASS | `xdg_toplevel.close`, then client destroys B, A and toplevel; no `popup_done` required on this path | +| 8. Mosaic → splith with A/B open | N/A | N/A | PASS | Same B responds at global `(900,190)` after layout change; no prior `popup_done` | + +The previously retained source fixtures and three patches must remain +byte-identical. A new private correction, if needed, is a separately named +delta with an explicit reason. Builds, sockets, generated protocol sources, +captures and logs remain outside Git. Output resize/migration, vertical, +negative-origin, scaled/rotated outputs, transaction generations, complete +damage/effects and package ABI are outside this experiment. + +## Reproduction and retained artifacts + +The lab is `/tmp/swayfx-mosaic-nested-grab-If8ofeUu/` (about 54 MB after all +runs). Its `artifacts-sha256.txt` lists 658 measured log, IPC and capture +files; the manifest SHA-256 is +`869f97f2ed8a7babc9db2bae49531a1c45e3aef84e6e799af87f2d8fcedb8a70`. +It was reconstructed from Mosaic +`cd550382b311efdbfe3ed892f4e1d281d852e754` and SceneFX +`37ccd723bef49e6891156ffafce8f549f01446cc`, applying the unchanged +private SceneFX patch, Mosaic patch, then hidden-owner guard in that order. +All three patch SHA-256 values match the [previous fixture](../../tooling/probes/step20c-valid-grab/README.md). +`prepare.sh` rebuilt both private libraries/compositor and generated XDG and +virtual-pointer code; `nested-client.c` and the reused `pointer-daemon.c` were +compiled with `-std=c17`. `/usr/bin/sway` provided the unmodified control. +The [new fixture README](../../tooling/probes/step20c-nested-grab/README.md) +gives clean-checkout commands. Each `run-*` directory holds client protocol, +pointer, `wev`, compositor, IPC and two-output screenshot evidence. The runner +uses short separate `/tmp/mng-*` socket directories recorded in `runtime-path`. + +The client fixture SHA-256 at the measured build is +`2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d`. +The runner was corrected during the probe: its first, long runtime socket +path caused one private `splith` startup to exit before any client appeared, +with `Socket path won't fit into ipc_sockaddr->sun_path` at +`sway/ipc-server.c:142` in the compositor log. `ipc_user_sockaddr()` checks +the `snprintf` result against `sun_path`; the retained aborted directory has +only `config`, `compositor.log` and an empty `wayland-1.lock`. A `SIGSEGV` +process result was reported during the probe, but no exit-status record, +core dump or stack trace is retained to corroborate the signal or locate its +origin. Source inspection identifies a plausible error-path defect: +`sway_abort` invokes the registered `sway_terminate(EXIT_FAILURE)` callback; +with a Wayland display already present, `sway_terminate` calls +`ipc_event_shutdown`, whose listener check dereferences `ipc_client_list`. +The length check runs before `ipc_init` creates that list. This ordering could +explain a null dereference, but no retained backtrace proves that this was the +observed process exit. The overlong path is a confirmed fixture setup error; +the reported `SIGSEGV` and its precise origin remain a separate **unverified +startup incident**. A future isolated diagnostic run could preserve the exit +status and a backtrace; this probe did not do so. +Short `/tmp/mng-*` paths avoided the length failure in subsequent control, +private standard and Mosaic runs. This is a fixture workaround, not a proven +compositor bug fix. The first `close_b` runs used a left click that legitimately +reopened B; they are retained with `-initial` suffix. The revised right click +measures A's continued interaction without reopening B. No private source +patch was changed to obtain these results. + +## Independent post-audit replay + +A fresh disposable laboratory was rebuilt from the pinned commits and the +same three private patches. The reconstructed SceneFX and Mosaic builds, +generated protocol code and both C17 clients succeeded; the Mosaic Meson +model test passed. Twelve representative isolated runs plus one final `open` +smoke run returned successfully. Direct log, +pointer and screenshot checks confirmed both genuine grabs in all three +`visible_click` configurations. With `b_cross`, unmodified SwayFX and private +`splith` each showed **15,860** B-green pixels on HEADLESS-2; private Mosaic +showed **0** there and **17,940** on HEADLESS-1. Mosaic's `(900,190)` click +went to `wev`, not B, while standard controls delivered it to B. With +`both_cross`, Mosaic showed **0** A-yellow and B-green pixels on HEADLESS-2 +and delivered separate left-output right clicks to A and B. In `hidden`, both +colors fell to **0** and returned to their prior counts with one A/B map each; +`hidden_click` dismissed B then A without a hidden popup button. `close_b` +left A receiving right press/release, `outside` logged B then A `popup_done` +and child-first destruction, and `toggle` restored B-green pixels and B's +button delivery on HEADLESS-2 in `splith`. These are fresh confirmations of +named samples, not a broader qualification. The original 658-artifact +manifest was also rechecked with **658 OK, 0 failures**. Its historical +measurements remain described here even if the temporary laboratory is later +removed. The post-audit runner changes affect socket isolation and cleanup, +not popup actions or the private compositor patches. + +## Protocol and hierarchy evidence + +In `run-control-open-visible/client.log`, A used button serial **61** from +TOPLEVEL and B used **66** from POPUP_A. Both respective `GRAB_REQUEST` +entries show `used=61` and `used=66`. `POPUP_A CREATE` records +`parent_xdg=9` (TOPLEVEL); `POPUP_B CREATE` records `parent_xdg=12`, exactly +A's XDG object. The two role configure events were `(50,80,250,170)` and +`(20,60,170,130)`; their `xdg_surface.configure` serials 62 and 67 were +acknowledged before each MAP. Private standard repeated 61→66 in the first +visible run; private Mosaic used distinct real serials **55** and **60**. +`WAYLAND_DEBUG=1` logs corroborate client request order. No protocol error, +immediate grab denial or pre-configure dismissal was observed in the successful +runs. A later right click on B was delivered in all three configurations +(control/private standard button serial 71; Mosaic 65, with releases). + +These are real nested XDG grabs, not merely two transient surfaces: B's parent +is A's `xdg_surface`, A was mapped before B was created, both were mapped +at the `open` capture, and each received pointer interaction. Source colors +are blue TOPLEVEL `(35,68,204)`, yellow A `(224,192,32)` and green B +`(32,208,160)` in the captured RGB images. Surface IDs and event targets are +the primary identity evidence. + +## Visual and pointer observations + +All screenshots are 800×600 for HEADLESS-1 and HEADLESS-2. For `b_cross`, +the compositor configured A at `(200,80,300,170)` relative to TOPLEVEL and B +at `(220,60,300,130)` relative to A. At `open`, both standard configurations +had **15,860** exact B-green pixels on HEADLESS-2; Mosaic had **0** while +retaining **17,940** B-green pixels on HEADLESS-1. At global `(700,190)`, B +received right press/release in all three cases. In the control, moving to +global `(900,190)` yielded B-local `(278,25)` and a further right press/release. +The private standard case matched it. In Mosaic, B received a leave, no +right-output B enter/button, and the ordinary `wev` on HEADLESS-2 received +the right press/release. The Mosaic grab then dismissed B and A. Thus a +legitimate grab's dismissal is separated from a hit on an invisible B. + +For `both_cross`, A was configured `(350,80,350,170)` and B +`(160,60,300,130)`. At `open`, both standard configurations had **6,120** +A-yellow and **27,560** B-green pixels on HEADLESS-2. Mosaic had **0** of +both colors there, while retaining **30,080** A-yellow and **6,240** B-green +pixels on HEADLESS-1. A right click at global `(700,190)` targeted A and +one at `(780,190)` targeted B in Mosaic, confirmed by separate surface logs; +the control and private standard also targeted A then B. At `(900,190)`, +only the standard layouts delivered the button to B; Mosaic's ordinary +right-output client received it and B/A were dismissed. These points test +two hierarchy levels rather than treating the whole menu as one colored area. + +On explicit `close_b`, the `POPUP_B DESTROY` entry preceded a right click on +A in all three configurations. A received press and release and B did not +reappear. The sampled pixel at B's former overlap changed from green to A's +yellow. On the outside click `(900,400)`, every configuration logged +`POPUP_B POPUP_DONE`, then `POPUP_A POPUP_DONE`, then client destruction B, +then A. `wev` got the left press/release. On compositor IPC `kill` of the +toplevel, the client logged `TOPLEVEL CLOSE_REQUEST`, destroyed B and A, then +destroyed TOPLEVEL; no `popup_done` preceded this normal owner teardown. + +## Hidden owner and standard-layout return + +The Mosaic owner remained IPC container ID **8**, logical rectangle +`(640,25,640,575)`, with `scroll_x` **400→1280→400**. Private compositor log +`PROBE_POPUP_HIDDEN_OWNER` recorded applied owner `(-640,0,640,600)` against +viewport `(0,0,800,600)`, an empty intersection. During `hidden` and +`hidden-hover`, both A-yellow and B-green exact pixel counts on HEADLESS-1 +were **0**, down from **42,200** and **17,940** at `open`. No popup enter, +button, `popup_done`, reconfigure, object re-creation or client exit occurred +before the hidden measurement. After camera return, both counts returned +to **42,200** and **17,940** with the same XDG object IDs. In the separate +`hidden_click` case, right click at `(100,190)` produced no A/B button but +did produce `popup_done` B then A; the returned frame correctly did not +restore them after dismissal. This is sampled guard and grab behavior, not +a universal offscreen-popup policy. + +The `toggle` run left Mosaic with B still mapped. HEADLESS-2 local +`(100,190)` changed from ordinary `(102,102,102)` to B-green +`(32,208,160)` in `splith`, and B then received right press/release at global +`(900,190)` without a prior `popup_done`. The private guard did not persist +into the sampled standard-layout path. + +## Classification and remaining gates + +The specified horizontal, two-output nested-grab experiment is **FEASIBLE** +at its measured placements and event points. This does **not** establish +continuous clipping between sample points or a general popup dismissal policy. +The startup incident above does not affect the later runs, which started their +clients, mapped both popups and produced protocol, pointer and pixel evidence. +No architectural guarantee is established for +universal grab behavior, complete damage/stale-pixel coverage, decorations, +shadows/blur/saved buffers, vertical/negative/scaled/rotated outputs, +output resize/migration, transaction-generation agreement, SceneFX ABI or +package maintenance. Each of those remains **NOT TESTED** in this probe. +The private SceneFX symbols and owner guard remain disposable; no source +or patch enters production. STEP20C remains NEXT and cross-output Mosaic +remains NOT YET SUPPORTED. The smallest next bounded experiment should +exercise a changed output geometry with these same nested grabbed surfaces +and compare the control, private standard and private Mosaic paths before any +production integration decision. diff --git a/tooling/probes/step20c-nested-grab/README.md b/tooling/probes/step20c-nested-grab/README.md new file mode 100644 index 0000000..0196d10 --- /dev/null +++ b/tooling/probes/step20c-nested-grab/README.md @@ -0,0 +1,97 @@ +# STEP20C nested valid-grab fixture (EXPERIMENTAL) + +This source-only fixture reproduces the [nested popup probe](../../../docs/design/STEP20C_NESTED_VALID_GRAB_PROBE.md). +It does not enter the Mosaic build, production patch or package. It reuses +the unchanged `pointer-daemon.c`, virtual-pointer XML and three private +patches from [`../step20c-valid-grab/`](../step20c-valid-grab/README.md). + +## Bases and dependencies + +| Input | Pinned base / version | SHA-256 | +| --- | --- | --- | +| Mosaic private patch | Mosaic `cd550382b311efdbfe3ed892f4e1d281d852e754` | `d416f6413ea097a48a44d231e50ffba363914f46c8687f0ea2e4dcacd5f31fe9` | +| SceneFX private patch | SceneFX `37ccd723bef49e6891156ffafce8f549f01446cc` (0.5) | `c74c515b98da8f5f637e260e6a3755c7a75bd78d1c39e54a67850f1c383e30e7` | +| Hidden-owner guard | After Mosaic private patch | `35785a8f3e863b3c1fd87932d8ff0c2949b03cb04382cc34e1126170968f802b` | +| Persistent virtual pointer | Previous fixture | `a00fcedc38ccb7ca4c25c982d9f38e8bde3168a3833f0c9cf2056e841d720938` | +| Virtual-pointer XML | wlr-protocols `b010a03648b88d143236de193bddbfea0c08bc84` | `3ff6d540be0bc5228195bf072bde42117ea17945a5c2061add5d3cf97d6bb524` | +| `nested-client.c` | This fixture | `2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d` | +| `prepare.sh` | This fixture, post-audit failed-build cleanup | `55fe81b7f17fb1cf8af7089a0034864910c49feccbe4cfe22e857c98b9db0dde` | +| `run.py` | This fixture, post-audit socket isolation and cleanup | `72ff17d8042edf3265b3a0ff91f0bc4805001b1438c4ecadfe29ead76278c9be` | + +The measured machine had SwayFX 0.6, wlroots 0.20.2, SceneFX 0.5.0, +Wayland client 1.26.0 and wayland-protocols 1.49. The installed XDG Shell +XML declares version 7; the fixture binds `xdg_wm_base` version 3, like +the previous client. Required local tools include Git, Meson, Ninja, C17 +compiler, `pkg-config`, `wayland-scanner`, `kitty`, `swaymsg`, `grim`, `wev` +and Python. `prepare.sh` downloads the pinned SceneFX source into a disposable +directory; it does not install anything. + +## Differences from the earlier fixture + +`nested-client.c` creates three distinct surfaces and XDG roles. Popup A's +parent is the toplevel `xdg_surface`; popup B's parent is A's `xdg_surface`. +Each popup uses a separate, logged, real pointer-button serial and maps only +after its own configure/ack. The client logs all surface object IDs, events, +local pointer coordinates, `popup_done` and child-first destruction. It accepts +`close_b` and `close_owner` on stdin. The runner exercises three placements: +`visible`, `b_cross` and `both_cross`. No prior source file is modified. + +## Fresh replay + +Run from a fresh repository checkout at or after the preserved fixture commit. +No old `/tmp` laboratory is needed. The script prints `LAB=/tmp/...` after +building a private SceneFX library, private guarded compositor, new client and +unchanged virtual-pointer daemon. Save that path for subsequent commands. + +```sh +prepare_log=$(mktemp /tmp/mng-prepare-XXXXXXXX) +sh tooling/probes/step20c-nested-grab/prepare.sh | tee "$prepare_log" +LAB=$(sed -n 's/^LAB=//p' "$prepare_log") +test -d "$LAB" +python tooling/probes/step20c-nested-grab/run.py "$LAB" control visible_click visible +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_standard visible_click visible +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic visible_click visible +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic crossing b_cross +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic crossing both_cross +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic close_b visible +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic outside visible +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic hidden b_cross +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic hidden_click b_cross +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic owner visible +python tooling/probes/step20c-nested-grab/run.py "$LAB" private_mosaic toggle b_cross +``` + +For a full comparison, repeat `crossing`, `close_b`, `outside` and `owner` +with `control` and `private_standard` as in the report. Each case name is +single-use in a lab; use a new lab or rename an earlier case directory to +retain both iterations. `run.py` uses a short, new `/tmp/mng-*` runtime path +because Unix socket paths have a strict length limit; each case's +`runtime-path` records the path for audit. The script removes inherited desktop +Wayland/IPC variables, uses explicit `swaymsg -s`, terminates and reaps only +its own process groups, then removes its runtime directory. It never reloads +or replaces the daily compositor. + +The retained historical runs used the earlier runner and preparation hashes; +the current `prepare.sh` removes an incomplete laboratory on failure, while +`run.py` adds an IPC path-length bound, clears inherited +`I3SOCK`, reaps child processes and removes its runtime directory. It changes +no popup protocol action, capture point or experimental compositor patch. + +Each case contains `client.log` with `WAYLAND_DEBUG=1`, `pointer.log`, +`compositor.log`, `wev.log`, `tree-*.json`, `outputs-*.json` and both output +PNGs. Read `POPUP_A/B CREATE`, `GRAB_REQUEST`, `ROLE_CONFIGURE`, +`XDG_CONFIGURE`, `MAP`, `POINTER_*`, `POPUP_DONE` and `DESTROY` in order. +Compare screenshots and traces: a right-output pixel alone does not prove +which surface received a button, and `popup_done` under a legitimate grab +does not prove an invisible surface hit. The `open` scenario intentionally +only captures mapping; `visible_click` also presses B. A case returning zero +is not automatically a scientific PASS. + +## Cleanup + +After all per-case processes have exited, check `runtime-path` files for any +directory left by an interrupted run and remove only those recorded `/tmp/mng-*` +directories, then remove the named `LAB` directory and `prepare_log`. Preserve +the laboratory while reviewing evidence. Generated +protocol files, binaries, private libraries, sockets, logs and images remain +outside Git. The source fixture and original patches stay in Git. diff --git a/tooling/probes/step20c-nested-grab/nested-client.c b/tooling/probes/step20c-nested-grab/nested-client.c new file mode 100644 index 0000000..3e504e2 --- /dev/null +++ b/tooling/probes/step20c-nested-grab/nested-client.c @@ -0,0 +1,263 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "xdg-shell-client.h" + +/* EXPERIMENTAL CONTRACT: every popup owns distinct XDG objects. B's actual + * parent is A's xdg_surface; logs name object IDs as well as colors. */ +struct surface { + const char *name; + struct wl_surface *wl; + struct xdg_surface *xdg; + struct xdg_popup *popup; + bool configured, mapped, done; + int width, height, anchor_x, anchor_y; + uint32_t color; +}; +static struct wl_display *display; +static struct wl_compositor *compositor; +static struct wl_shm *shm; +static struct xdg_wm_base *wm; +static struct wl_seat *seat; +static struct wl_pointer *pointer; +static struct xdg_toplevel *top_role; +static struct surface top = {.name="TOPLEVEL", .width=420, .height=320, .color=0x002344cc}; +static struct surface a = {.name="POPUP_A", .width=300, .height=170, .color=0x00e0c020}; +static struct surface b = {.name="POPUP_B", .width=300, .height=130, .color=0x0020d0a0}; +static volatile sig_atomic_t running = 1; +static struct surface *entered; + +static void draw(struct surface *s) { + size_t bytes = (size_t)s->width * (size_t)s->height * 4; + char path[] = "/tmp/mosaic-nested-buffer-XXXXXX"; + int fd = mkstemp(path); + if (fd < 0 || ftruncate(fd, (off_t)bytes) < 0) { perror("buffer"); exit(1); } + unlink(path); + uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (pixels == MAP_FAILED) { perror("mmap"); exit(1); } + for (size_t i=0; icolor; + struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, (int)bytes); + struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, s->width, s->height, + s->width*4, WL_SHM_FORMAT_XRGB8888); + wl_surface_attach(s->wl, buffer, 0, 0); + wl_surface_damage_buffer(s->wl, 0, 0, s->width, s->height); + wl_surface_commit(s->wl); + wl_buffer_destroy(buffer); + wl_shm_pool_destroy(pool); + munmap(pixels, bytes); + close(fd); + s->mapped = true; + fprintf(stderr, "%s MAP wl=%u xdg=%u\n", s->name, + wl_proxy_get_id((struct wl_proxy *)s->wl), wl_proxy_get_id((struct wl_proxy *)s->xdg)); +} +static void on_ping(void *data, struct xdg_wm_base *base, uint32_t serial) { + xdg_wm_base_pong(base, serial); +} +static const struct xdg_wm_base_listener wm_listener = {.ping=on_ping}; +static void on_xdg_configure(void *data, struct xdg_surface *xdg, uint32_t serial) { + struct surface *s=data; + fprintf(stderr, "%s XDG_CONFIGURE serial=%u ack\n", s->name, serial); + xdg_surface_ack_configure(xdg, serial); + s->configured=true; + if (!s->mapped && !s->done) draw(s); +} +static const struct xdg_surface_listener xdg_listener = {.configure=on_xdg_configure}; +static void on_top_configure(void *data, struct xdg_toplevel *role, int32_t w, + int32_t h, struct wl_array *states) { + fprintf(stderr, "TOPLEVEL ROLE_CONFIGURE w=%d h=%d\n", w,h); +} +static void on_top_close(void *data, struct xdg_toplevel *role) { + fprintf(stderr, "TOPLEVEL CLOSE_REQUEST\n"); running=0; +} +static const struct xdg_toplevel_listener top_listener = { + .configure=on_top_configure, .close=on_top_close, +}; +static void on_popup_configure(void *data, struct xdg_popup *role, int32_t x, + int32_t y, int32_t w, int32_t h) { + struct surface *s=data; + fprintf(stderr, "%s ROLE_CONFIGURE x=%d y=%d w=%d h=%d\n",s->name,x,y,w,h); +} +static void on_popup_done(void *data, struct xdg_popup *role) { + struct surface *s=data; + s->done=true; + s->mapped=false; + fprintf(stderr, "%s POPUP_DONE role=%u\n",s->name,wl_proxy_get_id((struct wl_proxy *)role)); +} +static const struct xdg_popup_listener popup_listener = { + .configure=on_popup_configure, .popup_done=on_popup_done, +}; +/* WHY: XDG requires child-first destruction even if the compositor sends + * dismissals for multiple levels in one dispatch batch. */ +static void destroy_popup(struct surface *s) { + if (!s->popup) return; + fprintf(stderr,"%s DESTROY role=%u xdg=%u wl=%u\n",s->name, + wl_proxy_get_id((struct wl_proxy *)s->popup), + wl_proxy_get_id((struct wl_proxy *)s->xdg), + wl_proxy_get_id((struct wl_proxy *)s->wl)); + xdg_popup_destroy(s->popup); + xdg_surface_destroy(s->xdg); + wl_surface_destroy(s->wl); + s->popup=NULL; s->xdg=NULL; s->wl=NULL; s->mapped=false; + if (entered==s) entered=NULL; +} +static void collect_dismissals(void) { + if (b.done) destroy_popup(&b); + if (a.done) { + destroy_popup(&b); + destroy_popup(&a); + } +} +static void open_popup(struct surface *s, struct surface *parent, uint32_t serial) { + if (s->popup || !parent->mapped || !serial) return; + 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); + struct xdg_positioner *pos=xdg_wm_base_create_positioner(wm); + xdg_positioner_set_size(pos,s->width,s->height); + xdg_positioner_set_anchor_rect(pos,s->anchor_x,s->anchor_y,1,1); + xdg_positioner_set_anchor(pos,XDG_POSITIONER_ANCHOR_TOP_LEFT); + xdg_positioner_set_gravity(pos,XDG_POSITIONER_GRAVITY_BOTTOM_RIGHT); + s->popup=xdg_surface_get_popup(s->xdg,parent->xdg,pos); + xdg_popup_add_listener(s->popup,&popup_listener,s); + fprintf(stderr,"%s CREATE wl=%u xdg=%u role=%u parent=%s parent_xdg=%u\n", + s->name,wl_proxy_get_id((struct wl_proxy *)s->wl), + wl_proxy_get_id((struct wl_proxy *)s->xdg), + wl_proxy_get_id((struct wl_proxy *)s->popup),parent->name, + wl_proxy_get_id((struct wl_proxy *)parent->xdg)); + fprintf(stderr,"%s GRAB_REQUEST origin=wl_pointer.button target=%s serial=%u used=%u\n", + s->name,parent->name,serial,serial); + xdg_popup_grab(s->popup,seat,serial); + wl_surface_commit(s->wl); /* Initial bufferless commit requests configure. */ + xdg_positioner_destroy(pos); +} +static struct surface *which(struct wl_surface *wl) { + if (wl==top.wl) return ⊤ + if (wl==a.wl && wl) return &a; + if (wl==b.wl && wl) return &b; + return NULL; +} +static void on_enter(void *data,struct wl_pointer *ptr,uint32_t serial, + struct wl_surface *wl,wl_fixed_t x,wl_fixed_t y) { + entered=which(wl); + fprintf(stderr,"POINTER_ENTER target=%s wl=%u serial=%u x=%.2f y=%.2f\n", + entered?entered->name:"OTHER",wl_proxy_get_id((struct wl_proxy *)wl),serial, + wl_fixed_to_double(x),wl_fixed_to_double(y)); +} +static void on_leave(void *data,struct wl_pointer *ptr,uint32_t serial, + struct wl_surface *wl) { + struct surface *s=which(wl); + fprintf(stderr,"POINTER_LEAVE target=%s serial=%u\n",s?s->name:"OTHER",serial); + if (entered==s) entered=NULL; +} +static void on_motion(void *data,struct wl_pointer *ptr,uint32_t time, + wl_fixed_t x,wl_fixed_t y) { + fprintf(stderr,"POINTER_MOTION target=%s x=%.2f y=%.2f\n", + entered?entered->name:"OTHER",wl_fixed_to_double(x),wl_fixed_to_double(y)); +} +static void on_button(void *data,struct wl_pointer *ptr,uint32_t serial, + uint32_t time,uint32_t button,uint32_t state) { + fprintf(stderr,"POINTER_BUTTON target=%s serial=%u button=%u state=%u\n", + entered?entered->name:"OTHER",serial,button,state); + if (state!=WL_POINTER_BUTTON_STATE_PRESSED || button!=272) return; + if (entered==&top && !a.popup) open_popup(&a,&top,serial); + else if (entered==&a && a.mapped && !b.popup) open_popup(&b,&a,serial); +} +static void on_axis(void *data,struct wl_pointer *ptr,uint32_t t,uint32_t axis,wl_fixed_t v) {} +static void on_frame(void *data,struct wl_pointer *ptr) {} +static const struct wl_pointer_listener pointer_listener={ + .enter=on_enter,.leave=on_leave,.motion=on_motion,.button=on_button, + .axis=on_axis,.frame=on_frame, +}; +static void on_caps(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 on_seat_name(void *data,struct wl_seat *s,const char *name) {} +static const struct wl_seat_listener seat_listener={.capabilities=on_caps,.name=on_seat_name}; +static void on_global(void *data,struct wl_registry *reg,uint32_t name, + const char *interface,uint32_t version) { + if (!strcmp(interface,wl_compositor_interface.name)) + compositor=wl_registry_bind(reg,name,&wl_compositor_interface,version<4?version:4); + else if (!strcmp(interface,wl_shm_interface.name)) + shm=wl_registry_bind(reg,name,&wl_shm_interface,1); + else if (!strcmp(interface,xdg_wm_base_interface.name)) + wm=wl_registry_bind(reg,name,&xdg_wm_base_interface,version<3?version:3); + else if (!strcmp(interface,wl_seat_interface.name)) { + seat=wl_registry_bind(reg,name,&wl_seat_interface,version<5?version:5); + wl_seat_add_listener(seat,&seat_listener,NULL); + } +} +static void on_removed(void *data,struct wl_registry *reg,uint32_t name) {} +static const struct wl_registry_listener registry_listener={.global=on_global,.global_remove=on_removed}; +static void stop(int sig) { running=0; } +int main(int argc,char **argv) { + signal(SIGTERM,stop); + const char *placement=argc>1?argv[1]:"b_cross"; + if (!strcmp(placement,"visible")) { + a.anchor_x=50; a.anchor_y=80; a.width=250; + b.anchor_x=20; b.anchor_y=60; b.width=170; + } else if (!strcmp(placement,"both_cross")) { + a.anchor_x=350; a.anchor_y=80; a.width=350; + b.anchor_x=160; b.anchor_y=60; b.width=300; + } else if (!strcmp(placement,"b_cross")) { + a.anchor_x=200; a.anchor_y=80; a.width=300; + b.anchor_x=220; b.anchor_y=60; b.width=300; + } else { fprintf(stderr,"invalid placement\n"); return 2; } + display=wl_display_connect(NULL); + if (!display) { fprintf(stderr,"cannot connect Wayland\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 || !seat || !pointer) { + fprintf(stderr,"missing Wayland globals/pointer\n"); return 1; + } + xdg_wm_base_add_listener(wm,&wm_listener,NULL); + top.wl=wl_compositor_create_surface(compositor); + top.xdg=xdg_wm_base_get_xdg_surface(wm,top.wl); + xdg_surface_add_listener(top.xdg,&xdg_listener,&top); + top_role=xdg_surface_get_toplevel(top.xdg); + xdg_toplevel_add_listener(top_role,&top_listener,NULL); + xdg_toplevel_set_title(top_role,"Mosaic Nested Grab Probe"); + xdg_toplevel_set_app_id(top_role,"org.mosaic.NestedGrabProbe"); + fprintf(stderr,"TOPLEVEL CREATE wl=%u xdg=%u role=%u placement=%s\n", + wl_proxy_get_id((struct wl_proxy *)top.wl), + wl_proxy_get_id((struct wl_proxy *)top.xdg), + wl_proxy_get_id((struct wl_proxy *)top_role),placement); + wl_surface_commit(top.wl); + 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,500); + if (ready<0) { if (errno==EINTR) continue; perror("poll"); break; } + if (fds[0].revents & (POLLIN|POLLERR|POLLHUP)) { + if (wl_display_dispatch(display)<0) break; + collect_dismissals(); + } + if (fds[1].revents & POLLIN) { + 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_owner",11)) { fprintf(stderr,"COMMAND close_owner\n"); running=0; } + } + } + } + destroy_popup(&b); destroy_popup(&a); + fprintf(stderr,"TOPLEVEL DESTROY\n"); + xdg_toplevel_destroy(top_role); xdg_surface_destroy(top.xdg); wl_surface_destroy(top.wl); + wl_display_flush(display); + wl_display_disconnect(display); + return 0; +} diff --git a/tooling/probes/step20c-nested-grab/prepare.sh b/tooling/probes/step20c-nested-grab/prepare.sh new file mode 100644 index 0000000..b5e0008 --- /dev/null +++ b/tooling/probes/step20c-nested-grab/prepare.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# EXPERIMENTAL: build all private inputs in a fresh temporary laboratory. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-valid-grab" +lab=$(mktemp -d /tmp/swayfx-mosaic-nested-grab-XXXXXXXX) +# CONTRACT: a failed preparation leaves no unreported private build or socket +# tree; a successful one transfers LAB ownership to the caller. +trap 'rm -rf -- "$lab"' 0 +mkdir "$lab/mosaic" "$lab/scenefx" +git -C "$repo" archive cd550382b311efdbfe3ed892f4e1d281d852e754 | tar -xf - -C "$lab/mosaic" +git clone --quiet https://github.com/wlrfx/scenefx.git "$lab/scenefx-git" +git -C "$lab/scenefx-git" checkout --quiet --detach 37ccd723bef49e6891156ffafce8f549f01446cc +git -C "$lab/scenefx-git" archive HEAD | tar -xf - -C "$lab/scenefx" +(cd "$lab/scenefx" && git apply --check "$fixture/scenefx-popup-plus-base.patch" && git apply "$fixture/scenefx-popup-plus-base.patch") +(cd "$lab/mosaic" && git apply --check "$fixture/mosaic-popup-plus-base.patch" && git apply "$fixture/mosaic-popup-plus-base.patch" && git apply --check "$fixture/hidden-guard.patch" && git apply "$fixture/hidden-guard.patch") +meson setup "$lab/scenefx-build" "$lab/scenefx" -Dexamples=false -Dwerror=false +meson compile -C "$lab/scenefx-build" -j 8 +PKG_CONFIG_PATH="$lab/scenefx-build/meson-uninstalled" meson setup "$lab/mosaic-build" "$lab/mosaic" \ + -Dmosaic-package=true -Ddefault-wallpaper=false -Dswaybar=false -Dswaynag=false \ + -Dman-pages=disabled -Dsd-bus-provider=libsystemd -Dwerror=false +PKG_CONFIG_PATH="$lab/scenefx-build/meson-uninstalled" meson compile -C "$lab/mosaic-build" -j 8 +wayland-scanner client-header /usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml "$lab/xdg-shell-client.h" +wayland-scanner private-code /usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml "$lab/xdg-shell-protocol.c" +wayland-scanner client-header "$fixture/wlr-virtual-pointer-unstable-v1.xml" "$lab/virtual-pointer-client.h" +wayland-scanner private-code "$fixture/wlr-virtual-pointer-unstable-v1.xml" "$lab/virtual-pointer-protocol.c" +cc -std=c17 -Wall -Wextra -Wno-unused-parameter -I"$lab" \ + "$repo/tooling/probes/step20c-nested-grab/nested-client.c" "$lab/xdg-shell-protocol.c" \ + -o "$lab/nested-client" $(pkg-config --cflags --libs wayland-client) +cc -std=c17 -Wall -Wextra -Wno-unused-parameter -I"$lab" \ + "$fixture/pointer-daemon.c" "$lab/virtual-pointer-protocol.c" \ + -o "$lab/pointer-daemon" $(pkg-config --cflags --libs wayland-client) +sha256sum "$fixture"/*.patch "$repo/tooling/probes/step20c-nested-grab/nested-client.c" > "$lab/source-sha256.txt" +trap - 0 +printf 'LAB=%s\n' "$lab" diff --git a/tooling/probes/step20c-nested-grab/run.py b/tooling/probes/step20c-nested-grab/run.py new file mode 100644 index 0000000..f278ee1 --- /dev/null +++ b/tooling/probes/step20c-nested-grab/run.py @@ -0,0 +1,263 @@ +"""EXPERIMENTAL isolated two-output nested XDG grab runner. + +Usage: python run.py LAB {control,private_standard,private_mosaic} SCENARIO PLACEMENT +SCENARIO: open, visible_click, crossing, close_b, outside, hidden, hidden_click, owner, toggle +PLACEMENT: visible, b_cross, both_cross +""" +import json +import os +import pathlib +import shutil +import signal +import subprocess +import sys +import tempfile +import time + +LAB = pathlib.Path(sys.argv[1]).resolve() +case, scenario, placement = sys.argv[2:5] +assert case in ('control', 'private_standard', 'private_mosaic') +assert scenario in ('open', 'visible_click', 'crossing', 'close_b', 'outside', 'hidden', + 'hidden_click', 'owner', 'toggle') +assert placement in ('visible', 'b_cross', 'both_cross') +assert LAB.is_dir() and (LAB / 'nested-client').is_file() +run = LAB / f'run-{case}-{scenario}-{placement}' +run.mkdir(mode=0o700, exist_ok=False) +runtime = pathlib.Path(tempfile.mkdtemp(prefix='mng-', dir='/tmp')) +# WHY: keep both Unix socket names below sockaddr_un.sun_path even if LAB is +# long; the compositor derives its IPC path from XDG_RUNTIME_DIR. +if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108: + raise RuntimeError('private IPC socket path exceeds Linux sockaddr_un.sun_path') +(run / 'runtime-path').write_text(str(runtime) + '\n') +(run / 'config').write_text( + 'output HEADLESS-1 resolution 800x600 position 0 0\n' + 'output HEADLESS-2 resolution 800x600 position 800 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') +env.pop('WAYLAND_DISPLAY', None) +env.pop('SWAYSOCK', None) +env.pop('I3SOCK', None) +env.pop('LD_LIBRARY_PATH', None) +compositor_env = env.copy() +if case == 'control': + binary = pathlib.Path('/usr/bin/sway') +else: + binary = LAB / 'mosaic-build/sway/swayfx-mosaic' + compositor_env['LD_LIBRARY_PATH'] = str(LAB / 'scenefx-build') +files = [] +children = [] + + +def spawn(argv, name, *, debug=False, stdin=None): + log = (run / 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=stdin, stdout=log, + stderr=subprocess.STDOUT, text=True, + start_new_session=True) + children.append(proc) + return proc + + +def wait(fn, seconds=12): + end = time.monotonic() + seconds + while time.monotonic() < end: + result = fn() + if result: + return result + if compositor.poll() is not None: + raise RuntimeError(f'compositor exited {compositor.returncode}') + time.sleep(.05) + raise RuntimeError('timeout waiting for probe event') + + +def ipc(*args): + return json.loads(subprocess.run(['swaymsg', '-s', str(sock), *args], + env=env, text=True, capture_output=True, check=True, + timeout=5).stdout) + + +def find(node, predicate): + if isinstance(node, dict): + if predicate(node): + return node + for child in node.get('nodes', []) + node.get('floating_nodes', []): + result = find(child, predicate) + if result: + return result + return None + + +def tree(): + return ipc('-t', 'get_tree') + + +def ws(): + 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 logged(s): + path = run / 'client.log' + return path.exists() and s in path.read_text() + + +def capture(tag): + (run / f'tree-{tag}.json').write_text(json.dumps(tree(), indent=2)) + (run / f'outputs-{tag}.json').write_text(json.dumps(ipc('-t', 'get_outputs'), indent=2)) + for output, label in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): + subprocess.run(['grim', '-o', output, str(run / f'{label}-{tag}.png')], + env=env, check=True, timeout=8, stdout=subprocess.DEVNULL) + + +def inject(action, x, y): + marker = f'INJECT_DONE {action} {x},{y}' + pointer.stdin.write(f'{action} {x} {y}\n') + pointer.stdin.flush() + wait(lambda: marker in (run / 'pointer.log').read_text()) + + +def command(value): + client.stdin.write(value + '\n') + client.stdin.flush() + wait(lambda: f'COMMAND {value}' in (run / 'client.log').read_text()) + + +compositor_log = (run / 'compositor.log').open('w') +files.append(compositor_log) +compositor = 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) + pointer = spawn([str(LAB / 'pointer-daemon')], 'pointer.log', + stdin=subprocess.PIPE) + wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text()) + ipc('workspace 1') + ipc('layout mosaic' if case == 'private_mosaic' else 'layout splith') + spawn(['kitty', '--class', 'A', '-o', 'background=#aa2222', + '/usr/bin/sleep', '90'], 'A.log') + wait(lambda: len(ws()['nodes']) >= 1) + client = spawn([str(LAB / 'nested-client'), placement], 'client.log', + debug=True, stdin=subprocess.PIPE) + wait(lambda: owner() and logged('TOPLEVEL MAP')) + for label in ('C', 'D'): + spawn(['kitty', '--class', label, '/usr/bin/sleep', '90'], label + '.log') + time.sleep(.35) + ipc('workspace 2') + spawn(['stdbuf', '-oL', 'wev'], 'wev.log') + time.sleep(.5) + ipc('workspace 1') + ipc('[app_id="org.mosaic.NestedGrabProbe"] focus') + if case == 'private_mosaic': + current = ws()['mosaic']['rows'][0]['scroll_x'] + ipc(f'layout mosaic scroll {400-current:g}') + time.sleep(.4) + o = owner() + scroll = ws()['mosaic']['rows'][0]['scroll_x'] if case == 'private_mosaic' else 0 + rendered_x = o['rect']['x'] - scroll + top_x = int(max(40, min(760, rendered_x + 100))) + inject('left', top_x, 150) + wait(lambda: logged('POPUP_A MAP')) + anchor_a = {'visible': 50, 'b_cross': 200, 'both_cross': 350}[placement] + a_x = int(max(40, min(760, rendered_x + anchor_a + 40))) + inject('left', a_x, 150) + wait(lambda: logged('POPUP_B MAP')) + time.sleep(.3) + capture('open') + print('CHAIN_OPEN', case, scenario, placement, 'owner_id', o['id'], + 'owner_rect', o['rect'], 'scroll', scroll, + 'top_click', top_x, 'a_click', a_x, + 'client_alive', client.poll() is None, flush=True) + if scenario == 'visible_click': + anchor_b = 20 + b_x = int(max(40, min(760, rendered_x + anchor_a + anchor_b + 40))) + inject('right', b_x, 190) + time.sleep(.2) + capture('child-click') + print('B_CLICK_POINT', b_x, 190, flush=True) + elif scenario == 'crossing': + actions = [('move', 700, 190), ('right', 700, 190)] + if placement == 'both_cross': + actions += [('move', 780, 190), ('right', 780, 190)] + actions += [('move', 900, 190), ('right', 900, 190)] + for action, x, y in actions: + inject(action, x, y) + time.sleep(.2) + capture(f'{action}-{x}') + elif scenario == 'close_b': + command('close_b') + time.sleep(.3) + capture('closed-b') + inject('right', a_x, 150) + time.sleep(.25) + capture('parent-click') + elif scenario == 'outside': + inject('left', 900, 400) + time.sleep(.4) + capture('outside') + elif scenario in ('hidden', 'hidden_click'): + assert case == 'private_mosaic' + ipc('layout mosaic scroll 880') + time.sleep(.5) + capture('hidden') + print('HIDDEN_STATE', ws()['mosaic'], 'owner_id', owner()['id'], + 'a_done', logged('POPUP_A POPUP_DONE'), + 'b_done', logged('POPUP_B POPUP_DONE'), flush=True) + inject('move', 100, 190) + time.sleep(.2) + capture('hidden-hover') + if scenario == 'hidden_click': + inject('right', 100, 190) + time.sleep(.2) + capture('hidden-click') + ipc('layout mosaic scroll -880') + time.sleep(.4) + capture('returned') + elif scenario == 'owner': + ipc(f'[con_id={o["id"]}] kill') + time.sleep(.4) + capture('owner-destroyed') + elif scenario == 'toggle': + assert case == 'private_mosaic' + ipc('layout splith') + time.sleep(.4) + capture('standard') + inject('right', 900, 190) + time.sleep(.2) + capture('standard-click') + print('FINAL', 'a_done', logged('POPUP_A POPUP_DONE'), + 'b_done', logged('POPUP_B POPUP_DONE'), + 'client_alive', client.poll() is None, flush=True) +finally: + for child in children: + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + if compositor is not None and compositor.poll() is None: + os.killpg(compositor.pid, signal.SIGTERM) + # CONTRACT: only process groups started by this runner are terminated. + # Reap them before removing their private socket directory. + 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) + for file in files: + file.close() + shutil.rmtree(runtime)