Files
NetMesh/components/terminal/runtime/terminalOutputPressure.ts

336 lines
11 KiB
TypeScript
Raw Normal View History

import type { Terminal as XTerm } from "@xterm/xterm";
import { XTERM_PERFORMANCE_CONFIG } from "../../../infrastructure/config/xtermPerformance";
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants";
export type TerminalOutputPressureMode =
| "normal"
| "large-output"
| "long-line"
| "background";
export type TerminalOutputPressureSnapshot = {
mode: TerminalOutputPressureMode;
background: boolean;
largeOutput: boolean;
longLine: boolean;
/** True when the active buffer is near its scrollback capacity (trim-on-write). */
scrollbackSaturated: boolean;
consecutiveUnbrokenBytes: number;
};
type OutputRateSample = {
at: number;
bytes: number;
};
type TerminalOutputPressureState = {
background: boolean;
largeOutput: boolean;
largeOutputUntil: number;
/**
* Separate, stricter flood gate for line-timestamp markers. Full scrollback +
* multi-line (e.g. `docker ps`) must NOT drop per-line timestamps only true
* high-rate dumps (seq/yes) skip registerMarker storms.
*/
timestampFloodUntil: number;
longLine: boolean;
consecutiveUnbrokenBytes: number;
/** True rolling window samples for high-rate small-chunk detection. */
recentSamples: OutputRateSample[];
recentSampleBytes: number;
};
/**
* Detect bulk streams that arrive as many small IPC chunks (e.g. `yes`, `seq`).
*
* Tabby has almost no write-path side work, so it never needs an explicit
* "bulk mode". We do (timestamps / keyword highlights), so arm large-output
* early enough that the *second* dump on a full scrollback does not spend its
* first ~64KB in the expensive normal path.
*/
const LARGE_OUTPUT_RATE_WINDOW_MS = 100;
/** Lower than a full 128KB xterm shard so pressure leads the first write batch. */
const LARGE_OUTPUT_RATE_BYTES = 16 * 1024;
/**
* Only skip line-timestamp markers at true flood rates. Early large-output
* (16KB) and saturated multi-line still degrade highlight/prep, but keep the
* product rule: each output line can get a gutter timestamp.
*/
const TIMESTAMP_SKIP_RATE_BYTES = 64 * 1024;
/**
* When scrollback is already full, any multi-line or modest chunk should arm
* bulk mode: every new line trims, and marker/highlight work multiplies cost.
*/
const SATURATED_SCROLLBACK_BULK_MIN_BYTES = 64;
const pressureStates = new WeakMap<XTerm, TerminalOutputPressureState>();
const getOrCreateState = (term: XTerm): TerminalOutputPressureState => {
let state = pressureStates.get(term);
if (!state) {
state = {
background: false,
largeOutput: false,
largeOutputUntil: 0,
timestampFloodUntil: 0,
longLine: false,
consecutiveUnbrokenBytes: 0,
recentSamples: [],
recentSampleBytes: 0,
};
pressureStates.set(term, state);
}
return state;
};
const noteRecentOutputRate = (
state: TerminalOutputPressureState,
now: number,
bytes: number,
): number => {
state.recentSamples.push({ at: now, bytes });
state.recentSampleBytes += bytes;
const cutoff = now - LARGE_OUTPUT_RATE_WINDOW_MS;
while (state.recentSamples.length > 0 && state.recentSamples[0]!.at < cutoff) {
const dropped = state.recentSamples.shift()!;
state.recentSampleBytes -= dropped.bytes;
}
if (state.recentSampleBytes < 0) state.recentSampleBytes = 0;
return state.recentSampleBytes;
};
const LINE_BREAK_SCAN = /[\n\r]/g;
const measureUnbrokenRuns = (
data: string,
initialRunBytes: number,
): { maxRunBytes: number; trailingRunBytes: number } => {
// Hot path for every output batch: hop between line breaks with a native
// regex scan instead of visiting each character in JS. A run only counts
// toward the max when this chunk actually appended characters to it,
// matching the original per-char accounting.
let maxRunBytes = 0;
let runStart = 0;
let carriedRunBytes = initialRunBytes;
LINE_BREAK_SCAN.lastIndex = 0;
for (
let match = LINE_BREAK_SCAN.exec(data);
match !== null;
match = LINE_BREAK_SCAN.exec(data)
) {
const appendedBytes = match.index - runStart;
if (appendedBytes > 0) {
const runBytes = carriedRunBytes + appendedBytes;
if (runBytes > maxRunBytes) {
maxRunBytes = runBytes;
}
}
carriedRunBytes = 0;
runStart = match.index + 1;
}
const trailingAppendedBytes = data.length - runStart;
const trailingRunBytes = carriedRunBytes + trailingAppendedBytes;
if (trailingAppendedBytes > 0 && trailingRunBytes > maxRunBytes) {
maxRunBytes = trailingRunBytes;
}
return { maxRunBytes, trailingRunBytes };
};
const resolveConfiguredScrollback = (term: XTerm): number => {
const options = (term as XTerm & { options?: { scrollback?: number } }).options;
const scrollback = options?.scrollback;
if (typeof scrollback === "number" && Number.isFinite(scrollback) && scrollback > 0) {
return Math.floor(scrollback);
}
return 0;
};
/**
* True when the active buffer is near capacity so new lines force scrollback
* trim. Second `seq` dumps hit this path for the entire run; first dumps only
* after the buffer fills.
*/
export const isTerminalScrollbackSaturated = (term: XTerm): boolean => {
try {
const active = term.buffer?.active as
| { length?: number; baseY?: number }
| undefined;
if (!active) return false;
const rows = Math.max(1, term.rows || 0);
const scrollback = resolveConfiguredScrollback(term);
if (scrollback <= 0) return false;
const maxLines = rows + scrollback;
const length = typeof active.length === "number" ? active.length : 0;
if (length <= 0) return false;
// Treat "within one viewport of full" as saturated — cheap, stable, and
// matches when xterm starts trimming aggressively on multi-line floods.
const slack = Math.max(rows, 8);
return length >= maxLines - slack;
} catch {
return false;
}
};
const markLargeOutput = (
state: TerminalOutputPressureState,
now: number,
quietMs: number,
): void => {
state.largeOutputUntil = now + quietMs;
state.largeOutput = true;
};
const resolveLargeOutputQuietMs = (scrollbackSaturated: boolean): number => {
const base = XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs;
// Full buffers stay expensive after the dump ends (trim/marker churn). Keep
// bulk side-work off a bit longer so a second dump does not reopen the
// expensive path between prompt echoes.
return scrollbackSaturated ? Math.max(base, base * 2) : base;
};
export const noteTerminalOutputPressureData = (
term: XTerm,
data: string,
): void => {
if (!data) return;
const state = getOrCreateState(term);
const now = performance.now();
const scrollbackSaturated = isTerminalScrollbackSaturated(term);
const quietMs = resolveLargeOutputQuietMs(scrollbackSaturated);
const recentBytes = noteRecentOutputRate(state, now, data.length);
const hasLineBreak = data.includes("\n") || data.includes("\r");
// Full scrollback + multi-line (seq/logs) or a modest plain chunk → bulk.
// Tiny single-key echoes without newlines stay on the normal path.
const saturatedBulkChunk = scrollbackSaturated
&& (
hasLineBreak
|| data.length >= SATURATED_SCROLLBACK_BULK_MIN_BYTES
);
const trueFlood = data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES
|| recentBytes >= TIMESTAMP_SKIP_RATE_BYTES;
if (
data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES
|| recentBytes >= LARGE_OUTPUT_RATE_BYTES
|| saturatedBulkChunk
) {
markLargeOutput(state, now, quietMs);
} else if (now >= state.largeOutputUntil) {
state.largeOutput = false;
}
// Timestamp markers: only suppress under true flood / long lines — never for
// "scrollback full + docker ps" style multi-line output.
if (trueFlood) {
state.timestampFloodUntil = now + quietMs;
}
const { maxRunBytes, trailingRunBytes } = measureUnbrokenRuns(
data,
state.consecutiveUnbrokenBytes,
);
state.consecutiveUnbrokenBytes = trailingRunBytes;
state.longLine = maxRunBytes >= TERMINAL_LONG_LINE_PRESSURE_BYTES;
if (state.longLine) {
state.timestampFloodUntil = Math.max(state.timestampFloodUntil, now + quietMs);
}
};
export const setTerminalOutputPressureVisibility = (
term: XTerm,
visible: boolean,
): void => {
getOrCreateState(term).background = !visible;
};
/** True while the pane is hidden (recorded via {@link setTerminalOutputPressureVisibility}). */
export const isTerminalOutputInBackground = (term: XTerm): boolean => (
pressureStates.get(term)?.background ?? false
);
export const setTerminalOutputPressureLargeOutput = (
term: XTerm,
largeOutput: boolean,
): void => {
const state = getOrCreateState(term);
state.largeOutput = largeOutput;
const quietMs = resolveLargeOutputQuietMs(isTerminalScrollbackSaturated(term));
state.largeOutputUntil = largeOutput
? performance.now() + quietMs
: 0;
// Explicit large-output flag is used by tests/flood paths that also suppress
// timestamp storms; clear both gates when turning off.
if (largeOutput) {
state.timestampFloodUntil = state.largeOutputUntil;
} else {
state.timestampFloodUntil = 0;
}
};
export const getTerminalOutputPressure = (
term: XTerm,
): TerminalOutputPressureSnapshot => {
const state = getOrCreateState(term);
const scrollbackSaturated = isTerminalScrollbackSaturated(term);
const largeOutput = state.largeOutput && performance.now() < state.largeOutputUntil;
const mode: TerminalOutputPressureMode = state.background
? "background"
: state.longLine
? "long-line"
: largeOutput
? "large-output"
: "normal";
return {
mode,
background: state.background,
largeOutput,
longLine: state.longLine,
scrollbackSaturated,
consecutiveUnbrokenBytes: state.consecutiveUnbrokenBytes,
};
};
/**
* True when hot-path side work (highlight scans, prep, coalesce) should degrade
* so xterm can keep painting bulk output smoothly closer to Tabby's
* near-empty write path (FlowControl + xterm.write only).
*/
export const shouldDegradeTerminalSideWork = (term: XTerm): boolean => {
const pressure = getTerminalOutputPressure(term);
return pressure.largeOutput || pressure.longLine;
};
/**
* Keyword coloring only examines the current write, so a stale quiet-window
* bulk flag must not make a later one-line prompt schedule a history rebuild.
*/
export const shouldDegradeTerminalKeywordHighlight = (
term: XTerm,
data: string,
): boolean => {
const state = getOrCreateState(term);
if (state.longLine || data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES) return true;
return state.recentSampleBytes >= LARGE_OUTPUT_RATE_BYTES;
};
/**
* Whether line-timestamp registerMarker work should be skipped.
*
* Stricter than {@link shouldDegradeTerminalSideWork}: full-scrollback multi-line
* output (docker ps, short command output) must still stamp each line. Only
* true flood rates / long lines suppress markers.
*/
export const shouldSkipTerminalLineTimestamps = (term: XTerm): boolean => {
const state = getOrCreateState(term);
if (state.longLine) return true;
return performance.now() < state.timestampFloodUntil;
};
export const resetTerminalOutputPressure = (term: XTerm): void => {
pressureStates.delete(term);
};