Reuse one SSH session per host, not one per command

`ssh_exec` built and tore down a complete SSH connection every time it
ran. Its `defer` freed the session and closed the transport before
returning, so each command paid a TCP connect, a handshake, a host-key
check, and a public-key or Secure Enclave signature. With a jump host
the whole route was rebuilt per hop, per command: an agent turn running
five commands behind one jump performed ten handshakes to do work that
needs one connection and five channels.

SFTP was pooled per `(runID, hostID)`, which is barely a pool — a run is
short — and it had a reentrancy hole. Two tool calls that both reached
the `await` on `connectSFTP` before either stored its client each built
a connection, and one was never disconnected.

The pool is now keyed by host alone and outlives runs and conversations,
which is what makes an idle timeout meaningful: run-scoped connections
are already bounded, so a sweep would rarely have fired. A follow-up
message reuses the connection; three idle minutes gives it back. Exec
and SFTP stay on separate sessions so a long command cannot block a
directory listing — at most two connections per host, against one per
command before.

Two boundaries make reuse safe rather than merely cheap.

A retry only happens while the command is provably not running.
`channelUnavailable` is thrown for a dead session, a channel that would
not open, or a `process_startup` that failed; everything after startup
succeeds is rethrown unchanged. A socket that drops mid-read must not
silently run half an `rm` a second time.

A command that timed out or was cancelled leaves the channel mid-stream
with the remote possibly still working. Closing it courteously means a
blocking `libssh2_channel_close` on the queue every later command for
that host has to wait behind, so the session is abandoned instead and
`libssh2_session_free` reclaims the channel with it.

Reuse also has to keep answering the same question. Each entry carries a
route fingerprint — host, port, username, known host keys, proxy, jump
chain — built only from records the store already holds. No Keychain
read: this runs before every command, and a rotated credential on an
unchanged identity is not a mismatch, because the live session is still
the same box and the same login. Editing a host's address retires the
connection bound to the old one rather than running the next command on
a machine the user no longer means.

No connect or disconnect tool. The model does not manage this, so
putting four more definitions in every request would buy nothing.

`AgentRuntime.runCleanup` goes with it: closing pooled connections was
its only caller, and leaving it wired to a no-op invites someone to hang
per-run teardown off a pool that is no longer per-run.

The unit tests run against stubs and prove the pool's behaviour, not
libssh2's. That one connection now carries five commands still has to be
confirmed against a real host, in its `auth.log`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kaede AkinoandClaude Opus 5 committed 2026-08-25 22:58:31 +08:00
1 parent ffdd36aa1d
commit 4b861eae26
8 files changed
+1112 -324

No files matched your search

+12 -6
View File
@@ -96,8 +96,12 @@ final class AppModel {
connections = connectionCoordinator
agentRunRepository = dependencies.agentRunRepository ?? InMemoryAgentRunRepository()
agentHosts = AgentHostResolver(store: hosts)
let agentHostResolver = agentHosts
agentConnections = AgentConnectionPool(
hosts: agentHosts, coordinator: connectionCoordinator
configuration: { try await agentHostResolver.configuration(for: $0) },
routeFingerprint: { try await agentHostResolver.routeFingerprint(for: $0) },
openExec: { try await connectionCoordinator.connectExec(using: $0) },
openSFTP: { try await connectionCoordinator.connectSFTP(using: $0) }
)
hostRepository = dependencies.hostRepository
hostLibrary = HostLibraryModel(repository: dependencies.hostRepository)
@@ -121,8 +125,12 @@ final class AppModel {
self?.temporaryAgentConfiguration(for: hostID)
}
)
hosts.willDeleteHost = { [weak forwarding] _, ruleIDs in
hosts.willDeleteHost = { [weak forwarding, agentConnections] hostID, ruleIDs in
forwarding?.stop(ruleIDs)
// Not a `HostDeletionOutbox` step like the others: a live socket
// cannot outlive the process, so there is nothing to replay after a
// crash and nothing to make durable.
Task { await agentConnections.disconnect(hostID: hostID) }
}
hosts.willDeletePortForwardings = { [weak forwarding] ruleIDs in
forwarding?.stop(ruleIDs)
@@ -252,10 +260,7 @@ final class AppModel {
let runtime = AgentRuntime(
model: model, tools: tools, approval: approval,
repository: agentRunRepository,
userInteraction: WeakAgentUserInteraction(conversation),
runCleanup: { [agentConnections] runID in
await agentConnections.cleanup(runID: runID)
}
userInteraction: WeakAgentUserInteraction(conversation)
)
// No second Shell Integration gate here. `Pane.remoteSystemInfo` already
// withholds the probe when the setting is off, and `context` is the same
@@ -401,6 +406,7 @@ final class AppModel {
for window in liveWindows { window.closeAll() }
sftpTransfers.cancelAll()
forwarding.stopAll()
Task { [agentConnections] in await agentConnections.closeAll() }
outboxRunner?.stop()
}
+277 -31
View File
@@ -56,6 +56,40 @@ final class AgentHostResolver: Sendable {
throw AgentToolError.invalidHost
}
/// A stable description of *which machine, reached how, and as whom* — the
/// only question a pooled connection has to keep answering the same way.
///
/// Built from records the store already holds in memory, with deliberately
/// no Keychain read: this runs before every reuse, and resolving credentials
/// here would put private-key material on the hot path of every command to
/// no purpose. A rotated credential on an unchanged identity is therefore
/// not a mismatch — the live session is still the same box and the same
/// login, and the new credential applies to the next connect.
func routeFingerprint(for id: UUID) throws -> String {
guard let host = store.host(id) else {
// A pane-backed target has no Vault record to describe, and its
// configuration is rebuilt from the live pane on every connect. Tie
// the fingerprint to that pane still existing, so a closed tab
// invalidates the Agent's connection to it.
guard temporaryTargets().contains(where: { $0.id == id }) else {
throw AgentToolError.invalidHost
}
return "temporary:\(id.uuidString)"
}
return store.route(for: host).map { node in
let identity = store.identity(for: node)
let proxy = node.proxy.map {
"\($0.kind.rawValue)://\($0.username)@\($0.hostname):\($0.port)"
}
let keys = store.knownHosts(for: node).map(\.fingerprint).sorted()
return [
node.id.uuidString, node.host, String(node.port),
identity?.id.uuidString ?? "", identity?.username ?? "",
proxy ?? "", keys.joined(separator: ","),
].joined(separator: "\u{1F}")
}.joined(separator: "\u{1E}")
}
private static func stateName(_ status: ConnectionStatus) -> String {
switch status {
case .disconnected: "disconnected"
@@ -67,34 +101,238 @@ final class AgentHostResolver: Sendable {
}
}
/// Keeps one authenticated SSH-exec session and one SFTP session per host alive
/// across tool calls, runs, and conversations.
///
/// Before this existed, `ssh_exec` paid a full TCP connect, handshake, host-key
/// check, and signature for *every command* — and a jump-host route rebuilt
/// every hop each time. Keying by host rather than by run is what lets a
/// follow-up message reuse the connection; the idle sweep, not the end of a run,
/// is what gives it back.
///
/// Exec and SFTP stay on separate sessions so one long-running command cannot
/// block a directory listing. That is at most two connections per host, against
/// one per command before.
actor AgentConnectionPool {
private struct Key: Hashable { var runID: UUID; var hostID: UUID }
private let hosts: AgentHostResolver
private let coordinator: RemoteConnectionCoordinator
private var sftpClients: [Key: any SFTPClientProtocol] = [:]
nonisolated enum Kind: String, Hashable, Sendable, CaseIterable { case exec, sftp }
init(hosts: AgentHostResolver, coordinator: RemoteConnectionCoordinator) {
self.hosts = hosts
self.coordinator = coordinator
typealias ConfigurationResolving =
@Sendable (UUID) async throws -> SSHConnectionConfiguration
typealias FingerprintResolving = @Sendable (UUID) async throws -> String
typealias ExecOpening =
@Sendable (SSHConnectionConfiguration) async throws -> any SSHExecSessionProtocol
typealias SFTPOpening =
@Sendable (SSHConnectionConfiguration) async throws -> any SFTPClientProtocol
/// Long enough that a user reading output and asking a follow-up keeps the
/// connection, short enough that walking away does not leave an
/// authenticated session open on a server indefinitely.
static let defaultIdleTimeout = Duration.seconds(180)
private enum Client: Sendable {
case exec(any SSHExecSessionProtocol)
case sftp(any SFTPClientProtocol)
func disconnect() async {
switch self {
case .exec(let session): await session.disconnect()
case .sftp(let client): await client.disconnect()
}
}
}
func configuration(for hostID: UUID) async throws -> SSHConnectionConfiguration {
try await hosts.configuration(for: hostID)
private struct Key: Hashable { var hostID: UUID; var kind: Kind }
private struct Entry {
var client: Client
var fingerprint: String
var lastUsedAt: ContinuousClock.Instant
}
func sftp(runID: UUID, hostID: UUID) async throws -> any SFTPClientProtocol {
let key = Key(runID: runID, hostID: hostID)
if let client = sftpClients[key] { return client }
let configuration = try await hosts.configuration(for: hostID)
let client = try await coordinator.connectSFTP(using: configuration)
sftpClients[key] = client
private struct InFlight {
var fingerprint: String
var task: Task<Client, any Error>
}
private let configuration: ConfigurationResolving
private let routeFingerprint: FingerprintResolving
private let openExec: ExecOpening
private let openSFTP: SFTPOpening
private let idleTimeout: Duration
private let sweepInterval: Duration?
private var entries: [Key: Entry] = [:]
private var pending: [Key: InFlight] = [:]
private var sweeper: Task<Void, Never>?
init(
configuration: @escaping ConfigurationResolving,
routeFingerprint: @escaping FingerprintResolving,
openExec: @escaping ExecOpening,
openSFTP: @escaping SFTPOpening,
idleTimeout: Duration = AgentConnectionPool.defaultIdleTimeout,
sweepInterval: Duration? = .seconds(30)
) {
self.configuration = configuration
self.routeFingerprint = routeFingerprint
self.openExec = openExec
self.openSFTP = openSFTP
self.idleTimeout = idleTimeout
self.sweepInterval = sweepInterval
}
/// Runs one command on the host's pooled session, opening one if needed.
///
/// Retries exactly once, and only on `channelUnavailable` — the failure that
/// is thrown while the command is provably not running. A pooled session
/// that died quietly between commands is invisible to the caller; anything
/// that failed *after* the command started is surfaced, because re-running
/// half of an `rm` is worse than a failed tool call.
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int, hostID: UUID
) async throws -> SSHExecResult {
do {
return try await exec(hostID: hostID).execute(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit
)
} catch SSHExecSessionError.channelUnavailable {
await disconnect(hostID: hostID, kind: .exec)
return try await exec(hostID: hostID).execute(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit
)
}
}
func exec(hostID: UUID) async throws -> any SSHExecSessionProtocol {
guard case .exec(let session) = try await resolve(hostID: hostID, kind: .exec)
else { throw AgentToolError.invalidHost }
return session
}
func sftp(hostID: UUID) async throws -> any SFTPClientProtocol {
guard case .sftp(let client) = try await resolve(hostID: hostID, kind: .sftp)
else { throw AgentToolError.invalidHost }
return client
}
func cleanup(runID: UUID) async {
let keys = sftpClients.keys.filter { $0.runID == runID }
let clients = keys.compactMap { sftpClients.removeValue(forKey: $0) }
for client in clients { await client.disconnect() }
/// Reports whether anything was actually open, which is what lets callers
/// treat closing an already-closed host as a no-op rather than a failure.
@discardableResult
func disconnect(hostID: UUID, kind: Kind) async -> Bool {
guard let entry = entries.removeValue(forKey: Key(hostID: hostID, kind: kind))
else { return false }
stopSweepingIfEmpty()
await entry.client.disconnect()
return true
}
@discardableResult
func disconnect(hostID: UUID) async -> Bool {
var closed = false
for kind in Kind.allCases {
closed = await disconnect(hostID: hostID, kind: kind) || closed
}
return closed
}
func closeAll() async {
let closing = entries.values.map(\.client)
entries.removeAll()
stopSweepingIfEmpty()
for client in closing { await client.disconnect() }
}
/// Closes everything untouched for longer than the idle timeout and reports
/// how many connections remain. Driven by a background task in the app and
/// called directly, with a synthetic instant, by tests.
@discardableResult
func sweepIdle(now: ContinuousClock.Instant) async -> Int {
let expired = entries.filter { now - $0.value.lastUsedAt >= idleTimeout }
for key in expired.keys { entries[key] = nil }
stopSweepingIfEmpty()
for entry in expired.values { await entry.client.disconnect() }
return entries.count
}
func isConnected(hostID: UUID, kind: Kind) -> Bool {
entries[Key(hostID: hostID, kind: kind)] != nil
}
var openConnectionCount: Int { entries.count }
private func resolve(hostID: UUID, kind: Kind) async throws -> Client {
let fingerprint: String
do {
fingerprint = try await routeFingerprint(hostID)
} catch {
// The host is gone — deleted from the Vault, or a temporary target
// whose pane closed. Do not keep a session to it alive.
await disconnect(hostID: hostID)
throw error
}
let key = Key(hostID: hostID, kind: kind)
if let entry = entries.removeValue(forKey: key) {
guard entry.fingerprint == fingerprint else {
// Nothing waits on a stale socket. Its worker also frees the
// libssh2 session in `deinit`, so dropping the reference alone
// would be safe; this just makes it prompt.
Task { await entry.client.disconnect() }
return try await open(key: key, fingerprint: fingerprint)
}
entries[key] = Entry(
client: entry.client, fingerprint: fingerprint, lastUsedAt: .now
)
return entry.client
}
return try await open(key: key, fingerprint: fingerprint)
}
private func open(key: Key, fingerprint: String) async throws -> Client {
// Without this join, two tool calls that both miss the cache would each
// build a connection and one of them would never be pooled — or closed.
if let inFlight = pending[key], inFlight.fingerprint == fingerprint {
let client = try await inFlight.task.value
commit(client, key: key, fingerprint: fingerprint)
return client
}
let task = Task { [configuration, openExec, openSFTP] () async throws -> Client in
let resolved = try await configuration(key.hostID)
switch key.kind {
case .exec: return .exec(try await openExec(resolved))
case .sftp: return .sftp(try await openSFTP(resolved))
}
}
pending[key] = InFlight(fingerprint: fingerprint, task: task)
defer { if pending[key]?.fingerprint == fingerprint { pending[key] = nil } }
// Committed on every path that receives a client, not only on the path
// that started the task: whoever started it may have been cancelled
// while a second caller was still waiting on the same connection.
let client = try await task.value
commit(client, key: key, fingerprint: fingerprint)
return client
}
private func commit(_ client: Client, key: Key, fingerprint: String) {
entries[key] = Entry(client: client, fingerprint: fingerprint, lastUsedAt: .now)
startSweeping()
}
private func startSweeping() {
guard sweeper == nil, let sweepInterval else { return }
sweeper = Task { [weak self, sweepInterval] in
while !Task.isCancelled {
do { try await Task.sleep(for: sweepInterval) } catch { return }
guard let self, await self.sweepIdle(now: .now) > 0 else { return }
}
}
}
private func stopSweepingIfEmpty() {
guard entries.isEmpty, pending.isEmpty else { return }
sweeper?.cancel()
sweeper = nil
}
}
@@ -175,11 +413,10 @@ nonisolated enum AgentBuiltInTools {
)
input = Data(secret.utf8)
}
let configuration = try await pool.configuration(for: hostID)
let output = try await SSHSessionActor.execute(
let output = try await pool.execute(
command, workingDirectory: arguments.optionalString("cwd"), stdin: input,
timeout: TimeInterval(arguments.optionalInt("timeout_seconds") ?? 60),
outputLimit: 256 * 1_024, using: configuration
outputLimit: 256 * 1_024, hostID: hostID
)
return result(invocation, .object([
"stdout": .string(output.stdout), "stderr": .string(output.stderr),
@@ -397,9 +634,9 @@ nonisolated enum AgentBuiltInTools {
return AnyAgentTool(
descriptor: tool,
preflight: hostPathPreflight(safety: .locallyReadOnly),
execute: { invocation, context in
execute: { invocation, _ in
let arguments = try Arguments(invocation)
let client = try await pool.sftp(runID: context.runID, hostID: arguments.uuid("host_id"))
let client = try await pool.sftp(hostID: arguments.uuid("host_id"))
return try await operation(client, normalizeRemotePath(arguments.string("path")), invocation)
}
)
@@ -424,23 +661,32 @@ nonisolated enum AgentBuiltInTools {
return AnyAgentTool(
descriptor: tool,
preflight: hostPathPreflight(safety: .requiresAuthorization),
execute: { invocation, context in
execute: { invocation, _ in
let arguments = try Arguments(invocation)
let client = try await pool.sftp(runID: context.runID, hostID: arguments.uuid("host_id"))
let client = try await pool.sftp(hostID: arguments.uuid("host_id"))
return try await operation(client, normalizeRemotePath(arguments.string("path")), arguments, invocation)
}
)
}
/// Resolves `host_id` before approval so the tool card names the host the
/// action is aimed at rather than an opaque argument blob.
private static func hostPreflight(
safety: AgentToolDescriptor.Safety
) -> @Sendable (AgentToolInvocation) async throws -> AgentToolPreflight {
{ invocation in
var checked = invocation
checked.targetHostID = try Arguments(invocation).uuid("host_id")
return AgentToolPreflight(invocation: checked, safety: safety)
}
}
private static func hostPathPreflight(
safety: AgentToolDescriptor.Safety
) -> @Sendable (AgentToolInvocation) async throws -> AgentToolPreflight {
{ invocation in
let arguments = try Arguments(invocation)
_ = try normalizeRemotePath(arguments.string("path"))
var checked = invocation
checked.targetHostID = try arguments.uuid("host_id")
return AgentToolPreflight(invocation: checked, safety: safety)
_ = try normalizeRemotePath(Arguments(invocation).string("path"))
return try await hostPreflight(safety: safety)(invocation)
}
}
+4 -7
View File
@@ -174,7 +174,6 @@ actor AgentRuntime {
private let approval: any AgentApprovalHandling
private let repository: any AgentRunPersisting
private let userInteraction: any AgentUserInteractionHandling
private let runCleanup: @Sendable (UUID) async -> Void
let secretBroker: SecretBroker
private var tools: [String: AnyAgentTool]
private var runTask: Task<Void, Never>?
@@ -191,7 +190,6 @@ actor AgentRuntime {
repository: any AgentRunPersisting = InMemoryAgentRunRepository(),
secretBroker: SecretBroker = SecretBroker(),
userInteraction: any AgentUserInteractionHandling = UnavailableAgentUserInteraction(),
runCleanup: @escaping @Sendable (UUID) async -> Void = { _ in },
maxTurns: Int = 24,
maxToolCalls: Int = 64
) {
@@ -207,7 +205,6 @@ actor AgentRuntime {
self.repository = repository
self.secretBroker = secretBroker
self.userInteraction = userInteraction
self.runCleanup = runCleanup
self.maxTurns = maxTurns
self.maxToolCalls = maxToolCalls
}
@@ -542,10 +539,10 @@ actor AgentRuntime {
}
private func finish(runID: UUID) {
Task {
await secretBroker.discardSecrets(for: runID)
await runCleanup(runID)
}
// Secrets, and nothing else. SSH and SFTP connections used to be closed
// here too; they are now pooled per host and given back by an idle
// sweep, so a run ending is no longer the event that releases them.
Task { await secretBroker.discardSecrets(for: runID) }
activeRunID = nil
runTask = nil
continuation?.finish()
@@ -16,6 +16,23 @@ nonisolated final class RemoteConnectionCoordinator: Sendable {
func makeSFTPClient() -> any SFTPClientProtocol { SFTPClient() }
func makeExecSession() -> any SSHExecSessionProtocol { SSHExecSession() }
/// Mirrors `connectSFTP`, including its guarantee that a session which fails
/// to come up is torn down here rather than handed back half-open.
func connectExec(
using configuration: SSHConnectionConfiguration
) async throws -> any SSHExecSessionProtocol {
let session = makeExecSession()
do {
try await session.connect(using: configuration)
return session
} catch {
await session.disconnect()
throw error
}
}
func connectSFTP(
using configuration: SSHConnectionConfiguration,
progress: SSHRouteProgressHandler? = nil,
@@ -52,6 +52,26 @@ nonisolated struct RemoteSystemInfo: Hashable, Codable, Sendable {
try container.encodeIfPresent(architecture, forKey: .architecture)
}
/// Reads the `id|version|kernel_name|kernel_release|arch` line that both
/// probes print — the PTY bootstrap's token-delimited one and the Agent's
/// dedicated exec channel. Returns nil when the line is malformed or when
/// every field was rejected by `sanitized`.
///
/// Empty fields are meaningful (a BSD has no os-release), so the split keeps
/// them rather than collapsing the run of separators.
init?(probeFields line: some StringProtocol) {
let fields = line.split(
separator: "|", maxSplits: 4, omittingEmptySubsequences: false
)
guard fields.count == 5 else { return nil }
let values = fields.map { Self.unquoted(String($0)) }
self.init(
distributionID: values[0], distributionVersion: values[1],
kernelName: values[2], kernelRelease: values[3], architecture: values[4]
)
if isEmpty { return nil }
}
var isEmpty: Bool {
distributionID == nil && distributionVersion == nil
&& kernelName == nil && kernelRelease == nil && architecture == nil
@@ -75,6 +95,17 @@ nonisolated struct RemoteSystemInfo: Hashable, Codable, Sendable {
return lines
}
/// os-release quotes values that could contain spaces. Stripping a matched
/// pair here rather than in `sanitized` keeps the ASCII-token grammar itself
/// free of exceptions: `"22.04"` becomes `22.04`, while a lone quote stays
/// and is rejected.
private static func unquoted(_ raw: String) -> String {
guard raw.count >= 2, let first = raw.first, let last = raw.last,
first == last, first == "\"" || first == "'"
else { return raw }
return String(raw.dropFirst().dropLast())
}
private static func sanitized(_ proposed: String?) -> String? {
guard let proposed else { return nil }
let value = proposed.trimmingCharacters(in: .whitespacesAndNewlines)
+377
View File
@@ -0,0 +1,377 @@
import CSSH2
import Darwin
import Foundation
nonisolated struct SSHExecResult: Hashable, Sendable {
var stdout: String
var stderr: String
var exitCode: Int32
var signal: String?
var stdoutTruncated: Bool
var stderrTruncated: Bool
}
/// The one failure that is safe to retry on a fresh connection.
///
/// It is thrown only while the remote command is provably *not running*: the
/// session was already gone, or the channel could not be opened or started.
/// Every failure after `process_startup` succeeds is rethrown unchanged, because
/// by then the command may have partially run and a transparent retry could
/// delete something twice.
nonisolated enum SSHExecSessionError: LocalizedError, Sendable {
case channelUnavailable(any Error)
var errorDescription: String? {
switch self {
case .channelUnavailable(let underlying): underlying.localizedDescription
}
}
}
/// A long-lived authenticated SSH session that runs each command on its own
/// non-PTY channel.
///
/// Splitting connect from execute is the whole point: the handshake, host-key
/// check, and signature are paid once per host instead of once per command, and
/// a jump-host route stops rebuilding every hop for every `ls`. Semantics are
/// unchanged — a command still gets a private channel and a private
/// `/bin/sh -lc`, so there is no shared shell state and no cwd carry-over.
nonisolated protocol SSHExecSessionProtocol: Sendable {
func connect(using configuration: SSHConnectionConfiguration) async throws
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int
) async throws -> SSHExecResult
func disconnect() async
}
actor SSHExecSession: SSHExecSessionProtocol {
private nonisolated let worker = SSHExecWorker()
func connect(using configuration: SSHConnectionConfiguration) async throws {
try await worker.connect(using: configuration)
}
/// Actor-isolated, so commands on one session queue behind each other. That
/// is not incidental: a libssh2 session cannot be driven from two places at
/// once, and the worker's queue would serialize them anyway.
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int
) async throws -> SSHExecResult {
let cancellation = TransferCancellationToken()
return try await withTaskCancellationHandler {
try await worker.execute(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation
)
} onCancel: {
cancellation.cancel()
}
}
func disconnect() async { await worker.disconnect() }
}
private nonisolated final class SSHExecWorker: @unchecked Sendable {
/// Every libssh2 call for this session happens here, the idle keepalive
/// included. libssh2 sessions are not thread-safe, and serializing on one
/// queue is what makes reuse across commands safe at all.
private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.exec")
private var routeTransport: SSHRoutedSocket?
private var session: OpaquePointer?
private var keepaliveTimer: (any DispatchSourceTimer)?
/// Set when the transport is known to be gone. The next `execute` reports
/// `channelUnavailable` instead of calling into a session that cannot answer.
private var isDead = false
deinit { cleanup() }
func connect(using configuration: SSHConnectionConfiguration) async throws {
try await perform {
self.cleanup()
let (transport, session) = try SSHSessionWorker.openAuthenticatedSession(
using: configuration, timeout: 20
)
self.routeTransport = transport
self.session = session
self.isDead = false
self.startKeepalive(for: session)
}
}
func disconnect() async { await performNoThrow { self.cleanup() } }
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken
) async throws -> SSHExecResult {
try await perform {
do {
return try self.executeOnQueue(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit, cancellation: cancellation
)
} catch {
// Still on the queue, and the channel defer has already run, so
// this is the right moment to give the socket back rather than
// leave a corpse in the pool for the idle sweep to find.
if self.isDead { self.cleanup() }
throw error
}
}
}
private func executeOnQueue(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken
) throws -> SSHExecResult {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw TermindSSHError.library("The command is empty.")
}
guard let session, !isDead else {
throw SSHExecSessionError.channelUnavailable(TermindSSHError.disconnected)
}
// Blocking for the two setup calls, non-blocking for the pump below.
libssh2_session_set_blocking(session, 1)
guard let channel = libssh2_channel_open_ex(
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0
) else {
throw notStarted(session, prefix: "Unable to open SSH exec channel")
}
// Only a command that ran to completion leaves a channel worth closing
// politely, and a session worth keeping.
var completed = false
defer {
if completed {
libssh2_session_set_blocking(session, 1)
_ = libssh2_channel_free(channel)
} else {
// Timed out, cancelled, or the transport failed: the command may
// still be running and the channel is mid-stream. Blocking here
// to close it courteously would stall every later command on this
// host, so give the whole session up instead — `cleanup` frees
// it, and freeing a session reclaims its channels with it.
isDead = true
}
}
let effective: String
if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines),
!cwd.isEmpty
{
effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))"
} else {
effective = "/bin/sh -lc \(Self.shellQuote(command))"
}
let started = effective.withCString {
libssh2_channel_process_startup(channel, "exec", 4, $0, UInt32(effective.utf8.count))
}
guard started == 0 else {
throw notStarted(session, prefix: "Unable to start remote command")
}
// Past this line the command is running. Nothing below may be retried.
libssh2_session_set_blocking(session, 0)
var pendingInput = stdin ?? Data()
if !pendingInput.isEmpty, pendingInput.last != 0x0A { pendingInput.append(0x0A) }
var inputOffset = 0
var sentEOF = pendingInput.isEmpty
if sentEOF { _ = libssh2_channel_send_eof(channel) }
var standardOut = Data()
var standardError = Data()
var outTruncated = false
var errorTruncated = false
var buffer = [CChar](repeating: 0, count: 16 * 1_024)
let deadline = Date().addingTimeInterval(max(1, min(timeout, 15 * 60)))
var lastKeepalive = Date()
while libssh2_channel_eof(channel) == 0 {
if cancellation.isCancelled { throw CancellationError() }
if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") }
var progressed = false
if inputOffset < pendingInput.count {
let written = pendingInput.withUnsafeBytes { bytes in
libssh2_channel_write_ex(
channel, 0,
bytes.baseAddress!.advanced(by: inputOffset)
.assumingMemoryBound(to: CChar.self),
bytes.count - inputOffset
)
}
if written > 0 { inputOffset += written; progressed = true }
else if written != LIBSSH2_ERROR_EAGAIN {
throw failed(session, prefix: "Unable to write command input")
}
} else if !sentEOF {
let result = libssh2_channel_send_eof(channel)
if result == 0 { sentEOF = true; progressed = true }
else if result != LIBSSH2_ERROR_EAGAIN {
throw failed(session, prefix: "Unable to finish command input")
}
}
for stream in [Int32(0), Int32(1)] {
while true {
let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count)
if count > 0 {
progressed = true
let chunk = Data(bytes: buffer, count: count)
if stream == 0 {
Self.appendCapped(
chunk, to: &standardOut, limit: outputLimit,
truncated: &outTruncated
)
} else {
Self.appendCapped(
chunk, to: &standardError, limit: outputLimit,
truncated: &errorTruncated
)
}
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { break }
else { throw failed(session, prefix: "Unable to read command output") }
}
}
if !progressed {
// The idle timer cannot fire while this queue is busy, so a
// long-running quiet command has to keep its own session alive.
if Date().timeIntervalSince(lastKeepalive) >= 30 {
lastKeepalive = Date()
var next = Int32(0)
let sent = libssh2_keepalive_send(session, &next)
if sent < 0, sent != LIBSSH2_ERROR_EAGAIN {
throw failed(session, prefix: "SSH keepalive failed")
}
}
usleep(10_000)
}
}
// Drain bytes buffered alongside EOF.
for stream in [Int32(0), Int32(1)] {
while true {
let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count)
guard count > 0 else { break }
let chunk = Data(bytes: buffer, count: count)
if stream == 0 {
Self.appendCapped(
chunk, to: &standardOut, limit: outputLimit, truncated: &outTruncated
)
} else {
Self.appendCapped(
chunk, to: &standardError, limit: outputLimit, truncated: &errorTruncated
)
}
}
}
while true {
let closed = libssh2_channel_close(channel)
if closed == 0 { break }
if closed != LIBSSH2_ERROR_EAGAIN {
throw failed(session, prefix: "Unable to close SSH exec channel")
}
if cancellation.isCancelled { throw CancellationError() }
if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") }
usleep(10_000)
}
// The remote is done and the channel is closed, so the session is clean
// and can carry the next command.
completed = true
var exitSignal: UnsafeMutablePointer<CChar>?
var exitSignalLength = 0
let signalResult = libssh2_channel_get_exit_signal(
channel, &exitSignal, &exitSignalLength, nil, nil, nil, nil
)
let signal: String?
if signalResult == 0, let exitSignal {
signal = String(decoding: UnsafeBufferPointer(
start: exitSignal, count: exitSignalLength
).map(UInt8.init(bitPattern:)), as: UTF8.self)
libssh2_free(session, exitSignal)
} else {
signal = nil
}
return SSHExecResult(
stdout: String(decoding: standardOut, as: UTF8.self),
stderr: String(decoding: standardError, as: UTF8.self),
exitCode: Int32(libssh2_channel_get_exit_status(channel)),
signal: signal,
stdoutTruncated: outTruncated, stderrTruncated: errorTruncated
)
}
/// The command never started, so the caller may open a fresh connection and
/// try again exactly once.
private func notStarted(_ session: OpaquePointer, prefix: String) -> SSHExecSessionError {
SSHExecSessionError.channelUnavailable(failed(session, prefix: prefix))
}
/// Records whether the session is still usable, then describes the failure.
private func failed(_ session: OpaquePointer, prefix: String) -> TermindSSHError {
let transport: [Int32] = [
LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV,
LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT,
LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_CHANNEL_CLOSED,
]
if transport.contains(libssh2_session_last_errno(session)) { isDead = true }
return SSHSessionWorker.sessionError(session, prefix: prefix)
}
private func startKeepalive(for session: OpaquePointer) {
libssh2_keepalive_config(session, 1, 30)
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 30, repeating: 30)
timer.setEventHandler { [weak self] in
guard let self, self.session == session else { return }
var secondsToNext: Int32 = 0
let result = libssh2_keepalive_send(session, &secondsToNext)
if result < 0, result != LIBSSH2_ERROR_EAGAIN {
self.isDead = true
self.cleanup()
}
}
keepaliveTimer = timer
timer.resume()
}
private func cleanup() {
keepaliveTimer?.cancel()
keepaliveTimer = nil
if let session {
_ = libssh2_session_disconnect_ex(
session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the exec session", ""
)
_ = libssh2_session_free(session)
self.session = nil
}
routeTransport?.close()
routeTransport = nil
}
private func perform<T: Sendable>(
_ operation: @escaping @Sendable () throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do { continuation.resume(returning: try operation()) }
catch { continuation.resume(throwing: error) }
}
}
}
private func performNoThrow(_ operation: @escaping @Sendable () -> Void) async {
await withCheckedContinuation { continuation in
queue.async { operation(); continuation.resume() }
}
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func appendCapped(
_ chunk: Data, to destination: inout Data, limit: Int, truncated: inout Bool
) {
let remaining = max(0, limit - destination.count)
if chunk.count > remaining { truncated = true }
if remaining > 0 { destination.append(chunk.prefix(remaining)) }
}
}
+49 -280
View File
@@ -356,27 +356,7 @@ nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
guard payload.count >= 2, payload.suffix(2).elementsEqual(Data("__".utf8))
else { return nil }
payload.removeLast(2)
let fields = String(decoding: payload, as: UTF8.self).split(
separator: "|", maxSplits: 4, omittingEmptySubsequences: false
)
guard fields.count == 5 else { return nil }
let values = fields.map { normalizedOSReleaseValue(String($0)) }
let info = RemoteSystemInfo(
distributionID: values[0], distributionVersion: values[1],
kernelName: values[2], kernelRelease: values[3], architecture: values[4]
)
return info.isEmpty ? nil : info
}
private func normalizedOSReleaseValue(_ raw: String) -> String? {
guard !raw.isEmpty else { return nil }
if raw.count >= 2,
(raw.first == "\"" && raw.last == "\""
|| raw.first == "'" && raw.last == "'")
{
return String(raw.dropFirst().dropLast())
}
return raw
return RemoteSystemInfo(probeFields: String(decoding: payload, as: UTF8.self))
}
private mutating func scanForLateProbe(now: UInt64) -> Update {
@@ -616,15 +596,6 @@ nonisolated enum SSHShellIntegrationEvent: Sendable {
case draft(String)
}
nonisolated struct SSHExecResult: Hashable, Sendable {
var stdout: String
var stderr: String
var exitCode: Int32
var signal: String?
var stdoutTruncated: Bool
var stderrTruncated: Bool
}
actor SSHSessionActor {
typealias OutputHandler = @Sendable (Data) -> Void
typealias ExitHandler = @Sendable (Result<Int32, TermindSSHError>) -> Void
@@ -666,29 +637,6 @@ actor SSHSessionActor {
)
}
/// Executes one command on a dedicated non-PTY channel. This never shares or
/// writes the user's interactive shell and therefore has deterministic output
/// and exit-code boundaries suitable for Agent tools.
static func execute(
_ command: String,
workingDirectory: String? = nil,
stdin: Data? = nil,
timeout: TimeInterval = 120,
outputLimit: Int = 256 * 1024,
using configuration: SSHConnectionConfiguration
) async throws -> SSHExecResult {
let cancellation = TransferCancellationToken()
return try await withTaskCancellationHandler {
try await SSHSessionWorker.execute(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit,
using: configuration, cancellation: cancellation
)
} onCancel: {
cancellation.cancel()
}
}
func connect(
hostID: Host.ID,
host: String,
@@ -800,207 +748,54 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable {
}
}
static func execute(
_ command: String,
workingDirectory: String?,
stdin: Data?,
timeout: TimeInterval,
outputLimit: Int,
/// Opens a transport, handshakes, validates the host key, and authenticates —
/// the identical prologue every non-PTY entry point needs before it can open
/// a channel. On any failure it frees what it created and rethrows, so a
/// caller only owns the pair once it has been handed one.
///
/// The returned session is left in blocking mode. Callers that want the
/// non-blocking pump switch it themselves once their channel is running.
static func openAuthenticatedSession(
using configuration: SSHConnectionConfiguration,
cancellation: TransferCancellationToken
) async throws -> SSHExecResult {
timeout: TimeInterval,
progress: SSHRouteProgressHandler? = nil
) throws -> (transport: SSHRoutedSocket, session: OpaquePointer) {
_ = initialization
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
continuation.resume(returning: try executeBlocking(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit,
using: configuration, cancellation: cancellation
))
} catch { continuation.resume(throwing: error) }
}
}
}
private static func executeBlocking(
_ command: String,
workingDirectory: String?,
stdin: Data?,
timeout: TimeInterval,
outputLimit: Int,
using configuration: SSHConnectionConfiguration,
cancellation: TransferCancellationToken
) throws -> SSHExecResult {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw TermindSSHError.library("The command is empty.")
}
var transport: SSHRoutedSocket?
var session: OpaquePointer?
var channel: OpaquePointer?
defer {
if let channel {
_ = libssh2_channel_send_eof(channel)
_ = libssh2_channel_close(channel)
_ = libssh2_channel_free(channel)
do {
let opened = try SSHRoutedSocket.open(
host: configuration.host, port: configuration.port,
proxy: configuration.proxy, jumps: configuration.jumps,
timeout: timeout, progress: progress
)
transport = opened
progress?(.connecting(configuration.hostID))
guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else {
throw TermindSSHError.library("Unable to create an SSH session.")
}
if let session {
_ = libssh2_session_disconnect_ex(
session, SSH_DISCONNECT_BY_APPLICATION,
"Termind completed agent command", ""
)
_ = libssh2_session_free(session)
session = created
libssh2_session_set_blocking(created, 1)
guard libssh2_session_handshake(created, opened.socket) == 0 else {
throw sessionError(created, prefix: "SSH handshake failed")
}
let observed = try observation(
host: configuration.host, port: configuration.port, session: created
)
try validateHostKey(observed, expected: configuration.expectedHostKeys)
progress?(.authenticating(configuration.hostID))
try authenticate(
username: configuration.username,
credentials: configuration.credentials,
session: created
)
progress?(.authenticated(configuration.hostID))
return (opened, created)
} catch {
if let session { _ = libssh2_session_free(session) }
transport?.close()
throw error
}
transport = try SSHRoutedSocket.open(
host: configuration.host, port: configuration.port,
proxy: configuration.proxy, jumps: configuration.jumps, timeout: 20
)
guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else {
throw TermindSSHError.library("Unable to create an SSH session.")
}
session = created
libssh2_session_set_blocking(created, 1)
guard let transport, libssh2_session_handshake(created, transport.socket) == 0 else {
throw sessionError(created, prefix: "SSH handshake failed")
}
let observed = try observation(
host: configuration.host, port: configuration.port, session: created
)
try validateHostKey(observed, expected: configuration.expectedHostKeys)
try authenticate(
username: configuration.username,
credentials: configuration.credentials,
session: created
)
guard let opened = libssh2_channel_open_ex(
created, "session", 7, 2 * 1024 * 1024, 32 * 1024, nil, 0
) else { throw sessionError(created, prefix: "Unable to open SSH exec channel") }
channel = opened
let effective: String
if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty {
effective = "cd -- \(shellQuote(cwd)) && /bin/sh -lc \(shellQuote(command))"
} else {
effective = "/bin/sh -lc \(shellQuote(command))"
}
let started = effective.withCString {
libssh2_channel_process_startup(opened, "exec", 4, $0, UInt32(effective.utf8.count))
}
guard started == 0 else { throw sessionError(created, prefix: "Unable to start remote command") }
libssh2_session_set_blocking(created, 0)
var pendingInput = stdin ?? Data()
if !pendingInput.isEmpty, pendingInput.last != 0x0A { pendingInput.append(0x0A) }
var inputOffset = 0
var sentEOF = pendingInput.isEmpty
if sentEOF { _ = libssh2_channel_send_eof(opened) }
var standardOut = Data()
var standardError = Data()
var outTruncated = false
var errorTruncated = false
var buffer = [CChar](repeating: 0, count: 16 * 1024)
let deadline = Date().addingTimeInterval(max(1, min(timeout, 15 * 60)))
while libssh2_channel_eof(opened) == 0 {
if cancellation.isCancelled { throw CancellationError() }
if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") }
var progressed = false
if inputOffset < pendingInput.count {
let written = pendingInput.withUnsafeBytes { bytes in
libssh2_channel_write_ex(
opened, 0,
bytes.baseAddress!.advanced(by: inputOffset).assumingMemoryBound(to: CChar.self),
bytes.count - inputOffset
)
}
if written > 0 { inputOffset += written; progressed = true }
else if written != LIBSSH2_ERROR_EAGAIN {
throw sessionError(created, prefix: "Unable to write command input")
}
} else if !sentEOF {
let result = libssh2_channel_send_eof(opened)
if result == 0 { sentEOF = true; progressed = true }
else if result != LIBSSH2_ERROR_EAGAIN {
throw sessionError(created, prefix: "Unable to finish command input")
}
}
for stream in [Int32(0), Int32(1)] {
while true {
let count = libssh2_channel_read_ex(opened, stream, &buffer, buffer.count)
if count > 0 {
progressed = true
let chunk = Data(bytes: buffer, count: count)
if stream == 0 {
appendCapped(chunk, to: &standardOut, limit: outputLimit, truncated: &outTruncated)
} else {
appendCapped(chunk, to: &standardError, limit: outputLimit, truncated: &errorTruncated)
}
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { break }
else { throw sessionError(created, prefix: "Unable to read command output") }
}
}
if !progressed { usleep(10_000) }
}
// Drain bytes buffered alongside EOF.
for stream in [Int32(0), Int32(1)] {
while true {
let count = libssh2_channel_read_ex(opened, stream, &buffer, buffer.count)
guard count > 0 else { break }
let chunk = Data(bytes: buffer, count: count)
if stream == 0 {
appendCapped(chunk, to: &standardOut, limit: outputLimit, truncated: &outTruncated)
} else {
appendCapped(chunk, to: &standardError, limit: outputLimit, truncated: &errorTruncated)
}
}
}
while true {
let closed = libssh2_channel_close(opened)
if closed == 0 { break }
if closed != LIBSSH2_ERROR_EAGAIN {
throw sessionError(created, prefix: "Unable to close SSH exec channel")
}
if cancellation.isCancelled { throw CancellationError() }
if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") }
usleep(10_000)
}
var exitSignal: UnsafeMutablePointer<CChar>?
var exitSignalLength = 0
let signalResult = libssh2_channel_get_exit_signal(
opened, &exitSignal, &exitSignalLength, nil, nil, nil, nil
)
let signal: String?
if signalResult == 0, let exitSignal {
signal = String(decoding: UnsafeBufferPointer(
start: exitSignal, count: exitSignalLength
).map(UInt8.init(bitPattern:)), as: UTF8.self)
libssh2_free(created, exitSignal)
} else {
signal = nil
}
return SSHExecResult(
stdout: String(decoding: standardOut, as: UTF8.self),
stderr: String(decoding: standardError, as: UTF8.self),
exitCode: Int32(libssh2_channel_get_exit_status(opened)),
signal: signal,
stdoutTruncated: outTruncated, stderrTruncated: errorTruncated
)
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func appendCapped(
_ chunk: Data, to destination: inout Data,
limit: Int, truncated: inout Bool
) {
let remaining = max(0, limit - destination.count)
if chunk.count > remaining { truncated = true }
if remaining > 0 { destination.append(chunk.prefix(remaining)) }
}
static func installAuthorizedKey(
@@ -1032,8 +827,9 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable {
private static func installAuthorizedKeyBlocking(
_ publicKey: String, using configuration: SSHConnectionConfiguration
) throws {
var transport: SSHRoutedSocket?
var session: OpaquePointer?
let (transport, created) = try openAuthenticatedSession(
using: configuration, timeout: 20
)
var channel: OpaquePointer?
defer {
if let channel {
@@ -1041,40 +837,13 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable {
_ = libssh2_channel_close(channel)
_ = libssh2_channel_free(channel)
}
if let session {
_ = libssh2_session_disconnect_ex(
session, SSH_DISCONNECT_BY_APPLICATION, "Termind completed key export", ""
)
_ = libssh2_session_free(session)
}
transport?.close()
_ = libssh2_session_disconnect_ex(
created, SSH_DISCONNECT_BY_APPLICATION, "Termind completed key export", ""
)
_ = libssh2_session_free(created)
transport.close()
}
transport = try SSHRoutedSocket.open(
host: configuration.host, port: configuration.port,
proxy: configuration.proxy, jumps: configuration.jumps, timeout: 20
)
guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else {
throw TermindSSHError.library("Unable to create an SSH session.")
}
session = created
libssh2_session_set_blocking(created, 1)
guard let transport,
libssh2_session_handshake(created, transport.socket) == 0
else {
throw sessionError(created, prefix: "SSH handshake failed")
}
let observed = try observation(
host: configuration.host, port: configuration.port, session: created
)
try validateHostKey(observed, expected: configuration.expectedHostKeys)
try authenticate(
username: configuration.username,
credentials: configuration.credentials,
session: created
)
guard
let opened = libssh2_channel_open_ex(
created, "session", 7, 2 * 1024 * 1024, 32 * 1024, nil, 0
+345
View File
@@ -0,0 +1,345 @@
import Foundation
import Testing
@testable import Termind
@Suite
struct AgentConnectionPoolTests {
@Test
func repeatedCommandsOnOneHostShareASingleConnection() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
for index in 0..<5 {
let result = try await pool.execute(
"echo \(index)", workingDirectory: nil, stdin: nil,
timeout: 60, outputLimit: 1_024, hostID: host
)
#expect(result.exitCode == 0)
}
// The whole point of the pool: five commands, one handshake.
#expect(await backend.execOpens == 1)
#expect(await backend.execDisconnects == 0)
}
@Test
func concurrentCallersShareOneConnectionInsteadOfLeakingOne() async throws {
let backend = StubBackend()
let gate = Gate()
let pool = makePool(backend, openerGate: gate)
let host = UUID()
async let first = pool.exec(hostID: host)
// Only start the second caller once the first is parked mid-connect, so
// it is guaranteed to find work already in flight rather than racing it.
while await gate.arrivals == 0 { await Task.yield() }
async let second = pool.exec(hostID: host)
await gate.open()
let sessions = try await [first, second]
#expect(try sessions.map(serial) == [1, 1])
#expect(await backend.execOpens == 1)
}
@Test
func execAndSFTPAreSeparateConnectionsThatCloseIndependently() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
_ = try await pool.exec(hostID: host)
_ = try await pool.sftp(hostID: host)
#expect(await pool.openConnectionCount == 2)
#expect(await pool.disconnect(hostID: host, kind: .exec))
#expect(await pool.isConnected(hostID: host, kind: .sftp))
#expect(await backend.execDisconnects == 1)
#expect(await backend.sftpDisconnects == 0)
}
@Test
func disconnectingATwiceReportsThatNothingWasOpenTheSecondTime() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
_ = try await pool.exec(hostID: host)
#expect(await pool.disconnect(hostID: host, kind: .exec))
#expect(await pool.disconnect(hostID: host, kind: .exec) == false)
#expect(await backend.execDisconnects == 1)
}
@Test
func idleConnectionsCloseAndTheNextCommandReconnects() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
_ = try await pool.exec(hostID: host)
let remaining = await pool.sweepIdle(
now: .now.advanced(by: AgentConnectionPool.defaultIdleTimeout + .seconds(1))
)
#expect(remaining == 0)
#expect(await backend.execDisconnects == 1)
#expect(await pool.isConnected(hostID: host, kind: .exec) == false)
_ = try await pool.exec(hostID: host)
#expect(await backend.execOpens == 2)
}
@Test
func aRecentlyUsedConnectionSurvivesTheSweep() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
_ = try await pool.exec(hostID: host)
let remaining = await pool.sweepIdle(now: .now)
#expect(remaining == 1)
#expect(await backend.execDisconnects == 0)
}
@Test
func editingAHostsRouteRetiresTheConnectionBoundToTheOldOne() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
let before = try await pool.exec(hostID: host)
await backend.setFingerprint("route-b", for: host)
let after = try await pool.exec(hostID: host)
// Not the same machine any more, so not the same session — otherwise a
// command aimed at the edited host would run on the old one.
#expect(try serial(before) == 1)
#expect(try serial(after) == 2)
#expect(await backend.execOpens == 2)
#expect(await pool.openConnectionCount == 1)
}
@Test
func aHostThatNoLongerResolvesHasItsConnectionClosed() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
_ = try await pool.exec(hostID: host)
await backend.forget(host)
await #expect(throws: AgentToolError.self) { _ = try await pool.exec(hostID: host) }
#expect(await backend.execDisconnects == 1)
#expect(await pool.openConnectionCount == 0)
}
@Test
func aSessionThatDiedBetweenCommandsIsReplacedTransparently() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
await backend.queueExecFailures([
SSHExecSessionError.channelUnavailable(TermindSSHError.disconnected)
])
let result = try await pool.execute(
"uptime", workingDirectory: nil, stdin: nil,
timeout: 60, outputLimit: 1_024, hostID: host
)
#expect(result.exitCode == 0)
#expect(await backend.execOpens == 2)
#expect(await backend.execDisconnects == 1)
}
@Test
func aCommandThatFailedAfterItStartedIsNeverRunTwice() async throws {
let backend = StubBackend()
let pool = makePool(backend)
let host = UUID()
// Not `channelUnavailable`: the command reached the remote shell, so a
// silent retry could delete something a second time.
await backend.queueExecFailures([TermindSSHError.library("connection reset")])
await #expect(throws: TermindSSHError.self) {
_ = try await pool.execute(
"rm -rf /srv//old", workingDirectory: nil, stdin: nil,
timeout: 60, outputLimit: 1_024, hostID: host
)
}
#expect(await backend.execOpens == 1)
#expect(await backend.totalExecuteCount() == 1)
}
@Test
func closeAllReleasesEveryHost() async throws {
let backend = StubBackend()
let pool = makePool(backend)
for _ in 0..<3 {
let host = UUID()
_ = try await pool.exec(hostID: host)
_ = try await pool.sftp(hostID: host)
}
await pool.closeAll()
#expect(await pool.openConnectionCount == 0)
#expect(await backend.execDisconnects == 3)
#expect(await backend.sftpDisconnects == 3)
}
}
/// Which stub connection the pool handed back. Distinguishing "reused" from
/// "reconnected" is the whole subject of these tests.
private func serial(_ session: any SSHExecSessionProtocol) throws -> Int {
try #require(session as? StubExecSession).stubSerial
}
/// `sweepInterval: nil` keeps the background sweeper out of the tests; every
/// case here drives `sweepIdle` itself with a synthetic instant.
private func makePool(
_ backend: StubBackend, openerGate: Gate? = nil
) -> AgentConnectionPool {
AgentConnectionPool(
configuration: { hostID in
SSHConnectionConfiguration(
hostID: hostID, host: "host.example", port: 22, username: "root",
credentials: [], expectedHostKeys: [], proxy: nil, jumps: []
)
},
routeFingerprint: { try await backend.fingerprint(for: $0) },
openExec: { _ in
if let openerGate { await openerGate.wait() }
return await backend.openExec()
},
openSFTP: { _ in await backend.openSFTP() },
sweepInterval: nil
)
}
private actor Gate {
private(set) var arrivals = 0
private var isOpen = false
private var waiting: [CheckedContinuation<Void, Never>] = []
func wait() async {
arrivals += 1
guard !isOpen else { return }
await withCheckedContinuation { waiting.append($0) }
}
func open() {
isOpen = true
for continuation in waiting { continuation.resume() }
waiting.removeAll()
}
}
private actor StubBackend {
private(set) var execOpens = 0
private(set) var sftpOpens = 0
private(set) var execDisconnects = 0
private(set) var sftpDisconnects = 0
private var fingerprints: [UUID: String] = [:]
private var forgotten: Set<UUID> = []
private var nextExecFailures: [any Error] = []
private var execSessions: [StubExecSession] = []
func fingerprint(for hostID: UUID) throws -> String {
guard !forgotten.contains(hostID) else { throw AgentToolError.invalidHost }
return fingerprints[hostID] ?? "route-a"
}
func setFingerprint(_ value: String, for hostID: UUID) { fingerprints[hostID] = value }
func forget(_ hostID: UUID) { forgotten.insert(hostID) }
/// Applies to the next session opened, so a test can arrange for a pooled
/// connection to fail once and the replacement to succeed.
func queueExecFailures(_ errors: [any Error]) { nextExecFailures = errors }
func openExec() -> StubExecSession {
execOpens += 1
let session = StubExecSession(
serial: execOpens, backend: self, failures: nextExecFailures
)
nextExecFailures = []
execSessions.append(session)
return session
}
func openSFTP() -> StubSFTPClient {
sftpOpens += 1
return StubSFTPClient(backend: self)
}
func noteExecDisconnect() { execDisconnects += 1 }
func noteSFTPDisconnect() { sftpDisconnects += 1 }
func totalExecuteCount() async -> Int {
var total = 0
for session in execSessions { total += await session.executeCount }
return total
}
}
private actor StubExecSession: SSHExecSessionProtocol {
nonisolated let stubSerial: Int
private let backend: StubBackend
private var failures: [any Error]
private(set) var executeCount = 0
init(serial: Int, backend: StubBackend, failures: [any Error]) {
stubSerial = serial
self.backend = backend
self.failures = failures
}
func connect(using configuration: SSHConnectionConfiguration) async throws {}
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int
) async throws -> SSHExecResult {
executeCount += 1
if !failures.isEmpty { throw failures.removeFirst() }
return SSHExecResult(
stdout: command, stderr: "", exitCode: 0, signal: nil,
stdoutTruncated: false, stderrTruncated: false
)
}
func disconnect() async { await backend.noteExecDisconnect() }
}
private actor StubSFTPClient: SFTPClientProtocol {
private let backend: StubBackend
init(backend: StubBackend) { self.backend = backend }
func connect(
using configuration: SSHConnectionConfiguration,
progress: SSHRouteProgressHandler?
) async throws {}
func disconnect() async { await backend.noteSFTPDisconnect() }
func homeDirectory() async throws -> String { "/root" }
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
func attributes(at path: String) async throws -> RemoteFile? { nil }
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws {}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws {}
func removeDirectory(at path: String) async throws {}
func uploadFile(
from localURL: URL, to remotePath: String, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
func downloadFile(
from remotePath: String, to localURL: URL, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
}