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)
card_source_size()(new, inadapter.c) feeds an ordinary-maximized card’s real committed container box (container->current.content_width/content_height— always exactly the output’s usable area for this card class) as the source rect, instead ofview->geometry.width/height. A compliant client’s geometry already tracks its resize exactly, so this changes nothing for every other card; it only stops trusting a client (mpv) that never acks the resize. Used at both call sites:sync_scene_impl’sCS_ENTERINGfast path andsync_card’scs_entry_set_geometry/cs_entry_visual_rectcalls. The small deck thumbnail’s own aspect-fit sizing keeps using the view’s real geometry (480x270) unchanged, since that one wants the decoded video’s actual aspect ratio, not the full-panel box.- Safety net: a mirror-sync failure inside
sync_card(a wlshm buffer the compositor can’t sample, an allocation failure under memory pressure) no longer fails the whole scene sync. The affected card falls back to its plain plate + icon + title (the same placeholder already used forCS_PRIVATE/CS_UNAVAILABLEcards), with no live mirror that frame, and the overview still opens — so a mirroring failure alone can never trap the user behind a full-screen card again, even for a cause other than the one reproduced here. tests/card_shell_policy_driver.c: addedentry_geometry_rejects_undersized_source, pinning the exact mechanism at the policy layer — the identical edge/finger state that opens the overview with the real full-panel source rect is rejected outright with a small one. Registered intests/test_card_shell_state.py. Host run,cc -std=c11 -Wall -Wextra -Werror -pedantic -g -fsanitize=address,undefined: all 35 cases pass (python3 tests/test_card_shell_state.py -v).
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).
- Open — bottom-edge swipe-up,
(284,1228)->(284,760):K230_CARD_SHELL_DEBUG_SCENE ... mode=1 ... selected_app_id=k230-video-software(mode=1isCS_DECK). Noentry-geometry-rejected, nomessage=10anywhere in the log for the rest of the session. Camera:contact-sheet.pngpanel 1 (video full-screen, the state the user was stuck in) -> panel 2 (overview open, the video’s real thumbnail small and centered). - Switch —
swaymsg card_shell previousmoved the deck’s selection off the video card to the other live card (selected_app_id=k230-terminalindebug-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. - Swipe-up close — back in the deck (
swaymsg card_shell nextreselected 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=7message=9isCS_MESSAGE_SOURCE_GONE.) Novideo-stop-helper unavailablelog line, socard_shell_video_stop()(theSWAY_K230_CARD_VIDEO_STOPhelper, i.e.k230-video-session stop) was actually invoked and succeeded, alongside the ordinary xdg_toplevel close. - Confirmed stopped:
pgrep -x mpvempty immediately after, and thek230-inject-videounit wrappingk230-video-session runhad exited (systemctl is-active->inactive) — the session controller’s own process ended, not merely the mpv child. Camera:contact-sheet.pngpanel 3 (deck now shows only the remaining terminal card; the video thumbnail is gone). - 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:
- Bottom-edge swipe-up:
state mode=1(CS_DECK) reached, video’s own mirror created (mirror id=6 ... width=568 height=320), no failure. - A tap on the deck’s centered card correctly expanded it
(
restored focus=5 message=0,message=0is success, notCS_MESSAGE_FAILED). swaymsg card_shell enter/next/closeagainst the video card:close-request id=6->source-gone id=6->unmap id=6, andpgrep -x mpvempty immediately after — the video actually stops.- The overview never got stuck; every gesture eventually completed and the shell returned to a normal, interactive state.
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.