Files
Termind/TermindTests/AgentConnectionPoolTests.swift
T
Kaede AkinoandClaude Opus 5 70665e7375 Show a command's output while it is still running
An `ssh_exec` card was blank for as long as the command took. `apt upgrade`
gave you a spinner for four minutes and then four minutes of text at once,
which is the worst possible ordering for the one thing you were watching
for — the line where it went wrong.

Pi has had the answer since `AgentToolUpdateCallback`: a tool may report
what it has so far, `executePreparedToolCall` turns each report into a
`tool_execution_update`, and the TUI redraws the card from it. Ported
here, with one change of address. Pi passes the callback as a fourth
argument to `execute` because it has nowhere else to put it;
`AgentToolExecutionContext` already threads per-call state to every tool,
so `report` goes there and no tool signature changes.

Where the partial goes is the whole design. `toolProgress` is not durable,
on the same grounds as `messageDelta` — history is written from run
snapshots, so a card that fills in as output arrives must not also stream
database writes. And it never reaches the transcript: the model is owed
one result per call, not a running commentary, and a turn that replayed
three partial views of the same command would pay for all three. The card
gets everything; the model gets what the tool finally returned. The card's
state is also left alone, because a call that has something to show has
not therefore finished.

`SSHExecSession` grows an `onOutput` handler called at the four points the
pump already captures a chunk. It runs on the session's serial queue
between two `libssh2` reads, so the contract is that it returns promptly
and never awaits — anything it blocks on stalls every command on that
host. `AgentCommandOutputStream` is the intended shape on the other side:
a lock, an accumulator, and a callback fired outside the lock.

Two details it exists to get right. Reports carry everything captured so
far rather than the newest chunk, because the card replaces its result
wholesale and would otherwise flicker instead of growing. And they are
throttled to one per 150ms — a build printing a thousand lines a second
would otherwise be a thousand SwiftUI invalidations per second to render
text nobody can read at that speed. Throttling drops the report, never the
output: what was captured is still captured, and the finished result
follows moments later regardless. A chunk boundary can also fall inside a
multi-byte character; that decodes as a replacement character and corrects
itself on the next report, and only the live view ever sees it, because
the finished result is decoded once, whole, by the session.

The test for the throttle is worth a note. The first version collected
reports into an actor and awaited them, and raced its own assertions. The
callback's contract is that it may not await, so a collector that has to
be awaited is not testing the thing — it is locked now, like the real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:16:40 +08:00

347 lines
12 KiB
Swift

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, onOutput: SSHExecOutputHandler?
) async throws -> SSHExecResult {
executeCount += 1
if !failures.isEmpty { throw failures.removeFirst() }
onOutput?(Data(command.utf8), false)
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 {}
}