[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
284
components/terminal/terminalHibernateFlush.test.ts
Normal file
284
components/terminal/terminalHibernateFlush.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
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*\}/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user