docs(mosaic): preserve nested XDG valid-grab probe

This commit is contained in:
fy59 2026-10-11 09:32:09 +02:00
parent 285239a58e
commit 52174ceb6f
10 changed files with 950 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,263 @@
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <wayland-client.h>
#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; i<bytes/4; ++i) pixels[i] = s->color;
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, (int)bytes);
struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, 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 &top;
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,&registry_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;
}

View file

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

View file

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