From 382baafb2dbe4af393ddd720e3ebee479d3d6c97 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 19 Sep 2026 07:24:18 +0800 Subject: [PATCH] Improve port forwarding concurrency and throughput --- .../SSH/PortForwardingController.swift | 12 +- .../SSH/SSHBufferedTransport.swift | 86 + .../SSH/SSHForwardingConnector.swift | 172 ++ .../SSH/SSHForwardingSession.swift | 790 +++++--- .../SSH/SSHRoutedSocket.swift | 185 +- .../SSH/SSHStreamPump.swift | 349 ++++ Memory/ARCHITECTURE.md | 22 + Memory/PORT_FORWARDING_BENCHMARK.json | 1746 +++++++++++++++++ Memory/PORT_FORWARDING_PERFORMANCE.md | 178 ++ Scripts/ssh-authentication-fixture.py | 117 +- TFMTests/PortForwardingJourneyTests.swift | 14 +- TFMTests/SSHForwardingPumpTests.swift | 304 +++ TFMTests/SSHForwardingWireTests.swift | 405 ++++ 13 files changed, 3984 insertions(+), 396 deletions(-) create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/SSHBufferedTransport.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnector.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift create mode 100644 Memory/PORT_FORWARDING_BENCHMARK.json create mode 100644 Memory/PORT_FORWARDING_PERFORMANCE.md create mode 100644 TFMTests/SSHForwardingPumpTests.swift create mode 100644 TFMTests/SSHForwardingWireTests.swift diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index 2f4b1e03..cef1e329 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -59,6 +59,7 @@ public final class PortForwardingConnection { fileprivate var session: (any SSHForwardingSessionProtocol)? fileprivate var task: Task? fileprivate var connected = false + fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:] fileprivate init(host: Host, route: [Host]) { hostID = host.id @@ -247,10 +248,12 @@ public final class PortForwardingController { { [weak self, weak context] id, count in Task { @MainActor in guard let self, let context, self.isCurrent(context, attempt: attempt), - self.generations[id] != nil, self.presentations[id] === context, - case .running = self.states[id] + self.generations[id] != nil, self.presentations[id] === context else { return } - self.states[id] = .running(activeConnections: count) + context.connectionCounts[id] = count + if case .running = self.states[id], self.states[id] != .running(activeConnections: count) { + self.states[id] = .running(activeConnections: count) + } } }, { [weak self, weak context] message in @@ -366,6 +369,7 @@ public final class PortForwardingController { private func startRule(_ rule: PortForwardingRule, in context: PortForwardingConnection) { guard let token = generations[rule.id], let session = context.session else { return } + context.connectionCounts[rule.id] = 0 states[rule.id] = .starting let previous = ruleTasks[rule.id] ruleTasks[rule.id] = Task { [weak self] in @@ -374,7 +378,7 @@ public final class PortForwardingController { do { try await session.start(rule) guard generations[rule.id] == token else { return } - states[rule.id] = .running(activeConnections: 0) + states[rule.id] = .running(activeConnections: context.connectionCounts[rule.id] ?? 0) context.journey.succeed() } catch { guard generations[rule.id] == token else { return } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHBufferedTransport.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHBufferedTransport.swift new file mode 100644 index 00000000..06657e89 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHBufferedTransport.swift @@ -0,0 +1,86 @@ +import CSSH2 +import Darwin +import Foundation + +/// Queue-confined encrypted output. libssh2's forwarded-tcpip confirmation +/// retry loses its pending channel in the pinned library when send returns +/// EAGAIN. Accept complete packets here and apply backpressure between library +/// calls instead. The receive callback also stops packet processing at the +/// watermark, bounding replies generated while libssh2 drains input. +nonisolated final class SSHBufferedTransport { + private let output = SSHByteQueue() + private var disposed = false + private var readAllowance = SSHStreamPump.quantum + var exhaustedReadBudget: Bool { readAllowance == 0 } + + func beginPass() { readAllowance = SSHStreamPump.quantum } + var isBackpressured: Bool { output.count >= SSHStreamPump.highWater } + var wantsWrite: Bool { !output.isEmpty } + + init(session: OpaquePointer) { + libssh2_session_abstract(session)?.pointee = Unmanaged.passUnretained(self).toOpaque() + libssh2_session_callback_set2( + session, LIBSSH2_CALLBACK_SEND, unsafeBitCast(Self.send, to: (@convention(c) () -> Void).self)) + libssh2_session_callback_set2( + session, LIBSSH2_CALLBACK_RECV, unsafeBitCast(Self.receive, to: (@convention(c) () -> Void).self)) + } + + func flush(socket: Int32) throws { + while !output.isEmpty { + let count = output.withFirstBytes { Darwin.send(socket, $0.baseAddress!, $0.count, MSG_DONTWAIT) } + if count > 0 { + output.consume(count) + } else if count < 0 && errno == EINTR { + continue + } else if count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { + return + } else { + throw SSHStreamPump.StreamError.closed + } + } + } + + /// Teardown cannot wait on a peer. Discard final protocol writes and return + /// EOF to reads, so libssh2 can release channels/listeners without EAGAIN. + func dispose(session: OpaquePointer) { + disposed = true + let result = libssh2_session_free(session) + assert(result == 0, "SSH session disposal must finish without socket I/O") + } + + private static let send: + @convention(c) ( + Int32, UnsafeRawPointer?, Int, Int32, UnsafeMutablePointer? + ) -> Int = { socket, bytes, count, flags, abstract in + guard let opaque = abstract?.pointee, let bytes else { return -Int(EINVAL) } + let transport = Unmanaged.fromOpaque(opaque).takeUnretainedValue() + if transport.disposed { return count } + var sent = 0 + if transport.output.isEmpty { + repeat { + sent = Darwin.send(socket, bytes, count, flags | MSG_DONTWAIT) + } while sent < 0 && errno == EINTR + if sent < 0 { + guard errno == EAGAIN || errno == EWOULDBLOCK else { return -Int(errno) } + sent = 0 + } + } + if sent < count { + transport.output.append(UnsafeRawBufferPointer(start: bytes + sent, count: count - sent)) + } + return count + } + + private static let receive: + @convention(c) ( + Int32, UnsafeMutableRawPointer?, Int, Int32, UnsafeMutablePointer? + ) -> Int = { socket, bytes, count, flags, abstract in + guard let opaque = abstract?.pointee else { return -Int(EINVAL) } + let transport = Unmanaged.fromOpaque(opaque).takeUnretainedValue() + if transport.disposed { return 0 } + if transport.isBackpressured || transport.exhaustedReadBudget { return -Int(EAGAIN) } + let received = Darwin.recv(socket, bytes, min(count, transport.readAllowance), flags | MSG_DONTWAIT) + if received > 0 { transport.readAllowance -= received } + return received < 0 ? -Int(errno) : received + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnector.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnector.swift new file mode 100644 index 00000000..ec4128f9 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnector.swift @@ -0,0 +1,172 @@ +import Darwin +import Foundation + +/// Forwarded target connections must never block the queue that services SSH. +/// Only DNS uses blocking workers (at most four); connect and its overall +/// five-second deadline use readiness events on the owning session queue. +nonisolated final class SSHForwardingConnector: @unchecked Sendable { + private struct Address: Sendable { + let family: Int32 + let type: Int32 + let proto: Int32 + let bytes: Data + } + + private static let resolver: OperationQueue = { + let queue = OperationQueue() + queue.name = "com.akinokaede.termind.forwarding-dns" + queue.maxConcurrentOperationCount = 4 + return queue + }() + + private let queue: DispatchQueue + private var completion: (@Sendable (Result) -> Void)? + private var addresses: [Address] = [] + private var socket: Int32 = -1 + private var events: SSHSocketEvents? + private var deadline: (any DispatchSourceTimer)? + private var resolution: BlockOperation? + private var finished = false + private var cancelled = false + + init(queue: DispatchQueue, completion: @escaping @Sendable (Result) -> Void) { + self.queue = queue + self.completion = completion + } + + func start(host: String, port: Int) { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 5) + timer.setEventHandler { [weak self] in self?.finish(.failure(POSIXError(.ETIMEDOUT))) } + timer.resume() + deadline = timer + // Numeric targets do not need a resolver worker or a thread-pool hop. + let numeric = Self.resolve(host: host, port: port, flags: AI_NUMERICHOST) + if !numeric.isEmpty { + addresses = numeric + tryNext() + return + } + let operation = BlockOperation { [weak self] in + let resolved = Self.resolve(host: host, port: port, flags: 0) + guard let self else { return } + self.queue.async { + guard !self.finished else { return } + self.resolution = nil + self.addresses = resolved + self.tryNext() + } + } + resolution = operation + Self.resolver.addOperation(operation) + } + + private static func resolve(host: String, port: Int, flags: Int32) -> [Address] { + var hints = addrinfo() + hints.ai_flags = flags + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + hints.ai_protocol = IPPROTO_TCP + var result: UnsafeMutablePointer? + guard getaddrinfo(host, String(port), &hints, &result) == 0, let first = result else { return [] } + defer { freeaddrinfo(first) } + var addresses: [Address] = [] + var cursor: UnsafeMutablePointer? = first + while let info = cursor?.pointee { + addresses.append( + Address( + family: info.ai_family, type: info.ai_socktype, proto: info.ai_protocol, + bytes: Data(bytes: info.ai_addr, count: Int(info.ai_addrlen)))) + cursor = info.ai_next + } + return addresses + } + + func cancel() { + cancelled = true + completion = nil + finish(.failure(CancellationError())) + } + + private func tryNext() { + guard !finished else { return } + guard !addresses.isEmpty else { + finish(.failure(POSIXError(.ECONNREFUSED))) + return + } + let address = addresses.removeFirst() + let candidate = Darwin.socket(address.family, address.type, address.proto) + guard candidate >= 0 else { + tryNext() + return + } + socket = candidate + var yes: Int32 = 1 + _ = setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size)) + let flags = fcntl(candidate, F_GETFL, 0) + guard flags >= 0, fcntl(candidate, F_SETFL, flags | O_NONBLOCK) == 0 else { + Darwin.close(candidate) + socket = -1 + tryNext() + return + } + let result = address.bytes.withUnsafeBytes { + Darwin.connect(candidate, $0.baseAddress!.assumingMemoryBound(to: sockaddr.self), socklen_t($0.count)) + } + if result == 0 { + finish(.success(candidate)) + return + } + guard errno == EINPROGRESS else { + Darwin.close(candidate) + socket = -1 + tryNext() + return + } + events = SSHSocketEvents(socket: candidate, queue: queue) { [weak self] in self?.connected() } + events?.update(read: false, write: true) + } + + private func connected() { + guard !finished else { return } + var error: Int32 = 0 + var size = socklen_t(MemoryLayout.size) + if getsockopt(socket, SOL_SOCKET, SO_ERROR, &error, &size) == 0 && error == 0 { + finish(.success(socket)) + } else { + let descriptor = socket + socket = -1 + events?.cancel { [self] in + Darwin.close(descriptor) + tryNext() + } + events = nil + } + } + + private func finish(_ result: Result) { + guard !finished else { return } + finished = true + resolution?.cancel() + resolution = nil + deadline?.cancel() + deadline = nil + let descriptor = socket + socket = -1 + let deliver: @Sendable () -> Void = { [self] in + if case .failure = result, descriptor >= 0 { Darwin.close(descriptor) } + if cancelled { + if case .success(let opened) = result { Darwin.close(opened) } + } else { + completion?(result) + } + completion = nil + } + if let events { + events.cancel(then: deliver) + self.events = nil + } else { + deliver() + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift index 2c3120e3..f1f24477 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift @@ -52,33 +52,46 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { precondition(libssh2_init(0) == 0, "libssh2 initialization failed") }() - private final class RuleContext { + private final class RuleContext: @unchecked Sendable { let rule: PortForwardingRule var socket: Int32 = -1 + var events: SSHSocketEvents? var remoteListener: OpaquePointer? var flows: [UUID: Flow] = [:] + var start: CheckedContinuation? + var stopped = false + var stop: (@Sendable () -> Void)? + var reportedCount = -1 init(rule: PortForwardingRule) { self.rule = rule } } - private final class Flow { - enum Phase { case greeting, request, forwarding } + private final class Flow: @unchecked Sendable { + enum Phase { case greeting, request, opening, connecting, forwarding } let id = UUID() - var socket: Int32 - var channel: OpaquePointer? + let context: RuleContext + let stream: SSHStreamPump var phase: Phase + var target: (String, Int)? var handshake = Data() - var toChannel = Data() - var toSocket = Data() - var socketEOF = false - var channelEOF = false - var socketWriteClosed = false - var channelWriteClosed = false + var events: SSHSocketEvents? + var connector: SSHForwardingConnector? + var retired = false - init(socket: Int32, dynamic: Bool, channel: OpaquePointer? = nil) { - self.socket = socket - self.phase = dynamic ? .greeting : .forwarding - self.channel = channel + init(socket: Int32, context: RuleContext, channel: OpaquePointer? = nil) { + self.context = context + stream = SSHStreamPump(socket: socket, channel: channel) + phase = context.rule.kind == .dynamic ? .greeting : channel == nil ? .opening : .connecting + if let host = context.rule.targetHost, let port = context.rule.targetPort { target = (host, port) } + } + } + + private final class RetiredListener { + let listener: OpaquePointer + let completion: () -> Void + init(_ listener: OpaquePointer, completion: @escaping () -> Void) { + self.listener = listener + self.completion = completion } } @@ -89,9 +102,25 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { private var routeTransport: SSHRoutedSocket? private var session: OpaquePointer? private var rules: [PortForwardingRule.ID: RuleContext] = [:] + private var flows: [UUID: Flow] = [:] + private var bufferedTransport: SSHBufferedTransport? + private var transportEvents: SSHSocketEvents? private var timer: (any DispatchSourceTimer)? + private var ready = SSHReadyQueue() + private var pendingOpens = SSHReadyQueue() + private var opening: Flow? + private var pendingListeners: [RuleContext] = [] + private var listening: RuleContext? + private var retiredFlows: [Flow] = [] + private var retiredListeners: [RetiredListener] = [] + private var scratch = [UInt8](repeating: 0, count: 32 * 1024) + private var scheduled = false + private var generation = 0 + private var controlDirections: Int32 = 0 + private var keepaliveDue = true + private var nextKeepalive = DispatchTime.now() - public init( + init( connectionHandler: @escaping SSHForwardingSession.ConnectionHandler, failureHandler: @escaping SSHForwardingSession.FailureHandler ) { @@ -100,7 +129,9 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { self.failureHandler = failureHandler } - public func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws { + deinit { cleanup() } + + func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws { await queue.perform { self.cleanup() } let opened = try await SSHSessionWorker.openAuthenticatedSession( using: request, timeout: 20, progress: progress) @@ -109,9 +140,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { self.routeTransport = opened.transport self.socket = opened.transport.socket self.session = opened.session + self.bufferedTransport = SSHBufferedTransport(session: opened.session) libssh2_session_set_blocking(opened.session, 0) - libssh2_keepalive_config(opened.session, 1, 20) - self.startTimer() + libssh2_keepalive_config(opened.session, 0, 20) + self.startEvents() } try Task.checkCancellation() } catch { @@ -120,11 +152,11 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { } } - public func start(_ rule: PortForwardingRule) async throws { + func start(_ rule: PortForwardingRule) async throws { try await withCheckedThrowingContinuation { continuation in queue.async { [self] in do { - guard let session else { throw TermindSSHError.disconnected } + guard session != nil else { throw TermindSSHError.disconnected } if rules[rule.id] != nil { continuation.resume() return @@ -132,223 +164,497 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { let context = RuleContext(rule: rule) switch rule.kind { case .local, .dynamic: - do { - context.socket = try makeListener( - host: rule.bindHost, port: rule.bindPort - ) - } catch { + do { context.socket = try makeListener(host: rule.bindHost, port: rule.bindPort) } catch { throw TermindSSHError.route( String( localized: "Unable to listen on \(rule.bindAddress): \(error.localizedDescription)") ) } + context.events = SSHSocketEvents(socket: context.socket, queue: queue) { + [weak self, weak context] in + guard let self, let context, !context.stopped else { return } + self.acceptLocal(context) + } + context.events?.update(read: true, write: false) + rules[rule.id] = context + report(context) + continuation.resume() case .remote: - var boundPort: Int32 = 0 - let listener = rule.bindHost.withCString { host in - libssh2_channel_forward_listen_ex( - session, host, Int32(rule.bindPort), &boundPort, 64 - ) - } - guard let listener else { - throw TermindSSHError.route( - SSHSessionWorker.sessionError( - session, prefix: String(localized: "Unable to start remote forwarding") - ).localizedDescription - ) - } - context.remoteListener = listener + context.start = continuation + rules[rule.id] = context + pendingListeners.append(context) + schedulePump() } - rules[rule.id] = context - connectionHandler(rule.id, 0) - continuation.resume() } catch { continuation.resume(throwing: error) } } } } - public func stop(_ id: PortForwardingRule.ID) async { + func stop(_ id: PortForwardingRule.ID) async { await withCheckedContinuation { continuation in queue.async { [self] in - if let context = rules.removeValue(forKey: id) { close(context) } - continuation.resume() + guard let context = rules.removeValue(forKey: id) else { + continuation.resume() + return + } + close(context) { continuation.resume() } + schedulePump() } } } - public func disconnect() async { - await withCheckedContinuation { continuation in - queue.async { [self] in - cleanup() - continuation.resume() - } - } - } + func disconnect() async { await queue.perform { self.cleanup() } } - private func startTimer() { - timer?.cancel() + private func startEvents() { + let token = generation + transportEvents = SSHSocketEvents(socket: socket, queue: queue) { [weak self] in + guard let self, self.generation == token else { return } + self.wakeAll() + } + transportEvents?.update(read: true, write: false) let source = DispatchSource.makeTimerSource(queue: queue) - source.schedule(deadline: .now(), repeating: .milliseconds(12), leeway: .milliseconds(4)) - source.setEventHandler { [weak self] in self?.pump() } + // This timer services protocol deadlines and keepalive, never data throughput. + source.schedule(deadline: .now() + 1, repeating: .seconds(1), leeway: .milliseconds(100)) + source.setEventHandler { [weak self] in + guard let self, self.generation == token else { return } + if DispatchTime.now() >= self.nextKeepalive { self.keepaliveDue = true } + if self.keepaliveDue || self.opening != nil || self.listening != nil + || !self.retiredFlows.isEmpty || !self.retiredListeners.isEmpty + { + self.wakeAll() + } + } timer = source source.resume() + keepaliveDue = true + schedulePump() + } + + private func wakeAll() { + for id in flows.keys { ready.insert(id) } + schedulePump() + } + + private func schedulePump() { + guard session != nil, !scheduled else { return } + scheduled = true + let token = generation + queue.async { [weak self] in + guard let self, self.generation == token else { return } + self.scheduled = false + self.pump() + } } private func pump() { - guard let session else { return } - for context in Array(rules.values) { - switch context.rule.kind { - case .local, .dynamic: acceptLocal(context, session: session) - case .remote: acceptRemote(context) - } - for flow in Array(context.flows.values) { pump(flow, in: context, session: session) } - connectionHandler(context.rule.id, context.flows.count) + guard let session, let bufferedTransport else { return } + do { try bufferedTransport.flush(socket: socket) } catch { + fail(session) + return } - var next = Int32(0) - let keepalive = libssh2_keepalive_send(session, &next) - if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { - let message = SSHSessionWorker.sessionError( - session, prefix: String(localized: "The forwarding SSH connection closed") - ).localizedDescription - cleanup() - failureHandler(message) + if bufferedTransport.isBackpressured { + for flow in flows.values { flow.events?.update(read: false, write: false) } + transportEvents?.update(read: false, write: true) + return } - } - - private func acceptLocal(_ context: RuleContext, session: OpaquePointer) { - while true { - let accepted = Darwin.accept(context.socket, nil, nil) - guard accepted >= 0 else { break } - makeNonblocking(accepted) - let flow = Flow(socket: accepted, dynamic: context.rule.kind == .dynamic) - if context.rule.kind == .local { - guard let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort else { - Darwin.close(accepted) - continue - } - flow.channel = targetHost.withCString { target in - libssh2_channel_direct_tcpip_ex(session, target, Int32(targetPort), "127.0.0.1", 0) - } - guard flow.channel != nil else { - Darwin.close(accepted) - continue - } - } - context.flows[flow.id] = flow - } - } - - private func acceptRemote(_ context: RuleContext) { - guard let listener = context.remoteListener, - let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort - else { return } - while let channel = libssh2_channel_forward_accept(listener) { - do { - let local = try SSHSocketConnector.open( - host: targetHost, port: targetPort, timeout: 5 - ) - makeNonblocking(local) - let flow = Flow(socket: local, dynamic: false, channel: channel) - context.flows[flow.id] = flow - } catch { - _ = libssh2_channel_free(channel) - } - } - } - - private func pump(_ flow: Flow, in context: RuleContext, session: OpaquePointer) { - let highWaterMark = 2 * 1024 * 1024 - if !flow.socketEOF, - (flow.phase == .forwarding ? flow.toChannel.count : flow.handshake.count) < highWaterMark + bufferedTransport.beginPass() + let deadline = DispatchTime.now().uptimeNanoseconds + 2_000_000 + controlDirections = 0 + var progressed = advanceListeners(session) + if advanceOpen(session) { progressed = true } + while !bufferedTransport.isBackpressured, DispatchTime.now().uptimeNanoseconds < deadline, let id = ready.pop() { - var buffer = [UInt8](repeating: 0, count: 32 * 1024) - let count = Darwin.recv(flow.socket, &buffer, buffer.count, 0) - if count > 0 { - let data = Data(buffer.prefix(count)) - if flow.phase == .forwarding { - flow.toChannel.append(data) - } else { - flow.handshake.append(data) - if flow.handshake.count > 64 * 1024 { - close(flow, in: context) - return - } - handleSOCKSHandshake(flow, session: session) - } - } else if count == 0 { - flow.socketEOF = true - } else if errno != EAGAIN && errno != EWOULDBLOCK { - close(flow, in: context) + guard let flow = flows[id] else { continue } + if pump(flow, session: session) { progressed = true } + } + // Any channel read/write can consume packets for another channel, + // including forwarded-tcpip opens and control replies. Service those + // queues after stream I/O; waiting for another socket edge would stall. + if advanceOpen(session) { progressed = true } + if advanceListeners(session) { progressed = true } + for context in rules.values where context.remoteListener != nil { + if acceptRemote(context) { progressed = true } + } + for flow in flows.values where flow.phase == .forwarding { + guard let channel = flow.stream.channel else { continue } + if (flow.stream.wantsChannelRead && libssh2_poll_channel_read(channel, 0) == 1) + || (!flow.stream.channelEOF && libssh2_channel_eof(channel) == 1) + { + ready.insert(flow.id) + } + } + if reap(session) { progressed = true } + if keepaliveDue { + var next: Int32 = 0 + let result = libssh2_keepalive_send(session, &next) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + } else if result < 0 { + fail(session) return - } - } - - if let channel = flow.channel { - while !flow.toChannel.isEmpty { - let written = flow.toChannel.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } - return libssh2_channel_write_ex(channel, 0, base, bytes.count) - } - if written > 0 { - flow.toChannel.removeFirst(written) - } else if written == LIBSSH2_ERROR_EAGAIN { - break - } else { - close(flow, in: context) - return - } - } - if flow.socketEOF, flow.toChannel.isEmpty, !flow.channelWriteClosed { - let result = libssh2_channel_send_eof(channel) - if result == 0 { - flow.channelWriteClosed = true - } else if result != LIBSSH2_ERROR_EAGAIN { - close(flow, in: context) - return - } - } - if flow.toSocket.count < highWaterMark { - var buffer = [CChar](repeating: 0, count: 32 * 1024) - while flow.toSocket.count < highWaterMark { - let count = libssh2_channel_read_ex(channel, 0, &buffer, buffer.count) - if count > 0 { - flow.toSocket.append(Data(bytes: buffer, count: count)) - } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { - break - } else { - close(flow, in: context) - return - } - } - } - if libssh2_channel_eof(channel) == 1 { flow.channelEOF = true } - } - - while !flow.toSocket.isEmpty { - let sent = flow.toSocket.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress else { return 0 } - return Darwin.send(flow.socket, base, bytes.count, 0) - } - if sent > 0 { - flow.toSocket.removeFirst(sent) - } else if errno == EAGAIN || errno == EWOULDBLOCK { - break } else { - close(flow, in: context) - return + keepaliveDue = false + nextKeepalive = .now() + .seconds(max(1, Int(next))) } } - if flow.channelEOF, flow.toSocket.isEmpty, !flow.socketWriteClosed { - _ = Darwin.shutdown(flow.socket, SHUT_WR) - flow.socketWriteClosed = true + for context in rules.values { report(context) } + let directions = flows.values.reduce(controlDirections) { $0 | $1.stream.blockDirections } + var byte: UInt8 = 0 + let available = Darwin.recv(socket, &byte, 1, MSG_PEEK | MSG_DONTWAIT) + if available == 0 || (available < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + fail(session) + return } - if flow.socketEOF, - flow.channel == nil || flow.channelEOF, - flow.toChannel.isEmpty, flow.toSocket.isEmpty - { - close(flow, in: context) + // With no channel/listener libssh2 has no public session-read operation. + // Park unsolicited bytes until the next operation can consume them; + // never repeatedly wake on the same unread packet. + let canRead = + available < 0 + || flows.values.contains { + $0.phase == .forwarding && ($0.stream.wantsChannelRead || !$0.stream.toChannel.isEmpty) + } || rules.values.contains { $0.remoteListener != nil } + || opening != nil || listening != nil || !retiredFlows.isEmpty || !retiredListeners.isEmpty + do { try bufferedTransport.flush(socket: socket) } catch { + fail(session) + return + } + transportEvents?.update( + read: canRead && !bufferedTransport.isBackpressured, + write: bufferedTransport.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0) + if progressed || bufferedTransport.exhaustedReadBudget { wakeAll() } else if !ready.isEmpty { schedulePump() } + } + + private func blocked(_ session: OpaquePointer) { + controlDirections |= libssh2_session_block_directions(session) + } + + private func advanceListeners(_ session: OpaquePointer) -> Bool { + if listening == nil { + while !pendingListeners.isEmpty { + let next = pendingListeners.removeFirst() + if !next.stopped { + listening = next + break + } + } + } + guard let context = listening else { return false } + var boundPort: Int32 = 0 + let listener = context.rule.bindHost.withCString { + libssh2_channel_forward_listen_ex(session, $0, Int32(context.rule.bindPort), &boundPort, 128) + } + if let listener { + listening = nil + if context.stopped { + retiredListeners.append(RetiredListener(listener, completion: context.stop ?? {})) + context.stop = nil + } else { + context.remoteListener = listener + context.start?.resume() + context.start = nil + report(context) + } + return true + } + if libssh2_session_last_errno(session) == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + listening = nil + if rules[context.rule.id] === context { rules[context.rule.id] = nil } + context.start?.resume( + throwing: SSHSessionWorker.sessionError( + session, prefix: String(localized: "Unable to start remote forwarding"))) + context.start = nil + context.stop?() + context.stop = nil + return true + } + + private func advanceOpen(_ session: OpaquePointer) -> Bool { + if opening == nil { + while let id = pendingOpens.pop() { + if let flow = flows[id], flow.phase == .opening { + opening = flow + break + } + } + } + guard let flow = opening, let (host, port) = flow.target else { return false } + let channel = host.withCString { + libssh2_channel_direct_tcpip_ex(session, $0, Int32(port), "127.0.0.1", 0) + } + guard let channel else { + if libssh2_session_last_errno(session) == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + opening = nil + if !flow.retired { + if flow.context.rule.kind == .dynamic { + flow.stream.toSocket.append(socksReply(0x05)) + flow.stream.socketEOF = true + flow.phase = .request + ready.insert(flow.id) + } else { + close(flow) + } + } + return true + } + opening = nil + flow.stream.channel = channel + if flow.retired { + retiredFlows.append(flow) + } else { + if flow.context.rule.kind == .dynamic { flow.stream.toSocket.append(socksReply(0x00)) } + flow.phase = .forwarding + ready.insert(flow.id) + } + return true + } + + private func acceptLocal(_ context: RuleContext) { + for _ in 0..<32 { + let descriptor = Darwin.accept(context.socket, nil, nil) + guard descriptor >= 0 else { break } + makeNonblocking(descriptor) + var yes: Int32 = 1 + setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size)) + let flow = Flow(socket: descriptor, context: context) + register(flow) + watch(flow) + if flow.phase == .opening { pendingOpens.insert(flow.id) } + } + schedulePump() + } + + private func acceptRemote(_ context: RuleContext) -> Bool { + guard let listener = context.remoteListener, let host = context.rule.targetHost, + let port = context.rule.targetPort + else { return false } + var progressed = false + for _ in 0..<32 { + guard let channel = libssh2_channel_forward_accept(listener) else { + if let session, libssh2_session_last_errno(session) == LIBSSH2_ERROR_EAGAIN { blocked(session) } + break + } + progressed = true + let flow = Flow(socket: -1, context: context, channel: channel) + register(flow) + let connector = SSHForwardingConnector(queue: queue) { [weak self, weak flow] result in + guard let self, let flow, !flow.retired else { + if case .success(let socket) = result { Darwin.close(socket) } + return + } + flow.connector = nil + switch result { + case .success(let socket): + flow.stream.socket = socket + flow.phase = .forwarding + self.watch(flow) + self.ready.insert(flow.id) + case .failure: self.close(flow) + } + self.schedulePump() + } + flow.connector = connector + connector.start(host: host, port: port) + } + return progressed + } + + private func register(_ flow: Flow) { + flows[flow.id] = flow + flow.context.flows[flow.id] = flow + ready.insert(flow.id) + } + + private func watch(_ flow: Flow) { + flow.events = SSHSocketEvents(socket: flow.stream.socket, queue: queue) { [weak self, weak flow] in + guard let self, let flow, !flow.retired else { return } + self.wakeAll() + } + updateInterest(flow) + } + + private func updateInterest(_ flow: Flow) { + let reading: Bool + switch flow.phase { + case .forwarding: reading = flow.stream.wantsSocketRead + case .greeting, .request: reading = !flow.stream.socketEOF + case .opening, .connecting: reading = false + } + flow.events?.update(read: reading, write: flow.stream.socketWriteBlocked) + } + + private func pump(_ flow: Flow, session: OpaquePointer) -> Bool { + guard !flow.retired else { return false } + var progressed = false + do { + switch flow.phase { + case .forwarding: + progressed = try flow.stream.pump(session: session, scratch: &scratch) + if flow.stream.finished { + close(flow) + return true + } + case .greeting, .request: + if !flow.stream.socketEOF { + let count = Darwin.recv(flow.stream.socket, &scratch, scratch.count, 0) + if count > 0 { + flow.handshake.append(contentsOf: scratch.prefix(count)) + guard flow.handshake.count <= 64 * 1024 else { + close(flow) + return true + } + handleSOCKSHandshake(flow, session: session) + progressed = true + } else if count == 0 { + flow.stream.socketEOF = true + progressed = true + } else if errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR { + close(flow) + return true + } + } + if try flow.stream.flushSocket() { progressed = true } + if flow.stream.socketEOF && flow.stream.toSocket.isEmpty { + close(flow) + return true + } + case .opening: + if try flow.stream.flushSocket() { progressed = true } + case .connecting: break + } + } catch { + close(flow) + return true + } + updateInterest(flow) + return progressed + } + + private func report(_ context: RuleContext) { + guard !context.stopped, context.start == nil, context.reportedCount != context.flows.count else { return } + context.reportedCount = context.flows.count + connectionHandler(context.rule.id, context.reportedCount) + } + + private func close(_ flow: Flow) { + guard !flow.retired else { return } + flow.retired = true + flows[flow.id] = nil + flow.context.flows[flow.id] = nil + flow.connector?.cancel() + flow.connector = nil + let descriptor = flow.stream.socket + flow.stream.socket = -1 + if let events = flow.events { + events.cancel { Darwin.close(descriptor) } + flow.events = nil + } else if descriptor >= 0 { + Darwin.close(descriptor) + } + if flow.stream.channel != nil { retiredFlows.append(flow) } + } + + private func close(_ context: RuleContext, completion: @escaping @Sendable () -> Void) { + context.stopped = true + context.start?.resume(throwing: CancellationError()) + context.start = nil + for flow in Array(context.flows.values) { close(flow) } + if context === listening { + // Drain the in-flight global request and cancel the resulting + // listener before allowing a replacement to bind the same port. + context.stop = completion + return + } + if let listener = context.remoteListener { + retiredListeners.append(RetiredListener(listener, completion: completion)) + context.remoteListener = nil + } else if let events = context.events { + let descriptor = context.socket + context.socket = -1 + context.events = nil + events.cancel { + Darwin.close(descriptor) + completion() + } + } else { + completion() } } + private func reap(_ session: OpaquePointer) -> Bool { + var progressed = false + retiredFlows.removeAll { flow in + guard flow.stream.finishPendingWrite(session: session), let channel = flow.stream.channel else { + blocked(session) + return false + } + flow.stream.toChannel.removeAll() + flow.stream.toSocket.removeAll() + let result = libssh2_channel_free(channel) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + flow.stream.channel = nil + progressed = true + return true + } + retiredListeners.removeAll { entry in + let result = libssh2_channel_forward_cancel(entry.listener) + if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + return false + } + entry.completion() + progressed = true + return true + } + return progressed + } + + private func fail(_ session: OpaquePointer) { + let message = SSHSessionWorker.sessionError( + session, prefix: String(localized: "The forwarding SSH connection closed") + ).localizedDescription + cleanup() + failureHandler(message) + } + + private func cleanup() { + generation += 1 + scheduled = false + timer?.cancel() + timer = nil + for context in rules.values { close(context, completion: {}) } + rules.removeAll() + // Shutdown makes disposal nonblocking even if a peer stopped reading. + if socket >= 0 { _ = Darwin.shutdown(socket, SHUT_RDWR) } + if let session { bufferedTransport?.dispose(session: session) } + bufferedTransport = nil + session = nil + for entry in retiredListeners { entry.completion() } + retiredListeners.removeAll() + retiredFlows.removeAll() + opening = nil + listening?.stop?() + listening?.stop = nil + listening = nil + pendingListeners.removeAll() + flows.removeAll() + ready = SSHReadyQueue() + pendingOpens = SSHReadyQueue() + if let transport = routeTransport { + if let events = transportEvents { events.cancel { transport.close() } } else { transport.close() } + } + transportEvents = nil + routeTransport = nil + socket = -1 + } private func handleSOCKSHandshake(_ flow: Flow, session: OpaquePointer) { switch flow.phase { case .greeting: @@ -358,19 +664,19 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { guard flow.handshake[0] == 0x05, flow.handshake[2..<(2 + methods)].contains(0x00) else { - flow.toSocket.append(contentsOf: [0x05, 0xff]) - flow.socketEOF = true + flow.stream.toSocket.append(contentsOf: [0x05, 0xff]) + flow.stream.socketEOF = true return } - flow.handshake.removeFirst(2 + methods) - flow.toSocket.append(contentsOf: [0x05, 0x00]) + flow.handshake = Data(flow.handshake.dropFirst(2 + methods)) + flow.stream.toSocket.append(contentsOf: [0x05, 0x00]) flow.phase = .request handleSOCKSHandshake(flow, session: session) case .request: guard flow.handshake.count >= 4 else { return } guard flow.handshake[0] == 0x05, flow.handshake[1] == 0x01 else { - flow.toSocket.append(socksReply(0x07)) - flow.socketEOF = true + flow.stream.toSocket.append(socksReply(0x07)) + flow.stream.socketEOF = true return } let parsed: (String, Int, Int)? @@ -404,27 +710,18 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { Int(flow.handshake[20]) << 8 | Int(flow.handshake[21]), 22 ) default: - flow.toSocket.append(socksReply(0x08)) - flow.socketEOF = true + flow.stream.toSocket.append(socksReply(0x08)) + flow.stream.socketEOF = true return } guard let (host, port, consumed) = parsed else { return } - flow.handshake.removeFirst(consumed) - flow.channel = host.withCString { target in - libssh2_channel_direct_tcpip_ex(session, target, Int32(port), "127.0.0.1", 0) - } - guard flow.channel != nil else { - flow.toSocket.append(socksReply(0x05)) - flow.socketEOF = true - return - } - flow.toSocket.append(socksReply(0x00)) - flow.phase = .forwarding - if !flow.handshake.isEmpty { - flow.toChannel.append(flow.handshake) - flow.handshake.removeAll() - } - case .forwarding: break + flow.target = (host, port) + flow.stream.toChannel.append(Data(flow.handshake.dropFirst(consumed))) + flow.handshake.removeAll() + flow.phase = .opening + pendingOpens.insert(flow.id) + schedulePump() + case .opening, .connecting, .forwarding: break } } @@ -467,45 +764,4 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { if flags >= 0 { _ = fcntl(socket, F_SETFL, flags | O_NONBLOCK) } } - private func close(_ flow: Flow, in context: RuleContext) { - context.flows[flow.id] = nil - if flow.socket >= 0 { - Darwin.close(flow.socket) - flow.socket = -1 - } - if let channel = flow.channel { - _ = libssh2_channel_close(channel) - _ = libssh2_channel_free(channel) - flow.channel = nil - } - } - - private func close(_ context: RuleContext) { - for flow in Array(context.flows.values) { close(flow, in: context) } - if context.socket >= 0 { - Darwin.close(context.socket) - context.socket = -1 - } - if let listener = context.remoteListener { - _ = libssh2_channel_forward_cancel(listener) - context.remoteListener = nil - } - } - - private func cleanup() { - timer?.cancel() - timer = nil - for context in Array(rules.values) { close(context) } - rules.removeAll() - if let session { - _ = libssh2_session_disconnect_ex( - session, SSH_DISCONNECT_BY_APPLICATION, "Termind stopped forwarding", "" - ) - _ = libssh2_session_free(session) - self.session = nil - } - socket = -1 - routeTransport?.close() - routeTransport = nil - } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift index 7d8aa98c..f937c743 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift @@ -420,19 +420,18 @@ private nonisolated final class SSHRouteOpeningState: @unchecked Sendable { /// to this queue. private nonisolated final class SSHChannelSocketBridge: @unchecked Sendable { public let clientSocket: Int32 - private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.route-bridge") private var transportSocket: Int32 - private var pumpSocket: Int32 private var session: OpaquePointer? - private var channel: OpaquePointer? + private let bufferedTransport: SSHBufferedTransport + private let stream: SSHStreamPump + private var transportEvents: SSHSocketEvents? + private var socketEvents: SSHSocketEvents? private var timer: (any DispatchSourceTimer)? - private var toChannel = Data() - private var toSocket = Data() - private var socketEOF = false - private var channelEOF = false - private var channelWriteClosed = false + private var scratch = [UInt8](repeating: 0, count: 32 * 1024) private var isClosed = false + private var scheduled = false + private var keepaliveDue = true public init( transportSocket: Int32, session: OpaquePointer, channel: OpaquePointer, @@ -447,8 +446,8 @@ private nonisolated final class SSHChannelSocketBridge: @unchecked Sendable { } self.transportSocket = transportSocket self.session = session - self.channel = channel - pumpSocket = pair[0] + bufferedTransport = SSHBufferedTransport(session: session) + stream = SSHStreamPump(socket: pair[0], channel: channel) clientSocket = pair[1] var yes: Int32 = 1 @@ -467,97 +466,77 @@ private nonisolated final class SSHChannelSocketBridge: @unchecked Sendable { clientSocket, SOL_SOCKET, SO_SNDTIMEO, &interval, socklen_t(MemoryLayout.size) ) - let flags = fcntl(pumpSocket, F_GETFL, 0) - if flags >= 0 { _ = fcntl(pumpSocket, F_SETFL, flags | O_NONBLOCK) } + let flags = fcntl(stream.socket, F_GETFL, 0) + if flags >= 0 { _ = fcntl(stream.socket, F_SETFL, flags | O_NONBLOCK) } libssh2_session_set_blocking(session, 0) - let source = DispatchSource.makeTimerSource(queue: queue) - source.schedule(deadline: .now(), repeating: .milliseconds(2), leeway: .milliseconds(1)) - source.setEventHandler { [weak self] in self?.pump() } - timer = source - source.resume() + libssh2_keepalive_config(session, 0, 20) + queue.async { [self] in startEvents() } } - public func close() { - queue.sync { closeOnQueue() } + private func startEvents() { + guard !isClosed else { return } + transportEvents = SSHSocketEvents(socket: transportSocket, queue: queue) { [weak self] in self?.schedule() } + socketEvents = SSHSocketEvents(socket: stream.socket, queue: queue) { [weak self] in self?.schedule() } + transportEvents?.update(read: true, write: false) + socketEvents?.update(read: true, write: false) + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now() + 20, repeating: .seconds(20), leeway: .seconds(1)) + source.setEventHandler { [weak self] in + self?.keepaliveDue = true + self?.schedule() + } + timer = source + source.resume() + schedule() + } + + public func close() { queue.sync { closeOnQueue() } } + + private func schedule() { + guard !isClosed, !scheduled else { return } + scheduled = true + queue.async { [weak self] in + guard let self, !self.isClosed else { return } + self.scheduled = false + self.pump() + } } private func pump() { - guard !isClosed, let session, let channel else { return } - let highWaterMark = 2 * 1024 * 1024 - - if !socketEOF, toChannel.count < highWaterMark { - var bytes = [UInt8](repeating: 0, count: 32 * 1024) - let count = Darwin.recv(pumpSocket, &bytes, bytes.count, 0) - if count > 0 { - toChannel.append(contentsOf: bytes.prefix(count)) - } else if count == 0 { - socketEOF = true - } else if errno != EAGAIN && errno != EWOULDBLOCK { + guard !isClosed, let session else { return } + do { + try bufferedTransport.flush(socket: transportSocket) + if bufferedTransport.isBackpressured { + transportEvents?.update(read: false, write: true) + socketEvents?.update(read: false, write: stream.socketWriteBlocked) + return + } + bufferedTransport.beginPass() + let progressed = try stream.pump(session: session, scratch: &scratch) + if stream.finished { closeOnQueue() return } - } - - while !toChannel.isEmpty { - let written = toChannel.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } - return libssh2_channel_write_ex(channel, 0, base, bytes.count) - } - if written > 0 { - toChannel.removeFirst(written) - } else if written == LIBSSH2_ERROR_EAGAIN { - break - } else { - closeOnQueue() - return - } - } - if socketEOF, toChannel.isEmpty, !channelWriteClosed { - let result = libssh2_channel_send_eof(channel) - if result == 0 { - channelWriteClosed = true - } else if result != LIBSSH2_ERROR_EAGAIN { - closeOnQueue() - return - } - } - - if toSocket.count < highWaterMark { - var bytes = [CChar](repeating: 0, count: 32 * 1024) - while toSocket.count < highWaterMark { - let count = libssh2_channel_read_ex(channel, 0, &bytes, bytes.count) - if count > 0 { - toSocket.append(Data(bytes: bytes, count: count)) - } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { - break - } else { + if keepaliveDue { + var next: Int32 = 0 + let result = libssh2_keepalive_send(session, &next) + if result == LIBSSH2_ERROR_EAGAIN { + stream.blocked(session) + } else if result < 0 { closeOnQueue() return + } else { + keepaliveDue = false } } - } - if libssh2_channel_eof(channel) == 1 { channelEOF = true } - - while !toSocket.isEmpty { - let sent = toSocket.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress else { return 0 } - return Darwin.send(pumpSocket, base, bytes.count, 0) - } - if sent > 0 { - toSocket.removeFirst(sent) - } else if errno == EAGAIN || errno == EWOULDBLOCK { - break - } else { - closeOnQueue() - return - } - } - if channelEOF && toSocket.isEmpty { - _ = Darwin.shutdown(pumpSocket, SHUT_WR) - } - var next = Int32(0) - _ = libssh2_keepalive_send(session, &next) + try bufferedTransport.flush(socket: transportSocket) + socketEvents?.update(read: stream.wantsSocketRead, write: stream.socketWriteBlocked) + transportEvents?.update( + read: !bufferedTransport.isBackpressured && (stream.wantsChannelRead || !stream.toChannel.isEmpty), + write: bufferedTransport.wantsWrite || stream.blockDirections & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0) + if progressed || bufferedTransport.exhaustedReadBudget { schedule() } + } catch { closeOnQueue() } } private func closeOnQueue() { @@ -565,25 +544,17 @@ private nonisolated final class SSHChannelSocketBridge: @unchecked Sendable { isClosed = true timer?.cancel() timer = nil - if pumpSocket >= 0 { - Darwin.close(pumpSocket) - pumpSocket = -1 - } - if let channel { - _ = libssh2_channel_close(channel) - _ = libssh2_channel_free(channel) - self.channel = nil - } - if let session { - _ = libssh2_session_disconnect_ex( - session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the jump route", "" - ) - _ = libssh2_session_free(session) - self.session = nil - } - if transportSocket >= 0 { - Darwin.close(transportSocket) - transportSocket = -1 - } + let local = stream.socket + stream.socket = -1 + if let socketEvents { socketEvents.cancel { Darwin.close(local) } } else { Darwin.close(local) } + socketEvents = nil + let remote = transportSocket + transportSocket = -1 + _ = Darwin.shutdown(remote, SHUT_RDWR) + if let session { bufferedTransport.dispose(session: session) } + session = nil + stream.channel = nil + if let transportEvents { transportEvents.cancel { Darwin.close(remote) } } else { Darwin.close(remote) } + transportEvents = nil } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift new file mode 100644 index 00000000..d337a5ba --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift @@ -0,0 +1,349 @@ +import CSSH2 +import Darwin +import Foundation + +/// Queue-confined chunks keep libssh2's retry pointer and length stable even when +/// new socket data arrives while an earlier write is blocked. +nonisolated final class SSHByteQueue { + private final class Chunk { + let bytes: UnsafeMutableRawPointer + let size: Int + var offset = 0 + + init(_ source: UnsafeRawBufferPointer) { + size = source.count + bytes = .allocate(byteCount: size, alignment: 1) + bytes.copyMemory(from: source.baseAddress!, byteCount: size) + } + + deinit { bytes.deallocate() } + } + + private var chunks: [Chunk?] = [] + private var head = 0 + private(set) var count = 0 + var isEmpty: Bool { count == 0 } + + func append(_ data: Data) { data.withUnsafeBytes { append($0) } } + func append(contentsOf bytes: [UInt8]) { bytes.withUnsafeBytes { append($0) } } + + func append(_ bytes: UnsafeRawBufferPointer) { + var offset = 0 + while offset < bytes.count { + let end = min(offset + 32 * 1024, bytes.count) + chunks.append(Chunk(UnsafeRawBufferPointer(rebasing: bytes[offset..(_ body: (UnsafeRawBufferPointer) -> T) -> T { + guard head < chunks.count, let chunk = chunks[head] else { + return body(UnsafeRawBufferPointer(start: nil, count: 0)) + } + return body(UnsafeRawBufferPointer(start: chunk.bytes + chunk.offset, count: chunk.size - chunk.offset)) + } + + func removeAll() { + chunks.removeAll() + head = 0 + count = 0 + } + + func consume(_ size: Int) { + precondition(size > 0 && size <= count) + let chunk = chunks[head]! + precondition(size <= chunk.size - chunk.offset) + chunk.offset += size + count -= size + if chunk.offset == chunk.size { + chunks[head] = nil + head += 1 + if head == chunks.count { + chunks.removeAll(keepingCapacity: true) + head = 0 + } else if head >= 64 && head * 2 >= chunks.count { + chunks.removeFirst(head) + head = 0 + } + } + } +} + +/// Sources, interest changes and their handlers all belong to the supplied queue. +/// Closing the descriptor must wait for both cancellation handlers, including +/// when a source was suspended for backpressure. +nonisolated final class SSHSocketEvents { + private let read: any DispatchSourceRead + private let write: any DispatchSourceWrite + private let queue: DispatchQueue + private let cancelled = DispatchGroup() + private var reading = false + private var writing = false + private var stopped = false + + init(socket: Int32, queue: DispatchQueue, ready: @escaping @Sendable () -> Void) { + self.queue = queue + read = DispatchSource.makeReadSource(fileDescriptor: socket, queue: queue) + write = DispatchSource.makeWriteSource(fileDescriptor: socket, queue: queue) + for source in [read as any DispatchSourceProtocol, write as any DispatchSourceProtocol] { + cancelled.enter() + source.setCancelHandler { [cancelled] in cancelled.leave() } + source.setEventHandler { [weak self] in + guard let self, !self.stopped else { return } + ready() + } + } + } + + func update(read wantsRead: Bool, write wantsWrite: Bool) { + guard !stopped else { return } + if wantsRead != reading { + reading = wantsRead + if reading { read.resume() } else { read.suspend() } + } + if wantsWrite != writing { + writing = wantsWrite + if writing { write.resume() } else { write.suspend() } + } + } + + func cancel(then completion: @escaping @Sendable () -> Void = {}) { + guard !stopped else { return } + stopped = true + read.cancel() + write.cancel() + if !reading { read.resume() } + if !writing { write.resume() } + cancelled.notify(queue: queue, execute: completion) + } +} + +/// Deduplicated FIFO; a hot stream goes behind its peers rather than monopolizing +/// the session queue. Also used for listener/channel work that must retain order. +nonisolated struct SSHReadyQueue { + private var items: [Element] = [] + private var members: Set = [] + private var head = 0 + var isEmpty: Bool { members.isEmpty } + + mutating func insert(_ item: Element) { + if members.insert(item).inserted { items.append(item) } + } + + mutating func pop() -> Element? { + guard head < items.count else { return nil } + let item = items[head] + members.remove(item) + head += 1 + if head == items.count { + items.removeAll(keepingCapacity: true) + head = 0 + } else if head >= 64 && head * 2 >= items.count { + items.removeFirst(head) + head = 0 + } + return item + } +} + +/// Internal I/O seam: production uses libssh2; tests can force short writes, +/// blocked windows and EOF independently of operating-system timing. +nonisolated struct SSHChannelIO { + var read: (OpaquePointer, UnsafeMutableRawBufferPointer) -> Int = { + libssh2_channel_read_ex($0, 0, $1.baseAddress!.assumingMemoryBound(to: CChar.self), $1.count) + } + var write: (OpaquePointer, UnsafeRawBufferPointer) -> Int = { + libssh2_channel_write_ex($0, 0, $1.baseAddress!.assumingMemoryBound(to: CChar.self), $1.count) + } + var writeWindow: (OpaquePointer) -> UInt = { libssh2_channel_window_write_ex($0, nil) } + var eof: (OpaquePointer) -> Int32 = { libssh2_channel_eof($0) } + var sendEOF: (OpaquePointer) -> Int32 = { libssh2_channel_send_eof($0) } + var directions: (OpaquePointer) -> Int32 = { libssh2_session_block_directions($0) } +} + +/// A socket/channel pair. Its owner serializes every call with all other calls +/// on the same SSH session, and provides a reusable scratch buffer. +nonisolated final class SSHStreamPump { + static let highWater = 2 * 1024 * 1024 + static let lowWater = 1024 * 1024 + static let quantum = 256 * 1024 + + private let io: SSHChannelIO + var socket: Int32 + var channel: OpaquePointer? + let toChannel = SSHByteQueue() + let toSocket = SSHByteQueue() + var socketEOF = false + var channelEOF = false + var socketWriteClosed = false + var channelWriteClosed = false + private(set) var writePending = false + private(set) var blockDirections: Int32 = 0 + private var socketReadPaused = false + private var channelReadPaused = false + private(set) var socketWriteBlocked = false + + init(socket: Int32, channel: OpaquePointer? = nil, io: SSHChannelIO = SSHChannelIO()) { + self.io = io + self.socket = socket + self.channel = channel + } + + var wantsSocketRead: Bool { !socketEOF && !socketReadPaused } + var wantsChannelRead: Bool { !channelEOF && !channelReadPaused } + var finished: Bool { socketEOF && channelEOF && toChannel.isEmpty && toSocket.isEmpty } + + func blocked(_ session: OpaquePointer) { + blockDirections |= io.directions(session) + } + + /// Returns whether bytes/EOF moved. Even a final successful read can have + /// buffered packets for another channel: the owner schedules another sweep. + func pump(session: OpaquePointer, scratch: inout [UInt8]) throws -> Bool { + blockDirections = 0 + socketWriteBlocked = false + var progressed = false + var waitingForWindow = false + if socketReadPaused && toChannel.count <= Self.lowWater { socketReadPaused = false } + if channelReadPaused && toSocket.count <= Self.lowWater { channelReadPaused = false } + var remaining = Self.quantum + while wantsSocketRead && remaining > 0 { + let capacity = min(scratch.count, remaining, Self.highWater - toChannel.count) + if capacity == 0 { + socketReadPaused = true + break + } + let count = Darwin.recv(socket, &scratch, capacity, 0) + if count > 0 { + scratch.withUnsafeBytes { toChannel.append(UnsafeRawBufferPointer(rebasing: $0[..= Self.highWater { socketReadPaused = true } + if let channel { + remaining = Self.quantum + while !toChannel.isEmpty && remaining > 0 { + let written = try write(session: session, channel: channel) + if written == 0 { + waitingForWindow = io.writeWindow(channel) == 0 + break + } + remaining -= written + progressed = true + } + if socketEOF && toChannel.isEmpty && !channelWriteClosed { + let result = io.sendEOF(channel) + if result == 0 { + channelWriteClosed = true + progressed = true + } else if result == LIBSSH2_ERROR_EAGAIN { + blocked(session) + } else { + throw StreamError.closed + } + } + remaining = Self.quantum + while wantsChannelRead && remaining > 0 { + let capacity = min(scratch.count, remaining, Self.highWater - toSocket.count) + if capacity == 0 { + channelReadPaused = true + break + } + let count = scratch.withUnsafeMutableBytes { + io.read(channel, UnsafeMutableRawBufferPointer(rebasing: $0[.. 0 { + scratch.withUnsafeBytes { toSocket.append(UnsafeRawBufferPointer(rebasing: $0[..= Self.highWater { channelReadPaused = true } + if try flushSocket() { progressed = true } + if channelEOF && toSocket.isEmpty && !socketWriteClosed { + _ = Darwin.shutdown(socket, SHUT_WR) + socketWriteClosed = true + progressed = true + } + if waitingForWindow, let channel, io.writeWindow(channel) > 0 { progressed = true } + return progressed + } + + func flushSocket() throws -> Bool { + var remaining = Self.quantum + var progressed = false + socketWriteBlocked = false + while !toSocket.isEmpty && remaining > 0 { + let sent = toSocket.withFirstBytes { Darwin.send(socket, $0.baseAddress!, $0.count, 0) } + if sent > 0 { + toSocket.consume(sent) + remaining -= sent + progressed = true + } else if sent < 0 && errno == EINTR { + continue + } else if sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { + socketWriteBlocked = true + break + } else { + throw StreamError.closed + } + } + return progressed + } + + /// Finish an already-started write before freeing a retired channel. The SSH + /// transport may still own this channel's packet, so simply dropping it can + /// prevent every peer from writing. + func finishPendingWrite(session: OpaquePointer) -> Bool { + guard writePending, let channel else { return true } + // A zero-window write has not entered libssh2's packet-send state. + // Cancellation must not wait for a peer that will never read again. + if io.writeWindow(channel) == 0 { + writePending = false + return true + } + do { _ = try write(session: session, channel: channel) } catch { writePending = false } + return !writePending + } + + private func write(session: OpaquePointer, channel: OpaquePointer) throws -> Int { + let written = toChannel.withFirstBytes { + io.write(channel, $0) + } + if written > 0 { + writePending = false + toChannel.consume(written) + return written + } + if written == LIBSSH2_ERROR_EAGAIN || written == 0 { + writePending = true + blocked(session) + return 0 + } + writePending = false + throw StreamError.closed + } + + enum StreamError: Error { case closed } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 63986415..4e591aa0 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -254,6 +254,28 @@ transport failure fails all current rules without restarting them. Host route ed (including jump hosts) and rule replacement stop affected runs. Authentication's own Continue and Save may continue its explicitly requested opening. Connection and rule generations reject late callbacks, and stop/start serializes listener cleanup. +Forwarding and jump-host bridges service sockets through DispatchSource readiness +on their owning serial queues; every libssh2 session has exactly one queue owner. +The shared stream pump retains stable write chunks across EAGAIN, uses a 256 KiB +per-direction quantum, caps encrypted reads at 256 KiB per session pass (including +reads performed internally by libssh2 writes), and pauses input at a 2 MiB high +watermark until the queue falls below 1 MiB. A deduplicated FIFO and a 2 ms worker slice prevent a hot flow +from monopolizing its peers. Half-close drains accepted bytes before propagating +EOF. Remote target DNS uses at most four workers; nonblocking TCP connects and a +five-second deadline never block SSH I/O. Channel opens and remote listener starts +retain and retry the library's session-wide operation state. + +Authenticated forwarding/bridge sessions install an encrypted-output adapter using +libssh2's public send/receive callbacks. It accepts complete encrypted packets and +applies bounded backpressure between library calls, including receive-generated +replies. This avoids the pinned libssh2 forwarded-tcpip confirmation retry losing +its channel on socket EAGAIN. It is installed only after authentication has released +the session abstract context. Teardown switches the adapter to discard writes and +return EOF, allowing session disposal without waiting on the peer. Socket descriptors +are closed only after dispatch-source cancellation handlers retire. Timers serve +keepalive/protocol deadlines, never bulk-data polling; count notifications occur +only on changes, including counts received while a rule is still starting. + An Identity may contain only a username: preparation permits an empty credential list, SSH tries `none`, then advertised methods. Missing configured secrets and existing authentication failure/retry behavior stay unchanged. diff --git a/Memory/PORT_FORWARDING_BENCHMARK.json b/Memory/PORT_FORWARDING_BENCHMARK.json new file mode 100644 index 00000000..0ee81f9d --- /dev/null +++ b/Memory/PORT_FORWARDING_BENCHMARK.json @@ -0,0 +1,1746 @@ +{ + "date": "2026-09-19", + "hardware": "Apple M3 Pro, 36 GiB", + "os": "macOS 26.6.2", + "compiler": "Xcode 27, Swift 6, Release with testability", + "fixture": "Paramiko 4.0.0, loopback echo", + "baseline_revision": "5c88a2bf", + "baseline_successful_measurements": 0, + "measurements": [ + { + "cpu_s": 0.012160999999999977, + "elapsed_s": 0.011542667, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 48.73223839863006, + "process_peak_rss_mib": 208.4375, + "ready_p95_ms": 0.873541, + "request_p95_ms": 1.576291, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.15225900000000003, + "elapsed_s": 0.1375825, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 65.41529627677939, + "process_peak_rss_mib": 211.78125, + "ready_p95_ms": 40.767292, + "request_p95_ms": 16.479084, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5926740000000001, + "elapsed_s": 0.556970667, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 64.63536077026477, + "process_peak_rss_mib": 219.953125, + "ready_p95_ms": 196.66958300000002, + "request_p95_ms": 56.901917000000005, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.008758000000000044, + "elapsed_s": 0.009582709, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 58.69947631718755, + "process_peak_rss_mib": 219.953125, + "ready_p95_ms": 0.318708, + "request_p95_ms": 1.0534999999999999, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.14532100000000003, + "elapsed_s": 0.137828542, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 65.29852140494964, + "process_peak_rss_mib": 220.921875, + "ready_p95_ms": 20.9705, + "request_p95_ms": 16.46225, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5919400000000001, + "elapsed_s": 0.556569584, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 64.68193921283344, + "process_peak_rss_mib": 225.15625, + "ready_p95_ms": 75.43950000000001, + "request_p95_ms": 63.841584000000005, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.008860000000000312, + "elapsed_s": 0.009423334, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 59.69224904900962, + "process_peak_rss_mib": 225.15625, + "ready_p95_ms": 0.47791700000000004, + "request_p95_ms": 0.9877499999999999, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.14199499999999965, + "elapsed_s": 0.135654, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 66.34526073687469, + "process_peak_rss_mib": 225.15625, + "ready_p95_ms": 52.972875, + "request_p95_ms": 16.814417000000002, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.6072889999999997, + "elapsed_s": 0.568617291, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 63.31147604865924, + "process_peak_rss_mib": 226.71875, + "ready_p95_ms": 210.971875, + "request_p95_ms": 56.694666, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.3367359999999997, + "elapsed_s": 0.232821959, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 38.6561475500685, + "process_peak_rss_mib": 226.90625, + "ready_p95_ms": 146.529167, + "request_p95_ms": 20.0935, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5231919999999999, + "elapsed_s": 0.244978209, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.73796145680859, + "process_peak_rss_mib": 227.078125, + "ready_p95_ms": 154.877042, + "request_p95_ms": 21.946167, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.33865800000000057, + "elapsed_s": 0.250335291, + "jumps": 1, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 35.95178276322214, + "process_peak_rss_mib": 227.078125, + "ready_p95_ms": 140.055791, + "request_p95_ms": 27.095333, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.521369, + "elapsed_s": 0.244982083, + "jumps": 2, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.73738050467961, + "process_peak_rss_mib": 227.078125, + "ready_p95_ms": 158.46450000000002, + "request_p95_ms": 22.46125, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.33490900000000057, + "elapsed_s": 0.229176292, + "jumps": 1, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 39.27107783033683, + "process_peak_rss_mib": 227.078125, + "ready_p95_ms": 145.57737500000002, + "request_p95_ms": 19.935042, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.517671, + "elapsed_s": 0.244585625, + "jumps": 2, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.79692950066055, + "process_peak_rss_mib": 227.078125, + "ready_p95_ms": 155.02195799999998, + "request_p95_ms": 21.015083999999998, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.12202300000000044, + "elapsed_s": 0.120197375, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 67.07717202642736, + "process_peak_rss_mib": 227.078125, + "ready_p95_ms": 0.400167, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 1.9298289999999998, + "elapsed_s": 1.8838795830000001, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 68.47571424632824, + "process_peak_rss_mib": 228.828125, + "ready_p95_ms": 91.40279199999999, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 7.929413, + "elapsed_s": 7.7019345, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 66.99615531656364, + "process_peak_rss_mib": 245.171875, + "ready_p95_ms": 447.786, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.12036399999999858, + "elapsed_s": 0.11565725, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 69.71028621206193, + "process_peak_rss_mib": 245.171875, + "ready_p95_ms": 0.577875, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 1.9651560000000021, + "elapsed_s": 1.833799459, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 70.34575093088192, + "process_peak_rss_mib": 271.765625, + "ready_p95_ms": 431.23275, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 7.9544640000000015, + "elapsed_s": 7.59330425, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 67.9546061913692, + "process_peak_rss_mib": 271.765625, + "ready_p95_ms": 1801.3675, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.12176600000000093, + "elapsed_s": 0.120065833, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 67.15066058801258, + "process_peak_rss_mib": 271.765625, + "ready_p95_ms": 0.609541, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 1.8493890000000022, + "elapsed_s": 1.7994934580000002, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 71.68684021967697, + "process_peak_rss_mib": 271.765625, + "ready_p95_ms": 145.7725, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 7.835163000000001, + "elapsed_s": 7.622186667, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 67.6971087882175, + "process_peak_rss_mib": 273.03125, + "ready_p95_ms": 643.807125, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 1.100400999999998, + "elapsed_s": 1.075913542, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 60.066142900634674, + "process_peak_rss_mib": 273.03125, + "ready_p95_ms": 98.43625, + "request_p95_ms": 56.761875, + "small_request_p95_ms": 56.761875, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 2.430391, + "elapsed_s": 2.165896959, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 29.837973729063258, + "process_peak_rss_mib": 273.03125, + "ready_p95_ms": 1920.035166, + "request_p95_ms": 2.3606249999999998, + "small_request_p95_ms": 2.3606249999999998, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 3.784842999999995, + "elapsed_s": 2.195654833, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 29.433577441769057, + "process_peak_rss_mib": 273.03125, + "ready_p95_ms": 1196.9411249999998, + "request_p95_ms": 121.971625, + "small_request_p95_ms": 121.971625, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 1, + "implementation": "event_driven" + }, + { + "cpu_s": 0.012477000000000071, + "elapsed_s": 0.011267042, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 49.92437234191547, + "process_peak_rss_mib": 208.171875, + "ready_p95_ms": 0.850959, + "request_p95_ms": 1.2306249999999999, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.15468100000000007, + "elapsed_s": 0.139533042, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 64.50085134673692, + "process_peak_rss_mib": 211.078125, + "ready_p95_ms": 36.087209, + "request_p95_ms": 17.848084, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.574066, + "elapsed_s": 0.539923833, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 66.67607132652726, + "process_peak_rss_mib": 219.125, + "ready_p95_ms": 186.036125, + "request_p95_ms": 56.916792, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.008799000000000001, + "elapsed_s": 0.010664958, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 52.74282374107802, + "process_peak_rss_mib": 219.125, + "ready_p95_ms": 0.47091700000000003, + "request_p95_ms": 1.161167, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.14519500000000019, + "elapsed_s": 0.136082583, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 66.13631077240795, + "process_peak_rss_mib": 220.3125, + "ready_p95_ms": 33.316584000000006, + "request_p95_ms": 16.881417, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5842969999999998, + "elapsed_s": 0.548881584, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 65.58791741134459, + "process_peak_rss_mib": 224.6875, + "ready_p95_ms": 89.29487499999999, + "request_p95_ms": 61.655042, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.008825000000000305, + "elapsed_s": 0.009350375, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 60.158015052872216, + "process_peak_rss_mib": 224.6875, + "ready_p95_ms": 0.49066699999999996, + "request_p95_ms": 0.9857500000000001, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.14291900000000002, + "elapsed_s": 0.137793208, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 65.31526575678534, + "process_peak_rss_mib": 224.6875, + "ready_p95_ms": 48.157291, + "request_p95_ms": 16.938291, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.6112600000000001, + "elapsed_s": 0.57023775, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 63.13156222996461, + "process_peak_rss_mib": 228.265625, + "ready_p95_ms": 230.330167, + "request_p95_ms": 56.035708, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.3425010000000004, + "elapsed_s": 0.236750125, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 38.01476345577431, + "process_peak_rss_mib": 228.546875, + "ready_p95_ms": 146.44750000000002, + "request_p95_ms": 20.486125, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5116929999999997, + "elapsed_s": 0.243753834, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.92249616061424, + "process_peak_rss_mib": 228.703125, + "ready_p95_ms": 150.366166, + "request_p95_ms": 22.244208, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.3397350000000001, + "elapsed_s": 0.248465375, + "jumps": 1, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.222350901005825, + "process_peak_rss_mib": 228.859375, + "ready_p95_ms": 155.656458, + "request_p95_ms": 24.906292, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5216419999999999, + "elapsed_s": 0.248292041, + "jumps": 2, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.2476379176407, + "process_peak_rss_mib": 229.078125, + "ready_p95_ms": 155.761333, + "request_p95_ms": 24.298166, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.3344800000000001, + "elapsed_s": 0.234432833, + "jumps": 1, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 38.390526978787136, + "process_peak_rss_mib": 229.078125, + "ready_p95_ms": 144.732208, + "request_p95_ms": 21.957834, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5202369999999998, + "elapsed_s": 0.242678834, + "jumps": 2, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 37.08605258915988, + "process_peak_rss_mib": 229.09375, + "ready_p95_ms": 155.738084, + "request_p95_ms": 21.038166, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.12158699999999989, + "elapsed_s": 0.119834541, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 67.28026771513231, + "process_peak_rss_mib": 229.09375, + "ready_p95_ms": 0.365666, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 1.9023310000000002, + "elapsed_s": 1.8550740000000001, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 69.53900491301155, + "process_peak_rss_mib": 230.8125, + "ready_p95_ms": 123.452334, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 7.921331, + "elapsed_s": 7.83314275, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 65.8739431245524, + "process_peak_rss_mib": 248, + "ready_p95_ms": 515.007667, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.12123299999999837, + "elapsed_s": 0.116345958, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 69.2976373102708, + "process_peak_rss_mib": 248, + "ready_p95_ms": 0.661334, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 1.981516000000001, + "elapsed_s": 1.8479468749999999, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 69.80720157336775, + "process_peak_rss_mib": 274.5625, + "ready_p95_ms": 480.264625, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 7.9881969999999995, + "elapsed_s": 7.629365542, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 67.6334089852422, + "process_peak_rss_mib": 274.5625, + "ready_p95_ms": 1790.026334, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.1240099999999984, + "elapsed_s": 0.121761792, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 66.21535267812091, + "process_peak_rss_mib": 274.5625, + "ready_p95_ms": 0.49354200000000004, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 1.8858779999999982, + "elapsed_s": 1.835222, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 70.29122362308212, + "process_peak_rss_mib": 274.5625, + "ready_p95_ms": 175.021792, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 8.119906, + "elapsed_s": 7.913643583, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 65.2038463178283, + "process_peak_rss_mib": 274.6875, + "ready_p95_ms": 658.7373749999999, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 1.0790949999999953, + "elapsed_s": 1.059411459, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 61.001772270333724, + "process_peak_rss_mib": 274.6875, + "ready_p95_ms": 119.381292, + "request_p95_ms": 55.927749999999996, + "small_request_p95_ms": 55.927749999999996, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 2.4547430000000006, + "elapsed_s": 2.126802583, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 30.386448220003878, + "process_peak_rss_mib": 274.6875, + "ready_p95_ms": 1699.364417, + "request_p95_ms": 95.025792, + "small_request_p95_ms": 95.025792, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 3.7533439999999985, + "elapsed_s": 2.233250208, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 28.938081515002374, + "process_peak_rss_mib": 275.25, + "ready_p95_ms": 1457.708333, + "request_p95_ms": 21.403708, + "small_request_p95_ms": 21.403708, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 2, + "implementation": "event_driven" + }, + { + "cpu_s": 0.011788999999999938, + "elapsed_s": 0.011656958, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 48.2544416819551, + "process_peak_rss_mib": 208.5, + "ready_p95_ms": 1.268125, + "request_p95_ms": 1.208084, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.152906, + "elapsed_s": 0.139472042, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 64.52906167388014, + "process_peak_rss_mib": 211.484375, + "ready_p95_ms": 34.776916, + "request_p95_ms": 18.174042, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5806009999999999, + "elapsed_s": 0.54497475, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 66.05810636180851, + "process_peak_rss_mib": 219.640625, + "ready_p95_ms": 194.24825, + "request_p95_ms": 55.065833000000005, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.00882000000000005, + "elapsed_s": 0.010755416, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 52.29923231235314, + "process_peak_rss_mib": 219.640625, + "ready_p95_ms": 0.4835, + "request_p95_ms": 1.173166, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.14241199999999998, + "elapsed_s": 0.134359042, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 66.98469910197781, + "process_peak_rss_mib": 219.640625, + "ready_p95_ms": 27.226666, + "request_p95_ms": 17.067666, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5806050000000003, + "elapsed_s": 0.54583375, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 65.95414812660448, + "process_peak_rss_mib": 222.453125, + "ready_p95_ms": 81.91137499999999, + "request_p95_ms": 66.844583, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.008918999999999677, + "elapsed_s": 0.010668625, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 589824, + "payload_mib_s_per_direction": 52.72469507551348, + "process_peak_rss_mib": 222.453125, + "ready_p95_ms": 0.777459, + "request_p95_ms": 1.11175, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.14405800000000024, + "elapsed_s": 0.137742125, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 65.33948855515334, + "process_peak_rss_mib": 222.875, + "ready_p95_ms": 51.032875, + "request_p95_ms": 17.005791, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.594436, + "elapsed_s": 0.560302417, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 37748736, + "payload_mib_s_per_direction": 64.25101678617246, + "process_peak_rss_mib": 225.78125, + "ready_p95_ms": 217.309791, + "request_p95_ms": 53.290458, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.3378070000000002, + "elapsed_s": 0.233324541, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 38.57288205272844, + "process_peak_rss_mib": 225.96875, + "ready_p95_ms": 147.469791, + "request_p95_ms": 19.456792, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5231469999999998, + "elapsed_s": 0.243537, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.95537023121743, + "process_peak_rss_mib": 226.140625, + "ready_p95_ms": 151.225, + "request_p95_ms": 20.86825, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.3381599999999998, + "elapsed_s": 0.249894833, + "jumps": 1, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 36.01515042129743, + "process_peak_rss_mib": 226.15625, + "ready_p95_ms": 162.54441599999998, + "request_p95_ms": 23.487000000000002, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5217909999999994, + "elapsed_s": 0.254228792, + "jumps": 2, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 35.40118304145504, + "process_peak_rss_mib": 226.265625, + "ready_p95_ms": 163.359417, + "request_p95_ms": 22.544042, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.3356969999999997, + "elapsed_s": 0.233003209, + "jumps": 1, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 38.6260774631649, + "process_peak_rss_mib": 226.265625, + "ready_p95_ms": 146.355917, + "request_p95_ms": 21.051291, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.5078179999999994, + "elapsed_s": 0.241983208, + "jumps": 2, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 37.192663385138694, + "process_peak_rss_mib": 226.453125, + "ready_p95_ms": 152.018959, + "request_p95_ms": 19.803042, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.12119999999999997, + "elapsed_s": 0.119144917, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 67.66969337013344, + "process_peak_rss_mib": 226.453125, + "ready_p95_ms": 0.341958, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 1.9292150000000001, + "elapsed_s": 1.88212025, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 68.53972268775068, + "process_peak_rss_mib": 228.515625, + "ready_p95_ms": 91.77025, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 7.973093, + "elapsed_s": 7.746645667, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 66.60947488512514, + "process_peak_rss_mib": 245.734375, + "ready_p95_ms": 427.356041, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.11886600000000058, + "elapsed_s": 0.114792166, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 70.23562914563351, + "process_peak_rss_mib": 245.734375, + "ready_p95_ms": 0.649125, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 2.0235360000000018, + "elapsed_s": 1.8651665419999999, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 69.16272466568833, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 293.444375, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 7.997571000000001, + "elapsed_s": 7.633765833, + "jumps": 0, + "kind": "remote", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 67.59442341935406, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 1727.928708, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 0.12190599999999918, + "elapsed_s": 0.1202025, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 8454144, + "payload_mib_s_per_direction": 67.07431209833406, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 0.619667, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 1, + "successful_streams": 1, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 1.894552000000001, + "elapsed_s": 1.845027042, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 135266304, + "payload_mib_s_per_direction": 69.91767440989085, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 167.890959, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 7.8234099999999955, + "elapsed_s": 7.613951042, + "jumps": 0, + "kind": "dynamic", + "payload_bytes_per_direction": 541065216, + "payload_mib_s_per_direction": 67.77033332019683, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 653.682917, + "request_p95_ms": 0, + "small_request_p95_ms": 0, + "streams": 64, + "successful_streams": 64, + "workload": "duplex", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 1.0661330000000007, + "elapsed_s": 1.048438833, + "jumps": 0, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 61.64019733757801, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 120.373833, + "request_p95_ms": 55.271291, + "small_request_p95_ms": 55.271291, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 2.473281, + "elapsed_s": 2.1332865, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 30.29409156365073, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 1553.542584, + "request_p95_ms": 56.08025, + "small_request_p95_ms": 56.08025, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 3.748441999999997, + "elapsed_s": 2.251257583, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 67765248, + "payload_mib_s_per_direction": 28.706611384904328, + "process_peak_rss_mib": 272.609375, + "ready_p95_ms": 1909.5145420000001, + "request_p95_ms": 3.369334, + "small_request_p95_ms": 3.369334, + "streams": 24, + "successful_streams": 24, + "workload": "mixed", + "run": 3, + "implementation": "event_driven" + }, + { + "cpu_s": 1.8134640000000002, + "elapsed_s": 2.420209833, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.718685825205471, + "process_peak_rss_mib": 235.75, + "ready_p95_ms": 1457.080542, + "request_p95_ms": 212.244333, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.889297, + "elapsed_s": 3.515056166, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.560414279309129, + "process_peak_rss_mib": 265.234375, + "ready_p95_ms": 2298.290959, + "request_p95_ms": 317.733666, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.9105090000000002, + "elapsed_s": 2.398469709, + "jumps": 1, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.752392605263459, + "process_peak_rss_mib": 266.84375, + "ready_p95_ms": 1461.8873749999998, + "request_p95_ms": 195.160625, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.9210650000000005, + "elapsed_s": 3.456063334, + "jumps": 2, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.604118944077198, + "process_peak_rss_mib": 266.84375, + "ready_p95_ms": 2161.793666, + "request_p95_ms": 275.68733299999997, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.9815429999999985, + "elapsed_s": 2.487353291, + "jumps": 1, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.618303854367908, + "process_peak_rss_mib": 266.84375, + "ready_p95_ms": 1523.683625, + "request_p95_ms": 204.720583, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.8408580000000008, + "elapsed_s": 3.453320667, + "jumps": 2, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.6061871652998163, + "process_peak_rss_mib": 289.78125, + "ready_p95_ms": 2272.8334999999997, + "request_p95_ms": 292.56383400000004, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 1, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.8204370000000003, + "elapsed_s": 2.44045325, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.6878395437404916, + "process_peak_rss_mib": 235.609375, + "ready_p95_ms": 1485.247875, + "request_p95_ms": 204.778041, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.6388779999999996, + "elapsed_s": 2.902215917, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.101078712745548, + "process_peak_rss_mib": 264.078125, + "ready_p95_ms": 1813.520291, + "request_p95_ms": 257.11566600000003, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.8457549999999996, + "elapsed_s": 2.393209333, + "jumps": 1, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.760640523960383, + "process_peak_rss_mib": 265.78125, + "ready_p95_ms": 1467.010375, + "request_p95_ms": 200.079917, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.8910780000000003, + "elapsed_s": 3.486789875, + "jumps": 2, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.5811707394613217, + "process_peak_rss_mib": 269.28125, + "ready_p95_ms": 2139.643791, + "request_p95_ms": 291.051667, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.9026460000000007, + "elapsed_s": 2.453195875, + "jumps": 1, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.6686838143325997, + "process_peak_rss_mib": 270.09375, + "ready_p95_ms": 1474.36325, + "request_p95_ms": 203.48499999999999, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.925334000000001, + "elapsed_s": 3.435524709, + "jumps": 2, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.6196871693057004, + "process_peak_rss_mib": 292.09375, + "ready_p95_ms": 2218.192083, + "request_p95_ms": 292.813292, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 2, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.7839540000000003, + "elapsed_s": 2.422235167, + "jumps": 1, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.715576473586885, + "process_peak_rss_mib": 235.796875, + "ready_p95_ms": 1465.7968329999999, + "request_p95_ms": 205.532875, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.8318820000000002, + "elapsed_s": 3.506092916, + "jumps": 2, + "kind": "local", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.566959922519064, + "process_peak_rss_mib": 265.4375, + "ready_p95_ms": 2235.673, + "request_p95_ms": 305.98141699999996, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.784236, + "elapsed_s": 2.402592875, + "jumps": 1, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.745953005042521, + "process_peak_rss_mib": 267.1875, + "ready_p95_ms": 1472.993792, + "request_p95_ms": 209.70083300000002, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.7738060000000004, + "elapsed_s": 3.4380550420000002, + "jumps": 2, + "kind": "remote", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.617759137085973, + "process_peak_rss_mib": 267.1875, + "ready_p95_ms": 2081.6316659999998, + "request_p95_ms": 274.272334, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 1.983436000000001, + "elapsed_s": 2.485039291, + "jumps": 1, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 3.621673119050897, + "process_peak_rss_mib": 267.1875, + "ready_p95_ms": 1490.626583, + "request_p95_ms": 200.19083300000003, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "original_bridge_new_worker" + }, + { + "cpu_s": 2.8986530000000013, + "elapsed_s": 3.474458833, + "jumps": 2, + "kind": "dynamic", + "payload_bytes_per_direction": 9437184, + "payload_mib_s_per_direction": 2.5903314537847053, + "process_peak_rss_mib": 289.375, + "ready_p95_ms": 2248.098458, + "request_p95_ms": 292.311542, + "small_request_p95_ms": 0, + "streams": 16, + "successful_streams": 16, + "workload": "request_response", + "run": 3, + "implementation": "original_bridge_new_worker" + } + ], + "idle": [ + { + "run": 1, + "cpu_s": 0.11494899999999575, + "elapsed_s": 5.000335375, + "jumps": 2, + "implementation": "event_driven" + }, + { + "run": 2, + "cpu_s": 0.1102509999999981, + "elapsed_s": 5.004823, + "jumps": 2, + "implementation": "event_driven" + }, + { + "run": 3, + "cpu_s": 0.1233789999999999, + "elapsed_s": 5.007869833, + "jumps": 2, + "implementation": "event_driven" + }, + { + "run": 1, + "cpu_s": 0.48540700000000037, + "elapsed_s": 5.001429791, + "jumps": 2, + "implementation": "original_bridge_new_worker" + }, + { + "run": 2, + "cpu_s": 0.5173489999999994, + "elapsed_s": 5.001960333, + "jumps": 2, + "implementation": "original_bridge_new_worker" + }, + { + "run": 3, + "cpu_s": 0.5309120000000007, + "elapsed_s": 5.002045583, + "jumps": 2, + "implementation": "original_bridge_new_worker" + } + ], + "source_sha256": { + "Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnector.swift": "12748f8089b6ecdb7c604fc28faa6f9ebdef3e1a2932ccc17168e61be58019a9", + "Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift": "fc1d533f6860a65387b4b7b4584cda659c12eff138a73c586eaefc27f9275300", + "Frameworks/TermindRemoteInfrastructure/SSH/SSHStreamPump.swift": "9303013739c6279d178a4efba3249f47f7373ac23ec928e5f5f54131330aed39", + "Frameworks/TermindRemoteInfrastructure/SSH/SSHBufferedTransport.swift": "b975bca1a856cac13cbbf2a548e7dd980df28fc3238233cfce325b08ee29f925", + "Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift": "aef62aef5995b1e83dc7f3f23407b3cf1b7bc965c87b888dac5717c780805e69", + "TFMTests/SSHForwardingWireTests.swift": "232a23ca3c8b171cca905b6e3404ffa2973808bffd9603f231095e9b45ebf5bd" + } +} diff --git a/Memory/PORT_FORWARDING_PERFORMANCE.md b/Memory/PORT_FORWARDING_PERFORMANCE.md new file mode 100644 index 00000000..bc259e9e --- /dev/null +++ b/Memory/PORT_FORWARDING_PERFORMANCE.md @@ -0,0 +1,178 @@ +# Port forwarding concurrency and transfer evaluation + +Measured on 2026-09-19, Apple M3 Pro, 36 GiB RAM, macOS 26.6.2, Xcode 27. + +## Implementation + +Local, remote and SOCKS5 forwarding share an event-driven stream pump with jump +bridges. Socket readiness replaces the old repeating data timer. Each SSH session +still has one serial owner: SSH channels run concurrently without concurrently +calling libssh2 on the same session. A ready FIFO, per-flow transfer quantum and +session input budget bound each scheduling pass. Immutable chunks preserve the +write address and length required by libssh2 across EAGAIN. + +Remote connects are nonblocking, DNS uses four resolver workers, and numeric +addresses bypass DNS workers. Half-close drains data, high/low watermarks stop +unbounded application queues, and source cancellation precedes descriptor close. +Listener/channel creation retries EAGAIN; stopping an in-flight remote listener +waits for its request and cancellation before permitting a replacement. Starting +connection counts are retained instead of resetting the UI count to zero. + +The pinned libssh2 implementation clears its pending forwarded-tcpip channel when +retrying a blocked open confirmation (`packet_queue_listener` in `packet.c`). This +caused remote forwarding through jumps to stall even with the old bridge. The new +adapter uses public send/receive callbacks to queue complete encrypted packets and +apply backpressure outside the library. No dependency pins or private ABI changed. + +## Method + +The opt-in tests in `TFMTests/SSHForwardingWireTests.swift` use +`Scripts/ssh-authentication-fixture.py` with Paramiko 4.0.0 and loopback echo targets. +Every received payload and final EOF is checked. Metrics are printed only after all +streams complete successfully. Dedicated client threads avoid exhausting the +Dispatch worker pool used by the implementation. + +- Request/response: 1, 16, 64 streams; nine 64 KiB exchanges per stream. +- Sustained duplex echo: 1, 16, 64 streams; 129 × 64 KiB per stream per direction, + with independent producers and consumers (516 MiB per direction at 64 streams). +- Jumps: each forwarding kind, 16 streams, one and two SSH bridges. +- Mixed: eight duplex bulk streams plus sixteen 64-byte request streams, 0/1/2 jumps. +- Lifecycle: slow consumer, repeated disconnect under backpressure, independent + listeners, in-flight listener cancellation, port reuse, fragmented SOCKS handshake. +- Idle: an established forwarding connection through two jumps, observed for five seconds. + +All throughput values are useful payload **per direction**, including connection +setup in elapsed time. `ready_p95_ms` measures TCP connect, channel/target setup and +first echoed probe; SOCKS negotiation is included. Request latency starts after +that probe. CPU is user+system CPU of the entire test-host process, including test +clients; fixture CPU is excluded. RSS is the process-lifetime high-water mark, +including the app, test threads and earlier cases; it is not per-flow memory. + +Three fresh Release test-host runs were made sequentially without simultaneous +builds or other test suites. Normal desktop background processes were not disabled. +Raw successful measurements are in `PORT_FORWARDING_BENCHMARK.json`. + +## Results + +All table entries are medians of three runs; ranges show the three-run spread. + +| Kind | Streams | MiB/s/direction (range) | Ready p95 ms | CPU ms/total payload MiB | +|---|---:|---:|---:|---:| +| local | 1 | 67.3 (67.1–67.7) | 0.4 | 7.54 | +| local | 16 | 68.5 (68.5–69.5) | 91.8 | 7.48 | +| local | 64 | 66.6 (65.9–67.0) | 447.8 | 7.68 | +| remote | 1 | 69.7 (69.3–70.2) | 0.6 | 7.46 | +| remote | 16 | 69.8 (69.2–70.3) | 431.2 | 7.68 | +| remote | 64 | 67.6 (67.6–68.0) | 1790.0 | 7.74 | +| dynamic | 1 | 67.1 (66.2–67.2) | 0.6 | 7.56 | +| dynamic | 16 | 70.3 (69.9–71.7) | 167.9 | 7.31 | +| dynamic | 64 | 67.7 (65.2–67.8) | 653.7 | 7.59 | + +At 64 streams, aggregate throughput remains within about 5% of the single-stream +rate, with all payloads intact. Concurrency improves service to many connections; +it does not multiply a shared session’s bandwidth. Remote connection readiness +under sustained load is noticeably slower (p95 about 1.79 s at 64 streams). + +| Mixed workload jumps | MiB/s/direction | Ready p95 ms | Small request p95 ms (range) | +|---|---:|---:|---:| +| 0 | 61.0 | 119.4 | 55.9 (55.3–56.8) | +| 1 | 30.3 | 1699.4 | 56.1 (2.4–95.0) | +| 2 | 28.9 | 1457.7 | 21.4 (3.4–122.0) | + +The mixed workload completes every stream, but new-channel admission remains a +latency limitation under bulk traffic, especially through jumps. The large spread +in post-setup latency reflects how much bulk traffic remains when small streams +become ready. This is not evidence of consistently low interactive latency under +saturation. Further optimization should profile admission and protocol queueing +against an OpenSSH server before changing session-sharing/authentication policy. + +Across full runs, process peak RSS was 273.0, 275.3 and 272.6 MiB. The two-jump idle +sample used 0.110–0.123 CPU seconds over five seconds (2.2–2.5% of one core for the +entire app/test process). These figures include the host UI and test client overhead; +they do not establish a transport-only CPU or allocation profile. + +Bridge comparison, 16 request/response streams, median of three runs on each implementation: + +| Kind | Jumps | Original bridge MiB/s | New bridge MiB/s | Ratio | CPU seconds before → after | +|---|---:|---:|---:|---:|---:| +| local | 1 | 3.72 | 38.57 | 10.4× | 1.813 → 0.338 | +| local | 2 | 2.57 | 36.92 | 14.4× | 2.832 → 0.523 | +| remote | 1 | 3.75 | 36.02 | 9.6× | 1.846 → 0.339 | +| remote | 2 | 2.60 | 36.25 | 13.9× | 2.891 → 0.522 | +| dynamic | 1 | 3.62 | 38.63 | 10.7× | 1.982 → 0.335 | +| dynamic | 2 | 2.61 | 37.09 | 14.2× | 2.899 → 0.518 | + +The bridge comparison improves useful throughput by 9.6–14.4× for this workload. +It also reduces CPU time to move the same payload. The original two-jump bridge +idle samples consumed 0.485–0.531 CPU seconds per five seconds, versus 0.110–0.123 +with the new bridge. This comparison includes the app/test host in both cases. + +## Comparison limits + +The unmodified runtime at `5c88a2bf` produced no successful metric records with the +same harness: direct channel opens were treated as failures on EAGAIN, remote +listener creation returned “Would block”, and SOCKS runs could terminate the test +host. A whole-implementation throughput speedup cannot be calculated from failed +transfers. The separate bridge comparison retains the new forwarding worker and +substitutes only the original `SSHRoutedSocket.swift`; it isolates the bridge +implementation, not the complete old application and not the timer alone. + +This is an encrypted loopback/Paramiko benchmark, not a WAN or OpenSSH capacity +claim. All channels share a serialized SSH session and server; increasing connection +count does not promise proportional aggregate bandwidth. Mixed-load readiness and +request latency must be considered together: a low post-setup request percentile +can hide admission delayed by existing bulk traffic. + +Application plaintext buffering is bounded at 2 MiB per direction per flow, with +resume below 1 MiB. Encrypted output is paused at 2 MiB per session, with bounded +overshoot from the current pump/control operations. libssh2 windows, socket buffers, +thread stacks and app memory are additional. These are per-flow/session bounds, +not a fixed total-memory cap for an unlimited number of connections. + +## Reproduction + +Create a temporary virtualenv, install `paramiko==4.0.0`, then run: + +```sh +python Scripts/ssh-authentication-fixture.py --metadata /tmp/ssh-fixture.json +``` + +In a second terminal: + +```sh +TEST_RUNNER_TERMIND_SSH_FIXTURE=/tmp/ssh-fixture.json \ +TEST_RUNNER_TERMIND_FORWARDING_BENCHMARK=1 \ +xcodebuild -project Termind.xcodeproj -scheme TFM \ + -destination 'platform=macOS,arch=arm64' \ + -onlyUsePackageVersionsFromResolvedFile \ + -only-testing:TFMTests/SSHForwardingWireTests test +``` + +That command uses Debug. Release measurements used an isolated worktree with +`-configuration Release ENABLE_TESTABILITY=YES ONLY_ACTIVE_ARCH=YES`. The existing +Release test host needed two harness-only adjustments, applied equally before and +after: exclude FrameworkTests and unrelated Debug-only TFM tests from that host; +link the existing ScrubberKit package product in TFM's Release target. No such +project/dependency changes were made in the submitted tree. Subsequent repetitions +used `test-without-building`, with the same environment variables. Do not benchmark +alongside builds, simulator rendering tests, or other traffic generators. + +## Validation and outstanding suite failures + +- Final macOS targeted run: **56 tests passed**, covering forwarding, deterministic + pump/callback tests, authentication, controller journey and architecture boundaries. +- Shared frameworks: **335 tests passed** on macOS; also **335 passed** in the iOS run. +- Release harness: all three complete performance runs passed, with 27 successful + metric records each, plus lifecycle and idle cases. +- Final iOS Simulator app build passed. Repository-wide strict Swift formatting, + Chinese localization validation and Python syntax checks passed. +- The full macOS suite was attempted but did not finish cleanly: the StoreKit annual + product test hung and was interrupted. Timing-sensitive Agent/forwarding-rendering + failures passed in the focused rerun; three language variants of + `MetricsDetailToolbarTests.legacyCompactSearchOpensTheNativeFieldAndKeepsItsQueryWhenWidened` + still failed. No Metrics UI code was changed. +- Full iOS app suite: 319 tests completed with nine issues in + `MobileSavedNameRenderingTests`, `MobileUploadBatchTests`, `MobileEditorToolbarTests` + and `MobileForwardingRenderingTests`. Its observer remained running after test + output and was interrupted. These failures were not repaired as part of this + transport change; the full repository suite is **not all green**. diff --git a/Scripts/ssh-authentication-fixture.py b/Scripts/ssh-authentication-fixture.py index 5e542169..eddf38be 100644 --- a/Scripts/ssh-authentication-fixture.py +++ b/Scripts/ssh-authentication-fixture.py @@ -16,6 +16,7 @@ import select import signal import socket import threading +import time import paramiko @@ -101,6 +102,7 @@ class Files(paramiko.SFTPServerInterface): class Authentication(paramiko.ServerInterface): def __init__(self, client_key): + self.listeners = {} self.client_key = client_key self.user = "" self.factor = False @@ -119,7 +121,7 @@ class Authentication(paramiko.ServerInterface): def check_auth_none(self, username): self.user = username - return paramiko.AUTH_SUCCESSFUL if username == "none" else paramiko.AUTH_FAILED + return paramiko.AUTH_SUCCESSFUL if username in ("none", "none-slow-forward") else paramiko.AUTH_FAILED def check_auth_password(self, username, password): if password != "fixture-password": @@ -181,27 +183,99 @@ class Authentication(paramiko.ServerInterface): def check_channel_direct_tcpip_request(self, chanid, origin, destination): # Allow routing only back to this fixture, never to other services. - if destination != ("127.0.0.1", self.port): + if destination not in self.allowed_destinations: return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED self.destinations[chanid] = destination return paramiko.OPEN_SUCCEEDED + def check_port_forward_request(self, address, port): + if self.user == "none-slow-forward": + time.sleep(0.1) + if address not in ("127.0.0.1", "::1"): + return False + listener = socket.socket(socket.AF_INET6 if ":" in address else socket.AF_INET) + try: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((address, port)) + listener.listen(128) + listener.settimeout(0.2) + except OSError as error: + print(f"Fixture bind {address}:{port} failed: {error}", flush=True) + listener.close() + return False + bound = listener.getsockname()[1] + self.listeners[(address, bound)] = listener + + def accept(): + while self.transport.is_active(): + try: + peer, origin = listener.accept() + channel = self.transport.open_forwarded_tcpip_channel(origin[:2], (address, bound)) + threading.Thread(target=relay_peer, args=(channel, peer), daemon=True).start() + except socket.timeout: + continue + except (OSError, EOFError, paramiko.SSHException): + break + + threading.Thread(target=accept, daemon=True).start() + return bound + + def cancel_port_forward_request(self, address, port): + listener = self.listeners.pop((address, port), None) + if listener: + listener.close() + + +def relay_peer(channel, peer): + with peer: + try: + readers = [channel, peer] + while readers: + ready, _, _ = select.select(readers, [], [], 1) + for source in ready: + destination = peer if source is channel else channel + data = source.recv(32768) + if data: + destination.sendall(data) + else: + readers.remove(source) + if destination is channel: + channel.shutdown_write() + else: + peer.shutdown(socket.SHUT_WR) + except (OSError, EOFError, paramiko.SSHException): + pass + finally: + try: + channel.close() + except (OSError, EOFError, paramiko.SSHException): + pass + + +def echo_server(listener): + def echo(peer): + with peer: + try: + while data := peer.recv(65536): + peer.sendall(data) + except OSError: + pass + while True: + try: + peer, _ = listener.accept() + threading.Thread(target=echo, args=(peer,), daemon=True).start() + except OSError: + return + def relay(channel, destination): - with socket.create_connection(destination) as peer: - try: - while True: - ready, _, _ = select.select([channel, peer], [], [], 1) - for source in ready: - data = source.recv(32768) - if not data: - return - (peer if source is channel else channel).sendall(data) - finally: - channel.close() + try: + relay_peer(channel, socket.create_connection(destination)) + except OSError: + channel.close() -def serve(sock, host_key, client_key, port): +def serve(sock, host_key, client_key, port, echo_port): transport = paramiko.Transport(sock) transport.banner_timeout = 120 transport.auth_timeout = 120 @@ -209,6 +283,8 @@ def serve(sock, host_key, client_key, port): transport.set_subsystem_handler("sftp", paramiko.SFTPServer, Files) server = Authentication(client_key) server.port = port + server.transport = transport + server.allowed_destinations = {("127.0.0.1", port), ("127.0.0.1", echo_port), ("::1", echo_port)} channels = [] try: transport.start_server(server=server) @@ -222,6 +298,8 @@ def serve(sock, host_key, client_key, port): except (EOFError, OSError, paramiko.SSHException): pass finally: + for listener in server.listeners.values(): + listener.close() transport.close() @@ -236,13 +314,18 @@ def main(): client_key.write_private_key(private) encrypted_private = io.StringIO() client_key.write_private_key(encrypted_private, password="fixture-passphrase") - with socket.socket() as listener: + with socket.socket() as listener, socket.socket() as echo_listener: + echo_listener.bind(("127.0.0.1", 0)) + echo_listener.listen(128) + echo_port = echo_listener.getsockname()[1] + threading.Thread(target=echo_server, args=(echo_listener,), daemon=True).start() listener.bind(("127.0.0.1", 0)) - listener.listen() + listener.listen(128) port = listener.getsockname()[1] fingerprint = base64.b64encode(hashlib.sha256(host_key.asbytes()).digest()).decode().rstrip("=") metadata = { "port": port, + "echoPort": echo_port, "privateKey": private.getvalue(), "encryptedPrivateKey": encrypted_private.getvalue(), "hostFingerprint": "SHA256:" + fingerprint, @@ -255,7 +338,7 @@ def main(): try: while True: sock, _ = listener.accept() - threading.Thread(target=serve, args=(sock, host_key, client_key, port), daemon=True).start() + threading.Thread(target=serve, args=(sock, host_key, client_key, port, echo_port), daemon=True).start() except KeyboardInterrupt: pass finally: diff --git a/TFMTests/PortForwardingJourneyTests.swift b/TFMTests/PortForwardingJourneyTests.swift index 8a51d066..ab551a53 100644 --- a/TFMTests/PortForwardingJourneyTests.swift +++ b/TFMTests/PortForwardingJourneyTests.swift @@ -10,6 +10,14 @@ import struct TermindDomain.Host @Suite @MainActor struct PortForwardingJourneyTests { + @Test func aCountReceivedWhileStartingSurvivesStartCompletion() async throws { + let fixture = Fixture(mode: .countDuringStart) + defer { fixture.controller.stopAll() } + let rule = fixture.rules[0] + fixture.controller.start(rule.id) + try await wait { fixture.controller.state(for: rule.id) == .running(activeConnections: 3) } + } + @Test func windowsShareForwardingAuthenticationAndDeletingItsJumpHostCancelsIt() async throws { let jump = Host(name: "Jump", host: "jump.invalid") let host = Host(name: "Server", host: "server.invalid", jumpHostID: jump.id) @@ -312,7 +320,7 @@ struct PortForwardingJourneyTests { } private actor ForwardingTestSession: SSHForwardingSessionProtocol { - enum Mode: Sendable { case immediate, keyboard, trust, failure, listenerFailure } + enum Mode: Sendable { case immediate, keyboard, trust, failure, listenerFailure, countDuringStart } let mode: Mode let count: SSHForwardingSession.ConnectionHandler let failure: SSHForwardingSession.FailureHandler @@ -356,6 +364,10 @@ private actor ForwardingTestSession: SSHForwardingSessionProtocol { } func start(_ rule: PortForwardingRule) async throws { if mode == .listenerFailure && rule.bindPort == 9000 { throw TermindSSHError.route("Port in use") } + if mode == .countDuringStart { + count(rule.id, 3) + try await Task.sleep(for: .milliseconds(20)) + } starts.append(rule.id) listening.insert(rule.id) } diff --git a/TFMTests/SSHForwardingPumpTests.swift b/TFMTests/SSHForwardingPumpTests.swift new file mode 100644 index 00000000..b8d2d037 --- /dev/null +++ b/TFMTests/SSHForwardingPumpTests.swift @@ -0,0 +1,304 @@ +import CSSH2 +import Darwin +import Foundation +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +struct SSHForwardingPumpTests { + @Test func blockedWriteKeepsItsPointerLengthAndBytesWhileMoreDataArrives() { + let queue = SSHByteQueue() + queue.append(Data(repeating: 0x31, count: 100)) + let original = queue.withFirstBytes { (UInt(bitPattern: $0.baseAddress!), $0.count, Data($0)) } + for _ in 0..<100 { queue.append(Data(repeating: 0x72, count: 32768)) } + for _ in 0..<5 { + queue.withFirstBytes { + #expect(UInt(bitPattern: $0.baseAddress!) == original.0) + #expect($0.count == original.1) + #expect(Data($0) == original.2) + } + } + queue.consume(37) + queue.withFirstBytes { + #expect(UInt(bitPattern: $0.baseAddress!) == original.0 + 37) + #expect(Data($0) == Data(repeating: 0x31, count: 63)) + } + queue.consume(63) + #expect(queue.withFirstBytes { Data($0) } == Data(repeating: 0x72, count: 32768)) + } + + @Test func chunkCompactionPreservesOrderAndReleasesConsumedBytes() { + let queue = SSHByteQueue() + for value in UInt8(0)..<200 { queue.append(contentsOf: [value]) } + for value in UInt8(0)..<200 { + #expect(queue.withFirstBytes { Array($0) } == [value]) + queue.consume(1) + #expect(queue.count == 199 - Int(value)) + } + #expect(queue.isEmpty) + queue.append(contentsOf: [255]) + #expect(queue.withFirstBytes { Array($0) } == [255]) + } + + @Test func readyQueueDeduplicatesAndPutsHotStreamsBehindPeers() { + var ready = SSHReadyQueue() + for value in 0..<100 { + ready.insert(value) + ready.insert(value) + } + for value in 0..<100 { + #expect(ready.pop() == value) + ready.insert(value) + } + for value in 0..<100 { #expect(ready.pop() == value) } + #expect(ready.pop() == nil) + } + + @Test func cancellingSuspendedSourcesClosesOnlyAfterBothHandlersRetire() async throws { + let descriptors = try pair() + defer { Darwin.close(descriptors[1]) } + await withCheckedContinuation { continuation in + let queue = DispatchQueue(label: "forwarding-source-test") + queue.async { + let events = SSHSocketEvents(socket: descriptors[0], queue: queue) { + Issue.record("A cancelled source delivered readiness") + } + events.cancel { + #expect(fcntl(descriptors[0], F_GETFD) >= 0) + Darwin.close(descriptors[0]) + continuation.resume() + } + } + } + } + + @Test func targetConnectionCanBeCancelledDuringDNSWithoutLateDelivery() async { + let queue = DispatchQueue(label: "forwarding-connect-test") + await withCheckedContinuation { continuation in + queue.async { + let connector = SSHForwardingConnector(queue: queue) { result in + if case .success(let socket) = result { Darwin.close(socket) } + Issue.record("Cancelled target delivered a result") + } + connector.start(host: "localhost", port: 1) + connector.cancel() + queue.asyncAfter(deadline: .now() + 0.1) { + _ = connector + continuation.resume() + } + } + } + } + + @Test func repeatedEAGAINRetainsTheOriginalWriteAndPreservesLaterData() throws { + let sockets = try pair() + defer { for socket in sockets { Darwin.close(socket) } } + _ = fcntl(sockets[0], F_SETFL, O_NONBLOCK) + var attempts: [(UInt, Data)] = [] + var delivered = Data() + var io = idleIO() + io.write = { _, bytes in + attempts.append((UInt(bitPattern: bytes.baseAddress!), Data(bytes))) + if attempts.count <= 2 { return Int(LIBSSH2_ERROR_EAGAIN) } + let count = min(7, bytes.count) + delivered.append(contentsOf: bytes.prefix(count)) + return count + } + let handle = OpaquePointer(bitPattern: 1)! + let stream = SSHStreamPump(socket: sockets[0], channel: handle, io: io) + var scratch = [UInt8](repeating: 0, count: 32768) + let first = Data(repeating: 31, count: 20) + let second = Data(repeating: 72, count: 13) + first.withUnsafeBytes { _ = Darwin.send(sockets[1], $0.baseAddress!, $0.count, 0) } + _ = try stream.pump(session: handle, scratch: &scratch) + second.withUnsafeBytes { _ = Darwin.send(sockets[1], $0.baseAddress!, $0.count, 0) } + _ = try stream.pump(session: handle, scratch: &scratch) + #expect(attempts.count == 2) + #expect(attempts[0].0 == attempts[1].0) + #expect(attempts[0].1 == first && attempts[1].1 == first) + _ = try stream.pump(session: handle, scratch: &scratch) + #expect(delivered == first + second) + #expect(stream.toChannel.isEmpty) + } + + @Test func backpressureParksSocketReadsAndResumesAfterDraining() throws { + let sockets = try pair() + defer { for socket in sockets { Darwin.close(socket) } } + for socket in sockets { _ = fcntl(socket, F_SETFL, O_NONBLOCK) } + var blocked = true + var io = idleIO() + io.write = { _, bytes in blocked ? Int(LIBSSH2_ERROR_EAGAIN) : bytes.count } + let handle = OpaquePointer(bitPattern: 1)! + let stream = SSHStreamPump(socket: sockets[0], channel: handle, io: io) + var scratch = [UInt8](repeating: 0, count: 32768) + stream.toChannel.append(Data(repeating: 42, count: SSHStreamPump.highWater)) + _ = try stream.pump(session: handle, scratch: &scratch) + #expect(stream.toChannel.count == SSHStreamPump.highWater) + #expect(!stream.wantsSocketRead) + blocked = false + for _ in 0..<10 { _ = try stream.pump(session: handle, scratch: &scratch) } + #expect(stream.toChannel.isEmpty) + #expect(stream.wantsSocketRead) + } + + @Test func halfCloseWaitsForPendingWritesAndStillDeliversTheResponse() throws { + let sockets = try pair() + defer { for socket in sockets { Darwin.close(socket) } } + _ = fcntl(sockets[0], F_SETFL, O_NONBLOCK) + var blocked = true + var sentEOF = 0 + var sentResponse = false + var io = idleIO() + io.write = { _, bytes in blocked ? Int(LIBSSH2_ERROR_EAGAIN) : bytes.count } + io.sendEOF = { _ in + sentEOF += 1 + return 0 + } + io.read = { _, bytes in + guard !blocked, !sentResponse else { return Int(LIBSSH2_ERROR_EAGAIN) } + bytes[0] = 99 + sentResponse = true + return 1 + } + io.eof = { _ in sentResponse ? 1 : 0 } + let handle = OpaquePointer(bitPattern: 1)! + let stream = SSHStreamPump(socket: sockets[0], channel: handle, io: io) + stream.toChannel.append(contentsOf: [42]) + _ = Darwin.shutdown(sockets[1], SHUT_WR) + var scratch = [UInt8](repeating: 0, count: 32768) + _ = try stream.pump(session: handle, scratch: &scratch) + #expect(sentEOF == 0) + blocked = false + _ = try stream.pump(session: handle, scratch: &scratch) + #expect(sentEOF == 1) + #expect(stream.finished) + var byte: UInt8 = 0 + #expect(Darwin.recv(sockets[1], &byte, 1, 0) == 1) + #expect(byte == 99) + #expect(Darwin.recv(sockets[1], &byte, 1, 0) == 0) + } + + @Test func aWindowUpdateWithoutPayloadSchedulesTheBlockedWriterAgain() throws { + let sockets = try pair() + defer { for socket in sockets { Darwin.close(socket) } } + _ = fcntl(sockets[0], F_SETFL, O_NONBLOCK) + var window: UInt = 0 + var io = idleIO() + io.write = { _, bytes in window == 0 ? Int(LIBSSH2_ERROR_EAGAIN) : bytes.count } + io.writeWindow = { _ in window } + io.read = { _, _ in + window = 32768 + return Int(LIBSSH2_ERROR_EAGAIN) + } + let handle = OpaquePointer(bitPattern: 1)! + let stream = SSHStreamPump(socket: sockets[0], channel: handle, io: io) + stream.toChannel.append(contentsOf: [42]) + var scratch = [UInt8](repeating: 0, count: 32768) + #expect(try stream.pump(session: handle, scratch: &scratch)) + #expect(stream.toChannel.count == 1) + #expect(try stream.pump(session: handle, scratch: &scratch)) + #expect(stream.toChannel.isEmpty) + } + + @Test func encryptedPacketsSurviveSocketBackpressureWithoutLibrarySendRetries() throws { + let sockets = try pair() + defer { for socket in sockets { Darwin.close(socket) } } + for socket in sockets { _ = fcntl(socket, F_SETFL, O_NONBLOCK) } + var capacity: Int32 = 4096 + setsockopt(sockets[0], SOL_SOCKET, SO_SNDBUF, &capacity, socklen_t(MemoryLayout.size)) + let session = try #require(libssh2_session_init_ex(nil, nil, nil, nil)) + let transport = SSHBufferedTransport(session: session) + defer { transport.dispose(session: session) } + let callback = try #require(libssh2_session_callback_set2(session, LIBSSH2_CALLBACK_SEND, nil)) + libssh2_session_callback_set2(session, LIBSSH2_CALLBACK_SEND, callback) + typealias Send = + @convention(c) ( + Int32, UnsafeRawPointer?, Int, Int32, UnsafeMutablePointer? + ) -> Int + let send = unsafeBitCast(callback, to: Send.self) + var expected = Data() + for index in 0..<100 where !transport.isBackpressured { + let packet = Data(repeating: UInt8(index), count: 32768) + expected.append(packet) + let result = packet.withUnsafeBytes { + send(sockets[0], $0.baseAddress!, $0.count, 0, libssh2_session_abstract(session)) + } + #expect(result == packet.count) + } + #expect(transport.isBackpressured) + #expect(expected.count <= SSHStreamPump.highWater + 65536) + var actual = Data() + var scratch = [UInt8](repeating: 0, count: 32768) + for _ in 0..<1000 { + let count = Darwin.recv(sockets[1], &scratch, scratch.count, 0) + if count > 0 { actual.append(contentsOf: scratch.prefix(count)) } + try transport.flush(socket: sockets[0]) + if actual.count == expected.count { break } + } + #expect(actual == expected) + #expect(!transport.wantsWrite) + } + + @Test func cancellingAZeroWindowWriteDoesNotWaitForThePeerToRead() throws { + let sockets = try pair() + defer { for socket in sockets { Darwin.close(socket) } } + _ = fcntl(sockets[0], F_SETFL, O_NONBLOCK) + var io = idleIO() + var writes = 0 + io.write = { _, _ in + writes += 1 + return Int(LIBSSH2_ERROR_EAGAIN) + } + io.writeWindow = { _ in 0 } + let handle = OpaquePointer(bitPattern: 1)! + let stream = SSHStreamPump(socket: sockets[0], channel: handle, io: io) + stream.toChannel.append(Data(repeating: 42, count: 32768)) + var scratch = [UInt8](repeating: 0, count: 32768) + _ = try stream.pump(session: handle, scratch: &scratch) + #expect(stream.writePending) + #expect(stream.finishPendingWrite(session: handle)) + #expect(!stream.writePending) + #expect(writes == 1) + } + + @Test func continuousEncryptedInputYieldsAtTheSessionReadBudget() throws { + let sockets = try pair() + defer { for socket in sockets { Darwin.close(socket) } } + for socket in sockets { _ = fcntl(socket, F_SETFL, O_NONBLOCK) } + let session = try #require(libssh2_session_init_ex(nil, nil, nil, nil)) + let transport = SSHBufferedTransport(session: session) + defer { transport.dispose(session: session) } + let callback = try #require(libssh2_session_callback_set2(session, LIBSSH2_CALLBACK_RECV, nil)) + libssh2_session_callback_set2(session, LIBSSH2_CALLBACK_RECV, callback) + typealias Receive = + @convention(c) ( + Int32, UnsafeMutableRawPointer?, Int, Int32, UnsafeMutablePointer? + ) -> Int + let receive = unsafeBitCast(callback, to: Receive.self) + var packet = [UInt8](repeating: 42, count: 4096) + for _ in 0..<(SSHStreamPump.quantum / packet.count) { + try #require(Darwin.send(sockets[1], &packet, packet.count, 0) == packet.count) + try #require( + receive(sockets[0], &packet, packet.count, 0, libssh2_session_abstract(session)) == packet.count) + } + #expect(transport.exhaustedReadBudget) + try #require(Darwin.send(sockets[1], &packet, packet.count, 0) == packet.count) + #expect(receive(sockets[0], &packet, packet.count, 0, libssh2_session_abstract(session)) == -Int(EAGAIN)) + transport.beginPass() + #expect(receive(sockets[0], &packet, packet.count, 0, libssh2_session_abstract(session)) == packet.count) + } + + private func idleIO() -> SSHChannelIO { + SSHChannelIO( + read: { _, _ in Int(LIBSSH2_ERROR_EAGAIN) }, write: { _, bytes in bytes.count }, + writeWindow: { _ in 32768 }, eof: { _ in 0 }, sendEOF: { _ in 0 }, + directions: { _ in LIBSSH2_SESSION_BLOCK_INBOUND }) + } + + private func pair() throws -> [Int32] { + var sockets: [Int32] = [-1, -1] + guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &sockets) == 0 else { throw POSIXError(.EIO) } + return sockets + } +} diff --git a/TFMTests/SSHForwardingWireTests.swift b/TFMTests/SSHForwardingWireTests.swift new file mode 100644 index 00000000..5e26ad6c --- /dev/null +++ b/TFMTests/SSHForwardingWireTests.swift @@ -0,0 +1,405 @@ +import Darwin +import Foundation +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure +import Testing + +/// Uses only the loopback fixture from Scripts/ssh-authentication-fixture.py. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"] != nil), .serialized) +nonisolated struct SSHForwardingWireTests { + @Test(arguments: [PortForwardingRule.Kind.local, .remote, .dynamic], [1, 16, 64]) + func concurrentStreamsKeepTheirPayloadsAndHalfClose(_ kind: PortForwardingRule.Kind, _ concurrency: Int) + async throws + { + try await exercise(kind: kind, concurrency: concurrency, jumps: 0) + } + + @Test(arguments: [PortForwardingRule.Kind.local, .remote, .dynamic], [1, 2]) + func forwardingCrossesJumpBridges(_ kind: PortForwardingRule.Kind, _ jumps: Int) async throws { + try await exercise(kind: kind, concurrency: 16, jumps: jumps) + } + + @Test func stoppingOneRulePreservesItsPeerAndReleasesItsListener() async throws { + let fixture = try fixture() + let session = SSHForwardingSession( + connectionHandler: { _, _ in }, failureHandler: { Issue.record(Comment(rawValue: $0)) }) + try await session.connect(request(fixture, jumps: 0)) + do { + let first = try rule(.local, fixture: fixture) + let second = try rule(.local, fixture: fixture) + try await session.start(first) + try await session.start(second) + await session.stop(first.id) + try await session.start(first) + try await exchange(port: first.bindPort, target: nil, seed: 1, rounds: 2) + await session.stop(first.id) + try await exchange(port: second.bindPort, target: nil, seed: 2, rounds: 2) + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } + + @Test( + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_FORWARDING_BENCHMARK"] == "1"), + arguments: [PortForwardingRule.Kind.local, .remote, .dynamic], [1, 16, 64]) + func sustainedDuplexTransfer(_ kind: PortForwardingRule.Kind, _ concurrency: Int) async throws { + try await exercise(kind: kind, concurrency: concurrency, jumps: 0, rounds: 128, streaming: true) + } + + @Test( + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_FORWARDING_BENCHMARK"] == "1"), arguments: [0, 1, 2]) + func smallRequestsProgressBesideBulkStreams(_ jumps: Int) async throws { + try await exercise(kind: .local, concurrency: 24, jumps: jumps, rounds: 128, mixed: true) + } + + @Test func slowConsumerPreservesPayloadWhileItsPeerProgresses() async throws { + let fixture = try fixture() + let session = SSHForwardingSession(connectionHandler: { _, _ in }, failureHandler: { _ in }) + try await session.connect(request(fixture, jumps: 2)) + do { + let rule = try rule(.local, fixture: fixture) + try await session.start(rule) + async let slow = exchange( + port: rule.bindPort, target: nil, seed: 41, rounds: 256, streaming: true, delayedRead: true) + async let small = exchange(port: rule.bindPort, target: nil, seed: 42, rounds: 128, payloadSize: 64) + let (bulk, peer) = try await (slow, small) + try #require(bulk.bytes == 257 * 65536) + try #require(peer.bytes == 129 * 64) + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } + + @Test func disconnectUnderBackpressureReleasesTheListenerAcrossRepeatedStarts() async throws { + let fixture = try fixture() + let session = SSHForwardingSession(connectionHandler: { _, _ in }, failureHandler: { _ in }) + let rule = try rule(.local, fixture: fixture) + for _ in 0..<5 { + try await session.connect(request(fixture, jumps: 1)) + try await session.start(rule) + let socket = try SSHSocketConnector.open(host: "127.0.0.1", port: rule.bindPort, timeout: 5) + let writer = Task { + await withCheckedContinuation { continuation in + Thread.detachNewThread { + let payload = Data(repeating: 42, count: 65536) + for _ in 0..<1024 { + do { try send(socket, payload) } catch { break } + } + continuation.resume() + } + } + } + try await Task.sleep(for: .milliseconds(200)) + let began = ContinuousClock.now + await session.disconnect() + try #require(began.duration(to: .now) < .seconds(2)) + _ = Darwin.shutdown(socket, SHUT_RDWR) + await writer.value + Darwin.close(socket) + } + try await session.connect(request(fixture, jumps: 0)) + try await session.start(rule) + try await exchange(port: rule.bindPort, target: nil, seed: 1, rounds: 2) + await session.disconnect() + } + + @Test func stoppingAnInFlightRemoteListenDrainsItBeforeRebinding() async throws { + let fixture = try fixture() + let session = SSHForwardingSession(connectionHandler: { _, _ in }, failureHandler: { _ in }) + try await session.connect(request(fixture, jumps: 0, username: "none-slow-forward")) + do { + let rule = try rule(.remote, fixture: fixture) + let starting = Task { try await session.start(rule) } + try await Task.sleep(for: .milliseconds(30)) + await session.stop(rule.id) + do { + try await starting.value + Issue.record("Slow listener should have been cancelled while starting") + } catch is CancellationError {} + try await session.start(rule) + try await exchange(port: rule.bindPort, target: nil, seed: 1, rounds: 2) + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } + + @Test func multipleRemoteListenersCanStopAndRestartIndependently() async throws { + let fixture = try fixture() + let session = SSHForwardingSession(connectionHandler: { _, _ in }, failureHandler: { _ in }) + try await session.connect(request(fixture, jumps: 1)) + do { + let first = try rule(.remote, fixture: fixture) + let second = try rule(.remote, fixture: fixture) + async let startFirst: Void = session.start(first) + async let startSecond: Void = session.start(second) + _ = try await (startFirst, startSecond) + try await exchange(port: first.bindPort, target: nil, seed: 1, rounds: 2) + await session.stop(first.id) + try await exchange(port: second.bindPort, target: nil, seed: 2, rounds: 2) + try await session.start(first) + try await exchange(port: first.bindPort, target: nil, seed: 3, rounds: 2) + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_FORWARDING_BENCHMARK"] == "1")) + func idleForwardingCPU() async throws { + let fixture = try fixture() + let session = SSHForwardingSession(connectionHandler: { _, _ in }, failureHandler: { _ in }) + try await session.connect(request(fixture, jumps: 2)) + let rule = try rule(.local, fixture: fixture) + try await session.start(rule) + let socket = try SSHSocketConnector.open(host: "127.0.0.1", port: rule.bindPort, timeout: 5) + try send(socket, Data([42])) + try #require(try receive(socket, count: 1) == Data([42])) + try await Task.sleep(for: .milliseconds(300)) + let before = usage() + let began = ContinuousClock.now + try await Task.sleep(for: .seconds(5)) + let elapsed = seconds(began.duration(to: .now)) + let after = usage() + print("FORWARDING_IDLE cpu_s=\(after.cpu - before.cpu) elapsed_s=\(elapsed) jumps=2") + Darwin.close(socket) + await session.disconnect() + } + + private func exercise( + kind: PortForwardingRule.Kind, concurrency: Int, jumps: Int, + rounds: Int = 8, streaming: Bool = false, mixed: Bool = false + ) async throws { + let fixture = try fixture() + let session = SSHForwardingSession( + connectionHandler: { _, _ in }, failureHandler: { Issue.record(Comment(rawValue: $0)) }) + try await session.connect(request(fixture, jumps: jumps)) + do { + let rule = try rule(kind, fixture: fixture) + try await session.start(rule) + let start = ContinuousClock.now + let usageBefore = usage() + let results = try await withThrowingTaskGroup(of: Measurement.self) { group in + for index in 0..= 8 ? 64 : 65536, + streaming: streaming || (mixed && index < 8)) + } + } + var results: [Measurement] = [] + for try await result in group { results.append(result) } + return results + } + let elapsed = seconds(start.duration(to: .now)) + let usageAfter = usage() + let payloadBytes = results.reduce(0) { $0 + $1.bytes } + let record: [String: Any] = [ + "kind": kind.rawValue, "streams": concurrency, "jumps": jumps, + "workload": mixed ? "mixed" : streaming ? "duplex" : "request_response", + "successful_streams": results.count, "payload_bytes_per_direction": payloadBytes, + "elapsed_s": elapsed, "payload_mib_s_per_direction": Double(payloadBytes) / 1_048_576 / elapsed, + "ready_p95_ms": percentile(results.map(\.connect)) * 1000, + "request_p95_ms": percentile(results.filter { !$0.streaming }.flatMap(\.latencies)) * 1000, + "small_request_p95_ms": percentile(results.filter { $0.small }.flatMap(\.latencies)) * 1000, + "cpu_s": usageAfter.cpu - usageBefore.cpu, + "process_peak_rss_mib": Double(usageAfter.rss) / 1_048_576, + ] + print( + "FORWARDING_METRIC " + + String( + decoding: try JSONSerialization.data( + withJSONObject: record, options: [.sortedKeys]), as: UTF8.self)) + await session.stop(rule.id) + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } + + private struct Fixture: Decodable { + let port: Int + let echoPort: Int + } + + private func fixture() throws -> Fixture { + let path = try #require(ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"]) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + } + + private func request(_ fixture: Fixture, jumps: Int, username: String = "none") -> SSHConnectionRequest { + var configuration = SSHConnectionConfiguration( + hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: username, credentials: [], + expectedHostKeys: []) + configuration.jumps = (0.. PortForwardingRule { + let descriptor = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + defer { Darwin.close(descriptor) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { throw POSIXError(.EIO) } + var length = socklen_t(MemoryLayout.size) + let bound = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &length) } + } + guard bound == 0 else { throw POSIXError(.EIO) } + return PortForwardingRule( + hostID: UUID(), kind: kind, bindHost: "127.0.0.1", bindPort: Int(UInt16(bigEndian: address.sin_port)), + targetHost: "127.0.0.1", targetPort: fixture.echoPort) + } + + private struct Measurement: Sendable { + let connect: Double + let latencies: [Double] + let bytes: Int + let streaming: Bool + let small: Bool + } + + private func seconds(_ duration: Duration) -> Double { + Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + } + + private func percentile(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + return sorted[min(sorted.count - 1, Int(ceil(Double(sorted.count) * 0.95)) - 1)] + } + + private func usage() -> (cpu: Double, rss: Int) { + var value = rusage() + getrusage(RUSAGE_SELF, &value) + let cpu = + Double(value.ru_utime.tv_sec + value.ru_stime.tv_sec) + + Double(value.ru_utime.tv_usec + value.ru_stime.tv_usec) / 1e6 + return (cpu, value.ru_maxrss) + } + + @discardableResult private func exchange( + port: Int, target: Int?, seed: UInt8, rounds: Int, payloadSize: Int = 65536, streaming: Bool = false, + delayedRead: Bool = false + ) async throws -> Measurement { + // Dedicated test-client threads cannot exhaust the dispatch worker pool + // needed by the transport and its bounded DNS resolver. + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Thread.detachNewThread { + do { + let began = ContinuousClock.now + let socket = try SSHSocketConnector.open(host: "127.0.0.1", port: port, timeout: 15) + defer { Darwin.close(socket) } + if let target { + // A fragmented greeting followed by CONNECT + payload in + // one write exercises both parser boundaries. + try send(socket, Data([5])) + try send(socket, Data([1, 0])) + try #require(try receive(socket, count: 2) == Data([5, 0])) + let request = Data([5, 1, 0, 1, 127, 0, 0, 1, UInt8(target >> 8), UInt8(target & 255)]) + try send(socket, request + Data([seed])) + try #require(try receive(socket, count: 10) == Data([5, 0, 0, 1, 0, 0, 0, 0, 0, 0])) + try #require(try receive(socket, count: 1) == Data([seed])) + } + // Include channel negotiation and target connect for every + // forwarding kind, rather than just the local TCP accept. + try send(socket, Data([seed])) + try #require(try receive(socket, count: 1) == Data([seed])) + let connected = seconds(began.duration(to: .now)) + let payload = Data((0.. 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + offset += count + } + } + } + + private func receive(_ socket: Int32, count: Int) throws -> Data { + var data = Data(count: count) + try data.withUnsafeMutableBytes { bytes in + var offset = 0 + while offset < count { + let read = Darwin.recv(socket, bytes.baseAddress! + offset, count - offset, 0) + guard read > 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + offset += read + } + } + return data + } +}