diff --git a/application/i18n/locales/en/core.ts b/application/i18n/locales/en/core.ts index fd504323ca..2a66d44d93 100644 --- a/application/i18n/locales/en/core.ts +++ b/application/i18n/locales/en/core.ts @@ -677,6 +677,8 @@ Highlight the focused split pane: 'settings.terminal.rendering.renderer': 'Renderer', 'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.', 'settings.terminal.rendering.auto': 'Auto', + 'settings.terminal.rendering.allowTransparency': 'Allow transparency', + 'settings.terminal.rendering.allowTransparency.desc': 'Rasterise glyphs onto a transparent tile rather than onto their background colour. Costs a little quality on low-DPI displays, but lets one cached glyph serve every background it is drawn over — which keeps content that changes the background per cell (animated backgrounds, heatmaps, ANSI art) from re-rasterising every glyph on every frame. Takes effect on new terminal sessions only (xterm cannot change this after open).', 'settings.terminal.rendering.hibernateHiddenTabs': 'Hibernate hidden tabs', 'settings.terminal.rendering.hibernateHiddenTabs.desc': 'Dispose the terminal renderer for off-screen tabs to save memory while keeping the SSH session connected. Skipped during file transfers.', 'settings.terminal.rendering.hibernateHiddenTabsDelay': 'Hibernate delay', diff --git a/application/syncPayload.ts b/application/syncPayload.ts index e41b7d205b..3c89ae05fe 100644 --- a/application/syncPayload.ts +++ b/application/syncPayload.ts @@ -202,6 +202,7 @@ const SYNCABLE_TERMINAL_KEYS = [ 'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType', 'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont', 'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio', + 'allowTransparency', 'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText', 'kittyKeyboardProtocolEnabled', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste', diff --git a/components/settings/tabs/SettingsTerminalTab.tsx b/components/settings/tabs/SettingsTerminalTab.tsx index 449bb8672f..47a0499b69 100644 --- a/components/settings/tabs/SettingsTerminalTab.tsx +++ b/components/settings/tabs/SettingsTerminalTab.tsx @@ -1049,6 +1049,15 @@ function SettingsTerminalTab(props: { className="w-32" /> + + updateTerminalSetting("allowTransparency", v)} + /> + { test("isTerminalViewportScrolledUp is false when buffer is missing", () => { assert.equal(isTerminalViewportScrolledUp({ rows: 24 } as never), false); }); + +/** + * Chunk boundaries that land mid-escape must hold back only the split + * sequence. Scanning longest-suffix-first held back everything from the + * chunk's *first* ESC, so escape-dense frames (60fps TUI, per-cell truecolor + * backgrounds) starved xterm for whole chunks at a time and released ~1MB + * bursts once a chunk happened to end cleanly. + */ +const escapeDenseFrame = (cells: number): string => { + let out = SYNC_START + "\x1b[1;1H"; + for (let i = 0; i < cells; i += 1) { + out += `\x1b[0m\x1b[38;5;245m\x1b[48;2;${i % 256};128;200m#`; + } + return out + SYNC_END; +}; + +test("a mid-escape chunk boundary holds back only the split sequence", () => { + const state = createSyncBlockFilterState(); + const frame = escapeDenseFrame(4000); + // Split inside the trailing `\x1b[48;2;...m` of some cell. + const cut = frame.lastIndexOf("\x1b[48;2;", frame.length - 40) + 6; + const emitted = filterSyncBlockClears(frame.slice(0, cut), state); + + assert.ok( + state.pending.length < 32, + `pending should hold one partial sequence, held ${state.pending.length} bytes`, + ); + assert.equal(emitted + state.pending, frame.slice(0, cut)); +}); + +test("escape-dense frames stream through without withholding whole chunks", () => { + const state = createSyncBlockFilterState(); + const stream = escapeDenseFrame(4000) + escapeDenseFrame(4000); + const CHUNK = 8192; + let emitted = ""; + let emptyEmits = 0; + + for (let i = 0; i < stream.length; i += CHUNK) { + const output = filterSyncBlockClears(stream.slice(i, i + CHUNK), state); + if (output.length === 0) emptyEmits += 1; + emitted += output; + assert.ok( + state.pending.length < CHUNK, + `pending must not accumulate across chunks, reached ${state.pending.length} bytes`, + ); + } + + assert.equal(emptyEmits, 0, "every chunk should release data to xterm"); + assert.equal(emitted + state.pending, stream); +}); diff --git a/components/terminal/runtime/filterSyncBlockClears.ts b/components/terminal/runtime/filterSyncBlockClears.ts index 082fe23d59..915980bb59 100644 --- a/components/terminal/runtime/filterSyncBlockClears.ts +++ b/components/terminal/runtime/filterSyncBlockClears.ts @@ -152,7 +152,20 @@ const splitPendingMarkerSuffix = (input: string): { emit: string; pending: strin // Only suffixes that start with ESC can qualify; skip other start positions // with a charCode probe so no substring is allocated for them. - for (let length = input.length; length > 0; length -= 1) { + // + // Scan SHORTEST suffix first (from the end of the chunk backwards). + // `isIncompleteEscapePrefix` walks forward across *complete* sequences and + // only reports the incomplete one it eventually reaches, so it also answers + // `true` for every longer suffix that merely contains the incomplete tail — + // including the one starting at the chunk's first ESC. Scanning longest-first + // therefore held back everything from that first ESC onwards instead of just + // the split sequence. On escape-dense output (a 60fps TUI painting per-cell + // truecolor backgrounds) the first ESC sits within a few bytes of the chunk + // start, so whole chunks were withheld and `state.pending` grew across chunks + // until one happened to end on a clean boundary — xterm then received nothing + // for hundreds of ms followed by a ~1MB burst. The last ESC in the chunk is + // the real split point: an incomplete CSI cannot contain a further ESC. + for (let length = 1; length <= input.length; length += 1) { if (input.charCodeAt(input.length - length) !== 0x1b) { continue; } diff --git a/components/terminal/runtime/syncFrameBoundary.test.ts b/components/terminal/runtime/syncFrameBoundary.test.ts new file mode 100644 index 0000000000..2a97774dc0 --- /dev/null +++ b/components/terminal/runtime/syncFrameBoundary.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { frameSafeSliceEnd, isInsideSyncBlockAt } from "./syncFrameBoundary"; + +const H = "\x1b[?2026h"; +const L = "\x1b[?2026l"; + +test("isInsideSyncBlockAt: open before close is inside", () => { + const data = `${H}frame body`; + assert.equal(isInsideSyncBlockAt(data, 0, data.length), true); +}); + +test("isInsideSyncBlockAt: closed block is not inside", () => { + const data = `${H}frame${L}`; + assert.equal(isInsideSyncBlockAt(data, 0, data.length), false); +}); + +test("isInsideSyncBlockAt: point between close and next open is not inside", () => { + const data = `${H}a${L}XX${H}b${L}`; + const between = `${H}a${L}X`.length; + assert.equal(isInsideSyncBlockAt(data, 0, between), false); +}); + +test("isInsideSyncBlockAt: a DECRQM query is not a frame boundary", () => { + const data = "\x1b[?2026$pplain text"; + assert.equal(isInsideSyncBlockAt(data, 0, data.length), false); +}); + +test("isInsideSyncBlockAt: a cut mid-close still counts as inside", () => { + // startsWith matches whole SYNC_CLOSE in data even when `to` lands inside the + // marker. The scan must not flip to closed until the full closer is before `to`. + const frame = `${H}${"x".repeat(20)}${L}`; + const closeStart = frame.length - L.length; + for (let mid = 1; mid < L.length; mid++) { + const to = closeStart + mid; + assert.equal( + isInsideSyncBlockAt(frame, 0, to), + true, + `mid-close at +${mid} must still be inside the open block`, + ); + } + assert.equal(isInsideSyncBlockAt(frame, 0, frame.length), false); +}); + +test("frameSafeSliceEnd: a cut inside a frame extends to past its close", () => { + const frame = `${H}${"x".repeat(100)}${L}`; + const data = `${frame}${frame}`; + // Desired cut lands inside the first frame. + const cut = H.length + 50; + const end = frameSafeSliceEnd(data, 0, cut); + assert.equal(end, frame.length, "must extend to the end of the open frame"); + assert.equal(isInsideSyncBlockAt(data, 0, end), false); +}); + +test("frameSafeSliceEnd: a cut between frames is left untouched", () => { + const frame = `${H}${"x".repeat(100)}${L}`; + const data = `${frame}${frame}`; + const cut = frame.length; // exactly on the boundary + assert.equal(frameSafeSliceEnd(data, 0, cut), cut); +}); + +test("frameSafeSliceEnd: an unterminated frame is held to the end", () => { + const data = `${H}${"x".repeat(100)}`; // no close + const cut = H.length + 50; + assert.equal(frameSafeSliceEnd(data, 0, cut), data.length); +}); + +test("frameSafeSliceEnd: plain output is never adjusted", () => { + const data = "just some normal terminal output with no sync blocks"; + assert.equal(frameSafeSliceEnd(data, 0, 20), 20); +}); + +test("frameSafeSliceEnd: end at data.length is returned as-is", () => { + const data = `${H}x${L}`; + assert.equal(frameSafeSliceEnd(data, 0, data.length), data.length); +}); + +test("frameSafeSliceEnd: never moves the end backwards", () => { + const frame = `${H}${"x".repeat(100)}${L}`; + const data = `${frame}tail`; + const cut = H.length + 10; + const end = frameSafeSliceEnd(data, 0, cut); + assert.ok(end >= cut, "the adjusted end must not precede the desired end"); +}); + +test("frameSafeSliceEnd: a cut inside the close marker includes the full marker", () => { + const frame = `${H}${"x".repeat(20)}${L}`; + const data = `${frame}tail`; + // Land mid-close: after ESC[?2026 but before the final `l`. + const closeStart = frame.length - L.length; + for (let mid = 1; mid < L.length; mid++) { + const cut = closeStart + mid; + const end = frameSafeSliceEnd(data, 0, cut); + assert.equal( + end, + frame.length, + `mid-close cut at +${mid} must extend to full close marker`, + ); + assert.equal( + data.slice(0, end).endsWith(L), + true, + "slice must end on a complete close marker", + ); + } +}); + +test("frameSafeSliceEnd: a cut exactly at the close start still completes the frame", () => { + const frame = `${H}${"x".repeat(20)}${L}`; + const data = `${frame}next`; + const closeStart = frame.length - L.length; + // At the start of the close the block is still open, so extend past it. + assert.equal(frameSafeSliceEnd(data, 0, closeStart), frame.length); +}); diff --git a/components/terminal/runtime/syncFrameBoundary.ts b/components/terminal/runtime/syncFrameBoundary.ts new file mode 100644 index 0000000000..c3694e47f0 --- /dev/null +++ b/components/terminal/runtime/syncFrameBoundary.ts @@ -0,0 +1,147 @@ +/** + * DEC 2026 synchronized-output frame boundaries, for slicing terminal output + * without tearing a frame. + * + * A modern full-screen TUI (Tachikoma, and most others) enters the alternate + * screen once, then delimits every rendered frame with a DEC private mode 2026 + * synchronized-output block: `\x1b[?2026h` … full frame … `\x1b[?2026l`. xterm + * buffers rendering while the block is open and paints once on close, so a + * frame is coherent as long as its whole block reaches xterm before xterm's + * 1000ms synchronized-output timeout expires (RenderService SyncOutputHandler). + * + * The write coalescer/slicer, however, is only aware of alt-screen DECSET + * toggles — it never sees 2026 — so it slices a continuous frame stream by + * byte size and hands the shards to xterm across `setTimeout` gaps. A shard + * boundary that lands inside an open block leaves xterm mid-frame; if the rest + * of the frame arrives after the 1000ms timeout, xterm force-flushes a partial + * frame and the display tears. + * + * These helpers let the slicer keep every 2026 block whole. + */ + +const SYNC_OPEN = "\x1b[?2026h"; +const SYNC_CLOSE = "\x1b[?2026l"; +/** 8-bit C1 CSI form of the same markers (`CSI` = 0x9B). xterm accepts both. */ +const SYNC_OPEN_C1 = "\x9b?2026h"; +const SYNC_CLOSE_C1 = "\x9b?2026l"; + +const indexOfSyncMarkerPrefix = (data: string, from: number): number => { + const a = data.indexOf("\x1b[?2026", from); + const b = data.indexOf("\x9b?2026", from); + if (a === -1) return b; + if (b === -1) return a; + return Math.min(a, b); +}; + +/** + * Sync-block nesting state at `to`, scanning `data` from `from`. + * + * DEC 2026 does not nest in practice (a frame is one open/close pair), so this + * tracks "open" as a boolean latched by the most recent marker rather than a + * depth count. Returns whether an open block is still unclosed at `to`. + * Recognizes both 7-bit ESC CSI and 8-bit C1 CSI forms (Codex P2). + */ +export function isInsideSyncBlockAt(data: string, from: number, to: number): boolean { + let open = false; + let i = indexOfSyncMarkerPrefix(data, from); + while (i !== -1 && i < to) { + if (data.startsWith(SYNC_OPEN, i) || data.startsWith(SYNC_OPEN_C1, i)) { + const len = data.startsWith(SYNC_OPEN, i) ? SYNC_OPEN.length : SYNC_OPEN_C1.length; + // Only latch open once the whole opener is before `to`. A cut mid-open + // leaves the block not yet entered for this scan. + if (i + len > to) break; + open = true; + i += len; + } else if (data.startsWith(SYNC_CLOSE, i) || data.startsWith(SYNC_CLOSE_C1, i)) { + const len = data.startsWith(SYNC_CLOSE, i) ? SYNC_CLOSE.length : SYNC_CLOSE_C1.length; + // Only latch closed once the whole closer is before `to`. `startsWith` + // matches against full `data`, so a mid-close cut would otherwise see + // the complete marker and report "outside" while the trailing `l` is + // still past `to` — splitting the close sequence itself. + if (i + len > to) break; + open = false; + i += len; + } else { + // A different `?2026` sequence (e.g. a DECRQM query `\x1b[?2026$p`) — not + // a frame boundary; step past this ESC/C1 and keep scanning. + i += 1; + } + i = indexOfSyncMarkerPrefix(data, i); + } + return open; +} + +/** + * If `pos` lands strictly inside a DEC 2026 close marker (`ESC[?2026l`), return + * the index just past that full marker; otherwise return `pos` unchanged. + * + * Defence in depth with {@link isInsideSyncBlockAt}: that helper already keeps + * a mid-close cut "inside" so the open-block path extends past the closer, but + * this also catches mid-close cuts if a caller ever uses a looser inside check. + * Without either fix, a large-write slicer can split `\x1b[?2026` from the + * trailing `l` and leave xterm stuck in synchronized-output mode. + */ +function extendPastCloseMarkerIfSplit( + data: string, + offset: number, + pos: number, +): number { + if (pos <= offset || pos >= data.length) return pos; + // pos is strictly inside SYNC_CLOSE when some candidateStart < pos and + // candidateStart + SYNC_CLOSE.length > pos, and data starts with SYNC_CLOSE + // there. Check each proper prefix length that ends at pos. + for (const closer of [SYNC_CLOSE, SYNC_CLOSE_C1]) { + for (let k = 1; k < closer.length; k++) { + const candidateStart = pos - k; + if (candidateStart < offset) break; + if (data.startsWith(closer, candidateStart)) { + return candidateStart + closer.length; + } + } + } + return pos; +} + +/** + * A slice end at or after `desiredEnd` that never falls strictly inside an open + * DEC 2026 block, and never splits a close marker. + * + * If `desiredEnd` lands inside an open frame, it is pushed forward to just past + * that frame's `\x1b[?2026l`. If the frame never closes within `data`, the end + * is pushed to `data.length` so the incomplete frame is held for the next + * write rather than emitted in pieces. If `desiredEnd` lands mid-close marker + * after a completed block, the cut is extended to include the full marker. + * + * Never moves the end backwards, so it composes with the slicer's other + * boundary rules (which only ever shrink a slice). + */ +export function frameSafeSliceEnd( + data: string, + offset: number, + desiredEnd: number, +): number { + if (desiredEnd >= data.length) return data.length; + // Never leave a half-written close marker at a slice boundary — even when + // the block is already considered closed at desiredEnd. + const end = extendPastCloseMarkerIfSplit(data, offset, desiredEnd); + if (!isInsideSyncBlockAt(data, offset, end)) return end; + const closeA = data.indexOf(SYNC_CLOSE, end); + const closeB = data.indexOf(SYNC_CLOSE_C1, end); + let close = -1; + let closeLen = SYNC_CLOSE.length; + if (closeA === -1) { + close = closeB; + closeLen = SYNC_CLOSE_C1.length; + } else if (closeB === -1) { + close = closeA; + closeLen = SYNC_CLOSE.length; + } else if (closeA <= closeB) { + close = closeA; + closeLen = SYNC_CLOSE.length; + } else { + close = closeB; + closeLen = SYNC_CLOSE_C1.length; + } + if (close === -1) return data.length; + return close + closeLen; +} diff --git a/components/terminal/runtime/terminalFlowConstants.ts b/components/terminal/runtime/terminalFlowConstants.ts index fe43635704..de34450d39 100644 --- a/components/terminal/runtime/terminalFlowConstants.ts +++ b/components/terminal/runtime/terminalFlowConstants.ts @@ -8,6 +8,11 @@ import terminalFlowConstants from "../../../infrastructure/config/terminalFlowCo */ export const FLOW_HIGH_WATER_MARK = terminalFlowConstants.FLOW_HIGH_WATER_MARK; export const FLOW_LOW_WATER_MARK = terminalFlowConstants.FLOW_LOW_WATER_MARK; +// Relaxed watermarks for local shells, which have no network to overwhelm. +export const LOCAL_FLOW_HIGH_WATER_MARK = + terminalFlowConstants.LOCAL_FLOW_HIGH_WATER_MARK; +export const LOCAL_FLOW_LOW_WATER_MARK = + terminalFlowConstants.LOCAL_FLOW_LOW_WATER_MARK; export const FLOW_CHAR_COUNT_ACK_SIZE = terminalFlowConstants.FLOW_CHAR_COUNT_ACK_SIZE; export const MAX_PENDING_WRITE_COALESCE_BYTES = terminalFlowConstants.MAX_PENDING_WRITE_COALESCE_BYTES; diff --git a/components/terminal/runtime/terminalFrameGate.test.ts b/components/terminal/runtime/terminalFrameGate.test.ts new file mode 100644 index 0000000000..8650f3a345 --- /dev/null +++ b/components/terminal/runtime/terminalFrameGate.test.ts @@ -0,0 +1,305 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + apportionFrameGateIngress, + collapseAndSplit, + endsWithSyncOpenerPrefix, + isDroppableVisualPayload, + makesFullRepaint, + payloadContainsSgr, + payloadMayAutowrapScroll, + viewportRepaintCoverage, +} from "./terminalFrameGate.ts"; + +const ON = "\x1b[?2026h"; +const OFF = "\x1b[?2026l"; +const HOME = "\x1b[1;1H"; +const frame = (paint: string) => `${ON}${HOME}${paint}${OFF}`; +// Most collapse tests isolate the frame-boundary logic; a permissive predicate +// treats every homing successor as a full repaint. +const always = () => true; + +test("no frames: everything is complete, nothing held", () => { + assert.deepEqual(collapseAndSplit("plain text", always), { + complete: "plain text", + partial: "", + dropped: 0, + }); +}); + +test("a trailing unterminated frame is held as partial", () => { + const a = frame("A"); + const partialFrame = `${ON}${HOME}half`; + assert.deepEqual(collapseAndSplit(a + partialFrame, always), { + complete: a, + partial: partialFrame, + dropped: 0, + }); +}); + +test("collapses a run of full-repaint frames to the last", () => { + const a = frame("A"); + const b = frame("B"); + const c = frame("C"); + const r = collapseAndSplit(a + b + c, always); + assert.equal(r.complete, c); + assert.equal(r.dropped, a.length + b.length); +}); + +test("collapses complete frames but still holds a trailing partial", () => { + const a = frame("A"); + const b = frame("B"); + const partialFrame = `${ON}${HOME}new`; + const r = collapseAndSplit(a + b + partialFrame, always); + assert.equal(r.complete, b); + assert.equal(r.partial, partialFrame); + assert.equal(r.dropped, a.length); +}); + +test("does not drop across a non-empty gap between frames", () => { + const a = frame("A"); + const b = frame("B"); + const input = a + "\r\n" + b; + assert.deepEqual(collapseAndSplit(input, always), { complete: input, partial: "", dropped: 0 }); +}); + +test("does not drop when the successor is not a full repaint", () => { + const a = frame("AAAA"); + const b = frame("B"); + const input = a + b; + // Predicate rejects the successor → nothing collapses. + assert.deepEqual(collapseAndSplit(input, () => false), { complete: input, partial: "", dropped: 0 }); +}); + +test("does not drop a frame whose payload is not purely visual", () => { + const bell = `${ON}${HOME}ping\x07${OFF}`; // carries a BEL + const b = frame("B"); + const input = bell + b; + assert.deepEqual(collapseAndSplit(input, always), { complete: input, partial: "", dropped: 0 }); +}); + +// ---- isDroppableVisualPayload --------------------------------------------- + +test("droppable payload: cursor moves, SGR, erase and text are allowed", () => { + assert.equal(isDroppableVisualPayload("\x1b[1;1H\x1b[38;5;5mabc\x1b[K"), true); + assert.equal(isDroppableVisualPayload("\r\tplain text"), true); + assert.equal(isDroppableVisualPayload("\nrepaint"), false, "LF can scroll scrollback"); + assert.equal(isDroppableVisualPayload("\r\n\tplain text"), false, "LF not droppable"); + assert.equal(isDroppableVisualPayload("\x1b[2Jfull"), true); +}); + +test("un-droppable payload: side-effecting sequences are rejected", () => { + assert.equal(isDroppableVisualPayload("bell\x07"), false, "BEL"); + assert.equal(isDroppableVisualPayload("\x1b]0;title\x07"), false, "OSC"); + assert.equal(isDroppableVisualPayload("\x1b[6n"), false, "device status report query"); + assert.equal(isDroppableVisualPayload("\x1b[?25l"), false, "private mode"); + assert.equal(isDroppableVisualPayload("\x1b[?1049h"), false, "alt-screen"); + assert.equal(isDroppableVisualPayload("\x1bP1;2q\x1b\\"), false, "DCS"); + assert.equal(isDroppableVisualPayload("\x1b[c"), false, "device attributes"); + assert.equal(isDroppableVisualPayload("\x1b[1S"), false, "SU scrolls buffer"); + assert.equal(isDroppableVisualPayload("\x1b[1T"), false, "SD scrolls buffer"); +}); + +// ---- viewportRepaintCoverage / makesFullRepaint --------------------------- + +test("coverage counts distinct written cells, with wrap", () => { + // Paints only count after an origin reset (CUP/home — not ED2 alone). + assert.equal(viewportRepaintCoverage(`${HOME}abcd`, 4, 1), 4); + assert.equal(viewportRepaintCoverage(`${HOME}abcd`, 2, 2), 4); // wraps to second row + assert.equal(viewportRepaintCoverage(`${HOME}ab`, 4, 1), 2); + assert.equal(viewportRepaintCoverage("abcd", 4, 1), 0, "no origin → no coverage"); +}); + +test("coverage honors cursor positioning and erases", () => { + // ED2 is not coverage — may be stripped later when scrolled up. + assert.equal(viewportRepaintCoverage("\x1b[2J", 4, 2), 0, "ED2 grants no coverage credit"); + // ED2 does not home the cursor; paints after ED2 without CUP stay uncounted. + assert.equal( + viewportRepaintCoverage(`\x1b[2J${"z".repeat(8)}`, 4, 2), + 0, + "ED2 alone is not a known origin", + ); + assert.equal(viewportRepaintCoverage("\x1b[1;1H\x1b[K", 4, 2), 4, "erase-line covers one row"); + // CUP to row 2 then one char covers a single cell there. + assert.equal(viewportRepaintCoverage("\x1b[2;3Hx", 4, 2), 1); +}); + +test("makesFullRepaint accepts a whole-screen paint and rejects a small update", () => { + const cols = 4; + const rows = 2; + const fullPaint = `${HOME}${"z".repeat(cols * rows)}`; // writes every cell + const smallPaint = `${HOME}z`; // one cell + assert.equal(makesFullRepaint(fullPaint, cols, rows), true); + assert.equal(makesFullRepaint(smallPaint, cols, rows), false); + // SGR-heavy small update must not pass on byte length alone. + const sgrHeavySmall = `${HOME}\x1b[38;2;1;2;3m\x1b[48;2;4;5;6mz`; + assert.equal(makesFullRepaint(sgrHeavySmall, cols, rows), false); +}); + +test("makesFullRepaint rejects partial paints even when well above 40% coverage", () => { + const cols = 10; + const rows = 10; + // 60% of cells — previously accepted under the 0.4 bar, must now fail. + const partial = `${HOME}${"z".repeat(Math.floor(cols * rows * 0.6))}`; + assert.equal(makesFullRepaint(partial, cols, rows), false); + // 98% is still short of the 0.99 bar (must not drop prior frames). + const almost = `${HOME}${"z".repeat(98)}`; + assert.equal(makesFullRepaint(almost, cols, rows), false); + // ED2 alone is not a full repaint (may be stripped when scrolled up). + assert.equal(makesFullRepaint("\x1b[2J", cols, rows), false); + // Near-full (>= 99%) is accepted: 99 of 100 cells. + const nearFull = `${HOME}${"z".repeat(99)}`; + assert.equal(makesFullRepaint(nearFull, cols, rows), true); + // Exact full coverage is accepted. + const full = `${HOME}${"z".repeat(100)}`; + assert.equal(makesFullRepaint(full, cols, rows), true); +}); + +test("collapse keeps SGR-bearing frames when the successor has no SGR", () => { + const sgrOnly = `${ON}${HOME}\x1b[31m${OFF}`; + // Full-viewport successor without SGR would otherwise drop the SGR frame and + // leave xterm on the previous color/style for the plain paint. + const plainFull = `${ON}${HOME}${"x".repeat(8)}${OFF}`; + const buffer = sgrOnly + plainFull; + const result = collapseAndSplit(buffer, (content) => makesFullRepaint(content, 4, 2)); + assert.equal(result.dropped, 0, "must not drop SGR when successor omits SGR"); + assert.ok(result.complete.includes("\x1b[31m")); + assert.equal(payloadContainsSgr("\x1b[1;1H\x1b[31mtext"), true); + assert.equal(payloadContainsSgr("\x1b[1;1Hplain"), false); +}); + +test("collapse drops only when coverage proves a full repaint", () => { + const cols = 4; + const rows = 2; + const pred = (c: string) => makesFullRepaint(c, cols, rows); + const stale = frame("AAAAAAAA"); + const fullNext = frame("z".repeat(cols * rows)); + const smallNext = frame("z"); + // Full-repaint successor → predecessor dropped. + assert.equal(collapseAndSplit(stale + fullNext, pred).dropped, stale.length); + // Small successor → nothing dropped. + assert.equal(collapseAndSplit(stale + smallNext, pred).dropped, 0); + // Mid-coverage successor (~50%) must not drop the predecessor. + const halfNext = frame("z".repeat(Math.floor((cols * rows) / 2))); + assert.equal(collapseAndSplit(stale + halfNext, pred).dropped, 0); +}); + +// ---- ingress apportioning -------------------------------------------------- + +test("ingress apportioning always sums back to the total", () => { + const cases: Array<[number, number, number, number, number]> = [ + [1000, 1000, 400, 400, 200], + [1500, 1000, 400, 400, 200], + [700, 1000, 400, 400, 200], + [999, 1000, 333, 333, 334], + [0, 0, 0, 0, 0], + [500, 500, 0, 0, 500], + [500, 500, 500, 0, 0], + [500, 500, 0, 500, 0], + ]; + for (const [total, totalChars, fwd, drop, held] of cases) { + const s = apportionFrameGateIngress(total, totalChars, fwd, drop, held); + assert.equal(s.forward + s.dropped + s.held, total); + assert.ok(s.forward >= 0 && s.dropped >= 0 && s.held >= 0); + } +}); + +test("ingress apportioning routes bytes to the right bucket in the exact case", () => { + assert.deepEqual(apportionFrameGateIngress(1000, 1000, 400, 400, 200), { + forward: 400, + dropped: 400, + held: 200, + }); +}); + +test("ED3 (clear scrollback) makes a frame un-droppable", () => { + assert.equal(isDroppableVisualPayload("\x1b[1;1H\x1b[2Jrepaint"), true, "ED2 viewport clear is fine"); + assert.equal(isDroppableVisualPayload("\x1b[1;1H\x1b[3Jrepaint"), false, "ED3 clears scrollback"); +}); + +test("ED3 does not count as viewport coverage for full-repaint drops", () => { + const cols = 4; + const rows = 2; + // ED3 alone must not mark any viewport cells. + assert.equal(viewportRepaintCoverage("\x1b[3J", cols, rows), 0, "ED3 covers zero viewport cells"); + assert.equal(makesFullRepaint("\x1b[3J", cols, rows), false, "ED3 is not a full viewport repaint"); + // ED2 also grants no coverage (may be stripped when scrolled up). + assert.equal(viewportRepaintCoverage("\x1b[2J", cols, rows), 0, "ED2 grants no coverage credit"); + // A successor that only does ED3 must not justify dropping a prior visual frame. + const stale = frame("AAAAAAAA"); + const ed3Only = frame("\x1b[3J"); + const pred = (c: string) => makesFullRepaint(c, cols, rows); + assert.equal(collapseAndSplit(stale + ed3Only, pred).dropped, 0, "ED3 successor must not drop prior frame"); +}); + +test("8-bit C1 controls make a frame un-droppable", () => { + // C1 CSI (0x9B) and OSC (0x9D) would otherwise fall through as printable text. + assert.equal(isDroppableVisualPayload("\x9b1;1Hplain"), false, "C1 CSI"); + assert.equal(isDroppableVisualPayload("\x9d0;title\x07"), false, "C1 OSC"); + assert.equal(isDroppableVisualPayload("hello\x90data\x9c"), false, "C1 DCS"); + // Unicode cell text above the C1 range remains droppable. + assert.equal(isDroppableVisualPayload("café中文"), true, "printable Unicode cell text"); +}); + +test("coverage ignores OSC/DCS control-string payloads (not cell paints)", () => { + const cols = 4; + const rows = 2; + // Long OSC title would falsely fill the viewport if payload bytes were counted. + const oscBel = "\x1b]0;" + "T".repeat(cols * rows * 2) + "\x07"; + const oscSt = "\x1b]0;" + "T".repeat(cols * rows * 2) + "\x1b\\"; + assert.equal(viewportRepaintCoverage(oscBel, cols, rows), 0, "OSC … BEL covers zero cells"); + assert.equal(viewportRepaintCoverage(oscSt, cols, rows), 0, "OSC … ST covers zero cells"); + assert.equal(makesFullRepaint(oscBel, cols, rows), false, "OSC-only is not a full repaint"); + // DCS / APC / PM / SOS payloads are likewise not paints. + assert.equal(viewportRepaintCoverage("\x1bP1$r\x1b\\", cols, rows), 0, "DCS"); + assert.equal(viewportRepaintCoverage("\x1b_payload\x1b\\", cols, rows), 0, "APC"); + assert.equal(viewportRepaintCoverage("\x1b^payload\x1b\\", cols, rows), 0, "PM"); + assert.equal(viewportRepaintCoverage("\x1bXpayload\x1b\\", cols, rows), 0, "SOS"); + // C1 forms: OSC (0x9D) + BEL, DCS (0x90) + C1 ST (0x9C). + assert.equal(viewportRepaintCoverage("\x9d0;title\x07", cols, rows), 0, "C1 OSC"); + assert.equal(viewportRepaintCoverage("\x90data\x9c", cols, rows), 0, "C1 DCS"); + // Real cell writes after origin + OSC still count; OSC itself does not inflate. + assert.equal(viewportRepaintCoverage(`${HOME}${oscBel}ab`, cols, rows), 2, "cells after OSC still count"); + // A short paint padded with OSC must not pass the full-repaint bar. + const shortPlusOsc = `${HOME}z${oscBel}`; + assert.equal(makesFullRepaint(shortPlusOsc, cols, rows), false, "OSC must not inflate partial paint"); + // Predecessor must not drop when successor is only OSC noise. + const stale = frame("AAAAAAAA"); + const oscOnly = frame(oscBel); + const pred = (c: string) => makesFullRepaint(c, cols, rows); + assert.equal(collapseAndSplit(stale + oscOnly, pred).dropped, 0, "OSC successor must not drop prior frame"); + // CSI H only after paints must not retroactively validate those paints. + assert.equal( + viewportRepaintCoverage(`ab${HOME}`, cols, rows), + 0, + "origin at end does not count preceding cells", + ); +}); + +test("holds back a split sync opener as partial", () => { + // Buffer ends mid-opener (ESC[?20); it must be held, not forwarded. + const r = collapseAndSplit("hello\x1b[?20", always); + assert.equal(r.complete, "hello"); + assert.equal(r.partial, "\x1b[?20"); + assert.equal(r.dropped, 0); +}); + +test("endsWithSyncOpenerPrefix detects a split opener tail", () => { + assert.equal(endsWithSyncOpenerPrefix("abc\x1b[?2026"), true); + assert.equal(endsWithSyncOpenerPrefix("abc\x1b"), true); + assert.equal(endsWithSyncOpenerPrefix("abc\x1b[?2026h"), false, "complete opener is not a prefix hold"); + assert.equal(endsWithSyncOpenerPrefix("plain"), false); +}); + +test("payloadMayAutowrapScroll detects wrap past bottom-right", () => { + const cols = 4; + const rows = 2; + // Exact fill of viewport from home: last cell sets wrap pending, no scroll yet. + const exact = `\x1b[H${"x".repeat(cols * rows)}`; + assert.equal(payloadMayAutowrapScroll(exact, cols, rows), false); + // One more cell after last: delayed autowrap scrolls. + const overflow = `\x1b[H${"x".repeat(cols * rows + 1)}`; + assert.equal(payloadMayAutowrapScroll(overflow, cols, rows), true); + assert.equal(payloadMayAutowrapScroll("x".repeat(cols * rows + 1), cols, rows), true); +}); diff --git a/components/terminal/runtime/terminalFrameGate.ts b/components/terminal/runtime/terminalFrameGate.ts new file mode 100644 index 0000000000..783f8d7e14 --- /dev/null +++ b/components/terminal/runtime/terminalFrameGate.ts @@ -0,0 +1,573 @@ +/** + * Frame-rate gate for full-screen animated TUIs. + * + * A TUI like TryIt.jl emits every frame as a DEC 2026 synchronized-output block + * that homes the cursor and repaints every cell: + * + * ESC[?2026h ESC[1;1H ESC[?2026l + * + * At ~60 fps each frame is ~140 KB. xterm.js can render that rate, but only if + * it is never more than a frame or two behind — otherwise frames queue up and + * the display (and the keyboard echo waiting behind it) runs up to a second + * late. The flow-control watermark bounds that backlog by *pausing* the source, + * which throttles the animation. This gate instead bounds it by *dropping* + * superseded frames: when a full-repaint frame is buffered behind another, only + * the last is visible (the next repaints every cell the previous drew), so the + * earlier one can be skipped. The source is never paused, so the animation keeps + * its full rate while the backlog — and the latency — stays small. + * + * Dropping is deliberately conservative, and proven rather than guessed: + * - the dropped frame must be a droppable *visual* payload (allowlist of cursor + * moves, SGR, erase and cell text — never a bell, device query, OSC, DCS/APC + * or private mode whose side effect would be lost); and + * - its successor must *demonstrably* repaint the whole viewport, measured by + * simulating the writes and counting covered cells — not by raw byte length, + * which SGR escapes inflate. + * + * This module is the pure buffer transform. It has no state and no side + * effects; the caller owns the per-terminal buffer, the accounting and the + * fail-open handling for frames that never complete. + */ + +const SYNC_ON = "\x1b[?2026h"; +const SYNC_OFF = "\x1b[?2026l"; +const SYNC_ON_C1 = "\x9b?2026h"; +const SYNC_OFF_C1 = "\x9b?2026l"; + +/** Length of a trailing run of `s` that is a proper (non-empty) prefix of the + * sync opener — 7-bit ESC CSI or 8-bit C1 CSI form. 0 when none. */ +const trailingSyncOpenerPrefixLen = (s: string): number => { + let best = 0; + for (const opener of [SYNC_ON, SYNC_ON_C1]) { + const max = Math.min(opener.length - 1, s.length); + for (let k = max; k >= 1; k--) { + if (s.endsWith(opener.slice(0, k))) { + best = Math.max(best, k); + break; + } + } + } + return best; +}; + +/** True when `s` ends with a split sync opener (a proper prefix of `ESC[?2026h`). */ +export const endsWithSyncOpenerPrefix = (s: string): boolean => + trailingSyncOpenerPrefixLen(s) > 0; + +/** CSI final bytes that only move the cursor, set SGR, or erase. */ +const DROPPABLE_CSI_FINALS = new Set([ + "A", "B", "C", "D", "E", "F", "G", "H", "f", // cursor moves / positioning + "d", "`", // line / column position (VPA / HPA) + "m", // SGR + "J", "K", // erase in display / line + // SU/SD (S/T) intentionally omitted: they scroll the buffer/region and can + // mutate history; a later full repaint restores cells but not lost scroll + // (Codex P2 on d2e6999e). +]); +/** + * C0 controls that only move the cursor without mutating scrollback. + * LF (`\n`) is intentionally excluded: on the normal buffer with the cursor on + * the bottom row, xterm scrolls and can add a history line. Dropping such a + * frame before xterm sees it loses scrollback that a later full repaint cannot + * restore (while the backend has already been acked). + */ +const DROPPABLE_C0 = new Set(["\r", "\t", "\b"]); + +/** + * True when `content` contains at least one CSI SGR (`…m`) sequence. + * Used so a frame that only changes rendition is not dropped unless its + * successor also re-establishes SGR (otherwise xterm keeps pre-drop colors). + */ +export const payloadContainsSgr = (content: string): boolean => { + let i = 0; + while (i < content.length) { + if (content[i] === "\x1b" && content[i + 1] === "[") { + let j = i + 2; + while (j < content.length) { + const c = content.charCodeAt(j); + if (c >= 0x30 && c <= 0x3f) { + j++; + continue; + } + if (c >= 0x20 && c <= 0x2f) { + j++; + continue; + } + if (content[j] === "m") return true; + i = j + 1; + break; + } + if (j >= content.length) return false; + continue; + } + i++; + } + return false; +}; + +/** + * True when every byte of `content` is a cursor move, SGR, erase, or cell text + * — i.e. dropping the frame loses nothing but pixels a full repaint overwrites. + * Anything else (BEL, device query/report, OSC, DCS/APC/PM/SOS, a private-mode + * or alternate-screen toggle, an intermediate-byte CSI) makes the frame + * un-droppable, since its side effect would never reach xterm. + */ +export const isDroppableVisualPayload = (content: string): boolean => { + let i = 0; + while (i < content.length) { + const ch = content[i]; + const code = content.charCodeAt(i); + if (ch === "\x1b") { + if (content[i + 1] !== "[") return false; // only CSI; reject OSC/DCS/APC/single-char ESC + let j = i + 2; + let priv = false; + let params = ""; + while (j < content.length) { + const c = content.charCodeAt(j); + if (c >= 0x30 && c <= 0x3f) { + if (c === 0x3c || c === 0x3d || c === 0x3e || c === 0x3f) priv = true; // < = > ? + else params += content[j]; + j++; + } else { + break; + } + } + if (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) { + return false; // intermediate byte — uncommon, treat as un-droppable + } + const final = content[j]; + if (final === undefined) return false; // incomplete CSI + if (priv || !DROPPABLE_CSI_FINALS.has(final)) return false; + // `CSI 3 J` (ED3) clears the saved scrollback, not just the viewport — a + // side effect a repaint does not restore, so a frame carrying it is not + // droppable. + if (final === "J" && params.split(";").includes("3")) return false; + i = j + 1; + continue; + } + if (code < 0x20) { + if (!DROPPABLE_C0.has(ch)) return false; // BEL and other C0 side effects + i++; + continue; + } + if (code === 0x7f) return false; // DEL + // 8-bit C1 controls (0x80–0x9F): CSI (0x9B), OSC (0x9D), DCS (0x90), … + // xterm accepts these as equivalents of the ESC-prefixed forms. Treat any + // C1 as un-droppable so title/mode/cursor side effects are never skipped + // when a frame is collapsed (C1 bytes would otherwise fall through as + // printable cell text). + if (code >= 0x80 && code <= 0x9f) return false; + i++; // printable / Unicode cell text + } + return true; +}; + +/** + * Count the distinct viewport cells `content` writes, by simulating cursor + * movement, cell output and erases against a `cols`×`rows` grid. Used to prove a + * frame repaints (nearly) the whole screen before an earlier frame is dropped. + */ +/** + * True when writing `content` from the default (0,0) cursor can trigger xterm + * delayed autowrap into a scroll (past the bottom-right cell). Such frames must + * not be dropped: the successor full-repaint restores cells but not scrollback + * history lines created by the wrap (Codex P2). + */ +export const payloadMayAutowrapScroll = ( + content: string, + cols: number, + rows: number, +): boolean => { + if (cols <= 0 || rows <= 0) return true; + let row = 0; + let col = 0; + let wrapPending = false; + const applyCsi = (paramStart: number): number => { + let j = paramStart; + let params = ""; + while (j < content.length) { + const c = content.charCodeAt(j); + if (c >= 0x30 && c <= 0x3f) { params += content[j]; j++; } else break; + } + while (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) j++; + const final = content[j]; + const nums = params.split(";").map((p) => (p === "" ? undefined : parseInt(p, 10))); + const n0 = nums[0]; + if (final === "H" || final === "f") { + row = Math.max(0, Math.min(rows - 1, (n0 ?? 1) - 1)); + col = Math.max(0, Math.min(cols - 1, (nums[1] ?? 1) - 1)); + wrapPending = false; + } else if (final === "A") { row = Math.max(0, row - (n0 ?? 1)); wrapPending = false; } + else if (final === "B" || final === "E") { row = Math.min(rows - 1, row + (n0 ?? 1)); wrapPending = false; } + else if (final === "C") { col = Math.min(cols - 1, col + (n0 ?? 1)); wrapPending = false; } + else if (final === "D") { col = Math.max(0, col - (n0 ?? 1)); wrapPending = false; } + else if (final === "G" || final === "`") { col = Math.max(0, Math.min(cols - 1, (n0 ?? 1) - 1)); wrapPending = false; } + else if (final === "d") { row = Math.max(0, Math.min(rows - 1, (n0 ?? 1) - 1)); wrapPending = false; } + return final === undefined ? content.length : j + 1; + }; + let i = 0; + while (i < content.length) { + const ch = content[i]; + const code = content.charCodeAt(i); + if (ch === "\x1b") { + if (content[i + 1] === "[") { + i = applyCsi(i + 2); + continue; + } + i += content[i + 1] === undefined ? 1 : 2; + continue; + } + if (code === 0x9b) { + i = applyCsi(i + 1); + continue; + } + if (code < 0x20) { + if (ch === "\n") { + wrapPending = false; + row += 1; + if (row >= rows) return true; + col = 0; + } else if (ch === "\r") { + col = 0; + wrapPending = false; + } else if (ch === "\b") { + col = Math.max(0, col - 1); + wrapPending = false; + } else if (ch === "\t") { + col = Math.min(cols - 1, (Math.floor(col / 8) + 1) * 8); + wrapPending = false; + } + i++; + continue; + } + if (code === 0x7f || (code >= 0x80 && code <= 0x9f)) { + i++; + continue; + } + if (wrapPending) { + wrapPending = false; + col = 0; + row += 1; + if (row >= rows) return true; + } + if (col >= cols - 1) { + wrapPending = true; + col = cols - 1; + } else { + col += 1; + } + i++; + } + return false; +}; + +export const viewportRepaintCoverage = ( + content: string, + cols: number, + rows: number, +): number => { + if (cols <= 0 || rows <= 0) return 0; + const covered = new Set(); + let row = 0; + let col = 0; + // Only count cells painted after an explicit origin reset (CUP/home). A late + // CSI H at the end of a frame must not retroactively validate paints from + // the simulated (0,0) start. ED2 alone is not an origin (Codex P2). + let originKnown = false; + const clampRow = () => { row = row < 0 ? 0 : row >= rows ? rows - 1 : row; }; + const clampCol = () => { col = col < 0 ? 0 : col >= cols ? cols - 1 : col; }; + const mark = (r: number, c: number) => { + if (!originKnown) return; + if (r >= 0 && r < rows && c >= 0 && c < cols) covered.add(r * cols + c); + }; + const markRange = (r: number, from: number, to: number) => { + for (let c = Math.max(0, from); c <= to && c < cols; c++) mark(r, c); + }; + /** Advance past a control-string payload terminated by BEL, ST (ESC \), or C1 ST. */ + const skipControlString = (from: number): number => { + let j = from; + while (j < content.length) { + const c = content.charCodeAt(j); + if (c === 0x07) return j + 1; // BEL + if (c === 0x9c) return j + 1; // C1 ST + if (content[j] === "\x1b" && content[j + 1] === "\\") return j + 2; // ESC \ + j++; + } + return content.length; // incomplete — consume remainder, never count as cells + }; + /** Apply CSI cursor/erase effects starting at the first parameter byte. */ + const applyCsi = (paramStart: number): number => { + let j = paramStart; + let params = ""; + while (j < content.length) { + const c = content.charCodeAt(j); + if (c >= 0x30 && c <= 0x3f) { params += content[j]; j++; } else break; + } + while (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) j++; + const final = content[j]; + const nums = params.split(";").map((p) => (p === "" ? undefined : parseInt(p, 10))); + const n0 = nums[0]; + if (final === "H" || final === "f") { + row = (n0 ?? 1) - 1; + col = (nums[1] ?? 1) - 1; + clampRow(); + clampCol(); + // CUP establishes a known origin for subsequent paints only. + originKnown = true; + } else if (final === "A") { row -= n0 ?? 1; clampRow(); } + else if (final === "B" || final === "E") { row += n0 ?? 1; clampRow(); } + else if (final === "C") { col += n0 ?? 1; clampCol(); } + else if (final === "D") { col -= n0 ?? 1; clampCol(); } + else if (final === "G" || final === "`") { col = (n0 ?? 1) - 1; clampCol(); } + else if (final === "d") { row = (n0 ?? 1) - 1; clampRow(); } + else if (final === "J") { + const p = n0 ?? 0; + // ED2 clears the display but does NOT move the cursor (xterm). Do not + // treat it as a known origin — only CUP/home does. ED2 also grants no + // coverage credit (scrolled-up filter may strip it later) (Codex P2). + if (p === 2) { /* no originKnown; no coverage */ } + // ED0/ED1 still mark the cells they clear (not stripped by that filter). + else if (p === 0) { markRange(row, col, cols - 1); for (let r = row + 1; r < rows; r++) markRange(r, 0, cols - 1); } + else if (p === 1) { for (let r = 0; r < row; r++) markRange(r, 0, cols - 1); markRange(row, 0, col); } + } else if (final === "K") { + const p = n0 ?? 0; + if (p === 0) markRange(row, col, cols - 1); + else if (p === 1) markRange(row, 0, col); + else if (p === 2) markRange(row, 0, cols - 1); + } + return final === undefined ? content.length : j + 1; + }; + let i = 0; + while (i < content.length) { + const ch = content[i]; + const code = content.charCodeAt(i); + if (ch === "\x1b") { + const intro = content[i + 1]; + if (intro === "[") { + // CSI: parse cursor/erase; do not count parameter bytes as cell paints. + i = applyCsi(i + 2); + continue; + } + // OSC / DCS / SOS / PM / APC control strings — payloads run until ST or BEL. + // Counting their bytes as cell paints falsely inflates repaint coverage. + if (intro === "]" || intro === "P" || intro === "X" || intro === "^" || intro === "_") { + i = skipControlString(i + 2); + continue; + } + // Other ESC sequences (e.g. ESC 7, ESC (B): skip introducer + body; never + // mark intermediate/final bytes as viewport cells. + if (intro !== undefined) { + let j = i + 1; + while (j < content.length && content.charCodeAt(j) >= 0x20 && content.charCodeAt(j) <= 0x2f) j++; + if (j < content.length && content.charCodeAt(j) >= 0x30 && content.charCodeAt(j) <= 0x7e) { + i = j + 1; + } else { + i = content.length; + } + continue; + } + i++; + continue; + } + if (code < 0x20) { + if (ch === "\n") { row += 1; clampRow(); } + else if (ch === "\r") { col = 0; } + else if (ch === "\b") { col -= 1; clampCol(); } + else if (ch === "\t") { col = Math.min(cols - 1, (Math.floor(col / 8) + 1) * 8); } + i++; + continue; + } + if (code === 0x7f) { i++; continue; } + // 8-bit C1: CSI (0x9B) and control-string introducers — never cell paints. + if (code === 0x9b) { + i = applyCsi(i + 1); + continue; + } + if (code === 0x90 || code === 0x98 || code === 0x9d || code === 0x9e || code === 0x9f) { + i = skipControlString(i + 1); + continue; + } + if (code >= 0x80 && code <= 0x9f) { i++; continue; } + mark(row, col); + col += 1; + if (col >= cols) { col = 0; row += 1; clampRow(); } + i++; + } + return covered.size; +}; + +/** + * Fraction of the viewport a successor must repaint before its predecessor is + * dropped. Require near-full coverage so incremental successors (HOME + a few + * cells, or SGR-heavy partial paints) never justify dropping a prior frame. + * + * Historical note: a 0.4 threshold accepted ~40% paints and dropped prior + * frames with untouched cells still showing stale content. 0.99 keeps only a + * single-cell wrap/clamp off-by-one margin while still demanding essentially + * complete cell overwrite (or an ED2 clear). + */ +const FULL_REPAINT_COVERAGE = 0.99; + +/** + * True when the frame establishes a known cursor origin via CUP/home. + * ED2 clears the display but does not home the cursor in xterm, so it is not + * a known origin by itself (Codex P2). + */ +export const hasKnownCursorOrigin = (content: string): boolean => { + if ( + content.includes("\x1b[H") + || content.includes("\x1b[f") + || content.includes("\x1b[1;1H") + || content.includes("\x1b[1;1f") + || content.includes("\x1b[;1H") + || content.includes("\x9bH") + || content.includes("\x9bf") + || content.includes("\x9b1;1H") + || content.includes("\x9b1;1f") + ) { + return true; + } + return false; +}; + +/** A successor frame that demonstrably repaints (almost) the whole viewport. */ +export const makesFullRepaint = (content: string, cols: number, rows: number): boolean => { + if (cols <= 0 || rows <= 0) return false; + // Coverage only counts cells after an in-order CUP/home origin reset. + const total = cols * rows; + const covered = viewportRepaintCoverage(content, cols, rows); + // Exact full coverage, or near-full (>= 99%) so tiny clamp/wrap off-by-ones + // do not block legitimate full repaints while partial paints still fail. + if (covered >= total) return true; + return covered >= Math.ceil(total * FULL_REPAINT_COVERAGE); +}; + +type Frame = { start: number; end: number; content: string }; + +/** + * Result of {@link collapseAndSplit}: + * - `complete` — the leading, collapsed run of complete frames, ready to write. + * - `partial` — a trailing, not-yet-closed frame to keep buffering. + * - `dropped` — characters removed from `complete` by collapsing. + */ +export type FrameGateSplit = { complete: string; partial: string; dropped: number }; + +/** + * Split `buffer` into its complete-frame prefix and a trailing incomplete + * frame, collapsing runs of superseded full-repaint frames in the prefix down + * to the last. + * + * A frame is dropped only when it is a droppable visual payload AND the frame + * directly after it, per `isFullRepaint`, demonstrably repaints the whole + * viewport (so it overwrites everything the dropped frame drew). Everything the + * transform is unsure about is preserved verbatim. + */ +export const collapseAndSplit = ( + buffer: string, + isFullRepaint: (content: string) => boolean, + viewport?: { cols: number; rows: number }, +): FrameGateSplit => { + const frames: Frame[] = []; + let cursor = 0; + let partialStart = buffer.length; + const nextOpen = (from: number): { at: number; len: number } | null => { + const a = buffer.indexOf(SYNC_ON, from); + const b = buffer.indexOf(SYNC_ON_C1, from); + if (a < 0 && b < 0) return null; + if (a < 0) return { at: b, len: SYNC_ON_C1.length }; + if (b < 0) return { at: a, len: SYNC_ON.length }; + return a <= b ? { at: a, len: SYNC_ON.length } : { at: b, len: SYNC_ON_C1.length }; + }; + const nextClose = (from: number): { at: number; len: number } | null => { + const a = buffer.indexOf(SYNC_OFF, from); + const b = buffer.indexOf(SYNC_OFF_C1, from); + if (a < 0 && b < 0) return null; + if (a < 0) return { at: b, len: SYNC_OFF_C1.length }; + if (b < 0) return { at: a, len: SYNC_OFF.length }; + return a <= b ? { at: a, len: SYNC_OFF.length } : { at: b, len: SYNC_OFF_C1.length }; + }; + while (true) { + const open = nextOpen(cursor); + if (!open) break; + const contentStart = open.at + open.len; + const close = nextClose(contentStart); + if (!close) { partialStart = open.at; break; } + const end = close.at + close.len; + frames.push({ start: open.at, end, content: buffer.slice(contentStart, close.at) }); + cursor = end; + } + + // Hold back a trailing byte run that is a proper prefix of the sync opener, so + // an opener split across PTY chunks reunites with the next chunk instead of + // being forwarded and missed. Only when no unterminated frame already covers + // the tail. + if (partialStart === buffer.length) { + const holdLen = trailingSyncOpenerPrefixLen(buffer); + if (holdLen > 0) partialStart = buffer.length - holdLen; + } + + const partial = buffer.slice(partialStart); + const completeRegion = buffer.slice(0, partialStart); + if (frames.length < 2) return { complete: completeRegion, partial, dropped: 0 }; + + const drop = new Array(frames.length).fill(false); + for (let i = 0; i < frames.length - 1; i++) { + const cur = frames[i]; + const next = frames[i + 1]; + // If the dropped frame carried SGR, the successor must re-establish + // rendition state (any CSI … m). Otherwise xterm keeps pre-drop colors + // when the successor omits redundant SGR (Codex P2 on dd606f39). + if ( + next.start === cur.end + && isDroppableVisualPayload(cur.content) + && isFullRepaint(next.content) + && (!payloadContainsSgr(cur.content) || payloadContainsSgr(next.content)) + // Reject predecessors that can autowrap-scroll (Codex P2). + && !( + viewport + && payloadMayAutowrapScroll(cur.content, viewport.cols, viewport.rows) + ) + ) { + drop[i] = true; + } + } + if (!drop.some(Boolean)) return { complete: completeRegion, partial, dropped: 0 }; + + let complete = ""; + let dropped = 0; + let pos = 0; + for (let i = 0; i < frames.length; i++) { + const f = frames[i]; + complete += completeRegion.slice(pos, f.start); + if (drop[i]) dropped += f.end - f.start; + else complete += completeRegion.slice(f.start, f.end); + pos = f.end; + } + complete += completeRegion.slice(pos); + return { complete, partial, dropped }; +}; + +/** Exact three-way split of buffered ingress bytes; parts always sum to `total`. */ +export type FrameGateIngressSplit = { forward: number; dropped: number; held: number }; + +/** + * Apportion `total` flow-control ingress bytes across the forwarded, dropped and + * still-held parts of a buffer, by character share. Each share is the exact + * complement of the rounded parts before it, so the three always sum back to + * `total` regardless of rounding — the backend is never over- or + * under-acknowledged even when a chunk's ingress differs from its length. + */ +export const apportionFrameGateIngress = ( + total: number, + totalChars: number, + forwardChars: number, + droppedChars: number, + heldChars: number, +): FrameGateIngressSplit => { + const held = totalChars > 0 ? Math.round((total * heldChars) / totalChars) : 0; + const leaving = total - held; + const leavingChars = forwardChars + droppedChars; + const forward = leavingChars > 0 ? Math.round((leaving * forwardChars) / leavingChars) : 0; + const dropped = leaving - forward; + return { forward, dropped, held }; +}; diff --git a/components/terminal/runtime/terminalSessionAttachment.test.ts b/components/terminal/runtime/terminalSessionAttachment.test.ts index 945f7147c5..6fe6ae483c 100644 --- a/components/terminal/runtime/terminalSessionAttachment.test.ts +++ b/components/terminal/runtime/terminalSessionAttachment.test.ts @@ -18,6 +18,7 @@ import { resolveAttachSnapshot, tryAttachSessionToTerminal, writeSessionData, + writeTerminalLine, } from "./terminalSessionAttachment.ts"; import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts"; import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts"; @@ -39,7 +40,10 @@ import { clearDeferredTerminalWriteAck, getDeferredTerminalWriteAckBytes, } from "./terminalWriteAckDeferral.ts"; -import { flushTerminalWriteQueueBypassingTimers } from "./terminalWriteQueue.ts"; +import { + flushTerminalWriteQueueBypassingTimers, + WRITE_QUEUE_STALL_TIMEOUT_MS, +} from "./terminalWriteQueue.ts"; import { prioritizeTerminalInput } from "./terminalOutputPipeline"; import { createPromptLineBreakState, @@ -1733,6 +1737,84 @@ test("writeSessionData flushes deferred IPC acks before small output can leave t clearTerminalSessionFlowAck("session-1"); }); +test("watchdog recovery flushes deferred IPC acks held for a stalled non-deferred write", async () => { + // Small writes batch IPC acks into the deferral buffer. A later large write + // used to clear that buffer before xterm's callback; if the callback is lost + // and the stall watchdog recovers, only the large write's dropBytes were + // acked — the cleared deferred total never reached main, leaving SSH paused. + clearTerminalSessionFlowAck("session-1"); + let stallLarge = true; + const term = { + buffer: { active: { type: "normal" } }, + write(data: string, callback?: () => void) { + if (stallLarge && data.length > XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES) { + // Lost xterm callback: accept the write, never invoke done. + return; + } + callback?.(); + }, + scrollToBottom() {}, + } as unknown as XTerm; + let mainUnackedBytes = 0; + const ctx = { + ...createContext(false), + isVisibleRef: { current: true }, + sessionRef: { current: "session-1" }, + terminalBackend: { + ackSessionFlow: (_sessionId: string, bytes: number) => { + mainUnackedBytes = Math.max(0, mainUnackedBytes - bytes); + }, + }, + }; + getFlowController(ctx as never, term); + + const small = "s".repeat(64); + const smallCount = 3; + for (let i = 0; i < smallCount; i += 1) { + mainUnackedBytes += small.length; + writeSessionData(ctx as never, term, small); + } + flushTerminalWriteCoalescer(term); + // Drain small queue items so they complete (and defer IPC acks). + flushTerminalWriteQueueBypassingTimers(term); + await new Promise((resolve) => { setTimeout(resolve, 5); }); + flushTerminalWriteQueueBypassingTimers(term); + + const deferredBeforeLarge = getDeferredTerminalWriteAckBytes(term); + assert.equal(deferredBeforeLarge, small.length * smallCount); + + const large = "L".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1); + mainUnackedBytes += large.length; + writeSessionData(ctx as never, term, large); + flushTerminalWriteCoalescer(term); + // Start the large write (still no callback). + flushTerminalWriteQueueBypassingTimers(term); + + // Deferred bytes must still be present until someone owns the ack. + assert.equal( + getDeferredTerminalWriteAckBytes(term), + deferredBeforeLarge, + "non-deferred write must not clear deferred acks before owning the claim", + ); + + await new Promise((resolve) => { + setTimeout(resolve, WRITE_QUEUE_STALL_TIMEOUT_MS + 300); + }); + stallLarge = false; + + assert.equal( + getDeferredTerminalWriteAckBytes(term), + 0, + "watchdog recovery must flush deferred IPC acks", + ); + assert.equal( + mainUnackedBytes, + 0, + "deferred + stalled write ingress must both reach main-process ACK", + ); + clearTerminalSessionFlowAck("session-1"); +}); + test("writeSessionData acks ingress bytes to match main-process trackEmitted", () => { clearTerminalSessionFlowAck("session-1"); const { term } = createFakeTerm(); @@ -1936,6 +2018,32 @@ test("writeSessionData does not wipe scrollback for delayed sync clears after re assert.equal(output.includes("\x1b[H\x1b[2Jframe"), true, output); }); +test("writeTerminalLine flushes a held DEC 2026 partial before the lifecycle line", () => { + const { term, writes } = createFakeTerm(); + const ctx = createContext(false); + + // Open a synchronized frame without its closer — held by the frame gate. + writeSessionData(ctx as never, term, "\x1b[?2026h\x1b[1;1Hpartial-frame"); + assert.equal( + writes.join(""), + "", + "incomplete frame must stay buffered until flush or fail-open", + ); + + // Session-exit style lifecycle line must not race a later fail-open release. + writeTerminalLine(ctx as never, term, "\r\n[session closed]"); + + const output = writes.join(""); + const partialAt = output.indexOf("partial-frame"); + const closedAt = output.indexOf("[session closed]"); + assert.ok(partialAt >= 0, `expected held partial in output: ${JSON.stringify(output)}`); + assert.ok(closedAt >= 0, `expected exit line in output: ${JSON.stringify(output)}`); + assert.ok( + partialAt < closedAt, + `held frame must precede exit line (partial@${partialAt}, closed@${closedAt}): ${JSON.stringify(output)}`, + ); +}); + test("writeSessionData always uses ledger recording regardless of gutter toggle", () => { const { term, writes, markerLines } = createFakeTerm(); const ctx = createContext(false, { showLineTimestamps: false }); diff --git a/components/terminal/runtime/terminalSessionAttachment.ts b/components/terminal/runtime/terminalSessionAttachment.ts index 8c2ee98826..eca92f76f6 100644 --- a/components/terminal/runtime/terminalSessionAttachment.ts +++ b/components/terminal/runtime/terminalSessionAttachment.ts @@ -43,10 +43,17 @@ import { } from "./terminalSudoAutofill"; import { filterTerminalSessionData, + flushTerminalSyncBlockFilterPending, isTerminalSyncBlockOpen, resetTerminalSyncBlockFilter, } from "./terminalSyncBlockFilter"; import { appendEraseScrollbackAfterFullErases } from "../clearTerminalViewport"; +import { + apportionFrameGateIngress, + collapseAndSplit, + endsWithSyncOpenerPrefix, + makesFullRepaint, +} from "./terminalFrameGate"; import { type CoalescedTerminalWriteOptions, enqueueCoalescedTerminalWrite, @@ -68,6 +75,8 @@ import { import { FLOW_HIGH_WATER_MARK, FLOW_LOW_WATER_MARK, + LOCAL_FLOW_HIGH_WATER_MARK, + LOCAL_FLOW_LOW_WATER_MARK, XTERM_WRITE_CALLBACK_BATCH_BYTES, XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES, } from "./terminalFlowConstants"; @@ -88,6 +97,7 @@ import { } from "./terminalOutputPipeline"; import { hasPendingTerminalWrites, + registerFrameGateHibernateHooks, maybeFlushTerminalWriteCoalescerWhenUnfocused, scheduleTerminalRepaintWhenUnfocused, shouldFlushTerminalWritesForBackgroundOutput, @@ -373,9 +383,24 @@ export const getFlowController = ( ): OutputFlowController => { let controller = terminalFlowControllers.get(term); if (!controller) { + // A local shell needs no output back-pressure: there is no network to + // overwhelm, and the source (a local process) blocks on its own write + // when the pipe fills. The 1 MB watermark meant for SSH otherwise pauses + // the PTY several times a second under a full-screen animated TUI, which + // throttles its frame loop far below what the renderer can paint. Give + // local sessions a much higher ceiling so the pause is rare; SSH keeps the + // tight default. The ceiling still bounds memory — it does not disable + // back-pressure, only relaxes it where a flood cannot originate. + const isLocal = (ctx.hostRef?.current ?? ctx.host)?.protocol === "local"; + const highWaterMark = isLocal + ? Math.max(FLOW_HIGH_WATER_MARK, LOCAL_FLOW_HIGH_WATER_MARK) + : FLOW_HIGH_WATER_MARK; + const lowWaterMark = isLocal + ? Math.max(FLOW_LOW_WATER_MARK, LOCAL_FLOW_LOW_WATER_MARK) + : FLOW_LOW_WATER_MARK; controller = createOutputFlowController({ - highWaterMark: FLOW_HIGH_WATER_MARK, - lowWaterMark: FLOW_LOW_WATER_MARK, + highWaterMark, + lowWaterMark, onPause: () => { const id = ctx.sessionRef.current; if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true); @@ -387,11 +412,21 @@ export const getFlowController = ( }); terminalFlowControllers.set(term, controller); setTerminalWriteQueueDropHandler(term, (bytes) => { - if (bytes <= 0) return; - controller?.written(bytes); + // Watchdog recovery only claims the active item's dropBytes. Small writes + // may have left ingress in the deferred IPC-ack buffer; a non-deferred + // write must not clear that buffer until it owns the ack, so a stall can + // still flush those earlier bytes here (without re-running flow.written — + // deferred writes already accounted them when they completed). + const deferredAck = clearDeferredTerminalWriteAck(term); const sessionId = ctx.sessionRef.current; - ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes); - if (sessionId) { + if (bytes > 0) { + controller?.written(bytes); + ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes); + } + if (deferredAck > 0) { + ackTerminalSessionFlow(ctx.terminalBackend, sessionId, deferredAck); + } + if (sessionId && (bytes > 0 || deferredAck > 0)) { flushTerminalSessionFlowAck(sessionId); } }); @@ -413,13 +448,21 @@ export const resetTerminalLineTimestampState = resetTerminalLineTimestamps; export const acknowledgeDroppedTerminalDisplayBytes = ( ctx: TerminalSessionStartersContext, bytes: number, + term?: XTerm, ): void => { if (bytes <= 0) return; const sessionId = ctx.sessionRef.current; ackTerminalSessionFlow(ctx.terminalBackend, sessionId, bytes); if (sessionId) { flushTerminalSessionFlowAck(sessionId); - ctx.terminalBackend.setSessionFlowPaused?.(sessionId, false); + // Dropped frames free backend ingress, but the renderer flow controller may + // still be above its high-water mark on forwarded writes. Only force-resume + // when it is not paused; otherwise the controller resumes when pending + // drains (Codex P2 on efe412a6). + const flow = term ? getFlowControllerForTerm(term) : undefined; + if (!flow?.isPaused?.()) { + ctx.terminalBackend.setSessionFlowPaused?.(sessionId, false); + } } }; @@ -435,22 +478,282 @@ export const writeTerminalLine = ( term: XTerm, data: string, ) => { + // Flush held DEC 2026 frames before lifecycle lines. Without this, a process + // that exits mid-frame leaves a fail-open timer armed; the timer would + // forward the older partial *after* `[session closed]`, reversing output + // order and potentially overwriting the exit message. + resetFrameGate(term, (buffer, ingress) => { + forwardSessionData(ctx, term, buffer, ingress); + }); // Keep lifecycle/control lines ordered after all preceding PTY output. flushPendingTerminalOutputNow(term); const lineData = `${data}\r\n`; + // dropBytes: 0 — lifecycle banners never called flow.received(); if the + // stall watchdog recovered them with display-length dropBytes it would + // under-count real session backlog and resume SSH too early (Codex P2). enqueueTerminalWrite(term, lineData.length, (done) => { ctx.onTerminalLogData?.(lineData); term.write(lineData, done); - }); + }, { dropBytes: 0 }); flushTerminalWritesForBackgroundOutput(term); }; +/** + * Backlog (unacknowledged bytes awaiting xterm) below which the frame gate + * forwards frames straight through — a few frames deep, enough to keep xterm + * fed at full rate without starving. Above it the gate drops superseded frames + * instead of letting the backlog (and the latency riding behind it) grow. + */ +const FRAME_GATE_FORWARD_BACKLOG = 512 * 1024; +/** Retry cadence for draining held *complete* output when the backlog clears. */ +const FRAME_GATE_FLUSH_MS = 8; +/** + * Fail-open ceiling for the held buffer. Releasing at once past this keeps the + * gate from withholding output unboundedly and, kept below the SSH flow + * watermark, avoids deadlocking on a frame larger than that watermark (the + * backend would pause before the withheld closer could arrive). + */ +const FRAME_GATE_MAX_HELD_BYTES = 512 * 1024; +/** + * How long a lone trailing partial (an opener with no closer) may be held before + * it is released to xterm. Long enough not to fire during normal frame assembly, + * short enough that a process killed mid-frame does not leave its prompt hidden. + */ +const FRAME_GATE_PARTIAL_FAILOPEN_MS = 200; + +type FrameGateState = { + buffer: string; + /** + * Flow-control ingress bytes attributable to `buffer`. Tracked separately + * from `buffer.length` because plugin processing can make a chunk's ingress + * differ from its rendered length; it is apportioned exactly (via complements) + * as bytes are forwarded, dropped or held so the backend is neither over- nor + * under-acknowledged. + */ + ingress: number; + meta?: TerminalSessionDataMeta; + flushTimer?: ReturnType; +}; +const frameGateStates = new WeakMap(); + +const getFrameGateState = (term: XTerm): FrameGateState => { + let state = frameGateStates.get(term); + if (!state) { + state = { buffer: "", ingress: 0 }; + frameGateStates.set(term, state); + } + return state; +}; + +export const resetFrameGate = ( + term: XTerm, + onHeld?: (buffer: string, ingress: number) => void, +): void => { + const state = frameGateStates.get(term); + if (!state) return; + if (state.flushTimer !== undefined) clearTimeout(state.flushTimer); + // Never silently drop held output before its state is deleted: a reset racing + // an incomplete frame (hibernation, detach) would otherwise lose the buffered + // bytes and leave their ingress unacknowledged to the backend. The caller + // decides how to release it — forward it where a write context exists, + // acknowledge its ingress where only the backend is available. + if (state.buffer) onHeld?.(state.buffer, state.ingress); + frameGateStates.delete(term); +}; + +/** Ingress flushed to xterm during hibernate before release can ACK it. */ +const frameGateHibernateFlushedIngress = new WeakMap(); + +// Wire hibernate/close drains to write held DEC 2026 buffers into xterm before +// serialization (avoids circular import with terminalUnfocusedRepaint). +registerFrameGateHibernateHooks({ + hasHeld: (term) => { + const state = frameGateStates.get(term); + return Boolean(state?.buffer); + }, + flushToTerm: (term) => { + const state = frameGateStates.get(term); + if (!state) return; + if (state.flushTimer !== undefined) { + clearTimeout(state.flushTimer); + state.flushTimer = undefined; + } + if (!state.buffer) return; + const buffer = state.buffer; + const ingress = state.ingress; + // Clear held buffer so hasHeld is false, but keep gate state deleted so + // subsequent writes re-evaluate engagement cleanly. + frameGateStates.delete(term); + try { + // Route through the same scrollback-safe filter as live writes so a + // held HOME+CSI 2 J full redraw does not yank scrollback on hibernate + // (Codex P2 on e8c49563). Then force-release any filter-pending ESC + // suffix/cursor-home so snapshot drains do not drop them. + const filtered = filterTerminalSessionData(term, buffer); + const pending = flushTerminalSyncBlockFilterPending(term); + const toWrite = `${filtered || ""}${pending || ""}`; + if (toWrite) term.write(toWrite); + } catch { + try { + term.write(buffer); + } catch { + // ignore write failures during teardown + } + } + // Preserve ingress for releaseTerminalFlowBeforeHibernate; also try to ACK + // immediately via the session drop path if a flow controller is attached + // so non-hibernate flushPending callers do not leave an unacked floor. + if (ingress > 0) { + frameGateHibernateFlushedIngress.set( + term, + (frameGateHibernateFlushedIngress.get(term) ?? 0) + ingress, + ); + try { + const flow = terminalFlowControllers.get(term); + if (flow) { + flow.written(ingress); + } + } catch { + // ignore + } + } + }, +}); + +/** + * Drain as much of the gate's held buffer as the current backlog allows, + * collapsing superseded frames first. Held frames that cannot be forwarded yet + * stay buffered so the next arrival (or flush) collapses them against newer + * ones instead of letting them pile up. + */ +const drainFrameGate = ( + ctx: TerminalSessionStartersContext, + term: XTerm, +): void => { + const state = getFrameGateState(term); + if (state.flushTimer !== undefined) { + clearTimeout(state.flushTimer); + state.flushTimer = undefined; + } + if (!state.buffer) return; + + // Drop a frame only when its successor demonstrably repaints the whole + // viewport (proven by simulating the writes and counting covered cells), never + // on raw payload length, which SGR escapes inflate. + const { complete, partial, dropped } = collapseAndSplit( + state.buffer, + (content) => makesFullRepaint(content, term.cols, term.rows), + { cols: term.cols, rows: term.rows }, + ); + + // Apportion the buffered ingress across forwarded / dropped / held so the + // backend is acknowledged in its own units, not rendered-string lengths. + const { + forward: ingressComplete, + dropped: ingressDropped, + held: ingressPartial, + } = apportionFrameGateIngress( + state.ingress, + state.buffer.length, + complete.length, + dropped, + partial.length, + ); + + state.buffer = partial; + state.ingress = ingressPartial; + if (dropped > 0) acknowledgeDroppedTerminalDisplayBytes(ctx, ingressDropped, term); + + let heldComplete = false; + if (complete) { + const backlog = getFlowControllerForTerm(term)?.pendingBytes() ?? 0; + if (backlog < FRAME_GATE_FORWARD_BACKLOG) { + forwardSessionData(ctx, term, complete, ingressComplete, state.meta); + } else { + // xterm is still behind: keep the collapsed complete run buffered ahead of + // the trailing partial so newer frames supersede it rather than queue up. + state.buffer = complete + state.buffer; + state.ingress += ingressComplete; + heldComplete = true; + } + } + + if (!state.buffer) return; + + // Fail-open: never withhold output indefinitely. A held buffer past the cap + // (e.g. a frame larger than the SSH flow watermark, which would otherwise + // deadlock) is released at once. + if (state.buffer.length >= FRAME_GATE_MAX_HELD_BYTES) { + forwardSessionData(ctx, term, state.buffer, state.ingress, state.meta); + state.buffer = ""; + state.ingress = 0; + return; + } + + // Held *complete* output is released by a clearing backlog, so poll quickly. + // A lone trailing partial can only be completed by new session data (which + // calls drainFrameGate itself); poll it only on a longer one-shot that + // fail-opens if it fires — so a process killed mid-frame never leaves its + // prompt hidden, yet a stalled session never busy-polls. + if (state.flushTimer === undefined) { + const delay = heldComplete ? FRAME_GATE_FLUSH_MS : FRAME_GATE_PARTIAL_FAILOPEN_MS; + state.flushTimer = setTimeout(() => { + state.flushTimer = undefined; + if (heldComplete) { + drainFrameGate(ctx, term); + return; + } + // Fired with no intervening drain: the partial is stuck — release it. + const stuck = getFrameGateState(term); + if (stuck.buffer) { + forwardSessionData(ctx, term, stuck.buffer, stuck.ingress, stuck.meta); + stuck.buffer = ""; + stuck.ingress = 0; + } + }, delay); + } +}; + +/** + * Entry point for PTY output. When a full-screen animation is in flight (a DEC + * 2026 frame is present or already buffered) it runs through the frame gate, + * which caps the display/keyboard latency by dropping superseded frames. All + * other output takes the direct path unchanged. + */ export const writeSessionData = ( ctx: TerminalSessionStartersContext, term: XTerm, data: string, ingressBytes: number = data.length, meta?: TerminalSessionDataMeta, +) => { + const state = frameGateStates.get(term); + // Engage on a complete opener, on already-buffered output, or on a trailing + // split opener (`ESC[?2026h` cut across PTY chunks) so an aligned stream can + // never bypass the gate by landing the opener on a chunk boundary. + // Include 8-bit C1 CSI opener (`\x9b?2026h`) so C1 animated streams enter the + // collapse/drop gate the same way as ESC CSI (Codex P2 on 3690e6e4). + const engaged = (state && state.buffer.length > 0) + || data.includes("\x1b[?2026h") + || data.includes("\x9b?2026h") + || endsWithSyncOpenerPrefix(data); + if (!engaged) { + forwardSessionData(ctx, term, data, ingressBytes, meta); + return; + } + const gate = getFrameGateState(term); + gate.buffer += data; + gate.ingress += ingressBytes; + gate.meta = meta; + drainFrameGate(ctx, term); +}; + +const forwardSessionData = ( + ctx: TerminalSessionStartersContext, + term: XTerm, + data: string, + ingressBytes: number = data.length, + meta?: TerminalSessionDataMeta, ) => { const flow = getFlowController(ctx, term); const isPaneCurrentlyVisible = () => isTerminalPaneVisible(ctx); @@ -546,7 +849,7 @@ const writeSessionDataImmediate = ( const displayBytes = data.length; const bulkYieldAfter = shouldDegradeTerminalSideWork(term) && displayBytes >= XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES; - enqueueTerminalWrite(term, displayBytes, (done) => { + enqueueTerminalWrite(term, displayBytes, (done, signal) => { const shouldMeasurePerf = Boolean(writeOptions.perfTrace); const queueItemStartedAt = shouldMeasurePerf ? performance.now() : 0; const prepareStartedAt = shouldMeasurePerf ? performance.now() : 0; @@ -705,10 +1008,21 @@ const writeSessionDataImmediate = ( ); }; + /** + * Flow accounting must be exclusive with the stall watchdog's onDropped + * path. When the watchdog force-completes a lost/late callback it claims + * first; a subsequent real xterm callback must not re-ack the same bytes. + */ + const commitFlowAckIfOwned = (): boolean => { + if (!signal.tryClaimFlowAck()) return false; + flow.written(ingressBytes); + return true; + }; + if (deferFlowAck) { writePreparedDisplayData(() => { finishQueueItem(); - flow.written(ingressBytes); + if (!commitFlowAckIfOwned()) return; const deferredTotal = accumulateDeferredTerminalWriteAck(term, ingressBytes); if (deferredTotal >= XTERM_WRITE_CALLBACK_BATCH_BYTES) { flushDeferredIpcAck(); @@ -719,16 +1033,15 @@ const writeSessionDataImmediate = ( return; } - const deferredBeforeCallback = clearDeferredTerminalWriteAck(term); - const ackOnCallback = deferredBeforeCallback + ingressBytes; + // Do not clear deferred IPC acks until this callback owns flow-ack. Clearing + // early loses those bytes when the stall watchdog recovers the write: the + // write closure has already dropped them from the deferral buffer, and the + // late/no-op callback never flushes them — leaving the SSH channel paused. writePreparedDisplayData(() => { finishQueueItem(); - flow.written(ingressBytes); - if (deferredBeforeCallback > 0) { - flushIpcAck(ackOnCallback); - } else { - flushIpcAck(ackOnCallback); - } + if (!commitFlowAckIfOwned()) return; + const deferredBeforeCallback = clearDeferredTerminalWriteAck(term); + flushIpcAck(deferredBeforeCallback + ingressBytes); }); }, { dropBytes: ingressBytes, @@ -790,6 +1103,18 @@ export const releaseTerminalFlowBeforeHibernate = ( setTerminalWriteCoalescerFlushGate(term); pendingTimestampSecondByTerm.delete(term); resetDeferredTerminalWriteAck(term); + // Only the backend is in scope here; acknowledge held ingress so hibernation + // does not leave the source paused on bytes that will never be written. + // Also ACK any ingress already flushed to xterm by the hibernate drain hook. + let hibernateIngress = frameGateHibernateFlushedIngress.get(term) ?? 0; + frameGateHibernateFlushedIngress.delete(term); + resetFrameGate(term, (_buffer, ingress) => { + hibernateIngress += ingress; + }); + if (hibernateIngress > 0) { + ackTerminalSessionFlow(backend, sessionId, hibernateIngress); + flushTerminalSessionFlowAck(sessionId); + } terminalFlowControllers.delete(term); }; @@ -838,6 +1163,11 @@ export const attachSessionToTerminal = ( teardownTerminalOutputPipeline(ctx, term, id, flow); flushTerminalWriteCoalescer(term); resetTerminalSyncBlockFilter(term); + // A write context exists here, so flush any held output to xterm rather than + // dropping it (forwardSessionData also acknowledges its ingress). + resetFrameGate(term, (buffer, ingress) => { + forwardSessionData(ctx, term, buffer, ingress); + }); resetTerminalLineTimestamps(term); resetTerminalOutputPressure(term); ctx.onSessionAttached?.(id); diff --git a/components/terminal/runtime/terminalSyncBlockFilter.ts b/components/terminal/runtime/terminalSyncBlockFilter.ts index 113da6ffc0..6e87efdad0 100644 --- a/components/terminal/runtime/terminalSyncBlockFilter.ts +++ b/components/terminal/runtime/terminalSyncBlockFilter.ts @@ -73,3 +73,22 @@ export const filterTerminalSessionData = (term: XTerm, data: string): string => return output; }; + +/** + * Force-release pending filter held bytes (split ESC sequences / cursor-home) + * so hibernate/snapshot drains do not lose output that was retained in filter + * state after the frame-gate buffer was written (Codex P2 on 741a911e). + */ +export const flushTerminalSyncBlockFilterPending = (term: XTerm): string => { + const state = getSyncBlockFilterState(term); + let released = ""; + if (state.pending) { + released += state.pending; + state.pending = ""; + } + if (state.pendingCursorHome) { + released += state.pendingCursorHome; + state.pendingCursorHome = null; + } + return released; +}; diff --git a/components/terminal/runtime/terminalUnfocusedRepaint.ts b/components/terminal/runtime/terminalUnfocusedRepaint.ts index fbddc5c7c1..5bbf9c1121 100644 --- a/components/terminal/runtime/terminalUnfocusedRepaint.ts +++ b/components/terminal/runtime/terminalUnfocusedRepaint.ts @@ -85,11 +85,27 @@ function getPendingTerminalWriteBufferBytes(term: XTerm): number { return bytes; } +/** + * Optional frame-gate hooks registered from terminalSessionAttachment to avoid + * a circular import. Hibernate/close drains must write held DEC 2026 buffers + * into xterm before serialization, not only ACK their ingress. + */ +type FrameGateHibernateHooks = { + hasHeld: (term: XTerm) => boolean; + flushToTerm: (term: XTerm) => void; +}; +let frameGateHibernateHooks: FrameGateHibernateHooks | null = null; + +export const registerFrameGateHibernateHooks = (hooks: FrameGateHibernateHooks): void => { + frameGateHibernateHooks = hooks; +}; + export function hasPendingTerminalWrites(term: XTerm): boolean { return ( getTerminalWriteCoalescerPendingBytes(term) > 0 || hasPendingTerminalWriteQueueWork(term) || getPendingTerminalWriteBufferBytes(term) > 0 + || Boolean(frameGateHibernateHooks?.hasHeld(term)) ); } @@ -181,6 +197,8 @@ export async function flushPendingTerminalWritesBeforeHibernate( ): Promise { const deadline = Date.now() + Math.max(0, timeoutMs); while (true) { + // Release held DEC 2026 frames into xterm before snapshotting (Codex P2). + frameGateHibernateHooks?.flushToTerm(term); flushTerminalWriteCoalescer(term); flushTerminalWriteQueueBypassingTimers(term); @@ -193,6 +211,7 @@ export async function flushPendingTerminalWritesBeforeHibernate( await waitForTerminalWriteCallbacks(Math.min(TERMINAL_WRITE_SETTLE_POLL_MS, remainingMs)); } + frameGateHibernateHooks?.flushToTerm(term); flushTerminalWriteCoalescer(term); flushTerminalWriteQueueBypassingTimers(term); return !hasPendingTerminalWrites(term); diff --git a/components/terminal/runtime/terminalWriteCoalescer.ts b/components/terminal/runtime/terminalWriteCoalescer.ts index c71be7b2db..fa4b6abef4 100644 --- a/components/terminal/runtime/terminalWriteCoalescer.ts +++ b/components/terminal/runtime/terminalWriteCoalescer.ts @@ -8,6 +8,7 @@ import { MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES, } from "./terminalFlowConstants"; import { shouldDegradeTerminalSideWork } from "./terminalOutputPressure"; +import { frameSafeSliceEnd } from "./syncFrameBoundary"; import { createWriteCoalescer, type WriteCoalesceScheduleMode, @@ -507,6 +508,13 @@ const writeLargeTerminalBatch = ( } } } + // Never cut inside a DEC 2026 synchronized-output frame: a shard that ends + // mid-frame is handed to xterm across a setTimeout gap, and if the frame's + // close arrives after xterm's sync timeout the display tears. Applied last, + // and only ever extends the end, so it does not fight the boundary rules + // above. Bounded: it extends at most to this frame's close (or, for an + // unterminated trailing frame, holds it whole for the next write). + end = frameSafeSliceEnd(data, offset, end); const slice = data.slice(offset, end); const sliceIngress = end >= data.length ? remainingIngressBytes diff --git a/components/terminal/runtime/terminalWriteQueue.ts b/components/terminal/runtime/terminalWriteQueue.ts index f1e8c9dad5..2ee8dc4804 100644 --- a/components/terminal/runtime/terminalWriteQueue.ts +++ b/components/terminal/runtime/terminalWriteQueue.ts @@ -12,6 +12,45 @@ export const MAX_WRITE_QUEUE_BYTES = 512 * 1024; */ export const WRITE_QUEUE_TURN_BUDGET_MS = 10; +/** + * How long an active queue item may make no progress, with nothing scheduled to + * make any, before the stall watchdog considers recovering it. + * + * The completion of an item depends on its write closure invoking the callback + * it is handed, which in the real pipeline is wired to xterm's + * `term.write(data, cb)`. A lost `cb` — xterm accepting and parsing a write yet + * never firing its callback — would otherwise wedge the queue permanently. + * + * The watchdog is *not* time-only: it only force-completes when xterm's internal + * write buffer is idle (drained). A slow parse that still holds pending data + * re-arms the timer instead of treating the write as dropped. That keeps + * legitimate multi-hundred-ms callbacks from defeating backpressure. + * + * Set above any legitimate single-write callback latency once xterm is idle, + * yet short enough that a recovered stall reads as a brief hitch rather than a + * freeze. Aligned with {@link LARGE_WRITE_FLUSH_WATCHDOG_MS} in the session + * write path, which guards the same kind of stuck-write condition. + */ +export const WRITE_QUEUE_STALL_TIMEOUT_MS = 250; + +/** + * Signal handed to each write closure so flow-control acknowledgement is + * exclusive between the normal completion path and the stall-watchdog / + * drop-handler path. Callers that perform `flow.written` / IPC ack must claim + * via {@link TerminalWriteSignal.tryClaimFlowAck} before acknowledging. + */ +export type TerminalWriteSignal = { + /** True after the queue force-completed or aborted this item. */ + isCancelled: () => boolean; + /** + * Claim exclusive ownership of flow-control ack for this step's dropBytes. + * Returns true only once — either the normal completion path or the + * watchdog/drop path may claim it. Prevents double-ack when a late xterm + * callback arrives after watchdog recovery. + */ + tryClaimFlowAck: () => boolean; +}; + export type TerminalWriteQueueOptions = { onDropped?: (bytes: number) => void; dropBytes?: number; @@ -33,8 +72,54 @@ type QueuedWrite = { type QueuedWriteStep = { bytes: number; dropBytes: number; - write: (done: () => void) => void; + write: (done: () => void, signal: TerminalWriteSignal) => void; yieldAfter: boolean; + /** Set once when this step's dropBytes are acknowledged (normal or drop path). */ + flowAckClaimed: boolean; +}; + +/** xterm.js private write-buffer shape used only to detect an idle drain. */ +type XTermWithPrivateWriteBuffer = XTerm & { + _core?: { + _writeBuffer?: { + _bufferOffset?: number; + _pendingData?: number; + _writeBuffer?: Array; + }; + }; +}; + +/** + * True when xterm has no queued input left to parse. A missing private API + * (tests / future xterm) is treated as idle so recovery still works; a + * positive pending count means a write is still in flight and must not be + * force-completed on elapsed time alone. + */ +export const isXtermWriteBufferIdle = (term: XTerm): boolean => { + const writeBuffer = (term as XTermWithPrivateWriteBuffer)._core?._writeBuffer; + if (!writeBuffer) return true; + + if ( + typeof writeBuffer._pendingData === "number" + && Number.isFinite(writeBuffer._pendingData) + && writeBuffer._pendingData > 0 + ) { + return false; + } + + const buffer = writeBuffer._writeBuffer; + if (!Array.isArray(buffer) || buffer.length === 0) return true; + const offset = typeof writeBuffer._bufferOffset === "number" + && Number.isFinite(writeBuffer._bufferOffset) + ? Math.max(0, writeBuffer._bufferOffset) + : 0; + return offset >= buffer.length; +}; + +const tryClaimStepFlowAck = (step: QueuedWriteStep): boolean => { + if (step.flowAckClaimed) return false; + step.flowAckClaimed = true; + return true; }; type TerminalWriteQueue = { @@ -49,6 +134,8 @@ type TerminalWriteQueue = { drainTimer?: ReturnType; stepTimer?: ReturnType; stepContinuation?: () => void; + stallWatchdog?: ReturnType; + progressSeq: number; }; const terminalWriteQueues = new WeakMap(); @@ -65,6 +152,7 @@ const getOrCreateQueue = (term: XTerm): TerminalWriteQueue => { floodMode: false, turnStartedAt: 0, onDropped: terminalWriteQueueDropHandlers.get(term), + progressSeq: 0, }; terminalWriteQueues.set(term, queue); } @@ -86,6 +174,107 @@ const isQueueTurnBudgetExceeded = (queue: TerminalWriteQueue): boolean => { return performance.now() - queue.turnStartedAt >= WRITE_QUEUE_TURN_BUDGET_MS; }; +const clearStallWatchdog = (queue: TerminalWriteQueue): void => { + if (queue.stallWatchdog !== undefined) { + clearTimeout(queue.stallWatchdog); + queue.stallWatchdog = undefined; + } +}; + +/** + * Claim dropBytes only for the currently dispatched step of a stalled item. + * Flood merges put many unstarted steps after `nextIndex`; those have never + * been written to xterm and must not be ACKed as dropped (Codex P1). + * `nextIndex` points one past the dispatched step, hence `nextIndex - 1`. + * Claiming here makes a late real callback's + * {@link TerminalWriteSignal.tryClaimFlowAck} return false. + */ +const claimDispatchedStepBytesOf = (item: QueuedWrite): number => { + const from = Math.max(0, item.nextIndex - 1); + const step = item.steps[from]; + if (!step) { + return item.dropBytes; + } + return tryClaimStepFlowAck(step) ? step.dropBytes : 0; +}; + +/** + * Requeue steps that have not been written yet after a stall recovery on a + * flood-merged item, so remaining chunks still reach xterm. + */ +const requeueUnstartedTail = (queue: TerminalWriteQueue, item: QueuedWrite): void => { + const tail = item.steps.slice(item.nextIndex); + if (tail.length === 0) return; + let bytes = 0; + let dropBytes = 0; + for (const step of tail) { + bytes += step.bytes; + dropBytes += step.dropBytes; + } + queue.pending.unshift({ + bytes, + dropBytes, + steps: tail, + nextIndex: 0, + cancelled: false, + yieldAfter: item.yieldAfter, + maxDrainBytes: item.maxDrainBytes, + }); + queue.pendingBytes += bytes; +}; + +/** + * Arm a watchdog against a lost write callback wedging the queue. It fires only + * when the same item is still active, has made no progress since it was armed, + * has nothing scheduled to advance it, *and* xterm's write buffer is idle — a + * genuine lost-callback stall, never a slow-but-progressing parse. On fire it + * claims only the dispatched step's unacked bytes (so flow control resumes once, + * not twice when a late callback arrives), requeues any unstarted merged tail, + * and advances the queue. + */ +const armStallWatchdog = ( + term: XTerm, + queue: TerminalWriteQueue, + item: QueuedWrite, +): void => { + clearStallWatchdog(queue); + const progressAtArm = queue.progressSeq; + queue.stallWatchdog = setTimeout(() => { + queue.stallWatchdog = undefined; + if (terminalWriteQueues.get(term) !== queue) return; + if ( + queue.active !== item + || item.cancelled + || queue.progressSeq !== progressAtArm + || queue.stepTimer !== undefined + || queue.stepContinuation !== undefined + || queue.drainTimer !== undefined + ) { + // Either the item advanced/completed, or something is scheduled to make + // it advance. Not a stall — leave it be. + return; + } + if (!isXtermWriteBufferIdle(term)) { + // xterm is still parsing this write. Elapsed time alone is not evidence of + // a lost callback — re-arm and wait for a real drain or real progress. + armStallWatchdog(term, queue, item); + return; + } + // Mark cancelled so a late real callback becomes a queue no-op, claim flow + // ack only for the dispatched step, requeue unstarted flood-merge tail, then + // advance. + item.cancelled = true; + const unacked = claimDispatchedStepBytesOf(item); + requeueUnstartedTail(queue, item); + queue.active = undefined; + queue.drainBytes = 0; + if (unacked > 0) { + queue.onDropped?.(unacked); + } + scheduleQueueDrain(term, queue, true); + }, WRITE_QUEUE_STALL_TIMEOUT_MS); +}; + const scheduleQueueDrain = ( term: XTerm, queue: TerminalWriteQueue, @@ -109,6 +298,8 @@ const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => { queue.writing = false; queue.drainBytes = 0; queue.floodMode = false; + queue.active = undefined; + clearStallWatchdog(queue); endQueueTurn(queue); if (terminalWriteQueues.get(term) === queue) { terminalWriteQueues.delete(term); @@ -137,10 +328,12 @@ const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => { if (queue.pendingBytes < 0) queue.pendingBytes = 0; queue.writing = true; queue.active = next; + armStallWatchdog(term, queue, next); runQueuedWrite(next, () => { if (queue.active !== next) { return; } + clearStallWatchdog(queue); queue.drainBytes += next.bytes; if (queue.active === next) { queue.active = undefined; @@ -166,6 +359,13 @@ const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => { }, 0); queue.stepTimer = timer; queue.stepContinuation = continuation; + }, () => { + // Step progress: advance the sequence and re-arm the watchdog so a stall on + // a later step of a multi-step item is covered as well as the first. + if (queue.active === next) { + queue.progressSeq += 1; + armStallWatchdog(term, queue, next); + } }); }; @@ -182,6 +382,7 @@ const runQueuedWrite = ( item: QueuedWrite, done: () => void, deferStep: (continuation: () => void) => void, + onStepProgress?: () => void, ): void => { let index = 0; let completed = false; @@ -225,7 +426,15 @@ const runQueuedWrite = ( let callbackCalledSynchronously = false; let insideWrite = true; + const signal: TerminalWriteSignal = { + isCancelled: () => item.cancelled, + tryClaimFlowAck: () => tryClaimStepFlowAck(step), + }; const continueAfterStep = (): void => { + // A completed step is progress: re-arm the stall watchdog against the new + // step so a lost callback on a *later* step of a multi-step item is + // covered too, not only the first. + onStepProgress?.(); currentDrainBytes += step.bytes; // Inter-step yields honor per-step flags only. item.yieldAfter applies // after the whole item finishes (see scheduleNextTerminalWrite), so that @@ -255,7 +464,7 @@ const runQueuedWrite = ( return; } continueAfterStep(); - }); + }, signal); insideWrite = false; if (callbackCalledSynchronously) { continueAfterStep(); @@ -393,7 +602,7 @@ export const flushTerminalWriteQueueBypassingTimers = (term: XTerm): boolean => export const enqueueTerminalWrite = ( term: XTerm, bytes: number, - write: (done: () => void) => void, + write: (done: () => void, signal: TerminalWriteSignal) => void, options: TerminalWriteQueueOptions = {}, ): void => { const queue = getOrCreateQueue(term); @@ -411,7 +620,13 @@ export const enqueueTerminalWrite = ( queue.pending.push({ bytes, dropBytes, - steps: [{ bytes, dropBytes, write, yieldAfter: Boolean(options.yieldAfter) }], + steps: [{ + bytes, + dropBytes, + write, + yieldAfter: Boolean(options.yieldAfter), + flowAckClaimed: false, + }], nextIndex: 0, cancelled: false, yieldAfter: Boolean(options.yieldAfter), @@ -462,6 +677,7 @@ export const abortTerminalWriteQueue = ( queue.stepTimer = undefined; } queue.stepContinuation = undefined; + clearStallWatchdog(queue); terminalWriteQueues.delete(term); if (droppedBytes > 0) { diff --git a/components/terminal/runtime/terminalWriteQueueWatchdog.test.ts b/components/terminal/runtime/terminalWriteQueueWatchdog.test.ts new file mode 100644 index 0000000000..88c5b4be35 --- /dev/null +++ b/components/terminal/runtime/terminalWriteQueueWatchdog.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Terminal as XTerm } from "@xterm/xterm"; + +import { + enqueueTerminalWrite, + isXtermWriteBufferIdle, + setTerminalWriteQueueDropHandler, + WRITE_QUEUE_STALL_TIMEOUT_MS, + type TerminalWriteSignal, +} from "./terminalWriteQueue"; + +/** + * Recovery from a lost xterm write callback. + * + * A queue item completes only when its `write` closure calls the `done` it is + * handed, and that `done` is wired to xterm's `term.write(data, cb)` callback. + * If xterm accepts the write, parses it (its buffer drains to empty), yet never + * invokes `cb` — observed against a full-screen DEC 2026 TUI — the item never + * completes: `queue.active` stays set, `queue.writing` stays true, and every + * item behind it is stranded. Nothing reschedules it, because the queue is + * waiting on a callback that will never arrive. + * + * Downstream this is a permanent freeze: the completion callback is where + * `flow.written()` and the IPC ack live (terminalSessionAttachment), so the + * renderer backlog never drains, the main process pauses the PTY at the high + * watermark, and a TUI's own writes then block — its render loop stalls and its + * keyboard goes dead. + * + * The queue must not depend solely on xterm's callback. A stall watchdog + * force-completes an active item that has made no progress, has nothing + * scheduled, *and* whose xterm write buffer is idle — acknowledging its bytes + * once so flow control recovers without double-acking a late real callback. + */ + +const settle = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +const makeTerm = (): XTerm => ({}) as XTerm; + +type PendingBufferTerm = XTerm & { + _core: { + _writeBuffer: { + _pendingData: number; + _writeBuffer: Array; + _bufferOffset: number; + }; + }; +}; + +const makeBusyTerm = (pendingData: number): PendingBufferTerm => ({ + _core: { + _writeBuffer: { + _pendingData: pendingData, + _writeBuffer: [], + _bufferOffset: 0, + }, + }, +}) as PendingBufferTerm; + +test("a write whose callback never fires does not wedge the queue forever", async () => { + const term = makeTerm(); + const ran: number[] = []; + const dropped: number[] = []; + setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes)); + + // The first write simulates a lost xterm callback: it runs, but its `done` + // is never called. Term has no write buffer → treated as idle. + enqueueTerminalWrite(term, 4096, () => { ran.push(0); }); + + // Ordinary writes queued behind the stalled one. + for (let i = 1; i <= 5; i += 1) { + enqueueTerminalWrite(term, 4096, (done) => { + ran.push(i); + setTimeout(done, 0); + }); + } + + // Before the watchdog: only the stalled write has run; the rest are stuck. + await settle(50); + assert.deepEqual(ran, [0], "writes behind a stalled one must not run yet"); + + // After the watchdog fires: the queue recovers and drains the rest in order. + await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300); + assert.deepEqual( + ran, [0, 1, 2, 3, 4, 5], + "queue must recover and drain the stranded writes" + ); + assert.ok( + dropped.reduce((a, b) => a + b, 0) > 0, + "the stalled item's bytes must be acknowledged so flow control resumes" + ); +}); + +test("the watchdog leaves a healthy queue untouched", async () => { + const term = makeTerm(); + const ran: number[] = []; + const dropped: number[] = []; + setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes)); + + // Every write completes normally, some slowly but well within the timeout. + for (let i = 0; i < 8; i += 1) { + enqueueTerminalWrite(term, 4096, (done) => { + ran.push(i); + setTimeout(done, 5); + }, { yieldAfter: i % 2 === 0 }); + } + + await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300); + assert.deepEqual(ran, [0, 1, 2, 3, 4, 5, 6, 7], "all writes ran"); + assert.equal( + dropped.reduce((a, b) => a + b, 0), 0, + "nothing must be dropped when the queue is healthy" + ); +}); + +test("a late callback after a watchdog recovery does not double-advance", async () => { + const term = makeTerm(); + const ran: number[] = []; + let stalledDone: (() => void) | undefined; + + // First write captures its done without calling it, then fires it LATE — + // after the watchdog has already force-completed the item. + enqueueTerminalWrite(term, 4096, (done) => { ran.push(0); stalledDone = done; }); + for (let i = 1; i <= 3; i += 1) { + enqueueTerminalWrite(term, 4096, (done) => { + ran.push(i); + setTimeout(done, 0); + }); + } + + await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300); + const afterRecovery = [...ran]; + // The real callback finally arrives; it must be a harmless no-op. + stalledDone?.(); + await settle(100); + + assert.deepEqual(afterRecovery, [0, 1, 2, 3], "queue recovered before the late callback"); + assert.deepEqual(ran, [0, 1, 2, 3], "a late callback must not re-run or duplicate writes"); +}); + +test("late callback after watchdog cannot re-claim flow ack (no double-ack)", async () => { + const term = makeTerm(); + const dropped: number[] = []; + setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes)); + + let signal: TerminalWriteSignal | undefined; + let stalledDone: (() => void) | undefined; + enqueueTerminalWrite(term, 4096, (done, sig) => { + signal = sig; + stalledDone = done; + }, { dropBytes: 4096 }); + + enqueueTerminalWrite(term, 10, (done) => { done(); }); + + await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300); + assert.deepEqual(dropped, [4096], "watchdog claims and acks once"); + assert.equal(signal?.isCancelled(), true); + assert.equal( + signal?.tryClaimFlowAck(), false, + "late path must not re-claim after watchdog" + ); + + // Late real callback still calls done; queue must ignore it. + stalledDone?.(); + await settle(50); + assert.deepEqual(dropped, [4096], "no second onDropped from late done"); +}); + +test("watchdog requeues unstarted flood-merged steps instead of ACKing them", async () => { + const term = makeTerm(); + const dropped: number[] = []; + const ran: number[] = []; + setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes)); + + // Flood merge into one active multi-step item: stall on step 0, keep steps 1-2 + // unwritten. Watchdog must ACK only step 0 and requeue the rest. + for (let i = 0; i < 3; i += 1) { + enqueueTerminalWrite(term, 100, (done) => { + ran.push(i); + if (i === 0) return; // lost callback on first step only + setTimeout(done, 0); + }, { dropBytes: 100 }); + } + + await settle(50); + assert.deepEqual(ran, [0], "only the first merged step has started"); + + await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 300); + assert.deepEqual(dropped, [100], "only the dispatched step is flow-acked"); + assert.deepEqual(ran, [0, 1, 2], "unstarted tail is requeued and still runs"); +}); + +test("watchdog does not force-complete while xterm write buffer is still busy", async () => { + const term = makeBusyTerm(8192); + assert.equal(isXtermWriteBufferIdle(term), false); + + const ran: number[] = []; + const dropped: number[] = []; + setTerminalWriteQueueDropHandler(term, (bytes) => dropped.push(bytes)); + + let release: (() => void) | undefined; + enqueueTerminalWrite(term, 4096, (done) => { + ran.push(0); + release = done; + }); + enqueueTerminalWrite(term, 10, (done) => { + ran.push(1); + done(); + }); + + // Well past one stall timeout: still busy inside xterm → must not recover yet. + await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 100); + assert.deepEqual(ran, [0], "must not advance while xterm is still parsing"); + assert.deepEqual(dropped, [], "must not drop/ack a still-in-flight write"); + + // Buffer drains (xterm finished parse) but callback still missing → now recover. + term._core._writeBuffer._pendingData = 0; + await settle(WRITE_QUEUE_STALL_TIMEOUT_MS + 200); + assert.deepEqual(ran, [0, 1], "recovers once xterm is idle with no callback"); + assert.deepEqual(dropped, [4096]); + + // Late callback after recovery is a no-op for the queue. + release?.(); + await settle(20); + assert.deepEqual(ran, [0, 1]); +}); diff --git a/components/terminal/useTerminalEffects.ts b/components/terminal/useTerminalEffects.ts index 713a049105..b057979f36 100644 --- a/components/terminal/useTerminalEffects.ts +++ b/components/terminal/useTerminalEffects.ts @@ -959,6 +959,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) { terminalSettings.drawBoldInBrightColors; termRef.current.options.minimumContrastRatio = terminalSettings.minimumContrastRatio; + // allowTransparency is construction-time only (xterm: must be set before + // Terminal.open()). Do not assign here — the renderer/glyph atlas keeps + // the open-time mode until a new terminal session is created (Codex P2). termRef.current.options.smoothScrollDuration = terminalSettings.smoothScrolling ? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration diff --git a/domain/models/terminal.ts b/domain/models/terminal.ts index 39d01ac49e..5bdb39eb7f 100644 --- a/domain/models/terminal.ts +++ b/domain/models/terminal.ts @@ -60,6 +60,13 @@ export interface TerminalSettings { // Accessibility minimumContrastRatio: number; // Minimum contrast ratio (1-21) + // Rendering + // Rasterise glyphs onto a transparent tile instead of onto their background + // colour. The WebGL glyph cache is keyed on the background, so baking it in + // makes every glyph over a per-cell background (animated backgrounds, + // heatmaps, ANSI art) a cache miss and a fresh rasterisation on every frame. + allowTransparency: boolean; + // Keyboard altAsMeta: boolean; // Use ⌥ as the Meta key optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump @@ -368,6 +375,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { cursorShape: 'block', cursorBlink: true, minimumContrastRatio: 1, + allowTransparency: false, altAsMeta: false, optionArrowWordJump: false, shiftEnterNewlineEnabled: true, diff --git a/electron/bridges/terminalFlowAck.cjs b/electron/bridges/terminalFlowAck.cjs index 8c06c4afbc..1a7c135baf 100644 --- a/electron/bridges/terminalFlowAck.cjs +++ b/electron/bridges/terminalFlowAck.cjs @@ -3,9 +3,25 @@ const { FLOW_HIGH_WATER_MARK, FLOW_LOW_WATER_MARK, + LOCAL_FLOW_HIGH_WATER_MARK, + LOCAL_FLOW_LOW_WATER_MARK, } = require("../../infrastructure/config/terminalFlowConstants.cjs"); const { logTerminalOutputPerf } = require("./terminalPerformanceDiagnostics.cjs"); +// A local shell has no network to overwhelm and its source blocks on write when +// the pipe fills, so the tight SSH watermark only serves to throttle it. Give +// local sessions a much higher (still bounded) ceiling; every other kind keeps +// the default. Matches the renderer-side gate in terminalSessionAttachment. +function isLocalSession(session) { + return session?.protocol === "local" || session?.type === "local"; +} +function highWaterFor(session) { + return isLocalSession(session) ? LOCAL_FLOW_HIGH_WATER_MARK : FLOW_HIGH_WATER_MARK; +} +function lowWaterFor(session) { + return isLocalSession(session) ? LOCAL_FLOW_LOW_WATER_MARK : FLOW_LOW_WATER_MARK; +} + function getFlowTarget(session) { return session?.stream || session?.proc || session?.socket || session?.serialPort || null; } @@ -36,7 +52,7 @@ function ensureFlowState(session) { state.lastPerfAckLogAt = Number.isFinite(state.lastPerfAckLogAt) ? Math.max(0, state.lastPerfAckLogAt) : 0; state.sessionId = typeof state.sessionId === "string" && state.sessionId ? state.sessionId : null; if (typeof state.outputPaused !== "boolean") { - state.outputPaused = state.appliedPause && (state.rendererPaused || state.unackedBytes >= FLOW_HIGH_WATER_MARK); + state.outputPaused = state.appliedPause && (state.rendererPaused || state.unackedBytes >= highWaterFor(session)); } return session.flowState; } @@ -71,8 +87,8 @@ function getFlowPerfDetails(session, extra = {}) { appliedPause: state.appliedPause, unackedBytes: state.unackedBytes, bufferedBytes: state.bufferedBytes, - highWaterMark: FLOW_HIGH_WATER_MARK, - lowWaterMark: FLOW_LOW_WATER_MARK, + highWaterMark: highWaterFor(session), + lowWaterMark: lowWaterFor(session), ...extra, }; } @@ -95,15 +111,17 @@ function reconcileSessionFlow(session) { const target = getFlowTarget(session); if (!target) return; - if (!state.outputPaused && (state.rendererPaused || state.unackedBytes >= FLOW_HIGH_WATER_MARK)) { + const highWaterMark = highWaterFor(session); + const lowWaterMark = lowWaterFor(session); + if (!state.outputPaused && (state.rendererPaused || state.unackedBytes >= highWaterMark)) { state.outputPaused = true; - } else if (state.outputPaused && !state.rendererPaused && state.unackedBytes <= FLOW_LOW_WATER_MARK) { + } else if (state.outputPaused && !state.rendererPaused && state.unackedBytes <= lowWaterMark) { state.outputPaused = false; } const pendingBytes = state.unackedBytes + state.bufferedBytes; - const shouldPause = state.outputPaused || pendingBytes >= FLOW_HIGH_WATER_MARK; - const shouldResume = !state.outputPaused && pendingBytes <= FLOW_LOW_WATER_MARK; + const shouldPause = state.outputPaused || pendingBytes >= highWaterMark; + const shouldResume = !state.outputPaused && pendingBytes <= lowWaterMark; if (!state.appliedPause && shouldPause) { logTerminalOutputPerf("backend-flow-pause", getFlowPerfDetails(session, { pendingBytes })); diff --git a/infrastructure/config/terminalFlowConstants.json b/infrastructure/config/terminalFlowConstants.json index 0004cdd1fd..2424d4edd1 100644 --- a/infrastructure/config/terminalFlowConstants.json +++ b/infrastructure/config/terminalFlowConstants.json @@ -10,5 +10,7 @@ "TERMINAL_LONG_LINE_PRESSURE_BYTES": 65536, "TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS": 65536, "XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES": 1024, - "XTERM_WRITE_CALLBACK_BATCH_BYTES": 100000 -} + "XTERM_WRITE_CALLBACK_BATCH_BYTES": 100000, + "LOCAL_FLOW_HIGH_WATER_MARK": 4194304, + "LOCAL_FLOW_LOW_WATER_MARK": 2097152 +} \ No newline at end of file diff --git a/package.json b/package.json index 9e4914ef0f..3ca6e73f49 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never", "pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never", "pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never", - "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs", + "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs && node scripts/patch-xterm-sync-render.cjs", "rebuild": "electron-builder install-app-deps", "tool:cli": "node electron/cli/netcatty-tool-cli.cjs", "generate:capability-tools": "node scripts/generate-capability-tools.cjs", diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs new file mode 100644 index 0000000000..e5d216b0be --- /dev/null +++ b/scripts/patch-xterm-sync-render.cjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/* global process, console */ +/** + * Render a DEC 2026 synchronized-output frame the moment it closes, instead of + * on the next debounced tick. + * + * xterm's RenderService buffers rows while synchronized output is on and, on + * close, requests a refresh that is scheduled through the render debouncer + * (requestAnimationFrame). Under a continuous full-screen animation the next + * frame opens a new 2026 block before that rAF fires, and `_renderRows` skips + * while sync is on — so the debounced paint is dropped and the frame only + * appears when the 1000ms synchronized-output timeout expires. The display is + * then pinned at ~1fps however fast frames arrive. + * + * The fix renders synchronously when a synchronized-output buffer was just + * flushed. `refreshRows(...,sync,...)` normally does + * `sync ? _renderRows(...) : _renderDebouncer.refresh(...)`; we widen the + * condition to also render synchronously when the flush returned buffered rows + * (the local holding `_syncOutputHandler.flush()`). At that point the mode is + * already off, so the completed frame paints before the next can reopen it. + * + * Upstream: https://github.com/xtermjs/xterm.js (fix pending). Applied here as a + * string patch on the minified build, like patch-xterm-webgl-atlas.cjs, so a + * version bump that moves the target surfaces as an install failure rather than + * silently losing the fix. + * + * Idempotent. + */ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); + +const MARKER = "/*netcatty:sync-render*/"; + +// The minified `sync ? _renderRows(a,b) : _renderDebouncer.refresh(a,b,c)` +// ternary, and the `buffered` local to widen it with. Token names differ +// between the CJS and ESM builds, so each target names its own. +const TARGETS = [ + { + file: "node_modules/@xterm/xterm/lib/xterm.js", + // const r = flush(); ... i ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount) + from: "i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + to: "(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + }, + { + file: "node_modules/@xterm/xterm/lib/xterm.mjs", + // let o = flush(); ... r ? _renderRows(e,t) : _renderDebouncer.refresh(e,t,this._rowCount) + from: "r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + to: "(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)", + }, +]; + +let patched = 0; +let already = 0; +let missing = 0; + +for (const { file, from, to } of TARGETS) { + const abs = path.resolve(process.cwd(), file); + let src; + try { + src = fs.readFileSync(abs, "utf8"); + } catch { + console.warn(`[patch-xterm-sync-render] skip (not found): ${file}`); + missing++; + continue; + } + const withMarker = to + MARKER; + if (src.includes(withMarker)) { + already++; + continue; + } + // Upstream (or a prior unmarked apply) already has the widened ternary — + // treat as already fixed so postinstall does not fail (Codex P3). + if (src.includes(to)) { + already++; + continue; + } + if (src.split(from).length - 1 === 1) { + fs.writeFileSync(abs, src.replace(from, withMarker), "utf8"); + patched++; + } else { + console.warn( + `[patch-xterm-sync-render] ERROR: sync-render ternary not found (or ambiguous) in ${file}. ` + + "Refresh the minified target before upgrading @xterm/xterm.", + ); + missing++; + } +} + +console.log( + `[patch-xterm-sync-render] patched=${patched} already=${already} missing=${missing}`, +); + +if (missing > 0) process.exitCode = 1;