fix(#133): debounce DISPLAY_MODE_CHANGE — confirm a mode across N consecutive reads#138
Open
aradanmn wants to merge 2 commits into
Open
fix(#133): debounce DISPLAY_MODE_CHANGE — confirm a mode across N consecutive reads#138aradanmn wants to merge 2 commits into
aradanmn 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.