Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 b21b2142ce fix(ssh): skip auto-reconnect after an administrative exit-signal
An idle kick that arrives as an SSH exit-signal was reported as reason
"closed", so the default reconnect loop redialed every 5 seconds.
Classify that end as "signaled" and plumb exitSignal, while bare
transport closed and error exits still reconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 09:27:33 +00:00
9 changed files with 273 additions and 28 deletions

No files matched your search

@@ -67,11 +67,19 @@ test("backend closed events keep the tab and mark it disconnected", () => {
);
});
test("administrative exit-signal disconnects keep the tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
});
test("disabled auto-close keeps command and attached terminal popups open", () => {
@@ -143,6 +151,17 @@ test("exit messages show the reported code for a real remote exit", () => {
);
});
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
"\r\n[session closed by remote signal]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
"\r\n[session closed by remote signal]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
@@ -4,7 +4,10 @@ export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
// terminated the session process. Distinct from a transport "closed".
exitSignal?: string;
// True when the remote closed the channel without sending exit-status or
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
// a distinct message instead of a misleading "code 0".
@@ -16,6 +19,9 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
return "\r\n[session closed by remote signal]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
@@ -58,11 +58,17 @@ test("normal shell exits do not auto reconnect", () => {
);
});
test("administrative idle and timeout exits do not auto reconnect", () => {
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
for (const evt of [
{ reason: "timeout" as const },
{ reason: "timeout" as const, exitCode: 0 },
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
{ reason: "signaled" as const },
// A path that still says "closed" but plumbed the exit-signal is the same kick.
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
{ reason: "closed" as const, exitSignal: " SIGHUP " },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
@@ -81,6 +87,10 @@ test("transport drops still auto reconnect", () => {
{ reason: "closed" as const },
{ reason: "error" as const, error: "connection reset" },
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
{ reason: "closed" as const, exitSignal: "" },
{ reason: "closed" as const, exitSignal: " " },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
+9 -3
View File
@@ -41,9 +41,15 @@ export function shouldAutoReconnectAfterExit({
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
// (or a connect timeout), not a transport drop — reconnecting would bypass
// the server's idle policy. Transport drops stay "error" / "closed".
return evt.reason !== "exited" && evt.reason !== "timeout";
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
// Reconnecting would fight that policy. Transport "error" and bare "closed"
// still retry.
if (evt.reason === "error") return true;
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
return false;
}
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
}
export function shouldContinueAutoReconnectAfterFailure({
@@ -2415,7 +2415,65 @@ test("an exit-signal with no code does not fabricate an exit code 0", async (t)
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGUSR2");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
});
test("a connection close after an exit-signal stays an administrative disconnect", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "signal-then-conn-close" });
const session = sessions.get("signal-then-conn-close");
// ssh2 can emit conn "close" before the channel's paced finalize. The
// signal recorded on "exit" must still win over a generic transport close.
session.stream.emit("exit", null, "SIGHUP");
session.conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGHUP");
assert.equal(exits[0].exitCode, undefined);
});
test("a connection close without an exit-signal stays a transport close", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "transport-conn-close" });
sessions.get("transport-conn-close").conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, 0);
assert.equal(exits[0].exitSignal, undefined);
});
@@ -0,0 +1,57 @@
/**
* Classify how a remote SSH shell channel ended.
*
* An exit-signal (ssh2: code null, signal set) is an administrative
* termination — idle kick, SIGHUP, SIGTERM — not a VPN/transport flap.
* Those stay "signaled" so auto-reconnect can leave them alone.
* A channel that closes with neither exit-status nor exit-signal stays
* "closed" and is still eligible to reconnect.
*/
function normalizeRemoteExitSignal(signal) {
return typeof signal === "string" && signal.length > 0 ? signal : null;
}
function classifyRemoteShellClose({ idleTimedOut, exitSignal, shellExited }) {
if (idleTimedOut) return "timeout";
const signal = normalizeRemoteExitSignal(exitSignal);
if (signal) return "signaled";
if (shellExited) return "exited";
return "closed";
}
function buildRemoteShellExitPayload({
sessionId,
generation,
exitCode,
exitSignal,
shellExited,
idleTimedOut,
}) {
const signal = normalizeRemoteExitSignal(exitSignal);
const reason = classifyRemoteShellClose({
idleTimedOut: idleTimedOut === true,
exitSignal: signal,
shellExited: shellExited === true,
});
const payload = {
sessionId,
reason,
_terminalSessionGeneration: generation,
};
if (typeof exitCode === "number") {
payload.exitCode = exitCode;
} else if (!signal) {
payload.remoteClosedWithoutExitStatus = true;
}
if (signal) {
payload.exitSignal = signal;
}
return payload;
}
module.exports = {
normalizeRemoteExitSignal,
classifyRemoteShellClose,
buildRemoteShellExitPayload,
};
@@ -0,0 +1,73 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildRemoteShellExitPayload,
classifyRemoteShellClose,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
test("normalizeRemoteExitSignal keeps a reported signal and drops empty values", () => {
assert.equal(normalizeRemoteExitSignal("SIGHUP"), "SIGHUP");
assert.equal(normalizeRemoteExitSignal("SIGUSR2"), "SIGUSR2");
assert.equal(normalizeRemoteExitSignal(""), null);
assert.equal(normalizeRemoteExitSignal(null), null);
assert.equal(normalizeRemoteExitSignal(undefined), null);
});
test("an exit-signal is an administrative signaled close, not a transport close", () => {
for (const exitSignal of ["SIGHUP", "SIGTERM", "SIGUSR2"]) {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal, shellExited: false }),
"signaled",
);
}
});
test("a channel close without exit-status or exit-signal stays closed", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: false }),
"closed",
);
});
test("a numeric shell exit stays exited, and a TMOUT banner stays timeout", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: true }),
"exited",
);
assert.equal(
classifyRemoteShellClose({ idleTimedOut: true, exitSignal: null, shellExited: true }),
"timeout",
);
});
test("an exit-signal payload reports the signal and does not invent an exit code", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "signal-exit",
generation: 3,
exitSignal: "SIGUSR2",
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "signaled");
assert.equal(payload.exitSignal, "SIGUSR2");
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, undefined);
assert.equal(payload._terminalSessionGeneration, 3);
});
test("a bare channel-close payload stays a reconnectable transport close", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "transport-close",
exitSignal: null,
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "closed");
assert.equal(payload.exitSignal, undefined);
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, true);
});
+37 -21
View File
@@ -27,6 +27,10 @@ const {
annotateMacLocalNetworkErrorMessage,
resolveFirstTcpEndpoint,
} = require("../macLocalNetworkAccess.cjs");
const {
buildRemoteShellExitPayload,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
@@ -665,7 +669,8 @@ function createStartSessionApi(ctx) {
// "close" fires whenever the channel closes (could be network drop).
// Only treat it as user-initiated exit if "exit" fired with a numeric
// code and no signal. Signal terminations (e.g. server kill, idle
// timeout) have code=null and signal set — those are not user exits.
// timeout) have code=null and signal set — those are not user exits
// and are reported as "signaled", not the reconnectable "closed".
// A third case exists: a remote that closes the channel without sending
// exit-status or exit-signal never fires "exit" at all (#3591). The
// initial values below stay null in that case and must not be reported
@@ -676,8 +681,14 @@ function createStartSessionApi(ctx) {
stream.on("exit", (code, signal) => {
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
streamExitCode = typeof code === "number" ? code : null;
streamExitSignal = typeof signal === "string" && signal ? signal : null;
streamExitSignal = normalizeRemoteExitSignal(signal);
streamExited = typeof code === "number" && !streamExitSignal;
// conn "close" can win the race against this channel's paced
// finalize. Stamp the signal so that path can still tell an
// administrative exit-signal from a transport drop.
if (streamExitSignal && sessions.get(sessionId) === session) {
session._remoteExitSignal = streamExitSignal;
}
});
let closeFinalized = false;
@@ -686,6 +697,7 @@ function createStartSessionApi(ctx) {
sessionId,
hostname: options.hostname,
streamExitCode,
streamExitSignal,
streamExited,
reused: !!isReused,
transportError: sessions.get(sessionId)?._transportError,
@@ -717,27 +729,20 @@ function createStartSessionApi(ctx) {
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
} else {
// A shell TMOUT auto-logout is a clean exit (numeric code, no
// signal) — identical to a user-typed `exit` by code/signal —
// so detect it via the banner the shell prints just before
// exiting and report it as a timeout. That keeps the tab open
// for reconnect instead of auto-closing it (#1062 / #977).
// TMOUT auto-logout is a clean exit (numeric code, no signal) and
// is reported as "timeout" via the shell banner (#1062 / #977).
// An exit-signal is a separate administrative kick ("signaled").
// A channel close with neither exit-status nor exit-signal stays
// "closed" and must not be reported as code 0 (#3591).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
const exitPayload = {
const exitPayload = buildRemoteShellExitPayload({
sessionId,
reason,
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
};
// Report only exit facts the remote actually sent (#3591). When
// the peer closed the channel without exit-status/exit-signal,
// there is no code to show — flag it instead so the renderer can
// distinguish an abnormal teardown from a user-typed `exit 0`.
if (typeof streamExitCode === "number") {
exitPayload.exitCode = streamExitCode;
} else if (streamExitSignal === null) {
exitPayload.remoteClosedWithoutExitStatus = true;
}
generation: liveSession?._terminalSessionGeneration,
exitCode: streamExitCode,
exitSignal: streamExitSignal,
shellExited: streamExited,
idleTimedOut,
});
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
}
liveSession?.zmodemSentry?.cancel();
@@ -2638,6 +2643,17 @@ function createStartSessionApi(ctx) {
reason: "error",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
} else if (normalizeRemoteExitSignal(session?._remoteExitSignal)) {
// The shell already reported an exit-signal. This connection
// close is the server finishing that administrative kick, not
// a transport flap — keep it out of the reconnect loop.
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
sessionId,
generation: session?._terminalSessionGeneration,
exitSignal: session._remoteExitSignal,
shellExited: false,
idleTimedOut: false,
}));
} else {
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
+1 -1
View File
@@ -531,7 +531,7 @@ declare global {
): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void
): () => void;
onTelnetAutoLoginComplete?(
sessionId: string,