evidence · 15.7 KiB

README.md

Committed at docs/evidence/card-shell/video-card-gestures/README.md. This is what grounds the requirements that cite it.

Open raw source

Video card trap: root cause, fix, and board verification

Evidence class: board webcam recording with injected touch (evdev, via evemu-device/tools/inject-tap.sh) for the open and close gestures, plus the compositor’s own card_shell debug/step IPC (swaymsg card_shell previous|next|debug-scene) used to select a card deterministically for the switch step — the deck’s own per-card screen rects are not exposed by any external query, and a hand-computed guess at their pixels proved unreliable for a horizontal drag. Not a real finger. Captured 2026-09-26.

The report

“the video’s on the screen and i can’t close it or interact with the screen any more”. The coordinator stopped it with k230-video-session stop. The compositor log from the incident:

for_window '[app_id="^k230-video-(software|mvx)$"]' matches view ...
  cmd: 'card_shell ordinary, floating enable, resize set 100 ppt 100 ppt, move position 0 0'
K230_CARD_SHELL map id=12 class=2
K230_CARD_SHELL restored focus=12 message=10   (repeated on every later gesture)

message=10 is CS_MESSAGE_FAILED: the overview never opened, and cs_leave’s fallback re-focused the same full-screen video (id 12) every time — the trap.

Root cause

nix/card-shell/adapter.c’s sync_card/sync_scene_impl feed the direct-switch entry gesture’s anchor math (cs_entry_set_geometry) the card’s source rect: the on-screen box the app is dragged away from. For every other app this is view->geometry.width/height, because a well-behaved xdg_shell client acks the compositor’s resize set 100 ppt 100 ppt and its own reported window geometry ends up matching the container’s real, full-panel size.

mpv’s --vo=wlshm (launched by nix/video-session.py with --force-window=yes --geometry=480x270 or 568x320) does not: it ignores the resize configure entirely and keeps reporting its small initial decode buffer as view->geometry forever, even once card_shell_commit’s ordinary_resize has put its container at the full 568x1232 panel size. Confirmed live on the board via swaymsg -t get_tree while a video card was mapped:

"rect":        {"x": 0, "y": 0, "width": 568, "height": 1232}   # container: correct, full panel
"window_rect": {"x": 0, "y": 0, "width": 568, "height": 1232}   # correct
"geometry":    {"x": 0, "y": 0, "width": 480, "height": 270}    # view's own geometry: stale, the launch size

Feeding that stale 480x270 into cs_entry_set_geometry as the source rect puts the gesture’s anchor point far past the bottom of the screen (the anchor fraction (edge.y-source_y)/source_height blows up with the tiny source_height), so travel < config.entry_distance and the call is rejected on the very first frame of the direct-switch entry gesture — for every subsequent attempt, since nothing about mpv’s geometry ever changes.

Diagnostic logging (commit fb30f890)

Added a failure-only sway_log(SWAY_INFO, ...) naming the reason at every silent return false in sync_scene_impl, sync_card, sync_node, chrome_impl/rebuild_chrome. Built and test-activated as i6mz5kzrg9rrkln7040gbgnddq2l8afz-nixos-system-nixos-26.11.20260919.20b1ddd, reproduced the bug with a real mpv session and injected touch, and got the exact new log line for the first time:

K230_CARD_SHELL sync_scene fail reason=entry-geometry-rejected id=6
K230_CARD_SHELL restored focus=6 message=10

(id 6 was the video card in that session; matches the incident’s id 12 exactly in shape.)

Fix and safety net (commit 4ad12358)

Board verification

System test-activated: c57f8gflr1hw25hddp67rd960j5kyw5c-nixos-system-nixos-26.11.20260919.20b1ddd (fix/video-card-gestures at 4ad12358, base feat/more-apps at 1e17d6c5), replacing i6mz5kzrg9rrkln7040gbgnddq2l8afz-... (the logging-only build used for the repro above), which replaced the board’s prior system har97636isrripp3iyi0gf9ar04mg2v9-....

Video started the way the drawer does (k230-video.desktop’s Exec=k230-video-session run), as the shell user with XDG_RUNTIME_DIR=/run/shell WAYLAND_DISPLAY=wayland-1. Injected touch via an evemu-device clone of /dev/input/event0 (real GT9895 description), driven with tools/inject-tap.sh (open, panel pixels on the 568x1232 portrait panel) and a small custom close-throw.sh (fewer/larger steps than the generic drag helper, so per-step speed reliably clears the deck’s throw_speed=0.4px/ms close threshold).

  1. Open — bottom-edge swipe-up, (284,1228)->(284,760): K230_CARD_SHELL_DEBUG_SCENE ... mode=1 ... selected_app_id=k230-video-software (mode=1 is CS_DECK). No entry-geometry-rejected, no message=10 anywhere in the log for the rest of the session. Camera: contact-sheet.png panel 1 (video full-screen, the state the user was stuck in) -> panel 2 (overview open, the video’s real thumbnail small and centered).
  2. Switch — swaymsg card_shell previous moved the deck’s selection off the video card to the other live card (selected_app_id=k230-terminal in debug-scene); a plain tap (284,550) on the now-centered card (injected touch) expanded it to full screen: K230_CARD_SHELL restored focus=5 message=0. The video kept playing in the background, unaffected.
  3. Swipe-up close — back in the deck (swaymsg card_shell next reselected the video card), a fast injected vertical throw on the video card’s own rect, (284,900)->(284,400) over ~120ms (close-throw.sh /dev/input/event1 284 900 400):
    K230_CARD_SHELL close-request id=7
    K230_CARD_SHELL state mode=1 actions=33 message=9 cards=1
    K230_CARD_SHELL unmap id=7
    
    (message=9 is CS_MESSAGE_SOURCE_GONE.) No video-stop-helper unavailable log line, so card_shell_video_stop() (the SWAY_K230_CARD_VIDEO_STOP helper, i.e. k230-video-session stop) was actually invoked and succeeded, alongside the ordinary xdg_toplevel close.
  4. Confirmed stopped: pgrep -x mpv empty immediately after, and the k230-inject-video unit wrapping k230-video-session run had exited (systemctl is-active -> inactive) — the session controller’s own process ended, not merely the mpv child. Camera: contact-sheet.png panel 3 (deck now shows only the remaining terminal card; the video thumbnail is gone).
  5. Left the deck (swaymsg card_shell back): active=0 mode=0, back to the normal Home/ordinary-app state, no video playing.

Rollback timer (k230-deploy-restore.timer) disarmed after activation; readlink -f /run/current-system confirms c57f8gfl... is what the board is actually running.

Not established

A real-finger check by the user on the glass. Everything above is injected touch (open, close) or the compositor’s own deterministic step/debug IPC (switch); no physical finger touched the panel for this change.

Follow-up: the overview became unusable after opening (“no touches

register”), on integrate/apps-video-catalog

Reported after the fix above landed on an integration build carrying the apps/live-catalog/shade changes rebased with it: “once i open the cards list i can’t do anything no touches register”. Reproduced with injected touch (/run/k230-vo.sh: launch video, bottom-edge swipe-up, tap, flick, tap) against system p40rq5r8w8pfibl7nyhkr6vq4qs0w3n7-nixos-system-nixos-26.11.20260919.20b1ddd (the first fix’s commits rebased onto integrate/apps-video-catalog, no further change yet). Extended the state mode=... debug log with entry_settling/entry_reversing/entry_interrupted_hold/entry_progress/ blocked_until_up/blocked_contacts to see the mechanism directly instead of guessing from mode numbers alone.

Root cause 1 (card-shell-policy.c, commit f53f451c): a touch landing during the post-release entry settle triggers cs_down’s existing “catch and reverse” path, which sets entry_interrupted_hold and freezes cs_tick’s own elapsed-time computation until that same contact’s up clears the hold via cs_up — but adapter.c routes every up through cs_entry_up_at while mode == CS_ENTERING (covers both settling and reversing), which only ever resolves the original bottom-edge contact and bails for any other id. The interrupting contact’s own release can therefore never reach cs_up, so the hold has no way to clear on its own; a chain of closely-spaced touches (tap, then flick, then another tap, each landing before the previous one’s hold could clear) held the transition frozen far longer than its own ~240ms clock. Fix: entry_interrupt_started_ms bounds the freeze to one settle duration regardless of whether that contact’s up ever arrives, then proceeds on the untouched entry_started_ms timeline. New policy test entry_settle_completes_despite_stuck_interrupt models the worst case directly (an interrupting touch that never gets an up at all).

Root cause 2 (adapter.c, commit 2c838d0b, then 1ff6a837): board-reproduced separately — launching video and swiping up almost immediately let the video’s xdg_shell map (card_shell_observe) land while the deck was already CS_ENTERING. The brand-new card had never captured a scene position, so sync_card’s entering branch hard-failed it (reason=entry-source-invalid), which failed the whole sync_scene, which cs_leave-with-CS_MESSAGE_FAILED’d the entire transition back to CS_NORMAL with a stray blocked_until_up left behind — matching the report exactly:

K230_CARD_SHELL sync_card fail id=6 reason=entry-source-invalid
K230_CARD_SHELL sync_scene fail reason=card-sync-failed id=6
K230_CARD_SHELL restored focus=5 message=10

Fix: only the entry_id card (the one actually being dragged away from its live position) needs a real captured position; any other card appearing mid-transition is now skipped for the rest of the entry animation (reason=entry-source-not-ready) instead of aborting the whole transition. A follow-up board run then surfaced a second-order bug this introduced: sync_card’s own position capture was gated on !c->hidden, and a deferred card is marked hidden on the very same frame, so it could never become source_valid — a later tap-to-expand on that exact card failed the identical way forever (reason=expand-source-invalid). 1ff6a837 lets a card keep retrying its position whenever !c->hidden || !c->source_valid, preserving the existing freeze-once-valid-and-hidden behavior other cards rely on for their entry/expand return-to-source animation.

Performance finding, not fully resolved (adapter.c, commit ef63a564): with both correctness bugs fixed, the entry animation still took ~4.2-4.4s wall-clock end to end (bottom-edge swipe release to CS_DECK) against the coordinator’s ~400ms target, with state mode=4 steps landing roughly every 150-250ms instead of ~20ms. handle_result was running a full, synchronous sync_scene()+chrome() pass for every raw touch motion sample (tens a second during the drag), duplicating what the existing 16ms tick_impl timer already guarantees moments later. Coalescing that redundant per-sample sync to at most once per ~12ms during an active gesture is a safe, real improvement (touch dispatch itself is unaffected, since it is computed from policy state alone) but did not measurably shorten the ~4.2s total, because tick_impl’s own unconditional per-16ms sync_scene() call (via card_shell_prepare) is equally expensive and is not gated by this throttle — the dominant cost is each sync_scene() pass itself taking ~150-200ms wall time while mpv decodes and Pixman recomposites the whole 568x1232 output in software, not how many times it is invoked per touch sample. The coordinator was explicit that the small preview must stay live (no freezing, no pausing mpv), so the only path left to meet the timing target is to make each recomposition of the video card’s mirror cheaper while still updating every commit — e.g. extending the existing SWAY_K230_CARD_SCALED_CACHE machinery’s cheap pre-scaled-buffer path to video cards unconditionally, with its “frozen thumbnail” behavior removed, rather than reducing how often the mirror is refreshed. That is follow-up work, not done here.

Board verification (system

28qilnk3zqlivjgij78ahqnjvnjz8j5y-nixos-system-nixos-26.11.20260919.20b1ddd, integrate/apps-video-catalog at 1ff6a837)

With all three correctness/coalescing commits applied, a full injected /run/k230-vo.sh run (swipe-up, tap, flick, tap) and a follow-up swaymsg card_shell enter/next/close cycle produced zero reason=... failure or defer lines and zero message=10/restored focus with CS_MESSAGE_FAILED anywhere in the journal across multiple repeated runs. Concretely:

Not met: the ~400ms/~20ms-step timing target, for the performance reason above — entry still takes several real seconds end to end while mpv is actively decoding. Contact sheet contact-sheet-overview-freeze.png (webcam, injected/IPC-driven, not a real finger): video full-screen live -> overview open with the video’s real live thumbnail -> overview after close (video card gone, mpv exited). Rollback timer disarmed after activation; readlink -f /run/current-system confirms 28qilnk3... is what the board is actually running, with no video playing and no failed units.

Still not established: a real-finger check, and the deeper performance fix (cheap live pre-scaled video mirror, decoupled from SWAY_K230_CARD_SCALED_CACHE’s frozen-thumbnail behavior) needed to meet the entry-timing target while keeping the preview live.