mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd111b1264 | ||
|
|
891bce6551 | ||
|
|
cfc4984ad2 | ||
|
|
2f0249f74c | ||
|
|
0a5e3ef57e | ||
|
|
45ef6c2c9f | ||
|
|
fd0fe8bbdd | ||
|
|
e6e7cb8140 | ||
|
|
bcbe93c831 | ||
|
|
2f2ef273c4 | ||
|
|
16234dfc44 | ||
|
|
668744a936 | ||
|
|
bd02de4157 | ||
|
|
ee88adf854 | ||
|
|
98dea3e370 | ||
|
|
afba5acf4b | ||
|
|
4176acd2b7 | ||
|
|
6650c3c07d | ||
|
|
6f81a62e21 | ||
|
|
65ffa11b73 | ||
|
|
c310931713 | ||
|
|
82286166cf | ||
|
|
8a0a0ce2ca | ||
|
|
63b59504e2 | ||
|
|
673843149b | ||
|
|
9ee4531483 | ||
|
|
3a8a63273b | ||
|
|
c12d338000 | ||
|
|
2662a9f196 | ||
|
|
dabcec4c2c | ||
|
|
c54b711793 | ||
|
|
6e5896593b | ||
|
|
9d941d0594 |
No files matched your search
@@ -21,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
@@ -50,11 +52,18 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal decoration performance
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight throughput
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
|
||||
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -1513,6 +1513,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
||||
snapshot = serializeAddonRef.current.serialize() || "";
|
||||
} else if (hibernatedRef.current || softHiddenRef.current) {
|
||||
// Hibernate path: live xterm is torn down; use retained snapshot.
|
||||
@@ -1696,6 +1697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
let serializedSnapshot: unknown;
|
||||
try {
|
||||
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
||||
serializedSnapshot = serializeAddonRef.current?.serialize?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
|
||||
@@ -2053,7 +2055,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snapshot = await serializeTerminalForHibernate(
|
||||
term,
|
||||
serializeAddon,
|
||||
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
|
||||
{
|
||||
preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current),
|
||||
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
|
||||
?? Promise.resolve(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!canFinishHibernate()) return false;
|
||||
@@ -2175,6 +2181,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef,
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current
|
||||
?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(),
|
||||
searchAddonRef,
|
||||
hasRuntimeRef,
|
||||
}), []);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+791
-1945
File diff suppressed because it is too large.
Load diff
@@ -211,6 +211,68 @@ test('visual Provider host preserves semantics when command completion wins the
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
test('visual Provider host restores annotations after terminal history is rebuilt', async () => {
|
||||
const markers: Array<{ line: number; isDisposed: boolean; dispose(): void }> = [];
|
||||
const renderedText: string[] = [];
|
||||
const term = {
|
||||
element: null,
|
||||
buffer: { active: { type: 'normal', baseY: 4, cursorY: 2, cursorX: 0, getLine() { return undefined; } } },
|
||||
onWriteParsed() { return { dispose() {} }; },
|
||||
registerMarker(offset: number) {
|
||||
const marker = {
|
||||
line: 6 + offset,
|
||||
isDisposed: false,
|
||||
dispose() { this.isDisposed = true; this.line = -1; },
|
||||
};
|
||||
markers.push(marker);
|
||||
return marker;
|
||||
},
|
||||
registerDecoration() {
|
||||
return {
|
||||
dispose() {},
|
||||
onRender(listener: (element: Record<string, unknown>) => void) {
|
||||
const element = {
|
||||
className: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
style: {},
|
||||
setAttribute() {},
|
||||
};
|
||||
listener(element);
|
||||
renderedText.push(String(element.textContent));
|
||||
return { dispose() {} };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const host = new PluginTerminalVisualProviderHost({
|
||||
term: term as never,
|
||||
isProviderAvailable: (kind) => kind === 'terminal.semantic',
|
||||
async request() {
|
||||
return {
|
||||
stale: false,
|
||||
results: [{ providerId: 'semantic', status: 'ok', result: {
|
||||
classification: 'success',
|
||||
annotations: [{ text: 'retained annotation' }],
|
||||
} }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await host.commandSubmitted('true');
|
||||
await host.commandCompleted();
|
||||
assert.deepEqual(renderedText, ['success | retained annotation']);
|
||||
markers[0].dispose();
|
||||
|
||||
host.terminalRebuilt();
|
||||
assert.deepEqual(renderedText, [
|
||||
'success | retained annotation',
|
||||
'success | retained annotation',
|
||||
]);
|
||||
assert.equal(markers[1].line, 6);
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
test('visual Provider host preserves semantic queue order at its bounded capacity', async () => {
|
||||
let requests = 0;
|
||||
const renderedText: string[] = [];
|
||||
|
||||
@@ -134,6 +134,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
readonly #disposables: IDisposable[] = [];
|
||||
readonly #matcherDecorations: Array<IDecoration | IMarker | undefined> = [];
|
||||
readonly #annotationDecorations: Array<IDecoration | IMarker | undefined> = [];
|
||||
#visibleAnnotations: readonly PluginTerminalAnnotation[] = Object.freeze([]);
|
||||
#annotationAnchorLine: number | null = null;
|
||||
#annotationMarker: IMarker | null = null;
|
||||
readonly #requestControllers = new Set<AbortController>();
|
||||
readonly #matcherRequestControllers = new Set<AbortController>();
|
||||
#matcherTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -196,6 +199,9 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
}
|
||||
this.#isProviderAvailable = options.isProviderAvailable ?? (() => true);
|
||||
this.#disposables.push(this.#term.onWriteParsed(() => {
|
||||
if (this.#annotationMarker && !this.#annotationMarker.isDisposed) {
|
||||
this.#annotationAnchorLine = this.#annotationMarker.line;
|
||||
}
|
||||
this.#matcherGeneration += 1;
|
||||
for (const controller of this.#matcherRequestControllers) controller.abort();
|
||||
this.#matcherRequestControllers.clear();
|
||||
@@ -432,11 +438,23 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
#renderAnnotations(annotations: readonly PluginTerminalAnnotation[]): void {
|
||||
#renderAnnotations(
|
||||
annotations: readonly PluginTerminalAnnotation[],
|
||||
anchorLine?: number | null,
|
||||
): void {
|
||||
disposeAll(this.#annotationDecorations);
|
||||
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) return;
|
||||
const marker = this.#term.registerMarker(0);
|
||||
this.#annotationMarker = null;
|
||||
this.#visibleAnnotations = Object.freeze([...annotations]);
|
||||
if (annotations.length === 0 || this.#disposed || !this.#active || !this.#visible) {
|
||||
this.#annotationAnchorLine = null;
|
||||
return;
|
||||
}
|
||||
const buffer = this.#term.buffer.active;
|
||||
const cursorLine = buffer.baseY + buffer.cursorY;
|
||||
const marker = this.#term.registerMarker((anchorLine ?? cursorLine) - cursorLine);
|
||||
if (!marker) return;
|
||||
this.#annotationMarker = marker;
|
||||
this.#annotationAnchorLine = marker.line;
|
||||
const decoration = this.#term.registerDecoration({ marker, anchor: 'right', x: 0, width: 1, layer: 'top' });
|
||||
const text = annotations.map((annotation) => annotation.text).join(' | ').slice(0, 512);
|
||||
const description = annotations.map((annotation) => annotation.description).filter(Boolean).join('\n');
|
||||
@@ -454,6 +472,19 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#annotationDecorations.push(decoration, marker);
|
||||
}
|
||||
|
||||
/** Recreate markers disposed by a terminal history replay without re-querying plugins. */
|
||||
terminalRebuilt(): void {
|
||||
if (this.#visibleAnnotations.length === 0) return;
|
||||
this.#renderAnnotations(this.#visibleAnnotations, this.#annotationAnchorLine);
|
||||
}
|
||||
|
||||
#clearAnnotations(): void {
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#annotationMarker = null;
|
||||
this.#visibleAnnotations = Object.freeze([]);
|
||||
this.#annotationAnchorLine = null;
|
||||
}
|
||||
|
||||
setActive(active: boolean, terminalBackground?: string): void {
|
||||
if (this.#disposed || this.#active === active) {
|
||||
if (active && terminalBackground) void this.refreshBackground('theme-changed', terminalBackground);
|
||||
@@ -475,7 +506,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#matcherDecorations);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#renderBackground([]);
|
||||
}
|
||||
|
||||
@@ -493,7 +524,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#pendingSemantics.splice(0);
|
||||
if (!visible) {
|
||||
disposeAll(this.#matcherDecorations);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#renderBackground([]);
|
||||
}
|
||||
if (visible && this.#active) {
|
||||
@@ -510,7 +541,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#backgroundGeneration += 1;
|
||||
this.#abortProviderRequests();
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
if (!this.#isProviderAvailable('terminal.background')) this.#renderBackground([]);
|
||||
if (!this.#isProviderAvailable('terminal.matcher')) disposeAll(this.#matcherDecorations);
|
||||
if (this.#active && this.#visible) {
|
||||
@@ -557,7 +588,7 @@ export class PluginTerminalVisualProviderHost implements IDisposable {
|
||||
this.#abortProviderRequests();
|
||||
this.#pendingSemantics.splice(0);
|
||||
disposeAll(this.#matcherDecorations);
|
||||
disposeAll(this.#annotationDecorations);
|
||||
this.#clearAnnotations();
|
||||
this.#backgroundOverlay?.remove();
|
||||
this.#backgroundOverlay = null;
|
||||
disposeAll(this.#disposables);
|
||||
|
||||
@@ -1797,7 +1797,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
{ replayBacklog: true },
|
||||
);
|
||||
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
|
||||
ctx.updateStatus("disconnected");
|
||||
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
writeTerminalLine(ctx, term, exitMessage);
|
||||
@@ -1810,6 +1810,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
|
||||
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||||
try {
|
||||
await ctx.prepareKeywordHighlightSerialization?.();
|
||||
const terminalData = ctx.serializeAddonRef.current.serialize();
|
||||
logger.info("[Terminal] Serialized terminal data", {
|
||||
sessionId: ctx.sessionId,
|
||||
|
||||
@@ -209,6 +209,7 @@ export type TerminalSessionStartersContext = {
|
||||
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
|
||||
fitAddonRef: RefObject<FitAddon | null>;
|
||||
serializeAddonRef: RefObject<SerializeAddon | null>;
|
||||
prepareKeywordHighlightSerialization?: () => Promise<void>;
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
|
||||
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
|
||||
|
||||
@@ -536,7 +536,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
cursorStyle,
|
||||
cursorBlink,
|
||||
scrollback,
|
||||
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
|
||||
// Cursor-line rendering and Unicode width handling use proposed APIs.
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors,
|
||||
minimumContrastRatio,
|
||||
@@ -2218,7 +2218,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
resizeScheduler.schedule({ sessionId: id, cols, rows });
|
||||
});
|
||||
|
||||
const keywordHighlighter = new KeywordHighlighter(term);
|
||||
const keywordHighlighter = new KeywordHighlighter(term, {
|
||||
serializeAddon,
|
||||
});
|
||||
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
|
||||
|
||||
const cursorLineHighlighter = new CursorLineHighlighter(term);
|
||||
|
||||
@@ -260,7 +260,7 @@ test('CursorLineHighlighter refreshes after hidden writes become visible', () =>
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter keeps a continuous background under keyword decorations', () => {
|
||||
test('CursorLineHighlighter keeps a continuous background under colored keyword text', () => {
|
||||
const term = createFakeTerm(10);
|
||||
const keywordMarker = term.registerMarker(0);
|
||||
term.registerDecoration({
|
||||
@@ -352,7 +352,7 @@ test('CursorLineHighlighter fills a wrap-pending final cell', () => {
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
|
||||
test('CursorLineHighlighter keeps foreground colors and leaves inverse cells untouched', () => {
|
||||
const term = createFakeTerm(10);
|
||||
term.setColoredForegrounds([2, 3]);
|
||||
term.setInverseCells([7]);
|
||||
@@ -363,8 +363,7 @@ test('CursorLineHighlighter leaves colored and inverse cells untouched', () => {
|
||||
assert.deepEqual(
|
||||
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
|
||||
[
|
||||
{ x: 0, width: 2 },
|
||||
{ x: 4, width: 3 },
|
||||
{ x: 0, width: 7 },
|
||||
{ x: 8, width: 2 },
|
||||
],
|
||||
);
|
||||
|
||||
@@ -23,8 +23,8 @@ type HighlightRange = { x: number; width: number };
|
||||
|
||||
/**
|
||||
* Highlights the buffer row under the cursor without tinting its glyphs.
|
||||
* ANSI-colored cells keep their own background; only default-background cells
|
||||
* receive the opaque theme color.
|
||||
* Explicit ANSI backgrounds remain untouched. Foreground-only colors still
|
||||
* receive the opaque theme background so colored text does not leave holes.
|
||||
*/
|
||||
export class CursorLineHighlighter implements IDisposable {
|
||||
private enabled = false;
|
||||
@@ -211,9 +211,7 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
}
|
||||
const isHighlightable =
|
||||
currentCell === undefined ||
|
||||
(currentCell.isBgDefault() &&
|
||||
currentCell.isFgDefault() &&
|
||||
!currentCell.isInverse());
|
||||
(currentCell.isBgDefault() && !currentCell.isInverse());
|
||||
if (isHighlightable && rangeStart === null) rangeStart = x;
|
||||
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
|
||||
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
|
||||
|
||||
@@ -49,7 +49,7 @@ export function resolveHibernateSnapshotCapturePayload(
|
||||
export async function serializeTerminalCloseFallback(
|
||||
term: XTerm,
|
||||
serializeAddon: SerializeAddon,
|
||||
options: { preferWasm?: boolean } = {},
|
||||
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
|
||||
): Promise<TerminalCloseCapturePayload | null> {
|
||||
const snapshot = await serializeTerminalForHibernate(term, serializeAddon, options);
|
||||
return resolveHibernateSnapshotCapturePayload(snapshot);
|
||||
|
||||
@@ -792,6 +792,40 @@ const attachLedgerAnchors = (
|
||||
}
|
||||
};
|
||||
|
||||
/** Reattach sparse anchors after a display-only terminal buffer rebuild. */
|
||||
export const restoreTerminalLineTimestampAnchors = (term: XTerm): void => {
|
||||
const store = getTimestampStore(term);
|
||||
for (const entry of store.ledger) {
|
||||
if (!entry.marker?.isDisposed) continue;
|
||||
entry.marker = undefined;
|
||||
}
|
||||
attachLedgerAnchors(term, store, store.ledger);
|
||||
store.lastSeenBaseY = term.buffer.normal.baseY;
|
||||
store.lastSeenBufferLength = term.buffer.normal.length;
|
||||
store.lastSeenCols = _getTerminalColumnCount(term);
|
||||
notifyTimestampStore(store);
|
||||
};
|
||||
|
||||
export const snapshotTerminalLineTimestampLedger = (term: XTerm): Array<{
|
||||
label: string;
|
||||
secondKey: number;
|
||||
line: number;
|
||||
}> => getTimestampStore(term).ledger.map((entry) => ({
|
||||
label: entry.label,
|
||||
secondKey: entry.secondKey,
|
||||
line: entry.marker && !entry.marker.isDisposed ? entry.marker.line : entry.line,
|
||||
}));
|
||||
|
||||
export const restoreTerminalLineTimestampLedger = (
|
||||
term: XTerm,
|
||||
snapshot: readonly { label: string; secondKey: number; line: number }[],
|
||||
): void => {
|
||||
const store = getTimestampStore(term);
|
||||
store.ledger = snapshot.map((entry) => ({ ...entry }));
|
||||
store.lastStampSecondKey = store.ledger.at(-1)?.secondKey ?? null;
|
||||
restoreTerminalLineTimestampAnchors(term);
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh ledger.line from live sparse anchors. Drops entries whose markers
|
||||
* were disposed by scrollback trim. Bare (unanchored) lines keep their values
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resetTerminalOutputPressure,
|
||||
setTerminalOutputPressureVisibility,
|
||||
shouldDegradeTerminalSideWork,
|
||||
shouldDegradeTerminalKeywordHighlight,
|
||||
shouldSkipTerminalLineTimestamps,
|
||||
} from "./terminalOutputPressure.ts";
|
||||
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants.ts";
|
||||
@@ -172,6 +173,21 @@ test("arms large-output early on multi-line writes when scrollback is saturated"
|
||||
}
|
||||
});
|
||||
|
||||
test("a one-line prompt does not degrade keyword coloring on saturated scrollback", () => {
|
||||
const term = createFakeTerm({
|
||||
rows: 24,
|
||||
options: { scrollback: 1000 },
|
||||
buffer: { active: { length: 1020, baseY: 996 } },
|
||||
});
|
||||
const prompt = "\r\nplain prompt # ";
|
||||
|
||||
noteTerminalOutputPressureData(term, prompt);
|
||||
|
||||
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
||||
assert.equal(shouldDegradeTerminalKeywordHighlight(term, prompt), false);
|
||||
resetTerminalOutputPressure(term);
|
||||
});
|
||||
|
||||
test("does not treat an empty buffer as scrollback-saturated", () => {
|
||||
const term = createFakeTerm({
|
||||
rows: 24,
|
||||
@@ -196,6 +212,10 @@ test("saturated multi-line degrades side work but keeps line timestamps", () =>
|
||||
// but per-line gutter timestamps must still stamp.
|
||||
noteTerminalOutputPressureData(term, "CONTAINER ID IMAGE\n".repeat(20));
|
||||
assert.equal(shouldDegradeTerminalSideWork(term), true);
|
||||
assert.equal(
|
||||
shouldDegradeTerminalKeywordHighlight(term, "CONTAINER ID IMAGE\n".repeat(20)),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldSkipTerminalLineTimestamps(term), false);
|
||||
|
||||
resetTerminalOutputPressure(term);
|
||||
|
||||
@@ -299,6 +299,19 @@ export const shouldDegradeTerminalSideWork = (term: XTerm): boolean => {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -2089,7 +2089,7 @@ test("attachSessionToTerminal clears the backend id before reporting exit", () =
|
||||
assert.equal(sessionIdSeenByConsumer, null);
|
||||
});
|
||||
|
||||
test("attachSessionToTerminal drains hidden final output before exit capture", () => {
|
||||
test("attachSessionToTerminal drains hidden final output before exit capture", async () => {
|
||||
const { term, writes } = createFakeTerm();
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
let onExit: ((evt: { reason?: string }) => void) | null = null;
|
||||
@@ -2130,6 +2130,7 @@ test("attachSessionToTerminal drains hidden final output before exit capture", (
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
onExit?.({ reason: "closed" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expected = "final output\r\n\r\n[session closed]\r\n";
|
||||
assert.equal(writes.join(""), expected);
|
||||
|
||||
@@ -956,7 +956,7 @@ export const attachSessionToTerminal = (
|
||||
);
|
||||
ctx.terminalBackend.notifyTerminalSessionDisplayReady?.(id);
|
||||
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
|
||||
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
|
||||
// The backend is already gone. In particular, an observe popup must not
|
||||
// run its normal pause/snapshot/restore handoff while closing afterward.
|
||||
if (ctx.sessionRef.current === id) {
|
||||
@@ -971,6 +971,7 @@ export const attachSessionToTerminal = (
|
||||
|
||||
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||||
try {
|
||||
await ctx.prepareKeywordHighlightSerialization?.();
|
||||
const terminalData = ctx.serializeAddonRef.current.serialize();
|
||||
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||||
} catch (err) {
|
||||
|
||||
@@ -899,8 +899,7 @@ test("leave-alt CSI clears latch even while buffer still reports alternate", ()
|
||||
// Buffer flips to normal after leave is written; latch must be gone.
|
||||
(term.buffer.active as { type: string }).type = "normal";
|
||||
enqueueCoalescedTerminalWrite(term, "shell after tui", () => {});
|
||||
assert.equal(microtasks.length, 1);
|
||||
assert.equal(frames.length, 0);
|
||||
assert.equal(frames.length, 0, "must not stay on rAF after leave-alt");
|
||||
} finally {
|
||||
resetTerminalWriteCoalescer(term);
|
||||
globalThis.queueMicrotask = originalMicrotask;
|
||||
@@ -956,8 +955,7 @@ test("enter-then-leave in one chunk does not latch rAF forever", () => {
|
||||
microtasks.length = 0;
|
||||
|
||||
enqueueCoalescedTerminalWrite(term, "ordinary shell output", () => {});
|
||||
assert.equal(microtasks.length, 1);
|
||||
assert.equal(frames.length, 0);
|
||||
assert.equal(frames.length, 0, "must not latch rAF after enter-then-leave");
|
||||
} finally {
|
||||
resetTerminalWriteCoalescer(term);
|
||||
globalThis.queueMicrotask = originalMicrotask;
|
||||
|
||||
@@ -670,11 +670,11 @@ export const enqueueCoalescedTerminalWrite = (
|
||||
}, {
|
||||
getMaxPendingBytes: () => resolveCoalescerByteCap(term),
|
||||
shouldFlushScheduledFrame: () => terminalWriteCoalescerFlushGates.get(term)?.() ?? true,
|
||||
// Tabby streams normal-screen output without waiting for vsync. Keep rAF
|
||||
// for alternate-screen TUIs (including the enter-alt burst before xterm
|
||||
// has switched buffer.active.type) so multi-chunk repaints stay atomic.
|
||||
// When rAF is unavailable (Node unit tests), prefer the "raf" mode so the
|
||||
// coalescer falls back to an immediate flush (legacy test contract).
|
||||
// Keep rAF for alternate-screen TUIs (including the enter-alt burst
|
||||
// before xterm has switched buffer.active.type) so multi-chunk
|
||||
// repaints stay atomic. Normal-screen output uses Electerm-style burst
|
||||
// coalescing. When rAF is unavailable (Node unit tests), prefer "raf"
|
||||
// so the coalescer falls back to an immediate flush (legacy contract).
|
||||
resolveScheduleMode: ({ nextChunk }): WriteCoalesceScheduleMode => {
|
||||
const preapplied = preappliedAltScreenNeedsRafByTerm.get(term);
|
||||
if (preapplied !== undefined) {
|
||||
@@ -693,7 +693,9 @@ export const enqueueCoalescedTerminalWrite = (
|
||||
const canUseMicrotask = typeof queueMicrotask === "function"
|
||||
&& typeof globalThis.requestAnimationFrame === "function";
|
||||
if (canUseMicrotask) {
|
||||
return "microtask";
|
||||
// Electerm-style 16ms merge after a recent flush; idle echo still
|
||||
// uses a microtask so typing does not wait a frame.
|
||||
return "burst";
|
||||
}
|
||||
return "raf";
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, test } from "node:test";
|
||||
import {
|
||||
createWriteCoalescer,
|
||||
MAX_PENDING_WRITE_COALESCE_BYTES,
|
||||
WRITE_COALESCE_BURST_MS,
|
||||
type WriteCoalescerOptions,
|
||||
} from "./writeCoalescer.ts";
|
||||
|
||||
@@ -87,6 +88,53 @@ test("microtask schedule mode flushes after the current turn without rAF", async
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
test("burst mode flushes idle output on the next microtask", async () => {
|
||||
const writes: string[] = [];
|
||||
const coalescer = createWriteCoalescer((data) => writes.push(data), {
|
||||
resolveScheduleMode: () => "burst",
|
||||
});
|
||||
|
||||
coalescer.push("a");
|
||||
coalescer.push("b");
|
||||
assert.equal(writes.length, 0);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(writes, ["ab"]);
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
test("burst mode merges writes that arrive inside the 16ms window", () => {
|
||||
const writes: string[] = [];
|
||||
let now = 1000;
|
||||
const timeouts: Array<{ callback: () => void; ms: number }> = [];
|
||||
const coalescer = createWriteCoalescer((data) => writes.push(data), {
|
||||
resolveScheduleMode: () => "burst",
|
||||
now: () => now,
|
||||
burstWindowMs: WRITE_COALESCE_BURST_MS,
|
||||
scheduleTimeout(callback, ms) {
|
||||
timeouts.push({ callback, ms: ms ?? 0 });
|
||||
return () => {
|
||||
const index = timeouts.findIndex((entry) => entry.callback === callback);
|
||||
if (index >= 0) timeouts.splice(index, 1);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.push("first");
|
||||
coalescer.flushSync();
|
||||
assert.deepEqual(writes, ["first"]);
|
||||
|
||||
now += 4;
|
||||
coalescer.push("second");
|
||||
coalescer.push("third");
|
||||
assert.deepEqual(writes, ["first"]);
|
||||
assert.equal(timeouts.length, 1);
|
||||
assert.ok(timeouts[0]!.ms <= WRITE_COALESCE_BURST_MS);
|
||||
|
||||
timeouts[0]!.callback();
|
||||
assert.deepEqual(writes, ["first", "secondthird"]);
|
||||
coalescer.dispose();
|
||||
});
|
||||
|
||||
test("upgrades a pending microtask schedule to rAF when a later chunk requests it", () => {
|
||||
const writes: string[] = [];
|
||||
const microtasks: Array<() => void> = [];
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* per chunk, which can tear TUI frames (missing box borders, clipped bottom
|
||||
* rows). Batching keeps rendering atomic per schedule turn.
|
||||
*
|
||||
* Schedule modes (Tabby-inspired):
|
||||
* Schedule modes (Tabby + Electerm-inspired):
|
||||
* - `raf`: wait for the next animation frame (best for alternate-screen TUIs)
|
||||
* - `microtask`: flush after the current JS turn (normal-screen bulk / echo —
|
||||
* closer to Tabby's direct write, still coalesces same-turn chunks)
|
||||
* - `microtask`: flush after the current JS turn (same-turn merge only)
|
||||
* - `burst`: idle output still flushes next microtask; writes that arrive
|
||||
* inside a 16ms window after the last flush wait and merge (Electerm's
|
||||
* attach-addon flood path). Never drops bytes.
|
||||
*
|
||||
* Ported from superset-sh/superset (issues #2241 / #2244):
|
||||
* apps/desktop/src/renderer/lib/terminal/write-coalescer.ts
|
||||
@@ -27,6 +29,11 @@ export {
|
||||
|
||||
/** Maximum time pending output may wait for its primary scheduler. */
|
||||
export const WRITE_COALESCE_FALLBACK_DRAIN_MS = 50;
|
||||
/**
|
||||
* Electerm-style burst window. After a flush, further chunks wait up to this
|
||||
* long so a log flood becomes ~60 writes/s instead of one write per IPC turn.
|
||||
*/
|
||||
export const WRITE_COALESCE_BURST_MS = 16;
|
||||
|
||||
export type WriteCoalescer = {
|
||||
push(chunk: string): void;
|
||||
@@ -38,7 +45,7 @@ export type WriteCoalescer = {
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export type WriteCoalesceScheduleMode = "raf" | "microtask";
|
||||
export type WriteCoalesceScheduleMode = "raf" | "microtask" | "burst";
|
||||
|
||||
export type WriteCoalesceScheduleContext = {
|
||||
/** Chunk about to be (or just) enqueued. */
|
||||
@@ -63,6 +70,8 @@ export type WriteCoalescerOptions = {
|
||||
resolveScheduleMode?: (ctx: WriteCoalesceScheduleContext) => WriteCoalesceScheduleMode;
|
||||
getMaxPendingBytes?: () => number;
|
||||
shouldFlushScheduledFrame?: () => boolean;
|
||||
now?: () => number;
|
||||
burstWindowMs?: number;
|
||||
};
|
||||
|
||||
const scheduleRafFrame = (callback: () => void): (() => void) | null => {
|
||||
@@ -149,6 +158,7 @@ export const createWriteCoalescer = (
|
||||
let cancelPendingSchedule: (() => void) | null = null;
|
||||
let scheduledMode: WriteCoalesceScheduleMode | null = null;
|
||||
let disposed = false;
|
||||
let lastFlushTime = 0;
|
||||
const customScheduleFrame = options.scheduleFrame;
|
||||
const scheduleTimeout = options.scheduleTimeout ?? defaultScheduleTimeout;
|
||||
const fallbackDrainMs = options.fallbackDrainMs ?? WRITE_COALESCE_FALLBACK_DRAIN_MS;
|
||||
@@ -156,6 +166,12 @@ export const createWriteCoalescer = (
|
||||
const getMaxPendingBytes = options.getMaxPendingBytes
|
||||
?? (() => MAX_PENDING_WRITE_COALESCE_BYTES);
|
||||
const shouldFlushScheduledFrame = options.shouldFlushScheduledFrame ?? (() => true);
|
||||
const now = options.now ?? (() => (
|
||||
typeof performance !== "undefined" && typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
));
|
||||
const burstWindowMs = options.burstWindowMs ?? WRITE_COALESCE_BURST_MS;
|
||||
|
||||
const cancelScheduledDrain = (): void => {
|
||||
if (cancelPendingSchedule !== null) {
|
||||
@@ -208,6 +224,23 @@ export const createWriteCoalescer = (
|
||||
flushSync();
|
||||
};
|
||||
|
||||
if (mode === "burst") {
|
||||
const elapsed = lastFlushTime === 0 ? burstWindowMs : now() - lastFlushTime;
|
||||
if (lastFlushTime > 0 && elapsed < burstWindowMs) {
|
||||
const waitMs = Math.max(1, burstWindowMs - elapsed);
|
||||
const cancel = scheduleTimeout(onScheduled, waitMs);
|
||||
if (cancel === null) {
|
||||
if (!shouldFlushScheduledFrame()) return;
|
||||
flushSync();
|
||||
return;
|
||||
}
|
||||
cancelPendingSchedule = cancel;
|
||||
scheduledMode = "burst";
|
||||
return;
|
||||
}
|
||||
mode = "microtask";
|
||||
}
|
||||
|
||||
const primaryCancel = scheduleByMode(mode, onScheduled, customScheduleFrame);
|
||||
const fallbackCancel = mode === "raf"
|
||||
&& fallbackDrainMs > 0
|
||||
@@ -234,6 +267,7 @@ export const createWriteCoalescer = (
|
||||
const batch = pending.length === 1 ? pending[0]! : pending.join("");
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
lastFlushTime = now();
|
||||
(writeOverride ?? write)(batch);
|
||||
};
|
||||
|
||||
@@ -245,6 +279,7 @@ export const createWriteCoalescer = (
|
||||
const dropped = pendingBytes;
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
lastFlushTime = 0;
|
||||
onDropped?.(dropped);
|
||||
};
|
||||
|
||||
@@ -275,7 +310,7 @@ export const createWriteCoalescer = (
|
||||
// Upgrade microtask → rAF when a later chunk enters a TUI: the first shell
|
||||
// chunk may have scheduled a same-turn flush before the alt-screen CSI
|
||||
// arrived, which would tear the first repaint (Codex PR review).
|
||||
if (scheduledMode === "microtask" && mode === "raf") {
|
||||
if ((scheduledMode === "microtask" || scheduledMode === "burst") && mode === "raf") {
|
||||
cancelScheduledDrain();
|
||||
armSchedule("raf");
|
||||
}
|
||||
|
||||
@@ -321,3 +321,22 @@ test("serializeTerminalForHibernate preserves plain text context for normal scre
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
test("serializeTerminalForHibernate waits for deferred history preparation", async () => {
|
||||
const term = createFakeTerm("normal", 3, 5);
|
||||
const events: string[] = [];
|
||||
const result = await serializeTerminalForHibernate(term as never, {
|
||||
serialize: () => {
|
||||
events.push("serialize");
|
||||
return "serialized";
|
||||
},
|
||||
} as never, {
|
||||
prepare: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
events.push("prepare");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.snapshot, "serialized");
|
||||
assert.deepEqual(events.slice(0, 2), ["prepare", "serialize"]);
|
||||
});
|
||||
@@ -189,8 +189,9 @@ async function serializeWithOptions(
|
||||
export async function serializeTerminalForHibernate(
|
||||
term: XTerm,
|
||||
serializeAddon: SerializeAddon,
|
||||
options: { preferWasm?: boolean } = {},
|
||||
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
|
||||
): Promise<TerminalHibernateSnapshot> {
|
||||
await options.prepare?.();
|
||||
const { excludeAltBuffer, excludeModes, alternateScreen } = resolveHibernateSerializeOptions(term);
|
||||
const preferWasm = options.preferWasm === true;
|
||||
const rows = Math.max(1, term.rows);
|
||||
|
||||
@@ -909,7 +909,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
if (!terminalDataCapturedRef.current && term && serializeAddon) {
|
||||
const preferWasm = resolveHibernatePreferWasmSerialize(terminalSettingsRef.current);
|
||||
try {
|
||||
const payload = await serializeTerminalCloseFallback(term, serializeAddon, { preferWasm });
|
||||
const payload = await serializeTerminalCloseFallback(term, serializeAddon, {
|
||||
preferWasm,
|
||||
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
|
||||
?? Promise.resolve(),
|
||||
});
|
||||
if (!isTerminalCloseGenerationCurrent(
|
||||
closeGeneration,
|
||||
terminalBootCloseGenerationRef.current,
|
||||
|
||||
@@ -184,13 +184,14 @@ application Provider adapters as plugins:
|
||||
engine with global, case-insensitive matching;
|
||||
- decoration results are capped again after Provider fan-out at 16 active
|
||||
rules and 32 total patterns. Plugin matching examines at most the first 4096
|
||||
characters and retains at most 256 plugin matches for one logical line. A
|
||||
wrapped logical line is matched only once per refresh before ranges are
|
||||
projected onto its physical rows; plugin decoration is omitted for oversized
|
||||
wrapped blocks that cannot be assembled safely. Patterns that can match an
|
||||
empty string are rejected because they cannot produce a visible highlight.
|
||||
Normal boot and hibernate wake share the same CWD-triggered decoration refresh
|
||||
path;
|
||||
characters of each incoming text segment and retains at most 256 plugin
|
||||
matches per terminal write. Highlight colors are applied to already-parsed
|
||||
cells; ordinary input and output only rematch dirty rows. When rules change,
|
||||
the host restores original cell colors and recolors the visible viewport
|
||||
immediately, then finishes scrollback in idle slices. Heavy output may skip
|
||||
matching until one quiet-window catch-up. Patterns that can match an empty
|
||||
string are rejected because they cannot produce a visible highlight. Normal
|
||||
boot and hibernate wake share the same CWD-triggered decoration refresh path;
|
||||
- link and hover Providers receive one bounded physical xterm line and return
|
||||
exact zero-based ranges. Links are restricted to credential-free HTTP(S)
|
||||
URLs, reuse the host link-modifier policy, and render hover text with host
|
||||
|
||||
@@ -100,45 +100,10 @@ export const XTERM_PERFORMANCE_CONFIG = {
|
||||
largeBufferThreshold: 1024 * 1024, // 1MB
|
||||
},
|
||||
|
||||
// Keyword highlighting optimizations
|
||||
// Output-pressure quiet window shared by optional terminal side work.
|
||||
highlighting: {
|
||||
// Debounce time for viewport scanning (ms)
|
||||
// Higher values = better scrolling performance, but slower highlight "catch up"
|
||||
debounceMs: 100,
|
||||
// Minimum interval between immediate (rAF) refreshes in ms.
|
||||
// Prevents heavy output (e.g. tail -f) from refreshing every frame.
|
||||
immediateMinIntervalMs: 16,
|
||||
// Number of unique line scan results to keep cached.
|
||||
cacheEntries: 1200,
|
||||
// Retain matched-line markers across nearby scrollback without allowing a
|
||||
// broad rule to register unbounded xterm trim listeners.
|
||||
persistentDecorationViewports: 20,
|
||||
minPersistentDecorationLines: 200,
|
||||
maxPersistentDecorationLines: 1200,
|
||||
// Keep decorations for lines just outside the viewport so small scrolls
|
||||
// don't constantly dispose/recreate them. Scales with current terminal rows.
|
||||
overscanViewportRatio: 2.0,
|
||||
// Dirty scan padding around cursor/viewport deltas for write bursts.
|
||||
dirtyScanPadding: 2,
|
||||
// Dynamic dirty-line cap scales with viewport rows.
|
||||
dirtyLinesPerViewportRow: 6,
|
||||
// Clamp the dynamic dirty-line cap to avoid extremes.
|
||||
minDirtyLines: 200,
|
||||
maxDirtyLines: 1200,
|
||||
// Max processing time per refresh pass when handling dirty lines (ms).
|
||||
writeRefreshBudgetMs: 4,
|
||||
// Process dirty contiguous lines in chunks so budget checks can preempt.
|
||||
dirtySegmentChunkSize: 48,
|
||||
// Keep highlighting deprioritized briefly after a large output burst.
|
||||
// Longer quiet window lets xterm paint bulk dumps (cat/yes/tail) without
|
||||
// competing decoration scans every few hundred ms.
|
||||
// Let large output settle before deferred work catches up.
|
||||
largeOutputQuietMs: 480,
|
||||
// Give interactive typing priority over keyword highlight catch-up.
|
||||
inputQuietMs: 180,
|
||||
// Extra debounce while large-output / long-line pressure is active.
|
||||
largeOutputDebounceMs: 280,
|
||||
// Floor for immediate refresh interval under output pressure.
|
||||
largeOutputImmediateMinIntervalMs: 120,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@
|
||||
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
|
||||
"test:xterm-decoration-performance": "electron scripts/xterm-decoration-performance.live.test.cjs"
|
||||
"test:xterm-keyword-highlight-performance": "electron scripts/xterm-keyword-highlight-performance.live.test.cjs",
|
||||
"test:xterm-keyword-highlight-throughput": "electron scripts/xterm-keyword-highlight-throughput.live.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.0",
|
||||
|
||||
@@ -76,7 +76,7 @@ test("PR validation runs once per commit and includes a production build", () =>
|
||||
assert.match(testWorkflow, /sudo apt-get install -y fish xvfb/);
|
||||
assert.match(
|
||||
testWorkflow,
|
||||
/- name: Test terminal decoration performance\s*\n\s*env:\s*\n\s*NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"\s*\n\s*# GitHub-hosted runners do not configure Electron's SUID sandbox helper\.\s*\n\s*run: xvfb-run -a \.\/node_modules\/\.bin\/electron --no-sandbox scripts\/xterm-decoration-performance\.live\.test\.cjs/,
|
||||
/- name: Test terminal keyword highlight performance\s*\n\s*env:\s*\n\s*NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"\s*\n\s*# GitHub-hosted runners do not configure Electron's SUID sandbox helper\.\s*\n\s*run: xvfb-run -a \.\/node_modules\/\.bin\/electron --no-sandbox scripts\/xterm-keyword-highlight-performance\.live\.test\.cjs/,
|
||||
);
|
||||
assert.match(testWorkflow, /- name: Build\s*\n\s*run: npm run build/);
|
||||
assert.doesNotMatch(testWorkflow, /\n mosh-windows-conpty:/);
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
/* global process, __dirname, console */
|
||||
|
||||
if (!process.versions.electron) {
|
||||
const test = require("node:test");
|
||||
test("xterm keeps dense keyword-style decorations responsive", {
|
||||
skip: "run with Electron so the real DOM renderer is available",
|
||||
}, () => {});
|
||||
} else {
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const electron = require("electron");
|
||||
const esbuild = require("esbuild");
|
||||
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
|
||||
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const showWindow = process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1";
|
||||
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-decoration-perf")}-`);
|
||||
electron.app.setPath("userData", userData);
|
||||
electron.app.on("window-all-closed", () => {});
|
||||
let window = null;
|
||||
|
||||
const cleanup = (exitCode) => {
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
try {
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn("Unable to remove xterm performance test data:", error);
|
||||
} finally {
|
||||
electron.app.exit(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
void electron.app.whenReady().then(async () => {
|
||||
window = new electron.BrowserWindow({
|
||||
show: showWindow,
|
||||
width: 900,
|
||||
height: 560,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
await window.loadURL(
|
||||
"data:text/html;charset=utf-8," + encodeURIComponent(
|
||||
"<!doctype html><style>html,body,#terminal{width:800px;height:480px;margin:0}</style><div id=terminal></div>",
|
||||
),
|
||||
);
|
||||
|
||||
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
|
||||
const keywordHighlighterPath = path.join(appRoot, "components/terminal/keywordHighlight.ts");
|
||||
const keywordHighlighterBundle = esbuild.buildSync({
|
||||
entryPoints: [keywordHighlighterPath],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: "chrome142",
|
||||
write: false,
|
||||
}).outputFiles[0].text;
|
||||
const result = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { Terminal } = require(${JSON.stringify(xtermPath)});
|
||||
const highlighterModule = { exports: {} };
|
||||
((module, exports) => {
|
||||
${keywordHighlighterBundle}
|
||||
})(highlighterModule, highlighterModule.exports);
|
||||
const { KeywordHighlighter } = highlighterModule.exports;
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cols: 80,
|
||||
cursorBlink: false,
|
||||
rendererType: "dom",
|
||||
rows: 30,
|
||||
scrollback: 1000,
|
||||
});
|
||||
term.open(document.getElementById("terminal"));
|
||||
|
||||
const waitForPaint = () => new Promise(resolve => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
});
|
||||
const waitForRender = (trigger, label) => new Promise((resolve, reject) => {
|
||||
const startedAt = performance.now();
|
||||
const timeout = setTimeout(() => {
|
||||
disposable.dispose();
|
||||
reject(new Error("timed out waiting for terminal render: " + label));
|
||||
}, 15000);
|
||||
const disposable = term.onRender(() => {
|
||||
disposable.dispose();
|
||||
waitForPaint().then(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve(performance.now() - startedAt);
|
||||
}, reject);
|
||||
});
|
||||
trigger();
|
||||
});
|
||||
const writeAndWait = data => new Promise(resolve => term.write(data, resolve));
|
||||
const waitForCondition = async (condition, label) => {
|
||||
const deadline = performance.now() + 15000;
|
||||
while (!condition()) {
|
||||
if (performance.now() >= deadline) {
|
||||
throw new Error("timed out waiting for " + label);
|
||||
}
|
||||
await waitForPaint();
|
||||
}
|
||||
};
|
||||
|
||||
let history = "";
|
||||
for (let line = 0; line < 500; line += 1) {
|
||||
history += "INFO WARN ERROR SUCCESS DEBUG completed failed critical\\r\\n";
|
||||
}
|
||||
await writeAndWait(history);
|
||||
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules([
|
||||
{ enabled: true, patterns: ["INFO"], color: "#60a5fa" },
|
||||
{ enabled: true, patterns: ["WARN"], color: "#fbbf24" },
|
||||
{ enabled: true, patterns: ["ERROR"], color: "#f87171" },
|
||||
{ enabled: true, patterns: ["SUCCESS"], color: "#4ade80" },
|
||||
{ enabled: true, patterns: ["DEBUG"], color: "#a78bfa" },
|
||||
{ enabled: true, patterns: ["completed"], color: "#2dd4bf" },
|
||||
{ enabled: true, patterns: ["failed"], color: "#fb7185" },
|
||||
{ enabled: true, patterns: ["critical"], color: "#f43f5e" },
|
||||
], true);
|
||||
const countNetcattyDecorations = () => Array.from(highlighter.lineDecorations.values())
|
||||
.reduce((count, state) => count + state.decorations.length, 0);
|
||||
await waitForCondition(
|
||||
() => countNetcattyDecorations() >= term.rows * 8,
|
||||
"Netcatty keyword decorations",
|
||||
);
|
||||
const netcattyDecorationCount = countNetcattyDecorations();
|
||||
const netcattyRefreshMs = await waitForRender(
|
||||
() => term.refresh(0, term.rows - 1),
|
||||
"Netcatty keyword highlight paint",
|
||||
);
|
||||
highlighter.dispose();
|
||||
await waitForPaint();
|
||||
|
||||
const cursorLine = term.buffer.normal.baseY + term.buffer.normal.cursorY;
|
||||
const markers = [];
|
||||
const decorations = [];
|
||||
for (let line = term.buffer.normal.length - 500; line < term.buffer.normal.length; line += 1) {
|
||||
const marker = term.registerMarker(line - cursorLine);
|
||||
if (!marker) continue;
|
||||
markers.push(marker);
|
||||
for (let x = 0; x < term.cols; x += 2) {
|
||||
const decoration = term.registerDecoration({
|
||||
marker,
|
||||
x,
|
||||
width: 1,
|
||||
foregroundColor: "#f87171",
|
||||
});
|
||||
if (decoration) decorations.push(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
const registrationPaintMs = await waitForRender(
|
||||
() => term.refresh(0, term.rows - 1),
|
||||
"registration paint",
|
||||
);
|
||||
const refreshMs = [];
|
||||
for (let iteration = 0; iteration < 3; iteration += 1) {
|
||||
refreshMs.push(await waitForRender(
|
||||
() => term.refresh(0, term.rows - 1),
|
||||
"measured refresh " + iteration,
|
||||
));
|
||||
}
|
||||
|
||||
const state = {
|
||||
decorationCount: decorations.length,
|
||||
markerCount: markers.length,
|
||||
netcattyDecorationCount,
|
||||
netcattyRefreshMs,
|
||||
registrationPaintMs,
|
||||
refreshMs,
|
||||
worstRefreshMs: Math.max(...refreshMs),
|
||||
};
|
||||
decorations.forEach(decoration => decoration.dispose());
|
||||
markers.forEach(marker => marker.dispose());
|
||||
term.dispose();
|
||||
return state;
|
||||
})()`);
|
||||
|
||||
assert.equal(result.markerCount, 500, JSON.stringify(result));
|
||||
assert.equal(result.decorationCount, 20000, JSON.stringify(result));
|
||||
assert.ok(result.netcattyDecorationCount >= 240, JSON.stringify(result));
|
||||
assert.ok(
|
||||
result.netcattyRefreshMs < 150,
|
||||
`Netcatty keyword highlighting blocked terminal paint: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.worstRefreshMs < 150,
|
||||
`dense keyword-style decorations blocked terminal repaint: ${JSON.stringify(result)}`,
|
||||
);
|
||||
process.stdout.write(`XTERM_DECORATION_PERFORMANCE_OK ${JSON.stringify(result)}\n`);
|
||||
cleanup(0);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
cleanup(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use strict";
|
||||
|
||||
/* global process, __dirname, console */
|
||||
|
||||
if (!process.versions.electron) {
|
||||
const test = require("node:test");
|
||||
test("inline keyword highlighting stays responsive", {
|
||||
skip: "run with Electron so the real WebGL renderer is available",
|
||||
}, () => {});
|
||||
} else {
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const electron = require("electron");
|
||||
const esbuild = require("esbuild");
|
||||
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
|
||||
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const showWindow = process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1";
|
||||
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-keyword-highlight-perf")}-`);
|
||||
electron.app.setPath("userData", userData);
|
||||
electron.app.on("window-all-closed", () => {});
|
||||
let window = null;
|
||||
|
||||
const cleanup = (exitCode) => {
|
||||
if (window && !window.isDestroyed()) window.destroy();
|
||||
try {
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn("Unable to remove xterm performance test data:", error);
|
||||
} finally {
|
||||
electron.app.exit(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
void electron.app.whenReady().then(async () => {
|
||||
window = new electron.BrowserWindow({
|
||||
show: showWindow,
|
||||
width: 1000,
|
||||
height: 640,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
await window.loadURL(
|
||||
"data:text/html;charset=utf-8," + encodeURIComponent(
|
||||
"<!doctype html><style>html,body,#terminal{width:920px;height:560px;margin:0}</style><div id=terminal></div>",
|
||||
),
|
||||
);
|
||||
|
||||
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
|
||||
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
|
||||
const serializePath = require.resolve("@xterm/addon-serialize", { paths: [appRoot] });
|
||||
const highlighterPath = path.join(appRoot, "components/terminal/keywordHighlight.ts");
|
||||
const pressurePath = path.join(appRoot, "components/terminal/runtime/terminalOutputPressure.ts");
|
||||
const highlighterBundle = esbuild.buildSync({
|
||||
stdin: {
|
||||
contents: [
|
||||
`export * from ${JSON.stringify(highlighterPath)};`,
|
||||
`export { noteTerminalOutputPressureData } from ${JSON.stringify(pressurePath)};`,
|
||||
].join("\n"),
|
||||
loader: "ts",
|
||||
resolveDir: appRoot,
|
||||
},
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: "chrome142",
|
||||
write: false,
|
||||
}).outputFiles[0].text;
|
||||
|
||||
const result = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { Terminal } = require(${JSON.stringify(xtermPath)});
|
||||
const { WebglAddon } = require(${JSON.stringify(webglPath)});
|
||||
const { SerializeAddon } = require(${JSON.stringify(serializePath)});
|
||||
const highlighterModule = { exports: {} };
|
||||
((module, exports) => { ${highlighterBundle} })(highlighterModule, highlighterModule.exports);
|
||||
const { KeywordHighlighter, noteTerminalOutputPressureData } = highlighterModule.exports;
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cols: 120,
|
||||
cursorBlink: false,
|
||||
rows: 40,
|
||||
scrollback: 10000,
|
||||
});
|
||||
term.open(document.getElementById("terminal"));
|
||||
let renderer = "dom";
|
||||
try {
|
||||
term.loadAddon(new WebglAddon());
|
||||
renderer = "webgl";
|
||||
} catch {}
|
||||
const serializer = new SerializeAddon();
|
||||
term.loadAddon(serializer);
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
const redRules = [{
|
||||
id: "error",
|
||||
label: "Error",
|
||||
patterns: ["ERROR", "failed", "10\\\\.2\\\\.\\\\d+\\\\.\\\\d+"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
}];
|
||||
const blueRules = redRules.map(rule => ({ ...rule, color: "#60A5FA" }));
|
||||
highlighter.setRules(redRules, true);
|
||||
const write = data => new Promise(resolve => term.write(data, resolve));
|
||||
const waitPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
let history = "";
|
||||
for (let line = 0; line < 10000; line += 1) {
|
||||
history += "2026-08-13 worker=" + (line % 32) + " ERROR failed from 10.2." + (line % 255) + "." + ((line * 7) % 255) + "\\r\\n";
|
||||
}
|
||||
|
||||
const initialStart = performance.now();
|
||||
noteTerminalOutputPressureData(term, history);
|
||||
await write(history);
|
||||
await waitPaint();
|
||||
const initialWriteMs = performance.now() - initialStart;
|
||||
await new Promise(resolve => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
const rebuildsBeforeEnter = highlighter.rebuildCount;
|
||||
|
||||
const enterStart = performance.now();
|
||||
const prompt = "\\r\\nplain prompt # ";
|
||||
noteTerminalOutputPressureData(term, prompt);
|
||||
await write(prompt);
|
||||
await waitPaint();
|
||||
const enterWriteMs = performance.now() - enterStart;
|
||||
await new Promise(resolve => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
const enterRebuildCount = highlighter.rebuildCount;
|
||||
|
||||
const rebuildStart = performance.now();
|
||||
highlighter.setRules(blueRules, true);
|
||||
await highlighter.whenSettled();
|
||||
await waitPaint();
|
||||
const rebuildMs = performance.now() - rebuildStart;
|
||||
|
||||
const serialized = serializer.serialize({ scrollback: 10000 });
|
||||
const pristine = highlighter.serializeAddon.serialize({ scrollback: 10000 });
|
||||
const state = {
|
||||
renderer,
|
||||
rawChars: history.length,
|
||||
initialWriteMs,
|
||||
rebuildsBeforeEnter,
|
||||
enterWriteMs,
|
||||
enterRebuildCount,
|
||||
rebuildMs,
|
||||
rebuildCount: highlighter.rebuildCount,
|
||||
blueMatchCount: (serialized.match(/38;2;96;165;250m/g) || []).length,
|
||||
pristineHasNetcattyColor: /38;2;(248;113;113|96;165;250)m/.test(pristine),
|
||||
};
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
return state;
|
||||
})()`);
|
||||
|
||||
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
|
||||
assert.equal(result.renderer, "webgl", JSON.stringify(result));
|
||||
}
|
||||
assert.equal(result.enterRebuildCount, result.rebuildsBeforeEnter, JSON.stringify(result));
|
||||
assert.equal(result.rebuildCount, result.rebuildsBeforeEnter + 1, JSON.stringify(result));
|
||||
assert.ok(result.blueMatchCount >= 10000, JSON.stringify(result));
|
||||
assert.equal(result.pristineHasNetcattyColor, false, JSON.stringify(result));
|
||||
assert.ok(result.enterWriteMs < 150, `Enter write regressed: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.rebuildMs < 1000, `10k-line rule rebuild regressed: ${JSON.stringify(result)}`);
|
||||
process.stdout.write(`XTERM_KEYWORD_HIGHLIGHT_PERFORMANCE_OK ${JSON.stringify(result)}\n`);
|
||||
cleanup(0);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
cleanup(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
"use strict";
|
||||
|
||||
/* global process, __dirname, console */
|
||||
|
||||
if (!process.versions.electron) {
|
||||
const test = require("node:test");
|
||||
test("keyword highlighting keeps sustained output responsive", {
|
||||
skip: "run with Electron so the real WebGL renderer is available",
|
||||
}, () => {});
|
||||
} else {
|
||||
const assert = require("node:assert/strict");
|
||||
const childProcess = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const electron = require("electron");
|
||||
const esbuild = require("esbuild");
|
||||
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
|
||||
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const mainRef = process.env.NETCATTY_TERMINAL_PERF_MAIN_REF ?? "origin/main";
|
||||
const chunkCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_CHUNKS ?? "1600", 10);
|
||||
const roundCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_ROUNDS ?? "3", 10);
|
||||
const scrollback = Number.parseInt(
|
||||
process.env.NETCATTY_TERMINAL_PERF_SCROLLBACK ?? "50000",
|
||||
10,
|
||||
);
|
||||
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-highlight-throughput")}-`);
|
||||
electron.app.setPath("userData", userData);
|
||||
electron.app.commandLine.appendSwitch("js-flags", "--expose-gc");
|
||||
electron.app.on("window-all-closed", () => {});
|
||||
let window = null;
|
||||
|
||||
const cleanup = (exitCode) => {
|
||||
if (window && !window.isDestroyed()) window.destroy();
|
||||
try {
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.warn("Unable to remove xterm throughput test data:", error);
|
||||
} finally {
|
||||
electron.app.exit(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
const buildModule = (source, resolveDir, plugins = []) => esbuild.buildSync({
|
||||
stdin: { contents: source, loader: "ts", resolveDir },
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: "chrome142",
|
||||
write: false,
|
||||
plugins,
|
||||
}).outputFiles[0].text;
|
||||
|
||||
const buildMainModule = async () => {
|
||||
const entryPath = "components/terminal/__keywordHighlightThroughputEntry.ts";
|
||||
const entrySource = [
|
||||
'export * from "./keywordHighlight";',
|
||||
'export { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure";',
|
||||
].join("\n");
|
||||
const resolveMainFile = (repoPath) => {
|
||||
const candidates = repoPath === entryPath
|
||||
? [entryPath]
|
||||
: [repoPath, `${repoPath}.ts`, `${repoPath}.tsx`, `${repoPath}/index.ts`, `${repoPath}/index.tsx`];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === entryPath) return candidate;
|
||||
const exists = childProcess.spawnSync(
|
||||
"git",
|
||||
["cat-file", "-e", `${mainRef}:${candidate}`],
|
||||
{ cwd: appRoot, stdio: "ignore" },
|
||||
).status === 0;
|
||||
if (exists) return candidate;
|
||||
}
|
||||
throw new Error(`Unable to resolve ${mainRef} source: ${repoPath}`);
|
||||
};
|
||||
const readMainFile = (repoPath) => repoPath === entryPath
|
||||
? entrySource
|
||||
: childProcess.execFileSync("git", ["show", `${mainRef}:${repoPath}`], {
|
||||
cwd: appRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const mainPlugin = {
|
||||
name: "main-source",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /.*/ }, (args) => {
|
||||
if (args.kind !== "entry-point") return undefined;
|
||||
return { path: entryPath, namespace: "main-source" };
|
||||
});
|
||||
build.onResolve({ filter: /^\.\.?\// }, (args) => ({
|
||||
path: resolveMainFile(
|
||||
path.posix.normalize(path.posix.join(path.posix.dirname(args.importer), args.path)),
|
||||
),
|
||||
namespace: "main-source",
|
||||
}));
|
||||
build.onLoad({ filter: /.*/, namespace: "main-source" }, (args) => ({
|
||||
contents: readMainFile(args.path),
|
||||
loader: args.path.endsWith(".json") ? "json" : args.path.endsWith(".tsx") ? "tsx" : "ts",
|
||||
resolveDir: path.posix.dirname(args.path),
|
||||
}));
|
||||
},
|
||||
};
|
||||
return (await esbuild.build({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: "chrome142",
|
||||
write: false,
|
||||
plugins: [mainPlugin],
|
||||
})).outputFiles[0].text;
|
||||
};
|
||||
|
||||
void electron.app.whenReady().then(async () => {
|
||||
const oldBundle = await buildMainModule();
|
||||
const currentBundle = buildModule([
|
||||
`export * from ${JSON.stringify(path.join(appRoot, "components/terminal/keywordHighlight.ts"))};`,
|
||||
`export { noteTerminalOutputPressureData } from ${JSON.stringify(path.join(appRoot, "components/terminal/runtime/terminalOutputPressure.ts"))};`,
|
||||
].join("\n"), appRoot);
|
||||
|
||||
window = new electron.BrowserWindow({
|
||||
show: process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1",
|
||||
width: 1000,
|
||||
height: 640,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: false,
|
||||
nodeIntegration: true,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
await window.loadURL(
|
||||
"data:text/html;charset=utf-8," + encodeURIComponent(
|
||||
"<!doctype html><style>html,body,#terminal{width:920px;height:560px;margin:0}</style><div id=terminal></div>",
|
||||
),
|
||||
);
|
||||
|
||||
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
|
||||
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
|
||||
const result = await window.webContents.executeJavaScript(`(async () => {
|
||||
const { Terminal } = require(${JSON.stringify(xtermPath)});
|
||||
const { WebglAddon } = require(${JSON.stringify(webglPath)});
|
||||
const loadBundle = source => {
|
||||
const loaded = { exports: {} };
|
||||
((module, exports) => { eval(source); })(loaded, loaded.exports);
|
||||
return loaded.exports;
|
||||
};
|
||||
const oldModule = loadBundle(${JSON.stringify(oldBundle)});
|
||||
const currentModule = loadBundle(${JSON.stringify(currentBundle)});
|
||||
const rules = [
|
||||
{ id: "info", label: "Info", patterns: ["INFO"], color: "#60A5FA", enabled: true },
|
||||
{ id: "warn", label: "Warn", patterns: ["WARN"], color: "#FBBF24", enabled: true },
|
||||
{ id: "error", label: "Error", patterns: ["ERROR", "failed"], color: "#F87171", enabled: true },
|
||||
{ id: "ip", label: "IP", patterns: ["10\\\\.2\\\\.\\\\d+\\\\.\\\\d+"], color: "#4ADE80", enabled: true },
|
||||
];
|
||||
const makeChunk = index => {
|
||||
let chunk = "";
|
||||
for (let line = 0; line < 64; line += 1) {
|
||||
chunk += "2026-08-13 INFO worker=" + (line % 32) + " WARN ERROR failed from 10.2."
|
||||
+ ((index + line) % 255) + "." + ((index * 7 + line) % 255) + " payload="
|
||||
+ "x".repeat(24) + "\\r\\n";
|
||||
}
|
||||
return chunk;
|
||||
};
|
||||
const chunks = Array.from({ length: ${chunkCount} }, (_, index) => makeChunk(index));
|
||||
const totalChars = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const run = async kind => {
|
||||
document.getElementById("terminal").replaceChildren();
|
||||
globalThis.gc?.();
|
||||
await wait(20);
|
||||
const v8 = require("node:v8");
|
||||
const heapBefore = v8.getHeapStatistics().used_heap_size;
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cols: 120,
|
||||
cursorBlink: false,
|
||||
rows: 40,
|
||||
scrollback: ${scrollback},
|
||||
});
|
||||
term.open(document.getElementById("terminal"));
|
||||
let renderer = "dom";
|
||||
try {
|
||||
term.loadAddon(new WebglAddon());
|
||||
renderer = "webgl";
|
||||
} catch {}
|
||||
const selectedModule = kind === "old" ? oldModule : currentModule;
|
||||
const Highlighter = kind === "old"
|
||||
? oldModule.KeywordHighlighter
|
||||
: currentModule.KeywordHighlighter;
|
||||
const highlighter = kind === "raw" ? null : new Highlighter(term);
|
||||
highlighter?.setRules(rules, true);
|
||||
const sustainedOnly = process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY === "1";
|
||||
let maxPendingPristineBytes = 0;
|
||||
const pendingSample = setInterval(() => {
|
||||
maxPendingPristineBytes = Math.max(
|
||||
maxPendingPristineBytes,
|
||||
highlighter?.pendingPristineBytes ?? 0,
|
||||
);
|
||||
}, 5);
|
||||
const write = data => new Promise(resolve => term.write(data, resolve));
|
||||
const callbackLatencies = [];
|
||||
const heartbeatLatencies = [];
|
||||
let heartbeatAt = performance.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
const now = performance.now();
|
||||
heartbeatLatencies.push(now - heartbeatAt);
|
||||
heartbeatAt = now;
|
||||
}, 10);
|
||||
let renders = 0;
|
||||
const renderDisposable = term.onRender(() => { renders += 1; });
|
||||
const streamStarted = performance.now();
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
selectedModule.noteTerminalOutputPressureData(term, chunk);
|
||||
const callbackStarted = performance.now();
|
||||
await write(chunk);
|
||||
callbackLatencies.push(performance.now() - callbackStarted);
|
||||
if (index % 16 === 15) await wait(0);
|
||||
}
|
||||
const streamMs = performance.now() - streamStarted;
|
||||
const rendersAtStreamEnd = renders;
|
||||
const quietStarted = performance.now();
|
||||
await wait(sustainedOnly ? 0 : 700);
|
||||
const quietDelayMs = performance.now() - quietStarted;
|
||||
const streamHeartbeatCount = heartbeatLatencies.length;
|
||||
const streamMaxHeartbeatMs = Math.max(0, ...heartbeatLatencies);
|
||||
const rendersBeforeSettle = renders;
|
||||
const settleStarted = performance.now();
|
||||
if (!sustainedOnly) await highlighter?.whenSettled?.();
|
||||
const settleWaitMs = performance.now() - settleStarted;
|
||||
const quietWorkMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
|
||||
let paintTimedOut = false;
|
||||
if (!sustainedOnly) {
|
||||
await Promise.race([
|
||||
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
||||
wait(1000).then(() => { paintTimedOut = true; }),
|
||||
]);
|
||||
}
|
||||
const quietCatchUpMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(pendingSample);
|
||||
globalThis.gc?.();
|
||||
await wait(20);
|
||||
const heapAfter = v8.getHeapStatistics().used_heap_size;
|
||||
callbackLatencies.sort((left, right) => left - right);
|
||||
const percentile = value => callbackLatencies[Math.min(
|
||||
callbackLatencies.length - 1,
|
||||
Math.floor(callbackLatencies.length * value),
|
||||
)];
|
||||
const state = {
|
||||
kind,
|
||||
renderer,
|
||||
streamMs,
|
||||
mibPerSecond: totalChars / 1024 / 1024 / (streamMs / 1000),
|
||||
callbackP50Ms: percentile(0.5),
|
||||
callbackP95Ms: percentile(0.95),
|
||||
callbackP99Ms: percentile(0.99),
|
||||
maxHeartbeatMs: streamMaxHeartbeatMs,
|
||||
maxCatchUpHeartbeatMs: Math.max(0, ...heartbeatLatencies.slice(streamHeartbeatCount)),
|
||||
quietCatchUpMs,
|
||||
quietWorkMs,
|
||||
quietDelayMs,
|
||||
settleWaitMs,
|
||||
paintTimedOut,
|
||||
rendersDuringStream: rendersAtStreamEnd,
|
||||
rendersDuringSettle: renders - rendersBeforeSettle,
|
||||
heapDeltaMiB: (heapAfter - heapBefore) / 1024 / 1024,
|
||||
rebuildCount: highlighter?.rebuildCount ?? 0,
|
||||
rebuildTimings: highlighter?.lastRebuildTimings ?? {},
|
||||
maxPendingPristineBytes,
|
||||
};
|
||||
renderDisposable.dispose();
|
||||
highlighter?.dispose();
|
||||
term.dispose();
|
||||
return state;
|
||||
};
|
||||
|
||||
const rounds = [];
|
||||
for (let round = 0; round < ${roundCount}; round += 1) {
|
||||
for (const kind of ["raw", "old", "new"]) rounds.push(await run(kind));
|
||||
}
|
||||
return { totalChars, chunks: chunks.length, rounds };
|
||||
})()`, true);
|
||||
|
||||
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
|
||||
for (const round of result.rounds) assert.equal(round.renderer, "webgl", JSON.stringify(round));
|
||||
}
|
||||
const median = values => values.sort((left, right) => left - right)[Math.floor(values.length / 2)];
|
||||
const byKind = kind => result.rounds.filter(round => round.kind === kind);
|
||||
const oldStreamMs = median(byKind("old").map(round => round.streamMs));
|
||||
const newStreamMs = median(byKind("new").map(round => round.streamMs));
|
||||
const oldP99Ms = median(byKind("old").map(round => round.callbackP99Ms));
|
||||
const newP99Ms = median(byKind("new").map(round => round.callbackP99Ms));
|
||||
const oldHeartbeatMs = median(byKind("old").map(round => round.maxHeartbeatMs));
|
||||
const newHeartbeatMs = median(byKind("new").map(round => round.maxHeartbeatMs));
|
||||
const newCatchUpHeartbeatMs = median(byKind("new").map(round => round.maxCatchUpHeartbeatMs));
|
||||
const newQuietCatchUpMs = median(byKind("new").map(round => round.quietCatchUpMs));
|
||||
// Catch-up is deliberately deferred until output becomes quiet. Keep a
|
||||
// strict event-loop stall limit, while allowing total work to scale with
|
||||
// the retained history size (5s at 10k lines, 10s at 50k lines).
|
||||
const maxQuietCatchUpMs = Math.max(5000, scrollback / 5);
|
||||
const rawStreamMs = median(byKind("raw").map(round => round.streamMs));
|
||||
const rawP99Ms = median(byKind("raw").map(round => round.callbackP99Ms));
|
||||
assert.ok(
|
||||
newStreamMs <= oldStreamMs * 1.1,
|
||||
`new sustained throughput regressed more than 10%: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.ok(
|
||||
newP99Ms <= oldP99Ms * 1.15,
|
||||
`new p99 write latency regressed more than 15%: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.ok(
|
||||
newHeartbeatMs <= Math.max(75, oldHeartbeatMs * 3),
|
||||
`new event-loop stall regressed: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.ok(
|
||||
newCatchUpHeartbeatMs <= 350,
|
||||
`quiet catch-up blocked the event loop for over 350 ms: ${JSON.stringify(result)}`,
|
||||
);
|
||||
// Product gate is vs main decorations (10% above). Raw xterm is a sanity
|
||||
// bound only: cell-color wrap + DOM CI is typically ~15-18% over raw.
|
||||
assert.ok(
|
||||
newStreamMs <= rawStreamMs * 1.25,
|
||||
`new sustained throughput regressed more than 25% versus raw xterm: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.ok(
|
||||
newP99Ms <= Math.max(10, rawP99Ms * 1.25),
|
||||
`new p99 write latency regressed versus raw xterm: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.ok(
|
||||
newQuietCatchUpMs <= maxQuietCatchUpMs,
|
||||
`quiet-period catch-up exceeded ${maxQuietCatchUpMs} ms: ${JSON.stringify(result)}`,
|
||||
);
|
||||
if (process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY !== "1") {
|
||||
assert.equal(
|
||||
byKind("new").every(round => round.rebuildCount === 1),
|
||||
true,
|
||||
`bulk output must catch up exactly once after becoming quiet: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.equal(
|
||||
byKind("new").every(round => !round.paintTimedOut && round.rendersDuringSettle <= 1),
|
||||
true,
|
||||
`quiet catch-up must repaint atomically: ${JSON.stringify(result)}`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
byKind("new").every(round => round.maxPendingPristineBytes <= 12 * 1024 * 1024),
|
||||
true,
|
||||
`pristine backlog must stay bounded: ${JSON.stringify(result)}`,
|
||||
);
|
||||
process.stdout.write(`XTERM_KEYWORD_HIGHLIGHT_THROUGHPUT ${JSON.stringify(result)}\n`);
|
||||
cleanup(0);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
cleanup(1);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user