// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import TermindApplication import TermindDomain import Testing @testable import TermindRemoteInfrastructure @Suite struct ResumableTerminalEngineTests { @Test func restoreStartsWithTheLatestViewportAcceptedDuringPreparationWithoutRedundantResize() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["prepare"]) let client = client(engine, events: events) let connecting = Task { try await connect(client) } await events.wait(for: "gated:prepare") try await client.resize(columns: 100, rows: 30) try await client.resize(columns: 132, rows: 43) try await client.resize(columns: 132, rows: 43) #expect(!events.values.contains(where: { $0.hasPrefix("resize:") || $0.hasPrefix("start:") })) await engine.release("prepare") try await connecting.value #expect(await engine.preparedPayload == checkpoint.payload) #expect(events.values.filter { $0.hasPrefix("start:") } == ["start:132x43"]) #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) #expect(await engine.appliedGrid == "132x43") #expect(events.values.last == "state:connected") await client.close() } @Test func startupPreservesTerminalProtocolRepliesBeforePublishingConnected() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["start"]) let client = client(engine, events: events) let connecting = Task { try await connect(client) } await events.wait(for: "gated:start") await events.emit(.connected) let reply = "\u{1B}[1;1R" try await client.write(Data(reply.utf8)) #expect(events.values.contains("write:\(reply)")) #expect(!events.values.contains("state:connected")) await engine.release("start") try await connecting.value #expect(events.values.last == "state:connected") await client.close() } @Test func startupDrainsResizeChangesAcrossStartAndResizeAwaitsBeforeConnected() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["start", "resize"]) let client = client(engine, events: events) let connecting = Task { try await connect(client) } await events.wait(for: "gated:start") try await client.resize(columns: 100, rows: 30) try await client.resize(columns: 132, rows: 43) await events.emit(.connected) #expect(!events.values.contains("state:connected")) #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) await engine.release("start") await events.wait(for: "gated:resize") #expect(await engine.appliedGrid == "80x24") try await client.resize(columns: 150, rows: 50) try await client.resize(columns: 160, rows: 60) try await client.resize(columns: 160, rows: 60) await events.emit(.activity) #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43"]) #expect(!events.values.contains("state:connected")) await engine.release("resize") try await connecting.value #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43", "resize:160x60"]) #expect(await engine.appliedGrid == "160x60") #expect(events.values.last == "state:connected") try await client.write(Data("after".utf8)) #expect(events.values.last == "write:after") await client.close() } @Test func startupRechecksViewportAfterWritingRestoredIntegrationBeforePublishingConnected() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["start", "write"], restoresIntegration: true) let client = client(engine, events: events) let connecting = Task { try await connect(client) } await events.wait(for: "gated:start") await events.emit(.output(Data("__TRMD_RESTORE_SHELL__/bin/bash__\r\n".utf8))) #expect(!events.values.contains(where: { $0.hasPrefix("write:") })) await engine.release("start") await events.wait(for: "gated:write") try await client.resize(columns: 140, rows: 50) #expect(!events.values.contains("state:connected")) #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) await engine.release("write") try await connecting.value #expect(events.values.filter { $0.hasPrefix("write:") && $0.contains("TermindCommand=RESTORE") }.count == 1) #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:140x50"]) #expect(await engine.appliedGrid == "140x50") #expect(events.values.last == "state:connected") await client.close() } @Test func cancellingStartupDuringResizeDrainClosesWithoutAdmittingConnectedOrInput() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["start", "resize"]) let client = client(engine, events: events) let connecting = Task { try await connect(client) } await events.wait(for: "gated:start") try await client.resize(columns: 132, rows: 43) await engine.release("start") await events.wait(for: "gated:resize") connecting.cancel() try await client.resize(columns: 160, rows: 60) await engine.release("resize") await #expect(throws: CancellationError.self) { try await connecting.value } #expect(!events.values.contains("state:connected")) #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43"]) #expect(Array(events.values.suffix(3)) == ["rollback", "close", "state:closed"]) await #expect(throws: CancellationError.self) { try await client.write(Data("blocked".utf8)) } } @Test func routeFactsPassWhileRecoveryIsFrozenWithoutConfirmingProtocolReadiness() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry"]) let client = client(engine, events: events) try await connect(client) await events.emit(.recovering) let retrying = Task { try await client.retryNow() } await events.wait(for: "gated:retry") let attemptID = UUID() await events.emit(.routeChanged(.init(attemptID: attemptID, revision: 1, stage: .opening))) await events.emit(.routeChanged(.init(attemptID: attemptID, revision: 2, stage: .ready))) #expect(events.values.suffix(2) == ["route:1:opening", "route:2:ready"]) #expect(events.values.filter { $0.hasPrefix("state:") }.last == "state:recovering") await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } await engine.release("retry") try await retrying.value #expect(events.values.filter { $0.hasPrefix("state:") }.last == "state:recovering") #expect(events.values.filter { $0 == "prepareRetry" }.count == 1) #expect(events.values.filter { $0 == "retry" }.count == 1) await events.emit(.connected) #expect(events.values.last == "state:connected") await client.close() } @Test func reorderedRouteCallbacksAndClosedClientCannotReviveAnOldRoute() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) let old = UUID() let next = UUID() await events.emit(.routeChanged(.init(attemptID: old, revision: 3, stage: .unavailable))) await events.emit(.routeChanged(.init(attemptID: old, revision: 2, stage: .ready))) await events.emit(.routeChanged(.init(attemptID: next, revision: 4, stage: .opening))) await events.emit(.routeChanged(.init(attemptID: old, revision: 3, stage: .unavailable))) #expect(events.values.filter { $0.hasPrefix("route:") } == ["route:3:unavailable", "route:4:opening"]) await client.close() let closed = events.values await events.emit(.routeChanged(.init(attemptID: next, revision: 5, stage: .ready))) #expect(events.values == closed) } @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(3)) == ["prepareRetry", "retry", "resize:80x24"]) #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( output: { _ in }, exit: { _ in }) await channel.configureTransport( .mosh, options: options, route: { _ in events.record("route") throw CancellationError() }, stateChanged: { _ in }) await channel.configureStartup { _ in events.record("startup") return nil } let password = SSHSecret("one-use") var request = request request.configuration.credentials = [.password(password)] request.configuration.beforeConnect = { events.record("wake") } do { try await channel.connect(using: request, columns: 80, rows: 24) Issue.record("An unregistered transport must fail before attempting SSH") } catch TerminalTransportError.unsupportedRoute { } #expect(events.values.isEmpty) #expect(password.isConsumed) await channel.disconnect() } @Test func coordinatorVariantUsesRegisteredEngineAndRestoresWithoutStartupOrRoute() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let coordinator = RemoteConnectionCoordinator(transportEngines: registry(engine, events: events)) .withKeyboardInteractive { _ in [] } let channel = coordinator.makePTYChannel( output: { events.record("output:\(String(decoding: $0, as: UTF8.self))") }, exit: { _ in }) await channel.configureTransport( .mosh, options: options, checkpoint: checkpoint, route: { _ in events.record("route") throw CancellationError() }, stateChanged: { events.record("state:\($0)") }) await channel.configureStartup { _ in events.record("startup") return nil } try await channel.connect(using: request, columns: 81, rows: 25) #expect(await engine.preparedPayload == checkpoint.payload) #expect(await engine.permitsAgentForwarding == false) #expect(events.values.contains("start:81x25")) #expect(events.values.contains("output:ready")) #expect(!events.values.contains("startup")) #expect(!events.values.contains("route")) await channel.write(Data("hello".utf8)) await channel.resize(columns: 100, rows: 30, widthPixels: 0, heightPixels: 0) await channel.disconnect() await channel.disconnect() #expect(events.values.contains("write:hello")) #expect(events.values.contains("resize:100x30")) #expect(events.values.filter { $0 == "close" }.count == 1) } @Test func suspensionDrainsWritesAndLateActivityCannotUnfreezeTheCheckpoint() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) let writing = Task { try await client.write(Data("hold".utf8)) } #expect(await eventually { events.values.contains("write:hold") }) let suspending = Task { try await client.suspend() } #expect( await eventually { do { try await client.write(Data("probe".utf8)) return false } catch TerminalTransportError.suspended { return true } catch { return false } }) #expect(!events.values.contains("checkpoint")) await events.emit(.connected) await events.emit(.activity) await events.emit(.output(Data("tail".utf8))) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } try await client.resize(columns: 132, rows: 43) #expect(!events.values.contains("resize:132x43")) await engine.finishWrite() try await writing.value let saved = try await suspending.value #expect(saved.transport == .mosh) #expect(saved.payload == Data("saved-state".utf8)) #expect(events.values.contains("output:tail")) #expect(events.values.last == "state:suspended") await events.emit(.connected) #expect(events.values.last == "state:suspended") try await client.retryNow() let retry = events.values.suffix(5) #expect(Array(retry) == ["prepareRetry", "beforeResume", "state:recovering", "resume", "resize:132x43"]) try await client.write(Data("after".utf8)) #expect(events.values.last == "write:after") await client.close() } @Test func resumeCoalescesAndFlushesOnlyTheLatestViewportBeforeIntegrationAndConnected() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["resume"], restoresIntegration: true) let client = client(engine, events: events) try await connect(client) _ = try await client.suspend() let resuming = Task { try await client.resume() } await events.wait(for: "gated:resume") let secondResume = Task { try await client.resume() } await events.wait(for: "lifecycle:queued", count: 3) try await client.resize(columns: 100, rows: 30) try await client.resize(columns: 132, rows: 43) await events.emit(.connected) await events.emit(.output(Data("__TRMD_RESTORE_SHELL__/bin/bash__\r\n".utf8))) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } #expect(!events.values.contains(where: { $0.hasPrefix("resize:") || $0.hasPrefix("write:") })) #expect(events.values.filter { $0.hasPrefix("state:") }.last == "state:recovering") await engine.release("resume") try await resuming.value try await secondResume.value #expect(events.values.filter { $0 == "resume" }.count == 1) #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43"]) let writes = events.values.filter { $0.hasPrefix("write:") } #expect(writes.count == 1) #expect(writes.first?.contains("TermindCommand=RESTORE") == true) let resizeIndex = try #require(events.values.firstIndex(of: "resize:132x43")) let writeIndex = try #require(events.values.firstIndex(where: { $0.hasPrefix("write:") })) #expect(resizeIndex < writeIndex) #expect(events.values.last == "state:connected") #expect(!events.values.contains("state:failed")) try await client.write(Data("after".utf8)) #expect(events.values.last == "write:after") await client.close() } @Test func retryDrainsExistingSendsAndCoalescesWhilePreservingBlockedViewportChanges() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry"]) let client = client(engine, events: events) try await connect(client) let writing = Task { try await client.write(Data("hold".utf8)) } await events.wait(for: "write:hold") let retrying = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued") let secondRetry = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued", count: 2) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } try await client.resize(columns: 111, rows: 31) #expect(!events.values.contains("prepareRetry")) #expect(!events.values.contains("retry")) await engine.finishWrite() try await writing.value await events.wait(for: "gated:retry") try await client.resize(columns: 140, rows: 50) #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) await events.emit(.connected) await engine.release("retry") try await retrying.value try await secondRetry.value #expect(events.values.filter { $0 == "prepareRetry" }.count == 1) #expect(events.values.filter { $0 == "retry" }.count == 1) #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:140x50"]) #expect(events.values.last == "state:connected") #expect(!events.values.contains("state:failed")) await client.close() } @Test func checkpointAndResumeQueueBehindRetryWithoutOpeningSendAdmissionBetweenThem() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry", "checkpoint", "resume"]) let client = client(engine, events: events) try await connect(client) let retrying = Task { try await client.retryNow() } await events.wait(for: "gated:retry") let suspending = Task { try await client.suspend() } await events.wait(for: "lifecycle:queued", count: 2) let resuming = Task { try await client.resume() } await events.wait(for: "lifecycle:queued", count: 3) #expect(!events.values.contains("checkpoint")) #expect(!events.values.contains("resume")) await events.emit(.connected) await engine.release("retry") try await retrying.value await events.wait(for: "gated:checkpoint") await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } #expect(!events.values.contains("resume")) await engine.release("checkpoint") let saved = try await suspending.value #expect(saved.payload == Data("saved-state".utf8)) await events.wait(for: "gated:resume") await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } await events.emit(.connected) await engine.release("resume") try await resuming.value #expect( events.values.filter { ["retry", "checkpoint", "resume"].contains($0) } == [ "retry", "checkpoint", "resume", ]) #expect(events.values.filter { $0 == "state:connected" }.count == 2) #expect(!events.values.contains("state:failed")) try await client.write(Data("after".utf8)) await client.close() } @Test func queuedResumePreservesTheOnlyConnectedEventFromRetry() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry"]) let client = client(engine, events: events) try await connect(client) let retrying = Task { try await client.retryNow() } await events.wait(for: "gated:retry") let resuming = Task { try await client.resume() } await events.wait(for: "lifecycle:queued", count: 2) await events.emit(.connected) await engine.release("retry") try await retrying.value try await resuming.value #expect(events.values.last == "state:connected") #expect(events.values.filter { $0 == "resume" }.count == 1) #expect(!events.values.contains("state:failed")) await client.close() } @Test func checkpointQueuedDuringInitialStartWaitsForTheLatestViewportToDrain() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["start", "resize"]) let client = client(engine, events: events) let connecting = Task { try await connect(client) } await events.wait(for: "gated:start") let suspending = Task { try await client.suspend() } await events.wait(for: "lifecycle:queued") try await client.resize(columns: 132, rows: 43) #expect(!events.values.contains("checkpoint")) await engine.release("start") await events.wait(for: "gated:resize") try await client.resize(columns: 160, rows: 60) #expect(!events.values.contains("checkpoint")) #expect(!events.values.contains("state:connected")) await engine.release("resize") try await connecting.value let saved = try await suspending.value #expect(await engine.appliedGrid == "160x60") let resizeIndex = try #require(events.values.firstIndex(of: "resize:160x60")) let checkpointIndex = try #require(events.values.firstIndex(of: "checkpoint")) #expect(resizeIndex < checkpointIndex) #expect(saved.transport == .mosh) #expect(events.values.last == "state:suspended") #expect(!events.values.contains("state:failed")) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } try await client.resume() try await client.write(Data("after".utf8)) await client.close() } @Test func checkpointAfterStartDoesNotRequireTheConnectedObserverToHaveCaughtUp() async throws { let events = EngineEvents() let engine = TestEngine(events: events, reportsConnectedOnStart: false) let client = client(engine, events: events) try await connect(client) _ = try await client.suspend() #expect(events.values.contains("checkpoint")) #expect(events.values.last == "state:suspended") try await client.resume() try await client.write(Data("after".utf8)) await events.emit(.connected) #expect(events.values.last == "state:connected") await client.close() } @Test func resizeChangesDuringRecoveryFlushAreAppliedBeforeUserWritesReopen() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["resize"]) let client = client(engine, events: events) try await connect(client) _ = try await client.suspend() let resuming = Task { try await client.resume() } await events.wait(for: "gated:resize") try await client.resize(columns: 110, rows: 40) try await client.resize(columns: 150, rows: 60) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:80x24"]) await engine.release("resize") try await resuming.value #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:80x24", "resize:150x60"]) try await client.write(Data("after".utf8)) await client.close() } @Test func coalescedResumeFailureReachesEveryCallerAndKeepsAdmissionFrozenUntilRetry() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["resume", "prepareRetry"], failures: ["resume"]) let client = client(engine, events: events) try await connect(client) _ = try await client.suspend() let resuming = Task { try await client.resume() } await events.wait(for: "gated:resume") let secondResume = Task { try await client.resume() } await events.wait(for: "lifecycle:queued", count: 3) let retrying = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued", count: 4) await engine.release("resume") await expectUnreachable(resuming) await expectUnreachable(secondResume) await events.wait(for: "gated:prepareRetry") #expect(events.values.filter { $0 == "resume" }.count == 1) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) await engine.release("prepareRetry") try await retrying.value #expect(events.values.filter { $0 == "resume" }.count == 2) try await client.write(Data("after".utf8)) await client.close() } @Test func failedResumeAdmissionLeavesTheExistingCheckpointEligible() async throws { let events = EngineEvents() let engine = TestEngine(events: events, failures: ["beforeResume"]) let client = client(engine, events: events) try await connect(client) let saved = try await client.suspend() await expectUnreachable(Task { try await client.resume() }) let stillSaved = try await client.suspend() #expect(stillSaved.payload == saved.payload) #expect(events.values.filter { $0 == "checkpoint" }.count == 1) #expect(!events.values.contains("resume")) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } try await client.retryNow() try await client.write(Data("after".utf8)) await client.close() } @Test func failedResumePreflightWithoutASuspensionKeepsBufferedStateFrozenUntilRecovery() async throws { let events = EngineEvents() let engine = TestEngine( events: events, gates: ["beforeResume"], restoresIntegration: true, failures: ["beforeResume"]) let client = client(engine, events: events) try await connect(client) let resuming = Task { try await client.resume() } await events.wait(for: "gated:beforeResume") try await client.resize(columns: 150, rows: 60) await events.emit(.output(Data("__TRMD_RESTORE_SHELL__/bin/bash__\r\n".utf8))) await engine.release("beforeResume") await expectUnreachable(resuming) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } #expect(!events.values.contains(where: { $0.hasPrefix("resize:") })) #expect(!events.values.contains(where: { $0.hasPrefix("write:") })) try await client.retryNow() #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:150x60"]) #expect(events.values.filter { $0.hasPrefix("write:") && $0.contains("TermindCommand=RESTORE") }.count == 1) try await client.write(Data("after".utf8)) await client.close() } @Test func cancellingOneCoalescedRetryDoesNotCancelTheOtherCaller() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry"]) let client = client(engine, events: events) try await connect(client) let retrying = Task { try await client.retryNow() } await events.wait(for: "gated:retry") let cancelled = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued", count: 2) cancelled.cancel() await #expect(throws: CancellationError.self) { try await cancelled.value } await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } await engine.release("retry") try await retrying.value #expect(events.values.filter { $0 == "retry" }.count == 1) try await client.write(Data("after".utf8)) await client.close() } @Test func cancelledQueuedCheckpointDoesNotSuspendTheRecoveredEngine() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry"]) let client = client(engine, events: events) try await connect(client) let retrying = Task { try await client.retryNow() } await events.wait(for: "gated:retry") let cancelled = Task { try await client.suspend() } await events.wait(for: "lifecycle:queued", count: 2) cancelled.cancel() await #expect(throws: CancellationError.self) { try await cancelled.value } await engine.release("retry") try await retrying.value #expect(!events.values.contains("checkpoint")) try await client.write(Data("after".utf8)) await client.close() } @Test func cancellingRetryCallerDuringSendDrainStillFinishesBeforeTheQueuedCheckpoint() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) let writing = Task { try await client.write(Data("hold".utf8)) } await events.wait(for: "write:hold") let cancelled = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued") let suspending = Task { try await client.suspend() } await events.wait(for: "lifecycle:queued", count: 2) cancelled.cancel() await #expect(throws: CancellationError.self) { try await cancelled.value } #expect(!events.values.contains("checkpoint")) await engine.finishWrite() try await writing.value _ = try await suspending.value #expect( events.values.filter { ["prepareRetry", "retry", "checkpoint"].contains($0) } == [ "prepareRetry", "retry", "checkpoint", ]) #expect(events.values.last == "state:suspended") await client.close() } @Test func cancelledRetryCallerStillFlushesAndANewCallerCanJoinItsAdmittedWork() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["retry"], restoresIntegration: true) let client = client(engine, events: events) try await connect(client) await events.emit(.recovering) let writing = Task { try await client.write(Data("hold".utf8)) } await events.wait(for: "write:hold") let cancelled = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued") try await client.resize(columns: 150, rows: 60) await events.emit(.output(Data("__TRMD_RESTORE_SHELL__/bin/bash__\r\n".utf8))) #expect(events.values.filter { $0.hasPrefix("state:") }.last == "state:recovering") cancelled.cancel() await #expect(throws: CancellationError.self) { try await cancelled.value } #expect(!events.values.contains("prepareRetry")) let replacement = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued", count: 2) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } await engine.finishWrite() try await writing.value await events.wait(for: "gated:retry") await events.emit(.connected) await engine.release("retry") try await replacement.value #expect(events.values.filter { $0 == "retry" }.count == 1) #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:150x60"]) #expect(events.values.filter { $0.hasPrefix("write:") && $0.contains("TermindCommand=RESTORE") }.count == 1) #expect(events.values.last == "state:connected") try await client.write(Data("after".utf8)) await client.close() } @Test func cancelledResumeCallerLeavesProtocolWorkSerializedBeforeTheNextCheckpoint() async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: ["resume"]) let client = client(engine, events: events) try await connect(client) _ = try await client.suspend() let cancelled = Task { try await client.resume() } await events.wait(for: "gated:resume") cancelled.cancel() await #expect(throws: CancellationError.self) { try await cancelled.value } let suspending = Task { try await client.suspend() } await events.wait(for: "lifecycle:queued", count: 3) #expect(events.values.filter { $0 == "checkpoint" }.count == 1) await #expect(throws: TerminalTransportError.self) { try await client.write(Data("blocked".utf8)) } await engine.release("resume") _ = try await suspending.value #expect(events.values.filter { $0 == "checkpoint" }.count == 2) #expect(events.values.last == "state:suspended") #expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:80x24"]) await client.close() } @Test(arguments: ["resume", "retry", "checkpoint", "beforeResume", "prepareRetry"]) func closeImmediatelyReleasesLifecycleCallersAndFencesLateEngineCompletion(_ gate: String) async throws { let events = EngineEvents() let engine = TestEngine(events: events, gates: [gate]) let client = client(engine, events: events) try await connect(client) if gate == "resume" || gate == "beforeResume" { _ = try await client.suspend() } let baseline = events.values.filter { $0 == "lifecycle:queued" }.count let operation = Task { if gate == "checkpoint" { _ = try await client.suspend() } else if gate == "retry" || gate == "prepareRetry" { try await client.retryNow() } else { try await client.resume() } } await events.wait(for: "gated:\(gate)") let queued = Task { if gate == "checkpoint" { try await client.resume() } else { _ = try await client.suspend() } } await events.wait(for: "lifecycle:queued", count: baseline + 2) await client.close() await #expect(throws: CancellationError.self) { try await operation.value } await #expect(throws: CancellationError.self) { try await queued.value } let closedEvents = events.values await engine.release(gate) await events.wait(for: "released:\(gate)") await events.emit(.output(Data("late".utf8))) await events.emit(.connected) await events.emit(.activity) await #expect(throws: CancellationError.self) { try await client.resize(columns: 200, rows: 80) } await #expect(throws: CancellationError.self) { try await client.write(Data("late".utf8)) } #expect(events.values == closedEvents + ["released:\(gate)"]) #expect(events.values.filter { $0 == "close" }.count == 1) } @Test func closeReleasesARetryWaitingForAnOutstandingWrite() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) let writing = Task { try await client.write(Data("hold".utf8)) } await events.wait(for: "write:hold") let retrying = Task { try await client.retryNow() } await events.wait(for: "lifecycle:queued") await client.close() await #expect(throws: CancellationError.self) { try await retrying.value } try await writing.value #expect(!events.values.contains("prepareRetry")) #expect(!events.values.contains("retry")) #expect(events.values.last == "state:closed") } @Test func initialHealthyPathDoesNotReconnectButSubsequentHealthyHandoversDo() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) await client.networkPathChanged(available: true) #expect(!events.values.contains("networkAvailable")) #expect(!events.values.contains("state:recovering")) await client.networkPathChanged(available: true) await client.networkPathChanged(available: true) #expect(events.values.filter { $0 == "networkAvailable" }.count == 2) await client.close() await client.networkPathChanged(available: true) #expect(events.values.filter { $0 == "networkAvailable" }.count == 2) } @Test func initialUnavailablePathPresentsRecoveryAndRestorationNotifiesTheEngine() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) await client.networkPathChanged(available: false) #expect(events.values.last == "state:recovering") #expect(!events.values.contains("networkAvailable")) await client.networkPathChanged(available: true) #expect(events.values.filter { $0 == "networkAvailable" }.count == 1) await client.close() } @Test func frozenNetworkObservationsAreRememberedWithoutContactingTheEngine() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) _ = try await client.suspend() await client.networkPathChanged(available: true) await client.networkPathChanged(available: false) #expect(!events.values.contains("networkAvailable")) #expect(events.values.last == "state:suspended") try await client.resume() await client.networkPathChanged(available: true) #expect(events.values.filter { $0 == "networkAvailable" }.count == 1) await client.close() } @Test func failedStartRollsBackAndClosesTheSelectedEngine() async throws { let events = EngineEvents() let engine = TestEngine(events: events, failsStart: true) let client = client(engine, events: events) await #expect(throws: TerminalTransportError.self) { try await connect(client) } #expect(Array(events.values.suffix(3)) == ["rollback", "close", "state:closed"]) await client.close() #expect(events.values.filter { $0 == "close" }.count == 1) } @Test func checkpointCapturesShellEventsEmittedDuringCheckpointPreparation() async throws { let events = EngineEvents() let engine = TestEngine(events: events, emitsShellEventDuringCheckpoint: true) let client = client(engine, events: events) try await connect(client) _ = try await client.suspend() var filter = try #require(await engine.capturedShell?.commandFilter) let update = filter.receive(Data("hello\u{7}visible".utf8)) #expect(update.events == [.commandStarted("echo hello")]) #expect(update.visibleOutput == Data("visible".utf8)) await client.close() } @Test func remoteEndClosesOnceAndRejectsLateEngineEvents() async throws { let events = EngineEvents() let engine = TestEngine(events: events) let client = client(engine, events: events) try await connect(client) await events.emit(.ended) let ended = events.values await events.emit(.output(Data("late".utf8))) await events.emit(.connected) await events.emit(.ended) await client.close() #expect(events.values == ended) #expect(events.values.filter { $0 == "state:ended" }.count == 1) #expect(events.values.filter { $0 == "close" }.count == 1) await #expect(throws: CancellationError.self) { try await client.write(Data("late".utf8)) } } @Test func incompatibleEnvelopeNeverConstructsAnEngine() async throws { for invalid in [ TerminalTransportCheckpoint(transport: .eternalTerminal, payload: Data()), { var value = checkpoint value.version = 99 return value }(), ] { let events = EngineEvents() let engine = TestEngine(events: events) let client = ResumableTerminalClient( transport: .mosh, checkpoint: invalid, output: { _ in }, stateChanged: { _ in }, shellIntegration: { _ in }, registry: registry(engine, events: events)) await #expect(throws: TerminalTransportError.self) { try await connect(client) } #expect(events.values.isEmpty) } } @Test func legacyMoshPayloadRemainsTheUnwrappedVendorSnapshot() throws { let literal = Data( """ {"endpoint":{"host":"old.example","port":60001,"keyBase64_22":"AAAAAAAAAAAAAAAAAAAAAA"}, "transportState":"bGVnYWN5LXN0YXRl","createdAtMs":1700000000123,"schemaVersion":2} """.utf8) let snapshot = try MoshTerminalEngine.decodeCheckpoint(literal) #expect(snapshot.endpoint.host == "old.example") #expect(snapshot.endpoint.port == 60001) #expect(snapshot.transportState == Data("legacy-state".utf8)) #expect(try json(JSONEncoder().encode(snapshot)) == json(literal)) } @Test func legacyEternalPayloadRetainsFlatKeysAndRestoredShellInput() throws { let literal = Data( """ {"clientID":"legacy-client","passkey":"AAECAwQ=", "checkpoint":{"reader":{"nonce":"AQID","sequenceNumber":17}, "writer":{"nonce":"BAUG","sequenceNumber":19,"serializedBackupPackets":["YWJj"]}}, "integratedShell":{"shell":"zsh","homeDirectory":"/home/legacy"}, "pendingIntegrationCommand":"cGVuZGluZw=="} """.utf8) let payload = try EternalTerminalEngine.decodeCheckpoint(literal) #expect(payload.clientID == "legacy-client") #expect(payload.passkey == Data([0, 1, 2, 3, 4])) #expect(payload.shellState.integratedShell?.homeDirectory == "/home/legacy") #expect(payload.shellState.pendingIntegrationCommand == Data("pending".utf8)) #expect(try json(JSONEncoder().encode(payload)) == json(literal)) } private var options: HostTerminalTransports { var options = HostTerminalTransports() options.moshEnabled = true return options } private var request: SSHConnectionRequest { .init( configuration: .init( hostID: UUID(), host: "127.0.0.1", port: 1, username: "unused", credentials: [], expectedHostKeys: [])) } private var checkpoint: TerminalTransportCheckpoint { .init(transport: .mosh, payload: Data("old-state".utf8)) } private func registry(_ engine: TestEngine, events: EngineEvents) -> ResumableTerminalEngineRegistry { .init(factories: [ .mosh: { events.install($0) events.record("factory") return engine } ]) } private func client(_ engine: TestEngine, events: EngineEvents) -> ResumableTerminalClient { .init( transport: .mosh, checkpoint: checkpoint, output: { events.record("output:\(String(decoding: $0, as: UTF8.self))") }, stateChanged: { events.record("state:\($0)") }, shellIntegration: { _ in }, registry: registry(engine, events: events), pathMonitor: nil, lifecycleRequestEnqueued: { events.record("lifecycle:queued") }, routeChanged: { events.record("route:\($0.revision):\($0.stage)") }) } private func connect(_ client: ResumableTerminalClient) async throws { try await client.connect( request: request, options: options, environment: [], shellIntegrationEnabled: true, columns: 80, rows: 24, route: { _ in throw CancellationError() }, progress: nil, startupPreparation: { _ in Issue.record("Restoring a checkpoint must not prepare a new shell") return nil }) } private func json(_ data: Data) throws -> NSDictionary { try #require(JSONSerialization.jsonObject(with: data) as? NSDictionary) } private func expectUnreachable(_ task: Task) async { do { try await task.value Issue.record("Expected the injected engine failure") } catch TerminalTransportError.unreachable { } catch { Issue.record("Unexpected lifecycle error: \(error)") } } private func eventually(_ predicate: () async -> Bool) async -> Bool { for _ in 0..<200 { if await predicate() { return true } try? await Task.sleep(for: .milliseconds(10)) } return await predicate() } } private nonisolated final class EngineEvents: @unchecked Sendable { private struct Waiter { let value: String let count: Int let continuation: CheckedContinuation } private let lock = NSLock() private var recorded: [String] = [] private var waiters: [Waiter] = [] private var receive: ResumableTerminalEngine.EventHandler? var values: [String] { lock.withLock { recorded } } func record(_ value: String) { let ready = lock.withLock { recorded.append(value) let count = recorded.filter { $0 == value }.count let ready = waiters.filter { $0.value == value && $0.count <= count } waiters.removeAll { $0.value == value && $0.count <= count } return ready } for waiter in ready { waiter.continuation.resume() } } func wait(for value: String, count: Int = 1) async { await withCheckedContinuation { continuation in let ready = lock.withLock { if recorded.filter({ $0 == value }).count >= count { return true } waiters.append(.init(value: value, count: count, continuation: continuation)) return false } if ready { continuation.resume() } } } func install(_ receive: @escaping ResumableTerminalEngine.EventHandler) { lock.withLock { self.receive = receive } } func emit(_ event: ResumableTerminalEngineEvent) async { let receive = lock.withLock { self.receive } await receive?(event) } } private actor TestEngine: ResumableTerminalEngine { let events: EngineEvents let failsStart: Bool let emitsShellEventDuringCheckpoint: Bool let restoresIntegration: Bool let reportsConnectedOnStart: Bool private(set) var preparedPayload: Data? private(set) var appliedGrid: String? private(set) var permitsAgentForwarding = true private(set) var capturedShell: ResumableTerminalShellState? private var writing: CheckedContinuation? private var gates: Set private var failures: Set private var blocked: [String: CheckedContinuation] = [:] init( events: EngineEvents, failsStart: Bool = false, emitsShellEventDuringCheckpoint: Bool = false, gates: Set = [], restoresIntegration: Bool = false, failures: Set = [], reportsConnectedOnStart: Bool = true ) { self.events = events self.failsStart = failsStart self.emitsShellEventDuringCheckpoint = emitsShellEventDuringCheckpoint self.gates = gates self.restoresIntegration = restoresIntegration self.failures = failures self.reportsConnectedOnStart = reportsConnectedOnStart } func prepare(_ context: ResumableTerminalEngineContext) async throws -> ResumableTerminalEnginePreparation { preparedPayload = context.checkpoint permitsAgentForwarding = context.request.permitsAgentForwarding events.record("prepare") await waitAtGate("prepare") return .init( shell: .init( integration: restoresIntegration ? RemoteShellIntegrationBootstrap(token: "RESTORE") : nil, commandFilter: emitsShellEventDuringCheckpoint ? RemoteCommandEventFilter(token: "capture") : nil)) } func start(columns: UInt16, rows: UInt16) async throws { events.record("start:\(columns)x\(rows)") if failsStart { throw TerminalTransportError.unreachable } await waitAtGate("start") appliedGrid = "\(columns)x\(rows)" if reportsConnectedOnStart { await events.emit(.connected) } await events.emit(.output(Data("ready".utf8))) } func write(_ data: Data) async throws { events.record("write:\(String(decoding: data, as: UTF8.self))") await waitAtGate("write") if data == Data("hold".utf8) { await withCheckedContinuation { writing = $0 } } } func finishWrite() { writing?.resume() writing = nil } func resize(columns: UInt16, rows: UInt16) async throws { events.record("resize:\(columns)x\(rows)") await waitAtGate("resize") try failIfRequested("resize") appliedGrid = "\(columns)x\(rows)" } func checkpoint(shell: @Sendable () async -> ResumableTerminalShellState) async throws -> Data { events.record("checkpoint") await waitAtGate("checkpoint") try failIfRequested("checkpoint") if emitsShellEventDuringCheckpoint { await events.emit(.output(Data("\u{1b}]777;TermindCommand=capture;echo%20".utf8))) } capturedShell = await shell() return Data("saved-state".utf8) } func beforeResume() async throws { events.record("beforeResume") await waitAtGate("beforeResume") try failIfRequested("beforeResume") } func resume() async throws { events.record("resume") await waitAtGate("resume") try failIfRequested("resume") } func prepareRetry() async { events.record("prepareRetry") await waitAtGate("prepareRetry") } func retry() async throws { events.record("retry") await waitAtGate("retry") try failIfRequested("retry") } func networkAvailable() async { events.record("networkAvailable") } private func waitAtGate(_ name: String) async { guard gates.contains(name) else { return } await withCheckedContinuation { blocked[name] = $0 events.record("gated:\(name)") } events.record("released:\(name)") } func release(_ name: String) { gates.remove(name) blocked.removeValue(forKey: name)?.resume() } private func failIfRequested(_ name: String) throws { if failures.remove(name) != nil { throw TerminalTransportError.unreachable } } func rollback(using bootstrap: SSHConnection) async { events.record("rollback") } func close() async { events.record("close") finishWrite() } }