Files
aiturk-hermes-ide/web/src/lib/pty-resume-sanitizer.ts
T

129 lines
5.5 KiB
TypeScript

/**
* PTY resume output sanitizer — strips pathological ANSI sequences that
* Ink's two-pass virtual scroll emits during session resume.
*
* Three properties of the real pipeline drive this design:
*
* 1. **The PTY is in cooked mode (ONLCR).** `hermes_cli/pty_bridge.py` spawns
* via `ptyprocess.PtyProcess.spawn()` and never calls `setraw()`, so the
* line discipline rewrites every LF the child writes as CRLF. A burst
* therefore arrives as `\r\n\r\n\r\n…`, never as bare `\n\n\n…`, and any
* filter matching `\n{50,}` would never fire in production.
*
* 2. **Reads are chunked, not message-framed.** `bridge.read()` does
* `os.read(fd, 65536)` per drain tick and `pty_session.py` forwards each
* read as its own binary WebSocket frame. A CSI escape, a UTF-8 code
* point, *and* a long blank-line run can each straddle a frame boundary,
* so all three need trailing-state buffering to survive reassembly.
*
* 3. **Erase codes are only pathological during the resume replay.** Once the
* replay has settled, `ESC[K` / `ESC[X` are exactly how a TUI clears stale
* glyphs for spinners, progress bars, and status lines. Stripping them
* forever corrupts normal interactive output, so suppression is bounded to
* a short window after connect (see PTY_RESUME_SANITIZE_WINDOW_MS).
*/
/** A blank-line run: CRLF (real PTY, cooked mode) or bare LF (raw-mode PTY). */
const BLANK_LINE_BURST = /(?:\r?\n){50,}/g;
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
const ERASE_LINE = /\x1b\[\d*K/g;
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
const ERASE_CHAR = /\x1b\[\d*X/g;
/** Still-incomplete trailing escape: "\x1b", "\x1b[", "\x1b[\d*". */
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
const PARTIAL_ESC = /^\x1b(?:\[\d*)?$/;
/**
* A trailing run of newlines that may continue into the next frame. Held back
* so a burst split across frames still meets the collapse threshold instead of
* slipping through as sub-threshold fragments. A lone trailing `\r` is
* included: a frame boundary can fall between the CR and LF of a CRLF pair,
* and emitting the CR early would break one run into two shorter ones.
*
* Always matches (possibly empty, at end of input).
*/
const TRAILING_NEWLINES = /(?:\r?\n)*\r?$/;
/** Collapsed form of a pathological burst: one blank row, CRLF for xterm. */
const COLLAPSED_BURST = "\r\n\r\n";
/**
* Apply all suppression rules to a safely-completed string.
*
* @param stripErase When false, `ESC[K` / `ESC[X` are preserved. Blank-line
* burst collapsing still applies — a thousand-row burst is pathological
* whenever it appears, but erase codes are legitimate once resume settles.
* Exported for focused unit-testing of filter behaviour.
*/
export function applyPtyFilters(input: string, stripErase = true): string {
const collapsed = input.replace(BLANK_LINE_BURST, COLLAPSED_BURST);
if (!stripErase) return collapsed;
return collapsed.replace(ERASE_LINE, "").replace(ERASE_CHAR, "");
}
/** Stateful chunk processor that guards against cross-frame split sequences. */
export class PtyResumeSanitizer {
#pending = "";
#stripErase = true;
/**
* Stop stripping erase codes while continuing to collapse blank-line bursts.
* Called when the resume-replay window closes so that ordinary interactive
* redraws (spinners, progress bars, status lines) keep their `ESC[K`.
*/
endEraseSuppression(): void {
this.#stripErase = false;
}
/** True while erase-code stripping is still active. */
get isSuppressingErase(): boolean {
return this.#stripErase;
}
/** Feed one decoded WebSocket frame payload. Returns the sanitized output. */
next(chunk: string): string {
const combined = this.#pending + chunk;
if (combined === "") {
this.#pending = "";
return "";
}
// Hold back a trailing partial escape so a CSI split across frames is
// still recognised once its terminating byte arrives.
const lastEsc = combined.lastIndexOf("\x1b");
if (lastEsc !== -1 && PARTIAL_ESC.test(combined.slice(lastEsc))) {
this.#pending = combined.slice(lastEsc);
return applyPtyFilters(combined.slice(0, lastEsc), this.#stripErase);
}
// Hold back a trailing newline run so a burst spanning frames accumulates
// to the collapse threshold instead of leaking through in fragments.
// TRAILING_NEWLINES always matches; an empty match at end of input means
// the frame does not end mid-run and nothing needs to be held back.
const trailing = TRAILING_NEWLINES.exec(combined) as RegExpExecArray;
if (trailing.index === 0) {
// The whole frame is newlines — keep accumulating, emit nothing yet.
this.#pending = combined;
return "";
}
this.#pending = trailing[0];
return applyPtyFilters(combined.slice(0, trailing.index), this.#stripErase);
}
/**
* Drain buffered state at end of stream.
*
* A buffered *partial escape* is dropped rather than written: `#pending` only
* ever holds an incomplete sequence, and emitting one would leave xterm's
* parser in an "in-escape" state that swallows subsequent output after
* reconnect. A buffered *newline run* is safe and is emitted (collapsed).
*/
flush(): string {
const last = this.#pending;
this.#pending = "";
if (last === "" || last.includes("\x1b")) return "";
return applyPtyFilters(last, this.#stripErase);
}
}