mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45b8778112 | ||
|
|
8b36bce69e | ||
|
|
ebce76bf53 | ||
|
|
1ce5b83367 | ||
|
|
441cd1be25 | ||
|
|
78dba35ee6 | ||
|
|
5e1c1135c7 | ||
|
|
40b42e9e82 | ||
|
|
fb79963a53 | ||
|
|
033e86f94d | ||
|
|
caaf79648c | ||
|
|
bdeeb3c003 | ||
|
|
0bde7b8bed |
No files matched your search
+81
-13
@@ -448,6 +448,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateContextViewportSnapshotRef = useRef("");
|
||||
const hibernateContextScrollbackSnapshotRef = useRef("");
|
||||
const hibernatePendingBufferRef = useRef("");
|
||||
const hibernatePendingCapDisabledRef = useRef(false);
|
||||
const hibernateAlternateScreenRef = useRef(false);
|
||||
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
@@ -1785,10 +1786,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
backendId,
|
||||
(chunk, meta) => {
|
||||
observeTerminalInputPrompt(chunk, meta);
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
);
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + chunk
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
chunk,
|
||||
);
|
||||
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||||
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||||
: chunk.length;
|
||||
@@ -1808,10 +1811,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setError(evt.error);
|
||||
}
|
||||
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
exitMessage,
|
||||
);
|
||||
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
|
||||
? hibernatePendingBufferRef.current + exitMessage
|
||||
: appendHibernatePendingBuffer(
|
||||
hibernatePendingBufferRef.current,
|
||||
exitMessage,
|
||||
);
|
||||
onSessionExitRef.current?.(sessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
});
|
||||
@@ -3686,16 +3691,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
wakeInProgressRef.current = true;
|
||||
setPasswordPickerState(null);
|
||||
|
||||
const stopHibernateListeners = () => {
|
||||
const backendId = sessionRef.current;
|
||||
const stopHibernateDataListener = () => {
|
||||
disposeDataRef.current?.();
|
||||
disposeDataRef.current = null;
|
||||
};
|
||||
|
||||
const stopHibernateListeners = (opts?: { keepPaused?: boolean }) => {
|
||||
const backendId = sessionRef.current;
|
||||
stopHibernateDataListener();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
if (backendId) {
|
||||
flushTerminalSessionFlowAck(backendId);
|
||||
clearTerminalSessionFlowAck(backendId);
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
if (!opts?.keepPaused) {
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3704,7 +3715,64 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
runtimeContext,
|
||||
container,
|
||||
getPayload,
|
||||
stopHibernateListeners,
|
||||
prepareWakeFlow: async () => {
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) return true;
|
||||
// Always pause when a backend exists, including reconnect wakes
|
||||
// (sessionConnected=false) that will not reattach. Stopping the
|
||||
// hibernate listener without a pause drops live output into the
|
||||
// preload backlog without flow ACKs until cleanupSession.
|
||||
if (terminalBackend.setSessionFlowPausedAndWait) {
|
||||
const result = await terminalBackend.setSessionFlowPausedAndWait(backendId, true);
|
||||
return result?.success === true;
|
||||
}
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, true);
|
||||
return false;
|
||||
},
|
||||
takePendingBuffer: () => {
|
||||
const pending = hibernatePendingBufferRef.current;
|
||||
hibernatePendingBufferRef.current = "";
|
||||
return pending;
|
||||
},
|
||||
stopHibernateDataListener,
|
||||
setHibernatePendingCapDisabled: (disabled) => {
|
||||
hibernatePendingCapDisabledRef.current = disabled;
|
||||
},
|
||||
stopHibernateListeners: () => stopHibernateListeners({ keepPaused: true }),
|
||||
restoreAfterFailedWake: (takenPending) => {
|
||||
hibernatePendingCapDisabledRef.current = false;
|
||||
const pendingStillInRef = hibernatePendingBufferRef.current;
|
||||
disposeRuntimeOnly();
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) {
|
||||
// No backend to reattach listeners to; still keep taken bytes for a
|
||||
// later wake of a disconnected hibernated tab.
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
takenPending || "",
|
||||
pendingStillInRef,
|
||||
);
|
||||
return;
|
||||
}
|
||||
beginHibernatedSessionListeners(backendId);
|
||||
// beginHibernatedSessionListeners clears pending; restore take-and-cleared
|
||||
// bytes from this wake plus anything that arrived after the last take.
|
||||
const restored = appendHibernatePendingBuffer(
|
||||
takenPending || "",
|
||||
pendingStillInRef,
|
||||
);
|
||||
if (restored) {
|
||||
hibernatePendingBufferRef.current = appendHibernatePendingBuffer(
|
||||
restored,
|
||||
hibernatePendingBufferRef.current,
|
||||
);
|
||||
}
|
||||
},
|
||||
resumeAfterReattach: () => {
|
||||
hibernatePendingCapDisabledRef.current = false;
|
||||
const backendId = sessionRef.current;
|
||||
if (!backendId) return;
|
||||
terminalBackend.setSessionFlowPaused?.(backendId, false);
|
||||
},
|
||||
sessionConnected: options.sessionConnected,
|
||||
getSessionConnected: () => getSessionConnectedRef.current(),
|
||||
reattachSession: (term) => {
|
||||
@@ -3730,7 +3798,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
wakePromiseRef.current = wakePromise;
|
||||
return wakePromise;
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings, beginHibernatedSessionListeners]);
|
||||
|
||||
const wakeHibernatedRuntime = useCallback(async (sessionConnected: boolean): Promise<boolean> => {
|
||||
if (!hibernatedRef.current) {
|
||||
|
||||
@@ -2,11 +2,151 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import xterm from "@xterm/xterm";
|
||||
import serializeMod from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import { applyHibernateWakeToTerminal } from "./terminalHibernateRuntime.ts";
|
||||
import {
|
||||
applyHibernateWakeToTerminal,
|
||||
resolveHibernateWakeHistory,
|
||||
} from "./terminalHibernateRuntime.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
const { SerializeAddon } = serializeMod;
|
||||
|
||||
const readActiveBufferText = (term: XTerm): string => {
|
||||
const buffer = term.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < buffer.length; index += 1) {
|
||||
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const writeAndWait = (term: XTerm, data: string): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
term.write(data, () => resolve());
|
||||
});
|
||||
|
||||
test("hibernate wake pauses flow before replay and resumes only after reattach", () => {
|
||||
// #2762 / Codex: full-history wake must not race the capped pending buffer or
|
||||
// the 64 KiB preload backlog. Pause+wait drains into pending, then stop the
|
||||
// data listener, take pending once, replay, reattach, and only then resume.
|
||||
const mountSource = readFileSync(new URL("./terminalRuntimeMount.ts", import.meta.url), "utf8");
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(mountSource, /prepareWakeFlow\?:\s*\(\) => Promise<boolean>/);
|
||||
assert.match(mountSource, /restoreAfterFailedWake\?:\s*\(takenPending: string\) => void/);
|
||||
assert.match(mountSource, /resumeAfterReattach\?:\s*\(\) => void/);
|
||||
assert.match(mountSource, /takePendingBuffer: \(\) => string/);
|
||||
assert.match(mountSource, /stopHibernateDataListener: \(\) => void/);
|
||||
assert.match(mountSource, /const drainOk = \(await prepareWakeFlow\?\.\(\)\) \?\? true;/);
|
||||
assert.match(mountSource, /const takeAndTrackPending = \(\): string =>/);
|
||||
assert.match(mountSource, /const pendingAtApplyStart = takeAndTrackPending\(\);/);
|
||||
assert.match(mountSource, /const pendingTail = takeAndTrackPending\(\);/);
|
||||
assert.match(mountSource, /if \(!pendingTail\) break;/);
|
||||
assert.match(mountSource, /restoreAfterFailedWake\?\.\(takenPendingForRestore\)/);
|
||||
assert.doesNotMatch(mountSource, /pending\.slice\(replayedPendingLength\)/);
|
||||
assert.doesNotMatch(mountSource, /for \(let drainPass = 0;/);
|
||||
assert.doesNotMatch(mountSource, /finalPendingDelta/);
|
||||
assert.doesNotMatch(mountSource, /preTeardownTail/);
|
||||
|
||||
const prepareIndex = mountSource.indexOf("const drainOk = (await prepareWakeFlow?.()) ?? true;");
|
||||
const disableCapIndex = mountSource.indexOf("setHibernatePendingCapDisabled?.(true);");
|
||||
const stopDataBeforeReplay = mountSource.indexOf("if (drainOk) {\n stopHibernateDataListener();");
|
||||
const pendingIndex = mountSource.indexOf("const pendingAtApplyStart = takeAndTrackPending();");
|
||||
const applyIndex = mountSource.indexOf("await applyHibernateWakeToTerminal(");
|
||||
const stopDataBeforeTail = mountSource.indexOf(
|
||||
"stopHibernateDataListener();",
|
||||
applyIndex,
|
||||
);
|
||||
const pendingTailIndex = mountSource.indexOf("const pendingTail = takeAndTrackPending();");
|
||||
const emptyBreakIndex = mountSource.indexOf("if (!pendingTail) break;");
|
||||
const shouldReattachIndex = mountSource.indexOf(
|
||||
"const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);",
|
||||
);
|
||||
const stopAllIndex = mountSource.indexOf("stopHibernateListeners();", shouldReattachIndex);
|
||||
const reattachIndex = mountSource.indexOf("reattachSession(term);", shouldReattachIndex);
|
||||
const failedRestoreIndex = mountSource.indexOf("restoreAfterFailedWake?.(takenPendingForRestore);");
|
||||
const resumeIndex = mountSource.indexOf("resumeAfterReattach?.();");
|
||||
|
||||
assert.ok(prepareIndex >= 0, "wake must pause backend flow before history replay");
|
||||
assert.ok(
|
||||
disableCapIndex > prepareIndex,
|
||||
"drain failure must disable the pending cap before keeping the live listener",
|
||||
);
|
||||
assert.ok(stopDataBeforeReplay > prepareIndex, "successful drain stops the data listener before replay");
|
||||
assert.ok(pendingIndex > stopDataBeforeReplay, "pending take must run after drain handling");
|
||||
assert.ok(applyIndex > pendingIndex, "history replay follows the pending capture");
|
||||
assert.ok(
|
||||
stopDataBeforeTail > applyIndex,
|
||||
"data listener must stop again before residual pending drain",
|
||||
);
|
||||
assert.ok(
|
||||
pendingTailIndex > stopDataBeforeTail,
|
||||
"residual pending drain must run after history replay",
|
||||
);
|
||||
assert.ok(
|
||||
emptyBreakIndex > pendingTailIndex,
|
||||
"residual drain must end with an empty take before teardown",
|
||||
);
|
||||
assert.ok(
|
||||
shouldReattachIndex > emptyBreakIndex,
|
||||
"reattach decision must run after until-empty pending drain",
|
||||
);
|
||||
assert.ok(
|
||||
stopAllIndex > shouldReattachIndex,
|
||||
"full hibernate listener teardown must wait until after the reattach decision",
|
||||
);
|
||||
assert.ok(reattachIndex > stopAllIndex, "reattach runs after hibernate listeners are cleared");
|
||||
assert.ok(failedRestoreIndex >= 0, "failed wakes must restore take-and-cleared pending");
|
||||
assert.ok(resumeIndex > reattachIndex, "flow resume must wait until after reattach");
|
||||
assert.match(
|
||||
mountSource,
|
||||
/if \(!wakeSucceeded\) \{[\s\S]*?restoreAfterFailedWake\?\.\(takenPendingForRestore\);[\s\S]*?\} else if \(didReattach\) \{[\s\S]*?resumeAfterReattach\?\.\(\);/,
|
||||
);
|
||||
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/takePendingBuffer:\s*\(\)\s*=>\s*\{\s*const pending = hibernatePendingBufferRef\.current;\s*hibernatePendingBufferRef\.current = "";\s*return pending;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/setSessionFlowPausedAndWait\(backendId,\s*true\)/,
|
||||
);
|
||||
// Reconnect wakes (sessionConnected=false) must still pause when a backend
|
||||
// session exists; otherwise stopping the hibernate listener drops live output.
|
||||
assert.doesNotMatch(
|
||||
terminalSource,
|
||||
/prepareWakeFlow: async \(\) => \{\s*if \(!options\.sessionConnected\) return true;/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/stopHibernateListeners\(\{\s*keepPaused:\s*true\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/restoreAfterFailedWake:\s*\(takenPending\)\s*=>\s*\{[\s\S]*?disposeRuntimeOnly\(\);[\s\S]*?beginHibernatedSessionListeners\(backendId\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/appendHibernatePendingBuffer\(\s*takenPending \|\| "",\s*pendingStillInRef,\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/resumeAfterReattach:\s*\(\)\s*=>\s*\{[\s\S]*?setSessionFlowPaused\?\.\(backendId,\s*false\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/hibernatePendingCapDisabledRef\.current\s*\?\s*hibernatePendingBufferRef\.current \+ chunk/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/result\?\.success === true/,
|
||||
);
|
||||
});
|
||||
|
||||
test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", async () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
@@ -22,7 +162,43 @@ test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", a
|
||||
assert.equal(writes.join(""), payload);
|
||||
});
|
||||
|
||||
test("applyHibernateWakeToTerminal defers scrollback to idle and chunks viewport first", async () => {
|
||||
test("resolveHibernateWakeHistory prefers the coherent full snapshot", () => {
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "FULL",
|
||||
viewportSnapshot: "VIEW",
|
||||
scrollbackSnapshot: "SCROLL",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"FULL",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHibernateWakeHistory falls back to scrollback before viewport with a seam newline", () => {
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "",
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
|
||||
);
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "",
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START\r\n",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyHibernateWakeToTerminal replays snapshot then pending without idle append", async () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
rows: 24,
|
||||
@@ -43,38 +219,32 @@ test("applyHibernateWakeToTerminal defers scrollback to idle and chunks viewport
|
||||
// @ts-expect-error test override
|
||||
globalThis.requestIdleCallback = (cb: () => void) => {
|
||||
idleScheduled = true;
|
||||
// Do not run immediately — proves scrollback is deferred off the wake path.
|
||||
setTimeout(cb, 0);
|
||||
return 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const viewport = "VIEWPORT";
|
||||
const scrollback = "S".repeat(40_000);
|
||||
const snapshot = "SCROLLBACK_START\r\nVIEWPORT_END\r\n";
|
||||
const pending = "PENDING_TAIL\r\n";
|
||||
await applyHibernateWakeToTerminal(
|
||||
term,
|
||||
runtime as never,
|
||||
{
|
||||
snapshot: viewport,
|
||||
viewportSnapshot: viewport,
|
||||
scrollbackSnapshot: scrollback,
|
||||
pendingBuffer: "",
|
||||
snapshot,
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START\r\n",
|
||||
pendingBuffer: pending,
|
||||
alternateScreen: false,
|
||||
},
|
||||
{ replayOptions: { chunkBytes: 8_192 } },
|
||||
);
|
||||
|
||||
// Viewport written on the wake path; scrollback scheduled idle.
|
||||
assert.ok(writes.join("").includes(viewport));
|
||||
assert.equal(idleScheduled, true);
|
||||
assert.ok(
|
||||
!writes.join("").includes(scrollback.slice(0, 1000)),
|
||||
"scrollback must not be written synchronously on wake",
|
||||
assert.equal(writes.join(""), `${snapshot}${pending}`);
|
||||
assert.equal(
|
||||
idleScheduled,
|
||||
false,
|
||||
"scrollback must not be deferred to idle after viewport (append would evict the end)",
|
||||
);
|
||||
|
||||
// Flush idle callback.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(writes.join("").includes(scrollback.slice(0, 100)), "scrollback eventually applied");
|
||||
} finally {
|
||||
if (originalRic) {
|
||||
globalThis.requestIdleCallback = originalRic;
|
||||
@@ -85,9 +255,115 @@ test("applyHibernateWakeToTerminal defers scrollback to idle and chunks viewport
|
||||
}
|
||||
});
|
||||
|
||||
test("hibernate runtime source schedules scrollback via requestIdleCallback", () => {
|
||||
test("hibernate wake keeps newest rows from a SerializeAddon snapshot under a finite scrollback cap", async () => {
|
||||
// #2762: idle-appending older scrollback after viewport under scrollback=N
|
||||
// evicts the newest rows. Prefer the full SerializeAddon snapshot.
|
||||
const rows = 5;
|
||||
const scrollbackCap = 8;
|
||||
const source = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: 50,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const serializeAddon = new SerializeAddon();
|
||||
source.loadAddon(serializeAddon);
|
||||
|
||||
const olderLines = Array.from({ length: 20 }, (_, index) => `old-${index}`);
|
||||
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
|
||||
await writeAndWait(source, `${olderLines.join("\r\n")}\r\n${newestLines.join("\r\n")}\r\n`);
|
||||
|
||||
const snapshot = serializeAddon.serialize();
|
||||
const bufferLength = source.buffer.active.length;
|
||||
const viewportStart = Math.max(0, bufferLength - rows);
|
||||
const scrollbackSnapshot = serializeAddon.serialize({
|
||||
range: { start: Math.max(0, viewportStart - 20), end: viewportStart - 1 },
|
||||
});
|
||||
const viewportSnapshot = serializeAddon.serialize({
|
||||
range: { start: viewportStart, end: bufferLength - 1 },
|
||||
});
|
||||
source.dispose();
|
||||
|
||||
// Range concat is not byte-identical to the full snapshot (missing seam newline).
|
||||
assert.notEqual(scrollbackSnapshot + viewportSnapshot, snapshot);
|
||||
|
||||
const term = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: scrollbackCap,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const runtime = {
|
||||
ensureWebglRenderer: () => {},
|
||||
clearTextureAtlas: () => {},
|
||||
};
|
||||
|
||||
try {
|
||||
await applyHibernateWakeToTerminal(
|
||||
term,
|
||||
runtime as never,
|
||||
{
|
||||
snapshot,
|
||||
viewportSnapshot,
|
||||
scrollbackSnapshot,
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
},
|
||||
{ replayOptions: { chunkBytes: 1024 } },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
const text = readActiveBufferText(term);
|
||||
for (const line of newestLines) {
|
||||
assert.match(text, new RegExp(line), `newest viewport line missing after wake: ${line}`);
|
||||
}
|
||||
assert.doesNotMatch(text, /new-0new-1/, "seam must not merge adjacent snapshot lines");
|
||||
} finally {
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("wrong wake order (viewport then scrollback append) drops newest rows under scrollback cap", async () => {
|
||||
// Guardrail: documents why idle-append-after-viewport is unsafe.
|
||||
const rows = 5;
|
||||
const scrollbackCap = 10;
|
||||
const term = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: scrollbackCap,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const olderLines = Array.from({ length: scrollbackCap + rows }, (_, index) => `old-${index}`);
|
||||
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
|
||||
await writeAndWait(term, `${newestLines.join("\r\n")}\r\n`);
|
||||
await writeAndWait(term, `${olderLines.join("\r\n")}\r\n`);
|
||||
|
||||
const text = readActiveBufferText(term);
|
||||
for (const line of newestLines) {
|
||||
assert.equal(
|
||||
text.includes(line),
|
||||
false,
|
||||
`viewport-first append must evict newest line under the cap: ${line}`,
|
||||
);
|
||||
}
|
||||
assert.match(text, /old-14/);
|
||||
} finally {
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("hibernate runtime source prefers resolveHibernateWakeHistory on the wake path", () => {
|
||||
const source = readFileSync(new URL("./terminalHibernateRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /requestIdleCallback|scheduleIdle/);
|
||||
assert.match(source, /writeTerminalPayloadChunked\(term, scrollback/);
|
||||
assert.match(source, /writeTerminalReplaySequence\(term, \[viewport, payload\.pendingBuffer\]/);
|
||||
assert.match(source, /export function resolveHibernateWakeHistory/);
|
||||
assert.match(
|
||||
source,
|
||||
/writeTerminalReplaySequence\(\s*term,\s*\[\s*history,\s*payload\.pendingBuffer\s*\]/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/scheduleIdle\(\(\)\s*=>\s*\{\s*void writeTerminalPayloadChunked\(term, scrollback/,
|
||||
);
|
||||
});
|
||||
@@ -295,13 +295,23 @@ export type ApplyHibernateWakeOptions = {
|
||||
deferWebgl?: boolean;
|
||||
};
|
||||
|
||||
const scheduleIdle = (callback: () => void): void => {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => callback(), { timeout: 500 });
|
||||
return;
|
||||
}
|
||||
setTimeout(callback, 0);
|
||||
};
|
||||
/**
|
||||
* Resolve the pre-pending history bytes to replay on hibernate wake.
|
||||
* Prefer the coherent full snapshot: SerializeAddon range slices omit a
|
||||
* trailing newline at the boundary, so concatenating scrollback+viewport can
|
||||
* merge the seam lines. Fall back to scrollback -> viewport when snapshot is
|
||||
* empty (still never viewport-first -- that evicts newest rows under a finite
|
||||
* scrollback cap; #2762).
|
||||
*/
|
||||
export function resolveHibernateWakeHistory(payload: TerminalHibernateWakePayload): string {
|
||||
if (payload.snapshot) return payload.snapshot;
|
||||
const viewport = payload.viewportSnapshot ?? "";
|
||||
const scrollback = payload.scrollbackSnapshot ?? "";
|
||||
if (!scrollback) return viewport;
|
||||
if (!viewport) return scrollback;
|
||||
if (/\r?\n$/.test(scrollback)) return `${scrollback}${viewport}`;
|
||||
return `${scrollback}\r\n${viewport}`;
|
||||
}
|
||||
|
||||
export async function applyHibernateWakeToTerminal(
|
||||
term: XTerm,
|
||||
@@ -310,10 +320,16 @@ export async function applyHibernateWakeToTerminal(
|
||||
options: ApplyHibernateWakeOptions = {},
|
||||
): Promise<void> {
|
||||
const replayOptions = options.replayOptions;
|
||||
const viewport = payload.viewportSnapshot ?? payload.snapshot;
|
||||
const scrollback = payload.scrollbackSnapshot ?? "";
|
||||
const history = resolveHibernateWakeHistory(payload);
|
||||
|
||||
await writeTerminalReplaySequence(term, [viewport, payload.pendingBuffer], replayOptions);
|
||||
// Chunked writes already yield to the event loop for large buffers. Do not
|
||||
// idle-append older scrollback after the viewport: under a finite xterm
|
||||
// scrollback cap that trims the just-restored newest rows (#2762).
|
||||
await writeTerminalReplaySequence(
|
||||
term,
|
||||
[history, payload.pendingBuffer],
|
||||
replayOptions,
|
||||
);
|
||||
|
||||
if (!options.deferWebgl) {
|
||||
runtime.ensureWebglRenderer();
|
||||
@@ -323,12 +339,6 @@ export async function applyHibernateWakeToTerminal(
|
||||
if (payload.alternateScreen) {
|
||||
refreshTerminalViewport(term);
|
||||
}
|
||||
|
||||
if (scrollback) {
|
||||
scheduleIdle(() => {
|
||||
void writeTerminalPayloadChunked(term, scrollback, replayOptions);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function nudgeAlternateScreenRedraw(term: XTerm): void {
|
||||
|
||||
@@ -49,8 +49,41 @@ export type WakeTerminalFromHibernateOptions = {
|
||||
runtimeContext: Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible" | "deferWebglUntilReplayComplete">;
|
||||
container: HTMLDivElement;
|
||||
getPayload: () => TerminalHibernateWakePayload;
|
||||
/** Stop hibernate IPC listeners before reading the final replay payload. */
|
||||
/**
|
||||
* Pause backend output and wait for in-flight chunks to drain into the
|
||||
* hibernate pending buffer before history replay. Returns true when the
|
||||
* drain succeeded (safe to detach the data listener). Returns false when
|
||||
* pause/drain was best-effort only; caller must keep a live uncapped
|
||||
* pending listener through history replay (#2762).
|
||||
*/
|
||||
prepareWakeFlow?: () => Promise<boolean>;
|
||||
/**
|
||||
* Atomically read and clear hibernate pending output after the data listener
|
||||
* has been stopped (and preferably after prepareWakeFlow).
|
||||
*/
|
||||
takePendingBuffer: () => string;
|
||||
/** Stop only the hibernate data listener so pending stops growing. */
|
||||
stopHibernateDataListener: () => void;
|
||||
/**
|
||||
* When prepareWakeFlow returns false, disable the 512 KiB pending cap so
|
||||
* ACKed bytes that arrive during history replay are not trimmed.
|
||||
*/
|
||||
setHibernatePendingCapDisabled?: (disabled: boolean) => void;
|
||||
/**
|
||||
* Stop hibernate data+exit listeners and clear flow-ack state.
|
||||
* Must keep the backend paused until resumeAfterReattach runs.
|
||||
*/
|
||||
stopHibernateListeners: () => void;
|
||||
/**
|
||||
* On a thrown/failed wake: dispose the partial xterm runtime and restore
|
||||
* hibernate listeners so output is ACKed again (never unpause without a
|
||||
* listener). `takenPending` is every byte already take-and-cleared during
|
||||
* this wake attempt; it must be restored because those bytes no longer
|
||||
* live in the pending ref and the partial xterm is disposed.
|
||||
*/
|
||||
restoreAfterFailedWake?: (takenPending: string) => void;
|
||||
/** Resume backend output after the live display listener is attached. */
|
||||
resumeAfterReattach?: () => void;
|
||||
reattachSession: (term: XTerm) => void;
|
||||
safeFit: (options?: { force?: boolean; requireVisible?: boolean }) => void;
|
||||
resizeSession: () => void;
|
||||
@@ -74,7 +107,13 @@ export async function wakeTerminalFromHibernate(
|
||||
runtimeContext,
|
||||
container,
|
||||
getPayload,
|
||||
prepareWakeFlow,
|
||||
takePendingBuffer,
|
||||
stopHibernateDataListener,
|
||||
setHibernatePendingCapDisabled,
|
||||
stopHibernateListeners,
|
||||
restoreAfterFailedWake,
|
||||
resumeAfterReattach,
|
||||
reattachSession,
|
||||
safeFit,
|
||||
resizeSession,
|
||||
@@ -120,79 +159,120 @@ export async function wakeTerminalFromHibernate(
|
||||
);
|
||||
|
||||
const term = runtime.term;
|
||||
const initialPayload = getPayload();
|
||||
const pendingAtApplyStart = initialPayload.pendingBuffer;
|
||||
const replayOptions = { chunkBytes: replayChunkBytes };
|
||||
// Pause first so in-flight output drains into pending via the still-live
|
||||
// hibernate listener, then detach that listener before capturing pending.
|
||||
// Full-history replay can take many frames; leaving the capped pending
|
||||
// buffer open (or leaving no display listener while flow is live) drops
|
||||
// ACKed bytes under sustained `cat` output (#2762).
|
||||
let didReattach = false;
|
||||
let wakeSucceeded = false;
|
||||
// Bytes take-and-cleared from the pending ref during this wake. On failure
|
||||
// they must be handed back: the partial xterm is disposed and the ref no
|
||||
// longer holds them.
|
||||
let takenPendingForRestore = "";
|
||||
const takeAndTrackPending = (): string => {
|
||||
const pending = takePendingBuffer();
|
||||
if (pending) takenPendingForRestore += pending;
|
||||
return pending;
|
||||
};
|
||||
try {
|
||||
const drainOk = (await prepareWakeFlow?.()) ?? true;
|
||||
if (drainOk) {
|
||||
stopHibernateDataListener();
|
||||
} else {
|
||||
// Drain timed out / unavailable: keep the hibernate data listener so
|
||||
// in-flight ACKed bytes still land in pending, but disable the 512 KiB
|
||||
// cap for the wake window so a busy `cat` cannot trim the front.
|
||||
setHibernatePendingCapDisabled?.(true);
|
||||
}
|
||||
const initialPayload = getPayload();
|
||||
const pendingAtApplyStart = takeAndTrackPending();
|
||||
const replayOptions = { chunkBytes: replayChunkBytes };
|
||||
let replayedPendingChars = pendingAtApplyStart.length;
|
||||
|
||||
await applyHibernateWakeToTerminal(term, runtime, initialPayload, {
|
||||
replayOptions,
|
||||
deferWebgl: true,
|
||||
});
|
||||
runtime.cursorLineHighlighter.refresh({ force: true });
|
||||
|
||||
let replayedPendingLength = pendingAtApplyStart.length;
|
||||
for (let drainPass = 0; drainPass < 16; drainPass += 1) {
|
||||
const pending = getPayload().pendingBuffer;
|
||||
if (pending.length <= replayedPendingLength) break;
|
||||
await appendTerminalReplayData(
|
||||
term,
|
||||
pending.slice(replayedPendingLength),
|
||||
await applyHibernateWakeToTerminal(term, runtime, {
|
||||
...initialPayload,
|
||||
pendingBuffer: pendingAtApplyStart,
|
||||
}, {
|
||||
replayOptions,
|
||||
);
|
||||
replayedPendingLength = pending.length;
|
||||
}
|
||||
const finalPending = getPayload().pendingBuffer;
|
||||
if (finalPending.length > replayedPendingLength) {
|
||||
await appendTerminalReplayData(
|
||||
term,
|
||||
finalPending.slice(replayedPendingLength),
|
||||
replayOptions,
|
||||
);
|
||||
replayedPendingLength = finalPending.length;
|
||||
}
|
||||
deferWebgl: true,
|
||||
});
|
||||
runtime.cursorLineHighlighter.refresh({ force: true });
|
||||
|
||||
stopHibernateListeners();
|
||||
const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);
|
||||
if (shouldReattach) {
|
||||
reattachSession(term);
|
||||
updateStatus("connected");
|
||||
}
|
||||
// Stop the data listener before draining residual pending. Captures:
|
||||
// - exit "[session closed]" tails appended during history replay
|
||||
// - uncapped live arrivals when drainOk was false
|
||||
// Only the exit listener can still append after this point, so a short
|
||||
// until-empty loop is enough; the last take before teardown must be empty
|
||||
// after any awaited replay.
|
||||
stopHibernateDataListener();
|
||||
setHibernatePendingCapDisabled?.(false);
|
||||
for (;;) {
|
||||
const pendingTail = takeAndTrackPending();
|
||||
if (!pendingTail) break;
|
||||
await appendTerminalReplayData(term, pendingTail, replayOptions);
|
||||
replayedPendingChars += pendingTail.length;
|
||||
}
|
||||
|
||||
runtime.ensureWebglRenderer();
|
||||
runtime.clearTextureAtlas();
|
||||
// Recompute after drains so an exit during those awaits cannot leave
|
||||
// shouldReattach stale (would reattach a dead session as connected).
|
||||
const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);
|
||||
stopHibernateListeners();
|
||||
if (shouldReattach) {
|
||||
reattachSession(term);
|
||||
updateStatus("connected");
|
||||
didReattach = true;
|
||||
}
|
||||
|
||||
safeFit({ force: true });
|
||||
resizeSession();
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
} else {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
runtime.ensureWebglRenderer();
|
||||
runtime.clearTextureAtlas();
|
||||
|
||||
window.setTimeout(() => safeFit({ force: true }), 0);
|
||||
window.setTimeout(() => {
|
||||
safeFit({ force: true });
|
||||
resizeSession();
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
} else {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
}, 100);
|
||||
window.setTimeout(() => {
|
||||
safeFit({ force: true });
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
}
|
||||
}, 350);
|
||||
|
||||
logger.info("[Terminal] Resumed from hibernate", {
|
||||
sessionId,
|
||||
snapshotChars: initialPayload.snapshot.length,
|
||||
viewportChars: initialPayload.viewportSnapshot?.length ?? initialPayload.snapshot.length,
|
||||
scrollbackChars: initialPayload.scrollbackSnapshot?.length ?? 0,
|
||||
pendingChars: replayedPendingLength,
|
||||
alternateScreen: initialPayload.alternateScreen,
|
||||
});
|
||||
return true;
|
||||
window.setTimeout(() => safeFit({ force: true }), 0);
|
||||
window.setTimeout(() => {
|
||||
safeFit({ force: true });
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
}
|
||||
}, 100);
|
||||
window.setTimeout(() => {
|
||||
safeFit({ force: true });
|
||||
forceSyncRenderAfterResize(term);
|
||||
if (initialPayload.alternateScreen) {
|
||||
nudgeAlternateScreenRedraw(term);
|
||||
}
|
||||
}, 350);
|
||||
|
||||
logger.info("[Terminal] Resumed from hibernate", {
|
||||
sessionId,
|
||||
snapshotChars: initialPayload.snapshot.length,
|
||||
viewportChars: initialPayload.viewportSnapshot?.length ?? initialPayload.snapshot.length,
|
||||
scrollbackChars: initialPayload.scrollbackSnapshot?.length ?? 0,
|
||||
pendingChars: replayedPendingChars,
|
||||
alternateScreen: initialPayload.alternateScreen,
|
||||
});
|
||||
wakeSucceeded = true;
|
||||
return true;
|
||||
} finally {
|
||||
setHibernatePendingCapDisabled?.(false);
|
||||
if (!wakeSucceeded) {
|
||||
// Dispose the partial runtime and restore hibernate listeners so a
|
||||
// failed wake never unpauses into a listener-less backlog gap, and so a
|
||||
// retry can re-enter wake (hasRuntimeRef stays false). Hand back every
|
||||
// take-and-cleared pending byte so it is not lost with the disposed term.
|
||||
restoreAfterFailedWake?.(takenPendingForRestore);
|
||||
} else if (didReattach) {
|
||||
resumeAfterReattach?.();
|
||||
}
|
||||
// Successful reconnect wakes (!didReattach) keep the pause until cleanupSession.
|
||||
}
|
||||
}
|
||||
@@ -160,11 +160,11 @@ export function shouldSkipHibernateForAltScreen(
|
||||
|
||||
/** Snapshot + buffered output to replay when recreating xterm after hibernate. */
|
||||
export type TerminalHibernateWakePayload = {
|
||||
/** Full serialized terminal state (legacy combined snapshot). */
|
||||
/** Full serialized terminal state -- preferred wake history (coherent ANSI). */
|
||||
snapshot: string;
|
||||
/** Visible viewport rows for fast first paint during wake. */
|
||||
/** Visible viewport rows; used with scrollback only when snapshot is empty. */
|
||||
viewportSnapshot?: string;
|
||||
/** Older scrollback rows replayed lazily after viewport + pending. */
|
||||
/** Older scrollback rows; used with viewport only when snapshot is empty. */
|
||||
scrollbackSnapshot?: string;
|
||||
pendingBuffer: string;
|
||||
/** True when the pane was hibernated while a full-screen app owned the alt buffer. */
|
||||
|
||||
Reference in new issue
Block a user