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:
Kaede Akino committed 2026-10-17 14:34:55 +08:00
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 }
}
}
+55 -55
View File
@@ -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() })
}
}
+2
View File
@@ -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(
+18 -30
View File
@@ -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) {
+11 -35
View File
@@ -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])
}
}
+23 -23
View File
@@ -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
}
}
+7
View File
@@ -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()
+27 -26
View File
@@ -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
+15 -55
View File
@@ -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)