swayfx-mosaic/docs/design/STEP20C_VALID_GRAB_HIDDEN_OWNER_PROBE.md

25 KiB
Raw Permalink Blame History

STEP20C valid grab and hidden popup owner probe

Status: EXPERIMENTAL / PARTIALLY_FEASIBLE — valid-grab fixture demonstrated; hidden-owner leak found in the retained private candidate and closed at sampled points by a further private guard. This is an isolated feasibility probe, not a production STEP20C implementation or a multi-output support claim. The canonical architecture, UX, limitations, and roadmap retain authority.

Problem and existing evidence

SwayFX attaches a root XDG popup scene below root->layers.popup, outside its owner's tiled scene tree. The earlier private SceneFX inherited clip, applied to the tiled tree alone, therefore allowed the popup to render and receive pointer events on HEADLESS-2. The later popup ownership probe applied a clip to the root popup scene derived from its Mosaic owner's applied tiled viewport. Sampled visible and clipped-away root and nested popup points behaved as intended on two horizontal headless outputs; a standard splith return restored sampled ordinary behavior. That probe's GTK request used xdg_popup.grab(wl_seat, 0), so valid-grab behavior remains NOT TESTED. Its attempt to hide the owner changed focus and dismissed the popup before measurement; a fully hidden owner remains NOT TESTED.

The relevant paths are sway/desktop/xdg_shell.c (owner, popup scene creation and unconstrain), sway/desktop/transaction.c (applied tiled and popup scene positions), sway/input/cursor.c (scene hit traversal), wlroots 0.20 XDG shell grab and pointer handling, and private SceneFX 0.5 inherited render/hit/damage clipping. XDG unconstrain currently reads pending content geometry, while the private clip reads applied scene/current state. Their agreement across transaction generations has not been proved.

Hypotheses and expected boundary

  1. A popup created after a real wl_pointer.button serial and xdg_popup.grab(seat, serial) remains interactive where visible. At a clipped-away scene position, its surface receives no pointer enter or button. A valid grab may still dismiss the popup or consume an outside click; compare this with unmodified SwayFX instead of assuming click-through.
  2. A root popup can remain mapped after its tiled Mosaic owner is translated entirely outside the applied viewport. The owner retains the same Sway container identity and workspace. The private clip should then prevent popup pixels and scene hits outside the owner's viewport. If popup_done, unmap, focus change or client teardown occurs first, the test is inconclusive for clipping.
  3. Returning the owner to view or leaving Mosaic restores the appropriate sampled popup rendering/input without stale pixels. Comprehensive transaction, damage, effects and output-geometry coverage remains a later gate.

Experimental protocol and acceptance

Use a small Wayland client with a real XDG toplevel and popup. The client must log pointer enter/leave, both button codes, the exact serial received in a wl_pointer.button, the same serial passed to xdg_popup.grab, XDG configure acknowledgements, popup mapping, popup pointer events and popup_done. Request the grab after the input event and before the first popup buffer commit, in protocol order. Preserve WAYLAND_DEBUG=1 traces. An arbitrary nonzero serial or a grab request without an interactive configured popup is FAIL as a fixture. A popup that closed before the hidden-owner measurement is INCONCLUSIVE, not a clip PASS.

Run three private headless cases with two horizontal 800×600 outputs: A unmodified SwayFX with a standard layout; B private SceneFX/Mosaic binary with a standard layout; C that private binary with Mosaic on HEADLESS-1 and an ordinary client on HEADLESS-2. Place the owner and popup so the popup is fully visible, partly visible and physically extends beyond the Mosaic viewport. Inject left and right clicks at visible, clipped-away and ordinary outside positions. Record both output screenshots, global/output/client-local coordinates, client and compositor logs, protocol trace, IPC tree/outputs, applied clip logs, hit and button events, dismissal and popup_done. Compare outside-click consumption and dismissal in A/B/C; an invisible popup scene hit is a failure even if a legitimate grab consumes an outside click.

For the hidden-owner case, open and verify the popup first. Move only the Mosaic camera so the owner's applied tiled rectangle is entirely outside the viewport, without opening new windows or transferring focus as part of the maneuver. Record its container ID, logical rectangle, scroll_x, output viewport, popup configure/map state, scene clip, two-output pixels and pointer events. Move the camera back, then separately test workspace switch and owner destruction. Distinguish clip removal from XDG dismissal and unmap. Check appearance, movement and disappearance for sampled stale pixels and SceneFX damage records, without claiming exhaustive damage correctness.

Scenario Control A Private standard B Private Mosaic C Acceptance state
Valid-serial XDG grab PASS: button/grab serial 61 PASS: serial 61 PASS: serial 55 PASS: configured, interactive popup and outside-click dismissal without protocol error
Entire popup inside HEADLESS-1 PASS: 250×190, both buttons PASS: same PASS: same PASS at (350,150); no popup_done
Visible region of crossing popup PASS at (700,150) PASS at (700,150) PASS at (700,150) PASS for sampled left/right popup events
Partly clipped popup Ordinary cross-output popup visible Same as control Visible HEADLESS-1, clipped HEADLESS-2 PASS at sampled pixels and pointer positions
Input outside viewport (900,150) Popup receives left/right Popup receives left/right No popup enter/button; wev receives left/right PASS for sampled clipped region; valid grab also sends popup_done
True exterior (900,400) under grab wev gets left/right; popup_done Same as control Same as control PASS for sampled dismissal and delivery
Nested popup NOT TESTED in this probe NOT TESTED NOT TESTED Previous non-valid-grab nested case does not qualify this row
Fully hidden owner with popup still mapped No camera analogue No camera analogue Baseline FAIL; private guard PASS at sampled point Owner stayed ID 8; no popup_done before hidden measurement
Return of hidden owner — — PASS at sampled point with same popup Camera 1280→400 restored yellow popup without configure/recreation
Return to standard layout Ordinary reference Ordinary reference PASS at (900,150) Clip cleared; popup pixel and right-button delivery returned
Workspace switch / owner destruction NOT TESTED NOT TESTED Measured separately Switch sent popup_done; destroy ended client without popup_done
Damage and stale pixels Sampled screenshots Sampled screenshots PARTIAL No yellow residue at tested points; logs show damage, no complete effect/transaction proof

Any persistent visible/hittable leak or unavoidable ordinary-layout regression is BLOCKED for this approach. Positive sampled cases with open gates are PARTIALLY_FEASIBLE. If a reliable fixture cannot be obtained, the affected cases are INCONCLUSIVE or NOT TESTED. This matrix does not qualify STEP20C as complete.

Isolation and starting provenance

No production source, package, dotfile, installed library, session or live IPC socket may change. Use a dedicated /tmp laboratory with private source/build directories, XDG_RUNTIME_DIR, WAYLAND_DISPLAY, explicit SWAYSOCK, headless outputs and process-local LD_LIBRARY_PATH only for the private compositor. Do not alter the public struct wlr_scene_tree layout. Transient logs, screenshots, generated protocol code and builds stay outside the repository. The later evidence-freeze task retained only reviewed source fixtures under tooling/probes/step20c-valid-grab/; they are not production dependencies.

At probe start, local main, origin/main and github/main were 76a765a9965815a52586cc10e56e9d99f2720bcb; the worktree was clean. Installed wayland-client, SceneFX and wlroots pkg-config versions were 1.26.0, 0.5.0 and 0.20.2. /usr/bin/sway reported SwayFX 0.6. The retained /tmp/swayfx-mosaic-popup-yEamPX/ laboratory exists: private Mosaic source HEAD cd550382b311efdbfe3ed892f4e1d281d852e754, SceneFX source HEAD 37ccd723bef49e6891156ffafce8f549f01446cc; complete private patch SHA-256 values match the prior report (d416f6413ea097a48a44d231e50ffba363914f46c8687f0ea2e4dcacd5f31fe9 and c74c515b98da8f5f637e260e6a3755c7a75bd78d1c39e54a67850f1c383e30e7). Private binaries exist but will be copied or rebuilt in a new disposable laboratory before use. Their presence alone is not a new result.

Running observations

The new laboratory is /tmp/swayfx-mosaic-valid-grab-7SA2yO/. It holds copies of the verified private sources, fresh scenefx-build/ and mosaic-build/, grab-client.c, generated XDG protocol files, pointer-daemon.c, run.py, and per-case logs/screenshots. The client initially observed wl_seat.capabilities(0) before a virtual pointer existed; a first attempt without a persistent pointer produced no usable input and is not counted. The corrected fixture starts a virtual-pointer process before the client, retains it for the full scenario and logs injected events. A second initial Mosaic attempt used logical IPC rect.x as if it were rendered x; correcting by the measured scroll_x gave the real input point. These failed fixture attempts do not count as scientific observations.

In run-control, run-private_standard and run-private_mosaic, the client received a real left wl_pointer.button press serial (61, 61 and 55 respectively) on its toplevel and passed the identical serial in xdg_popup.grab(wl_seat, serial) before the popup's first commit. Each run received xdg_popup.configure followed by xdg_surface.configure (62 in control and private standard, 56 in private Mosaic), acknowledged it and mapped a 500×190 yellow SHM popup. The client stayed alive and received no protocol error. At global (900,150), the standard control and private standard screenshots show yellow popup pixels on HEADLESS-2; the private Mosaic screenshot shows the ordinary output client. This is initial valid-serial and visual evidence, pending more pointer comparisons.

The retained private candidate fails the fully hidden owner case. The owner remained container ID 8 with logical rectangle (640,25,640,575) and the same focused Mosaic row. The applied camera changed from scroll_x=400 to 1280, putting the owner horizontally at [-640,0) against viewport [0,800). No popup_done occurred before or during the measurement; the client remained alive. The popup remained yellow at HEADLESS-1 (100,150) while the normal background at (300,150) was dark. A virtual-pointer move entered the popup (POINTER_ENTER target=popup), and a right click at (100,150) generated popup button 273 press/release. The private log changed the root clip from local=-242,-25 800x600 to local=638,-25 800x600, a viewport-only clip that still intersects the popup despite its owner being completely outside. Returning to scroll_x=400 preserved the mapped popup and generated no popup_done; the later ordinary outside right click delivered to wev and then popup_done. Screenshots and logs are in run-private_mosaic_hit/. This is a real scene pixel/hit leak, not a grab-only outside-click effect. It blocks accepting the current owner-derived viewport clip as implemented for fully hidden owners.

To discriminate whether the failure was inherent to the architecture, a second private variant checked the owner's applied scene rectangle before setting the popup clip. That change is a feasibility discriminant, not a production patch or a settled UX policy.

That discriminant was implemented only in the copied private mosaic/sway/desktop/transaction.c, with the previous file retained as transaction-before-hidden.c. After rebuilding the private compositor, run-private_mosaic/ and run-private_mosaic_hit/ repeated the same 400→1280→400 camera sequence. The guard checks whether the owner's applied scene rectangle intersects the applied viewport and sets an empty root-popup clip when it does not. At hidden (100,150), the yellow baseline pixel (224,192,32) became ordinary dark background (30,30,46); the hover produced no popup enter and the hidden right click produced no popup button. The compositor logged PROBE_POPUP_HIDDEN_OWNER once for the hide transition. The popup remained mapped and no popup_done occurred during hide or hover; with no hidden click, returning the camera restored its visible yellow region without a new popup configure or recreation. In the separate hidden-click run, the valid grab generated popup_done on the outside click, as expected from grab dismissal, while the popup surface received no button. These are sampled visual/input results for the private guard; they do not settle whether offscreen popups should be hidden, dismissed or repositioned in production.

The final hidden-owner log recorded the applied owner scene rectangle (-640,0,640,600) and applied viewport (0,0,800,600), directly confirming their empty horizontal intersection. IPC retained the owner's logical rectangle (640,25,640,575) and container identity; the scene coordinates differ because camera translation and decorations are applied by the compositor. This is why the guard uses scene position rather than treating IPC rect.x as physical x.

Comparative pointer and lifecycle results

The XDG positioner reported popup geometry (350,80,500,190) relative to the owner in all three compositor configurations. The private Mosaic owner was logical (640,25,640,575) with camera 400 at popup creation. The control and private standard layout rendered the popup at global (900,150) (HEADLESS-2 local (100,150)), where screenshots read (224,192,32). They delivered both left button 272 and right button 273 to the popup at that point; wev received neither, and the popup was not dismissed. In Mosaic, HEADLESS-2 local (100,150) read ordinary client (238,238,238) with no popup enter/button at global (900,150). wev received both left and right buttons in separate runs; each exterior click also caused popup_done. At visible Mosaic popup (700,150), left and right clicks reached the popup in separate runs without popup_done. Thus the clipped-away surface was not receiving events, while the valid grab still handled dismissal. The pointer entered and left the popup on camera changes; the client-local coordinates remained reported by Wayland, but exhaustive coordinate transformations were not tested.

A second XDG positioner variant (50,80,250,190) kept the entire popup within HEADLESS-1. In control, private standard and private Mosaic runs, global (350,150) was yellow (224,192,32) and the popup received both left and right buttons without popup_done; HEADLESS-2 local (100,150) remained ordinary (238,238,238). These separate fully_visible runs establish the whole-popup case instead of inferring it from the visible portion of a crossing popup.

At the true exterior (900,400), both button codes reached wev and caused popup_done in all three configurations. The direct control comparison is important: a popup_done alone does not mean that a hidden scene surface received the click. Returning from Mosaic to splith while the popup remained open made HEADLESS-2 local (100,150) yellow again and delivered right button 273 to the popup, with no popup_done before that click. Switching workspaces while the owner was hidden sent popup_done and left the client alive. Destroying the hidden owner instead terminated the client; no popup_done was observed in that path. Neither mechanism is counted as a clip proof.

Sampled damage observations are limited. On HEADLESS-1, exact yellow popup-color pixels counted 39,520 while open, 0 with the private guard and owner hidden, and 39,520 after camera return. The unguarded baseline had 40,280 yellow pixels while the owner was hidden. The private SceneFX log contains SPIKE_DAMAGE entries on both outputs during the transitions, including large regions; these logs do not isolate popup-only invalidation. The captured frames show no yellow residue at the sampled point after hiding or after popup_done, but do not prove all vacated pixels, frames, effects or saved transaction buffers are correct. The client remained mapped during the no-click hide/return run; no new XDG configure was required for its return.

Reproduction and retained artifacts

The original laboratory root is /tmp/swayfx-mosaic-valid-grab-7SA2yO/; this path is historical and not required for the preserved replay fixture. Its three private source patches are mosaic-popup-plus-base.patch (SHA-256 d416f6413ea097a48a44d231e50ffba363914f46c8687f0ea2e4dcacd5f31fe9), scenefx-popup-plus-base.patch (c74c515b98da8f5f637e260e6a3755c7a75bd78d1c39e54a67850f1c383e30e7) and hidden-guard.patch (35785a8f3e863b3c1fd87932d8ff0c2949b03cb04382cc34e1126170968f802b). The first Mosaic patch applies to the already-Mosaic project commit cd550382b311efdbfe3ed892f4e1d281d852e754, not directly to pristine upstream SwayFX fd71a6bd...; an initial attempted git apply --check on the upstream commit failed at transaction.c:974. The SceneFX patch applies to 37ccd723bef49e6891156ffafce8f549f01446cc. Applying the patches in that order on clean archives, then comparing include/, sway/, Meson files, the Mosaic model test, and SceneFX include/ and types/ against the laboratory sources passed. The archive check is a source reproducibility check, not a package/ABI qualification.

grab-client.c SHA-256 is 202afdc5dae0ee4aba570eef4a563f8b298474eb84e33af9510c4c81c05d16e4; pointer-daemon.c is a00fcedc38ccb7ca4c25c982d9f38e8bde3168a3833f0c9cf2056e841d720938; run.py is b2aaa90188ea387cb8a69aa69935b56e63f5ce70eb98e74783b0fb3c9236a41f. These six source files are now byte-identically preserved in Git with the three patches. The added upstream wlr-virtual-pointer-unstable-v1.xml at wlr-protocols commit b010a03648b88d143236de193bddbfea0c08bc84 has SHA-256 3ff6d540be0bc5228195bf072bde42117ea17945a5c2061add5d3cf97d6bb524 and regenerates the original virtual-pointer C/header byte-for-byte. Generated XDG and virtual-pointer C/header files, both private builds, the earlier unguarded transaction-before-hidden.c, and per-run client.log (including WAYLAND_DEBUG=1), pointer.log, wev.log, compositor.log, IPC JSON and two-output PNG files remain outside Git in the original laboratory. run-private_mosaic_hit_baseline/ is the retained unguarded leak; run-private_mosaic_hit/ is the guarded hidden-click comparison. run-control/, run-private_standard/, run-private_mosaic/, the six boundary_{left,right} directories, outside_left, visible_left, fully_visible, standard_return, lifecycle_switch and lifecycle_destroy directories retain the other cases.

The core commands used, after copying or reconstructing the two verified private sources and applying hidden-guard.patch, were:

R=/tmp/swayfx-mosaic-valid-grab-7SA2yO
meson setup "$R/scenefx-build" "$R/scenefx" -Dexamples=false -Dwerror=false
meson compile -C "$R/scenefx-build" -j 8
PKG_CONFIG_PATH="$R/scenefx-build/meson-uninstalled" meson setup "$R/mosaic-build" "$R/mosaic" \
  -Dmosaic-package=true -Ddefault-wallpaper=false -Dswaybar=false -Dswaynag=false \
  -Dman-pages=disabled -Dsd-bus-provider=libsystemd -Dwerror=false
PKG_CONFIG_PATH="$R/scenefx-build/meson-uninstalled" meson compile -C "$R/mosaic-build" -j 8
wayland-scanner client-header /usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml "$R/xdg-shell-client.h"
wayland-scanner private-code /usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml "$R/xdg-shell-protocol.c"
cc -std=c17 -Wall -Wextra -Wno-unused-parameter -I"$R" "$R/grab-client.c" "$R/xdg-shell-protocol.c" -o "$R/grab-client" $(pkg-config --cflags --libs wayland-client)
cc -std=c17 -Wall -Wextra -Wno-unused-parameter -I"$R" "$R/pointer-daemon.c" "$R/virtual-pointer-protocol.c" -o "$R/pointer-daemon" $(pkg-config --cflags --libs wayland-client)
python "$R/run.py" control boundary_left
python "$R/run.py" private_standard boundary_right
python "$R/run.py" private_mosaic boundary_left
python "$R/run.py" private_mosaic boundary_right
python "$R/run.py" private_mosaic fully_visible
python "$R/run.py" private_mosaic
python "$R/run.py" private_mosaic_hit

The runner gives each case its own mode-0700 headless XDG_RUNTIME_DIR, discovers that case's Wayland/IPC sockets, and always passes the private socket with swaymsg -s. It sets private LD_LIBRARY_PATH only on the private compositor process; clients and injectors do not inherit it. Its virtual pointer remains alive until the case ends. Installed packages were swayfx 0.6-1, scenefx 0.5-1 and wlroots0.20 0.20.2-1; pkg-config reported Wayland client 1.26.0, SceneFX 0.5.0 and wlroots 0.20.2. ldd with process-local LD_LIBRARY_PATH resolved the private libscenefx-0.5.so and system libwlroots-0.20.so. nm -D found the private wlr_scene_tree_set_clip and wlr_scene_tree_get_clip symbols. The copied SceneFX source matched the verified prior private source byte-for-byte in the modified files; no installed SceneFX or wlroots file changed.

Evidence freeze and independent replay

The later evidence-freeze task preserved the six original source files and one upstream protocol XML in the Git fixture. The original six SHA-256 values above matched both the lab and the Git copies. The XML at a pinned wlr-protocols commit regenerated the lab's virtual-pointer header and C source byte-for-byte. No original fixture was edited, so there is no adapted-source hash. The XDG protocol code, build directories, binaries, logs, IPC JSON, screenshots, sockets and transaction-before-hidden.c remain temporary artifacts outside Git; the latter is reconstructible by applying the first Mosaic patch before the guard. The original /tmp lab remains historical evidence, while the linked README can create a fresh lab at any temporary path.

A fresh archive of Mosaic cd550382b311efdbfe3ed892f4e1d281d852e754 and a fresh clone of SceneFX 37ccd723bef49e6891156ffafce8f549f01446cc accepted their respective private patches with git apply --check and no rejects. The guard then applied to the patched Mosaic source. The four changed Mosaic/SceneFX files and the pre-guard transaction file matched the original lab byte-for-byte. Both guarded and unguarded private compositors, the private SceneFX library and both C clients compiled in the new lab. The replay runner set per-case headless sockets and explicit swaymsg -s; inspection found no path to the active Sway IPC socket.

The bounded replay ran control boundary_left, private_standard boundary_right, private_mosaic boundary_left, guarded private_mosaic, guarded private_mosaic_hit, and unguarded private_mosaic_hit. The control and private standard used button serial 61 for both receipt and xdg_popup.grab; private Mosaic used 55 for both. All received popup configure. The right-output sample (100,150) stayed yellow (224,192,32) in control/private standard and ordinary (238,238,238) in private Mosaic. With the owner hidden, the unguarded left-output sample (100,150) stayed yellow and the popup received right button 273; the guarded sample became background (30,30,46), with no hidden popup enter/button. The guarded no-click hide/return counted 39,520 exact yellow pixels before hiding, 0 while hidden and 39,520 after return, without popup_done before return. The guarded hidden-click run did cause popup_done, as expected for the valid grab's outside dismissal. These are repeated sampled observations, not a wider qualification matrix.

Decision and remaining gates

PARTIALLY_FEASIBLE for this bounded probe. The serial was genuinely received and used, the configured grabbed popup was interactive, and the private owner-visibility guard removed the demonstrated fully hidden owner pixel/hit leak while keeping the popup mapped for camera return. It also preserved sampled standard-layout behavior. The unguarded owner-derived viewport clip alone is FAIL for the hidden-owner invariant. A production policy for hiding versus dismissing or repositioning an offscreen popup is still undecided, and the private guard's use of animation_state.current_width/height needs a committed-generation/lifetime contract before any implementation. The probe did not exercise a valid-grab nested popup, vertical/negative/scaled/rotated outputs, output migration/resize, complete damage/effect/saved-buffer coverage, exact pending/current generation synchronization, or SceneFX/wlroots ABI/package maintenance. Those cells remain NOT TESTED or PARTIAL; multi-output Mosaic remains NOT YET SUPPORTED.

The smallest next experiment is a controlled valid-grab nested popup plus transaction/output-migration and damage measurements against the same unmodified SwayFX control, followed by a separate scientific/UX decision about offscreen popup lifecycle. Do not import either private patch into production on this evidence.