fix(#133): debounce DISPLAY_MODE_CHANGE — confirm a mode across N consecutive reads#138
fix(#133): debounce DISPLAY_MODE_CHANGE — confirm a mode across N consecutive reads#138aradanmn wants to merge 2 commits into
Conversation
HW-2 (2026-07-22) lost a live docked session to ONE spurious /sys/class/drm/card0-DP-1/status = disconnected read. The projector was physically connected and DP-1 read `connected` immediately before and after, but watch_display_mode emitted DISPLAY_MODE_CHANGE off that single read: Minecraft moved to the internal panel, froze, and teardown wedged badly enough to need a force reboot. Steam Game Mode rides the Deck's known DP flicker out; we pounced on the first blip. Both watch paths (inotify and poll) now route a candidate mode through _confirm_display_mode, which re-reads until the candidate has held for DOCK_DETECTION_CONFIRM_SAMPLES consecutive reads (default 3, spaced DOCK_DETECTION_CONFIRM_INTERVAL_S=1s), bailing on the first disagreement. A blip costs one interval and emits nothing; a real dock/undock still switches, ~2s later. Bad overrides clamp rather than kill the watcher — SAMPLES<1 degrades to the pre-#133 emit-on-first-read. Tests (suite 8 -> 11, CI baseline bumped): - T1.9 transient candidate is rejected - T1.10 sustained candidate confirms; SAMPLES=1 and garbage overrides clamp - T1.11 end-to-end: watch_display_mode swallows a blip on a live docked session and still emits the later real undock T1.11 is mutation-verified. Its first draft passed with the debounce replaced by `true` — the blip and the real change are the same mode, so first-message CONTENT cannot discriminate. The gate is "exactly one message": un-debounced, the blip's revert emits a second (docked) message. Asserting that absence needs a persistent O_RDWR fd, since `read -t` bounds the read but not the FIFO open, which blocks until a writer opens. Also redirects both backgrounded watchers off the CI capture pipe (>/dev/null 2>&1). CI runs out=$(bash "$suite" 2>&1), which blocks until every process holding stdout exits — the #80/#103 hang. T1.8 had the same shape; hardened while here. Known gap documented in-module, not introduced by this change: the inotify branch drops events fired during the settle/confirm window (window grows ~0.5s -> ~2.5s). Self-correcting whenever the state ends up steady, since confirmation reads live status rather than replaying events. Bounding inotifywait with -t so the loop doubles as a slow poll is the real fix and is out of scope here. Refs #133. Hardware validation (docked session survives a real DP flicker) still required before closing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZuoMpX28CbtGJbigVTv5H
… window The #133 confirm window (3 samples x 1s) is a guess. HW-2 caught the killer blip with a single 3s-interval poll read, which bounds the transient at "under 3s" and nothing tighter — so the default was picked for safety with no idea what it actually needs to cover. That guess is the whole argument, because the window is not free. Miller (1968) / Nielsen put ~1s as the limit for holding a user's flow of thought and ~0.1s for "instantaneous", and a dock/undock is user-initiated — the clock starts when they pull the cable. A 2s window is in "did it hang?" territory. If real blips are ~100ms, a ~300ms window kills them and nobody perceives it, and the safe-vs-responsive tension evaporates entirely. probe-dp-flicker.sh samples every DRM connector at up to ~50Hz and reports how long each `disconnected` episode lasted. Read-only; safe beside a live session. It answers two questions: Q1 how long is a transient -> sizes the window from data Q2 does any OTHER attribute corroborate the status flip? It samples enabled/dpms/edid/modes alongside status, because a real undock should drop the EDID and empty the mode list while a spurious status read plausibly does not. If that holds, dock_detection can emit IMMEDIATELY when every signal agrees and debounce only the ambiguous case — near zero latency in the common case, which is the actual fix for the responsiveness problem rather than a better-tuned compromise. Also reports which watch path the machine takes, because it decides the latency budget and is not knowable from this repo: with inotify-tools absent, watch_display_mode polls every 3s and THAT dominates any window we choose — the debounce would not even be the main delay. Fork-free sampling ($EPOCHREALTIME + a timed read on a held fd): at a 20ms interval, forking sleep/date per iteration adds jitter comparable to the interval and would blur the very transients being measured. Smoke-tested against a scripted fake DRM tree: a 120ms blip measured 112ms at 10ms resolution, and the permanent undock was correctly excluded from transient stats as ONGOING. That validates the instrument, NOT the Deck's behavior — the real numbers only exist after a docked run. .workdir/ gitignored (Deck-side scratch, #122). Refs #133. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UZuoMpX28CbtGJbigVTv5H
|
Parked — do not merge. On-Deck validation (2026-07-25) measured this pure-debounce fix at ~3.9s real dock/undock latency (Deck has no inotify-tools → 3s poll + 2s confirm), which is a bad trade for what looks like an N=1 event — the deliberate reproduction attempt caught zero spurious blips. See #133 for the full findings and the real-undock signature. If #133 recurs (a passive logger is now watching for it), the right fix is corroboration (emit on |
|
Closing — parked and not merging. On-Deck testing couldn't reproduce the dock-flicker (#133), and the pure-debounce fix measured ~3.9s real-undock latency (poll path), a bad trade for an N=1 event. The passive flicker logger stays running to catch any recurrence; if #133 comes back the corroboration approach (emit on status only when enabled+dpms agree) is the fix, not this blind debounce. Branch preserved for reference. |
Closes nothing yet — #133 stays open until the Deck confirms it (see Validation).
The bug
HW-2 (2026-07-22) lost a live docked session to one spurious
/sys/class/drm/card0-DP-1/status = disconnectedread. The projector was physically connected and DP-1 readconnectedimmediately before and after, butwatch_display_modeemittedDISPLAY_MODE_CHANGEoff that single read → Minecraft moved to the internal panel → froze → teardown wedged → force reboot.Steam Game Mode rides the Deck's known DP flicker out. We pounced on the first blip.
The fix
Both watch paths (inotify and poll) now route a candidate mode through
_confirm_display_mode, which re-reads until the candidate has held forDOCK_DETECTION_CONFIRM_SAMPLESconsecutive reads — default 3, spacedDOCK_DETECTION_CONFIRM_INTERVAL_S=1s— bailing on the first disagreement.SAMPLES<1degrades to the pre-dock_detection: no debounce — a single transient DRM read flips a working docked session to the internal panel #133 emit-on-first-read.Tests — 8 → 11 (CI baseline bumped)
SAMPLES=1+ garbage overrides clamp safelyT1.11 is mutation-verified. Worth flagging, because its first draft was worthless: it passed with the debounce replaced by
true. The blip and the real change are bothhandheld, so the first message's content cannot discriminate. The real gate is "exactly one message" — un-debounced, the blip's revert emits a second (docked) message. Asserting that absence needs a persistentO_RDWRfd, sinceread -tbounds the read but not the FIFO open, which blocks until a writer appears (that hung the suite before I switched toexec 3<>).Verified: with
_confirm_display_modestubbed totrue, T1.11 fails withextra message 'DISPLAY_MODE_CHANGE docked'; the blip was not debounced.Also in here
Both backgrounded watchers are now redirected off the CI capture pipe (
>/dev/null 2>&1). CI runsout=$(bash "$suite" 2>&1), which blocks until every process holding stdout exits — the #80/#103 hang. T1.8 had the same shape; hardened while I was here.Known gap (documented in-module, not introduced here)
The inotify branch drops DRM events that fire during the settle/confirm window, which this change grows from ~0.5s to ~2.5s. It is self-correcting whenever the state ends up steady, because confirmation samples read live status rather than replaying events. Residual risk: a change that lands inside the window and then emits no further event would leave that branch blocked until the next one. The proper fix is bounding
inotifywaitwith-tso the loop doubles as a slow poll — out of scope here.Verification
tests/test_dock_detection.sh— 11/11, run 5× for flake, and once under CI's exact$(...)capture form (no hang)shellcheck -S warning modules/dock_detection.sh— clean; test-file delta is 3 × SC2064 matching the file's existingtrapconventiontest_window_managerfails 7/9 identically onmain— pre-existing, encoded in the CI baseline.Validation still required
CI cannot prove the thing that actually matters. Before #133 closes, the Deck needs: a docked session that survives a real DP flicker without moving to the internal panel, and a real undock that still switches (within ~2s). Folding into the next docked session — this is meant to make the #112 work survivable.