Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 45b8778112 fix(terminal): drain hibernate pending until empty before teardown
After history replay, loop take-and-append until an empty take so an exit
or residual chunk that arrives during an awaited replay cannot be dropped
by stopHibernateListeners. Data listener is already stopped, so the loop
stays short in practice.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:20:49 +00:00
Cursor Agentand陈大猫 8b36bce69e fix(terminal): restore take-and-cleared pending on failed hibernate wake
Track every pending take during wake and pass it to restoreAfterFailedWake
so a thrown replay/reattach cannot permanently drop already-buffered output
with the disposed partial xterm. Also take once more before exit-listener
teardown.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:05:09 +00:00
Cursor Agentand陈大猫 ebce76bf53 fix(terminal): restore hibernate listeners on failed wake
On wake failure, dispose the partial xterm runtime and reinstall hibernate
listeners (preserving pending) instead of unpausing into a listener-less
backlog gap. Successful reconnect wakes still keep the pause until
cleanupSession. Add a final pending take before exit-listener teardown.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:25:18 +00:00
Cursor Agentand陈大猫 1ce5b83367 fix(terminal): pause reconnect hibernate wakes and recheck exit after late drain
Always pause when a backend session exists (including sessionConnected=false
reconnect wakes). Drain late pending plus an exit-tail take before deciding
reattach. Resume flow only after reattach, or on wake failure; successful
reconnect wakes stay paused until cleanupSession.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:18:52 +00:00
Cursor Agentand陈大猫 441cd1be25 fix(terminal): harden hibernate wake against drain failure and exit tails
Check setSessionFlowPausedAndWait success. On drain failure keep the
hibernate data listener with the pending cap disabled for the wake window.
After history replay, take pending once more so exit "[session closed]"
tails and uncapped live arrivals are not discarded.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:13:06 +00:00
Cursor Agentand陈大猫 78dba35ee6 fix(terminal): pause backend flow across hibernate history replay
Full-history wake no longer races the 512 KiB pending cap or the 64 KiB
preload backlog. Pause-and-wait drains into pending, stop the data
listener, take pending once, replay, reattach, then resume. Removes the
multi-pass drain that still dropped ACKed bytes under sustained output.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:07:23 +00:00
Cursor Agentand陈大猫 5e1c1135c7 fix(terminal): keep hibernate exit listener through final pending replay
Stop only the data listener before the final pending drain so pending
stops growing, but leave the exit listener registered until after the
reattach decision so an exit during that await is not missed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:53:28 +00:00
Cursor Agentand陈大猫 40b42e9e82 fix(terminal): stop hibernate listeners before final pending drain
Awaiting the final pending write while listeners are still live can
lose already-acked bytes that arrive during that write. Stop listeners
first, then take-and-replay any remainder.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:42:41 +00:00
Cursor Agentand陈大猫 fb79963a53 fix(terminal): final pending drain before hibernate listener teardown
After the bounded take-and-clear loop, take any remaining pending once
more before stopHibernateListeners so wake-time output is not dropped.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:30:48 +00:00
Cursor Agentand陈大猫 033e86f94d fix(terminal): drain hibernate pending via take-and-clear on wake
Full-history replay widens the window where capped pending output can
grow without increasing length. Length-based slice drains then skip new
bytes. Capture and clear pending before replay, then drain pure deltas.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:20:08 +00:00
Cursor Agentand陈大猫 caaf79648c style(terminal): keep hibernate wake comments ASCII-only
Address Codex P3: replace em dash / arrow punctuation in touched
comments per AGENTS.md ASCII convention.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:13:28 +00:00
Cursor Agentand陈大猫 bdeeb3c003 fix(terminal): prefer full hibernate snapshot on wake
SerializeAddon range slices omit the seam newline, so concatenating
scrollback+viewport can merge boundary lines. Prefer the coherent full
snapshot for wake history, with a scrollback-then-viewport fallback, and
cover the seam with a real SerializeAddon regression.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:11:10 +00:00
Cursor Agentand陈大猫 0bde7b8bed fix(terminal): restore hibernate scrollback before viewport
Hibernate wake was writing the viewport first and appending older
scrollback on idle. Under a finite xterm scrollback cap that trims the
just-restored newest rows, so switching back to a hibernated tab could
permanently drop the end of large output (#2762). Replay scrollback →
viewport → pending on the wake path while keeping chunked writes.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:02:57 +00:00
5 changed files with 553 additions and 119 deletions

No files matched your search

+81 -13
View File
@@ -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) {
+299 -23
View File
@@ -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/,
);
});
+26 -16
View File
@@ -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 {
+144 -64
View File
@@ -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.
}
}
+3 -3
View File
@@ -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. */