feat(ssh): reuse authenticated connection for Copy Tab to skip re-MFA

Duplicating an SSH tab ("Copy Tab") used to open a brand-new SSH
connection, forcing a second MFA prompt on hosts with multi-factor auth.
Like Tabby's session multiplexing, open a new shell *channel* on the
source tab's already-authenticated connection instead, so the duplicate
reuses the existing transport and skips key exchange + authentication.

- copySession records the source session id (reuseConnectionFromSessionId)
  for connected, non-mosh SSH sessions; it is threaded down to the SSH
  bridge as options.sourceSessionId.
- startSession.cjs gains a reuse path that opens conn.shell() on the
  source connection. The shell wiring is extracted into a shared
  setupShellSession helper used by both the fresh and reuse paths.
- Connection lifecycle is reference-counted via a new sshConnectionPool
  module (mirrors Tabby ref/unref/destroy): the shared transport + jump
  host chain are torn down only when the last channel closes, so closing
  a copy — or the original while a copy is open — never kills siblings.
  terminalBridge.closeSession routes SSH teardown through the same release.
- Reuse falls back to a fresh connection when the source is gone, and is
  skipped for X11 hosts (X11 is negotiated per channel).

Tests: sshConnectionPool refcount unit tests, bridge reuse integration
tests (reuse vs fresh, sibling survival, X11 skip, fallback), and
terminalBridge close lifecycle tests.

Refs #1204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
bincxz
2026-06-03 10:43:49 +08:00
parent b7093f88b1
commit 85f486e6cd
15 changed files with 1193 additions and 279 deletions

View File

@@ -806,6 +806,17 @@ export const useSessionState = () => {
? options?.localShellType
: session.shellType;
// Reuse the source connection only for plain (non-mosh) SSH shell
// sessions that are actually connected. ssh2 multiplexes multiple shell
// channels over one authenticated connection, so the copy can skip a
// second MFA prompt (issue #1204). Local/serial/telnet/mosh sessions have
// no such reusable channel concept, and reusing a still-connecting or
// disconnected source would be pointless, so they connect fresh.
const isReusableSshSource =
(session.protocol === 'ssh' || session.protocol === undefined) &&
!session.moshEnabled &&
session.status === 'connected';
const newSession: TerminalSession = {
id: newSessionId,
hostId: session.hostId,
@@ -823,6 +834,7 @@ export const useSessionState = () => {
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
reuseConnectionFromSessionId: isReusableSshSource ? sessionId : undefined,
};
// Schedule the activeTab + tabOrder updates only when creation

View File

@@ -99,6 +99,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
startupCommand,
noAutoRun,
reuseConnectionFromSessionId,
serialConfig,
hotkeyScheme = "disabled",
keyBindings = [],
@@ -569,6 +570,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
knownHosts,
resolvedChainHosts,
sessionId,
reuseConnectionFromSessionId,
startupCommand,
noAutoRun,
terminalSettings,

View File

@@ -372,6 +372,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths,
knownHosts: ctx.knownHosts,
// Ask the bridge to reuse the source tab's authenticated connection
// (issue #1204). Only honored on the very first connect attempt; the
// bridge silently falls back to a fresh connection if the source is
// gone, so reconnect/retry after the source closed still works.
sourceSessionId: ctx.reuseConnectionFromSessionId,
});
};

View File

@@ -88,6 +88,9 @@ export type TerminalSessionStartersContext = {
knownHosts?: KnownHost[];
resolvedChainHosts: Host[];
sessionId: string;
// Source session id to reuse an authenticated SSH connection from when this
// tab is a "Copy Tab" duplicate (issue #1204).
reuseConnectionFromSessionId?: string;
startupCommand?: string;
noAutoRun?: boolean;
terminalSettings?: TerminalSettings;

View File

@@ -85,6 +85,10 @@ export interface TerminalProps {
sessionId: string;
startupCommand?: string;
noAutoRun?: boolean;
// When this tab was created via "Copy Tab" on a connected SSH session, the id
// of the source session whose authenticated connection should be reused for a
// new shell channel — skipping a second MFA prompt (issue #1204).
reuseConnectionFromSessionId?: string;
serialConfig?: SerialConfig;
hotkeyScheme?: "disabled" | "mac" | "pc";
keyBindings?: KeyBinding[];

View File

@@ -686,6 +686,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
sessionId={session.id}
startupCommand={session.startupCommand}
noAutoRun={session.noAutoRun}
reuseConnectionFromSessionId={session.reuseConnectionFromSessionId}
serialConfig={session.serialConfig}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}

View File

@@ -335,4 +335,11 @@ export interface TerminalSession {
localShellArgs?: string[]; // Shell args for local terminals (from discovery)
localShellName?: string; // Display name for local shell (e.g., "Zsh", "Ubuntu (WSL)")
localShellIcon?: string; // Icon identifier for local shell (e.g., "zsh", "ubuntu")
// For tabs created via "Copy Tab" on an SSH session: the id of the source
// session whose already-authenticated connection should be reused so the
// duplicate does not trigger a second MFA prompt (issue #1204). The bridge
// reuses the source connection when it is still live, otherwise it falls back
// to a fresh connection — so this also applies on reconnect: a reconnect
// reuses the source again if still connected, else dials fresh.
reuseConnectionFromSessionId?: string;
}

View File

@@ -442,6 +442,12 @@ function resolveLangFromCharset(charset) {
}
const { safeSend } = require("./ipcUtils.cjs");
const {
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
findReusableSession,
} = require("./sshConnectionPool.cjs");
const zmodemOverwritePending = new Map(); // requestId -> (decision) => void
@@ -784,6 +790,7 @@ const startSessionApi = createStartSessionApi({
shouldLogSshDebugMessage, log, createSshDiagnosticLogger,
buildAlgorithms, randomUUID, findDefaultPrivateKey, findAllDefaultPrivateKeys,
preparePrivateKeyForAuth, loadFirstIdentityFileForAuth, createKeyboardInteractiveHandler,
createConnectionRef, acquireConnectionRef, releaseConnectionRef, findReusableSession,
get probeReceiveConflicts() { return probeReceiveConflicts; },
get removeRemoteFiles() { return removeRemoteFiles; },
get restoreRemoteModes() { return restoreRemoteModes; },

View File

@@ -0,0 +1,238 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
const { releaseConnectionRef } = require("./sshConnectionPool.cjs");
// 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) {
const bridgePath = require.resolve("./sshBridge.cjs");
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
const originalLoad = Module._load;
let clientConstructCount = 0;
class MockSSHClient extends EventEmitter {
connect() {
clientConstructCount += 1;
// 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() {}
destroy() {}
exec() {}
shell() {}
}
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockSSHClient,
utils: { parseKey: () => new Error("no key") },
};
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
const bridge = require("./sshBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
Module._load = originalLoad;
});
return { bridge, getClientConstructCount: () => clientConstructCount };
}
function makeSender() {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) { this.sent.push({ channel, payload }); },
};
}
// 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.end = () => { conn.ended += 1; };
conn.destroy = () => {};
conn.shell = (_opts, _shellOpts, cb) => {
const stream = makeStream();
conn.openedShells.push(stream);
// ssh2 invokes the callback asynchronously.
setImmediate(() => cb(null, stream));
};
return conn;
}
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();
const sourceStream = makeStream();
// Seed a live source session as if it had connected normally, including the
// reference-counted descriptor the owner session carries.
sessions.set("source", {
conn: sourceConn,
stream: sourceStream,
chainConnections: [],
connRef: { count: 1, conn: sourceConn, chainConnections: [] },
webContentsId: 1,
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"));
});
test("closing the reused channel keeps the source connection alive", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
const connRef = { count: 1, conn: sourceConn, chainConnections: [] };
sessions.set("source", {
conn: sourceConn,
stream: makeStream(),
chainConnections: [],
connRef,
webContentsId: 1,
});
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");
// Now releasing the source (last holder) ends the connection.
assert.equal(releaseConnectionRef(sessions.get("source")), true);
assert.equal(sourceConn.ended, 1);
});
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", {
conn: sourceConn,
stream: makeStream(),
chainConnections: [],
connRef: { count: 1, conn: sourceConn, chainConnections: [] },
webContentsId: 1,
});
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("falls back to a fresh connection when the source is gone", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
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.
await assert.rejects(
() => start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "missing-source",
},
),
);
assert.equal(getClientConstructCount(), 1, "should attempt one fresh connection");
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
"use strict";
/**
* Shared SSH connection pool helpers.
*
* Background (issue #1204): "Copy Tab" on an MFA-protected host used to open a
* brand-new SSH connection, forcing the user through a second MFA prompt. Like
* Tabby's session-multiplexing, we instead open an additional shell *channel*
* on the already-authenticated connection. The SSH protocol natively supports
* many session channels over one transport, so no re-authentication is needed.
*
* Multiplexing means several terminal sessions can share one ssh2 `Client`
* (`conn`) and one underlying jump-host chain. The transport must only be torn
* down once the *last* of those sessions goes away — closing a single channel
* (or even the channel of the session that originally opened the connection)
* must not kill the siblings. We model that with a small reference-counted
* descriptor shared by every session on the same connection, mirroring Tabby's
* ref()/unref()/destroy() lifecycle.
*
* The same `sessions` Map is shared by sshBridge and terminalBridge (see
* registerBridges.cjs), so the session objects — and the `connRef` descriptor
* attached here — are visible to both. That lets terminalBridge's closeSession
* and sshBridge's own connection event handlers funnel teardown through the
* same release path.
*/
/**
* Attach a fresh reference-counted connection descriptor to the session that
* established the connection. Called once, for the "owner" session, right after
* its shell channel opens.
*
* @param {object} session - the owner session object stored in the sessions Map
* @param {object} conn - the ssh2 Client for the established connection
* @param {Array} chainConnections - jump-host connections that must be ended
* together with the transport (owned by the connection, not any one channel)
* @returns {{ count: number, conn: object, chainConnections: Array }} descriptor
*/
function createConnectionRef(session, conn, chainConnections) {
const connRef = {
count: 1,
conn,
chainConnections: Array.isArray(chainConnections) ? chainConnections : [],
};
session.connRef = connRef;
return connRef;
}
/**
* Register an additional session (a reused channel) against an existing
* connection descriptor, incrementing its reference count.
*
* @param {object} session - the new session sharing the connection
* @param {{ count: number }} connRef - descriptor from createConnectionRef
*/
function acquireConnectionRef(session, connRef) {
if (!connRef) return;
connRef.count += 1;
session.connRef = connRef;
}
/**
* Release this session's hold on its shared connection.
*
* Decrements the reference count. When it reaches zero (the last channel is
* gone) the underlying transport and any jump-host chain connections are torn
* down. The caller remains responsible for closing this session's own shell
* stream/channel; this only governs the *shared* transport.
*
* Safe to call multiple times for the same session — the descriptor is detached
* after the first release so a later duplicate call is a no-op (important
* because both a stream "close" event and an explicit closeSession can fire).
*
* @param {object} session - the session being torn down
* @returns {boolean} true if the shared transport was ended by this call
*/
function releaseConnectionRef(session) {
const connRef = session && session.connRef;
if (!connRef) return false;
// Detach immediately so re-entrant / duplicate releases for the same session
// cannot double-decrement the shared counter.
session.connRef = null;
connRef.count -= 1;
if (connRef.count > 0) {
return false;
}
try {
connRef.conn?.end();
} catch {
/* connection may already be gone */
}
for (const c of connRef.chainConnections) {
try {
c?.end();
} catch {
/* ignore */
}
}
// Drop references so the descriptor doesn't pin connections after teardown.
connRef.chainConnections = [];
connRef.conn = null;
return true;
}
/**
* Find a live, fully-connected session whose authenticated SSH connection can
* host an additional shell channel. Used to satisfy a reuse request from a
* duplicated tab.
*
* Returns null when the source session is gone, has no usable connection, or is
* not an interactive SSH shell session (e.g. SFTP-only or local sessions), so
* the caller can safely fall back to establishing a fresh connection.
*
* @param {Map} sessions - the shared sessions Map
* @param {string} sourceSessionId - id of the session to reuse
* @returns {object|null} the reusable source session, or null
*/
function findReusableSession(sessions, sourceSessionId) {
if (!sessions || !sourceSessionId) return null;
const source = sessions.get(sourceSessionId);
if (!source) return null;
// Must be an interactive SSH shell session with a connection we own a
// reference to. `stream` + `connRef` are only set for shell sessions started
// through startSession.cjs; SFTP/exec-only or local/telnet/serial sessions
// won't have both, so they're skipped.
if (!source.conn || !source.stream || !source.connRef) return null;
// ssh2 Client exposes no public "is connected" flag; rely on the descriptor
// still being attached (it is nulled out on teardown) plus a non-destroyed
// underlying socket when ssh2 exposes one.
const sock = source.conn._sock;
if (sock && sock.destroyed) return null;
return source;
}
module.exports = {
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
findReusableSession,
};

View File

@@ -0,0 +1,139 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
findReusableSession,
} = require("./sshConnectionPool.cjs");
function makeConn() {
return {
ended: 0,
end() { this.ended += 1; },
};
}
function makeChainConn() {
return {
ended: 0,
end() { this.ended += 1; },
};
}
test("releaseConnectionRef ends transport only when the last channel closes", () => {
const conn = makeConn();
const chain = [makeChainConn(), makeChainConn()];
const owner = {};
const reused = {};
const connRef = createConnectionRef(owner, conn, chain);
assert.equal(connRef.count, 1);
acquireConnectionRef(reused, connRef);
assert.equal(connRef.count, 2);
assert.equal(reused.connRef, connRef);
// Closing the reused channel must not end the shared transport.
let ended = releaseConnectionRef(reused);
assert.equal(ended, false);
assert.equal(conn.ended, 0);
assert.equal(reused.connRef, null);
// Closing the last (owner) channel ends the transport + chain.
ended = releaseConnectionRef(owner);
assert.equal(ended, true);
assert.equal(conn.ended, 1);
assert.equal(chain[0].ended, 1);
assert.equal(chain[1].ended, 1);
assert.equal(owner.connRef, null);
});
test("releaseConnectionRef keeps siblings alive when the owner closes first", () => {
const conn = makeConn();
const owner = {};
const reused = {};
const connRef = createConnectionRef(owner, conn, []);
acquireConnectionRef(reused, connRef);
// Owner (the session that opened the connection) closes while a copy is live.
assert.equal(releaseConnectionRef(owner), false);
assert.equal(conn.ended, 0, "connection must survive for the remaining copy");
// The remaining copy is the last holder and ends the transport.
assert.equal(releaseConnectionRef(reused), true);
assert.equal(conn.ended, 1);
});
test("releaseConnectionRef is idempotent per session", () => {
const conn = makeConn();
const owner = {};
const connRef = createConnectionRef(owner, conn, []);
acquireConnectionRef({}, connRef); // bump count to 2 so a double release can't reach 0 by itself
assert.equal(releaseConnectionRef(owner), false);
// A second release for the same session must be a no-op (no double decrement).
assert.equal(releaseConnectionRef(owner), false);
assert.equal(connRef.count, 1);
assert.equal(conn.ended, 0);
});
test("releaseConnectionRef on a session without a descriptor is a safe no-op", () => {
assert.equal(releaseConnectionRef({}), false);
assert.equal(releaseConnectionRef(null), false);
assert.equal(releaseConnectionRef(undefined), false);
});
test("single-channel connection ends immediately on release", () => {
const conn = makeConn();
const chain = [makeChainConn()];
const owner = {};
createConnectionRef(owner, conn, chain);
assert.equal(releaseConnectionRef(owner), true);
assert.equal(conn.ended, 1);
assert.equal(chain[0].ended, 1);
});
test("findReusableSession returns a live interactive SSH shell session", () => {
const sessions = new Map();
const source = {
conn: { _sock: { destroyed: false } },
stream: {},
connRef: { count: 1 },
};
sessions.set("src", source);
assert.equal(findReusableSession(sessions, "src"), source);
});
test("findReusableSession rejects sessions missing a usable connection", () => {
const sessions = new Map();
// Missing stream (e.g. SFTP-only session)
sessions.set("no-stream", { conn: {}, connRef: { count: 1 } });
assert.equal(findReusableSession(sessions, "no-stream"), null);
// Missing connRef (not started through the shell path / already torn down)
sessions.set("no-ref", { conn: {}, stream: {} });
assert.equal(findReusableSession(sessions, "no-ref"), null);
// Missing conn (local/telnet/serial session)
sessions.set("no-conn", { stream: {}, connRef: { count: 1 } });
assert.equal(findReusableSession(sessions, "no-conn"), null);
// Destroyed underlying socket
sessions.set("dead", {
conn: { _sock: { destroyed: true } },
stream: {},
connRef: { count: 1 },
});
assert.equal(findReusableSession(sessions, "dead"), null);
});
test("findReusableSession handles missing inputs gracefully", () => {
assert.equal(findReusableSession(null, "x"), null);
assert.equal(findReusableSession(new Map(), ""), null);
assert.equal(findReusableSession(new Map(), "absent"), null);
});

View File

@@ -27,6 +27,7 @@ const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
const { releaseConnectionRef } = require("./sshConnectionPool.cjs");
const execFileAsync = promisify(execFile);
@@ -781,16 +782,33 @@ function closeSession(event, payload) {
session.zmodemSentry?.cancel();
session.flushPendingData?.();
if (session.stream) {
// Always close this session's own shell channel.
session.stream.close();
session.conn?.end();
if (session.connRef) {
// Multiplexed SSH shell (issue #1204): several tabs may share one
// authenticated connection. Closing this tab must only tear the shared
// transport (and jump-host chain) down once the last channel is gone,
// so route teardown through the reference-counted descriptor instead of
// ending the connection directly. releaseConnectionRef ends the chain
// connections itself when the count reaches zero.
releaseConnectionRef(session);
} else {
// Legacy / non-multiplexed path: this session owns its connection.
session.conn?.end();
if (session.chainConnections) {
for (const c of session.chainConnections) {
try { c.end(); } catch {}
}
}
}
} else if (session.proc) {
session.proc.kill();
} else if (session.socket) {
session.socket.destroy();
} else if (session.serialPort) {
session.serialPort.close();
}
if (session.chainConnections) {
} else if (session.chainConnections) {
// Non-stream session still carrying a jump-host chain (defensive).
for (const c of session.chainConnections) {
try { c.end(); } catch {}
}

View File

@@ -0,0 +1,108 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const { createConnectionRef, acquireConnectionRef } = require("./sshConnectionPool.cjs");
// Verifies the terminalBridge close path respects connection multiplexing
// (issue #1204): closing one tab that shares an SSH connection must only close
// that tab's channel, and only tear the shared transport down when the last
// tab is gone.
function makeStream() {
return {
closed: 0,
close() { this.closed += 1; },
};
}
function makeConn() {
return {
ended: 0,
end() { this.ended += 1; },
};
}
function makeSession(conn, connRef) {
return {
stream: makeStream(),
conn,
chainConnections: [],
connRef,
zmodemSentry: { cancel() {} },
};
}
test("closing a reused SSH tab keeps the shared connection alive", () => {
const sessions = new Map();
terminalBridge.init({ sessions, electronModule: {} });
const conn = makeConn();
const owner = makeSession(conn, null);
const connRef = createConnectionRef(owner, conn, []);
const copy = makeSession(conn, null);
acquireConnectionRef(copy, connRef);
sessions.set("owner", owner);
sessions.set("copy", copy);
assert.equal(connRef.count, 2);
// Close the copy tab.
terminalBridge.closeSession({ sender: {} }, { sessionId: "copy" });
assert.equal(copy.stream.closed, 1, "copy channel closed");
assert.equal(conn.ended, 0, "shared connection must stay up for the owner");
assert.equal(connRef.count, 1);
assert.equal(sessions.has("copy"), false);
// Close the owner tab — now the last holder, so the connection is ended.
terminalBridge.closeSession({ sender: {} }, { sessionId: "owner" });
assert.equal(owner.stream.closed, 1, "owner channel closed");
assert.equal(conn.ended, 1, "connection ended once last channel closes");
assert.equal(connRef.count, 0);
});
test("closing the owner tab first does not strand the copy's connection", () => {
const sessions = new Map();
terminalBridge.init({ sessions, electronModule: {} });
const conn = makeConn();
const owner = makeSession(conn, null);
const connRef = createConnectionRef(owner, conn, []);
const copy = makeSession(conn, null);
acquireConnectionRef(copy, connRef);
sessions.set("owner", owner);
sessions.set("copy", copy);
// Close the owner first.
terminalBridge.closeSession({ sender: {} }, { sessionId: "owner" });
assert.equal(conn.ended, 0, "connection survives for the still-open copy");
assert.equal(connRef.count, 1);
// Closing the copy (now last) ends the connection.
terminalBridge.closeSession({ sender: {} }, { sessionId: "copy" });
assert.equal(conn.ended, 1);
});
test("legacy SSH session without connRef still ends its own connection + chain", () => {
const sessions = new Map();
terminalBridge.init({ sessions, electronModule: {} });
const conn = makeConn();
const chain = [makeConn(), makeConn()];
const session = {
stream: makeStream(),
conn,
chainConnections: chain,
connRef: null,
zmodemSentry: { cancel() {} },
};
sessions.set("legacy", session);
terminalBridge.closeSession({ sender: {} }, { sessionId: "legacy" });
assert.equal(session.stream.closed, 1);
assert.equal(conn.ended, 1, "non-multiplexed connection ends directly");
assert.equal(chain[0].ended, 1);
assert.equal(chain[1].ended, 1);
});

5
global.d.ts vendored
View File

@@ -120,6 +120,11 @@ declare global {
sshDebugLogEnabled?: boolean;
// Local SSH key file paths (from SSH config IdentityFile)
identityFilePaths?: string[];
// When set, reuse the already-authenticated SSH connection of this existing
// session by opening a new shell channel on it, instead of dialing a fresh
// connection. Lets a duplicated tab skip a second MFA prompt (issue #1204).
// The bridge falls back to a fresh connection if the source is gone.
sourceSessionId?: string;
}
interface SftpStatResult {