mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 04:22:38 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc6a0d455 | ||
|
|
bdc7dbde0a | ||
|
|
507304d98c | ||
|
|
91c47c9591 | ||
|
|
75ce231743 | ||
|
|
bfca22ff0b | ||
|
|
651c447b84 | ||
|
|
c34c6e2822 | ||
|
|
44a74dfcb9 | ||
|
|
b30766cdd3 | ||
|
|
5a6a6d59c2 | ||
|
|
7a16b73fc1 | ||
|
|
0ff11a9a07 | ||
|
|
f05615f0d1 | ||
|
|
2358d280ec | ||
|
|
e6e17a780c |
No files matched your search
@@ -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.',
|
||||
'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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1049,6 +1049,15 @@ function SettingsTerminalTab(props: {
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.rendering.allowTransparency")}
|
||||
description={t("settings.terminal.rendering.allowTransparency.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.allowTransparency}
|
||||
onChange={(v) => updateTerminalSetting("allowTransparency", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.rendering.hibernateHiddenTabs")}
|
||||
description={t("settings.terminal.rendering.hibernateHiddenTabs.desc")}
|
||||
|
||||
@@ -420,6 +420,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const fontWeightBold = settings?.fontWeightBold ?? 700;
|
||||
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
|
||||
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
|
||||
const allowTransparency =
|
||||
settings?.allowTransparency ?? performanceConfig.options.allowTransparency;
|
||||
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
|
||||
const smoothScrollDuration = settings?.smoothScrolling
|
||||
? performanceConfig.options.smoothScrollDuration
|
||||
@@ -454,6 +456,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
|
||||
rescaleOverlappingGlyphs: true,
|
||||
// After the performanceConfig spread so the user setting wins over the
|
||||
// platform default.
|
||||
allowTransparency,
|
||||
fontSize: effectiveFontSize,
|
||||
fontFamily,
|
||||
fontWeight: fontWeight as
|
||||
|
||||
@@ -242,3 +242,53 @@ test("isTerminalViewportScrolledUp is false on alternate screen", () => {
|
||||
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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export function isInsideSyncBlockAt(data: string, from: number, to: number): boolean {
|
||||
let open = false;
|
||||
let i = data.indexOf("\x1b[?2026", from);
|
||||
while (i !== -1 && i < to) {
|
||||
if (data.startsWith(SYNC_OPEN, i)) {
|
||||
// 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 + SYNC_OPEN.length > to) break;
|
||||
open = true;
|
||||
i += SYNC_OPEN.length;
|
||||
} else if (data.startsWith(SYNC_CLOSE, i)) {
|
||||
// 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 + SYNC_CLOSE.length > to) break;
|
||||
open = false;
|
||||
i += SYNC_CLOSE.length;
|
||||
} else {
|
||||
// A different `?2026` sequence (e.g. a DECRQM query `\x1b[?2026$p`) — not
|
||||
// a frame boundary; step past this ESC and keep scanning.
|
||||
i += 1;
|
||||
}
|
||||
i = data.indexOf("\x1b[?2026", 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 (let k = 1; k < SYNC_CLOSE.length; k++) {
|
||||
const candidateStart = pos - k;
|
||||
if (candidateStart < offset) break;
|
||||
if (data.startsWith(SYNC_CLOSE, candidateStart)) {
|
||||
return candidateStart + SYNC_CLOSE.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 close = data.indexOf(SYNC_CLOSE, end);
|
||||
if (close === -1) return data.length;
|
||||
return close + SYNC_CLOSE.length;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
apportionFrameGateIngress,
|
||||
collapseAndSplit,
|
||||
endsWithSyncOpenerPrefix,
|
||||
isDroppableVisualPayload,
|
||||
makesFullRepaint,
|
||||
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\n\tplain text"), true);
|
||||
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");
|
||||
});
|
||||
|
||||
// ---- viewportRepaintCoverage / makesFullRepaint ---------------------------
|
||||
|
||||
test("coverage counts distinct written cells, with wrap", () => {
|
||||
assert.equal(viewportRepaintCoverage("abcd", 4, 1), 4);
|
||||
assert.equal(viewportRepaintCoverage("abcd", 2, 2), 4); // wraps to second row
|
||||
assert.equal(viewportRepaintCoverage("ab", 4, 1), 2);
|
||||
});
|
||||
|
||||
test("coverage honors cursor positioning and erases", () => {
|
||||
assert.equal(viewportRepaintCoverage("\x1b[2J", 4, 2), 8, "erase-all covers the grid");
|
||||
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 clear still counts as a full repaint.
|
||||
assert.equal(makesFullRepaint("\x1b[2J", cols, rows), true);
|
||||
// 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 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 still covers the whole grid.
|
||||
assert.equal(viewportRepaintCoverage("\x1b[2J", cols, rows), cols * rows, "ED2 covers the grid");
|
||||
// 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 OSC still count; OSC itself does not inflate.
|
||||
assert.equal(viewportRepaintCoverage(`${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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 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 <every cell rewritten with SGR> 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";
|
||||
|
||||
/** Length of a trailing run of `s` that is a proper (non-empty) prefix of the
|
||||
* sync opener — i.e. a `ESC[?2026h` split at a chunk boundary. 0 when none. */
|
||||
const trailingSyncOpenerPrefixLen = (s: string): number => {
|
||||
const max = Math.min(SYNC_ON.length - 1, s.length);
|
||||
for (let k = max; k >= 1; k--) {
|
||||
if (s.endsWith(SYNC_ON.slice(0, k))) return k;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** 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
|
||||
"S", "T", // scroll up / down
|
||||
]);
|
||||
/** C0 controls that only move the cursor. */
|
||||
const DROPPABLE_C0 = new Set(["\r", "\n", "\t", "\b"]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const viewportRepaintCoverage = (
|
||||
content: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): number => {
|
||||
if (cols <= 0 || rows <= 0) return 0;
|
||||
const covered = new Set<number>();
|
||||
let row = 0;
|
||||
let col = 0;
|
||||
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 (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(); }
|
||||
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 (p=2) clears the whole viewport and counts as coverage. ED3 (p=3)
|
||||
// clears saved scrollback only — it must not mark viewport cells, or
|
||||
// makesFullRepaint would let collapseAndSplit drop a prior visual frame
|
||||
// whose content the ED3 successor never repaints.
|
||||
if (p === 2) { for (let r = 0; r < rows; r++) markRange(r, 0, cols - 1); }
|
||||
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;
|
||||
|
||||
/** 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;
|
||||
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,
|
||||
): FrameGateSplit => {
|
||||
const frames: Frame[] = [];
|
||||
let cursor = 0;
|
||||
let partialStart = buffer.length;
|
||||
while (true) {
|
||||
const on = buffer.indexOf(SYNC_ON, cursor);
|
||||
if (on < 0) break;
|
||||
const contentStart = on + SYNC_ON.length;
|
||||
const off = buffer.indexOf(SYNC_OFF, contentStart);
|
||||
if (off < 0) { partialStart = on; break; }
|
||||
const end = off + SYNC_OFF.length;
|
||||
frames.push({ start: on, end, content: buffer.slice(contentStart, off) });
|
||||
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<boolean>(frames.length).fill(false);
|
||||
for (let i = 0; i < frames.length - 1; i++) {
|
||||
const cur = frames[i];
|
||||
const next = frames[i + 1];
|
||||
if (
|
||||
next.start === cur.end
|
||||
&& isDroppableVisualPayload(cur.content)
|
||||
&& isFullRepaint(next.content)
|
||||
) {
|
||||
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 };
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -47,6 +47,12 @@ import {
|
||||
resetTerminalSyncBlockFilter,
|
||||
} from "./terminalSyncBlockFilter";
|
||||
import { appendEraseScrollbackAfterFullErases } from "../clearTerminalViewport";
|
||||
import {
|
||||
apportionFrameGateIngress,
|
||||
collapseAndSplit,
|
||||
endsWithSyncOpenerPrefix,
|
||||
makesFullRepaint,
|
||||
} from "./terminalFrameGate";
|
||||
import {
|
||||
type CoalescedTerminalWriteOptions,
|
||||
enqueueCoalescedTerminalWrite,
|
||||
@@ -68,6 +74,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";
|
||||
@@ -373,9 +381,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 +410,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);
|
||||
}
|
||||
});
|
||||
@@ -435,6 +468,13 @@ 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`;
|
||||
@@ -445,12 +485,199 @@ export const writeTerminalLine = (
|
||||
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<typeof setTimeout>;
|
||||
};
|
||||
const frameGateStates = new WeakMap<XTerm, FrameGateState>();
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
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.
|
||||
const engaged = (state && state.buffer.length > 0)
|
||||
|| data.includes("\x1b[?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 +773,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 +932,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 +957,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 +1027,14 @@ 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.
|
||||
resetFrameGate(term, (_buffer, ingress) => {
|
||||
if (ingress > 0) {
|
||||
ackTerminalSessionFlow(backend, sessionId, ingress);
|
||||
flushTerminalSessionFlowAck(sessionId);
|
||||
}
|
||||
});
|
||||
terminalFlowControllers.delete(term);
|
||||
};
|
||||
|
||||
@@ -838,6 +1083,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | Uint8Array>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<typeof setTimeout>;
|
||||
stepTimer?: ReturnType<typeof setTimeout>;
|
||||
stepContinuation?: () => void;
|
||||
stallWatchdog?: ReturnType<typeof setTimeout>;
|
||||
progressSeq: number;
|
||||
};
|
||||
|
||||
const terminalWriteQueues = new WeakMap<XTerm, TerminalWriteQueue>();
|
||||
@@ -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,87 @@ 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 and sum unacknowledged dropBytes of a stalled item: the
|
||||
* dispatched-but-uncompleted step and everything after it. Steps before it
|
||||
* already claimed via their write closures, so counting the whole item would
|
||||
* double-ack them. `nextIndex` points one past the dispatched step, hence
|
||||
* `nextIndex - 1`. Claiming here makes a late real callback's
|
||||
* {@link TerminalWriteSignal.tryClaimFlowAck} return false.
|
||||
*/
|
||||
const claimUnackedBytesOf = (item: QueuedWrite): number => {
|
||||
const from = Math.max(0, item.nextIndex - 1);
|
||||
const steps = item.steps.slice(from);
|
||||
if (steps.length === 0) {
|
||||
// No step slots (shouldn't happen for a real item); fall back to item total
|
||||
// only when nothing has been claimed yet via steps.
|
||||
return item.dropBytes;
|
||||
}
|
||||
let unacked = 0;
|
||||
for (const step of steps) {
|
||||
if (tryClaimStepFlowAck(step)) {
|
||||
unacked += step.dropBytes;
|
||||
}
|
||||
}
|
||||
return unacked;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 the item's unacked bytes (so flow control resumes once, not twice when
|
||||
* a late callback arrives) 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 so the late path cannot double-ack, then advance.
|
||||
item.cancelled = true;
|
||||
const unacked = claimUnackedBytesOf(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 +278,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 +308,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 +339,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 +362,7 @@ const runQueuedWrite = (
|
||||
item: QueuedWrite,
|
||||
done: () => void,
|
||||
deferStep: (continuation: () => void) => void,
|
||||
onStepProgress?: () => void,
|
||||
): void => {
|
||||
let index = 0;
|
||||
let completed = false;
|
||||
@@ -225,7 +406,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 +444,7 @@ const runQueuedWrite = (
|
||||
return;
|
||||
}
|
||||
continueAfterStep();
|
||||
});
|
||||
}, signal);
|
||||
insideWrite = false;
|
||||
if (callbackCalledSynchronously) {
|
||||
continueAfterStep();
|
||||
@@ -393,7 +582,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 +600,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 +657,7 @@ export const abortTerminalWriteQueue = (
|
||||
queue.stepTimer = undefined;
|
||||
}
|
||||
queue.stepContinuation = undefined;
|
||||
clearStallWatchdog(queue);
|
||||
terminalWriteQueues.delete(term);
|
||||
|
||||
if (droppedBytes > 0) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const makeTerm = (): XTerm => ({}) as XTerm;
|
||||
|
||||
type PendingBufferTerm = XTerm & {
|
||||
_core: {
|
||||
_writeBuffer: {
|
||||
_pendingData: number;
|
||||
_writeBuffer: Array<string | Uint8Array>;
|
||||
_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 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]);
|
||||
});
|
||||
@@ -959,6 +959,8 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.allowTransparency =
|
||||
terminalSettings.allowTransparency;
|
||||
termRef.current.options.smoothScrollDuration =
|
||||
terminalSettings.smoothScrolling
|
||||
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/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;
|
||||
}
|
||||
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;
|
||||
Reference in new issue
Block a user