Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 d746530079 fix(terminal): resync side-panel refs after queued external writes
A pending tool switch stored an intermediate focused-tool map in the ref.
An urgent open on another tab then updated only state, and the identity
guard never copied that tab back, so a later click on the old tool was
ignored. Committed entries now catch up without dropping a write that is
still ahead of the last render.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 11:49:16 +00:00
16 changed files with 18 additions and 977 deletions

No files matched your search

@@ -80,76 +80,6 @@ test("hibernate failure after the soft-hidden marker drops does not retain the l
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
});
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
let keepCount = 0;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 2;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["soft-hide"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
let keepCount = 2;
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 0;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
});
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
const sessions = ["victim", "other", "leaving"];
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
terminalHiddenRendererStore.markSoftHidden("other");
let keepCount = 2;
const outcomes: string[] = [];
try {
await retainLeavingTabAfterEviction({
sessionId: "leaving",
candidates: ["victim"],
keepCount,
resolveKeepCount: () => keepCount,
stillWantsRetention: () => true,
softHide: () => outcomes.push("soft-hide"),
fullHibernate: () => outcomes.push("full-hibernate"),
evict: async () => {
keepCount = 1;
await Promise.resolve();
return true;
},
});
assert.deepEqual(outcomes, ["full-hibernate"]);
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
} finally {
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
}
});
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
const outcomes: string[] = [];
await retainLeavingTabAfterEviction({
+1 -9
View File
@@ -11,14 +11,7 @@ import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
export async function retainLeavingTabAfterEviction(input: {
sessionId: string;
candidates: readonly string[];
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
keepCount: number;
/**
* Live keep-count read after eviction settles. Settings can change while the
* victim drains or serializes; retaining against the captured count would
* soft-hide a tab the user no longer wants kept.
*/
resolveKeepCount?: () => number;
stillWantsRetention: () => boolean;
softHide: () => void;
fullHibernate: () => void;
@@ -38,10 +31,9 @@ export async function retainLeavingTabAfterEviction(input: {
}
}
if (!input.stillWantsRetention()) return;
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
if (
completed
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, input.keepCount)
) {
input.softHide();
return;
+1 -15
View File
@@ -2355,14 +2355,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Disconnected sessions never stay soft-hidden. A connected session whose
// keep count dropped to zero takes the same upgrade. A positive count stays
// retained until a later leave evicts overflow.
if (softHiddenRef.current) {
const retainedKeepCount = statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0;
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
if (statusRef.current === "disconnected") {
upgradeSoftHiddenRuntimeToHibernate();
}
return;
@@ -2393,13 +2387,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
candidates: keepDecision.candidates,
keepCount,
// Re-read after the victim drain. The count above was captured when
// this timer fired; a zero or tighter setting must full-hibernate.
resolveKeepCount: () => (
statusRef.current === "connected"
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
: 0
),
stillWantsRetention: () => (
hibernateEvictionAttemptRef.current === attempt
&& !isVisibleRef.current
@@ -4769,7 +4756,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSearchOpen,
hibernateEnabled: hibernateEnabled,
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
fileTransferActive: hibernateFileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -348,62 +348,6 @@ test("startSerial does not arm the fallback when auto-login is cancelled before
assert.deepEqual(writtenCommands, []);
});
test("restored serial attach ignores a startup command hydrated while the port open is pending", async () => {
const writtenCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let releaseOpen: (() => void) | undefined;
const openPending = new Promise<void>((resolve) => {
releaseOpen = resolve;
});
const bootHost = {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial" as const,
username: "admin",
password: "secret",
};
const hostRef = { current: bootHost };
const backend = buildBackend({
startSerialSession: async () => {
await openPending;
return "serial-session";
},
onTelnetAutoLoginComplete: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});
const ctx = buildCtx(backend, {
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
});
const started = createTerminalSessionStarters(ctx as never).startSerial(term as never);
// Vault hydration can replace the fallback host after preflight has already
// decided there is no command to hold for auto-login.
hostRef.current = { ...bootHost, startupCommand: "show version" };
releaseOpen?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
autoLoginComplete?.({ sessionId: "session-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(autoLoginComplete, null);
assert.equal(ctx.hasRunStartupCommandRef.current, false);
assert.deepEqual(writtenCommands, []);
});
for (const runMode of ['paste', 'lineDelay', 'noAutoRun']) {
test(`serial reconnect discards the previous delayed startup command (${runMode})`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -3653,207 +3653,6 @@ test("restored local reconnect runs the host startup command while automatic ret
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach runs the live host startup command when the boot snapshot predates vault hydration", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
// Session restore mounts the pane before the vault host (and group defaults)
// replace the fallback snapshot. The dial can still succeed from that
// snapshot; the startup command is only known on the effective host by the
// time the shell is attached.
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const effectiveHost = {
...bootHost,
startupCommand: "echo restored-startup",
startupCommandRunMode: "paste" as const,
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = effectiveHost;
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
sessionWrites.length = 0;
hostRef.current = effectiveHost;
const automaticCtx = createStarterContext({
...ctx,
sessionRef: { current: null },
hasRunStartupCommandRef: { current: false },
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("automatic"),
},
});
await createTerminalSessionStarters(automaticCtx as never).startSSH(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, []);
});
test("restored SSH attach ignores a live startup command for a different dial target", async () => {
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const mismatches = [
{ label: "hostname", patch: { hostname: "moved.example.test" } },
{ label: "port", patch: { port: 2222 } },
{ label: "username", patch: { username: "bob" } },
{ label: "protocol", patch: { protocol: "telnet" as const } },
];
for (const { label, patch } of mismatches) {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
...patch,
startupCommand: "echo moved-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(ctx.hasRunStartupCommandRef.current, false, label);
assert.deepEqual(sessionWrites, [], label);
}
});
test("restored SSH attach still runs a hydrated startup command when only the default port is materialized", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const bootHost = {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
port: 22,
protocol: "ssh" as const,
password: "secret",
};
const hostRef = { current: bootHost };
let releaseDial: (() => void) | undefined;
const dialStarted = new Promise<void>((resolve) => {
releaseDial = resolve;
});
const terminalBackend = {
backendAvailable: () => true,
startSSHSession: async () => {
await dialStarted;
return "ssh-session";
},
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
closeSession: noop,
};
const ctx = createStarterContext({
host: bootHost,
hostRef,
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: undefined,
suppressHostStartupCommandRef: {
current: shouldSuppressHostStartupCommandOnReconnect("restored"),
},
promptLineBreakStateRef: undefined,
});
const started = createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
hostRef.current = {
...bootHost,
port: undefined,
startupCommand: "echo restored-startup",
};
releaseDial?.();
await started;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "ssh-session", data: "echo restored-startup\r", automated: true },
]);
});
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
@@ -3,56 +3,6 @@ import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
/**
* Host startup is scheduled only after the shell attaches. Restored SSH panes
* boot from the session fallback first; vault hydration (including group
* defaults) replaces `hostRef` while that dial is still in flight. `startSSH`
* keeps dialing the boot snapshot (`ctx.host`). The startup command may come
* from the live host only when that object is still this dial: same id,
* hostname, username, and dial port (`port || 22`), and the same protocol
* when the live host sets one. A missing protocol is a legacy SSH host.
* A saved host edited onto another endpoint since the workspace was saved
* keeps the snapshot, so its command is not typed into the old session.
*
* Serial preflight uses this same resolver to decide whether to wait for
* auto-login, then schedules again after the port opens. Non-SSH transports
* stay on the snapshot, so a command that appears on `hostRef` during the
* open is not typed into a saved username/password exchange.
* A ref that only carries a write-path toggle (no id) is not a host snapshot.
* A different host id is a later pane, not this dial, so keep the snapshot.
*/
const prefersLiveStartupHost = (protocol: string | undefined): boolean =>
protocol === "ssh" || protocol === undefined;
const sshDialPort = (port: number | undefined): number => port || 22;
const sameSshDialTarget = (
dial: TerminalSessionStartersContext["host"],
live: TerminalSessionStartersContext["host"],
): boolean => {
if (live.hostname !== dial.hostname) return false;
if ((live.username ?? "") !== (dial.username ?? "")) return false;
if (sshDialPort(live.port) !== sshDialPort(dial.port)) return false;
// A live protocol, when set, must still be the protocol this dial used.
if (live.protocol !== undefined && live.protocol !== (dial.protocol || "ssh")) return false;
return true;
};
const resolveStartupHost = (
ctx: TerminalSessionStartersContext,
): TerminalSessionStartersContext["host"] => {
const live = ctx.hostRef?.current;
if (
prefersLiveStartupHost(ctx.host.protocol)
&& live
&& live.id === ctx.host.id
&& sameSshDialTarget(ctx.host, live)
) {
return live;
}
return ctx.host;
};
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
@@ -88,7 +38,7 @@ export const resolveStartupCommand = (
ctx: TerminalSessionStartersContext,
options?: { consumeSuppressHostStartupCommand?: boolean },
): string | undefined => {
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : resolveStartupHost(ctx).startupCommand);
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
ctx.suppressHostStartupCommandRef.current = false;
}
@@ -146,7 +96,7 @@ export const scheduleStartupCommand = (
const runMode = ctx.startupCommand
? (ctx.multiLineRunMode ?? "paste")
: (resolveStartupHost(ctx).startupCommandRunMode ?? "paste");
: (ctx.host.startupCommandRunMode ?? "paste");
if (runMode === "paste") {
timeoutId = setTimeout(() => {
if (cancelled) return;
@@ -211,7 +211,7 @@ test("ended soft-hidden and alternate-screen sessions can fully hibernate", () =
);
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;/,
);
assert.match(
alternateSkipBody,
@@ -227,7 +227,7 @@ test("a cancelled soft-hidden upgrade resumes its renderer", () => {
assert.match(
hibernateBody,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);/,
);
const wakeIndex = upgradeBody.indexOf("wakeSoftHiddenRuntimeRef.current?.()");
const upgradeIndex = upgradeBody.indexOf("fullHibernateRuntime().then(");
@@ -311,15 +311,10 @@ test("a full keep count soft-hides the leaving session instead of snapshotting i
assert.match(body, /getSoftHiddenOccupancy\(\)/);
assert.match(body, /stillWantsRetention:/);
assert.match(body, /softHide: \(\) => hideRuntimeOnly\(\)/);
assert.match(
body,
/resolveKeepCount: \(\) => \(\s*statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0\s*\)/,
"retention after eviction must read the live keep count, not the captured one",
);
assert.doesNotMatch(body, /resolveHiddenTabEviction/);
assert.doesNotMatch(body, /requestEviction\(keepDecision/);
assert.match(
body,
/if \(softHiddenRef\.current\) \{\s*const retainedKeepCount = statusRef\.current === "connected"\s*\? resolveHibernateKeepRendererCount\(terminalSettingsRef\.current\)\s*: 0;\s*if \(statusRef\.current === "disconnected" \|\| !\(retainedKeepCount > 0\)\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
/if \(softHiddenRef\.current\) \{\s*if \(statusRef\.current === "disconnected"\) \{\s*upgradeSoftHiddenRuntimeToHibernate\(\);\s*\}\s*return;\s*\}/,
);
});
@@ -20,8 +20,6 @@ type UseTerminalHibernateEffectOptions = {
isSearchOpen: boolean;
hibernateEnabled: boolean;
hibernateDelayMs: number;
/** Current retained-renderer limit. Zero rechecks soft-hidden tabs immediately. */
hibernateKeepRendererCount?: number;
fileTransferActive: boolean;
hibernatedRef: React.MutableRefObject<boolean>;
softHiddenRef: React.MutableRefObject<boolean>;
@@ -51,7 +49,6 @@ export function useTerminalHibernateEffect({
isSearchOpen,
hibernateEnabled,
hibernateDelayMs,
hibernateKeepRendererCount,
fileTransferActive,
hibernatedRef,
softHiddenRef,
@@ -213,27 +210,4 @@ export function useTerminalHibernateEffect({
softHiddenRef,
status,
]);
// Separate from the visibility timer so a keep-count edit does not clear an
// in-flight leave. Already soft-hidden tabs have no timer left to notice a
// later zero; this one re-enters hibernate without stacking a second snapshot
// when StrictMode runs the effect twice.
useEffect(() => {
if (!hibernateEnabled || hibernateKeepRendererCount !== 0) return;
if (!softHiddenRef.current) return;
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
const timer = window.setTimeout(() => {
if (resolvePaneVisible(sessionId, isVisibleRef.current)) return;
if (!softHiddenRef.current) return;
onHibernateRef.current();
}, 0);
return () => window.clearTimeout(timer);
}, [
hibernateEnabled,
hibernateKeepRendererCount,
isVisible,
isVisibleRef,
sessionId,
softHiddenRef,
]);
}
@@ -58,90 +58,6 @@ test("soft-hidden wake keeps its marker until the runtime has resumed", () => {
assert.doesNotMatch(softWakeBranch, /softHiddenRef\.current\s*=\s*false/);
});
test("a keep-count of zero rechecks a soft-hidden tab while hibernate stays enabled", async (t) => {
const sessionId = "keep-count-zero-recheck";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const previousActEnvironment = Object.getOwnPropertyDescriptor(
globalThis,
"IS_REACT_ACT_ENVIRONMENT",
);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { setTimeout, clearTimeout },
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
t.after(() => {
removePaneVisible(sessionId);
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
if (previousActEnvironment) {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
} else {
Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
}
});
let hibernateCalls = 0;
let renderer: ReactTestRenderer | null = null;
const softHiddenRef = { current: true };
const ref = <T>(current: T) => ({ current });
function Probe({ keepCount }: { keepCount: number }) {
useTerminalHibernateEffect({
sessionId,
isVisible: false,
isVisibleRef: ref(false),
getSessionConnectedRef: ref(() => true),
status: "connected",
isSearchOpen: false,
hibernateEnabled: true,
hibernateDelayMs: 60_000,
hibernateKeepRendererCount: keepCount,
fileTransferActive: false,
hibernatedRef: ref(false),
softHiddenRef,
hibernatePendingBufferRef: ref(""),
hibernateSnapshotRef: ref(""),
hibernateViewportSnapshotRef: ref(""),
hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""),
hibernateContextViewportSnapshotRef: ref(""),
hibernateContextScrollbackSnapshotRef: ref(""),
hibernateAlternateScreenRef: ref(false),
hasRuntimeRef: ref(true),
onHibernate: () => { hibernateCalls += 1; },
onSoftHideWake: () => {},
onWake: () => true,
});
return null;
}
const element = (keepCount: number) => React.createElement(
React.StrictMode,
null,
React.createElement(Probe, { keepCount }),
);
await act(async () => { renderer = create(element(2)); });
assert.equal(hibernateCalls, 0, "a positive keep count must leave the retained runtime alone");
await act(async () => { renderer!.update(element(1)); });
assert.equal(hibernateCalls, 0, "a still-positive keep count must not drop every retained runtime");
await act(async () => {
renderer!.update(element(0));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(hibernateCalls, 1, "zero retention must re-enter hibernate for a soft-hidden tab");
await act(async () => renderer!.unmount());
});
test("hidden disconnected remote terminals release their retained runtime", async (t) => {
const sessionId = "disconnected-hidden-hibernate-test";
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
-3
View File
@@ -195,9 +195,6 @@ module.exports = {
'node_modules/@cursor/sdk-*/**/*',
'node_modules/sqlite3/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',
// External MCP scripts run from asarUnpack via ELECTRON_RUN_AS_NODE and
// must resolve re2js from a real directory (issue #3910).
'node_modules/re2js/**/*',
'lib/**/*.cjs',
'lib/**/*.json',
'node_modules/zod/**/*',
+1 -26
View File
@@ -73,11 +73,6 @@ function normalizePassword(password) {
return typeof password === "string" ? password : "";
}
// Bare CR/LF used to wake a silent serial console before "login:" appears.
function isAutoLoginWakeInput(data) {
return typeof data === "string" && /^[\r\n]+$/.test(data);
}
function createTelnetAutoLogin(options = {}) {
const username = normalizeUsername(options.username);
const password = normalizePassword(options.password);
@@ -212,26 +207,7 @@ function createTelnetAutoLogin(options = {}) {
notifyIncomplete();
}
},
// Silent serial consoles need a bare CR/LF to wake getty before "login:"
// appears. While the detector is still active that wake is not a
// takeover: keep autofill armed, leave the caller's write on the port,
// and do not consume the one-shot continue-prompt response. A later
// "Press RETURN to continue" must still be answered. Once the detector
// is disabled (expiry, completion, or a prior cancel), the same Enter
// notifies like any other user input so callers can drop a deferred
// startup command. Any other user input still cancels.
handleUserInput(data) {
const wakeOnly = isAutoLoginWakeInput(data);
if (
wakeOnly
&& !disabled
&& !sentUsername
&& !sentPassword
&& !isUsernamePrompt(tail)
&& !isPasswordPrompt(tail)
) {
return;
}
handleUserInput() {
disabled = true;
clearExpiryTimer();
notifyUserInput();
@@ -248,7 +224,6 @@ function createTelnetAutoLogin(options = {}) {
module.exports = {
createTelnetAutoLogin,
isAutoLoginWakeInput,
isCommandPrompt,
isContinuePrompt,
isPasswordPrompt,
-149
View File
@@ -224,155 +224,6 @@ test("telnet auto-login stops when the user starts typing manually", () => {
assert.equal(cancelCount, 1);
});
test("telnet auto-login stays armed when a wake-only enter arrives before any login prompt", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// Silent serial consoles need a bare CR to wake getty before "login:" appears.
autoLogin.handleUserInput("\r");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login treats a bare newline as a wake and still autofills", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("\n");
autoLogin.handleUserInput("\r\n");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login notifies when a wake enter arrives after expiry", () => {
let cancelCount = 0;
let timerFn = null;
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref() {} };
},
clearTimeout: () => {},
});
// Quiet device: the 60s window elapses with no prompt, which disables the
// detector without a user-input notification. Serial startup still has its
// 65s fallback armed until this cancellation arrives.
timerFn();
assert.equal(cancelCount, 0);
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still answers a continue prompt after a blind wake enter", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// The user's Enter woke a silent console. It must not consume the
// detector's one-shot response when the device then asks to continue.
autoLogin.handleUserInput("\r");
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("\r\nlogin: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
});
test("telnet auto-login still cancels when a normal character arrives before login", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("x");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still cancels an enter after credentials have started", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, ["admin\r"]);
});
test("telnet auto-login still cancels an enter when a login prompt is already in the tail", () => {
let clock = 0;
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
now: () => clock,
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
});
// After the window, the prompt is recorded but credentials are not sent.
clock = 60_001;
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login avoids common non-prompt login text", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
+7 -27
View File
@@ -63,7 +63,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
@@ -1713,9 +1713,8 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
(session.type === 'telnet-native' || session.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
&& !payload.autoLoginInputHandled
) {
session.autoLogin?.handleUserInput(data);
session.autoLogin?.handleUserInput();
}
// Encode keystrokes with the SAME charset the output path decodes with so
@@ -1806,8 +1805,6 @@ function writeToSessionWithInterception(
paste = null,
index = 0,
) {
// Stays the original payload until ingress classification marks this write.
let sessionWritePayload = payload;
const writeWithReceipt = (nextData) => {
if (paste && !paste.active) return;
const current = sessions.get(payload.sessionId);
@@ -1820,7 +1817,7 @@ function writeToSessionWithInterception(
paste.finish(index, true);
return;
}
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
const written = writeToSessionNow(payload, nextData, logRewrite);
if (paste) paste.finish(written ? index : undefined);
};
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
@@ -1833,24 +1830,20 @@ function writeToSessionWithInterception(
writeWithReceipt(data);
return;
}
// Classify auto-login at input ingress, before the asynchronous
// Cancel the auto-login detector at input ingress, before the asynchronous
// interception pipeline: a slow interceptor would otherwise leave the
// detector armed while the user's keystrokes are queued, letting a login
// prompt that arrives during that wait trigger a saved-credential
// transmission after the user has already taken over. Same guard
// conditions as writeToSessionNow. Wake-only CR/LF is not idempotent once
// credentials start, so mark this write and do not classify those bytes
// again when they reach writeToSessionNow. That preserved classification
// describes the queued bytes only while they stay wake-only; writeIfCurrent
// classifies an interceptor rewrite that is no longer wake-only.
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
// later call there stays a no-op.
if (
expectedSession
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
expectedSession.autoLogin?.handleUserInput(data);
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
expectedSession.autoLogin?.handleUserInput();
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
@@ -1858,19 +1851,6 @@ function writeToSessionWithInterception(
paste?.finish();
return;
}
// An Enter-to-command macro can turn the wake CR/LF classified above
// into ordinary text. Classify that transformed value before it is
// written. autoLoginInputHandled stays set so writeToSessionNow does not
// classify the same write again. A rewrite that is still only CR/LF keeps
// the ingress classification: those bytes are not idempotent once
// credentials have started.
if (
sessionWritePayload.autoLoginInputHandled
&& !isAutoLoginWakeInput(nextData)
&& !isTerminalReportSequence(nextData)
) {
current.autoLogin?.handleUserInput(nextData);
}
writeWithReceipt(nextData);
};
const write = async () => {
@@ -1,32 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
function createSerialAutoLoginSession() {
const writes = [];
const events = [];
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: {
write: (data) => {
writes.push(typeof data === "string" ? data : Buffer.from(data).toString());
return true;
},
},
};
session.autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => {
session.serialPort.write(data);
},
onUserInput: () => { events.push("cancelled"); },
});
return { session, writes, events };
}
test("serial auto-login cancels on interactive user input but not automated writes", () => {
let userInputs = 0;
@@ -87,221 +61,6 @@ test("serial line-mode buffered input notification cancels auto-login", () => {
}
});
test("serial wake-only enter keeps auto-login armed and still reaches the port", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial character input before login still cancels auto-login", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["x"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake-only enter stays armed when an input interceptor is pending", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorResolved = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (sessionId, data) => {
await new Promise((resolve) => setTimeout(resolve, 20));
interceptorResolved = true;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\n" });
assert.deepEqual(events, []);
assert.equal(interceptorResolved, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(interceptorResolved, true);
assert.deepEqual(writes, ["\n"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\n", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake enter is not cancelled when login arrives during interceptor delay", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
await new Promise((resolve) => setTimeout(resolve, 30));
await interceptorGate;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Prompt arrives while the wake byte is still queued in the interceptor.
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into text cancels auto-login", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
assert.equal(data, "\r");
await new Promise((resolve) => setTimeout(resolve, 20));
// Enter-to-command macro: the bare wake CR is no longer what is written.
return "uptime\r";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Ingress still sees a wake, so autofill stays armed until the rewrite lands.
assert.deepEqual(events, []);
assert.deepEqual(writes, []);
const deadline = Date.now() + 500;
while (writes.length < 1 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into another wake still autofills", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
assert.equal(data, "\r");
await interceptorGate;
return "\r\n";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial line-mode notification still cancels auto-login without keystroke data", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, []);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial sessions without auto-login credentials accept input normally", () => {
const writes = [];
const session = {
+3 -3
View File
@@ -1,14 +1,14 @@
{
version = "1.1.86";
version = "1.1.85";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "sha256-RE9aE79gEBFE2HnwJ3BIDA4vJoEhvx2T1RP7relNXtI=";
hash = "sha256-tiFQ/IGprMO9rc6nB6JWCxetdgBmGD+Zn6wKHPR8v6I=";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "sha256-186ZW8LUDUJKLQAnz+WSE40tY7Rw+kctU+5iddgvrpM=";
hash = "sha256-LK/evHTu4wEu9TR/ULN1YDCEYlaoqVNIZ0gEhYDAL/s=";
};
};
}
-7
View File
@@ -128,13 +128,6 @@ test("asarUnpack no longer references removed legacy agent packages", () => {
test("asarUnpack keeps MCP server runtime deps unpacked", () => {
// @modelcontextprotocol/sdk is now a direct dep and the MCP server hard-requires it.
assert.ok(config.asarUnpack.includes("node_modules/@modelcontextprotocol/sdk/**/*"));
// externalMcpRedaction.cjs requires re2js. The launcher runs from
// app.asar.unpacked with ELECTRON_RUN_AS_NODE, which cannot see modules
// that exist only inside app.asar (issue #3910).
assert.ok(
config.asarUnpack.includes("node_modules/re2js/**/*"),
"external MCP bridge must unpack re2js so the stdio launcher can resolve it",
);
});
test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => {