Skip to content

fix: keep escape sequences intact across read boundaries (#123) - #130

Merged
meszmate merged 1 commit into
mainfrom
fix/split-escape-sequences
Aug 14, 2026
Merged

fix: keep escape sequences intact across read boundaries (#123)#130
meszmate merged 1 commit into
mainfrom
fix/split-escape-sequences

Conversation

@meszmate

Copy link
Copy Markdown
Owner

Fixes #123.

The bug

A terminal read hands over whatever bytes were ready, so a single sequence is regularly split in two. parse had no way to express "this is cut off, wait for more": when parseCsi returned null it fell through to consuming the ESC as an Escape key press, and the remaining bytes were re-read as individual characters.

During fast trackpad scrolling that turns SGR mouse reports into visible garbage ([<64;65;42M), and takes the app down when the leaked bytes land on a quit binding — exactly what @sessions-matthew reported.

The fix

Sequences are now framed structurally (ECMA-48 param/intermediate/final bytes, OSC & DCS string terminators) before being interpreted. That separates two failure modes which were previously indistinguishable:

before after
cut off by the read buffer ESC consumed as Escape, rest leaks as text parseStream.incomplete, bytes retained until the rest arrives
complete but not a key press (DA reply, cursor position report, OSC 52 answer, Kitty graphics ack) leaks its payload as text consumed as a unit and dropped

InputParser is the new stateful front end. It holds the tail of an unfinished sequence between reads, and because a lone ESC is byte-for-byte the start of every arrow key, it is held too and released as the Escape key after escape_timeout_ms (default 50 ms) of silence — the disambiguation vim and tmux use.

The issue's proposed patch used file-scope var pending_buf / pending_len. This avoids that: the buffer lives on the parser, which lives on the Program, so two programs (or two tests) cannot corrupt each other. It also can't deadlock — the original patch returned consumed = 0 for any parseCsi failure, so a complete-but-unrecognised sequence (e.g. a \x1b[?62;1;2c device attribute reply) would sit in the pending buffer forever and stall all further input.

Also fixed on the same path

  • u16 overflow in CSI parameter accumulationparams[n] * 10 + digit aborts the process on a debug build for any parameter with more than 5 digits. Found by the new fuzz-ish test; parameters now saturate. Same fix in mouse.parseSgr.
  • Multi-byte codepoints split across reads decoded as mojibake; they now wait for their continuation bytes.
  • Pastes larger than one read lost their opening marker and arrived as individual key presses. They now stream as paste events, split only on codepoint boundaries.
  • Legacy X10 mouse reports (CSI M + three raw bytes — what a terminal sends when it ignores the SGR request) are decoded instead of spilling three characters.
  • Input is drained until the terminal runs dry rather than one 256-byte read per frame, so a scroll burst no longer backs up across frames.

API

Additive only; parse / parseAll keep their signatures and their behaviour for callers handing over a complete buffer.

  • zz.InputParser — streaming parser (feed, pending, reset)
  • keyboard.parseStream / keyboard.parseFlush — the two halves parse is now built from
  • mouse.parseX10
  • Options.escape_timeout_ms (default 50)

Tests

New tests/input_stream_tests.zig (22 cases). The mouse and arrow-key tests re-run the same sequence split at every byte boundary, since the bug only shows up at specific offsets:

  • SGR mouse report split at every boundary → exactly one event
  • 20-report scroll burst chopped into 7-byte reads → 20 mouse events, zero key events
  • lone ESC held, then released on timeout; ESC + later keystroke → Escape then the key
  • DA reply / cursor position report / OSC 52 answer / Kitty graphics ack → swallowed, following keystroke still recognised
  • paste split across three reads (marker straddling the boundary) → one event
  • paste 3× the buffer size → chunks that reassemble byte-identically, no split codepoints
  • a never-terminating sequence longer than the buffer → cannot stall the stream
  • oversized parameters → no abort

zig build test and zig build both clean on 0.16.0.

A terminal read returns whatever bytes happen to be ready, so a single
sequence is regularly split in two. `parse` had no way to say "this is
cut off, wait for more": when `parseCsi` failed it fell through to
consuming the ESC as an Escape key press, and the rest of the sequence
was re-read as individual characters. During fast trackpad scrolling
that turns SGR mouse reports into visible garbage like `[<64;65;42M`,
and takes the app down entirely when the leaked bytes happen to hit a
quit binding.

Sequences are now framed structurally (ECMA-48) before being
interpreted, which separates the two failure modes that were previously
indistinguishable:

- Truncated by the buffer -> `parseStream` reports `.incomplete` and
  `InputParser` retains the bytes until the rest arrives.
- Complete but not a key press (device attribute replies, cursor
  position reports, OSC clipboard answers, Kitty graphics
  acknowledgements) -> consumed as a unit and dropped, instead of
  leaking their payload as text.

A lone ESC is indistinguishable from the start of a sequence, so it is
held too and released as the Escape key after `escape_timeout_ms`
(default 50 ms) of silence -- the disambiguation vim and tmux use.

Also fixed along the way, all reachable from the same input path:

- CSI parameter accumulation overflowed `u16` and aborted the process on
  a debug build; parameters now saturate.
- Multi-byte codepoints split across reads decoded as mojibake.
- Pastes larger than one read lost their opening marker and arrived as
  individual key presses; they now stream as `paste` events split on
  codepoint boundaries.
- Legacy X10 mouse reports (`CSI M` plus three raw bytes, what a
  terminal sends when it ignores the SGR request) are decoded rather
  than spilling three characters.
- Input is drained until the terminal runs dry instead of one 256-byte
  read per frame, so a scroll burst no longer backs up across frames.
meszmate added a commit that referenced this pull request Aug 14, 2026
CI only ever ran Debug. The `u16` overflow in `parseCsi` that #130 fixes
behaves differently either side of that line: Debug aborts the process,
ReleaseFast wraps silently and hands the parser a garbage coordinate.
Whichever way a bug lands, only one of the two builds shows it.

Adds ReleaseSafe and ReleaseFast test jobs on Linux. They catch codegen
and safety differences rather than platform ones, so there is no reason
to multiply them across the OS matrix.
@meszmate
meszmate merged commit acc25e3 into main Aug 14, 2026
9 checks passed
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.

SGR mouse sequences leak as garbage characters during rapid scrolling

1 participant