129 lines
5.5 KiB
TypeScript
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);
|
|
}
|
|
}
|