The demo promised three escapes and delivered none: the only way out was a
hard reboot. Two independent causes, and only one of them was demo-only.
launch_workout_app blocks until the app exits. Called straight from a Tk
button that froze the event loop, so the Escape binding and the close button
could not fire -- measured: zero handlers ran during the block, and the events
were never delivered, not merely delivered late. WorkoutSession drives the
same phases through `after` instead, on the main thread, so grab_release and
grab_input stay where Tk requires them and the loop keeps pumping (199 ticks
during a run that previously managed none).
The second cause survives a healthy event loop. The Flutter app takes its own
gdk_seat_grab with GDK_SEAT_CAPABILITY_ALL and never releases it, so while it
is up X delivers the keyboard to it and no binding of ours can fire. Correct
for production, fatal for a demo, and no flag turns it off -- so the demo's
escape is a file the app cannot intercept: touch ~/.cache/stop-workout-demo.
The previous commit's rationale for the blocking was wrong and is corrected
here rather than left to mislead. It claimed pumping Tk would let the recovery
watchdog steal the screen mid-workout; in fact release() stops the watchdog
first and stop() cancels its timers outright, so there were never any ticks to
race with. The blocking was incidental, which is why removing it costs the
production handoff nothing.
Verified with the real debug bundle under Xvfb: with the app up and holding
its seat grab, the event loop is alive, the escape file terminates it, and the
lock takes the grab back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
The locker cannot close() to let the app take over: close() also restores VT
switching, which would hand the user Ctrl+Alt+F2 and an exit from the very
obligation the lock exists to enforce. Only the grab moves; the Tk window
stays mapped underneath, so the screen is still covered if the app dies.
The watchdog has to be stopped for the duration, and measuring it is what
makes that non-negotiable. gatelock's recovery loop re-takes any grab it
finds missing every 1000ms -- by design, so that pulling a cable is not an
escape -- and holds_grab() is `grab_current() is not None`, which is False
once another X client holds it. Verified on a private Xvfb display: a naked
release is stolen back within ~1.5s, while stopping the loop first lets the
app keep the screen and the locker take it back cleanly afterwards.
The same harness confirms the ordering the handshake depends on: while the
locker holds the global grab a second X client cannot even finish connecting
(4s, no output at all), so there is no window in which the screen is
uncovered. It only gets through after the release.
Reaching into lock._recovery earns an SLF001 waiver rather than a public
pause on LockWindow: gatelock's _recovery.py carries a monotonicity proof
that no transition ever decreases the grab, and a public lend_grab() would
be exactly that transition. The waiver is documented where the other four
SLF001 waivers in this repo already live.
start_workout blocks the Tk loop for the whole workout, which is deliberate
-- pumping Tk underneath is what would let a tick steal the screen mid-set.
The docstring names the cost: a monitor hotplug is not handled until the app
exits and grab_input() re-covers every live output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg