Skip to content

fix(#133): debounce DISPLAY_MODE_CHANGE — confirm a mode across N consecutive reads#138

Open
aradanmn wants to merge 2 commits into
mainfrom
fix/133-dock-debounce
Open

fix(#133): debounce DISPLAY_MODE_CHANGE — confirm a mode across N consecutive reads#138
aradanmn wants to merge 2 commits into
mainfrom
fix/133-dock-debounce

Conversation

@aradanmn

Copy link
Copy Markdown
Owner

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 = 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 → 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 for DOCK_DETECTION_CONFIRM_SAMPLES consecutive reads — default 3, spaced DOCK_DETECTION_CONFIRM_INTERVAL_S=1s — bailing on the first disagreement.

Tests — 8 → 11 (CI baseline bumped)

T1.9 transient candidate rejected
T1.10 sustained candidate confirms; SAMPLES=1 + garbage overrides clamp safely
T1.11 end-to-end: a live docked session swallows a blip, still emits the later real undock

T1.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 both handheld, 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 persistent O_RDWR fd, since read -t bounds the read but not the FIFO open, which blocks until a writer appears (that hung the suite before I switched to exec 3<>).

Verified: with _confirm_display_mode stubbed to true, T1.11 fails with extra 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 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 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 inotifywait with -t so 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 existing trap convention
  • No regressions in test_controller_monitor / test_watchdog / test_runtime_context / test_utilities. test_window_manager fails 7/9 identically on main — 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.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant