From cd067ab91ee4ab0f0628f6d6b385e7b89d0cb9b9 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Sat, 30 May 2026 22:27:14 -0500 Subject: [PATCH] fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer (#35512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer When the Node event loop blocks during a heavy render/tool-call burst, stdin stops being drained. Mode-1003 any-motion mouse reports pile up in the kernel buffer, get partially read, and arrive as text with the `\x1b[<` prefix AND coordinate digits chewed off across many partial reads. The existing fragment recovery (SGR_MOUSE_FRAGMENT_RE) only handles clean `button;col;row[Mm]` triples, so the degraded shards leak into the composer as typed text — the user can no longer type or exit until the stall clears. Captured leak (Windows Terminal, during tool calls): M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M 48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22M M18M99;26M32MM38M63;44M47MM1;51M M4M54M Add two recovery layers in parseTextWithSgrMouseFragments / the text-token path: - MOUSE_BURST_NOISE_RE: whole-text fast path. If a text token is drawn only from the mouse-leak alphabet (`[ ] < ; I M m`, digits, spaces) AND carries the structural signature of mouse coordinates (>=3 M/m terminators, a digit, and a `;`), swallow it wholesale. - MOUSE_BURST_RESIDUE_RE: swallows pure-noise residue in the gaps between and after recovered fragments, so a partially-recovered burst doesn't trail a chewed-up tail into the prompt. All three constraints together preserve real prose: `Mmm MMM mmm yummy` has no digit/`;`, `see 1;2;3M for details` has disqualifying letters, and `1234;56;78M9;10;11M` has only two terminators — none are swallowed. This is defense-in-depth: it stops the leak/lockout regardless of what blocks the loop. The underlying event-loop stall during streaming is a separate, still-open issue that needs live-turn instrumentation to root-cause. * fix(tui): check mouse-burst noise before fragment recovery; drop test cast Copilot review on #35512: - MOUSE_BURST_NOISE_RE was only evaluated when parseTextWithSgrMouseFragments returned null. A noise blob that contains any intact ` * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hermes-ink/src/ink/parse-keypress.test.ts | 31 +++++++++++ .../hermes-ink/src/ink/parse-keypress.ts | 52 ++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts index cee7ab39d..2905c53a2 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -133,4 +133,35 @@ describe('fragmented SGR mouse recovery', () => { expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' }) }) + + it('swallows a fully degraded mouse-burst noise blob without leaking prompt text', () => { + // Captured from Windows Terminal during a heavy tool-call render: the event + // loop blocked past App's 50ms flush timer, so a long burst of SGR mouse + // reports (mode 1003 any-motion) arrived as text with prefixes AND + // too degraded for SGR_MOUSE_FRAGMENT_RE (1- and 2-param remnants, a + // stray focus-in `[I`), so without the whole-text noise fast path the entire + // blob types into the composer and locks the user out. + const blob = + 'M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22MM18M99;26M32MM38M63;44M47MM1;51M M4M54M' + const [events] = parseMultipleKeypresses(INITIAL_STATE, blob) + + expect(events).toEqual([]) + }) + + it('keeps plain prose that only contains scattered M and m letters', () => { + const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'Mmm MMM mmm yummy') + + expect(key).toMatchObject({ kind: 'key', sequence: 'Mmm MMM mmm yummy' }) + }) + + it('swallows noise wholesale even when it contains intact recoverable fragments', () => { + // A noise blob can carry a few intact `\|(.*?)(?:\x07|\x1b\\)$/s const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/ const SGR_MOUSE_FRAGMENT_RE = /(? cursor) { - parsed.push(parseKeypress(text.slice(cursor, first.index!))) + const gap = text.slice(cursor, first.index!) + // Skip pure mouse-leak residue between recovered fragments; only emit + // real text gaps as keypresses. + if (!MOUSE_BURST_RESIDUE_RE.test(gap)) { + parsed.push(parseKeypress(gap)) + } } for (const match of run) { @@ -690,7 +733,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null { } if (cursor < text.length) { - parsed.push(parseKeypress(text.slice(cursor))) + const tail = text.slice(cursor) + // Swallow a pure mouse-leak residue tail (the head fragments recovered, but + // the burst trailed off into chewed-up shards). Emit only real trailing text. + if (!MOUSE_BURST_RESIDUE_RE.test(tail)) { + parsed.push(parseKeypress(tail)) + } } return parsed