fix: keep escape sequences intact across read boundaries (#123) - #130
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #123.
The bug
A terminal read hands over whatever bytes were ready, so a single sequence is regularly split in two.
parsehad no way to express "this is cut off, wait for more": whenparseCsireturned null it fell through to consuming theESCas 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:
ESCconsumed as Escape, rest leaks as textparseStream→.incomplete, bytes retained until the rest arrivesInputParseris the new stateful front end. It holds the tail of an unfinished sequence between reads, and because a loneESCis byte-for-byte the start of every arrow key, it is held too and released as the Escape key afterescape_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 theProgram, so two programs (or two tests) cannot corrupt each other. It also can't deadlock — the original patch returnedconsumed = 0for anyparseCsifailure, so a complete-but-unrecognised sequence (e.g. a\x1b[?62;1;2cdevice attribute reply) would sit in the pending buffer forever and stall all further input.Also fixed on the same path
u16overflow in CSI parameter accumulation —params[n] * 10 + digitaborts 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 inmouse.parseSgr.pasteevents, split only on codepoint boundaries.CSI M+ three raw bytes — what a terminal sends when it ignores the SGR request) are decoded instead of spilling three characters.API
Additive only;
parse/parseAllkeep 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 halvesparseis now built frommouse.parseX10Options.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:zig build testandzig buildboth clean on 0.16.0.