Compare commits

...
10 changed files with 317 additions and 11 deletions

No files matched your search

@@ -50,7 +50,7 @@ test("app resume handlers flush backlog and recover the terminal renderer before
assertRecoverTerminalOnAppResumeOrder(source);
assert.match(
resumeEffectSource,
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) return;\s*recoverTerminalOnAppResume\(\);\s*\};/,
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
);
assert.match(
resumeEffectSource,
+128 -4
View File
@@ -6,7 +6,11 @@ import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serializ
import type { Terminal as XTermType } from "@xterm/xterm";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
import {
noteTerminalOutputPressureData,
setTerminalOutputPressureVisibility,
} from "./runtime/terminalOutputPressure.ts";
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
import { KeywordHighlighter } from "./keywordHighlight.ts";
const require = createRequire(import.meta.url);
@@ -243,6 +247,27 @@ test("bulk output skipped on the hot path is highlighted after output becomes qu
term.dispose();
});
test("streaming bulk output colors the viewport in the same write", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 50 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
// More than BULK_WRITE_LINE_BREAKS newlines per chunk, but far below the
// large-output rate, like a coalesced `tail -f` tick (#3271).
const chunk = Array.from({ length: 12 }, (_, index) => `line-${index} ERROR`).join("\r\n");
await write(term, chunk);
assert.equal(highlighter.rebuildCount, 0);
const viewportY = term.buffer.active.viewportY;
assert.equal(cellRgb(term, viewportY + 4, "ERROR"), RED, "visible rows must color in-frame");
assert.notEqual(cellRgb(term, 0, "ERROR"), RED, "history above the viewport stays with catch-up");
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
assert.equal(highlighter.rebuildCount, 1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
const highlighter = new KeywordHighlighter(term);
@@ -1039,7 +1064,9 @@ test("quiet catch-up refreshes a tall viewport once", async () => {
await write(term, flood);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.equal(refreshCount, 1);
// One refresh paints the viewport in-frame inside the bulk write callback;
// the second is the single catch-up viewport pass after output quiets.
assert.equal(refreshCount, 2);
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
@@ -1064,7 +1091,7 @@ test("identical flood lines are all colored after quiet catch-up", async () => {
term.dispose();
});
test("saturated flood does not rematch the pinned viewport on each write", async () => {
test("saturated streaming keeps the pinned viewport colored in-frame", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
@@ -1076,8 +1103,56 @@ test("saturated flood does not rematch the pinned viewport on each write", async
}
assert.equal(highlighter.rebuildCount, 0);
const viewportY = term.buffer.active.viewportY;
// Bulk bypass still colors the visible rows inside the write callback, so
// streaming logs never show an uncolored frame (#3271).
assert.equal(cellRgb(term, viewportY, "ERROR"), RED);
assert.equal(cellRgb(term, viewportY + 2, "ERROR"), RED);
highlighter.dispose();
term.dispose();
});
test("hidden panes keep bulk streaming on the deferred catch-up path", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 40 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
// Below the 16KB/100ms flood gate, so without the background check the bulk
// bypass would recolor and refresh the viewport for every hidden batch.
setTerminalOutputPressureVisibility(term, false);
for (let index = 0; index < 4; index += 1) {
noteTerminalOutputPressureData(term, chunk);
await write(term, chunk);
}
const viewportY = term.buffer.active.viewportY;
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
// Reveal the pane and let catch-up paint the deferred range.
setTerminalOutputPressureVisibility(term, true);
await new Promise((resolve) => setTimeout(resolve, 650));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
test("high-rate flood keeps the viewport on the deferred catch-up path", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
// ~20KB inside the 100ms rate window trips the large-output degradation, so
// the in-frame viewport repaint stays off and only catch-up colors.
const chunk = Array.from({ length: 300 }, () => line).join("\r\n");
for (let index = 0; index < 3; index += 1) {
noteTerminalOutputPressureData(term, chunk);
await write(term, chunk);
}
assert.equal(highlighter.rebuildCount, 0);
const viewportY = term.buffer.active.viewportY;
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
await new Promise((resolve) => setTimeout(resolve, 900));
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
highlighter.dispose();
term.dispose();
});
@@ -1098,3 +1173,52 @@ test("recycled identical flood rows are recolored on catch-up", async () => {
highlighter.dispose();
term.dispose();
});
test("hibernate replay defers bulk viewport coloring but subsequent live output colors immediately", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules(rule(), true);
const chunk = Array.from({ length: 20 }, () => "history ERROR").join("\r\n");
try {
await writeTerminalPayloadChunked(term, chunk);
assert.notEqual(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
await highlighter.whenSettled();
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
await write(term, "\r\n" + chunk);
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
} finally {
highlighter.dispose();
term.dispose();
}
});
test("bulk streaming does not recolor a scrolled-back history viewport", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
let bypass = false;
const highlighter = new KeywordHighlighter(term, { shouldBypassHighlight: () => bypass });
highlighter.setRules(rule(), true);
const chunk = Array.from({ length: 20 }, () => "log ERROR").join("\r\n") + "\r\n";
try {
await write(term, chunk);
await highlighter.whenSettled();
term.scrollToTop();
let refreshes = 0;
const refresh = term.refresh.bind(term);
term.refresh = (start, end) => { refreshes += 1; refresh(start, end); };
bypass = true;
await write(term, chunk);
const scrollRefreshes = refreshes;
refreshes = 0;
bypass = false;
await write(term, chunk);
assert.equal(refreshes, scrollRefreshes, "bulk callback must not add another history repaint");
term.scrollToBottom();
await write(term, chunk);
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
} finally {
highlighter.dispose();
term.dispose();
}
});
+53 -1
View File
@@ -5,9 +5,13 @@ import { isSafePluginDecorationPattern } from "../../domain/pluginTerminalProvid
import { checkRegexSafetyPattern } from "../../lib/regexSafety";
import type { KeywordHighlightRule } from "../../types";
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
import { isTerminalReplayWrite } from "./terminalReplay";
import { readPluginTerminalBufferText } from "./pluginTerminalBufferText";
import { compileRe2RangeMatcher, forEachNonEmptyRegexMatch } from "./keywordHighlightRegex";
import { shouldDegradeTerminalKeywordHighlight } from "./runtime/terminalOutputPressure";
import {
isTerminalOutputInBackground,
shouldDegradeTerminalKeywordHighlight,
} from "./runtime/terminalOutputPressure";
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
@@ -396,12 +400,43 @@ export class KeywordHighlighter implements IDisposable {
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
: null;
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
// Freeze in-frame eligibility at entry: the pressure window is a rolling
// sample, and by the time this write's callback runs (xterm parses in a
// macrotask while backlogged) later chunks may have aged it below the
// threshold. Re-checking inside the callback would let a write that took
// the flood path repaint the viewport anyway, defeating the deferred path.
// Pane visibility is the exception: it can flip while this write is still
// in flight (pane/page shown between entry and callback), so it is
// re-evaluated live in the callback below instead of frozen here.
const colorDuringBypass = bypass && this.mayColorViewportDuringBypass(data);
if (bypass) {
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
this.scheduleCatchUp();
}
return this.originalWrite(data, () => {
// Bulk writes skip per-write coloring so xterm can keep painting the
// flood, but the viewport itself must not sit uncolored until the
// catch-up timer fires (#3271). Recolor the visible rows here, inside
// the write callback: xterm parses in a macrotask and renders in the
// next rAF, so these colors land in the same frame as the text. The
// cost is O(viewport rows × rules), independent of chunk size, and
// true rate/long-line floods keep the fully deferred path. Rows that
// scrolled past the viewport stay with the deferred catch-up.
if (
this.term.buffer.active.type === "normal"
&& this.compiledPatterns.length > 0
&& colorDuringBypass
// Visibility may have changed since this write started (hidden pane
// shown before the async xterm parse finished): the entry snapshot
// only froze the flood/rate decision, so check the live state here.
&& !isTerminalOutputInBackground(this.term)
// Appended logs should not add another repaint while reading history.
// Scroll/reveal handlers retain their existing recoloring behavior.
&& this.term.buffer.active.viewportY === this.term.buffer.active.baseY
) {
this.recolorVisible();
}
if (this.term.buffer.active.type === "normal" && !startedOnNormal) {
if (this.enabled || this.compiledPatterns.length > 0) {
this.markCatchUp(0);
@@ -692,6 +727,23 @@ export class KeywordHighlighter implements IDisposable {
return this.countNewlines(data) >= BULK_WRITE_LINE_BREAKS;
}
/**
* Streaming logs usually trip only the bulk line-break heuristic (coalesced
* PTY ticks), which is cheap to color in-frame: rematching the visible
* viewport costs O(viewport rows × rules) per write, independent of chunk
* size. Rate/long-line floods and explicit full-bypass requests keep the
* deferred catch-up so xterm keeps painting the dump smoothly.
*
* This is the frozen flood/rate decision only: pane visibility is evaluated
* live in the write callback (`isTerminalOutputInBackground`) because it can
* flip between write entry and the asynchronous callback.
*/
private mayColorViewportDuringBypass(data: string | Uint8Array): boolean {
if (this.options.shouldBypassHighlight?.()) return false;
if (typeof data !== "string" || isTerminalReplayWrite(this.term)) return false;
return !shouldDegradeTerminalKeywordHighlight(this.term, data);
}
private countNewlines(data: string): number {
let count = 0;
for (let index = 0; index < data.length; index += 1) {
@@ -246,6 +246,11 @@ export const setTerminalOutputPressureVisibility = (
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,
@@ -20,7 +20,10 @@ import {
writeSessionData,
} from "./terminalSessionAttachment.ts";
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
import {
getTerminalOutputPressure,
noteTerminalOutputPressureData,
} from "./terminalOutputPressure.ts";
import {
clearTerminalSessionFlowAck,
@@ -1539,6 +1542,50 @@ test("writeSessionData keeps the hidden flush gate after coalescer reset and flu
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData refreshes pressure visibility when hidden output flushes after reveal", () => {
clearTerminalSessionFlowAck("session-1");
const payload = "x".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1);
const writes: string[] = [];
const term = {
buffer: { active: { type: "normal" } },
write(data: string, callback?: () => void) {
writes.push(data);
callback?.();
},
scrollToBottom() {},
} as unknown as XTerm;
const ctx = {
...createContext(false),
isVisibleRef: { current: false },
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: () => {},
},
};
getFlowController(ctx as never, term);
resetTerminalWriteCoalescer(term);
withAnimationFrameQueue(() => {
withDocumentVisibility("visible", () => {
writeSessionData(ctx as never, term, payload);
});
// The ingress snapshot records the pane as hidden.
assert.equal(getTerminalOutputPressure(term).background, true);
assert.deepEqual(writes, []);
ctx.isVisibleRef.current = true;
withDocumentVisibility("visible", () => {
flushPendingTerminalWritesOnResume(term);
});
// The batch is actually parsed after reveal, so the flush must clear the
// stale hidden flag: bulk write callbacks (e.g. keyword highlight) gate
// their in-frame viewport recolor on this state (#3272).
assert.deepEqual(writes, [payload]);
assert.equal(getTerminalOutputPressure(term).background, false);
});
clearTerminalSessionFlowAck("session-1");
});
test("hidden tab output marks pending scroll without scrolling immediately", async () => {
const writes: string[] = [];
let scrollCalls = 0;
@@ -89,6 +89,7 @@ import {
} from "./terminalOutputPipeline";
import {
hasPendingTerminalWrites,
isTerminalPageHidden,
maybeFlushTerminalWriteCoalescerWhenUnfocused,
scheduleTerminalRepaintWhenUnfocused,
shouldFlushTerminalWritesForBackgroundOutput,
@@ -114,6 +115,16 @@ const isTerminalPaneVisible = (ctx: TerminalSessionStartersContext): boolean =>
(ctx.isPaneVisibleRef?.current ?? ctx.isVisibleRef?.current) !== false
);
/**
* Visibility recorded for the output-pressure gates. A pane whose ref says
* "visible" still cannot paint while the document is page-hidden, so record it
* as background there too; otherwise bulk batches take the background flush
* path but keyword-highlight still repainted the viewport per batch.
*/
const isTerminalOutputVisibleForPressure = (ctx: TerminalSessionStartersContext): boolean => (
isTerminalPaneVisible(ctx) && !isTerminalPageHidden()
);
const handleTerminalOutputAutoScroll = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -471,7 +482,7 @@ export const writeSessionData = (
visible: isPaneVisible,
});
flow.received(ingressBytes);
setTerminalOutputPressureVisibility(term, isPaneVisible);
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
noteTerminalOutputPressureData(term, data);
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const preservePromptSourceChunks = Boolean(
@@ -485,6 +496,12 @@ export const writeSessionData = (
batchIngress: number,
writeOptions?: CoalescedTerminalWriteOptions,
): void => {
// Output queued while hidden can be flushed after the pane is revealed.
// Refresh the pressure visibility at flush time so the write callback
// sees the live pane state instead of the ingress snapshot; otherwise a
// bulk batch written after reveal stays on the fully deferred path and
// the viewport sits uncolored until the quiet catch-up (#3272).
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
writeSessionDataImmediate(ctx, term, batch, batchIngress, {
...writeOptions,
deferStart: writeOptions?.deferStart ?? !isPaneCurrentlyVisible(),
+11 -1
View File
@@ -12,10 +12,20 @@ const scheduleFrame = (): Promise<void> => new Promise((resolve) => {
setTimeout(resolve, 0);
});
// The highlighter snapshots this flag synchronously when term.write enters.
// Replay has no PTY pressure samples, so it must not look like live log output.
const replayWrites = new WeakSet<XTerm>();
export const isTerminalReplayWrite = (term: XTerm): boolean => replayWrites.has(term);
const writeChunk = (term: XTerm, data: string): Promise<void> => {
if (!data) return Promise.resolve();
return new Promise((resolve) => {
term.write(data, () => resolve());
replayWrites.add(term);
try {
term.write(data, () => resolve());
} finally {
replayWrites.delete(term);
}
});
};
@@ -25,7 +25,7 @@ test('tab-switch suppression does not consume the visible recovery pass', () =>
assert.match(source, /const becameVisible = isVisible && !wasVisibleRef\.current/);
assert.match(
source,
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*syncOutputPressureVisibility\(\);\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
);
assert.doesNotMatch(source, /wasVisibleRef\.current = isVisible;\s*if \(!isVisible \|\| isResizing\) return/);
});
+25 -1
View File
@@ -41,8 +41,10 @@ import {
flushPendingTerminalWritesBeforeHibernate,
flushPendingTerminalWritesOnResume,
forceTerminalRepaintBypassingAnimationFrame,
isTerminalPageHidden,
repaintTerminalAfterReveal,
} from './runtime/terminalUnfocusedRepaint';
import { setTerminalOutputPressureVisibility } from './runtime/terminalOutputPressure';
import {
forceXTermFontRemeasure,
type XTermFontRemeasureTarget,
@@ -1274,10 +1276,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
pendingOutputScrollRef.current = false;
};
// The output-pressure visibility flag is read live by the keyword
// highlighter's write callback, but the write path only refreshes it at
// ingress and coalescer flush. A batch dispatched while hidden can still be
// parsing when the pane/page is revealed, and a stale `background: true`
// would then skip the in-frame viewport recolor (#3272). Re-sync the flag
// on every visibility transition so in-flight writes always see the
// current pane/document state.
const syncOutputPressureVisibility = () => {
const term = termRef.current;
if (!term) return;
setTerminalOutputPressureVisibility(
term,
isVisibleRef.current && !isTerminalPageHidden(),
);
};
const flushTerminalWritesAfterBecomeVisible = () => {
lastCommittedVisibleLayoutKeyRef.current = null;
const term = termRef.current;
if (term) {
syncOutputPressureVisibility();
cancelScheduledUnfocusedRepaint(term);
flushPendingTerminalWritesOnResume(term);
repaintTerminalAfterReveal(term, () => isVisibleRef.current);
@@ -1327,6 +1346,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!isVisible) {
wasVisibleRef.current = false;
syncOutputPressureVisibility();
return;
}
@@ -1844,6 +1864,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const recoverTerminalOnAppResume = () => {
const term = termRef.current;
if (term) {
syncOutputPressureVisibility();
cancelScheduledUnfocusedRepaint(term);
flushPendingTerminalWritesOnResume(term);
forceTerminalRepaintBypassingAnimationFrame(term);
@@ -1854,7 +1875,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') return;
if (document.visibilityState !== 'visible') {
syncOutputPressureVisibility();
return;
}
recoverTerminalOnAppResume();
};
@@ -117,6 +117,33 @@ if (!process.versions.electron) {
const write = data => new Promise(resolve => term.write(data, resolve));
const waitPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// #3271: inspect the first actual renderer frame for tail -200f and
// subsequent coalesced log ticks, before the deferred catch-up can run.
let streamingFrames = 0;
let uncoloredStreamingFrame = false;
const streamingRender = term.onRender(() => {
streamingFrames += 1;
for (let row = 0; row < term.rows; row += 1) {
const line = term.buffer.active.getLine(term.buffer.active.viewportY + row);
const column = line?.translateToString(true).indexOf("ERROR") ?? -1;
if (column >= 0 && line.getCell(column).getFgColor() !== 0xf87171) {
uncoloredStreamingFrame = true;
}
}
});
for (const lines of [200, 12, 12, 12]) {
const batch = Array.from({ length: lines }, () => "log ERROR").join("\\r\\n") + "\\r\\n";
noteTerminalOutputPressureData(term, batch);
await write(batch);
await waitPaint();
await new Promise(resolve => setTimeout(resolve, 120));
}
streamingRender.dispose();
if (uncoloredStreamingFrame) throw new Error("Streaming output rendered before keyword color");
if (streamingFrames < 4) throw new Error("Streaming renderer frames were not observed");
await highlighter.whenSettled();
term.reset();
await write("row-1 ERROR\\r\\nrow-2 ERROR\\r\\nrow-3 ERROR\\r\\nrow-4 ERROR");
await write("\\x1b[1;");
await write(