From e7e873465bd434d3fcd66af636d57dc7c7d7246f Mon Sep 17 00:00:00 2001 From: OmleNessumsa <135130875+OmleNessumsa@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:22:59 +0200 Subject: [PATCH] Resync HID command stream after a stale report KeyboardAPI._hidCommand wrote one report, read exactly one report and threw "Receiving incorrect response for command" on an echo mismatch, without ever draining the mismatched report. A single stray reply (a second tab or client on the same device, a concurrent command) therefore shifted every following response by one, and because the next command was already sent before the late reply arrived, fastForwardGlobalBuffer could not discard it. The stream stayed shifted until the page was reloaded (#228). _hidCommand now discards replies whose echo belongs to another command and reads again, bounded by RESYNC_MAX_STALE_READS (4) and a 250 ms per-read timeout. A reply starting with 0xFF (QMK id_unhandled) is still treated as the real answer and fails immediately, so unsupported commands cannot stall the queue. The WebHID shim gains readWithTimeout(), which removes its own waiter on timeout. Verified on a Keychron Q3 Pro (0x3434:0x0630, protocol 11) on macOS 26.5 with Chrome 153: the previously permanent off-by-one no longer occurs and the full keymap loads with an empty error log. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VYbTK39cVBDbhebBZ5UQWg --- src/shims/node-hid.ts | 29 +++++++++++++++++++++++++++ src/utils/keyboard-api.ts | 41 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/shims/node-hid.ts b/src/shims/node-hid.ts index d6b35564..990eb3fd 100644 --- a/src/shims/node-hid.ts +++ b/src/shims/node-hid.ts @@ -192,6 +192,35 @@ const ExtendedHID = { readP = promisify((arg: any) => this.read(arg)); + // Like read(), but gives up after `ms` and removes its waiter so a + // report that never comes cannot wedge the command queue. Used by the + // resync loop in KeyboardAPI._hidCommand. + readWithTimeout(ms: number): Promise { + return new Promise((res, rej) => { + this.fastForwardGlobalBuffer(lastWriteTimestamp); + if (globalBuffer[this.path].length > 0) { + res(globalBuffer[this.path].shift()?.message as Uint8Array); + return; + } + let settled = false; + const waiter = (data: Uint8Array) => { + if (settled) return; + settled = true; + clearTimeout(timer); + res(data); + }; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + eventWaitBuffer[this.path] = eventWaitBuffer[this.path].filter( + (w) => w !== waiter, + ); + rej(new Error(`HID read timed out after ${ms}ms`)); + }, ms); + eventWaitBuffer[this.path].push(waiter); + }); + } + // The idea is discard any messages that have happened before the time a command was issued // since time-travel is not possible yet... fastForwardGlobalBuffer(time: number) { diff --git a/src/utils/keyboard-api.ts b/src/utils/keyboard-api.ts index b1c113bf..19b09a87 100644 --- a/src/utils/keyboard-api.ts +++ b/src/utils/keyboard-api.ts @@ -16,6 +16,11 @@ export {KeyboardValue} from './keyboard-values'; // VIA Command IDs const COMMAND_START = 0x00; // This is really a HID Report ID +// MYVIA resync tuning (see _hidCommand). Round trips on a healthy board are +// 3-25ms, so 250ms is generous without stalling the UI on a dead command. +const RESYNC_MAX_STALE_READS = 4; +const RESYNC_READ_TIMEOUT_MS = 250; +const HID_UNHANDLED_COMMAND = 0xff; // QMK via.c id_unhandled const PER_KEY_RGB_CHANNEL_COMMAND = [0, 1]; enum APICommand { @@ -759,8 +764,40 @@ export class KeyboardAPI { await this.getHID().write(paddedArray); - const buffer = Array.from(await this.getByteBuffer()); - const bufferCommandBytes = buffer.slice(0, commandBytes.length - 1); + // MYVIA resync: the stock implementation reads exactly one report and + // throws on an echo mismatch, never draining the stale reply. One stray + // report (a second tab, a concurrent rescan) then shifts every following + // response by one until the page is reloaded (the-via/app#228). + // Here we discard replies that clearly belong to another command and + // read again, bounded by RESYNC_MAX_STALE_READS and a per-read timeout. + // A reply starting with 0xFF (QMK id_unhandled) is a real answer to this + // command and is still treated as an error immediately. + let buffer = Array.from(await this.getByteBuffer()); + let bufferCommandBytes = buffer.slice(0, commandBytes.length - 1); + let staleReads = 0; + while ( + !eqArr(commandBytes.slice(1), bufferCommandBytes) && + buffer[0] !== HID_UNHANDLED_COMMAND && + staleReads < RESYNC_MAX_STALE_READS + ) { + staleReads++; + console.warn( + `Stale HID report for ${this.kbAddr}, resyncing (${staleReads}/${RESYNC_MAX_STALE_READS})`, + 'expected:', + commandBytes.slice(1), + 'got:', + buffer, + ); + try { + buffer = Array.from( + await this.getHID().readWithTimeout(RESYNC_READ_TIMEOUT_MS), + ); + } catch (e) { + // Nothing else arrived: the mismatched report was the only answer. + break; + } + bufferCommandBytes = buffer.slice(0, commandBytes.length - 1); + } logCommand(this.kbAddr, commandBytes, buffer); if (!eqArr(commandBytes.slice(1), bufferCommandBytes)) { console.error(