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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user