fix(terminal): unify resumable recovery in connection journey

This commit is contained in:
Kaede Akino committed 2026-10-17 01:34:50 +08:00
1 parent b139fbc841
commit 1398ff94e1
20 files changed
+853 -85

No files matched your search

@@ -7,6 +7,24 @@ import Testing
@Suite
struct ResumableTerminalEngineTests {
@Test func recoveryRetryUsesTheExistingEngineWithoutReplayingStartup() async throws {
let events = EngineEvents()
let engine = TestEngine(events: events)
let client = client(engine, events: events)
try await connect(client)
await events.emit(.recovering)
try await client.retryNow()
#expect(Array(events.values.suffix(2)) == ["prepareRetry", "retry"])
#expect(events.values.filter { $0 == "prepare" }.count == 1)
#expect(events.values.filter { $0.hasPrefix("start:") }.count == 1)
#expect(!events.values.contains(where: { $0.hasPrefix("write:") }))
await client.close()
let closedEvents = events.values
await events.emit(.recovering)
await events.emit(.connected)
#expect(events.values == closedEvents)
}
@Test func missingRegistrationFailsBeforeWakeOrBootstrapAndClearsFacadeSecrets() async throws {
let events = EngineEvents()
let channel = RemoteConnectionCoordinator(transportEngines: .init(factories: [:])).makePTYChannel(
@@ -0,0 +1,91 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct TransportRecoveryJourneyTests {
@Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession])
func recoveryRetainsRouteAndLogsWithoutReplayingAuthentication(_ purpose: ConnectionPurpose) {
let (journey, host) = fixture(purpose)
journey.waitForAuthentication(at: host.id)
journey.resume()
journey.succeed()
journey.hide()
let endpoints = journey.endpoints
let logs = journey.logs
journey.showsLogs = true
journey.updateTransportRecovery(.recovering, message: "Recovering")
let recoveryLogs = journey.logs
journey.updateTransportRecovery(.recovering, message: "Recovering")
#expect(journey.isPresented)
#expect(journey.mode == .reconnection)
#expect(journey.purpose == purpose)
#expect(journey.endpoints == endpoints)
#expect(journey.logs == recoveryLogs)
#expect(Array(journey.logs.prefix(logs.count)) == logs)
#expect(journey.logs.last?.message == "Recovering")
#expect(journey.showsLogs)
#expect(journey.checkpoint == nil)
#expect(journey.completedCheckpoints.isEmpty)
#expect(journey.activeEndpointIndex == endpoints.count - 1)
}
@Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession])
func suspensionRecoveryAndFailureHaveDistinctPresentation(_ purpose: ConnectionPurpose) {
let (journey, _) = fixture(purpose)
journey.updateTransportRecovery(.suspended, message: "Paused")
#expect(journey.transportRecovery == .suspended)
journey.updateTransportRecovery(.recovering, message: "Recovering")
#expect(journey.transportRecovery == .recovering)
journey.updateTransportRecovery(.failed, message: "Original transport error")
#expect(journey.phase == .failed("Original transport error"))
#expect(journey.transportRecovery == .failed)
let logs = journey.logs
journey.updateTransportRecovery(.failed, message: "Original transport error")
#expect(journey.logs == logs)
journey.hide()
#expect(journey.transportRecovery == nil)
#expect(!journey.isPresented)
}
@Test(arguments: [ConnectionPurpose.moshSession, .eternalTerminalSession])
func renewedRecoveryCancelsThePreviousSuccessTimer(_ purpose: ConnectionPurpose) async throws {
let (journey, _) = fixture(purpose)
journey.updateTransportRecovery(.recovering, message: "Recovering")
journey.succeed()
#expect(journey.phase == .succeeded)
#expect(journey.transportRecovery == nil)
journey.updateTransportRecovery(.recovering, message: "Lost again")
try await Task.sleep(for: .milliseconds(750))
#expect(journey.phase == .connecting)
#expect(journey.transportRecovery == .recovering)
journey.succeed()
try await Task.sleep(for: .milliseconds(750))
#expect(!journey.isPresented)
}
@Test func ordinaryConnectionFailuresAndSSHReconnectKeepTheirExistingPresentation() {
let (journey, host) = fixture(.moshSession)
journey.updateTransportRecovery(.failed, message: "Unavailable")
journey.begin(host: host, route: [host], purpose: .moshSession, username: "user")
journey.fail("Bootstrap failed")
#expect(journey.transportRecovery == nil)
#expect(journey.mode == .connection)
journey.begin(host: host, route: [host], purpose: .sshSession, username: "user", mode: .reconnection)
journey.updateTransportRecovery(.recovering, message: "Must not apply")
#expect(journey.transportRecovery == nil)
#expect(!journey.plainTextLog.contains("Must not apply"))
}
private func fixture(_ purpose: ConnectionPurpose) -> (ConnectionJourney, Host) {
let host = Host(name: "Server", host: "example.invalid")
let journey = ConnectionJourney()
journey.begin(host: host, route: [host], purpose: purpose, username: "user")
return (journey, host)
}
}
@@ -57781,6 +57781,53 @@
}
}
},
"Restoring the existing terminal session" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bestehende Terminalsitzung wird wiederhergestellt"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Restoring the existing terminal session"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Restauration de la session de terminal existante"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "既存のターミナルセッションを復元中"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "기존 터미널 세션 복원 중"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在恢复原终端会话"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在復原原終端階段"
}
}
}
},
"Result" : {
"extractionState" : "manual",
"localizations" : {
@@ -75173,6 +75220,53 @@
}
}
},
"The terminal session is paused. It will resume when the app returns to the foreground." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Die Terminalsitzung ist pausiert. Sie wird fortgesetzt, wenn die App wieder im Vordergrund ist."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The terminal session is paused. It will resume when the app returns to the foreground."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "La session de terminal est en pause. Elle reprendra lorsque l’app reviendra au premier plan."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ターミナルセッションは一時停止中です。アプリがフォアグラウンドに戻ると再開します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "터미널 세션이 일시 정지되었습니다. 앱이 포그라운드로 돌아오면 재개됩니다."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "终端会话已暂停。应用回到前台时将恢复。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "終端階段已暫停。應用程式回到前景時將會復原。"
}
}
}
},
"The terminal session is suspended." : {
"extractionState" : "manual",
"localizations" : {
@@ -64,6 +64,12 @@ public final class ConnectionJourney {
case failed(String)
}
public enum TransportRecovery: Equatable {
case recovering
case suspended
case failed
}
public enum CheckpointKind: Equatable {
case authentication
case hostKey
@@ -92,6 +98,7 @@ public final class ConnectionJourney {
public private(set) var mode: Mode = .connection
public private(set) var endpoints: [ConnectionEndpoint] = []
public private(set) var phase: Phase = .hidden
public private(set) var transportRecovery: TransportRecovery?
public private(set) var activeEndpointIndex = 0
public private(set) var completedEndpointIndex = 0
public private(set) var checkpoint: Checkpoint?
@@ -122,6 +129,7 @@ public final class ConnectionJourney {
username: String?, mode: Mode = .connection
) {
completionTask?.cancel()
transportRecovery = nil
let nextEndpoints =
[
ConnectionEndpoint(
@@ -169,6 +177,7 @@ public final class ConnectionJourney {
public func beginSerial(name: String, address: String, mode: Mode = .connection) {
completionTask?.cancel()
transportRecovery = nil
purpose = .serialSession
self.mode = mode
endpoints = [
@@ -202,6 +211,7 @@ public final class ConnectionJourney {
host: Host, username: String?, mode: Mode = .connection
) {
completionTask?.cancel()
transportRecovery = nil
purpose = .telnetSession
self.mode = mode
let address = host.displayAddress(for: .telnet)
@@ -391,8 +401,30 @@ public final class ConnectionJourney {
checkpoint = nil
}
/// Recovery retains the original route and log, but does not replay SSH authentication.
public func updateTransportRecovery(_ state: TransportRecovery, message: String) {
guard purpose == .moshSession || purpose == .eternalTerminalSession else { return }
guard transportRecovery != state || (state == .failed && phase != .failed(message)) else { return }
completionTask?.cancel()
transportRecovery = state
mode = .reconnection
checkpoint = nil
completedCheckpoints = [:]
localRequirement = nil
localRequirementEndpointIndex = nil
activeEndpointIndex = max(0, endpoints.count - 1)
if state == .failed {
fail(message)
transportRecovery = state
} else {
phase = .connecting
log(endpoint: destination?.name ?? purpose.logName, message: message)
}
}
public func succeed() {
guard !endpoints.isEmpty else { return }
transportRecovery = nil
checkpoint = nil
localRequirement = nil
localRequirementEndpointIndex = nil
@@ -410,6 +442,7 @@ public final class ConnectionJourney {
public func fail(_ message: String, at hostID: Host.ID? = nil) {
completionTask?.cancel()
transportRecovery = nil
checkpoint = nil
localRequirement = nil
localRequirementEndpointIndex = nil
@@ -427,6 +460,7 @@ public final class ConnectionJourney {
public func hide() {
completionTask?.cancel()
transportRecovery = nil
checkpoint = nil
completedCheckpoints = [:]
localRequirement = nil
@@ -86,6 +86,9 @@ public struct ConnectionJourneyRouteView: View {
guard journey.phase == .connecting, journey.checkpoint == nil,
let lastIndex = journey.endpoints.indices.last
else { return nil }
if let recovery = journey.transportRecovery {
return recovery == .recovering ? lastIndex : nil
}
let candidate: Int
if journey.activeEndpointIndex > journey.completedEndpointIndex {
candidate = journey.activeEndpointIndex - 1
@@ -96,12 +99,22 @@ public struct ConnectionJourneyRouteView: View {
}
private func endpointState(for index: Int) -> JourneyVisualState {
if let recovery = journey.transportRecovery {
if index == journey.activeEndpointIndex {
return recovery == .failed ? .failed : .active
}
return index == 0 ? .complete : .pending
}
if case .failed = journey.phase, index <= journey.activeEndpointIndex { return .failed }
if index <= journey.completedEndpointIndex { return .complete }
return .pending
}
private func segmentState(for targetIndex: Int) -> JourneyVisualState {
if let recovery = journey.transportRecovery {
guard targetIndex == journey.activeEndpointIndex else { return .pending }
return recovery == .failed ? .failed : recovery == .recovering ? .active : .pending
}
if case .failed = journey.phase, targetIndex <= journey.activeEndpointIndex { return .failed }
if targetIndex <= journey.completedEndpointIndex { return .complete }
if targetIndex == journey.activeEndpointIndex { return .active }
@@ -199,17 +199,10 @@ public struct ConnectionJourneyReconnectStatus: View {
.lineLimit(2)
.multilineTextAlignment(.center)
.textSelection(.enabled)
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
retryButton
stopButton
}
VStack(spacing: 10) {
retryButton
stopButton
}
ConnectionJourneyActions {
retryButton
stopButton
}
.controlSize(.large)
}
.padding(.top, 4)
}
@@ -235,3 +228,79 @@ public struct ConnectionJourneyReconnectStatus: View {
return String(localized: AppStrings.Connections.reconnectAttempt(state.attempt, retryingIn: seconds))
}
}
public struct ConnectionJourneyTransportRecoveryStatus: View {
let journey: ConnectionJourney
let retryNow: () -> Void
let close: () -> Void
let copyLogs: () -> Void
public init(
journey: ConnectionJourney, retryNow: @escaping () -> Void,
close: @escaping () -> Void, copyLogs: @escaping () -> Void
) {
self.journey = journey
self.retryNow = retryNow
self.close = close
self.copyLogs = copyLogs
}
public var body: some View {
VStack(spacing: 12) {
if case .failed(let message) = journey.phase {
Label(AppStrings.Connections.connectionFailed.swiftUIKey, systemImage: "exclamationmark.triangle.fill")
.font(.headline)
.foregroundStyle(.red)
Text(message)
if message != String(localized: AppStrings.Transports.sessionUnavailable) {
Text(AppStrings.Transports.sessionUnavailable)
.foregroundStyle(.secondary)
}
} else {
Label(
journey.transportRecovery == .suspended
? AppStrings.Transports.sessionSuspended : AppStrings.Transports.recoveringSession,
systemImage: journey.transportRecovery == .suspended
? "pause.circle" : "arrow.trianglehead.2.clockwise.rotate.90"
)
.font(.headline)
}
ConnectionJourneyActions { actions }
}
.multilineTextAlignment(.center)
.textSelection(.enabled)
.padding(.top, 8)
}
@ViewBuilder
private var actions: some View {
if journey.transportRecovery == .recovering {
Button(AppStrings.Transports.retryNow.swiftUIKey, action: retryNow)
.buttonStyle(.borderedProminent)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
if journey.transportRecovery == .failed {
Button(AppStrings.Terminal.copyLogs.swiftUIKey, action: copyLogs)
.buttonStyle(.bordered)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
Button(AppStrings.Transports.closeSession.swiftUIKey, role: .destructive, action: close)
.buttonStyle(.bordered)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
private struct ConnectionJourneyActions<Actions: View>: View {
@ViewBuilder let actions: Actions
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) { actions }
VStack(spacing: 10) { actions }
}
.controlSize(.large)
}
}
@@ -11,6 +11,10 @@ extension AppStrings {
public static var portRange: LocalizedStringResource { "Port Range" }
public static var retryNow: LocalizedStringResource { "Retry Now" }
public static var closeSession: LocalizedStringResource { "Close Session" }
public static var recoveringSession: LocalizedStringResource { "Restoring the existing terminal session" }
public static var sessionSuspended: LocalizedStringResource {
"The terminal session is paused. It will resume when the app returns to the foreground."
}
public static var sessionUnavailable: LocalizedStringResource {
"The terminal session cannot continue. Open a new session to connect again."
}
@@ -1,33 +0,0 @@
import SwiftUI
import TermindApplication
public struct TerminalTransportStatusView: View {
private let state: ResumableTerminalState?
private let retry: () -> Void
private let close: () -> Void
public init(state: ResumableTerminalState?, retry: @escaping () -> Void, close: @escaping () -> Void) {
self.state = state
self.retry = retry
self.close = close
}
public var body: some View {
if state == .recovering || state == .suspended || state == .failed || state == .ended {
VStack(spacing: 12) {
Text(
state == .failed || state == .ended
? AppStrings.Transports.sessionUnavailable : AppStrings.Connections.reconnecting)
HStack {
if state == .recovering {
Button(AppStrings.Transports.retryNow.swiftUIKey, action: retry)
}
Button(AppStrings.Transports.closeSession.swiftUIKey, role: .destructive, action: close)
}
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.padding()
}
}
}
@@ -5,14 +5,21 @@ import SwiftUI
struct MobileTerminalInitialFocus: ViewModifier {
let terminal: TerminalViewState
let isReady: Bool
var blocksInput = false
func body(content: Content) -> some View {
content
.onAppear { requestFocusIfReady() }
.onChange(of: isReady) { _, _ in requestFocusIfReady() }
.onChange(of: blocksInput) { _, _ in requestFocusIfReady() }
}
private func requestFocusIfReady() {
terminal.attachedPlatformView?.isUserInteractionEnabled = !blocksInput
if blocksInput {
terminal.attachedPlatformView?.resignFirstResponder()
return
}
guard isReady else { return }
terminal.requestFocus()
}
+78 -15
View File
@@ -73,6 +73,12 @@ final class MobileWorkspace: Identifiable {
private(set) var busy = false
var reconnectState: ReconnectState? { connection.reconnectState }
var journey: ConnectionJourney { connection.journey }
var acceptsTerminalInput: Bool {
!transport.isResumable
|| (!journey.isPresented
&& transportState != .recovering && transportState != .suspended
&& transportState != .failed && transportState != .ended && transportState != .closed)
}
var operationError: String?
private var connectionFailure: String?
private(set) var authenticationPrompt: SSHAuthenticationPrompt?
@@ -342,7 +348,7 @@ final class MobileWorkspace: Identifiable {
backend = InMemoryTerminalSession(
write: { data in
Task { @MainActor in
await relay.owner?.receiveInteractiveTerminalInput(data)
await relay.owner?.receiveTerminalWrite(data)
}
},
resize: { size in Task { @MainActor in await relay.owner?.resize(size) } },
@@ -554,15 +560,24 @@ final class MobileWorkspace: Identifiable {
}
func receiveInteractiveTerminalInput(_ data: Data) async {
guard acceptsTerminalInput else { return }
await receiveTerminalWrite(data)
}
private func receiveTerminalWrite(_ data: Data) async {
// Native interaction is disabled under Journey; this callback also carries
// protocol replies, which must still reach the existing transport.
invalidateCommandGeneration()
if descriptor.kind == .telnet {
telnet?.write(data)
} else {
await writeSSHDirectly(data)
} else if let ssh {
terminalContext.noteInput()
await connectionServices.writeSSH(ssh, data)
}
}
private func writeSSHDirectly(_ data: Data) async {
guard acceptsTerminalInput else { return }
terminalContext.noteInput()
guard let ssh else { return }
await connectionServices.writeSSH(ssh, data)
@@ -849,6 +864,7 @@ final class MobileWorkspace: Identifiable {
connectionFailure = TerminalTransportError.invalidCheckpoint.localizedDescription
status = connectionFailure ?? ""
busy = false
presentTransportRecovery(.failed, message: connectionFailure ?? "")
}
}
}
@@ -1204,6 +1220,7 @@ final class MobileWorkspace: Identifiable {
journey.receive(.connected(host.id))
}
guard generation == token, !Task.isCancelled, transportState != .ended else { return }
if transportState == .recovering || transportState == .suspended || transportState == .failed { return }
let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config)
transferSnapshot = additionalSnapshot
if let sftp {
@@ -1223,7 +1240,7 @@ final class MobileWorkspace: Identifiable {
}
}
status = String(localized: AppStrings.Connections.connected)
journey.succeed()
if journey.phase != .succeeded { journey.succeed() }
if sessionPersistence.attachment != nil {
pendingCommand = nil
pendingConnectionStartupCommand = nil
@@ -1266,7 +1283,8 @@ final class MobileWorkspace: Identifiable {
authenticationPrompt = nil
}
var requiresUserInteraction = false
var preventsAutomaticReconnect = error is CancellationError || managedSFTPFailure
var preventsAutomaticReconnect =
error is CancellationError || managedSFTPFailure || transport.isResumable
if let sshError = error as? TermindSSHError, !managedSFTPFailure {
switch sshError {
case .missingCredential, .missingUsername, .keyPassphraseRequired, .authenticationFailed:
@@ -1317,7 +1335,10 @@ final class MobileWorkspace: Identifiable {
}
}
if !requiresUserInteraction {
if (connection.hasConnectedOnce || reconnecting) && !preventsAutomaticReconnect {
if requiresTransportRecovery, transport.isResumable {
connection.stopRecovery()
presentTransportRecovery(.failed, message: connectionFailure ?? error.localizedDescription)
} else if (connection.hasConnectedOnce || reconnecting) && !preventsAutomaticReconnect {
scheduleReconnect(after: error.localizedDescription)
} else {
connection.stopRecovery()
@@ -1447,8 +1468,17 @@ final class MobileWorkspace: Identifiable {
resumeAfterBackground()
return
}
if transport.isResumable, let ssh {
Task { try? await ssh.retryTransport() }
if transport.isResumable {
guard transportState == .recovering, let ssh else { return }
let token = generation
Task { [weak self] in
do {
try await ssh.retryTransport()
} catch {
guard let self, self.generation == token else { return }
self.receiveTransportState(.failed, failure: error.localizedDescription)
}
}
return
}
connection.reconnect.retryNow()
@@ -1546,7 +1576,8 @@ final class MobileWorkspace: Identifiable {
try await ssh.resumeTransport()
self.transportSuspension = nil
} catch {
self.receiveTransportState(.failed)
guard self.generation == token else { return }
self.receiveTransportState(.failed, failure: error.localizedDescription)
}
}
return
@@ -1572,18 +1603,28 @@ final class MobileWorkspace: Identifiable {
}
}
func receiveTransportState(_ state: ResumableTerminalState) {
guard transportState != .ended else { return }
func receiveTransportState(_ state: ResumableTerminalState, failure: String? = nil) {
guard transportState != .ended, transportState != .closed else { return }
guard state != transportState || failure != nil else { return }
transportState = state
switch state {
case .connected:
connected = true
busy = false
status = String(localized: AppStrings.Connections.connected)
journey.hide()
if journey.transportRecovery != nil {
_ = connection.markConnected()
connectionFailure = nil
journey.succeed()
}
case .recovering, .suspended:
connected = false
status = String(localized: AppStrings.Connections.reconnecting)
presentTransportRecovery(
state == .recovering ? .recovering : .suspended,
message: String(
localized: state == .recovering
? AppStrings.Transports.recoveringSession : AppStrings.Transports.sessionSuspended))
case .ended:
connected = false
busy = false
@@ -1595,17 +1636,39 @@ final class MobileWorkspace: Identifiable {
Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) }
handleRemoteExit(.success(0))
case .failed:
let initialConnection =
!connection.hasConnectedOnce && resumeCheckpoint == nil && !requiresTransportRecovery
&& journey.mode == .connection && journey.isPresented && journey.transportRecovery == nil
connected = false
busy = false
connection.resetRecovery()
connectionFailure = TerminalTransportError.sessionFailed.localizedDescription
connectionFailure = failure ?? TerminalTransportError.sessionFailed.localizedDescription
status = connectionFailure ?? ""
if initialConnection {
journey.fail(connectionFailure ?? "")
} else {
presentTransportRecovery(.failed, message: connectionFailure ?? "")
}
let recoveryID = recoveryID
Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) }
// Transport cleanup can follow a failure; the workspace owns dismissing its Journey.
case .connecting, .closed: break
}
}
private func presentTransportRecovery(_ state: ConnectionJourney.TransportRecovery, message: String) {
if journey.endpoints.isEmpty {
let host =
connectionHost(descriptor.hostID)
?? Host(id: descriptor.hostID, name: descriptor.title, host: descriptor.title)
journey.begin(
host: host, route: connectionRoute?.hosts ?? hosts.route(for: host), purpose: connectionPurpose,
username: resumeUsername ?? connectionRoute?.usernames[host.id] ?? usernameHint,
mode: .reconnection)
}
journey.updateTransportRecovery(state, message: message)
}
func restoreRemoteSession(_ reference: RemoteSessionReference?) {
sessionPersistence.attachment = reference
activitySession.recovery?.remoteSession = reference.flatMap { try? JSONEncoder().encode($0) }
@@ -1698,8 +1761,7 @@ final class MobileWorkspace: Identifiable {
private func handleConnectionFailure(_ message: String) {
if transport.isResumable {
connectionFailure = message
receiveTransportState(.failed)
receiveTransportState(.failed, failure: message)
return
}
guard connection.hasConnectedOnce else { return }
@@ -1791,6 +1853,7 @@ final class MobileWorkspace: Identifiable {
func disconnect() {
if transport.isResumable {
transportState = .closed
let recoveryID = recoveryID
Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(recoveryID) }
recoveryLease = nil
@@ -116,11 +116,6 @@ struct MobileWorkspaceView: View {
MobileRegionReader { regions in
MobilePaneLayout(frames: [regions.continuous]) {
workspaceContent
.overlay(alignment: .top) {
TerminalTransportStatusView(
state: workspace.transportState, retry: workspace.retryReconnectNow,
close: { closeWorkspace(workspace.id) })
}
.overlay(alignment: .bottom) {
VStack(spacing: 12) {
if showsCommandGeneratorOverlay {
@@ -451,7 +446,8 @@ struct MobileWorkspaceView: View {
MobileTerminalInitialFocus(
terminal: workspace.terminal,
isReady: workspaceIsVisible && scenePhase == .active && workspace.connected
&& !showsJourney)
&& !showsJourney,
blocksInput: showsJourney)
)
.id(workspace.id)
.onChange(of: workspace.terminalSettings.preferences, initial: true) { _, value in
@@ -80,6 +80,9 @@ struct MobileConnectionJourneyView: View {
} else if let remoteSession, remoteSession.prompt != nil {
RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel)
.connectionFormChrome(isEnabled: true)
} else if journey.transportRecovery != nil {
ConnectionJourneyTransportRecoveryStatus(
journey: journey, retryNow: retryReconnectNow, close: cancel, copyLogs: copyLogs)
} else if case .failed(let message) = journey.phase {
MobileJourneyFailureCard(message: message, retry: retry, cancel: cancel, copyLogs: copyLogs)
} else if let reconnectState {
@@ -11,6 +11,53 @@ import UIKit
@Suite(.serialized, .requiresKeyWindow)
@MainActor
struct MobileTerminalBackgroundTests {
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal], [true, false])
func transportJourneyDisablesNativeInputWithoutReplacingTheSurface(
_ transport: HostTerminalProtocol, foreground: Bool
) async throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(name: "Recovery", host: "test.invalid")
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)),
hosts: app.library, transientHost: host)
workspace.receiveTransportState(.connected)
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: MobileWorkspaceView(
library: app.library, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: [workspace.descriptor], selectWorkspace: { _ in },
renameWorkspace: { _ in }, canCloseMultipleWorkspaces: false,
openSameDirectory: { _ in }, closeWorkspace: { _ in }, closeOtherWorkspaces: { _ in }
)
.environment(\.scenePhase, foreground ? .active : .inactive))
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
workspace.disconnect()
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
try await settle(controller)
let native = try #require(workspace.terminal.attachedPlatformView)
let surface = try #require(workspace.terminal.surface)
#expect(native.becomeFirstResponder())
workspace.receiveTransportState(.recovering)
try await settle(controller)
#expect(!native.isFirstResponder)
#expect(!native.isUserInteractionEnabled)
#expect(workspace.terminal.surface === surface)
workspace.receiveTransportState(.connected)
try await Task.sleep(for: .milliseconds(750))
try await settle(controller)
#expect(native.isUserInteractionEnabled)
#expect(native.isFirstResponder == foreground)
#expect(workspace.terminal.attachedPlatformView === native)
#expect(workspace.terminal.surface === surface)
}
@Test func oscBackgroundFillsSafeAreaWithoutChangingTerminalGeometryOrIdentity() async throws {
let app = try MobileAppModel(inMemory: true)
let workspace = MobileWorkspace(
@@ -8,6 +8,100 @@ import Testing
@Suite @MainActor
struct MobileTerminalRestorationTests {
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func initialFailureRemainsRetryableAfterTransportCleanup(_ transport: HostTerminalProtocol) {
let host = Host(name: "Server", host: "example.invalid")
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)),
hosts: HostLibraryModel(hosts: [host]))
defer { workspace.disconnect() }
workspace.journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "user")
workspace.receiveTransportState(.connected)
#expect(workspace.journey.phase == .connecting)
workspace.receiveTransportState(.failed, failure: "Bootstrap failed")
workspace.receiveTransportState(.closed)
#expect(workspace.journey.phase == .failed("Bootstrap failed"))
#expect(workspace.journey.transportRecovery == nil)
#expect(workspace.journey.mode == .connection)
}
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func unavailableCheckpointShowsJourneyWithoutOpeningANewShell(_ transport: HostTerminalProtocol) async throws {
for incompatibleCheckpoint in [false, true] {
let fixture = try Fixture()
defer { fixture.cleanUp() }
var host = Host(name: "Server", host: "example.invalid")
host.terminalTransports.moshEnabled = true
host.terminalTransports.eternalTerminalEnabled = true
let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .terminal(transport))
if incompatibleCheckpoint {
let other: HostTerminalProtocol = transport == .mosh ? .eternalTerminal : .mosh
let record = TerminalRecoveryRecord(
id: descriptor.id, host: host, username: "user", transport: other)
let lease = await fixture.recovery.register(record.id)
try await fixture.recovery.save(
record, checkpoint: .init(transport: other, payload: Data("opaque".utf8)), lease: lease)
}
var connections = 0
var services = MobileWorkspaceConnectionServices.live
services.connectSSH = { _, _, _, _, _, _ in connections += 1 }
let workspace = MobileWorkspace(
descriptor: descriptor, hosts: HostLibraryModel(hosts: [host]), connectionServices: services)
defer { workspace.disconnect() }
workspace.terminalRecoveryStore = fixture.recovery
workspace.requiresTransportRecovery = true
workspace.connect()
#expect(await eventually { workspace.journey.transportRecovery == .failed })
#expect(workspace.journey.purpose == .terminal(transport))
#expect(workspace.journey.phase == .failed(TerminalTransportError.invalidCheckpoint.localizedDescription))
#expect(workspace.requiresTransportRecovery)
workspace.retryReconnectNow()
#expect(connections == 0)
}
}
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func recoveryUsesJourneyAndRetainsTerminalWithoutSchedulingSSHReconnect(_ transport: HostTerminalProtocol)
async throws
{
let host = Host(name: "Server", host: "example.invalid")
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .terminal(transport)),
hosts: HostLibraryModel(hosts: [host]))
defer { workspace.disconnect() }
let terminal = workspace.terminal
workspace.journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "user")
workspace.receiveTransportState(.connected)
#expect(workspace.journey.phase == .connecting)
workspace.journey.hide()
workspace.receiveTransportState(.suspended)
#expect(workspace.journey.transportRecovery == .suspended)
#expect(!workspace.acceptsTerminalInput)
workspace.receiveTransportState(.recovering)
#expect(workspace.journey.transportRecovery == .recovering)
#expect(workspace.reconnectState == nil)
workspace.receiveTransportState(.connected)
#expect(workspace.journey.phase == .succeeded)
#expect(!workspace.acceptsTerminalInput)
let logs = workspace.journey.logs
workspace.receiveTransportState(.connected)
#expect(workspace.journey.logs == logs)
try await Task.sleep(for: .milliseconds(750))
#expect(!workspace.journey.isPresented)
#expect(workspace.acceptsTerminalInput)
#expect(workspace.terminal === terminal)
workspace.receiveTransportState(.failed, failure: "Original resume error")
#expect(workspace.journey.phase == .failed("Original resume error"))
#expect(workspace.journey.transportRecovery == .failed)
workspace.receiveTransportState(.closed)
#expect(workspace.journey.phase == .failed("Original resume error"))
workspace.disconnect()
workspace.receiveTransportState(.recovering)
workspace.receiveTransportState(.connected)
#expect(!workspace.journey.isPresented)
#expect(!workspace.connected)
}
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func remoteEndClosesWorkspaceOnceButRecoveryAndFailureDoNot(_ transport: HostTerminalProtocol) {
let host = Host(name: "Server", host: "example.invalid")
@@ -140,6 +234,9 @@ struct MobileTerminalRestorationTests {
scene.open(descriptor.destinationURL)
let workspace = try #require(scene.activeWorkspace)
#expect(await eventually { workspace.transportState == .failed })
#expect(workspace.journey.transportRecovery == .failed)
#expect(workspace.journey.isPresented)
#expect(workspace.journey.purpose == .moshSession)
scene.open(descriptor.destinationURL)
workspace.connect()
#expect(await eventually { workspace.transportState == .failed })
@@ -13,6 +13,33 @@ import UIKit
.serialized)
@MainActor
struct MobileConnectionJourneyRenderingTests {
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func rendersTransportRecoveryAndFailure(_ transport: HostTerminalProtocol) async throws {
let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid")
let library = HostLibraryModel(hosts: [host])
for language in ["en", "zh-Hans", "zh-Hant"] {
for state in [
ConnectionJourney.TransportRecovery.recovering, .suspended, .failed,
] {
let journey = ConnectionJourney()
journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "deploy")
journey.updateTransportRecovery(
state, message: String(localized: AppStrings.Transports.sessionUnavailable))
let view = MobileConnectionJourneyView(
journey: journey, library: library, authenticationPrompt: nil, hostKey: nil,
reconnectState: nil, submit: { _, _, _, _, _, _ in }, accept: { _ in },
retry: {
Issue.record("Transport recovery must not offer a new connection")
}, retryReconnectNow: {}, stopReconnecting: {}, cancel: {})
try await capture(
"\(transport.rawValue)-\(state)", language: language, scheme: .light, view: view)
try await capture(
"\(transport.rawValue)-\(state)-large-type", language: language, scheme: .light,
view: view.environment(\.dynamicTypeSize, .accessibility3))
}
}
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func rendersInteractiveAuthentication(_ language: String) async throws {
let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid")
+68 -13
View File
@@ -195,6 +195,13 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
var reconnectState: ReconnectState? { connection.reconnectState }
var acceptsTerminalInput: Bool {
!transport.isResumable
|| (!connectionJourney.isPresented
&& transportState != .recovering && transportState != .suspended
&& transportState != .failed && transportState != .ended && transportState != .closed)
}
func openPTYSFTPChannel(for attemptID: UUID?) async throws -> any SFTPChannelProtocol {
guard status == .connected, connectionAttemptID == attemptID, let ssh else {
throw SFTPError.notConnected
@@ -322,6 +329,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
}
func requestTerminalFocus() {
guard !connectionJourney.isPresented, sessionPersistence.prompt == nil else { return }
terminalSurface.requestFocus()
}
@@ -846,12 +854,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
connectionTask = nil
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
if transport.isResumable { transportState = .closed }
status = .disconnected
}
func retryReconnectNow() {
if transport.isResumable, let ssh {
Task { try? await ssh.retryTransport() }
if transport.isResumable {
guard transportState == .recovering, let ssh else { return }
let attemptID = connectionAttemptID
Task { [weak self] in
do {
try await ssh.retryTransport()
} catch {
guard let self, self.connectionAttemptID == attemptID else { return }
self.receiveTransportState(.failed, failure: error.localizedDescription)
}
}
return
}
reconnectController.retryNow()
@@ -996,6 +1014,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
try Task.checkCancellation()
guard connectionAttemptID == attemptID else { throw CancellationError() }
guard transportState != .ended else { return }
if transportState == .recovering || transportState == .suspended || transportState == .failed { return }
transferSnapshot =
statusStore?.additionalConnectionSnapshot(from: configuration)
?? SSHAdditionalConnectionSnapshot(configuration: configuration)
@@ -1017,9 +1036,9 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
pendingConnectionStartupCommand = nil
homeResolution = .unavailable
}
if presentsConnectionJourney {
if presentsConnectionJourney, connectionJourney.phase != .succeeded {
connectionJourney.succeed()
} else {
} else if !presentsConnectionJourney {
connectionJourney.hide()
}
if wasReconnecting, transport == .ssh, sessionPersistence.attachment == nil {
@@ -1038,26 +1057,56 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
}
}
func receiveTransportState(_ state: ResumableTerminalState) {
guard transportState != .ended else { return }
func receiveTransportState(_ state: ResumableTerminalState, failure: String? = nil) {
guard transportState != .ended, transportState != .closed else { return }
guard state != transportState || failure != nil else { return }
transportState = state
switch state {
case .connected:
status = .connected
connectionJourney.hide()
case .recovering:
if connectionJourney.transportRecovery != nil {
_ = connection.markConnected()
connectionJourney.succeed()
}
case .recovering, .suspended:
status = .reconnecting
presentTransportRecovery(
state == .recovering ? .recovering : .suspended,
message: String(
localized: state == .recovering
? AppStrings.Transports.recoveringSession : AppStrings.Transports.sessionSuspended))
case .ended:
// These protocols report session end, not the remote process's numeric exit status.
handleRemoteExit(.success(0))
case .failed:
let initialConnection =
!connection.hasConnectedOnce && connectionJourney.mode == .connection
&& connectionJourney.isPresented && connectionJourney.transportRecovery == nil
connection.resetRecovery()
status = .failed(TerminalTransportError.sessionFailed.localizedDescription)
case .suspended: status = .reconnecting
let message = failure ?? TerminalTransportError.sessionFailed.localizedDescription
status = .failed(message)
if initialConnection {
connectionJourney.fail(message)
} else {
presentTransportRecovery(.failed, message: message)
}
// Transport cleanup can follow a failure; the pane owns dismissing its Journey.
case .connecting, .closed: break
}
}
private func presentTransportRecovery(_ state: ConnectionJourney.TransportRecovery, message: String) {
if connectionJourney.endpoints.isEmpty {
let host =
connectionRoute?.host(hostID) ?? statusStore?.host(hostID) ?? transientHost
?? Host(id: hostID, name: title, host: title)
connectionJourney.begin(
host: host, route: connectionRoute?.hosts ?? [host], purpose: .terminal(transport),
username: connectionRoute?.usernames[hostID] ?? usernameHint, mode: .reconnection)
}
connectionJourney.updateTransportRecovery(state, message: message)
}
/// A normal remote-shell exit owns the pane lifecycle, not the reusable
/// Ghostty surface lifecycle. Marking the in-memory backend as finished
/// makes the surface permanently stop producing input, which is incorrect
@@ -1138,7 +1187,11 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
status = .disconnected
inputRouter?.closeSSHSessionPane(afterExit: self)
case .failure(let error):
beginAutomaticReconnect(after: error.localizedDescription)
if transport.isResumable {
receiveTransportState(.failed, failure: error.localizedDescription)
} else {
beginAutomaticReconnect(after: error.localizedDescription)
}
}
}
@@ -1295,17 +1348,19 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
/// captured user interaction. Mouse/focus reports and protocol replies use
/// the direct path even while Broadcast Input is enabled.
func handleTerminalWrite(_ data: Data, isInteractiveInput: Bool) {
guard !isInteractiveInput || acceptsTerminalInput else { return }
if isInteractiveInput { invalidateCommandGeneration() }
if isInteractiveInput, let inputRouter {
inputRouter.routeInteractiveInput(data, from: self)
} else {
writeInputDirectly(data)
writeInputDirectly(data, isInteractiveInput: isInteractiveInput)
}
}
/// The terminal side of the routing boundary. Broadcast targets call this
/// rather than feeding bytes back through the router, preventing recursion.
func writeInputDirectly(_ data: Data) {
func writeInputDirectly(_ data: Data, isInteractiveInput: Bool = true) {
guard !isInteractiveInput || acceptsTerminalInput else { return }
terminalContext.noteInput()
if let inputSink {
inputSink(data)
@@ -31,6 +31,9 @@ struct TerminalPlaceholderView: View {
@State private var sftpBindingID: UUID?
private var uploads: TerminalUploadModel { sshSession.terminalUpload }
private var showsJourney: Bool {
sshSession.connectionJourney.isPresented || sshSession.sessionPersistence.prompt != nil
}
/// The surface decides the pane's size; everything else is an overlay.
///
@@ -45,13 +48,12 @@ struct TerminalPlaceholderView: View {
/// terminal alone answers for the pane.
var body: some View {
terminalSurface
.opacity(showsJourney ? 0 : 1)
.allowsHitTesting(!showsJourney)
.accessibilityHidden(showsJourney)
.desktopTerminalChrome(state: sshSession.terminalState)
.overlay(alignment: .bottom) { uploadDecoration }
.overlay { connectionJourney }
.overlay(alignment: .top) {
TerminalTransportStatusView(
state: sshSession.transportState, retry: sshSession.retryReconnectNow, close: closePane)
}
.animation(Motion.panel, value: uploads.notice)
.onAppear {
// The mounted view is the final source of truth after any drag,
@@ -59,7 +61,7 @@ struct TerminalPlaceholderView: View {
// here so a stale layout callback cannot leave live keystrokes on a
// sshSession whose router was cleared while it moved.
sshSession.inputRouter = workspace
terminalFocused = requestsFocus
terminalFocused = requestsFocus && !showsJourney
TerminalFontCatalog.shared.refresh()
uploadBindingID = sshSession.terminalViewOwner.bindUpload(
action: chooseUploadFiles,
@@ -81,9 +83,12 @@ struct TerminalPlaceholderView: View {
for: uploadBindingID, isAvailable: { isVisible && uploads.uploadTargetProblem == nil })
}
.onChange(of: requestsFocus) { _, focused in
terminalFocused = focused
terminalFocused = focused && !showsJourney
if focused { sshSession.requestTerminalFocus() }
}
.onChange(of: showsJourney) { _, presented in
terminalFocused = requestsFocus && isVisible && !presented
}
.onChange(of: externalFileDrop?.id) {
guard let externalFileDrop else { return }
uploads.receiveFiles(externalFileDrop.urls, connections: app.connections, transfers: app.sftpTransfers)
@@ -196,8 +201,8 @@ struct TerminalPlaceholderView: View {
PersistentTerminalSurfaceView(
state: sshSession.terminalState,
owner: sshSession.terminalViewOwner,
isVisible: isVisible,
requestsFocus: requestsFocus
isVisible: isVisible && !showsJourney,
requestsFocus: requestsFocus && !showsJourney
)
#else
TerminalSurfaceView(context: sshSession.terminalState)
@@ -247,6 +247,9 @@ struct ConnectionJourneyView: View {
} else if let remoteSession, remoteSession.prompt != nil {
RemoteSessionPromptView(coordinator: remoteSession, cancel: cancel)
.desktopConnectionFormChrome(isEnabled: true)
} else if journey.transportRecovery != nil {
ConnectionJourneyTransportRecoveryStatus(
journey: journey, retryNow: retryReconnectNow ?? retry, close: cancel, copyLogs: copyLogs)
} else if case .failed(let message) = journey.phase {
JourneyFailureCard(
message: message, retry: retry, cancel: cancel,
@@ -0,0 +1,94 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite(.requiresVisibleWindow, .serialized) @MainActor
struct TerminalTransportJourneyRenderingTests {
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func recoveryHidesAndDefocusesTheMountedTerminal(_ transport: HostTerminalProtocol) async throws {
let host = TermindDomain.Host(name: "Recovery", host: "test.invalid")
let pane = Pane.sshSession(host: host, transport: transport)
let session = try #require(pane.sshSession)
let workspace = Workspace(pane: pane)
let navigation = WindowModel(workspaces: [workspace])
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
session.receiveTransportState(.connected)
let controller = NSHostingController(
rootView: TerminalPlaceholderView(
sshSession: session, workspace: workspace, isVisible: true, requestsFocus: true,
externalFileDrop: nil, externalRemoteDrop: nil, closePane: {}
)
.environment(app).environment(navigation))
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentViewController = controller
window.makeKeyAndOrderFront(nil)
defer {
session.disconnect()
window.orderOut(nil)
window.contentViewController = nil
window.close()
}
try await Task.sleep(for: .milliseconds(250))
let terminalController = session.terminalState.controller
#expect(session.terminalState.isSurfaceVisible)
#expect(session.terminalViewOwner.hasKeyboardFocusForTesting)
session.receiveTransportState(.recovering)
try await Task.sleep(for: .milliseconds(250))
#expect(!session.terminalState.isSurfaceVisible)
#expect(!session.terminalViewOwner.hasKeyboardFocusForTesting)
session.receiveTransportState(.connected)
try await Task.sleep(for: .milliseconds(900))
#expect(session.terminalState.isSurfaceVisible)
#expect(session.terminalState.controller === terminalController)
#expect(session.terminalViewOwner.hasKeyboardFocusForTesting)
}
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func rendersRecoveryAndFailureInShortPanes(_ transport: HostTerminalProtocol) async throws {
let host = TermindDomain.Host(name: "Production Gateway", host: "gateway.example.invalid")
for language in ["en", "zh-Hans", "zh-Hant"] {
for state in [ConnectionJourney.TransportRecovery.recovering, .suspended, .failed] {
let journey = ConnectionJourney()
journey.begin(host: host, route: [host], purpose: .terminal(transport), username: "deploy")
journey.updateTransportRecovery(
state, message: String(localized: AppStrings.Transports.sessionUnavailable))
let view = ConnectionJourneyView(
journey: journey, submitAuthentication: { _, _, _, _, _, _ in },
continueHostKey: { _ in }, trustHostKey: { _ in }, denyHostKey: {},
retry: { Issue.record("Recovery must not offer a new connection") }, cancel: {},
retryReconnectNow: {}
)
.environment(\.locale, Locale(identifier: language))
.frame(width: 480, height: 440)
let controller = NSHostingController(rootView: view)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 440),
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentViewController = controller
window.setContentSize(NSSize(width: 480, height: 440))
window.makeKeyAndOrderFront(nil)
defer {
window.orderOut(nil)
window.contentViewController = nil
window.close()
}
try await Task.sleep(for: .milliseconds(100))
controller.view.layoutSubtreeIfNeeded()
#expect(controller.view.bounds.height == 440)
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "\(transport.rawValue)-\(state)-\(language).png")
}
}
}
}
@@ -0,0 +1,81 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@Suite @MainActor
struct TerminalTransportJourneyTests {
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func initialConnectedEventDoesNotDismissBootstrapJourney(_ transport: HostTerminalProtocol) {
let host = Host(name: "Server", host: "example.invalid")
let session = SSHSessionModel(host: host)
session.transport = transport
defer { session.disconnect() }
session.connectionJourney.begin(host: host, route: [host], purpose: .terminal(transport), username: "user")
session.receiveTransportState(.connected)
#expect(session.connectionJourney.phase == .connecting)
#expect(session.connectionJourney.mode == .connection)
session.receiveTransportState(.failed, failure: "Bootstrap failed")
#expect(session.connectionJourney.phase == .failed("Bootstrap failed"))
#expect(session.connectionJourney.transportRecovery == nil)
#expect(session.connectionJourney.mode == .connection)
session.receiveTransportState(.closed)
#expect(session.connectionJourney.phase == .failed("Bootstrap failed"))
}
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func recoveryPreservesTerminalAndBlocksInteractiveAndBroadcastInput(_ transport: HostTerminalProtocol) async throws
{
let host = Host(name: "Server", host: "example.invalid")
let session = SSHSessionModel(host: host)
session.transport = transport
defer { session.disconnect() }
let surface = session.terminalState
var writes: [Data] = []
session.inputSink = { writes.append($0) }
session.receiveTransportState(.recovering)
#expect(session.connectionJourney.purpose == .terminal(transport))
#expect(session.connectionJourney.transportRecovery == .recovering)
#expect(session.reconnectState == nil)
session.handleTerminalWrite(Data("interactive".utf8), isInteractiveInput: true)
session.writeInputDirectly(Data("broadcast".utf8))
#expect(writes.isEmpty)
session.handleTerminalWrite(Data("protocol reply".utf8), isInteractiveInput: false)
#expect(writes == [Data("protocol reply".utf8)])
session.receiveTransportState(.suspended)
#expect(session.connectionJourney.transportRecovery == .suspended)
session.receiveTransportState(.recovering)
session.receiveTransportState(.connected)
#expect(session.connectionJourney.phase == .succeeded)
#expect(!session.acceptsTerminalInput)
let logs = session.connectionJourney.logs
session.receiveTransportState(.connected)
#expect(session.connectionJourney.logs == logs)
try await Task.sleep(for: .milliseconds(750))
#expect(session.acceptsTerminalInput)
#expect(session.terminalState === surface)
session.writeInputDirectly(Data("after".utf8))
#expect(writes.last == Data("after".utf8))
}
@Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal])
func failureKeepsOriginalErrorAndClosedSessionIgnoresLateEvents(_ transport: HostTerminalProtocol) {
let session = SSHSessionModel(host: Host(name: "Server", host: "example.invalid"))
session.transport = transport
session.receiveTransportState(.failed, failure: "Transport refused resume")
#expect(session.connectionJourney.phase == .failed("Transport refused resume"))
#expect(session.connectionJourney.transportRecovery == .failed)
#expect(session.reconnectState == nil)
session.receiveTransportState(.closed)
#expect(session.connectionJourney.phase == .failed("Transport refused resume"))
session.disconnect()
session.receiveTransportState(.recovering)
session.receiveTransportState(.connected)
#expect(!session.connectionJourney.isPresented)
#expect(session.status == .disconnected)
}
}