fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer (#35512)
* 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 `<b;c;r M` fragment makes fragment recovery return non-null, so the whole-text swallow never fired and the code emitted a pile of recovered mouse events instead of dropping the blob wholesale (contradicting the comment, and doing extra work mid-stall). Move the noise check ahead of fragment recovery so pure-noise tokens are dropped early. Add a regression test for a noise blob carrying intact fragments. - Drop the unnecessary `(e as { isPasted?: boolean })` cast in the test; discriminated-union narrowing on `e.kind === 'key'` exposes isPasted directly. * 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> * 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>
This commit is contained in:
@ -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 `<b;c;r M` fragments amid the chewed
|
||||
// shards. The whole-text noise check must run BEFORE fragment recovery —
|
||||
// otherwise parseTextWithSgrMouseFragments returns non-null and emits a
|
||||
// pile of recovered mouse events instead of dropping the blob wholesale.
|
||||
const blob = '<35;159;11M;44M20;43M0M7M<35;124;26M;47;40M9;15;32M5M2M'
|
||||
const [events] = parseMultipleKeypresses(INITIAL_STATE, blob)
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -65,6 +65,34 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
|
||||
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
|
||||
const SGR_MOUSE_FRAGMENT_RE = /(?<!\d)(?:\[<|<)?(?:[0-9]|[1-9][0-9]|1\d{2}|2[0-4]\d|25[0-5]);\d+;\d+[Mm]/g
|
||||
|
||||
// Whole-text mouse-burst noise fast path. When a heavy render blocks the event
|
||||
// loop past App's 50ms flush watchdog, a long burst of SGR mouse reports (mode
|
||||
// 1003 any-motion / 1006 SGR) can arrive as a single text token with prefixes
|
||||
// AND coordinate digits chewed off across many partial reads. The surviving
|
||||
// shards (1- and 2-param remnants, stray focus-in `[I`, lone `M`/`m`
|
||||
// terminators) are too degraded for SGR_MOUSE_FRAGMENT_RE, so the leftover
|
||||
// tail leaks into the composer and locks the user out (they can't type or exit).
|
||||
//
|
||||
// If the ENTIRE text token is drawn only from the mouse-leak alphabet
|
||||
// (`[ ] < ; I M m`, digits, and the stray spaces a burst can carry) AND it
|
||||
// carries the structural signature of mouse coordinates — ≥3 `M`/`m`
|
||||
// terminators, at least one digit, and at least one `;` separator — swallow it
|
||||
// wholesale. All three constraints together preserve real prose: `Mmm MMM mmm`
|
||||
// has no digit and no `;`, `see 1;2;3M for details` contains disqualifying
|
||||
// letters, and `1234;56;78M9;10;11M` has only two terminators.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const MOUSE_BURST_NOISE_RE = /^(?=[\s\S]*\d)(?=[\s\S]*;)(?=(?:[^Mm]*[Mm]){3})[\d;<\[\]IMm \x1b]+$/
|
||||
|
||||
// Residual-shard variant for the gaps BETWEEN / AFTER recovered fragments
|
||||
// inside parseTextWithSgrMouseFragments. A real recovery run leaves degraded
|
||||
// remnants (e.g. `M6M`, `7M;220;1MM0M`, lone `;157;47M`) that are pure
|
||||
// mouse-leak alphabet but too short to satisfy the ≥3-terminator whole-text
|
||||
// rule. Swallow such a residue only when it is pure alphabet AND carries a
|
||||
// digit AND at least one `M`/`m` — a prose gap like ` for details ` contains
|
||||
// disqualifying letters and never matches.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const MOUSE_BURST_RESIDUE_RE = /^(?=[^\d]*\d)(?=[^Mm]*[Mm])[\d;<\[\]IMm \x1b]+$/
|
||||
|
||||
function createPasteKey(content: string): ParsedKey {
|
||||
return {
|
||||
kind: 'key',
|
||||
@ -268,6 +296,16 @@ export function parseMultipleKeypresses(
|
||||
} else if (token.type === 'text') {
|
||||
if (inPaste) {
|
||||
pasteBuffer += token.value
|
||||
} else if (MOUSE_BURST_NOISE_RE.test(token.value)) {
|
||||
// Fully degraded mouse-burst noise — a heavy render (e.g. a sudo /
|
||||
// secret prompt repaint) blocked the event loop past App's 50ms flush
|
||||
// watchdog, so a long burst of SGR mouse reports arrived as text with
|
||||
// prefixes AND coordinate digits chewed off. Checked BEFORE fragment
|
||||
// recovery: a noise blob can still contain a few intact `<b;c;r M`
|
||||
// fragments, and parseTextWithSgrMouseFragments would then return
|
||||
// non-null and emit a pile of recovered mouse events instead of
|
||||
// dropping the blob wholesale. Swallow it here so it never leaks into
|
||||
// the composer (and we skip the extra fragment-recovery work mid-stall).
|
||||
} else {
|
||||
const mouseFragments = parseTextWithSgrMouseFragments(token.value)
|
||||
|
||||
@ -674,7 +712,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
|
||||
}
|
||||
|
||||
if (first.index! > 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
|
||||
|
||||
Reference in New Issue
Block a user