fix(tui): bound terminal input parsing so malformed sequences and non-bracketed pastes do not block the event loop

Three failure modes shared the same root cause on the StdinBuffer.process
hot path — unbounded synchronous work in a single call.

- `StdinBuffer.extractCompleteSequences` grew each escape candidate one code
  unit at a time and re-tested every prefix, so a malformed CSI/OSC/DCS/APC
  streamed across chunks re-inspected the accumulated buffer on every
  `process()` call (issue A). Replaced the grow-and-recheck loop with a
  single linear scan bounded by a per-type cap (CSI 4 KiB, OSC/DCS/APC 16
  MiB) and a resume-search offset carried on `StdinBuffer` so a legit
  chunked OSC 5522 image paste stays O(total) instead of O(total²).
- `BracketedPasteHandler` had no cap or timeout: a caller that bypassed
  StdinBuffer (fed the start marker with no end marker) grew `#buffer`
  forever (issue B, defense in depth). Added an optional `byteLimit` option
  (default 64 MiB) that aborts paste mode and delivers accumulated bytes as
  `pasteContent`, mirroring `StdinBuffer#abortPaste`.
- The `ProcessTerminal` data handler ran six always-executed escape-probe
  regex tests per `data` event; a 100 KB non-bracketed paste (terminal
  without DEC 2004 support) turned into ~600K regex executions (issue C).
  Added a fast path that forwards non-ESC sequences straight to the input
  handler when no reassembly buffer is holding state.

Verified with new stdin-buffer + bracketed-paste tests plus empirical
repros: a streamed 1 MiB malformed CSI drops from ~4300 ms to ~50 ms of
synchronous work, and a 1 MiB chunked OSC 5522 payload drops from ~6800 ms
to ~25 ms.

Fixes #4073
This commit is contained in:
roboomp
2026-07-01 07:19:28 +00:00
parent f70e4f1570
commit 089db21333
6 changed files with 413 additions and 222 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Bounded terminal input parsing so a malformed CSI/OSC/DCS/APC or a large non-bracketed paste no longer blocks the event loop. `StdinBuffer.extractCompleteSequences` now resolves each escape by a single linear scan with a per-type length cap (CSI 4 KiB, OSC/DCS/APC 16 MiB) and carries a resume-search offset so a chunked OSC 5522 payload stays O(total) instead of O(total²). `BracketedPasteHandler` gained a byte cap (default 64 MiB) that aborts paste mode and delivers the accumulated bytes when a lost end marker would otherwise hold memory forever — defense in depth for callers that bypass `StdinBuffer`. The `ProcessTerminal` data handler now takes a fast path when the sequence is not ESC-prefixed and no reassembly buffer is active, so a large non-bracketed paste skips six escape-probe regex tests per Unicode scalar ([#4073](https://github.com/can1357/oh-my-pi/issues/4073)).
## [16.2.12] - 2026-07-01
### Fixed
+46 -7
View File
@@ -40,6 +40,25 @@ export function decodeReencodedPasteControls(text: string): string {
.replace(REENCODED_CTRL_XTERM, decodeReencodedCtrlByte);
}
/**
* Options for {@link BracketedPasteHandler}.
*/
export type BracketedPasteHandlerOptions = {
/**
* Byte cap for buffered paste content (default: 64 MiB). When exceeded,
* paste mode is aborted and the accumulated content is delivered as
* `pasteContent` on the same `process()` call so a lost/corrupted end
* marker cannot consume unbounded memory. Mirrors `StdinBuffer#abortPaste`
* — defense in depth for callers that bypass `StdinBuffer` (issue #4073
* case B). The normal `ProcessTerminal` path re-wraps `StdinBuffer`'s
* bounded paste with both markers, so this cap only fires on alternate
* callers.
*/
byteLimit?: number;
};
const DEFAULT_BYTE_LIMIT = 64 * 1024 * 1024;
/**
* Handles bracketed paste mode buffering for terminal input components.
*
@@ -50,6 +69,11 @@ export function decodeReencodedPasteControls(text: string): string {
export class BracketedPasteHandler {
#buffer = "";
#active = false;
readonly #byteLimit: number;
constructor(options: BracketedPasteHandlerOptions = {}) {
this.#byteLimit = options.byteLimit ?? DEFAULT_BYTE_LIMIT;
}
/**
* Process incoming terminal data for bracketed paste sequences.
@@ -57,7 +81,8 @@ export class BracketedPasteHandler {
* @returns `{ handled: false }` if the data contains no paste sequence and
* should be processed normally. `{ handled: true }` if the data was
* consumed by paste buffering — `pasteContent` is set when a complete
* paste has been assembled; omitted when still buffering.
* paste has been assembled (or the byte cap has aborted a runaway
* buffer); omitted when still buffering.
*/
process(data: string): PasteResult {
if (data.includes(PASTE_START)) {
@@ -71,14 +96,28 @@ export class BracketedPasteHandler {
this.#buffer += data;
const endIndex = this.#buffer.indexOf(PASTE_END);
if (endIndex === -1) return { handled: true, remaining: "" };
if (endIndex !== -1) {
const pasteContent = this.#buffer.substring(0, endIndex);
const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
const pasteContent = this.#buffer.substring(0, endIndex);
const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
this.#buffer = "";
this.#active = false;
this.#buffer = "";
this.#active = false;
return { handled: true, pasteContent, remaining };
}
return { handled: true, pasteContent, remaining };
// Byte cap: a lost/corrupted end marker (ssh/tmux truncation) must not
// consume unbounded memory. Deliver the accumulated bytes so they are
// neither lost nor held forever, and reset paste mode so subsequent
// input recovers. See `StdinBuffer#abortPaste` for the sibling recovery
// semantics inside `StdinBuffer`.
if (this.#buffer.length > this.#byteLimit) {
const pasteContent = this.#buffer;
this.#buffer = "";
this.#active = false;
return { handled: true, pasteContent, remaining: "" };
}
return { handled: true, remaining: "" };
}
}
+212 -214
View File
@@ -43,171 +43,131 @@ const SGR_MOUSE_PARTIAL = /^\x1b\[<[\d;]*$/;
// completes (e.g. a bare ESC delivered while the kitty-active flag is
// stale); keep it small.
const PARTIAL_HOLD_MAX_MS = 150;
/**
* Check if a string is a complete escape sequence or needs more data
*/
function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" {
if (!data.startsWith(ESC)) {
return "not-escape";
}
// Escape-sequence length caps. `resolveEscapeEnd` scans within these bounds
// only, so a malformed CSI (missing final byte in `0x40-0x7E`) or a
// terminator-less OSC/DCS/APC cannot force `extractCompleteSequences` to
// re-inspect a growing prefix on every `process()` call — a single call
// stays bounded work, and a streamed run of garbage bytes is flushed as
// raw sequences instead of accumulated forever (issue #4073 case A).
//
// CSI is intentionally tight: real CSI keys, mouse reports, and DECRQM
// replies are always well under 4 KiB. OSC/DCS/APC allow much larger
// payloads (kitty OSC 5522 clipboard reads, Sixel DCS, kitty graphics APC),
// so the string-terminator cap is generous.
const MAX_CSI_BYTES = 4096;
const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
if (data.length === 1) {
return "incomplete";
}
const afterEsc = data.slice(1);
// CSI sequences: ESC [
if (afterEsc.startsWith("[")) {
// Check for old-style mouse sequence: ESC[M + 3 bytes
if (afterEsc.startsWith("[M")) {
// Old-style mouse needs ESC[M + 3 bytes = 6 total
return data.length >= 6 ? "complete" : "incomplete";
}
return isCompleteCsiSequence(data);
}
// OSC sequences: ESC ]
if (afterEsc.startsWith("]")) {
return isCompleteOscSequence(data);
}
// DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
if (afterEsc.startsWith("P")) {
return isCompleteDcsSequence(data);
}
// APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
if (afterEsc.startsWith("_")) {
return isCompleteApcSequence(data);
}
// SS3 sequences: ESC O
if (afterEsc.startsWith("O")) {
// ESC O followed by a single character
return afterEsc.length >= 2 ? "complete" : "incomplete";
}
// ESC-prefixed sequences (terminals with metaSendsEscape):
// Only when the inner ESC starts a CSI ('[') or SS3 ('O') sequence.
// Bare double-ESC (e.g. \x1b\x1bX) remains complete to avoid 10ms timeout lag.
if (afterEsc.startsWith(ESC)) {
const inner = data.slice(1);
const third = inner.charCodeAt(1);
if (third === 0x5b || third === 0x4f) {
return isCompleteSequence(inner);
}
return "complete";
}
// Meta key sequences: ESC followed by a single character
if (afterEsc.length === 1) {
return "complete";
}
// Unknown escape sequence - treat as complete
return "complete";
}
// SGR mouse report bodies live between `<` and the terminating `M`/`m`.
// Matched only when the trailing byte is a valid terminator, so the regex
// runs at most once per resolved report — never inside the growth loop.
const SGR_MOUSE_COMPLETE = /^<\d+;\d+;\d+[Mm]$/;
const DIGITS_ONLY = /^\d+$/;
/**
* Check if CSI sequence is complete
* CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
* Resolve the exclusive-end index of the escape sequence starting at `pos`
* (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
* for OSC/DCS/APC — it lets a chunked payload skip the prefix that a prior
* `process()` call already searched, so a large OSC 5522 image paste stays
* O(total) instead of O(total²).
*
* Meta-ESC (`\x1b\x1b…`) is not resolved here; the outer loop handles the
* disambiguation shared with the flush timer and the SGR mouse split. This
* helper returns -1 when the first byte after ESC is another ESC.
*
* Return codes:
* `end > pos` — complete sequence, exclusive end index.
* `-1` — incomplete, still under the per-type cap; buffer for more.
* `-2` — incomplete and the prefix already spans the per-type cap;
* the caller flushes it as raw bytes to guarantee progress.
*/
function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}[`)) {
return "complete";
}
function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
if (pos + 1 >= length) return -1;
const next = buffer.charCodeAt(pos + 1);
// Need at least ESC [ and one more character
if (data.length < 3) {
return "incomplete";
}
const payload = data.slice(2);
// CSI sequences end with a byte in the range 0x40-0x7E (@-~)
// This includes all letters and several special characters
const lastChar = payload[payload.length - 1];
const lastCharCode = lastChar.charCodeAt(0);
if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
// Special handling for SGR mouse sequences
// Format: ESC[<B;X;Ym or ESC[<B;X;YM
if (payload.startsWith("<")) {
// Must have format: <digits;digits;digits[Mm]
const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
if (mouseMatch) {
return "complete";
switch (next) {
case 0x1b /* ESC */:
// Meta-ESC handled by the caller.
return -1;
case 0x5b /* [ */: {
// CSI: ESC [ ... final byte in 0x40-0x7E.
if (pos + 2 >= length) return -1;
// Old-style X10 mouse: ESC [ M + 3 arbitrary bytes.
if (buffer.charCodeAt(pos + 2) === 0x4d /* M */) {
if (pos + 6 <= length) return pos + 6;
return length - pos >= MAX_CSI_BYTES ? -2 : -1;
}
// If it ends with M or m but doesn't match the pattern, still incomplete
if (lastChar === "M" || lastChar === "m") {
// Check if we have the right structure
const parts = payload.slice(1, -1).split(";");
if (parts.length === 3 && parts.every(p => /^\d+$/.test(p))) {
return "complete";
const capEnd = Math.min(length, pos + MAX_CSI_BYTES);
const isSgrMouse = buffer.charCodeAt(pos + 2) === 0x3c /* < */;
// Resume from where the last call gave up. `-1` preserves the
// safety window used by OSC/DCS/APC; CSI has no multi-byte
// terminator, but keeping the same rule avoids a fencepost gap.
let i = Math.max(pos + 2, resumeSearchFrom - 1);
if (i < pos + 2) i = pos + 2;
while (i < capEnd) {
const code = buffer.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) {
if (isSgrMouse) {
// SGR mouse only terminates on M/m. Any other final
// byte would be a malformed body — keep scanning to
// match the prior `isCompleteCsiSequence` semantics.
if (code !== 0x4d && code !== 0x6d) {
i++;
continue;
}
const payload = buffer.slice(pos + 2, i + 1);
if (SGR_MOUSE_COMPLETE.test(payload)) return i + 1;
const parts = payload.slice(1, -1).split(";");
if (parts.length === 3 && parts.every(p => DIGITS_ONLY.test(p))) return i + 1;
// Malformed body ending in M/m — keep scanning for a
// real terminator. Bounded by capEnd.
i++;
continue;
}
return i + 1;
}
i++;
}
return "incomplete";
return length - pos >= MAX_CSI_BYTES ? -2 : -1;
}
return "complete";
case 0x5d /* ] */: {
// OSC: ESC ] ... BEL or ST (ESC \).
const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
const belIndex = buffer.indexOf("\x07", searchFrom);
const stIndex = buffer.indexOf("\x1b\\", searchFrom);
let end = -1;
if (belIndex !== -1 && belIndex + 1 <= scanLimit) end = belIndex + 1;
if (stIndex !== -1 && stIndex + 2 <= scanLimit && (end === -1 || stIndex + 2 < end)) end = stIndex + 2;
if (end !== -1) return end;
return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
}
case 0x50 /* P */:
case 0x5f /* _ */: {
// DCS / APC: ESC P/_ ... ST (ESC \).
const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
const stIndex = buffer.indexOf("\x1b\\", searchFrom);
if (stIndex !== -1 && stIndex + 2 <= scanLimit) return stIndex + 2;
return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
}
case 0x4f /* O */:
// SS3: ESC O + 1 char.
return pos + 3 <= length ? pos + 3 : -1;
default:
// Meta chord: ESC + 1 char.
return pos + 2;
}
return "incomplete";
}
/**
* Check if OSC sequence is complete
* OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
* Per-type cap used to flush the incomplete prefix when `resolveEscapeEnd`
* returns -2. The cap keeps issue-4073's malformed streamed CSI/OSC/…
* bounded in both work and memory.
*/
function isCompleteOscSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}]`)) {
return "complete";
}
// OSC sequences end with ST (ESC \) or BEL (\x07)
if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
return "complete";
}
return "incomplete";
}
/**
* Check if DCS (Device Control String) sequence is complete
* DCS sequences: ESC P ... ST (where ST is ESC \)
* Used for XTVersion responses like ESC P >| ... ESC \
*/
function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}P`)) {
return "complete";
}
// DCS sequences end with ST (ESC \)
if (data.endsWith(`${ESC}\\`)) {
return "complete";
}
return "incomplete";
}
/**
* Check if APC (Application Program Command) sequence is complete
* APC sequences: ESC _ ... ST (where ST is ESC \)
* Used for Kitty graphics responses like ESC _ G ... ESC \
*/
function isCompleteApcSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}_`)) {
return "complete";
}
// APC sequences end with ST (ESC \)
if (data.endsWith(`${ESC}\\`)) {
return "complete";
}
return "incomplete";
function escapeCapFor(next: number): number {
// OSC/DCS/APC carry the large payloads (image paste, Sixel); CSI stays
// tight because real CSI keys/mouse/responses fit comfortably below 4 KiB.
return next === 0x5d || next === 0x50 || next === 0x5f ? MAX_STRING_SEQ_BYTES : MAX_CSI_BYTES;
}
/**
@@ -221,7 +181,10 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
return codepoint >= 32 ? codepoint : undefined;
}
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
function extractCompleteSequences(
buffer: string,
resumeSearchFrom: number,
): { sequences: string[]; remainder: string; resumeSearchFrom: number } {
const sequences: string[] = [];
const length = buffer.length;
let pos = 0;
@@ -229,75 +192,106 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
// Index-based scanning: this is the input hot path. Slicing the remaining
// buffer (or Array.from-ing it) per iteration would make plain-text bursts
// O(n²) — a 100KB non-bracketed paste must stay O(n).
while (pos < length) {
if (buffer.charCodeAt(pos) === 0x1b) {
// Find the end of this escape sequence by growing the candidate.
let end = pos + 1;
let consumed = false;
while (end <= length) {
const candidate = buffer.slice(pos, end);
const status = isCompleteSequence(candidate);
if (status === "incomplete") {
end++;
continue;
}
// "\x1b\x1b" is one of three things:
// 1. ESC prefixing CSI/SS3 (meta-CSI, held Esc joined by a follower):
// next byte is "[" or "O" — keep growing so the full sequence stays
// together. Consuming two bytes here would tear the follower and
// leak its tail as typed text (settings search filling with "[B"
// or "[<35;22;17M").
// 2. ESC followed by a legacy Alt chord (`\x1bd`, `\x1b\x7f`, …):
// emit the first ESC, then restart at the second ESC so downstream
// parsing still sees the Alt chord as one keypress (#3860 review).
// 3. Two real Esc keypresses bursted by terminal input batching:
// when the buffer ends here, hold the partial for the flush window
// so case 1/2 can still arrive; if no follower arrives, `flush()`
// splits the held remainder into two ESC events (#3857).
if (candidate === `${ESC}${ESC}`) {
if (end >= length) {
return { sequences, remainder: buffer.slice(pos) };
}
const next = buffer.charCodeAt(end);
if (next === 0x5b || next === 0x4f) {
end++;
continue;
}
sequences.push(ESC);
pos += 1;
consumed = true;
break;
}
// ESC + SGR mouse report is never a meta chord: alt-modified mouse
// reports carry the modifier in the button bits, not an ESC prefix.
// Deliver the bare ESC (a real Esc keypress) and the report separately.
if (candidate.startsWith(`${ESC}${ESC}[<`)) {
sequences.push(ESC, candidate.slice(1));
pos = end;
consumed = true;
break;
}
// "complete" — or "not-escape", which should not happen when
// starting with ESC; both consume the candidate.
sequences.push(candidate);
pos = end;
consumed = true;
break;
}
//
// `resumeSearchFrom` applies only when the buffer starts with an
// incomplete OSC/DCS/APC we buffered on the previous call; once any
// bytes are consumed (pos advances past the leading escape), the hint no
// longer maps to the current buffer offsets and is discarded.
let hint = resumeSearchFrom;
if (!consumed) {
return { sequences, remainder: buffer.slice(pos) };
}
} else {
while (pos < length) {
if (buffer.charCodeAt(pos) !== 0x1b) {
// Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
const codePoint = buffer.codePointAt(pos)!;
const charLength = codePoint > 0xffff ? 2 : 1;
sequences.push(buffer.slice(pos, pos + charLength));
pos += charLength;
hint = 0;
continue;
}
// `\x1b\x1b` is one of three things — see the outer switch below.
// Kept in the outer loop because it interacts with flush timing
// (bare `\x1b\x1b` is held for the timer chain) and with the SGR
// mouse split that splits `\x1b\x1b[<…` into `\x1b` + `\x1b[<…`.
if (pos + 1 < length && buffer.charCodeAt(pos + 1) === 0x1b) {
if (pos + 2 >= length) {
// Two real Esc keypresses bursted by terminal input batching:
// when the buffer ends here, hold the partial for the flush
// window so cases 1/2 can still arrive; if no follower
// arrives, `flush()` splits the held remainder into two ESC
// events (#3857).
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
}
const third = buffer.charCodeAt(pos + 2);
if (third !== 0x5b && third !== 0x4f) {
// ESC followed by a legacy Alt chord (`\x1bd`, `\x1b\x7f`, …):
// emit the first ESC, then restart at the second ESC so
// downstream parsing still sees the Alt chord as one
// keypress (#3860 review).
sequences.push(ESC);
pos += 1;
hint = 0;
continue;
}
// ESC prefixing CSI/SS3 (meta-CSI, held Esc joined by a follower):
// resolve the inner escape's end from `pos + 1`. Consuming two
// bytes here would tear the follower and leak its tail as typed
// text (settings search filling with "[B" or "[<35;22;17M").
const innerEnd = resolveEscapeEnd(buffer, pos + 1, length, 0);
if (innerEnd === -1) {
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
}
if (innerEnd === -2) {
const cap = escapeCapFor(third);
const flushEnd = Math.min(length, pos + cap);
sequences.push(buffer.slice(pos, flushEnd));
pos = flushEnd;
hint = 0;
continue;
}
// ESC + SGR mouse is never a meta chord: alt-modified mouse
// reports carry the modifier in the button bits, not an ESC
// prefix. Deliver the bare ESC and the report separately.
if (third === 0x5b && buffer.charCodeAt(pos + 3) === 0x3c) {
sequences.push(ESC);
sequences.push(buffer.slice(pos + 1, innerEnd));
pos = innerEnd;
hint = 0;
continue;
}
sequences.push(buffer.slice(pos, innerEnd));
pos = innerEnd;
hint = 0;
continue;
}
// Single ESC — resolve directly. Hint carries over from the previous
// call only when we are still on the buffered escape (pos === 0).
const end = resolveEscapeEnd(buffer, pos, length, pos === 0 ? hint : 0);
if (end === -1) {
// Buffer for more. When this is the leading OSC/DCS/APC,
// remember how far we scanned so the next `process()` call
// resumes from there instead of rescanning the whole buffer.
const next = pos + 1 < length ? buffer.charCodeAt(pos + 1) : -1;
const nextHint = pos === 0 && (next === 0x5d || next === 0x50 || next === 0x5f) ? length : 0;
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: nextHint };
}
if (end === -2) {
const next = buffer.charCodeAt(pos + 1);
const cap = escapeCapFor(next);
const flushEnd = Math.min(length, pos + cap);
sequences.push(buffer.slice(pos, flushEnd));
pos = flushEnd;
hint = 0;
continue;
}
sequences.push(buffer.slice(pos, end));
pos = end;
hint = 0;
}
return { sequences, remainder: "" };
return { sequences, remainder: "", resumeSearchFrom: 0 };
}
export type StdinBufferOptions = {
@@ -350,6 +344,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
#pasteWatchdog?: NodeJS.Timeout;
#pendingKittyPrintableCodepoint: number | undefined;
#pendingKittyPrintableAtMs = 0;
#escapeSearchOffset = 0;
constructor(options: StdinBufferOptions = {}) {
super();
@@ -402,7 +397,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
if (startIndex !== -1) {
if (startIndex > 0) {
const beforePaste = this.#buffer.slice(0, startIndex);
const result = extractCompleteSequences(beforePaste);
const result = extractCompleteSequences(beforePaste, 0);
for (const sequence of result.sequences) {
this.#emitDataSequence(sequence);
}
@@ -420,8 +415,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
return;
}
const result = extractCompleteSequences(this.#buffer);
const result = extractCompleteSequences(this.#buffer, this.#escapeSearchOffset);
this.#buffer = result.remainder;
this.#escapeSearchOffset = result.resumeSearchFrom;
for (const sequence of result.sequences) {
this.#emitDataSequence(sequence);
@@ -617,6 +613,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
const buffered = this.#buffer;
this.#buffer = "";
this.#escapeSearchOffset = 0;
this.#pendingKittyPrintableCodepoint = undefined;
// Bare double-ESC remainder (no disambiguating "[" / "O" arrived in time):
// two real Esc keypresses bursted by terminal batching, not a meta-CSI/SS3
@@ -639,6 +636,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
this.#pasteBytes = 0;
this.#pendingKittyPrintableCodepoint = undefined;
this.#partialHoldStartMs = 0;
this.#escapeSearchOffset = 0;
}
getBuffer(): string {
+21 -1
View File
@@ -691,8 +691,28 @@ export class ProcessTerminal implements Terminal {
// In-band resize report (DEC mode 2048): \x1b[48;rows;cols;yPixels;xPixels t
const inBandResizePattern = /^\x1b\[48;(\d+);(\d+);(\d+);(\d+)t$/;
// Forward individual sequences to the input handler
this.#stdinBuffer.on("data", (sequence: string) => {
// Fast path for plain-text bytes: every escape-probe regex below
// anchors on `^\x1b…`, so a byte that is not ESC can never match. A
// non-bracketed paste of N printable chars arrives as N per-scalar
// `data` events; running the full probe suite per event turns a
// 100 KB paste into ~600K regex executions and blocks the event
// loop. Skip straight to the input handler when no reassembly
// buffer is holding state that a non-ESC continuation could feed
// (issue #4073 case C).
if (
(sequence.length === 0 || sequence.charCodeAt(0) !== 0x1b) &&
this.#privateCsiResponseBuffer.length === 0 &&
this.#inBandResizeBuffer.length === 0 &&
this.#osc11ResponseBuffer.length === 0 &&
this.#osc99ResponseBuffer.length === 0
) {
if (this.#inputHandler) {
this.#inputHandler(sequence);
}
return;
}
// Reassemble split private CSI responses (DA1, kitty keyboard, Mode 2031).
// When the terminal writes the response slowly enough that the StdinBuffer's
// flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
+87
View File
@@ -0,0 +1,87 @@
/**
* Tests for BracketedPasteHandler
*
* Covers the byte-cap defense-in-depth (issue #4073 case B).
* The normal ProcessTerminal path re-wraps StdinBuffer's bounded paste with
* both markers so BracketedPasteHandler always receives them together; the
* byte cap only fires on alternate callers that bypass StdinBuffer.
*/
import { describe, expect, it } from "bun:test";
import { BracketedPasteHandler } from "@oh-my-pi/pi-tui/bracketed-paste";
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
describe("BracketedPasteHandler", () => {
describe("Byte cap (issue #4073 case B)", () => {
it("aborts paste mode and delivers accumulated bytes when the cap is exceeded", () => {
// A caller that bypasses StdinBuffer (feeds PASTE_START without an
// end marker) must not accumulate memory forever. The cap fires
// on the very chunk that would push the buffer past the limit,
// delivering the buffered bytes so they are neither lost nor held.
const handler = new BracketedPasteHandler({ byteLimit: 16 });
handler.process(PASTE_START);
const first = handler.process("0123456789");
expect(first).toEqual({ handled: true, remaining: "" });
const overflow = handler.process("abcdefgh");
expect(overflow.handled).toBe(true);
// @ts-expect-error - narrowed at runtime by the assertion above
expect(overflow.pasteContent).toBe("0123456789abcdefgh");
// @ts-expect-error - narrowed at runtime by the assertion above
expect(overflow.remaining).toBe("");
});
it("resets state after a cap-abort so subsequent input is not eaten as paste", () => {
const handler = new BracketedPasteHandler({ byteLimit: 8 });
handler.process(PASTE_START);
// One over the cap → cap-flush.
const abort = handler.process("0123456789");
expect(abort.handled).toBe(true);
// A follow-up plain byte after recovery must go through as
// unhandled so callers process it normally.
const next = handler.process("x");
expect(next).toEqual({ handled: false });
});
it("does not truncate a legitimate multi-chunk paste under the cap", () => {
const handler = new BracketedPasteHandler({ byteLimit: 1024 });
handler.process(PASTE_START);
handler.process("hello ");
handler.process("world");
const result = handler.process(PASTE_END);
expect(result.handled).toBe(true);
// @ts-expect-error - handled=true carries pasteContent
expect(result.pasteContent).toBe("hello world");
});
it("defaults to a generous cap that fits a small multi-chunk paste", () => {
// Default byte limit is 64 MiB — a normal-sized paste completes
// via the end marker, not via the cap.
const handler = new BracketedPasteHandler();
handler.process(PASTE_START);
handler.process("x".repeat(100_000));
const finish = handler.process(PASTE_END);
expect(finish.handled).toBe(true);
// @ts-expect-error - handled=true carries pasteContent
expect(finish.pasteContent.length).toBe(100_000);
});
});
describe("Baseline flow", () => {
it("returns handled=false when no paste marker has been seen", () => {
const handler = new BracketedPasteHandler();
expect(handler.process("plain text")).toEqual({ handled: false });
});
it("assembles a paste delivered as a single chunk with both markers", () => {
const handler = new BracketedPasteHandler();
const result = handler.process(`${PASTE_START}payload${PASTE_END}tail`);
expect(result.handled).toBe(true);
// @ts-expect-error - handled=true carries pasteContent + remaining
expect(result.pasteContent).toBe("payload");
// @ts-expect-error - remaining carries post-marker input
expect(result.remaining).toBe("tail");
});
});
});
+43
View File
@@ -570,6 +570,49 @@ describe("StdinBuffer", () => {
});
});
describe("Malformed Escape Bounds (issue #4073 case A)", () => {
it("caps a malformed CSI without terminator so a single process() stays bounded", () => {
// The prior grow-and-recheck inner loop rescanned every prefix on
// each call; a streamed run with no final byte in 0x40-0x7E left
// the whole prefix in the buffer and re-inspected it forever.
const input = `\x1b[${";".repeat(200_000)}`;
processInput(input);
// Cap-flush emitted the leading capped prefix as one raw sequence
// so progress is guaranteed; the rest is per-scalar plain text.
expect(emittedSequences.length).toBeGreaterThan(0);
expect(emittedSequences[0]!.length).toBeLessThan(input.length);
expect(buffer.getBuffer().length).toBe(0);
});
it("resumes OSC terminator search across chunks — chunked payload stays O(total)", () => {
// A legit chunked OSC 5522 payload must not force a full re-scan
// of the accumulated buffer per chunk. Delivery completes on the
// terminator; only the assembled sequence is emitted.
const chunkSize = 4096;
const chunkCount = 128;
const chunk = "a".repeat(chunkSize);
processInput("\x1b]5522;type=read;");
for (let i = 0; i < chunkCount - 1; i++) processInput(chunk);
processInput(`${chunk.slice(0, chunkSize - 1)}\x07`);
expect(emittedSequences.length).toBe(1);
expect(emittedSequences[0]!.startsWith("\x1b]5522;")).toBe(true);
expect(emittedSequences[0]!.endsWith("\x07")).toBe(true);
expect(buffer.getBuffer().length).toBe(0);
});
it("caps a streamed CSI garbage run so the buffer never grows without bound", () => {
// Streaming a malformed CSI (no terminator) in many small chunks
// used to accumulate the whole run in #buffer, giving O(n^2)
// cumulative work. After the cap fires, the buffer resets so
// subsequent chunks are re-scanned fresh.
processInput("\x1b[");
// Ten 8 KiB chunks — first two exceed MAX_CSI_BYTES (4 KiB) and
// force a cap-flush; the buffer must not retain the full run.
for (let i = 0; i < 10; i++) processInput(";".repeat(8192));
expect(buffer.getBuffer().length).toBeLessThan(8192);
});
});
describe("Destroy", () => {
it("should clear buffer on destroy", () => {
processInput("\x1b[<35");