Files
NetMesh/components/terminal/terminalHibernateFlush.test.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

285 lines
15 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const readFunctionBody = (source: string, marker: string): string => {
const start = source.indexOf(marker);
assert.notEqual(start, -1, `${marker} must exist`);
const bodyStart = source.indexOf("{", start);
assert.notEqual(bodyStart, -1, `${marker} must have a body`);
let depth = 0;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
if (char === "{") depth += 1;
if (char === "}") {
depth -= 1;
if (depth === 0) {
return source.slice(bodyStart, index + 1);
}
}
}
assert.fail(`${marker} body must close`);
};
test("full hibernate flushes pending hidden output before taking the snapshot", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const body = readFunctionBody(source, "const fullHibernateRuntime = useCallback(async (): Promise<boolean> =>");
const termCaptureIndex = body.indexOf("const term = termRef.current");
const clearHiddenIndex = body.indexOf("terminalHiddenRendererStore.clearSoftHidden(sessionId)");
const flushIndex = body.indexOf("flushPendingTerminalWritesBeforeHibernate(term)");
const retryIndex = body.indexOf("scheduleHibernateRetry()");
const alternateScreenSkipIndex = body.indexOf("shouldSkipHibernateForActiveAlternateScreen(term)");
const snapshotIndex = body.indexOf("serializeTerminalForHibernate(");
const releaseIndex = body.indexOf("releaseTerminalFlowBeforeHibernate(");
assert.notEqual(termCaptureIndex, -1, "hibernate must capture the active terminal once");
assert.notEqual(clearHiddenIndex, -1, "hibernate must clear hidden renderer state");
assert.notEqual(flushIndex, -1, "hibernate must flush pending terminal writes completely");
assert.notEqual(retryIndex, -1, "hibernate must retry when pending writes are still draining");
assert.notEqual(alternateScreenSkipIndex, -1, "hibernate must re-check alternate screen after draining output");
assert.notEqual(snapshotIndex, -1, "hibernate must serialize a terminal snapshot");
assert.notEqual(releaseIndex, -1, "hibernate must release flow after snapshot");
assert.ok(termCaptureIndex < flushIndex, "flush must use the captured terminal");
assert.ok(clearHiddenIndex < flushIndex, "clear hidden state before flushing pending writes");
assert.ok(flushIndex < retryIndex, "retry can only be scheduled after the drain attempt");
assert.ok(retryIndex < snapshotIndex, "retry branch must happen before snapshot");
assert.ok(flushIndex < alternateScreenSkipIndex, "alternate screen must be checked after pending writes are drained");
assert.ok(retryIndex < alternateScreenSkipIndex, "retry branch must run before the alternate-screen re-check");
assert.ok(alternateScreenSkipIndex < snapshotIndex, "alternate-screen skip must happen before snapshot");
assert.ok(flushIndex < snapshotIndex, "flush pending writes before snapshot");
assert.ok(snapshotIndex < releaseIndex, "release flow only after snapshot");
});
test("live context reads flush pending hidden output before reading the buffer", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const body = readFunctionBody(
source,
"const readTerminalContext = useCallback<TerminalContextReader>(async (request) =>",
);
const flushIndex = body.indexOf("await flushPendingTerminalWritesBeforeHibernate(targetTerm)");
const drainGuardIndex = body.indexOf("if (!flushed)");
const bufferReadIndex = body.indexOf("term.buffer.active");
assert.notEqual(flushIndex, -1, "context reads must flush pending terminal writes");
assert.notEqual(drainGuardIndex, -1, "context reads must reject an incomplete drain");
assert.notEqual(bufferReadIndex, -1, "context reads must inspect the live terminal buffer");
assert.ok(flushIndex < drainGuardIndex, "check the drain result after flushing");
assert.ok(flushIndex < bufferReadIndex, "flush pending writes before reading the live buffer");
});
test("hibernate retry preserves normal hibernate blockers", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const body = readFunctionBody(source, "const scheduleHibernateRetry = useCallback(() =>");
const searchBlockerIndex = body.indexOf("isSearchOpenRef.current");
const transferBlockerIndex = body.indexOf("hibernateFileTransferActiveRef.current");
const retryIndex = body.indexOf("fullHibernateRuntimeRef.current?.()");
assert.notEqual(searchBlockerIndex, -1, "retry must skip hibernate while search is open");
assert.notEqual(transferBlockerIndex, -1, "retry must skip hibernate while file transfer is active");
assert.notEqual(retryIndex, -1, "retry must be able to resume hibernation");
assert.ok(searchBlockerIndex < retryIndex, "search blocker must run before retrying hibernate");
assert.ok(transferBlockerIndex < retryIndex, "file-transfer blocker must run before retrying hibernate");
});
test("full hibernate rechecks live state after every asynchronous step", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const body = readFunctionBody(source, "const fullHibernateRuntime = useCallback(async (): Promise<boolean> =>");
const statusCaptureIndex = body.indexOf("const hibernateStatus = statusRef.current");
const flushIndex = body.indexOf("await flushPendingTerminalWritesBeforeHibernate(term)");
const afterFlushGuardIndex = body.indexOf("if (!canFinishHibernate()) return false;", flushIndex);
const serializeIndex = body.indexOf("await serializeTerminalForHibernate(");
const afterSerializeGuardIndex = body.indexOf("if (!canFinishHibernate()) return false;", serializeIndex);
const releaseIndex = body.indexOf("releaseTerminalFlowBeforeHibernate(");
assert.notEqual(statusCaptureIndex, -1, "hibernate must capture its starting lifecycle state");
assert.match(body, /!isVisibleRef\.current/);
assert.match(body, /hibernateEnabledRef\.current/);
assert.match(body, /termRef\.current === term/);
assert.match(body, /statusRef\.current === hibernateStatus/);
assert.match(body, /sessionRef\.current === backendId/);
assert.ok(statusCaptureIndex < flushIndex, "capture lifecycle state before the first await");
assert.ok(flushIndex < afterFlushGuardIndex, "visibility and settings must be rechecked after draining output");
assert.ok(afterFlushGuardIndex < serializeIndex, "the post-drain guard must run before serialization");
assert.ok(serializeIndex < afterSerializeGuardIndex, "visibility and settings must be rechecked after serialization");
assert.ok(afterSerializeGuardIndex < releaseIndex, "the final guard must run before releasing the live runtime");
});
test("ended hidden sessions release xterm without reopening dead backend listeners", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const fullHibernateBody = readFunctionBody(
source,
"const fullHibernateRuntime = useCallback(async (): Promise<boolean> =>",
);
const hibernateBody = readFunctionBody(source, "const hibernateRuntime = useCallback(() =>");
assert.match(
fullHibernateBody,
/canHibernateTerminalRuntimeSession\(hibernateStatus, backendId\)/,
);
assert.match(
fullHibernateBody,
/statusRef\.current === hibernateStatus\s*&& sessionRef\.current === backendId/,
);
assert.match(
fullHibernateBody,
/const connectedBackendId = hibernateStatus === "connected" \? backendId : null;\s*if \(connectedBackendId\) \{\s*releaseTerminalFlowBeforeHibernate\([\s\S]*?connectedBackendId\);\s*\}\s*disposeDataRef\.current\?\.\(\);[\s\S]*?disposeRuntimeOnly\(\);\s*if \(connectedBackendId\) \{\s*beginHibernatedSessionListeners\(connectedBackendId\);\s*\}/,
);
assert.match(
hibernateBody,
/const keepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettings\)\s*: 0;/,
);
});
test("reconnect preparation cancels an in-flight disconnected hibernate", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const fullHibernateBody = readFunctionBody(
source,
"const fullHibernateRuntime = useCallback(async (): Promise<boolean> =>",
);
const reconnectBody = readFunctionBody(
source,
'const startReconnect = async (mode: "manual" | "auto" = "manual") =>',
);
const startNewSessionBody = readFunctionBody(
source,
"const startNewSession = () =>",
);
const updateStatusBody = readFunctionBody(
source,
'const updateStatus = useCallback((next: TerminalSession["status"]) =>',
);
assert.match(
fullHibernateBody,
/reconnectPreparationTokenRef\.current === null/,
);
const claimIndex = reconnectBody.indexOf(
"reconnectPreparationTokenRef.current = retryToken",
);
const cleanupIndex = reconnectBody.indexOf("await cleanupSession({ retainOwnership: true })");
const startNewSessionIndex = reconnectBody.indexOf("const startNewSession = () =>");
assert.ok(claimIndex >= 0 && claimIndex < cleanupIndex);
assert.doesNotMatch(
reconnectBody.slice(cleanupIndex, startNewSessionIndex),
/reconnectPreparationTokenRef\.current = null/,
);
assert.match(
startNewSessionBody,
/if \(!retryStillActive\(\)\) \{\s*finishReconnectPreparation\(\);\s*return;\s*\}\s*finishReconnectPreparation\(\);/,
);
assert.match(
reconnectBody,
/term\.write\('\\x1b\[\?1049l', \(\) => \{\s*if \(!retryStillActive\(\)\) \{\s*finishReconnectPreparation\(\);\s*return;/,
);
assert.match(
updateStatusBody,
/statusRef\.current = next;\s*setStatus\(next\);/,
);
});
test("ended soft-hidden and alternate-screen sessions can fully hibernate", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const fullHibernateBody = readFunctionBody(
source,
"const fullHibernateRuntime = useCallback(async (): Promise<boolean> =>",
);
const hibernateBody = readFunctionBody(
source,
"const hibernateRuntime = useCallback(() =>",
);
const alternateSkipBody = readFunctionBody(
source,
"const shouldSkipHibernateForActiveAlternateScreen = useCallback((term: XTerm): boolean =>",
);
assert.match(
fullHibernateBody,
/softHiddenRef\.current && statusRef\.current !== "disconnected"/,
);
assert.match(
fullHibernateBody,
/!softHiddenRef\.current \|\| hibernateStatus === "disconnected"/,
);
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
);
assert.match(
alternateSkipBody,
/if \(statusRef\.current !== "connected"\) return false;/,
);
});
test("a cancelled soft-hidden upgrade resumes its renderer", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const hibernateBody = readFunctionBody(source, "const hibernateRuntime = useCallback(() =>");
const upgradeBody = readFunctionBody(source, "const upgradeSoftHiddenRuntimeToHibernate = useCallback(() =>");
const helperBody = readFunctionBody(source, "const resumeRendererAfterCancelledHibernateUpgrade = useCallback(() =>");
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
);
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
const cancelResumeIndex = upgradeBody.indexOf("resumeRendererAfterCancelledHibernateUpgradeRef.current?.()", upgradeIndex);
assert.notEqual(wakeIndex, -1, "every soft-hidden upgrade must resume the renderer before upgrading");
assert.notEqual(upgradeIndex, -1, "soft-hidden upgrades must await the full hibernate result");
assert.ok(wakeIndex < upgradeIndex, "the renderer must be live throughout the asynchronous upgrade");
assert.notEqual(cancelResumeIndex, -1, "a cancelled upgrade must resume the renderer again");
assert.match(helperBody, /ensureWebglRenderer\(\)/);
assert.match(helperBody, /clearTextureAtlas\(\)/);
assert.match(helperBody, /safeFitRef\.current\(\{ force: true \}\)/);
});
test("snapshot and handoff paths reject output that misses the settle deadline", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const drainWarningIndex = source.indexOf("Terminal output drain did not settle before the deadline");
const drainReturnIndex = source.indexOf("return;", drainWarningIndex);
const drainResponseIndex = source.indexOf("respondTerminalOutputDrain", drainReturnIndex);
assert.ok(drainWarningIndex >= 0 && drainWarningIndex < drainReturnIndex);
assert.ok(drainReturnIndex < drainResponseIndex);
const snapshotWarningIndex = source.indexOf("Terminal snapshot drain did not settle before the deadline");
const snapshotReturnIndex = source.indexOf("return;", snapshotWarningIndex);
const snapshotSerializeIndex = source.indexOf("serializeAddonRef.current.serialize", snapshotReturnIndex);
assert.ok(snapshotWarningIndex >= 0 && snapshotWarningIndex < snapshotReturnIndex);
assert.ok(snapshotReturnIndex < snapshotSerializeIndex);
const applyHandlerIndex = source.indexOf("onTerminalSessionApplySnapshot");
const applyDrainIndex = source.indexOf("flushPendingTerminalWritesBeforeHibernate(term)", applyHandlerIndex);
const applyFailureIndex = source.indexOf("Terminal output did not settle before applying the snapshot", applyDrainIndex);
const applyMetadataIndex = source.indexOf("setKittyKeyboardProtocolEnabled", applyHandlerIndex);
const applyResetIndex = source.indexOf("term.reset()", applyHandlerIndex);
const applySnapshotWriteIndex = source.indexOf("term.write(payload.snapshot, resolve)", applyResetIndex);
const applyCursorHighlightRefreshIndex = source.indexOf(
"cursorLineHighlighter.refresh({ force: true })",
applyResetIndex,
);
const applyScrollIndex = source.indexOf("term.scrollToBottom?.()", applyResetIndex);
assert.ok(applyHandlerIndex >= 0 && applyHandlerIndex < applyDrainIndex);
assert.ok(applyDrainIndex < applyFailureIndex);
assert.ok(applyFailureIndex < applyMetadataIndex, "failed drains must not mutate snapshot metadata");
assert.ok(applyMetadataIndex < applyResetIndex);
assert.ok(applyResetIndex < applySnapshotWriteIndex);
assert.ok(
applySnapshotWriteIndex < applyCursorHighlightRefreshIndex,
"cursor-line highlighting must refresh after applying an optional snapshot",
);
assert.ok(applyCursorHighlightRefreshIndex < applyScrollIndex);
assert.match(
source,
/const flushed = await flushPendingTerminalWritesBeforeHibernate\(snapshotTerm\);\s*if \(!flushed\) \{\s*throw new Error\("Terminal output did not settle before closing the attached display"\);\s*\}/,
);
});