mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c35127a7 |
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" };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user