Compare commits

...
Author SHA1 Message Date
netcatty-bot 35c35127a7 fix(#3591): automated AI fix 2026-10-05 16:34:57 +00:00
6 changed files with 167 additions and 10 deletions

No files matched your search

@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
formatSessionClosedExitMessage,
resolveTerminalSessionExitIntent,
shouldCloseTerminalPopupOnExit,
shouldRevealTerminalPopupOnExit,
@@ -120,3 +121,32 @@ test("attached terminal popups keep their existing auto-close behavior by defaul
true,
);
});
test("exit messages show the reported code for a real remote exit", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "exited", exitCode: 0 }),
"\r\n[session closed (code 0)]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "exited", exitCode: 127 }),
"\r\n[session closed (code 127)]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
reason: "closed",
remoteClosedWithoutExitStatus: true,
}),
"\r\n[session closed by remote (no exit status)]",
);
});
test("exit messages without a reported code stay plain instead of showing a sentinel", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "closed" }),
"\r\n[session closed]",
);
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
});
@@ -5,9 +5,20 @@ export type TerminalSessionExitEvent = {
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
// 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".
remoteClosedWithoutExitStatus?: boolean;
diagnostics?: ReadonlyArray<ProviderValidationIssue>;
};
export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): string {
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
+2 -1
View File
@@ -40,6 +40,7 @@ import {
type TerminalEncodingAttachConnection,
} from "../domain/terminalEncodingPreference";
import { resolveRestoreCwdIntent, resolveInheritedCwdIntent } from "../domain/sessionRestore";
import { formatSessionClosedExitMessage } from "../application/state/resolveTerminalSessionExitIntent";
import {
buildTerminalContextReadResult,
buildTerminalContextSnapshotText,
@@ -2066,7 +2067,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (evt.error) {
setError(evt.error);
}
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
const exitMessage = formatSessionClosedExitMessage(evt);
hibernatePendingBufferRef.current = hibernatePendingCapDisabledRef.current
? hibernatePendingBufferRef.current + exitMessage
: appendHibernatePendingBuffer(
@@ -2,7 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SSHKey } from "../../../types";
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
import { type TerminalSessionExitEvent, formatSessionClosedExitMessage } from "../../../application/state/resolveTerminalSessionExitIntent";
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
export type {
@@ -820,8 +820,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
isCurrentAttempt,
bootEpoch,
onConnected: () => ctx.setChainProgress(null),
onExitMessage: (evt) =>
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
onExitMessage: formatSessionClosedExitMessage,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
})) {
@@ -2318,3 +2318,104 @@ test("single-channel Copy Tab dials separately and keeps the original shell", as
assert.notEqual(sessions.get("copy").conn, sourceConn);
assert.equal(sessions.get("source").conn, sourceConn);
});
function waitForExitDeliveries() {
// finalizeClose runs after the paced output buffer flushes on the next
// event-loop turns; give both queue turns and timers a chance to run.
return new Promise((resolve) => setTimeout(resolve, 50));
}
function getExitPayloads(sender) {
return sender.sent
.filter((m) => m.channel === "netcatty:exit")
.map((m) => m.payload);
}
test("a channel closed without exit-status or exit-signal reports no exit code (#3591)", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "bastion.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "no-status-close" });
const stream = sessions.get("no-status-close").stream;
// Bastions/gateways built on stacks like golang.org/x/crypto/ssh close the
// session channel without ever sending exit-status or exit-signal, so ssh2
// never emits stream "exit".
stream.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined, "the sentinel 0 must not be reported as a real exit code");
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
});
test("a real exit-status still reports its numeric exit code", 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: "clean-exit" });
const stream = sessions.get("clean-exit").stream;
stream.emit("exit", 0);
stream.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "exited");
assert.equal(exits[0].exitCode, 0);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
});
test("an exit-signal with no code does not fabricate an exit code 0", 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-exit" });
const stream = sessions.get("signal-exit").stream;
stream.emit("exit", undefined, "SIGUSR2");
stream.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
});
+21 -6
View File
@@ -611,12 +611,18 @@ function createStartSessionApi(ctx) {
// 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.
let streamExitCode = 0;
// 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
// as a real "code 0" — that reads like a clean, user-typed `exit`.
let streamExitCode = null;
let streamExitSignal = null;
let streamExited = false;
stream.on("exit", (code, signal) => {
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
streamExitCode = typeof code === "number" ? code : 0;
streamExited = typeof code === "number" && !signal;
streamExitCode = typeof code === "number" ? code : null;
streamExitSignal = typeof signal === "string" && signal ? signal : null;
streamExited = typeof code === "number" && !streamExitSignal;
});
let closeFinalized = false;
@@ -663,12 +669,21 @@ function createStartSessionApi(ctx) {
// for reconnect instead of auto-closing it (#1062 / #977).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
const exitPayload = {
sessionId,
exitCode: streamExitCode,
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;
}
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
}
liveSession?.zmodemSentry?.cancel();
// Release this channel's hold on the shared connection. The transport