Files
NetMesh/electron/bridges/sshBridge.connectionReuse.test.cjs

2186 lines
73 KiB
JavaScript
Raw Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
const { abortPendingBoot } = require("./sessionBootEpoch.cjs");
const sshConnectionPool = require("./sshConnectionPool.cjs");
const {
createTransport,
borrowTransport,
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
beginTransportDial,
completeTransportDial,
buildConnectionReuseEndpoint,
resetSshTransportRegistryForTests,
} = sshConnectionPool;
// Load sshBridge with a mocked ssh2 module so we can observe whether a *new*
// SSH client is constructed (a fresh connection) versus an existing connection
// being reused for a new shell channel (issue #1204).
function loadBridgeWithMockedSsh2(t, {
connectReady = false,
remoteVer = "OpenSSH_9.0",
trackShellPids = false,
beforeShellPidScanResult,
} = {}) {
const bridgePath = require.resolve("./sshBridge.cjs");
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
const originalLoad = Module._load;
let clientConstructCount = 0;
class MockSSHClient extends EventEmitter {
constructor() {
super();
this._sock = {
destroyed: false,
setTimeout() {},
setNoDelay() {},
};
this._remoteVer = remoteVer;
this.openedShells = [];
this.ended = 0;
}
connect() {
clientConstructCount += 1;
if (connectReady) {
setImmediate(() => {
this.emit("connect");
this.emit("handshake");
this.emit("ready");
});
return;
}
// We never want the reuse test to reach a real connect; if it does the
// test asserts on clientConstructCount and fails clearly.
setImmediate(() => this.emit("error", new Error("unexpected fresh connect")));
}
end() { this.ended += 1; }
destroy() {}
exec(_command, callback) {
if (!trackShellPids) {
callback?.(new Error("exec unavailable"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const pids = this.openedShells
.map((_shell, index) => `${(index + 1) * 111} 1`)
.join("\n");
setImmediate(() => {
beforeShellPidScanResult?.();
stream.emit("data", Buffer.from(`${pids}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`));
stream.emit("close", 0);
});
callback(null, stream);
}
shell(_pty, _options, callback) {
const stream = makeStream();
this.openedShells.push(stream);
setImmediate(() => callback(null, stream));
}
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockSSHClient,
utils: { parseKey: () => new Error("no key") },
};
}
if (request === "ssh2/lib/agent.js") {
return { BaseAgent: class BaseAgent {} };
}
if (request === "ssh2/lib/protocol/keyParser.js") {
return { parseKey: () => new Error("no key") };
}
if (request === "electron") {
return {
app: {
getPath: (name) => `/tmp/netcatty-test-${name}`,
isReady: () => true,
getName: () => "netcatty",
getVersion: () => "0.0.0",
},
ipcMain: { handle() {}, on() {}, removeHandler() {} },
BrowserWindow: class BrowserWindow {},
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
shell: { openPath: async () => "" },
nativeTheme: { shouldUseDarkColors: false },
webContents: { fromId: () => null },
};
}
return originalLoad.call(this, request, parent, isMain);
};
const extraCached = [
require.resolve("./netcattyAgent.cjs"),
require.resolve("./zmodemHelper.cjs"),
require.resolve("./sshBridge/startSession.cjs"),
];
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
for (const extra of extraCached) delete require.cache[extra];
const bridge = require("./sshBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
for (const extra of extraCached) delete require.cache[extra];
Module._load = originalLoad;
});
return { bridge, getClientConstructCount: () => clientConstructCount };
}
test("simultaneous normal opens identify both shells before sharing one SSH connection", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
trackShellPids: true,
});
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
const [first, second] = await Promise.all([
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
]);
assert.equal(first.sessionId, "normal-1");
assert.equal(second.sessionId, "normal-2");
assert.equal(getClientConstructCount(), 1);
assert.equal(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
assert.equal(sessions.get("normal-1").connRef.count, 2);
assert.equal(sessions.get("normal-1").shellPid, "111");
assert.equal(sessions.get("normal-2").shellPid, "222");
});
test("simultaneous normal opens use separate connections when shell identity is unavailable", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
});
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.54",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await Promise.all([
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
]);
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
});
test("simultaneous normal opens do not trust a replacement session after identity discovery", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const sessions = new Map();
let replaced = false;
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
trackShellPids: true,
beforeShellPidScanResult: () => {
if (replaced) return;
replaced = true;
const original = sessions.get("normal-1");
sessions.set("normal-1", { ...original, shellPid: "999" });
},
});
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.55",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await Promise.all([
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
]);
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
});
test("reuseTransport false makes simultaneous same-host opens dial independently", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.51",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
reuseTransport: false,
};
await Promise.all([
start({ sender: makeSender() }, { ...options, sessionId: "fresh-1" }),
start({ sender: makeSender() }, { ...options, sessionId: "fresh-2" }),
]);
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("fresh-1").conn, sessions.get("fresh-2").conn);
});
test("a sequential ordinary open with reuse disabled bypasses the live same-host transport", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.52",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
await start({ sender: makeSender() }, {
...options,
sessionId: "second",
reuseTransport: false,
});
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("first").conn, sessions.get("second").conn);
});
test("an ordinary open with reuse disabled bypasses an idle same-host transport", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "10.0.0.53",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const firstTransport = first.connRef;
first.stream.emit("close");
assert.equal(firstTransport.state, "idle");
await start({ sender: makeSender() }, {
...options,
sessionId: "second",
reuseTransport: false,
});
assert.equal(getClientConstructCount(), 2);
assert.notEqual(firstTransport.conn, sessions.get("second").conn);
});
test("TERM-SSHD close does not idle-park, so reconnect dials a fresh connection", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
remoteVer: "TERM-SSHD",
});
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "blj.yd.com.cn",
username: "test",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const firstTransport = first.connRef;
assert.equal(firstTransport.allowIdlePark, false);
first.stream.emit("close");
assert.equal(firstTransport.state, "dead");
assert.equal(first.conn.ended, 1);
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
assert.equal(getClientConstructCount(), 2, "second open must not reuse a TERM-SSHD transport");
assert.notEqual(sessions.get("second").conn, first.conn);
});
test("idle-park reconnect falls back to a fresh dial when the reused shell exits immediately", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
remoteVer: "CustomBastion_1.0",
});
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "bastion.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
sshReusedShellLivenessMs: 25,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const parkedConn = first.conn;
const firstTransport = first.connRef;
first.stream.emit("close");
assert.equal(firstTransport.state, "idle", "unknown banners still park until proven broken");
parkedConn.shell = (_pty, _shellOpts, callback) => {
const stream = makeStream();
parkedConn.openedShells.push(stream);
setImmediate(() => {
callback(null, stream);
setImmediate(() => {
stream.emit("exit", 0);
stream.emit("close");
});
});
};
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
assert.equal(getClientConstructCount(), 2, "dead parked shell must fall back to a fresh connection");
assert.notEqual(sessions.get("second").conn, parkedConn);
assert.equal(firstTransport.state, "dead");
const second = sessions.get("second");
const secondTransport = second.connRef;
second.stream.emit("close");
assert.equal(secondTransport.state, "dead", "endpoint is denylisted so the next close does not park");
});
test("TERM-SSHD last shell with SFTP still open dials a fresh shell", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
remoteVer: "TERM-SSHD",
});
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "blj.yd.com.cn",
username: "test",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const firstTransport = first.connRef;
acquireConnectionRef({ id: "sftp-holder", __sshLeaseKind: "sftp" }, firstTransport);
first.stream.emit("close");
assert.equal(firstTransport.state, "live");
assert.equal(firstTransport.allowShellReuse, false);
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("second").conn, first.conn);
});
test("SFTP-held reconnect falls back when the reused shell exits immediately", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
remoteVer: "CustomBastion_1.0",
});
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "bastion.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
sshReusedShellLivenessMs: 25,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const parkedConn = first.conn;
const firstTransport = first.connRef;
acquireConnectionRef({ id: "sftp-holder", __sshLeaseKind: "sftp" }, firstTransport);
first.stream.emit("close");
assert.equal(firstTransport.state, "live");
assert.ok(firstTransport.pendingShellReconnectRisk);
parkedConn.shell = (_pty, _shellOpts, callback) => {
const stream = makeStream();
parkedConn.openedShells.push(stream);
setImmediate(() => {
callback(null, stream);
setImmediate(() => {
stream.emit("exit", 0);
stream.emit("close");
});
});
};
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
assert.equal(getClientConstructCount(), 2);
assert.notEqual(sessions.get("second").conn, parkedConn);
assert.equal(firstTransport.allowShellReuse, false);
});
test("idle-park reconnect after last shell closes skips post-open PID discovery", async (t) => {
// After the sole interactive shell returns its lease, the next open reuses the
// parked transport. Post-open discovery exec has no sibling PIDs to
// disambiguate and can tear down bastion sessions (issue #2923).
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "bastion.qzsec.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
sshChannelOpenRateLimitBackoffMs: 1,
};
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
const first = sessions.get("first");
const parkedConn = first.conn;
const firstTransport = first.connRef;
first.stream.emit("close");
assert.equal(firstTransport.state, "idle");
let execCalls = 0;
parkedConn.exec = (_command, callback) => {
execCalls += 1;
callback(new Error("idle reconnect must not open discovery exec"));
};
const shellsBefore = parkedConn.openedShells.length;
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
assert.equal(getClientConstructCount(), 1, "second open reuses the parked transport");
assert.equal(parkedConn.openedShells.length, shellsBefore + 1);
assert.equal(execCalls, 0, "must not open discovery exec after sole-shell reconnect");
const second = sessions.get("second");
assert.equal(second.blockUntargetedCwdProbe, true);
assert.notEqual(second.allowCwdRecovery, true);
second.pendingCwdRecoveryAfterUserCommand = true;
second.stream.emit("data", Buffer.from("command output\r\n"));
assert.equal(second.allowCwdRecovery, true);
// A second ordinary reconnect can arrive while the first risk-marked shell
// is still live but unassigned. It must not reopen PID discovery during the
// connection path either; both sessions stay fail-closed instead.
await start({ sender: makeSender() }, { ...options, sessionId: "third" });
const third = sessions.get("third");
assert.equal(execCalls, 0);
assert.equal(third.blockUntargetedCwdProbe, true);
assert.equal(third.parkedReconnectRisk.hasUnknownOldShell, true);
});
for (const leaseKind of ["sftp", "forward"]) {
test(`first shell on a ${leaseKind}-only transport keeps cwd discovery enabled`, async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: `${leaseKind}-only.example`,
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
const conn = makeReusableConn();
const transport = createTransport({
conn,
endpoint: buildConnectionReuseEndpoint(options),
});
borrowTransport(transport, {
kind: leaseKind,
holder: { id: `${leaseKind}-holder` },
});
await start({ sender: makeSender() }, { ...options, sessionId: "first-shell" });
assert.equal(getClientConstructCount(), 0);
assert.equal(sessions.get("first-shell").connRef, transport);
assert.notEqual(sessions.get("first-shell").blockUntargetedCwdProbe, true);
});
}
test("shell joining an SFTP-led initial dial keeps cwd discovery enabled", async (t) => {
const originalWaitForTransportDial = sshConnectionPool.waitForTransportDial;
let observeJoin;
const joinedDial = new Promise((resolve) => { observeJoin = resolve; });
sshConnectionPool.waitForTransportDial = (coordination, ...args) => {
if (coordination?.role === "join") observeJoin();
return originalWaitForTransportDial(coordination, ...args);
};
t.after(() => { sshConnectionPool.waitForTransportDial = originalWaitForTransportDial; });
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "sftp-led.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
const endpoint = buildConnectionReuseEndpoint(options);
const coordination = beginTransportDial(endpoint, { kind: "channel" });
const pendingStart = start({ sender: makeSender() }, { ...options, sessionId: "first-shell" });
await joinedDial;
const conn = makeReusableConn();
const transport = createTransport({ conn, endpoint });
borrowTransport(transport, { kind: "sftp", holder: { id: "sftp-holder" } });
completeTransportDial(coordination, transport);
await pendingStart;
assert.equal(getClientConstructCount(), 0);
assert.equal(sessions.get("first-shell").connRef, transport);
assert.notEqual(sessions.get("first-shell").blockUntargetedCwdProbe, true);
});
test("last shell close stays protected when an SFTP lease keeps the transport live", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const options = {
hostname: "shell-plus-sftp.example",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender: makeSender() }, { ...options, sessionId: "old-shell" });
const oldSession = sessions.get("old-shell");
const transport = oldSession.connRef;
oldSession.shellPid = "111";
acquireConnectionRef({ id: "sftp-holder", __sshLeaseKind: "sftp" }, transport);
oldSession.stream.emit("close");
assert.equal(transport.state, "live");
await start({ sender: makeSender() }, { ...options, sessionId: "new-shell" });
assert.equal(getClientConstructCount(), 1);
assert.equal(sessions.get("new-shell").connRef, transport);
assert.equal(sessions.get("new-shell").blockUntargetedCwdProbe, true);
assert.deepEqual(sessions.get("new-shell").parkedReconnectRisk, {
oldShellPids: ["111"],
hasUnknownOldShell: false,
});
});
function makeSender() {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) { this.sent.push({ channel, payload }); },
};
}
function getConnectionReuseFallbackEvents(sender) {
return sender.sent.filter((m) => m.channel === "netcatty:connection-reuse:fallback");
}
test.beforeEach(() => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
});
test.afterEach(() => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
});
// A fake ssh2 shell channel.
function makeStream() {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.closed = false;
stream.write = () => true;
stream.signal = () => {};
stream.close = () => { stream.closed = true; stream.emit("close"); };
return stream;
}
// A fake authenticated ssh2 connection that hands out shell channels.
function makeReusableConn() {
const conn = new EventEmitter();
conn._sock = { destroyed: false };
conn._remoteVer = "OpenSSH_9.0";
conn.ended = 0;
conn.openedShells = [];
conn.openedShellOptions = [];
conn.end = () => { conn.ended += 1; };
conn.destroy = () => {};
conn.shell = (_opts, shellOpts, cb) => {
const stream = makeStream();
conn.openedShells.push(stream);
conn.openedShellOptions.push(shellOpts);
// ssh2 invokes the callback asynchronously.
setImmediate(() => cb(null, stream));
};
return conn;
}
function makePidTrackingReusableConn({ delayFirstNewPid = false } = {}) {
const conn = makeReusableConn();
conn.shellPidSnapshots = [];
conn.sourceShellVisible = true;
let delayed = false;
conn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
let visibleShellCount = conn.openedShells.length;
if (delayFirstNewPid && conn.openedShells.length > 0 && !delayed) {
delayed = true;
visibleShellCount -= 1;
}
const snapshot = [
...(conn.sourceShellVisible ? ["111"] : []),
...Array.from(
{ length: visibleShellCount },
(_value, index) => String((index + 2) * 111),
),
];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
conn.shellPidSnapshots.push(snapshot);
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
return conn;
}
function makeUnavailableDiscoveryConn() {
const conn = makeReusableConn();
conn.discoveryExecCalls = 0;
conn.exec = (_command, callback) => {
conn.discoveryExecCalls += 1;
callback(new Error("exec channels disabled"));
};
return conn;
}
function makeFailedDiscoveryCommandConn() {
const conn = makeReusableConn();
conn.discoveryExecCalls = 0;
conn.exec = (_command, callback) => {
conn.discoveryExecCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
setImmediate(() => {
stream.emit("data", Buffer.from("111\n"));
stream.emit("close", 127);
});
callback(null, stream);
};
return conn;
}
// A reusable connection whose shell callback is held until released, so a test
// can simulate the source tab closing while conn.shell() is still pending.
function makeDeferredShellConn() {
const conn = new EventEmitter();
conn._sock = { destroyed: false };
conn._remoteVer = "OpenSSH_9.0";
conn.ended = 0;
conn.openedShells = [];
conn._pending = [];
conn.end = () => { conn.ended += 1; };
conn.destroy = () => {};
conn.shell = (_opts, _shellOpts, cb) => {
conn._pending.push(cb);
};
conn.flushShell = () => {
const cbs = conn._pending.splice(0);
for (const cb of cbs) {
const stream = makeStream();
conn.openedShells.push(stream);
cb(null, stream);
}
};
return conn;
}
// Build a live source session as if it had connected normally, including the
// registry transport lease and the recorded endpoint used for reuse target
// matching.
function makeSourceSession(conn, endpoint) {
const session = {
conn,
stream: makeStream(),
chainConnections: [],
webContentsId: 1,
zmodemSentry: { cancel() {} },
hostname: endpoint.hostname,
username: endpoint.username,
_reuseEndpoint: {
hostname: endpoint.hostname,
port: endpoint.port || 22,
username: endpoint.username,
...(Array.isArray(endpoint.jumpHosts) ? { jumpHosts: endpoint.jumpHosts } : {}),
},
};
createConnectionRef(session, conn, []);
return session;
}
function registerStartHandler(bridge, sessions) {
bridge.init({ sessions, electronModule: {} });
const ipcMain = {
handlers: new Map(),
handle(channel, handler) { this.handlers.set(channel, handler); },
on() {},
};
bridge.registerHandlers(ipcMain);
return ipcMain.handlers.get("netcatty:start");
}
test("Copy Tab reuses the source connection instead of dialing fresh", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
// Seed a live source session as if it had connected normally.
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
port: 22,
sourceSessionId: "source",
},
);
assert.equal(result.sessionId, "copy");
// No new SSH client was constructed/connected — the existing connection was reused.
assert.equal(getClientConstructCount(), 0);
// A new shell channel was opened on the source connection.
assert.equal(sourceConn.openedShells.length, 1);
// The new session is tracked and shares the source's connRef (count bumped).
const copy = sessions.get("copy");
assert.ok(copy, "copy session should be registered");
assert.equal(copy.conn, sourceConn);
assert.equal(copy.connRef.count, 2);
// A 'connected' progress event was emitted for the renderer.
const progress = sender.sent.filter((m) => m.channel === "netcatty:chain:progress");
assert.ok(progress.some((m) => m.payload.status === "connected"));
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0, "successful reuse should not emit fallback");
});
test("reuseTransport false bypasses a matching live source and connects fresh", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const sourceConn = makeReusableConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "fresh",
hostname: "10.0.0.1",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
sourceSessionId: "source",
reuseTransport: false,
},
);
assert.equal(getClientConstructCount(), 1);
assert.equal(sourceConn.openedShells.length, 0);
assert.notEqual(sessions.get("fresh").conn, sourceConn);
});
test("Copy Tab records a distinct remote shell for each shared terminal", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn();
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
// Live tabs normally already know their login shellPid (cwd / sessionOps).
// Copy Tab must not burn a discovery exec before opening the reused shell.
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(sourceConn.shellPidSnapshots.length >= 1);
assert.deepEqual(sourceConn.shellPidSnapshots.at(-1), ["111", "222"]);
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab skips POSIX shell discovery for network devices", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = () => { execCalls += 1; };
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
skipShellPidDiscovery: true,
},
);
assert.equal(execCalls, 0);
assert.equal(sourceConn.openedShells.length, 1);
assert.ok(sessions.get("copy"));
});
test("Copy Tab with jump hosts still discovers shell PIDs after a short delay", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn();
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(sourceConn.shellPidSnapshots.length >= 1, "jump-host reuse must still run post-open discovery");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
assert.equal(sourceConn.openedShells.length, 1);
});
test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// First post-open scan is rate-limited; later scans succeed.
if (execCalls === 1) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 2, "post-open discovery must retry after channelOpen too offen");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab retries rate-limited PID discovery for direct bastion targets", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// Direct bastion (no jumpHosts): first post-open scan is rate-limited.
if (execCalls === 1) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 2, "direct bastion reuse must retry rate-limited PID discovery");
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab claims an unambiguous leftover PID when the source never recorded one", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// First post-open snapshot still only sees the source shell; later scans
// include the copied shell so waitForNewInteractiveShellPid can finish.
const snapshot = execCalls === 1 ? ["111"] : ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 2);
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab waits before claiming a sole PID after the source closes", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const terminalBridge = require("./terminalBridge.cjs");
const sessions = new Map();
const sourceConn = makeDeferredShellConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Source tab already closed: early scans can still list the dying source
// shell before the copied shell is visible.
const snapshot = execCalls < 3 ? ["111"] : ["222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
});
sessions.set("source", source);
terminalBridge.init({ sessions, electronModule: {} });
const start = registerStartHandler(bridge, sessions);
const startPromise = start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
await new Promise((resolve) => setImmediate(resolve));
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
sourceConn.flushShell();
await startPromise;
assert.ok(execCalls >= 3, "must wait through the dying-source-only scans");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab disambiguates a two-PID first scan when the source never recorded shellPid", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// OSC 7 sources skip the cwd probe, so shellPid is unset. The copied shell
// is already visible in the first post-open scan — no gradual appearance.
// Ages (etimes seconds; higher = older) distinguish source from copy.
const pids = "111 40\n222 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(execCalls, 1, "must not require waitForNew retries once both PIDs are visible");
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab uses process age when PID wrap makes the copy numerically smaller", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// After PID wrap the copied shell can receive a lower numeric PID while
// still being the younger process (etimes 1 vs 90).
const pids = "50 1\n4000 90\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(sessions.get("source").shellPid, "4000");
assert.equal(sessions.get("copy").shellPid, "50");
});
test("Copy Tab leaves PIDs unassigned when both shells share the same etimes second", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Same whole-second etimes tick: age inequality cannot separate them, and
// numeric PID order is unsafe after wrap — leave both unassigned.
const pids = "111 1\n222 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(sessions.get("source").shellPid, undefined);
assert.equal(sessions.get("copy").shellPid, undefined);
});
test("Copy Tab leaves PIDs unassigned when remote ps lacks etimes", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Age-less "pid" lines only — hosts without etimes. Do not guess via PID
// order; a wrap between source and copy would swap the assignment.
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(sessions.get("source").shellPid, undefined);
assert.equal(sessions.get("copy").shellPid, undefined);
});
test("Copy Tab reconciles an untracked source even when another sibling PID is known", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Tracked sibling 100 plus untracked source 200 and new copy 300.
const pids = "100 120\n200 40\n300 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
});
const tracked = {
conn: sourceConn,
stream: makeStream(),
chainConnections: [],
webContentsId: 1,
zmodemSentry: { cancel() {} },
hostname: "10.0.0.1",
username: "alice",
_reuseEndpoint: source._reuseEndpoint,
shellPid: "100",
};
acquireConnectionRef(tracked, source.connRef);
sessions.set("tracked", tracked);
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(sessions.get("tracked").shellPid, "100");
assert.equal(sessions.get("source").shellPid, "200");
assert.equal(sessions.get("copy").shellPid, "300");
});
test("Copy Tab retries bastion channelOpen too offen before falling back", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let shellAttempts = 0;
sourceConn.shell = (_opts, _shellOpts, cb) => {
shellAttempts += 1;
if (shellAttempts === 1) {
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
return;
}
const stream = makeStream();
sourceConn.openedShells.push(stream);
setImmediate(() => cb(null, stream));
};
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
}));
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(result.sessionId, "copy");
assert.equal(shellAttempts, 2);
assert.equal(getClientConstructCount(), 0);
assert.equal(sourceConn.openedShells.length, 1);
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
});
test("Copy Tab keeps reusing when a bastion rate limit outlasts the legacy retry burst", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let shellAttempts = 0;
sourceConn.shell = (_opts, _shellOpts, cb) => {
shellAttempts += 1;
if (shellAttempts <= 4) {
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
return;
}
const stream = makeStream();
sourceConn.openedShells.push(stream);
setImmediate(() => cb(null, stream));
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(result.sessionId, "copy");
assert.equal(shellAttempts, 5);
assert.equal(getClientConstructCount(), 0);
assert.equal(sourceConn.openedShells.length, 1);
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
});
test("ordinary parked reuse keeps the legacy retry bound", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let shellAttempts = 0;
sourceConn.shell = (_opts, _shellOpts, cb) => {
shellAttempts += 1;
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "ordinary",
hostname: "bastion.example",
username: "alice",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
/unexpected fresh connect/,
);
// The ordinary path can try the same live transport through its parked and
// coordinated-reuse stages. Each stage keeps the legacy four-attempt bound.
assert.equal(shellAttempts, 8);
assert.equal(getClientConstructCount(), 1);
});
test("cancelling Copy Tab during rate-limit backoff stops retries and keeps the source alive", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let shellAttempts = 0;
sourceConn.shell = (_opts, _shellOpts, cb) => {
shellAttempts += 1;
setImmediate(() => {
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
setImmediate(() => abortPendingBoot("copy", 1));
});
};
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
});
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
bootEpoch: 1,
sshChannelOpenRateLimitBackoffMs: 50,
},
),
/aborted/,
);
await new Promise((resolve) => setTimeout(resolve, 75));
assert.equal(shellAttempts, 1);
assert.equal(getClientConstructCount(), 0);
assert.equal(sourceConn.ended, 0);
assert.equal(source.connRef.count, 1);
});
test("cancelling Copy Tab during reused-shell liveness does not register a stale session", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn._remoteVer = "CustomBastion_1.0";
let openedStream = null;
sourceConn.shell = (_opts, _shellOpts, cb) => {
openedStream = makeStream();
sourceConn.openedShells.push(openedStream);
setImmediate(() => {
cb(null, openedStream);
setTimeout(() => abortPendingBoot("copy", 1), 5);
});
};
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
});
source.connRef.pendingShellReconnectRisk = {
oldShellPids: [],
hasUnknownOldShell: true,
};
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
bootEpoch: 1,
sshReusedShellLivenessMs: 100,
},
),
/aborted/,
);
assert.equal(getClientConstructCount(), 0);
assert.equal(sessions.has("copy"), false);
assert.equal(openedStream.closed, true);
assert.equal(sourceConn.ended, 0);
assert.equal(source.connRef.count, 1);
});
test("cancelling ordinary parked reuse does not start a fresh login", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let shellAttempts = 0;
sourceConn.shell = (_opts, _shellOpts, cb) => {
shellAttempts += 1;
setImmediate(() => {
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
setImmediate(() => abortPendingBoot("ordinary", 1));
});
};
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
});
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "ordinary",
hostname: "bastion.example",
username: "alice",
bootEpoch: 1,
sshChannelOpenRateLimitBackoffMs: 50,
},
),
/aborted/,
);
assert.equal(shellAttempts, 1);
assert.equal(getClientConstructCount(), 0);
assert.equal(sourceConn.ended, 0);
assert.equal(source.connRef.count, 1);
});
test("an abandoned Copy Tab open blocks overlapping reuse until the raw callback settles", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeDeferredShellConn();
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
});
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
const firstStart = start(
{ sender: makeSender() },
{
sessionId: "copy-1",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
bootEpoch: 1,
},
);
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(sourceConn._pending.length, 1);
abortPendingBoot("copy-1", 1);
await assert.rejects(firstStart, /aborted/);
assert.equal(source.connRef.pendingAbandonedShellOpens, 1);
assert.equal(sourceConn._pending.length, 1);
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "copy-2",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
bootEpoch: 1,
},
),
/unexpected fresh connect/,
);
assert.equal(sourceConn._pending.length, 1, "second copy must not overlap the abandoned open");
sourceConn.flushShell();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(source.connRef.pendingAbandonedShellOpens, undefined);
const thirdStart = start(
{ sender: makeSender() },
{
sessionId: "copy-3",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
bootEpoch: 1,
skipShellPidDiscovery: true,
},
);
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.equal(sourceConn._pending.length, 1);
sourceConn.flushShell();
const result = await thirdStart;
assert.equal(result.sessionId, "copy-3");
assert.equal(getClientConstructCount(), 1);
assert.equal(sourceConn.ended, 0);
});
test("a shared connection error during Copy Tab backoff stops queued retries", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let shellAttempts = 0;
sourceConn.shell = (_opts, _shellOpts, cb) => {
shellAttempts += 1;
setImmediate(() => {
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
setImmediate(() => sourceConn.emit("error", new Error("transport lost")));
});
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await assert.rejects(
start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 50,
},
),
/unexpected fresh connect/,
);
await new Promise((resolve) => setTimeout(resolve, 75));
assert.equal(shellAttempts, 1);
assert.equal(getClientConstructCount(), 1);
});
test("Copy Tab opens the shell before PID discovery so bastion rate limits do not burn the session slot", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
const channelOrder = [];
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
channelOrder.push("exec");
// Bastions rate-limit rapid session channels. If discovery runs first it
// burns the budget and every subsequent shell open is rejected.
if (sourceConn.openedShells.length === 0) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const snapshot = ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sourceConn.shell = (_opts, _shellOpts, cb) => {
channelOrder.push("shell");
if (execCalls > 0 && sourceConn.openedShells.length === 0) {
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
return;
}
const stream = makeStream();
sourceConn.openedShells.push(stream);
setImmediate(() => cb(null, stream));
};
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(result.sessionId, "copy");
assert.equal(getClientConstructCount(), 0);
assert.equal(sourceConn.openedShells.length, 1);
assert.equal(channelOrder[0], "shell", "shell must open before any discovery exec");
assert.ok(execCalls >= 1, "PID discovery still runs after the shell opens");
assert.equal(sessions.get("copy").shellPid, "222");
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
});
test("Copy Tab waits briefly when the new remote shell is not visible immediately", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn({ delayFirstNewPid: true });
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(sourceConn.shellPidSnapshots.length >= 2);
assert.deepEqual(sourceConn.shellPidSnapshots[0], ["111"]);
assert.deepEqual(sourceConn.shellPidSnapshots.at(-1), ["111", "222"]);
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab does not retry when remote shell discovery is unavailable", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeUnavailableDiscoveryConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
},
);
assert.equal(sourceConn.discoveryExecCalls, 1);
assert.equal(sessions.get("source").shellPid, undefined);
assert.equal(sessions.get("copy").shellPid, undefined);
});
test("Copy Tab does not retry when the remote discovery command fails", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeFailedDiscoveryCommandConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
},
);
assert.equal(sourceConn.discoveryExecCalls, 1);
assert.equal(sessions.get("copy").shellPid, undefined);
});
test("concurrent Copy Tab requests serialize shell discovery per connection", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn();
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await Promise.all([
start(
{ sender: makeSender() },
{
sessionId: "copy-1",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
start(
{ sender: makeSender() },
{
sessionId: "copy-2",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
]);
assert.equal(sessions.get("source").shellPid, "111");
// The macOS network preflight runs before either request joins the shared
// shell-open queue, so under load either copy may enqueue first. The contract
// is that both remote shells are identified once and never collide.
assert.deepEqual(
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
new Set(["222", "333"]),
);
});
test("concurrent copies keep distinct shell IDs when the source closes immediately", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const terminalBridge = require("./terminalBridge.cjs");
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn({ delayFirstNewPid: true });
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
source.shellPid = "111";
const closeSourceStream = source.stream.close;
source.stream.close = () => {
sourceConn.sourceShellVisible = false;
closeSourceStream();
};
sessions.set("source", source);
terminalBridge.init({ sessions, electronModule: {} });
const start = registerStartHandler(bridge, sessions);
const copies = Promise.all([
start(
{ sender: makeSender() },
{
sessionId: "copy-1",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
start(
{ sender: makeSender() },
{
sessionId: "copy-2",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
]);
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
await copies;
assert.deepEqual(
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
new Set(["222", "333"]),
);
});
test("Copy Tab preserves the server locale unless the host explicitly overrides it", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy-default-locale",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
charset: "UTF-8",
env: { TERM: "xterm-256color" },
},
);
assert.deepEqual(sourceConn.openedShellOptions[0].env, {
COLORTERM: "truecolor",
TERM: "xterm-256color",
});
await start(
{ sender: makeSender() },
{
sessionId: "copy-explicit-locale",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
charset: "UTF-8",
env: { LANG: "zh_CN.UTF-8" },
},
);
assert.equal(sourceConn.openedShellOptions[1].env.LANG, "zh_CN.UTF-8");
});
test("closing the reused channel keeps the source connection alive", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
const connRef = source.connRef;
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
);
const copy = sessions.get("copy");
assert.equal(connRef.count, 2);
// Simulate the remote shell of the copy exiting: its channel closes.
copy.stream.emit("close");
// The shared connection must NOT be ended — the source is still using it.
assert.equal(sourceConn.ended, 0);
assert.equal(connRef.count, 1);
assert.equal(sessions.has("copy"), false, "copy session cleaned up");
assert.ok(sessions.has("source"), "source session still alive");
// Last release parks the healthy connection for later reuse.
assert.equal(releaseConnectionRef(sessions.get("source")), false);
assert.equal(sourceConn.ended, 0);
assert.equal(connRef.state, "idle");
});
test("skips reuse for X11-forwarding hosts and connects fresh", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
// X11 forwarding is per-channel, so a reused channel would lose it. The
// bridge must skip reuse and dial a fresh connection instead.
await assert.rejects(
() => start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
x11Forwarding: true,
},
),
);
assert.equal(sourceConn.openedShells.length, 0, "must not reuse the source connection");
assert.equal(getClientConstructCount(), 1, "should dial a fresh connection for X11");
});
test("source closed while reused shell is pending keeps the connection alive", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const terminalBridge = require("./terminalBridge.cjs");
const sessions = new Map();
const conn = makeDeferredShellConn();
const source = makeSourceSession(conn, { hostname: "10.0.0.1", username: "alice" });
const connRef = source.connRef;
sessions.set("source", source);
terminalBridge.init({ sessions, electronModule: {} });
const start = registerStartHandler(bridge, sessions);
// Begin the copy; conn.shell() is deferred. The request pin protects the
// explicit source across preflight, and the shell-open pin protects the
// pending channel (count -> 3) before the real session is attached.
const startPromise = start(
{ sender: makeSender() },
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
);
await new Promise((r) => setImmediate(r));
assert.equal(connRef.count, 3, "request and shell-open pins protect the source connection");
// Close the source tab while the copy's shell is still opening.
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
assert.equal(conn.ended, 0, "connection must survive for the pending copy");
assert.equal(connRef.count, 2);
// Now let the copy's shell open.
conn.flushShell();
const result = await startPromise;
assert.equal(result.sessionId, "copy");
assert.equal(conn.ended, 0, "connection still alive for the active copy");
const copy = sessions.get("copy");
assert.ok(copy);
assert.equal(copy.connRef, connRef);
assert.equal(connRef.count, 1, "count reflects exactly the one remaining channel");
// Closing the copy (last holder) parks the healthy connection.
terminalBridge.closeSession({ sender: {} }, { sessionId: "copy" });
assert.equal(conn.ended, 0);
assert.equal(connRef.state, "idle");
});
test("Copy Tab after TERM-SSHD source close falls back to a fresh dial", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
connectReady: true,
remoteVer: "TERM-SSHD",
});
const terminalBridge = require("./terminalBridge.cjs");
const sessions = new Map();
const conn = makeDeferredShellConn();
conn._remoteVer = "TERM-SSHD";
const source = makeSourceSession(conn, { hostname: "blj.yd.com.cn", username: "test" });
const connRef = source.connRef;
sessions.set("source", source);
terminalBridge.init({ sessions, electronModule: {} });
const start = registerStartHandler(bridge, sessions);
const startPromise = start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "blj.yd.com.cn",
username: "test",
sourceSessionId: "source",
},
);
await new Promise((r) => setImmediate(r));
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
assert.equal(connRef.allowShellReuse, false);
conn.flushShell();
await startPromise;
assert.equal(getClientConstructCount(), 1, "must not keep the TERM-SSHD source transport");
assert.notEqual(sessions.get("copy").conn, conn);
});
test("does not reuse when the source endpoint differs from the requested target", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
// Source connected to the OLD host; the saved host was then edited.
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
// The duplicate now targets a DIFFERENT hostname. Reusing the old connection
// would run commands on the wrong machine, so it must connect fresh instead.
await assert.rejects(
() => start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.2", // different host
username: "alice",
sourceSessionId: "source",
},
),
);
assert.equal(sourceConn.openedShells.length, 0, "must not reuse a mismatched connection");
assert.equal(getClientConstructCount(), 1, "should dial a fresh connection to the new host");
});
test("synchronous shell failure releases the ref and falls back to fresh", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
// Make conn.shell throw synchronously, as ssh2 does when the transport just
// dropped (e.g. "Not connected").
sourceConn.shell = () => { throw new Error("Not connected"); };
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
const connRef = source.connRef;
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
await assert.rejects(
() => start(
{ sender },
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
),
);
// The up-front ref hold must be released so the source's count isn't leaked.
assert.equal(connRef.count, 1, "ref count restored after synchronous shell failure");
assert.equal(getClientConstructCount(), 1, "should fall back to a fresh connection");
assert.deepEqual(
getConnectionReuseFallbackEvents(sender).map((m) => m.payload),
[{ sessionId: "copy", sourceSessionId: "source" }],
);
});
test("falls back to a fresh connection when the source is gone", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const otherConn = makeReusableConn();
sessions.set("other", makeSourceSession(otherConn, { hostname: "10.0.0.1", username: "alice" }));
const start = registerStartHandler(bridge, sessions);
// sourceSessionId points at a session that doesn't exist -> fresh connect.
// The mocked client emits an error on connect, so the start call rejects;
// the important assertion is that a fresh connection was attempted.
const sender = makeSender();
await assert.rejects(
() => start(
{ sender },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "missing-source",
},
),
);
assert.equal(otherConn.openedShells.length, 0, "must not substitute another matching transport");
assert.equal(getClientConstructCount(), 1, "should attempt one fresh connection");
assert.deepEqual(
getConnectionReuseFallbackEvents(sender).map((m) => m.payload),
[{ sessionId: "copy", sourceSessionId: "missing-source" }],
);
});