mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b21b2142ce |
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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user