fix(workspace): close sessions without waiting for remote cleanup
Keep mobile list snapshots and section identity stable during swipe closure. Move managed-session termination to app-owned background cleanup, transfer existing SSH connections, and allow one silent reconnect when needed. Preserve passive teardown behavior and prevent reopened workspaces from attaching to sessions being terminated. Cover connection ownership, cleanup cancellation, saved workspace closure, and list updates with regression tests.
This commit is contained in:
1 parent
0ac483d109
commit
a04db5f867
18 files changed
+949
-292
No files matched your search
@@ -0,0 +1,233 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct RemoteSessionTerminatorTests {
|
||||
@Test(arguments: [SessionPersistenceBackendID.tmux, .zellij, .zmx, .herdr])
|
||||
func transferredConnectionIsUsedWithoutOpeningAnother(backend: SessionPersistenceBackendID) async throws {
|
||||
let fixture = Fixture(backend: backend)
|
||||
let existing = Connection()
|
||||
let transfer = fixture.transfer(existing)
|
||||
let terminator = fixture.terminator { _ in
|
||||
Issue.record("An existing connection must be transferred, not replaced")
|
||||
throw TermindSSHError.disconnected
|
||||
}
|
||||
try await terminator.terminate(fixture.request(connection: transfer), hosts: fixture.hosts)
|
||||
await transfer.disconnect()
|
||||
#expect(await existing.commands == ["list", "terminate"])
|
||||
#expect(await existing.disconnects == 1)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func deadTransferredConnectionGetsOneSilentReplacement(timedOut: Bool) async throws {
|
||||
let fixture = Fixture()
|
||||
let old = Connection(failing: "list", timedOut: timedOut)
|
||||
let replacement = Connection()
|
||||
var opens = 0
|
||||
let terminator = fixture.terminator { request in
|
||||
opens += 1
|
||||
#expect(await old.disconnects == 1)
|
||||
#expect(request.interactionPolicy == .forbidden)
|
||||
#expect(!request.permitsAgentForwarding)
|
||||
#expect(request.configuration.beforeConnect == nil)
|
||||
return replacement
|
||||
}
|
||||
try await terminator.terminate(fixture.request(connection: fixture.transfer(old)), hosts: fixture.hosts)
|
||||
#expect(opens == 1)
|
||||
#expect(await old.commands == ["list"])
|
||||
#expect(await replacement.commands == ["list", "terminate"])
|
||||
#expect(await replacement.disconnects == 1)
|
||||
}
|
||||
|
||||
@Test func uncertainTerminationIsNeverRetried() async {
|
||||
let fixture = Fixture()
|
||||
let old = Connection(failing: "terminate")
|
||||
var opens = 0
|
||||
let terminator = fixture.terminator { _ in
|
||||
opens += 1
|
||||
return Connection()
|
||||
}
|
||||
await #expect(throws: (any Error).self) {
|
||||
try await terminator.terminate(fixture.request(connection: fixture.transfer(old)), hosts: fixture.hosts)
|
||||
}
|
||||
#expect(opens == 0)
|
||||
#expect(await old.commands == ["list", "terminate"])
|
||||
#expect(await old.disconnects == 1)
|
||||
}
|
||||
|
||||
@Test func identityMismatchNeverTerminatesOrReconnects() async {
|
||||
let fixture = Fixture()
|
||||
let old = Connection(mismatched: true)
|
||||
var opens = 0
|
||||
let terminator = fixture.terminator { _ in
|
||||
opens += 1
|
||||
return Connection()
|
||||
}
|
||||
await #expect(throws: RemoteSessionFailure.ownershipMismatch) {
|
||||
try await terminator.terminate(fixture.request(connection: fixture.transfer(old)), hosts: fixture.hosts)
|
||||
}
|
||||
#expect(opens == 0)
|
||||
#expect(await old.commands == ["list"])
|
||||
#expect(await old.disconnects == 1)
|
||||
}
|
||||
|
||||
@Test func disconnectedSessionAttemptsOnlyOneConnection() async {
|
||||
let fixture = Fixture()
|
||||
var opens = 0
|
||||
let terminator = fixture.terminator { request in
|
||||
opens += 1
|
||||
#expect(request.interactionPolicy == .forbidden)
|
||||
throw TermindSSHError.disconnected
|
||||
}
|
||||
await #expect(throws: (any Error).self) {
|
||||
try await terminator.terminate(fixture.request(), hosts: fixture.hosts)
|
||||
}
|
||||
#expect(opens == 1)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func missingCredentialsOrChangedUsernameCannotOpenAConnection(missingCredentials: Bool) async {
|
||||
let fixture = Fixture()
|
||||
var snapshot = fixture.snapshot
|
||||
snapshot.target.authentication = .init(
|
||||
username: missingCredentials ? "user" : "someone-else",
|
||||
permitsNone: !missingCredentials, hasEphemeralSecret: missingCredentials)
|
||||
let terminator = fixture.terminator { _ in
|
||||
Issue.record("An unverified identity must not open a cleanup connection")
|
||||
return Connection()
|
||||
}
|
||||
await #expect(throws: (any Error).self) {
|
||||
try await terminator.terminate(
|
||||
.init(reference: fixture.reference, snapshot: snapshot), hosts: fixture.hosts)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func cancellingBeforeExecutionStillReleasesTransferredOwnership() async {
|
||||
let fixture = Fixture()
|
||||
let old = Connection()
|
||||
let closer = RemoteSessionCloseCoordinator { _ in Issue.record("Cancelled cleanup must not execute") }
|
||||
closer.enqueue(fixture.request(connection: fixture.transfer(old)))
|
||||
closer.cancelAll()
|
||||
await closer.waitUntilIdle()
|
||||
#expect(await old.commands.isEmpty)
|
||||
#expect(await old.disconnects == 1)
|
||||
}
|
||||
|
||||
@Test func duplicateCloseReleasesBothConnectionsButTerminatesOnce() async {
|
||||
let fixture = Fixture()
|
||||
let first = Connection()
|
||||
let duplicate = Connection()
|
||||
let terminator = fixture.terminator { _ in
|
||||
Issue.record("Deduplication must keep the first transferred connection")
|
||||
throw TermindSSHError.disconnected
|
||||
}
|
||||
let closer = RemoteSessionCloseCoordinator {
|
||||
try await terminator.terminate($0, hosts: fixture.hosts)
|
||||
}
|
||||
let duplicateTransfer = fixture.transfer(duplicate)
|
||||
closer.enqueue(fixture.request(connection: fixture.transfer(first)))
|
||||
closer.enqueue(fixture.request(connection: duplicateTransfer))
|
||||
await closer.waitUntilIdle()
|
||||
await duplicateTransfer.disconnect()
|
||||
#expect(await first.commands == ["list", "terminate"])
|
||||
#expect(await duplicate.commands.isEmpty)
|
||||
#expect(await first.disconnects == 1)
|
||||
#expect(await duplicate.disconnects == 1)
|
||||
}
|
||||
|
||||
private struct Fixture {
|
||||
let reference: RemoteSessionReference
|
||||
let snapshot: SSHAdditionalConnectionSnapshot
|
||||
let hosts = HostLibraryModel()
|
||||
|
||||
init(backend: SessionPersistenceBackendID = .tmux) {
|
||||
let hostID = UUID()
|
||||
reference = .init(
|
||||
backend: backend, session: .init(id: "1", name: "Session", identity: Data([1])),
|
||||
hostID: hostID, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID())
|
||||
snapshot = .init(
|
||||
target: .init(
|
||||
id: hostID, name: "Host", host: "example.invalid", port: 22,
|
||||
authentication: .init(username: "user", permitsNone: true), expectedHostKeys: [], proxy: nil))
|
||||
}
|
||||
|
||||
func request(connection: RemoteSessionCloseConnection? = nil) -> RemoteSessionCloseRequest {
|
||||
.init(reference: reference, snapshot: snapshot, connection: connection)
|
||||
}
|
||||
|
||||
func transfer(_ connection: Connection) -> RemoteSessionCloseConnection {
|
||||
.init(
|
||||
prepare: { { try await connection.run($0) } },
|
||||
release: { await connection.disconnect() })
|
||||
}
|
||||
|
||||
func terminator(
|
||||
connect: @escaping (SSHConnectionRequest) async throws -> any SSHConnectionProtocol
|
||||
) -> RemoteSessionTerminator {
|
||||
let reference = reference
|
||||
return RemoteSessionTerminator(
|
||||
connect: connect,
|
||||
backends: .init([reference.backend: { Backend(reference: reference, execute: $0) }]))
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated struct Backend: RemoteSessionBackend {
|
||||
let reference: RemoteSessionReference
|
||||
let execute: RemoteSessionCommandExecutor
|
||||
var id: SessionPersistenceBackendID { reference.backend }
|
||||
var capabilities: RemoteSessionCapabilities { .init() }
|
||||
func sessions() async throws -> [RemoteSessionDescriptor] {
|
||||
let result = try await execute("list")
|
||||
return result.stdout == "mismatch" ? [] : [reference.session]
|
||||
}
|
||||
func matches(_ reference: RemoteSessionReference, session: RemoteSessionDescriptor) -> Bool {
|
||||
self.reference.hasSameIdentity(as: reference) && session == reference.session
|
||||
}
|
||||
func create(
|
||||
token: UUID, startupCommand: String?, shellIntegration: Bool, environment: [HostEnvironmentVariable]
|
||||
) async throws -> RemoteSessionDescriptor {
|
||||
Issue.record("Cleanup must never create a session")
|
||||
throw RemoteSessionFailure.unsupported
|
||||
}
|
||||
func launch(_ attachment: RemoteSessionReference, created: Bool) throws -> RemoteSessionLaunch {
|
||||
Issue.record("Cleanup must never attach a terminal")
|
||||
throw RemoteSessionFailure.unsupported
|
||||
}
|
||||
func terminate(_ attachment: RemoteSessionReference) async throws { _ = try await execute("terminate") }
|
||||
}
|
||||
|
||||
private actor Connection: SSHConnectionProtocol {
|
||||
let failing: String?
|
||||
let mismatched: Bool
|
||||
let timedOut: Bool
|
||||
var commands: [String] = []
|
||||
var disconnects = 0
|
||||
init(failing: String? = nil, mismatched: Bool = false, timedOut: Bool = false) {
|
||||
self.failing = failing
|
||||
self.mismatched = mismatched
|
||||
self.timedOut = timedOut
|
||||
}
|
||||
func connect(using configuration: SSHConnectionConfiguration) async throws {}
|
||||
func run(_ command: String) throws -> RemoteSessionCommandResult {
|
||||
commands.append(command)
|
||||
if command == failing {
|
||||
if timedOut { throw RemoteSessionFailure.timedOut }
|
||||
throw TermindSSHError.disconnected
|
||||
}
|
||||
return .init(stdout: mismatched ? "mismatch" : "", stderr: "", exitCode: 0)
|
||||
}
|
||||
func execute(
|
||||
_ command: String, workingDirectory: String?, stdin: SSHCommandInput?,
|
||||
timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler?
|
||||
) async throws -> SSHExecResult {
|
||||
let result = try run(command)
|
||||
return .init(
|
||||
stdout: result.stdout, stderr: "", exitCode: 0, stdoutTruncated: false, stderrTruncated: false)
|
||||
}
|
||||
func openSFTPChannel() async throws -> any SFTPChannelProtocol { throw TermindSSHError.disconnected }
|
||||
func disconnect() async { disconnects += 1 }
|
||||
}
|
||||
}
|
||||
@@ -416,42 +416,30 @@ struct SessionPersistenceTests {
|
||||
#expect(try await empty.sessions().isEmpty)
|
||||
}
|
||||
|
||||
@Test func closeFailureFallsBackToDisconnectAndQuitDoesNotTerminate() async throws {
|
||||
let backend = Backend()
|
||||
let coordinator = RemoteSessionCoordinator()
|
||||
let launch = try #require(
|
||||
try await coordinator.prepare(
|
||||
service: backend, hostID: UUID(), endpoint: "host:22", username: "u",
|
||||
startupCommand: nil, requiresNewSession: false, shellIntegration: false, onConnect: .createManaged))
|
||||
let close = RemoteSessionCloseCoordinator()
|
||||
#expect(
|
||||
await close.close(attachments: [launch.attachment], intent: .applicationTermination) { _ in
|
||||
Issue.record("Quit must preserve")
|
||||
})
|
||||
@Test func closeFailureIsBestEffortAndDoesNotRetry() async {
|
||||
var attempts = 0
|
||||
let allowed = await close.close(attachments: [launch.attachment]) { _ in
|
||||
let close = RemoteSessionCloseCoordinator { _ in
|
||||
attempts += 1
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
#expect(close.enqueue(closeRequest(reference())))
|
||||
#expect(attempts == 0)
|
||||
await close.waitUntilIdle()
|
||||
#expect(attempts == 1)
|
||||
#expect(allowed)
|
||||
#expect(!close.isClosing)
|
||||
#expect(coordinator.attachment == launch.attachment)
|
||||
}
|
||||
|
||||
@Test func explicitCloseEndsManagedSessionsWithoutAConfirmation() async {
|
||||
let close = RemoteSessionCloseCoordinator()
|
||||
@Test func explicitCloseQueuesOnlyManagedSessionsWithoutWaiting() async {
|
||||
let managed = reference()
|
||||
let external = RemoteSessionReference(
|
||||
backend: managed.backend, session: managed.session, hostID: managed.hostID,
|
||||
endpoint: managed.endpoint, username: managed.username, ownershipToken: nil)
|
||||
var ended: [RemoteSessionReference] = []
|
||||
|
||||
let allowed = await close.close(attachments: [managed, external]) { session in
|
||||
ended.append(session)
|
||||
}
|
||||
|
||||
#expect(allowed)
|
||||
let close = RemoteSessionCloseCoordinator { ended.append($0.reference) }
|
||||
#expect(close.enqueue(closeRequest(managed)))
|
||||
#expect(!close.enqueue(closeRequest(external)))
|
||||
#expect(ended.isEmpty)
|
||||
await close.waitUntilIdle()
|
||||
#expect(ended == [managed])
|
||||
#expect(!close.isClosing)
|
||||
}
|
||||
@@ -460,11 +448,12 @@ struct SessionPersistenceTests {
|
||||
TerminalTeardownIntent.applicationTermination, .suspension, .remoteExit, .detach,
|
||||
])
|
||||
func passiveTeardownPreservesManagedSessions(intent: TerminalTeardownIntent) async {
|
||||
let close = RemoteSessionCloseCoordinator()
|
||||
#expect(
|
||||
await close.close(attachments: [reference()], intent: intent) { _ in
|
||||
Issue.record("Passive teardown must not terminate a remote session")
|
||||
})
|
||||
let close = RemoteSessionCloseCoordinator { _ in
|
||||
Issue.record("Passive teardown must not terminate a remote session")
|
||||
}
|
||||
#expect(!close.enqueue(closeRequest(reference()), intent: intent))
|
||||
await close.waitUntilIdle()
|
||||
#expect(!close.isClosing)
|
||||
}
|
||||
|
||||
@Test func tmuxParsingKeepsNamesAsDataAndRejectsUnsupportedVersions() throws {
|
||||
@@ -552,44 +541,55 @@ struct SessionPersistenceTests {
|
||||
id: first.session.id, name: "Renamed", identity: first.session.identity, windows: 2, clients: 3)
|
||||
let second = reference()
|
||||
let third = reference()
|
||||
let close = RemoteSessionCloseCoordinator()
|
||||
var terminated: [RemoteSessionReference] = []
|
||||
var attempted: [RemoteSessionReference] = []
|
||||
let allowed = await close.close(attachments: [first, duplicate, second, third]) { reference in
|
||||
attempted.append(reference)
|
||||
if reference == second {
|
||||
throw RemoteSessionFailure.timedOut
|
||||
}
|
||||
terminated.append(reference)
|
||||
let close = RemoteSessionCloseCoordinator { request in
|
||||
attempted.append(request.reference)
|
||||
if request.reference == second { throw RemoteSessionFailure.timedOut }
|
||||
terminated.append(request.reference)
|
||||
}
|
||||
#expect(allowed)
|
||||
#expect(attempted == [first, second, third])
|
||||
#expect(terminated == [first, third])
|
||||
for reference in [first, duplicate, second, third] { close.enqueue(closeRequest(reference)) }
|
||||
await close.waitUntilIdle()
|
||||
#expect(Set(attempted) == Set([first, second, third]))
|
||||
#expect(attempted.count == 3)
|
||||
#expect(Set(terminated) == Set([first, third]))
|
||||
}
|
||||
|
||||
@Test func cancelledTerminationDoesNotTurnIntoPermissionToClose() async {
|
||||
let close = RemoteSessionCloseCoordinator()
|
||||
@Test func cancellationBeforeBackgroundCloseStartsDoesNotOpenConnections() async {
|
||||
var attempts = 0
|
||||
let allowed = await close.close(attachments: [reference(), reference()]) { _ in
|
||||
attempts += 1
|
||||
throw CancellationError()
|
||||
}
|
||||
#expect(!allowed)
|
||||
#expect(attempts == 1)
|
||||
let close = RemoteSessionCloseCoordinator { _ in attempts += 1 }
|
||||
close.enqueue(closeRequest(reference()))
|
||||
close.enqueue(closeRequest(reference()))
|
||||
close.cancelAll()
|
||||
await close.waitUntilIdle()
|
||||
#expect(attempts == 0)
|
||||
#expect(!close.isClosing)
|
||||
}
|
||||
|
||||
@Test func cancellationDuringBatchCloseDoesNotEndAdditionalSessions() async throws {
|
||||
let close = RemoteSessionCloseCoordinator()
|
||||
var ended = 0
|
||||
let task = Task {
|
||||
await close.close(attachments: [reference(), reference()]) { _ in
|
||||
ended += 1
|
||||
close.cancel()
|
||||
@Test func timeoutCancelsCleanupWithoutRetrying() async {
|
||||
var attempts = 0
|
||||
var cancelled = false
|
||||
let close = RemoteSessionCloseCoordinator(timeout: .milliseconds(30)) { _ in
|
||||
attempts += 1
|
||||
do { try await Task.sleep(for: .seconds(60)) } catch {
|
||||
cancelled = true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
#expect(await task.value == false)
|
||||
#expect(ended == 1)
|
||||
close.enqueue(closeRequest(reference()))
|
||||
await close.waitUntilIdle()
|
||||
#expect(attempts == 1)
|
||||
#expect(cancelled)
|
||||
#expect(!close.isClosing)
|
||||
}
|
||||
|
||||
private func closeRequest(_ reference: RemoteSessionReference) -> RemoteSessionCloseRequest {
|
||||
.init(
|
||||
reference: reference,
|
||||
snapshot: .init(
|
||||
target: .init(
|
||||
id: reference.hostID, name: "Host", host: "host", port: 22,
|
||||
authentication: .init(username: "u", permitsNone: true), expectedHostKeys: [], proxy: nil)))
|
||||
}
|
||||
|
||||
private func reference() -> RemoteSessionReference {
|
||||
|
||||
@@ -1,44 +1,111 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import TermindDomain
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class RemoteSessionCloseCoordinator {
|
||||
public private(set) var isClosing = false
|
||||
private var generation = UUID()
|
||||
public nonisolated struct RemoteSessionCloseRequest: Sendable {
|
||||
public let reference: RemoteSessionReference
|
||||
public let snapshot: SSHAdditionalConnectionSnapshot
|
||||
public let connection: RemoteSessionCloseConnection?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func cancel() {
|
||||
generation = UUID()
|
||||
}
|
||||
|
||||
/// Ending an owned session is best-effort; failures fall back to disconnecting
|
||||
/// without discarding its recovery identity or interrupting the close with UI.
|
||||
public func close(
|
||||
attachments: [RemoteSessionReference], intent: TerminalTeardownIntent = .explicitClose,
|
||||
terminate: @MainActor (RemoteSessionReference) async throws -> Void
|
||||
) async -> Bool {
|
||||
guard intent.endsManagedSession else { return true }
|
||||
let managed = attachments.filter(\.isManaged).reduce(into: [RemoteSessionReference]()) { unique, reference in
|
||||
if !unique.contains(where: { $0.hasSameIdentity(as: reference) }) { unique.append(reference) }
|
||||
}
|
||||
guard !managed.isEmpty else { return true }
|
||||
guard !isClosing else { return false }
|
||||
let attempt = generation
|
||||
isClosing = true
|
||||
defer { isClosing = false }
|
||||
for attachment in managed {
|
||||
guard generation == attempt, !Task.isCancelled else { return false }
|
||||
do {
|
||||
try await terminate(attachment)
|
||||
} catch is CancellationError {
|
||||
return false
|
||||
} catch {
|
||||
// Ownership checks still apply. Never force termination or mark a failed session ended.
|
||||
}
|
||||
}
|
||||
return generation == attempt && !Task.isCancelled
|
||||
public init(
|
||||
reference: RemoteSessionReference, snapshot: SSHAdditionalConnectionSnapshot,
|
||||
connection: RemoteSessionCloseConnection? = nil
|
||||
) {
|
||||
self.reference = reference
|
||||
self.snapshot = snapshot
|
||||
self.connection = connection
|
||||
}
|
||||
}
|
||||
|
||||
/// A transferred connection has exactly one cleanup owner, even after cancellation or deduplication.
|
||||
public actor RemoteSessionCloseConnection {
|
||||
private let prepare: @Sendable () async throws -> RemoteSessionCommandExecutor
|
||||
private let release: @Sendable () async -> Void
|
||||
private var released = false
|
||||
|
||||
public init(
|
||||
prepare: @escaping @Sendable () async throws -> RemoteSessionCommandExecutor,
|
||||
release: @escaping @Sendable () async -> Void
|
||||
) {
|
||||
self.prepare = prepare
|
||||
self.release = release
|
||||
}
|
||||
|
||||
public func executor() async throws -> RemoteSessionCommandExecutor {
|
||||
guard !released else { throw TermindSSHError.disconnected }
|
||||
return try await prepare()
|
||||
}
|
||||
|
||||
public func disconnect() async {
|
||||
guard !released else { return }
|
||||
released = true
|
||||
await release()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns one-shot cleanup independently of the workspace and its terminal transport.
|
||||
@MainActor
|
||||
public final class RemoteSessionCloseCoordinator {
|
||||
public typealias Terminate = @MainActor (RemoteSessionCloseRequest) async throws -> Void
|
||||
|
||||
private struct Pending {
|
||||
let reference: RemoteSessionReference
|
||||
let task: Task<Void, Never>
|
||||
}
|
||||
public var isClosing: Bool { !pending.isEmpty }
|
||||
|
||||
private var pending: [UUID: Pending] = [:]
|
||||
private let terminate: Terminate
|
||||
private let timeout: Duration
|
||||
|
||||
public init(timeout: Duration = .seconds(30), terminate: @escaping Terminate) {
|
||||
self.timeout = timeout
|
||||
self.terminate = terminate
|
||||
}
|
||||
|
||||
deinit {
|
||||
for entry in pending.values { entry.task.cancel() }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func enqueue(
|
||||
_ request: RemoteSessionCloseRequest, intent: TerminalTeardownIntent = .explicitClose
|
||||
) -> Bool {
|
||||
guard intent.endsManagedSession, request.reference.isManaged else {
|
||||
if let connection = request.connection { Task { await connection.disconnect() } }
|
||||
return false
|
||||
}
|
||||
if pending.values.contains(where: { $0.reference.hasSameIdentity(as: request.reference) }) {
|
||||
if let connection = request.connection { Task { await connection.disconnect() } }
|
||||
return true
|
||||
}
|
||||
let id = UUID()
|
||||
let task = Task { [weak self, terminate, timeout] in
|
||||
let operation = Task {
|
||||
try Task.checkCancellation()
|
||||
try await terminate(request)
|
||||
}
|
||||
let deadline = Task {
|
||||
try await Task.sleep(for: timeout)
|
||||
operation.cancel()
|
||||
}
|
||||
defer { deadline.cancel() }
|
||||
await withTaskCancellationHandler {
|
||||
_ = try? await operation.value
|
||||
} onCancel: {
|
||||
operation.cancel()
|
||||
}
|
||||
await request.connection?.disconnect()
|
||||
self?.pending[id] = nil
|
||||
}
|
||||
pending[id] = Pending(reference: request.reference, task: task)
|
||||
return true
|
||||
}
|
||||
|
||||
public func cancelAll() {
|
||||
for entry in pending.values { entry.task.cancel() }
|
||||
}
|
||||
|
||||
public func waitUntilIdle() async {
|
||||
for entry in Array(pending.values) { await entry.task.value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
@MainActor
|
||||
public struct RemoteSessionTerminator {
|
||||
private var connect: (SSHConnectionRequest) async throws -> any SSHConnectionProtocol
|
||||
private var backends: RemoteSessionBackendRegistry
|
||||
|
||||
public init(
|
||||
connect: @escaping (SSHConnectionRequest) async throws -> any SSHConnectionProtocol,
|
||||
backends: RemoteSessionBackendRegistry = .live
|
||||
) {
|
||||
self.connect = connect
|
||||
self.backends = backends
|
||||
}
|
||||
|
||||
public static func live(coordinator: RemoteConnectionCoordinator) -> Self {
|
||||
Self(connect: { try await coordinator.connectConnection(using: $0) })
|
||||
}
|
||||
|
||||
public func terminate(_ close: RemoteSessionCloseRequest, hosts: any RemoteSessionHostStore) async throws {
|
||||
let reference = close.reference
|
||||
guard reference.isManaged else { return }
|
||||
if let existing = close.connection {
|
||||
let prepared: (any RemoteSessionBackend)?
|
||||
do {
|
||||
let backend = try backends.backend(reference.backend, execute: await existing.executor())
|
||||
try await validate(backend, reference: reference)
|
||||
prepared = backend
|
||||
} catch {
|
||||
await existing.disconnect()
|
||||
// Only connection/preflight failure may fall back. Never retry a termination command.
|
||||
try Task.checkCancellation()
|
||||
if let failure = error as? RemoteSessionFailure, failure != .timedOut { throw error }
|
||||
prepared = nil
|
||||
}
|
||||
if let prepared {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
try await prepared.terminate(reference)
|
||||
await existing.disconnect()
|
||||
return
|
||||
} catch {
|
||||
await existing.disconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
var request = try hosts.additionalConnectionRequest(from: close.snapshot)
|
||||
defer { request.configuration.clearSecrets() }
|
||||
// Explicit close must not wake a sleeping host, forward an agent, or request user interaction.
|
||||
request.configuration.beforeConnect = nil
|
||||
request.permitsAgentForwarding = false
|
||||
guard request.configuration.hostID == reference.hostID,
|
||||
"\(request.configuration.host):\(request.configuration.port)" == reference.endpoint,
|
||||
request.configuration.username == reference.username
|
||||
else { throw RemoteSessionFailure.ownershipMismatch }
|
||||
try Task.checkCancellation()
|
||||
let connection = try await connect(request)
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
let backend = try backends.backend(reference.backend) { command in
|
||||
let result = try await connection.execute(
|
||||
command, workingDirectory: nil, stdin: nil, timeout: 5,
|
||||
outputLimit: 1024 * 1024, onOutput: nil)
|
||||
return RemoteSessionCommandResult(
|
||||
stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode,
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated)
|
||||
}
|
||||
try await validate(backend, reference: reference)
|
||||
// Backends recheck identity and the ownership token in the remote termination command.
|
||||
try await backend.terminate(reference)
|
||||
await connection.disconnect()
|
||||
} catch {
|
||||
await connection.disconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func validate(_ backend: any RemoteSessionBackend, reference: RemoteSessionReference) async throws {
|
||||
try Task.checkCancellation()
|
||||
try backend.validate(reference)
|
||||
let sessions = try await backend.sessions()
|
||||
try Task.checkCancellation()
|
||||
guard sessions.contains(where: { backend.matches(reference, session: $0) }) else {
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteSessionCloseConnection {
|
||||
public init(channel: SSHPTYChannel) {
|
||||
self.init(
|
||||
prepare: {
|
||||
try await channel.releaseTerminalForControl()
|
||||
return try await channel.sessionCommandExecutor()
|
||||
},
|
||||
release: { await channel.disconnect() })
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ struct MobileAppDependencies {
|
||||
var diagnosis = DiagnosisController()
|
||||
var logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared
|
||||
var connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator()
|
||||
var persistentSessionTerminator = RemoteSessionTerminator.live(coordinator: RemoteConnectionCoordinator())
|
||||
var container: ModelContainer? = nil
|
||||
var liveActivities: MobileLiveActivityController? = nil
|
||||
var terminalRecoveryStore: TerminalRecoveryStore = .shared
|
||||
@@ -45,6 +46,7 @@ struct MobileAppDependencies {
|
||||
diagnosis: DiagnosisController(defaults: .standard, reporter: FirebaseDiagnosticDataReporter.live()),
|
||||
logging: logging,
|
||||
connectionCoordinator: coordinator,
|
||||
persistentSessionTerminator: .live(coordinator: coordinator),
|
||||
terminalEffects: .live,
|
||||
makeForwardings: {
|
||||
PortForwardingController(
|
||||
|
||||
@@ -139,6 +139,7 @@ final class MobileAppModel {
|
||||
private let snippetTimestampFallback: Date
|
||||
private let background: any MobileBackgroundTaskManaging
|
||||
let workspaceConnectionServices: MobileWorkspaceConnectionServices
|
||||
let persistentSessionCloser: RemoteSessionCloseCoordinator
|
||||
let recentlyClosedStore = RecentlyClosedStore<RecentlyClosedRecord<MobileWorkspaceDescriptor>>(
|
||||
defaults: nil, key: "mobile.recentlyClosed.v2")
|
||||
private var orphanedSessionIDs: Set<String> = []
|
||||
@@ -298,6 +299,10 @@ final class MobileAppModel {
|
||||
persistence: persistence, secrets: secrets,
|
||||
localDefaults: memoryOnly ? nil : settingsDefaults
|
||||
)
|
||||
let sessionTerminator = dependencies.persistentSessionTerminator
|
||||
persistentSessionCloser = RemoteSessionCloseCoordinator { [library] request in
|
||||
try await sessionTerminator.terminate(request, hosts: library)
|
||||
}
|
||||
let tailscaleDevices = devices
|
||||
let tailscaleAccess = pro
|
||||
library.hostAccess.entitlement = pro.status
|
||||
@@ -712,6 +717,7 @@ final class MobileAppModel {
|
||||
assistant.suspendRecording()
|
||||
background.begin { [weak self] in
|
||||
guard let self else { return }
|
||||
persistentSessionCloser.cancelAll()
|
||||
for workspace in workspaces { workspace.suspendForBackgroundExpiration() }
|
||||
forwardings.stopAll()
|
||||
transfers.pauseAll()
|
||||
@@ -761,7 +767,6 @@ final class MobileAppModel {
|
||||
func discardWorkspaceSessions(_ ids: Set<String>) {
|
||||
guard !ids.isEmpty else { return }
|
||||
// Native dismissal detaches clients; only explicit workspace closure ends owned remote work.
|
||||
persistentClose.cancel()
|
||||
discardedSessionIDs.formUnion(ids)
|
||||
orphanedSessionIDs.subtract(ids)
|
||||
for id in Array(sceneSessionIDs.keys) where ids.contains(sceneSessionIDs[id] ?? "") { removeScene(id) }
|
||||
@@ -858,36 +863,19 @@ final class MobileAppModel {
|
||||
return available.first(where: isNotificationSceneActive) ?? available.first
|
||||
}
|
||||
|
||||
let persistentClose = RemoteSessionCloseCoordinator()
|
||||
|
||||
func authorizePersistentClose(_ targets: [MobileWorkspace]) async -> Bool {
|
||||
let owners = Dictionary(uniqueKeysWithValues: targets.map { ($0.id, workspaceOwner($0.id)) })
|
||||
let ownerIDs = Set(targets.compactMap { workspaceOwner($0.id) })
|
||||
let snapshot = workspaceSessions.filter { ownerIDs.contains($0.id) }.map { $0.orderedWorkspaceItems.map(\.id) }
|
||||
let allowed = await persistentClose.close(attachments: targets.compactMap { $0.sessionPersistence.attachment })
|
||||
{
|
||||
reference in
|
||||
guard let workspace = targets.first(where: { $0.sessionPersistence.attachment == reference }),
|
||||
self.containsWorkspace(workspace.id), self.workspaceOwner(workspace.id) == owners[workspace.id]
|
||||
else { throw CancellationError() }
|
||||
try await workspace.terminatePersistentSession(reference) {
|
||||
self.containsWorkspace(workspace.id) && self.workspaceOwner(workspace.id) == owners[workspace.id]
|
||||
func closePersistentSessions(_ targets: [MobileWorkspaceItem]) -> Set<UUID> {
|
||||
let requests = targets.compactMap { item -> (UUID, RemoteSessionCloseRequest)? in
|
||||
if let workspace = item.workspace {
|
||||
return workspace.takePersistentSessionCloseRequest().map { (item.id, $0) }
|
||||
}
|
||||
for workspace in targets
|
||||
where workspace.sessionPersistence.attachment?.hasSameIdentity(as: reference) == true {
|
||||
workspace.sessionPersistence.markEnded()
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
&& snapshot
|
||||
== workspaceSessions.filter { ownerIDs.contains($0.id) }.map { $0.orderedWorkspaceItems.map(\.id) }
|
||||
}
|
||||
|
||||
func deferPersistentClose(_ targets: [MobileWorkspace], action: @escaping @MainActor () -> Void) -> Bool {
|
||||
guard targets.contains(where: { $0.sessionPersistence.attachment?.isManaged == true }) else { return false }
|
||||
guard !persistentClose.isClosing else { return true }
|
||||
Task { if await authorizePersistentClose(targets) { action() } }
|
||||
return true
|
||||
guard let reference = item.descriptor.remoteSession, reference.isManaged,
|
||||
let host = item.host, host.id == reference.hostID,
|
||||
"\(host.host):\(host.sshPort)" == reference.endpoint,
|
||||
let snapshot = try? library.resumableControlSnapshot(for: host, username: reference.username)
|
||||
else { return nil }
|
||||
return (item.id, RemoteSessionCloseRequest(reference: reference, snapshot: snapshot))
|
||||
}.sorted { $0.1.connection != nil && $1.1.connection == nil }
|
||||
return Set(requests.compactMap { persistentSessionCloser.enqueue($0.1) ? $0.0 : nil })
|
||||
}
|
||||
|
||||
func workspaceWillOpen(_ workspace: MobileWorkspace, close: @escaping (UUID) -> Void) {
|
||||
|
||||
@@ -81,7 +81,6 @@ final class MobileWorkspaceSessionModel {
|
||||
}
|
||||
|
||||
func releaseForRecovery() {
|
||||
app.persistentClose.cancel()
|
||||
manuallyUnreadIDs.removeAll()
|
||||
workspaces.removeAll()
|
||||
savedWorkspaceDescriptors.removeAll()
|
||||
@@ -100,7 +99,6 @@ final class MobileWorkspaceSessionModel {
|
||||
|
||||
func discard() {
|
||||
guard !isDiscarded else { return }
|
||||
app.persistentClose.cancel()
|
||||
let states = orderedWorkspaceItems.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) }
|
||||
app.rememberClosedWorkspaces(states, isWindow: true)
|
||||
closeAll(rememberForReopen: false, authorized: true)
|
||||
@@ -295,19 +293,11 @@ final class MobileWorkspaceSessionModel {
|
||||
}
|
||||
|
||||
func close(_ id: UUID, rememberForReopen: Bool = true, authorized: Bool = false) {
|
||||
if !authorized,
|
||||
app.deferPersistentClose(
|
||||
workspaces.filter { $0.id == id },
|
||||
action: { [weak self] in
|
||||
self?.close(id, rememberForReopen: rememberForReopen, authorized: true)
|
||||
})
|
||||
{
|
||||
return
|
||||
}
|
||||
manuallyUnreadIDs.remove(id)
|
||||
let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id })
|
||||
guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return }
|
||||
if rememberForReopen { rememberClosedWorkspaces([id]) }
|
||||
let ending = authorized ? [] : app.closePersistentSessions(orderedWorkspaceItems.filter { $0.id == id })
|
||||
if rememberForReopen { rememberClosedWorkspaces([id], endingRemoteSessions: ending) }
|
||||
app.workspaceDidClose(id)
|
||||
if let workspace = workspaces.first(where: { $0.id == id }) {
|
||||
workspace.didChangeState = nil
|
||||
@@ -324,20 +314,11 @@ final class MobileWorkspaceSessionModel {
|
||||
}
|
||||
|
||||
func closeOtherWorkspaces(than id: MobileWorkspace.ID, authorized: Bool = false) {
|
||||
if !authorized,
|
||||
app.deferPersistentClose(
|
||||
workspaces.filter { $0.id != id },
|
||||
action: { [weak self] in
|
||||
self?.closeOtherWorkspaces(than: id, authorized: true)
|
||||
})
|
||||
{
|
||||
return
|
||||
}
|
||||
let knownIDs = Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id))
|
||||
guard knownIDs.contains(id), knownIDs.count > 1 else { return }
|
||||
rememberClosedWorkspaces(knownIDs.subtracting([id]))
|
||||
|
||||
let closing = workspaces.filter { $0.id != id }
|
||||
let ending = authorized ? [] : app.closePersistentSessions(orderedWorkspaceItems.filter { $0.id != id })
|
||||
rememberClosedWorkspaces(knownIDs.subtracting([id]), endingRemoteSessions: ending)
|
||||
for closedID in knownIDs where closedID != id { app.workspaceDidClose(closedID) }
|
||||
manuallyUnreadIDs = manuallyUnreadIDs.intersection([id])
|
||||
workspaces.removeAll { $0.id != id }
|
||||
@@ -351,19 +332,11 @@ final class MobileWorkspaceSessionModel {
|
||||
}
|
||||
|
||||
func closeAll(rememberForReopen: Bool = true, authorized: Bool = false) {
|
||||
if !authorized,
|
||||
app.deferPersistentClose(
|
||||
workspaces,
|
||||
action: { [weak self] in
|
||||
self?.closeAll(rememberForReopen: rememberForReopen, authorized: true)
|
||||
})
|
||||
{
|
||||
return
|
||||
}
|
||||
manuallyUnreadIDs.removeAll()
|
||||
guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return }
|
||||
if rememberForReopen { rememberClosedWorkspaces(Set(workspaceDisplayOrder)) }
|
||||
let closing = workspaces
|
||||
let ending = authorized ? [] : app.closePersistentSessions(orderedWorkspaceItems)
|
||||
if rememberForReopen { rememberClosedWorkspaces(Set(workspaceDisplayOrder), endingRemoteSessions: ending) }
|
||||
for id in Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)) {
|
||||
app.workspaceDidClose(id)
|
||||
}
|
||||
@@ -377,9 +350,12 @@ final class MobileWorkspaceSessionModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func rememberClosedWorkspaces(_ ids: Set<UUID>) {
|
||||
private func rememberClosedWorkspaces(_ ids: Set<UUID>, endingRemoteSessions: Set<UUID> = []) {
|
||||
for item in orderedWorkspaceItems where ids.contains(item.id) {
|
||||
app.rememberClosedWorkspaces([item.workspace?.reopenState ?? .init(descriptor: item.descriptor)])
|
||||
var state = item.workspace?.reopenState ?? .init(descriptor: item.descriptor)
|
||||
// Reopen must not attach to a session whose explicit termination is already in flight.
|
||||
if endingRemoteSessions.contains(item.id) { state.descriptor.remoteSession = nil }
|
||||
app.rememberClosedWorkspaces([state])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ final class MobileWorkspace: Identifiable {
|
||||
let transfers: SFTPTransferCoordinator?
|
||||
private var transferContext: SFTPTransferContext?
|
||||
private var transferSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
private var persistentSessionSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
var resumeCheckpoint: TerminalTransportCheckpoint?
|
||||
var requiresTransportRecovery = false
|
||||
var terminalRecoveryStore: TerminalRecoveryStore = .shared
|
||||
@@ -1260,6 +1261,7 @@ final class MobileWorkspace: Identifiable {
|
||||
if transportState == .recovering || transportState == .suspended || transportState == .failed { return }
|
||||
let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config)
|
||||
transferSnapshot = additionalSnapshot
|
||||
persistentSessionSnapshot = additionalSnapshot
|
||||
if let sftp {
|
||||
transferContext = SFTPTransferContext(
|
||||
snapshot: additionalSnapshot, hostStore: hosts, channel: sftp)
|
||||
@@ -1719,29 +1721,27 @@ final class MobileWorkspace: Identifiable {
|
||||
didChangeState?(activitySession)
|
||||
}
|
||||
|
||||
func terminatePersistentSession(
|
||||
_ reference: RemoteSessionReference, isCurrentOwner: @MainActor () -> Bool = { true }
|
||||
) async throws {
|
||||
guard sessionPersistence.attachment == reference, let channel = ssh else {
|
||||
throw RemoteSessionFailure.sessionMissing
|
||||
func takePersistentSessionCloseRequest() -> RemoteSessionCloseRequest? {
|
||||
guard let reference = sessionPersistence.attachment, reference.isManaged else { return nil }
|
||||
let snapshot: SSHAdditionalConnectionSnapshot
|
||||
if let persistentSessionSnapshot {
|
||||
snapshot = persistentSessionSnapshot
|
||||
} else {
|
||||
guard let host = connectionHost(reference.hostID),
|
||||
let restored = try? hosts.resumableControlSnapshot(for: host, username: reference.username)
|
||||
else { return nil }
|
||||
snapshot = restored
|
||||
}
|
||||
try await channel.releaseTerminalForControl()
|
||||
connected = false
|
||||
resetCommandGeneratorShellState()
|
||||
status = String(localized: AppStrings.Connections.disconnected)
|
||||
let execute = try await channel.sessionCommandExecutor()
|
||||
let backend = try RemoteSessionBackendRegistry.live.backend(reference.backend, execute: execute)
|
||||
let sessions = try await backend.sessions()
|
||||
guard isCurrentOwner(), ssh === channel, sessionPersistence.attachment == reference else {
|
||||
throw CancellationError()
|
||||
}
|
||||
guard sessions.contains(where: { backend.matches(reference, session: $0) }) else {
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
try await backend.terminate(reference)
|
||||
if sessionPersistence.attachment == reference { sessionPersistence.markEnded() }
|
||||
connected = false
|
||||
status = String(localized: AppStrings.SessionPersistence.ended)
|
||||
guard snapshot.hostID == reference.hostID,
|
||||
"\(snapshot.target.host):\(snapshot.target.port)" == reference.endpoint
|
||||
else { return nil }
|
||||
// Resumable transports have no reusable SSH control connection; their control callback
|
||||
// can prompt for authentication, so only an ordinary SSH transport is transferred.
|
||||
let transferred = transport == .ssh ? ssh : nil
|
||||
if transferred != nil { ssh = nil }
|
||||
return RemoteSessionCloseRequest(
|
||||
reference: reference, snapshot: snapshot,
|
||||
connection: transferred.map { RemoteSessionCloseConnection(channel: $0) })
|
||||
}
|
||||
|
||||
private func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
|
||||
@@ -1878,7 +1878,7 @@ final class MobileWorkspace: Identifiable {
|
||||
|
||||
/// Connection intent only: closed runtimes, entered credentials and one-shot commands are not retained.
|
||||
struct ReopenState {
|
||||
let descriptor: MobileWorkspaceDescriptor
|
||||
var descriptor: MobileWorkspaceDescriptor
|
||||
var transientHost: Host? = nil
|
||||
var connectionRoute: SSHConnectionRoute? = nil
|
||||
var usernameHint: String? = nil
|
||||
|
||||
@@ -36,17 +36,31 @@ struct MobileWorkspaceListView: View {
|
||||
static let historyLimit = 5
|
||||
|
||||
var body: some View {
|
||||
// List evaluates its content lazily. Keep section metadata and rows on the same snapshot.
|
||||
let items = scene.workspaceSession.orderedWorkspaceItems
|
||||
let recentHosts = items.isEmpty ? historyRows : []
|
||||
List {
|
||||
Section {
|
||||
Button(action: scene.openTransfers) {
|
||||
Label(AppStrings.SFTP.transfers.swiftUIKey, systemImage: "arrow.up.arrow.down")
|
||||
}
|
||||
}
|
||||
if !scene.workspaceSession.orderedWorkspaceItems.isEmpty || historyRows.isEmpty {
|
||||
Section(AppStrings.Navigation.workspaces.swiftUIKey) { workspaceRows }
|
||||
}
|
||||
if scene.workspaceSession.orderedWorkspaceItems.isEmpty, !historyRows.isEmpty {
|
||||
Section(AppStrings.Workspace.recents.swiftUIKey) { historyRowsContent }
|
||||
// Do not replace the section while UIKit is applying workspace row deletions.
|
||||
Section {
|
||||
if items.isEmpty && recentHosts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
AppStrings.Workspace.noWorkspaces.swiftUIKey, systemImage: "terminal",
|
||||
description: Text(AppStrings.Workspace.useConnectToStartAWorkspace)
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
workspaceRows(items)
|
||||
historyRowsContent(recentHosts)
|
||||
} header: {
|
||||
Text(
|
||||
recentHosts.isEmpty
|
||||
? AppStrings.Navigation.workspaces.swiftUIKey : AppStrings.Workspace.recents.swiftUIKey)
|
||||
}
|
||||
}
|
||||
.navigationTitle(AppStrings.Navigation.workspaces.swiftUIKey)
|
||||
@@ -108,16 +122,8 @@ struct MobileWorkspaceListView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var workspaceRows: some View {
|
||||
if scene.workspaceSession.orderedWorkspaceItems.isEmpty {
|
||||
ContentUnavailableView(
|
||||
AppStrings.Workspace.noWorkspaces.swiftUIKey, systemImage: "terminal",
|
||||
description: Text(AppStrings.Workspace.useConnectToStartAWorkspace)
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
ForEach(scene.workspaceSession.orderedWorkspaceItems) { item in
|
||||
private func workspaceRows(_ items: [MobileWorkspaceItem]) -> some View {
|
||||
ForEach(items) { item in
|
||||
Button {
|
||||
scene.selectWorkspace(item.id)
|
||||
} label: {
|
||||
@@ -165,9 +171,11 @@ struct MobileWorkspaceListView: View {
|
||||
)
|
||||
.accessibilityIdentifier("workspace-\(item.id)")
|
||||
.swipeActions {
|
||||
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark", role: .destructive) {
|
||||
// Let the model's list snapshot own row removal, not the swipe action's optimistic deletion.
|
||||
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark") {
|
||||
scene.closeWorkspace(item.id)
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
.contextMenu {
|
||||
let hasUnread = scene.workspaceSession.unreadCount(for: item.id) > 0
|
||||
@@ -199,8 +207,8 @@ struct MobileWorkspaceListView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var historyRowsContent: some View {
|
||||
ForEach(historyRows) { row in
|
||||
private func historyRowsContent(_ rows: [MobileRecentHostRow]) -> some View {
|
||||
ForEach(rows) { row in
|
||||
historyButton(row)
|
||||
.contextMenu {
|
||||
let kinds = MobileConnectionHistoryActionPolicy.availableMenuKinds(for: row.host)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindRemoteInfrastructure
|
||||
import Testing
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobilePersistentSessionCloseTests {
|
||||
@Test(arguments: ["single", "others", "all"], [false, true])
|
||||
func closeRemovesLocalWorkBeforeCleanupAndNeverRemovesLaterWork(action: String, unopened: Bool) async throws {
|
||||
var dependencies = MobileAppDependencies.testing()
|
||||
var attempts = 0
|
||||
dependencies.persistentSessionTerminator = RemoteSessionTerminator { _ in
|
||||
attempts += 1
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
throw CancellationError()
|
||||
}
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
let identity = Identity(name: "User", username: "user")
|
||||
app.library.upsert(identity)
|
||||
let host = Host(name: "Host", host: "example.invalid", identityID: identity.id)
|
||||
app.library.upsert(host)
|
||||
let transports: [HostTerminalProtocol] = [.ssh, .mosh, .eternalTerminal]
|
||||
let descriptors = (0..<3).map { index in
|
||||
MobileWorkspaceDescriptor(
|
||||
hostID: host.id, title: "Shell \(index)", kind: .terminal(transports[index]),
|
||||
remoteSession: .init(
|
||||
backend: .tmux, session: .init(id: "\(index)", name: "Shell", identity: Data([UInt8(index)])),
|
||||
hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID()))
|
||||
}
|
||||
let session = MobileWorkspaceSessionModel(
|
||||
id: "background-close", app: app, descriptors: unopened ? descriptors : [])
|
||||
if !unopened { session.restoreClosedStates(descriptors.map { .init(descriptor: $0) }) }
|
||||
defer {
|
||||
app.persistentSessionCloser.cancelAll()
|
||||
session.closeAll(authorized: true)
|
||||
}
|
||||
let remaining: Int
|
||||
switch action {
|
||||
case "single":
|
||||
session.close(descriptors[0].id)
|
||||
remaining = 2
|
||||
case "others":
|
||||
session.closeOtherWorkspaces(than: descriptors[0].id)
|
||||
remaining = 1
|
||||
default:
|
||||
session.closeAll()
|
||||
remaining = 0
|
||||
}
|
||||
#expect(session.orderedWorkspaceItems.count == remaining)
|
||||
#expect(attempts == 0)
|
||||
#expect(app.persistentSessionCloser.isClosing)
|
||||
#expect(app.closedWorkspaceRecords.flatMap(\.items).allSatisfy { $0.remoteSession == nil })
|
||||
|
||||
let added = MobileWorkspaceDescriptor(hostID: host.id, title: "New work", kind: .ssh)
|
||||
session.restoreClosedStates([.init(descriptor: added)])
|
||||
for _ in 0..<200 {
|
||||
if attempts == 3 - remaining { break }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
#expect(attempts == 3 - remaining)
|
||||
app.persistentSessionCloser.cancelAll()
|
||||
await app.persistentSessionCloser.waitUntilIdle()
|
||||
#expect(session.orderedWorkspaceItems.count == remaining + 1)
|
||||
#expect(session.workspaces.contains { $0.id == added.id })
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import Testing
|
||||
@Suite @MainActor
|
||||
struct MobileTerminalRestorationTests {
|
||||
@Test(arguments: [false, true])
|
||||
func unopenedManagedReferenceSurvivesTabAndWindowReopen(window: Bool) throws {
|
||||
func unopenedManagedReferenceIsPreservedOnlyForPassiveWindowClose(window: Bool) throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
defer { for workspace in app.workspaces { workspace.disconnect() } }
|
||||
defer {
|
||||
app.persistentSessionCloser.cancelAll()
|
||||
for workspace in app.workspaces { workspace.disconnect() }
|
||||
}
|
||||
let host = Host(name: "Server", host: "example.invalid")
|
||||
app.library.upsert(host)
|
||||
let reference = RemoteSessionReference(
|
||||
@@ -24,13 +27,14 @@ struct MobileTerminalRestorationTests {
|
||||
if window { source.discard() } else { source.close(descriptor.id) }
|
||||
let record = try #require(app.closedWorkspaceRecords.first)
|
||||
#expect(record.isWindow == window)
|
||||
#expect(record.items.first?.remoteSession == reference)
|
||||
#expect(record.items.first?.remoteSession == (window ? reference : nil))
|
||||
#expect(app.persistentSessionCloser.isClosing == !window)
|
||||
// Exercise the record-only reconstruction, not just retained in-process state.
|
||||
app.closedWorkspaceStates[record.id] = nil
|
||||
let destination = app.workspaceSession(for: "destination")
|
||||
#expect(app.restoreClosedRecord(record.id, into: destination) == descriptor.id)
|
||||
let reopened = try #require(destination.workspaces.first)
|
||||
#expect(reopened.sessionPersistence.attachment == reference)
|
||||
#expect(reopened.sessionPersistence.attachment == (window ? reference : nil))
|
||||
#expect(reopened.sessionPersistence.requiresSelection)
|
||||
#expect(reopened.pendingInitialCommand == nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite(.serialized, .requiresKeyWindow)
|
||||
@MainActor
|
||||
struct MobileWorkspaceListUpdateTests {
|
||||
@Test func closingAndReopeningWorkspacesKeepsThePresentedListConsistent() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let host = TermindDomain.Host(name: "Workspace list", host: "example.invalid")
|
||||
app.library.upsert(host)
|
||||
let scene = MobileSceneModel(app: app, sessionID: "list-updates")
|
||||
scene.selectedDestination = .workspaces
|
||||
let session = scene.workspaceSession
|
||||
defer { session.closeAll() }
|
||||
|
||||
try await withList(app: app, scene: scene) { controller in
|
||||
let list = try #require(find(UICollectionView.self, in: controller.view))
|
||||
try await expectRows(1, in: list)
|
||||
for _ in 0..<4 {
|
||||
let descriptors = (0..<3).map {
|
||||
MobileWorkspaceDescriptor(hostID: host.id, title: "Shell \($0)", kind: .ssh)
|
||||
}
|
||||
session.restoreClosedStates(descriptors.map { .init(descriptor: $0) })
|
||||
try await expectRows(3, in: list)
|
||||
app.connectionHistory.record(kind: .ssh, for: host.id)
|
||||
scene.closeWorkspace(descriptors[1].id)
|
||||
try await expectRows(2, in: list)
|
||||
scene.closeOtherWorkspaces(than: descriptors[2].id)
|
||||
try await expectRows(1, in: list)
|
||||
scene.closeAllWorkspaces()
|
||||
try await expectRows(1, in: list)
|
||||
#expect(session.orderedWorkspaceItems.isEmpty)
|
||||
app.connectionHistory.clearAll()
|
||||
try await expectRows(1, in: list)
|
||||
#expect(find(UICollectionView.self, in: controller.view) === list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func coalescedWorkspaceAndHistoryChangesKeepTheLatestRows() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let hosts = (0..<3).map { TermindDomain.Host(name: "Host \($0)", host: "example.invalid") }
|
||||
for host in hosts {
|
||||
app.library.upsert(host)
|
||||
app.connectionHistory.record(kind: .ssh, for: host.id)
|
||||
}
|
||||
let scene = MobileSceneModel(app: app, sessionID: "coalesced-list-updates")
|
||||
scene.selectedDestination = .workspaces
|
||||
let session = scene.workspaceSession
|
||||
defer { session.closeAll() }
|
||||
|
||||
try await withList(app: app, scene: scene) { controller in
|
||||
let list = try #require(find(UICollectionView.self, in: controller.view))
|
||||
try await expectRows(3, in: list)
|
||||
for _ in 0..<4 {
|
||||
let descriptor = MobileWorkspaceDescriptor(hostID: hosts[0].id, title: "Shell", kind: .ssh)
|
||||
session.restoreClosedStates([.init(descriptor: descriptor)])
|
||||
try await expectRows(1, in: list)
|
||||
scene.closeWorkspace(descriptor.id)
|
||||
app.connectionHistory.clear(for: hosts[0].id)
|
||||
try await expectRows(2, in: list)
|
||||
session.restoreClosedStates([.init(descriptor: descriptor)])
|
||||
scene.closeAllWorkspaces()
|
||||
app.connectionHistory.record(kind: .ssh, for: hosts[0].id)
|
||||
try await expectRows(3, in: list)
|
||||
#expect(find(UICollectionView.self, in: controller.view) === list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func closingTheSelectedWorkspaceUpdatesTheListWhilePoppingItsDetail() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let host = TermindDomain.Host(name: "Selected workspace", host: "example.invalid")
|
||||
app.library.upsert(host)
|
||||
app.connectionHistory.record(kind: .ssh, for: host.id)
|
||||
let scene = MobileSceneModel(app: app, sessionID: "popping-list-updates")
|
||||
scene.selectedDestination = .workspaces
|
||||
let session = scene.workspaceSession
|
||||
defer { session.closeAll() }
|
||||
|
||||
try await withList(app: app, scene: scene) { controller in
|
||||
let list = try #require(find(UICollectionView.self, in: controller.view))
|
||||
for _ in 0..<3 {
|
||||
let descriptor = MobileWorkspaceDescriptor(hostID: host.id, title: "Selected shell", kind: .ssh)
|
||||
session.restoreClosedStates([.init(descriptor: descriptor)])
|
||||
scene.selectWorkspace(descriptor.id)
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
#expect(scene.workspaceID == descriptor.id)
|
||||
scene.closeActiveWorkspace()
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
#expect(scene.workspaceID == nil)
|
||||
try await expectRows(1, in: list)
|
||||
#expect(find(UICollectionView.self, in: controller.view) === list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func closingAManagedWorkspaceRemovesItsRowBeforeBackgroundCleanup() async throws {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let hosts = (0..<2).map { TermindDomain.Host(name: "Host \($0)", host: "example.invalid") }
|
||||
for host in hosts {
|
||||
app.library.upsert(host)
|
||||
app.connectionHistory.record(kind: .ssh, for: host.id)
|
||||
}
|
||||
let scene = MobileSceneModel(app: app, sessionID: "deferred-list-close")
|
||||
scene.selectedDestination = .workspaces
|
||||
let session = scene.workspaceSession
|
||||
defer { session.closeAll(authorized: true) }
|
||||
let descriptor = MobileWorkspaceDescriptor(hostID: hosts[0].id, title: "Managed shell", kind: .ssh)
|
||||
session.restoreClosedStates([.init(descriptor: descriptor)])
|
||||
let workspace = try #require(session.workspaces.first)
|
||||
workspace.restoreRemoteSession(
|
||||
.init(
|
||||
backend: .tmux, session: .init(id: "$1", name: "shell", identity: Data("identity".utf8)),
|
||||
hostID: hosts[0].id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID()))
|
||||
|
||||
try await withList(app: app, scene: scene) { controller in
|
||||
let list = try #require(find(UICollectionView.self, in: controller.view))
|
||||
try await expectRows(1, in: list)
|
||||
scene.closeWorkspace(descriptor.id)
|
||||
// Local removal is synchronous even though remote cleanup has not run yet.
|
||||
#expect(session.workspaces.isEmpty)
|
||||
#expect(app.persistentSessionCloser.isClosing)
|
||||
try await expectRows(2, in: list)
|
||||
#expect(session.orderedWorkspaceItems.isEmpty)
|
||||
#expect(find(UICollectionView.self, in: controller.view) === list)
|
||||
}
|
||||
}
|
||||
|
||||
private func expectRows(_ count: Int, in list: UICollectionView) async throws {
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
list.layoutIfNeeded()
|
||||
#expect(list.numberOfSections == 2)
|
||||
#expect(list.numberOfItems(inSection: 0) == 1)
|
||||
#expect(list.numberOfItems(inSection: 1) == count)
|
||||
}
|
||||
|
||||
private func withList(
|
||||
app: MobileAppModel, scene: MobileSceneModel,
|
||||
check: (UIViewController) async throws -> Void
|
||||
) async throws {
|
||||
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previousWindow = windowScene.keyWindow
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
let controller = UIHostingController(
|
||||
rootView: NavigationStack { MobileWorkspaceListView(app: app, scene: scene) })
|
||||
window.rootViewController = controller
|
||||
window.windowLevel = .alert + 1
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
previousWindow?.makeKey()
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
try await check(controller)
|
||||
}
|
||||
|
||||
private func find<T: UIView>(_ type: T.Type, in root: UIView) -> T? {
|
||||
if let match = root as? T { return match }
|
||||
return root.subviews.lazy.compactMap { find(type, in: $0) }.first
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ final class AppModel {
|
||||
|
||||
@ObservationIgnored lazy var mainWindows = MainWindowCoordinator(app: self, sizes: windowSizeStore)
|
||||
let connections: RemoteConnectionCoordinator
|
||||
let persistentSessionCloser: RemoteSessionCloseCoordinator
|
||||
let serialPorts: any SerialPortServing
|
||||
let hostRepository: any HostRepository
|
||||
let settingsSync: SettingsSyncCoordinator?
|
||||
@@ -294,6 +295,10 @@ final class AppModel {
|
||||
)
|
||||
self.externalToolCalls = externalToolCalls
|
||||
connections = connectionCoordinator
|
||||
let sessionTerminator = RemoteSessionTerminator.live(coordinator: connectionCoordinator)
|
||||
persistentSessionCloser = RemoteSessionCloseCoordinator { [hosts] request in
|
||||
try await sessionTerminator.terminate(request, hosts: hosts)
|
||||
}
|
||||
serialPorts = dependencies.serialPortService
|
||||
agentRunRepository = MemoryIndexedAgentRepository(
|
||||
base: dependencies.agentRunRepository ?? InMemoryAgentRunRepository(), memory: memory)
|
||||
@@ -760,6 +765,7 @@ final class AppModel {
|
||||
guard !window.isClosed, windows[ObjectIdentifier(window)]?.value !== window else { return }
|
||||
configureConnectionHistory(in: window)
|
||||
window.connectionCoordinator = connections
|
||||
window.persistentSessionCloser = persistentSessionCloser
|
||||
window.hostStore = hosts
|
||||
window.rememberClosedWorkspace = { [weak self] in self?.rememberClosedWorkspaces([$0]) }
|
||||
window.rememberClosedWindow = { [weak self] workspaces, selected in
|
||||
@@ -997,6 +1003,7 @@ final class AppModel {
|
||||
@discardableResult
|
||||
func shutdownForTermination() -> Task<Void, Never> {
|
||||
isTerminating = true
|
||||
persistentSessionCloser.cancelAll()
|
||||
notificationBadge?.stop()
|
||||
sftpNotifications?.stop()
|
||||
assistantNotifications.stop()
|
||||
|
||||
@@ -148,6 +148,11 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
/// passphrase material are resolved only for each attempted transfer
|
||||
/// connection and never retained here. Cleared with the SSH connection.
|
||||
private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
private var persistentSessionSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
var isRemoteSessionClosing = false
|
||||
var restorableRemoteSession: RemoteSessionReference? {
|
||||
isRemoteSessionClosing ? nil : sessionPersistence.attachment
|
||||
}
|
||||
private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24)
|
||||
var connectionRoute: SSHConnectionRoute? {
|
||||
get { connection.connectionRoute }
|
||||
@@ -1029,6 +1034,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
transferSnapshot =
|
||||
statusStore?.additionalConnectionSnapshot(from: configuration)
|
||||
?? SSHAdditionalConnectionSnapshot(configuration: configuration)
|
||||
persistentSessionSnapshot = statusStore?.additionalConnectionSnapshot(from: configuration)
|
||||
suppliedAuthentications.removeAll()
|
||||
suppliedProxyAuthentications.removeAll()
|
||||
temporaryHostKeys.removeAll()
|
||||
@@ -1118,34 +1124,29 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
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
|
||||
/// for Termind's retained pane model. Close the pane through its current
|
||||
/// Workspace instead; transport failures remain visible for reconnection.
|
||||
func terminatePersistentSession(
|
||||
_ reference: RemoteSessionReference, isCurrentOwner: @MainActor () -> Bool = { true }
|
||||
) async throws {
|
||||
guard sessionPersistence.attachment == reference, let channel = ssh else {
|
||||
throw RemoteSessionFailure.sessionMissing
|
||||
func takePersistentSessionCloseRequest(hosts: any RemoteSessionHostStore) -> RemoteSessionCloseRequest? {
|
||||
guard let reference = sessionPersistence.attachment, reference.isManaged else { return nil }
|
||||
let snapshot: SSHAdditionalConnectionSnapshot
|
||||
if let persistentSessionSnapshot {
|
||||
snapshot = persistentSessionSnapshot
|
||||
} else {
|
||||
guard let host = connectionRoute?.host(reference.hostID) ?? hosts.host(reference.hostID) ?? transientHost,
|
||||
let restored = try? hosts.resumableControlSnapshot(for: host, username: reference.username)
|
||||
else { return nil }
|
||||
snapshot = restored
|
||||
}
|
||||
try await channel.releaseTerminalForControl()
|
||||
status = .disconnected
|
||||
resetCommandGeneratorShellState()
|
||||
workingDirectory = nil
|
||||
let execute = try await channel.sessionCommandExecutor()
|
||||
let backend = try RemoteSessionBackendRegistry.live.backend(reference.backend, execute: execute)
|
||||
let sessions = try await backend.sessions()
|
||||
guard isCurrentOwner(), ssh === channel, sessionPersistence.attachment == reference else {
|
||||
throw CancellationError()
|
||||
}
|
||||
guard sessions.contains(where: { backend.matches(reference, session: $0) }) else {
|
||||
throw RemoteSessionFailure.ownershipMismatch
|
||||
}
|
||||
try await backend.terminate(reference)
|
||||
if sessionPersistence.attachment == reference { sessionPersistence.markEnded() }
|
||||
guard snapshot.hostID == reference.hostID,
|
||||
"\(snapshot.target.host):\(snapshot.target.port)" == reference.endpoint
|
||||
else { return nil }
|
||||
let transferred = transport == .ssh ? ssh : nil
|
||||
if transferred != nil { ssh = nil }
|
||||
return RemoteSessionCloseRequest(
|
||||
reference: reference, snapshot: snapshot,
|
||||
connection: transferred.map { RemoteSessionCloseConnection(channel: $0) })
|
||||
}
|
||||
|
||||
/// Normal shell exit closes the pane, not the reusable Ghostty surface.
|
||||
/// Transport failures remain visible for reconnection.
|
||||
func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
|
||||
resetCommandGeneratorShellState()
|
||||
workingDirectory = nil
|
||||
@@ -1348,7 +1349,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
let host = transientHost ?? Host(id: hostID, name: title, host: title)
|
||||
let copy = SSHSessionModel(host: host, usernameHint: usernameHint)
|
||||
copy.transport = transport
|
||||
copy.sessionPersistence.attachment = sessionPersistence.attachment
|
||||
copy.sessionPersistence.attachment = restorableRemoteSession
|
||||
copy.connectionRoute = connectionRoute
|
||||
copy.connectionOrigin = connectionOrigin
|
||||
copy.connectionCoordinator = connectionCoordinator
|
||||
|
||||
@@ -17,7 +17,7 @@ struct ClosedWorkspaceRecord: Codable, Identifiable {
|
||||
let layout =
|
||||
workspace.panes.contains { $0.sshSession?.isExternalTemporaryConnection == true }
|
||||
? nil : workspace.workspaceTemplateLayout(in: hosts)
|
||||
self.remoteSessions = workspace.panes.map { $0.sshSession?.sessionPersistence.attachment }
|
||||
self.remoteSessions = workspace.panes.map { $0.sshSession?.restorableRemoteSession }
|
||||
self.id = workspace.id
|
||||
self.title = workspace.title(in: hosts)
|
||||
self.symbol = workspace.symbol
|
||||
|
||||
@@ -1490,7 +1490,6 @@ final class WindowModel {
|
||||
/// Moving preserves live transports and does not populate close history.
|
||||
func takeWorkspaceForMove(_ id: Workspace.ID) -> Workspace? {
|
||||
guard !isClosed, let workspace = workspaces.first(where: { $0.id == id }) else { return nil }
|
||||
persistentClose.cancel()
|
||||
removeWorkspaceForMove(id)
|
||||
workspace.lifecycleRouter = nil
|
||||
dragging = nil
|
||||
@@ -1510,7 +1509,6 @@ final class WindowModel {
|
||||
guard !isClosed, let pane = pane(id), pane.isCombinable,
|
||||
let source = workspace(holding: id)
|
||||
else { return nil }
|
||||
persistentClose.cancel()
|
||||
if source.panes.count == 1 { return takeWorkspaceForMove(source.id) }
|
||||
guard source.closePane(id) else { return nil }
|
||||
let workspace = automaticallyNamedWorkspace(pane: pane)
|
||||
@@ -1520,40 +1518,21 @@ final class WindowModel {
|
||||
return workspace
|
||||
}
|
||||
|
||||
let persistentClose = RemoteSessionCloseCoordinator()
|
||||
var persistentSessionCloser: RemoteSessionCloseCoordinator?
|
||||
|
||||
func authorizePersistentClose(_ sessions: [SSHSessionModel]) async -> Bool {
|
||||
let workspaceIDs = Set(workspaces.map(\.id))
|
||||
let paneIDs = Set(workspaces.flatMap(\.panes).map(\.id))
|
||||
let references = sessions.compactMap { $0.sessionPersistence.attachment }
|
||||
let allowed = await persistentClose.close(attachments: references) { [weak self] reference in
|
||||
guard let self, let session = sessions.first(where: { $0.sessionPersistence.attachment == reference }),
|
||||
self.workspaces.flatMap(\.panes).contains(where: { $0.sshSession === session })
|
||||
else { throw CancellationError() }
|
||||
try await session.terminatePersistentSession(reference) {
|
||||
self.workspaces.flatMap(\.panes).contains(where: { $0.sshSession === session })
|
||||
}
|
||||
for session in sessions
|
||||
where session.sessionPersistence.attachment?.hasSameIdentity(as: reference) == true {
|
||||
session.sessionPersistence.markEnded()
|
||||
}
|
||||
private func closePersistentSessions(_ sessions: [SSHSessionModel]) {
|
||||
guard let hostStore, let persistentSessionCloser else { return }
|
||||
let requests = sessions.compactMap { session in
|
||||
session.takePersistentSessionCloseRequest(hosts: hostStore).map { (session, $0) }
|
||||
}.sorted { $0.1.connection != nil && $1.1.connection == nil }
|
||||
for (session, request) in requests {
|
||||
if persistentSessionCloser.enqueue(request) { session.isRemoteSessionClosing = true }
|
||||
}
|
||||
// Confirmation covers this snapshot, never workspaces added or moved while it was open.
|
||||
return allowed && workspaceIDs == Set(workspaces.map(\.id))
|
||||
&& paneIDs == Set(workspaces.flatMap(\.panes).map(\.id))
|
||||
}
|
||||
|
||||
private func deferPersistentClose(_ sessions: [SSHSessionModel], action: @escaping @MainActor () -> Void) -> Bool {
|
||||
guard sessions.contains(where: { $0.sessionPersistence.attachment?.isManaged == true }) else { return false }
|
||||
guard !persistentClose.isClosing else { return true }
|
||||
Task { if await authorizePersistentClose(sessions) { action() } }
|
||||
return true
|
||||
}
|
||||
|
||||
func closeForWindow() {
|
||||
guard !isClosed else { return }
|
||||
// Window dismissal disconnects clients but preserves remote sessions and their recovery references.
|
||||
persistentClose.cancel()
|
||||
rememberClosedWindow?(workspaces, selectedWorkspaceID)
|
||||
isClosed = true
|
||||
for workspace in workspaces {
|
||||
@@ -1572,13 +1551,8 @@ final class WindowModel {
|
||||
// MARK: - Closing
|
||||
|
||||
func close(_ id: Workspace.ID, authorized: Bool = false) {
|
||||
if !authorized, let target = workspaces.first(where: { $0.id == id }),
|
||||
deferPersistentClose(
|
||||
target.panes.compactMap(\.sshSession), action: { [weak self] in self?.close(id, authorized: true) })
|
||||
{
|
||||
return
|
||||
}
|
||||
guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return }
|
||||
if !authorized { closePersistentSessions(workspaces[index].panes.compactMap(\.sshSession)) }
|
||||
let removed = workspaces.remove(at: index)
|
||||
removed.disconnectConnections()
|
||||
rememberClosedWorkspace?(removed)
|
||||
@@ -1603,12 +1577,9 @@ final class WindowModel {
|
||||
}
|
||||
|
||||
func closeOthers(than id: Workspace.ID, authorized: Bool = false) {
|
||||
if !authorized,
|
||||
deferPersistentClose(
|
||||
workspaces.filter { $0.id != id }.flatMap(\.panes).compactMap(\.sshSession),
|
||||
action: { [weak self] in self?.closeOthers(than: id, authorized: true) })
|
||||
{
|
||||
return
|
||||
guard workspaces.contains(where: { $0.id == id }) else { return }
|
||||
if !authorized {
|
||||
closePersistentSessions(workspaces.filter { $0.id != id }.flatMap(\.panes).compactMap(\.sshSession))
|
||||
}
|
||||
for workspace in workspaces where workspace.id != id {
|
||||
workspace.disconnectConnections()
|
||||
@@ -1619,13 +1590,8 @@ final class WindowModel {
|
||||
}
|
||||
|
||||
func closeAll(intent: TerminalTeardownIntent = .explicitClose, authorized: Bool = false) {
|
||||
if !intent.endsManagedSession { persistentClose.cancel() }
|
||||
if !authorized, intent.endsManagedSession,
|
||||
deferPersistentClose(
|
||||
workspaces.flatMap(\.panes).compactMap(\.sshSession),
|
||||
action: { [weak self] in self?.closeAll(authorized: true) })
|
||||
{
|
||||
return
|
||||
if !authorized, intent.endsManagedSession {
|
||||
closePersistentSessions(workspaces.flatMap(\.panes).compactMap(\.sshSession))
|
||||
}
|
||||
for workspace in workspaces {
|
||||
workspace.disconnectConnections()
|
||||
@@ -1662,13 +1628,7 @@ final class WindowModel {
|
||||
close(workspace.id, authorized: authorized)
|
||||
return
|
||||
}
|
||||
if !authorized,
|
||||
deferPersistentClose(
|
||||
[pane.sshSession].compactMap { $0 },
|
||||
action: { [weak self] in self?.closePane(paneID, authorized: true) })
|
||||
{
|
||||
return
|
||||
}
|
||||
if !authorized { closePersistentSessions([pane.sshSession].compactMap { $0 }) }
|
||||
pane.disconnectConnection()
|
||||
_ = workspace.closePane(paneID)
|
||||
// Keep only the removed pane: reopening must not reconnect surviving
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite @MainActor
|
||||
struct WorkspaceBackgroundCloseTests {
|
||||
@Test(arguments: ["workspace", "pane", "others", "all"])
|
||||
func explicitCloseDoesNotWaitForRemoteCleanup(action: String) async throws {
|
||||
let host = Host(name: "Host", host: "example.invalid")
|
||||
let hosts = HostLibraryModel(hosts: [host])
|
||||
let workspaces = (0..<3).map { _ in Workspace.sshSession(host: host) }
|
||||
if action == "pane" {
|
||||
workspaces[0].splitFocusedPane(axis: .horizontal, with: .sshSession(host: host))
|
||||
}
|
||||
for pane in workspaces.flatMap(\.panes) {
|
||||
let session = try #require(pane.sshSession)
|
||||
session.sessionPersistence.attachment = .init(
|
||||
backend: .tmux, session: .init(id: pane.id.uuidString, name: "Shell", identity: Data([1])),
|
||||
hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID())
|
||||
}
|
||||
var attempts = 0
|
||||
let closer = RemoteSessionCloseCoordinator { _ in
|
||||
attempts += 1
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
}
|
||||
let model = WindowModel(workspaces: workspaces)
|
||||
model.hostStore = hosts
|
||||
model.persistentSessionCloser = closer
|
||||
var saved: [ClosedWorkspaceRecord] = []
|
||||
model.rememberClosedWorkspace = { saved.append(.init($0, hosts: hosts)) }
|
||||
defer {
|
||||
closer.cancelAll()
|
||||
model.closeForWindow()
|
||||
}
|
||||
switch action {
|
||||
case "workspace":
|
||||
model.close(workspaces[0].id)
|
||||
#expect(model.workspaces.count == 2)
|
||||
case "pane":
|
||||
model.closePane(workspaces[0].focusedPaneID)
|
||||
#expect(model.workspaces.count == 3)
|
||||
#expect(workspaces[0].panes.count == 1)
|
||||
case "others":
|
||||
model.closeOthers(than: workspaces[0].id)
|
||||
#expect(model.workspaces.map(\.id) == [workspaces[0].id])
|
||||
default:
|
||||
model.closeAll()
|
||||
#expect(model.workspaces.isEmpty)
|
||||
}
|
||||
#expect(attempts == 0)
|
||||
#expect(closer.isClosing)
|
||||
#expect(!saved.isEmpty)
|
||||
#expect(saved.flatMap { $0.remoteSessions ?? [] }.allSatisfy { $0 == nil })
|
||||
closer.cancelAll()
|
||||
await closer.waitUntilIdle()
|
||||
}
|
||||
|
||||
@Test func passiveCloseDoesNotQueueRemoteTermination() throws {
|
||||
let host = Host(name: "Host", host: "example.invalid")
|
||||
let workspace = Workspace.sshSession(host: host)
|
||||
let session = try #require(workspace.focusedPane.sshSession)
|
||||
let reference = RemoteSessionReference(
|
||||
backend: .tmux, session: .init(id: "1", name: "Shell", identity: Data([1])),
|
||||
hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: UUID())
|
||||
session.sessionPersistence.attachment = reference
|
||||
let model = WindowModel(workspaces: [workspace])
|
||||
model.hostStore = HostLibraryModel(hosts: [host])
|
||||
let closer = RemoteSessionCloseCoordinator { _ in Issue.record("Quit must not end remote sessions") }
|
||||
model.persistentSessionCloser = closer
|
||||
model.closeAll(intent: .applicationTermination)
|
||||
#expect(model.workspaces.isEmpty)
|
||||
#expect(!closer.isClosing)
|
||||
#expect(session.restorableRemoteSession == reference)
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ struct WorkspaceCloseConfirmationTests {
|
||||
}
|
||||
|
||||
#expect(native.delegate?.windowShouldClose?(native) == true)
|
||||
#expect(!model.persistentClose.isClosing)
|
||||
#expect(model.persistentSessionCloser?.isClosing != true)
|
||||
model.closeForWindow()
|
||||
#expect(model.isClosed)
|
||||
#expect(model.workspaces.isEmpty)
|
||||
@@ -165,7 +165,7 @@ struct WorkspaceCloseConfirmationTests {
|
||||
#expect(restored.focusedPane.sshSession?.sessionPersistence.attachment == reference)
|
||||
}
|
||||
|
||||
@Test func failedManagedPaneCloseDisconnectsAndKeepsItsRecoveryReference() async throws {
|
||||
@Test func closeWithoutACleanupOwnerKeepsItsRecoveryReference() throws {
|
||||
let host = Host(name: "Server", host: "server.example")
|
||||
let hosts = HostLibraryModel(hosts: [host])
|
||||
let workspace = Workspace.sshSession(host: host)
|
||||
@@ -179,12 +179,8 @@ struct WorkspaceCloseConfirmationTests {
|
||||
model.rememberClosedWorkspace = { saved = ClosedWorkspaceRecord($0, hosts: hosts) }
|
||||
defer { model.closeForWindow() }
|
||||
|
||||
// There is no live SSH transport to end the remote session.
|
||||
// An unregistered window has no app-owned remote cleanup coordinator.
|
||||
model.closeSelected()
|
||||
for _ in 0..<200 {
|
||||
if model.workspaces.isEmpty { break }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
#expect(model.workspaces.isEmpty)
|
||||
#expect(session.sessionPersistence.attachment == reference)
|
||||
#expect(session.status == .disconnected)
|
||||
|
||||
Reference in new issue
Block a user