// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import Network import TermindApplication import TermindDomain public actor ResumableTerminalClient: ResumableTerminalSession { public typealias RouteFactory = @Sendable (SSHRouteProgressHandler?) async throws -> SSHRoutedSocket public typealias StateHandler = @Sendable (ResumableTerminalState) -> Void private let transport: HostTerminalProtocol private let registry: ResumableTerminalEngineRegistry private let output: SSHPTYChannel.OutputHandler private let stateChanged: StateHandler private let routeChanged: @Sendable (TerminalRouteSnapshot) -> Void private var routeRevision: UInt64? private let integrationChanged: SSHPTYChannel.ShellIntegrationHandler private let lifecycleRequestEnqueued: (@Sendable () -> Void)? private var engine: (any ResumableTerminalEngine)? private var maintenance: Task? private var pathMonitor: NWPathMonitor? private var hasObservedNetworkPath = false private var closed = false private var protocolSuspended = false private var startupNeedsCompletion = false private var recoveryNeedsCompletion = false private var suspendedCheckpoint: TerminalTransportCheckpoint? private var lifecycleOperations: [LifecycleOperation] = [] private var lifecycleTask: Task? private var engineState: ResumableTerminalState = .connecting private var state: ResumableTerminalState = .connecting private var integration: RemoteShellIntegrationBootstrap? private var commandFilter: RemoteCommandEventFilter? private var integratedShell: ResumableTerminalShellState.IntegratedShell? private var pendingCheckpoint: TerminalTransportCheckpoint? private var pendingIntegrationCommand: Data? private var sendsInFlight = 0 private var sendWaiters: [CheckedContinuation] = [] private var viewport: (columns: UInt16, rows: UInt16) = (80, 24) private var lifecycleFrozen: Bool { !lifecycleOperations.isEmpty || protocolSuspended || recoveryNeedsCompletion } private var frozen: Bool { startupNeedsCompletion || lifecycleFrozen } private enum LifecycleAction { case suspend, resume, retry } private struct LifecycleRequest { let id: UUID let continuation: CheckedContinuation } private struct LifecycleOperation { let id = UUID() let action: LifecycleAction var requests: [LifecycleRequest] } public init( transport: HostTerminalProtocol, checkpoint: TerminalTransportCheckpoint? = nil, output: @escaping SSHPTYChannel.OutputHandler, stateChanged: @escaping StateHandler, shellIntegration: @escaping SSHPTYChannel.ShellIntegrationHandler, registry: ResumableTerminalEngineRegistry = .live, routeChanged: @escaping @Sendable (TerminalRouteSnapshot) -> Void = { _ in } ) { self.init( transport: transport, checkpoint: checkpoint, output: output, stateChanged: stateChanged, shellIntegration: shellIntegration, registry: registry, pathMonitor: NWPathMonitor(), routeChanged: routeChanged) } init( transport: HostTerminalProtocol, checkpoint: TerminalTransportCheckpoint? = nil, output: @escaping SSHPTYChannel.OutputHandler, stateChanged: @escaping StateHandler, shellIntegration: @escaping SSHPTYChannel.ShellIntegrationHandler, registry: ResumableTerminalEngineRegistry, pathMonitor: NWPathMonitor?, lifecycleRequestEnqueued: (@Sendable () -> Void)? = nil, routeChanged: @escaping @Sendable (TerminalRouteSnapshot) -> Void = { _ in } ) { self.transport = transport self.registry = registry self.pendingCheckpoint = checkpoint self.output = output self.stateChanged = stateChanged self.routeChanged = routeChanged self.integrationChanged = shellIntegration self.pathMonitor = pathMonitor self.lifecycleRequestEnqueued = lifecycleRequestEnqueued } public func connect( request: SSHConnectionRequest, options: HostTerminalTransports, environment: [HostEnvironmentVariable], shellIntegrationEnabled: Bool, columns: UInt16, rows: UInt16, route: @escaping RouteFactory, progress: SSHRouteProgressHandler?, startupPreparation: SSHPTYChannel.StartupPreparation? = nil ) async throws { guard !closed, options.isValid, transport.isResumable else { throw CancellationError() } guard engine == nil else { throw TerminalTransportError.sessionFailed } viewport = (columns, rows) guard options.available(isTailscaleManaged: request.configuration.network != nil).contains(transport) else { throw TerminalTransportError.unsupportedRoute } guard environment.allSatisfy(\.isValid) else { throw TerminalTransportError.bootstrapFailed } if let pendingCheckpoint, pendingCheckpoint.version != 1 || pendingCheckpoint.transport != transport { throw TerminalTransportError.invalidCheckpoint } // Reject an unregistered engine before wake, route, bootstrap or startup side effects. let engine = try registry.make(transport) { [weak self] event in await self?.receiveEngineEvent(event) } self.engine = engine startupNeedsCompletion = true sendsInFlight += 1 defer { finishSend() } let bootstrap = SSHConnection() do { try checkOpen() try request.configuration.accessCheck?() await request.configuration.beforeConnect?() try checkOpen() publish(.connecting) if pendingCheckpoint == nil { try await bootstrap.connect(using: request, progress: progress, connectionFailure: nil) try checkOpen() } let startup = try await ResumableTerminalStartup.prepare( restoring: pendingCheckpoint != nil, preparation: startupPreparation, execute: { command in try await TerminalTransportRoute.execute(command, on: bootstrap) }) try checkOpen() let preparation = try await engine.prepare( .init( request: request, options: options, environment: environment, shellIntegrationEnabled: shellIntegrationEnabled, checkpoint: pendingCheckpoint?.payload, startup: startup, bootstrap: bootstrap, route: route)) try checkOpen() integration = preparation.shell.integration commandFilter = preparation.shell.commandFilter integratedShell = preparation.shell.integratedShell pendingIntegrationCommand = preparation.shell.pendingIntegrationCommand if pendingCheckpoint != nil { integration?.resetDeadlinesAfterRestore() if let shell = integratedShell { integrationChanged( .available(shell: shell.shell, systemInfo: shell.systemInfo, homeDirectory: shell.homeDirectory) ) } } var appliedViewport = viewport try await engine.start(columns: appliedViewport.columns, rows: appliedViewport.rows) var startupInput = preparation.startupInput var integrationStartCommand = pendingCheckpoint == nil ? integration?.startCommand : nil var bootstrapDisconnected = false // Keep viewport delivery and connected publication ordered across every await. // No concurrent resize may complete before a stale start overwrites it. while true { try checkOpen() if appliedViewport.columns != viewport.columns || appliedViewport.rows != viewport.rows { let latest = viewport try await engine.resize(columns: latest.columns, rows: latest.rows) appliedViewport = latest } else if let input = startupInput { startupInput = nil try await engine.write(input) } else if let command = integrationStartCommand { integrationStartCommand = nil try await engine.write(command) } else if let command = pendingIntegrationCommand { pendingIntegrationCommand = nil try await engine.write(command) } else if !bootstrapDisconnected { await bootstrap.disconnect() bootstrapDisconnected = true } else { pendingCheckpoint = nil startupNeedsCompletion = false if !frozen { publish(engineState) } break } } startMaintenance() startNetworkMonitoring() } catch { if state == .ended { await bootstrap.disconnect() return } await engine.rollback(using: bootstrap) await bootstrap.disconnect() await close() throw error } } public func write(_ data: Data) async throws { try checkOpen() // A starting surface can already answer terminal protocol queries. The host // gates interactive input on connected state; startup must not drop these replies. guard !lifecycleFrozen else { throw TerminalTransportError.suspended } integration?.noteUserInput() sendsInFlight += 1 defer { finishSend() } try await engine?.write(data) } public func resize(columns: UInt16, rows: UInt16) async throws { try checkOpen() viewport = (columns, rows) guard !frozen else { return } sendsInFlight += 1 defer { finishSend() } try await engine?.resize(columns: columns, rows: rows) } private var shellState: ResumableTerminalShellState { .init( integration: integration, commandFilter: commandFilter, integratedShell: integratedShell, pendingIntegrationCommand: pendingIntegrationCommand) } public func suspend() async throws -> TerminalTransportCheckpoint { guard let checkpoint = try await requestLifecycle(.suspend) else { throw TerminalTransportError.sessionFailed } return checkpoint } public func resume() async throws { _ = try await requestLifecycle(.resume) } public func retryNow() async throws { _ = try await requestLifecycle(.retry) } private func requestLifecycle(_ action: LifecycleAction) async throws -> TerminalTransportCheckpoint? { try checkOpen() guard engine != nil else { throw TerminalTransportError.sessionFailed } let id = UUID() let result: TerminalTransportCheckpoint? = try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in guard !closed, !Task.isCancelled else { continuation.resume(throwing: CancellationError()) return } let request = LifecycleRequest(id: id, continuation: continuation) // Coalesce only adjacent matching requests; a checkpoint remains an ordering barrier. if let last = lifecycleOperations.indices.last, lifecycleOperations[last].action == action { lifecycleOperations[last].requests.append(request) } else { lifecycleOperations.append(.init(action: action, requests: [request])) } lifecycleRequestEnqueued?() startNextLifecycleOperation() } } onCancel: { Task { await self.cancelLifecycleRequest(id) } } try Task.checkCancellation() return result } private func cancelLifecycleRequest(_ id: UUID) { guard let index = lifecycleOperations.firstIndex(where: { $0.requests.contains { $0.id == id } }), let requestIndex = lifecycleOperations[index].requests.firstIndex(where: { $0.id == id }) else { return } let request = lifecycleOperations[index].requests.remove(at: requestIndex) request.continuation.resume(throwing: CancellationError()) guard lifecycleOperations[index].requests.isEmpty else { return } // Admitted work belongs to the session: finish its barrier/flush even if every caller cancels. if index != 0 { lifecycleOperations.remove(at: index) } } private func startNextLifecycleOperation() { guard !closed, lifecycleTask == nil, let operation = lifecycleOperations.first else { return } lifecycleTask = Task { await self.runLifecycleOperation(operation) } } private func runLifecycleOperation(_ operation: LifecycleOperation) async { let result: Result do { try checkOpen() if sendsInFlight > 0 { await withCheckedContinuation { sendWaiters.append($0) } } try checkOpen() guard let engine else { throw CancellationError() } result = .success(try await performLifecycle(operation.action, engine: engine)) try checkOpen() } catch { finishLifecycleOperation(operation.id, result: .failure(error)) return } finishLifecycleOperation(operation.id, result: result) } private func finishLifecycleOperation( _ id: UUID, result: Result ) { guard lifecycleOperations.first?.id == id else { return } let operation = lifecycleOperations.removeFirst() lifecycleTask = nil if !frozen { publish(engineState) } for request in operation.requests { request.continuation.resume(with: result) } startNextLifecycleOperation() } private func performLifecycle( _ action: LifecycleAction, engine: any ResumableTerminalEngine ) async throws -> TerminalTransportCheckpoint? { if action == .suspend { if let suspendedCheckpoint { return suspendedCheckpoint } protocolSuspended = true // Capture shell state after protocol preparation, while every send path remains frozen. let payload = try await engine.checkpoint { await self.shellState } try checkOpen() let checkpoint = TerminalTransportCheckpoint(transport: transport, payload: payload) suspendedCheckpoint = checkpoint engineState = .suspended publish(.suspended) return checkpoint } recoveryNeedsCompletion = true if action == .retry || protocolSuspended { engineState = .recovering } if action == .retry { await engine.prepareRetry() try checkOpen() } if action == .resume || protocolSuspended { try await engine.beforeResume() try checkOpen() suspendedCheckpoint = nil publish(.recovering) try await engine.resume() } else { suspendedCheckpoint = nil publish(.recovering) try await engine.retry() } protocolSuspended = false try checkOpen() integration?.resetDeadlinesAfterRestore() var appliedViewport: (columns: UInt16, rows: UInt16)? // A resize or integration reply can arrive at each await; flush the newest state before admission reopens. while true { if appliedViewport?.columns != viewport.columns || appliedViewport?.rows != viewport.rows { let latest = viewport try await engine.resize(columns: latest.columns, rows: latest.rows) try checkOpen() appliedViewport = latest } else if let command = pendingIntegrationCommand { pendingIntegrationCommand = nil do { try await engine.write(command) } catch { if pendingIntegrationCommand == nil { pendingIntegrationCommand = command } throw error } try checkOpen() } else { recoveryNeedsCompletion = false return nil } } } public func close() async { guard !closed else { return } closed = true lifecycleTask?.cancel() lifecycleTask = nil let operations = lifecycleOperations lifecycleOperations.removeAll() for operation in operations { for request in operation.requests { request.continuation.resume(throwing: CancellationError()) } } resumeSendWaiters() maintenance?.cancel() pathMonitor?.cancel() pathMonitor = nil await engine?.close() engine = nil pendingCheckpoint = nil suspendedCheckpoint = nil if state != .ended { publish(.closed) } } func receiveEngineEvent(_ event: ResumableTerminalEngineEvent) async { guard !closed else { return } switch event { case .output(let data): await receive(data) case .routeChanged(let snapshot): guard routeRevision.map({ snapshot.revision > $0 }) ?? true else { return } routeRevision = snapshot.revision routeChanged(snapshot) case .activity, .connected: engineState = .connected if !frozen { publish(.connected) } case .recovering: engineState = .recovering if !frozen { publish(.recovering) } case .failed: engineState = .failed publish(.failed) case .ended: await finishRemoteExit() } } private func checkOpen() throws { try Task.checkCancellation() guard !closed else { throw CancellationError() } } private func finishSend() { sendsInFlight -= 1 if sendsInFlight == 0 { resumeSendWaiters() } } private func resumeSendWaiters() { let waiters = sendWaiters sendWaiters.removeAll() for waiter in waiters { waiter.resume() } } private func publish(_ next: ResumableTerminalState) { guard !closed || next == .closed, state != .ended, state != next else { return } state = next stateChanged(next) } private func finishRemoteExit() async { guard !closed, state != .ended else { return } publish(.ended) await close() } private func receive(_ data: Data) async { guard !closed else { return } if var integration { let update = integration.receive(data) self.integration = update.isComplete ? nil : integration await apply(update) } else { deliverIntegratedOutput(data) } } private func apply(_ update: RemoteShellIntegrationBootstrap.Update) async { if let command = update.command { if frozen { pendingIntegrationCommand = command } else { sendsInFlight += 1 do { try await engine?.write(command) } catch { if !closed { publish(.failed) } } finishSend() } } guard !closed else { return } if update.integrationInstalled { let shell = ResumableTerminalShellState.IntegratedShell( shell: update.shell ?? "unknown", systemInfo: update.systemInfo, homeDirectory: update.homeDirectory) integratedShell = shell integrationChanged( .available(shell: shell.shell, systemInfo: shell.systemInfo, homeDirectory: shell.homeDirectory)) } if update.homeResolutionUnavailable { integrationChanged(.homeResolutionUnavailable) } deliverIntegratedOutput(update.visibleOutput ?? Data()) } private func deliverIntegratedOutput(_ data: Data) { guard !closed else { return } var visible = data if var commandFilter { let update = commandFilter.receive(visible) self.commandFilter = commandFilter visible = update.visibleOutput for event in update.events { switch event { case .commandStarted(let command): integrationChanged(.commandStarted(command)) case .commandFinished(let code, let duration): integrationChanged(.commandFinished(code, duration: duration)) case .promptReady: integrationChanged(.promptReady) case .draft(let draft): integrationChanged(.draft(draft)) } } } if !visible.isEmpty { output(visible) } } private func startMaintenance() { maintenance = Task { [weak self] in while !Task.isCancelled { do { try await Task.sleep(for: .milliseconds(100)) } catch { return } await self?.pollIntegration() } } } private func pollIntegration() async { guard !closed, !frozen, var integration else { return } let update = integration.poll() self.integration = update.isComplete ? nil : integration await apply(update) } private func startNetworkMonitoring() { guard let monitor = pathMonitor else { return } monitor.pathUpdateHandler = { [weak self] path in let available = path.status == .satisfied Task { await self?.networkPathChanged(available: available) } } monitor.start(queue: DispatchQueue(label: "com.akinokaede.termind.terminal-network")) } func networkPathChanged(available: Bool) async { let initial = !hasObservedNetworkPath hasObservedNetworkPath = true guard !closed, !frozen, state == .connected || state == .recovering else { return } if !available { engineState = .recovering publish(.recovering) } // Never bootstrap another shell: both engines retain their own protocol state. if available, !initial { sendsInFlight += 1 defer { finishSend() } await engine?.networkAvailable() } } }