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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; },
|
||||
|
||||
238
electron/bridges/sshBridge.connectionReuse.test.cjs
Normal file
238
electron/bridges/sshBridge.connectionReuse.test.cjs
Normal 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
141
electron/bridges/sshConnectionPool.cjs
Normal file
141
electron/bridges/sshConnectionPool.cjs
Normal 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,
|
||||
};
|
||||
139
electron/bridges/sshConnectionPool.test.cjs
Normal file
139
electron/bridges/sshConnectionPool.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
108
electron/bridges/terminalBridge.closeReusedSession.test.cjs
Normal file
108
electron/bridges/terminalBridge.closeReusedSession.test.cjs
Normal 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
5
global.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user