diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 91939f07..27472192 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -97,8 +97,28 @@ private struct NativeTmuxAttachment { var clientTTYDirectory: String? var ignoresClientSize: Bool var previewGridSize: TmuxGridSize? + var supportsClientSizing: Bool var supportsPaneSplitting: Bool var remoteExitStatusURL: URL? + + /// Kwt starts the tmux client itself while establishing or repairing a + /// workspace, so Ghosthub cannot pass client flags to that attach. + var usesKwtWorkspaceAttach: Bool { + Self.usesKwtWorkspaceAttach( + launchMode: launchMode, + openWorkspace: openWorkspace, + protectedWorkspacePath: protectedWorkspacePath + ) + } + + static func usesKwtWorkspaceAttach( + launchMode: TmuxAttachmentLaunchMode, + openWorkspace: Bool, + protectedWorkspacePath: String? + ) -> Bool { + launchMode == .attach + && (openWorkspace || protectedWorkspacePath != nil) + } } enum TmuxAttachedSessionIdentityResolution: Equatable { @@ -119,6 +139,11 @@ enum TmuxClientSizingTransitionResult: Equatable { /// only binary resolution and the disposable local libghostty presentation. @MainActor final class NativeTmuxSessionCoordinator { + private enum PendingTmuxClientSizing { + case interactive + case preview(TmuxGridSize?) + } + private struct PaneSplitRequest { var shortcut: TerminalPaneSplitShortcut var target: TmuxPaneSplitTarget @@ -141,6 +166,14 @@ final class NativeTmuxSessionCoordinator { var task: Task } + private struct AttachmentCleanupTail { + var id: UUID + var task: Task + } + + private typealias SizingTransitionTask = + Task + private let terminalCoordinator: any NativeSessionSurfaceStoring private let tmuxPathProvider: @Sendable () -> Result @@ -165,7 +198,11 @@ final class NativeTmuxSessionCoordinator { private var targetHostsByHandle: [UUID: CommandHost] = [:] private var attachments: [UUID: NativeTmuxAttachment] = [:] private var attachmentClosures: [UUID: BorrowedTmuxAttachmentClosure] = [:] - private var launchedHandles: Set = [] + /// Identifies the launch failure whose deferred report is still owed, so + /// a replacement attach on the same handle is not told it disconnected. + private var surfaceLaunchFailureIDs: [UUID: UUID] = [:] + private var launchedAttachmentIDs: [UUID: UUID] = [:] + private var closedLaunchedHandles: Set = [] private var reportedConnectedAttachmentIDs: [UUID: UUID] = [:] private let tmuxResolutionCache = NativeTmuxResolutionCache() private var provisioningHandles: Set = [] @@ -178,7 +215,13 @@ final class NativeTmuxSessionCoordinator { private var previewIdentityRetryHandles: Set = [] private var unavailablePreviewIdentityHandles: Set = [] private var deferredPresentationStyleHandles: Set = [] - private var interactiveSizingHandles: Set = [] + private var pendingSizingByHandle: [UUID: PendingTmuxClientSizing] = [:] + private var sizingTransitionTails: [UUID: SizingTransitionTask] = [:] + private var sizingTransitionTasks: + [UUID: [UUID: SizingTransitionTask]] = [:] + private var attachmentCleanupTails: + [NativeTmuxSessionKey: AttachmentCleanupTail] = [:] + private var sizingTransitionCleanupTasks: [UUID: Task] = [:] private var interactiveSizingTransitionHandles: Set = [] private var isShuttingDown = false @@ -294,6 +337,8 @@ final class NativeTmuxSessionCoordinator { handlesByKey[key] = handle targetHostsByHandle[handle.id] = host attachmentClosures.removeValue(forKey: handle.id) + surfaceLaunchFailureIDs.removeValue(forKey: handle.id) + closedLaunchedHandles.remove(handle.id) guard !isShuttingDown, attachments[handle.id] == nil, @@ -306,8 +351,11 @@ final class NativeTmuxSessionCoordinator { let tmuxPathProvider = tmuxPathProvider let remoteTmuxPathProvider = remoteTmuxPathProvider let remoteConnectionProvider = remoteConnectionProvider + let attachmentCleanup = attachmentCleanupTails[key]?.task provisioningTasks[handle.id] = Task { [weak self] in do { + await attachmentCleanup?.value + try Task.checkCancellation() let sshConnection: KwtSSHConnection? let sshConnectionSnapshot: SSHConnectionArgumentsSnapshot if case let .ssh(info) = host { @@ -351,7 +399,7 @@ final class NativeTmuxSessionCoordinator { } onCancel: { probe.cancel() } - self?.finishAttach( + await self?.finishAttach( handle: handle, host: host, socketName: socketName, @@ -391,25 +439,58 @@ final class NativeTmuxSessionCoordinator { sshConnection: KwtSSHConnection?, tmuxPathCacheKey: NativeTmuxPathCacheKey, resolution: Result - ) { - provisioningTasks.removeValue(forKey: handle.id) - provisioningHandles.remove(handle.id) + ) async { + defer { + provisioningTasks.removeValue(forKey: handle.id) + provisioningHandles.remove(handle.id) + } let key = sessionKey(handle) guard handlesByKey[key] == handle, targetHostsByHandle[handle.id] == host, attachments[handle.id] == nil else { - Task { try? await sshConnection?.release() } + let release = Task { try? await sshConnection?.release() } + await release.value return } switch resolution { case let .success(resolved): let attachmentID = UUID() - let enablesInteractiveSizing = interactiveSizingHandles.remove( - handle.id - ) != nil + let supportsClientSizing = TmuxPaneSplitter + .supportsClientSizing( + version: resolved.version, + host: host + ) + let pendingSizing = pendingSizingByHandle.removeValue( + forKey: handle.id + ) let protectedWorkspacePath = tmuxAttachMode == .protected ? workingDirectory : nil + let usesKwtWorkspaceAttach = NativeTmuxAttachment + .usesKwtWorkspaceAttach( + launchMode: launchMode, + openWorkspace: openWorkspace, + protectedWorkspacePath: protectedWorkspacePath + ) + let effectiveIgnoresClientSize: Bool + let effectivePreviewGridSize: TmuxGridSize? + switch pendingSizing { + case .interactive: + effectiveIgnoresClientSize = false + effectivePreviewGridSize = nil + case let .preview(gridSize) + where supportsClientSizing && !usesKwtWorkspaceAttach: + effectiveIgnoresClientSize = true + effectivePreviewGridSize = gridSize + case .preview: + effectiveIgnoresClientSize = false + effectivePreviewGridSize = nil + case nil: + effectiveIgnoresClientSize = supportsClientSizing + && ignoresClientSize + effectivePreviewGridSize = effectiveIgnoresClientSize + ? previewGridSize : nil + } attachments[handle.id] = NativeTmuxAttachment( id: attachmentID, host: host, @@ -435,10 +516,9 @@ final class NativeTmuxSessionCoordinator { .appendingPathComponent( "tmux-clients", isDirectory: true ).path, - ignoresClientSize: enablesInteractiveSizing - ? false : ignoresClientSize, - previewGridSize: enablesInteractiveSizing - ? nil : previewGridSize, + ignoresClientSize: effectiveIgnoresClientSize, + previewGridSize: effectivePreviewGridSize, + supportsClientSizing: supportsClientSizing, supportsPaneSplitting: TmuxPaneSplitter .supportsPaneSplitting( version: resolved.version, @@ -450,13 +530,15 @@ final class NativeTmuxSessionCoordinator { ) onSurfaceReady?(handle) case let .failure(error): - Task { + let release = Task { if case let .sshConnectionFailed(_, classification) = error, classification.connectionUnusable { await sshConnection?.invalidate() } try? await sshConnection?.release() } + await release.value + guard handlesByKey[key] == handle else { return } attachmentClosures[handle.id] = switch error { case let .sshConnectionFailed(_, classification) where classification.kind == .transport: @@ -464,7 +546,7 @@ final class NativeTmuxSessionCoordinator { default: .launchFailed } - interactiveSizingHandles.remove(handle.id) + pendingSizingByHandle.removeValue(forKey: handle.id) onStateChanged?( handle, .disconnected(reason: error.localizedDescription) @@ -483,7 +565,7 @@ final class NativeTmuxSessionCoordinator { SSHConnectionFailure.retryableTransportFailure(error) == nil ? .launchFailed : .retryableTransportFailure - interactiveSizingHandles.remove(handle.id) + pendingSizingByHandle.removeValue(forKey: handle.id) onStateChanged?( handle, .disconnected(reason: error.localizedDescription) @@ -525,23 +607,40 @@ final class NativeTmuxSessionCoordinator { if !keyAlreadyRemoved { handlesByKey.removeValue(forKey: key) } - provisioningTasks.removeValue(forKey: handle.id)?.cancel() + let provisioningTask = provisioningTasks.removeValue( + forKey: handle.id + ) + provisioningTask?.cancel() cancelPaneSplits(handleID: handle.id) provisioningHandles.remove(handle.id) targetHostsByHandle.removeValue(forKey: handle.id) let attachment = attachments.removeValue(forKey: handle.id) - remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) - Task { try? await attachment?.sshConnection?.release() } + cancelSizingTransitionsAndRelease( + handleID: handle.id, + key: key, + attachment: attachment, + provisioningTask: provisioningTask, + removesRemoteExitStatus: true + ) attachmentClosures.removeValue(forKey: handle.id) - launchedHandles.remove(handle.id) + surfaceLaunchFailureIDs.removeValue(forKey: handle.id) + launchedAttachmentIDs.removeValue(forKey: handle.id) + closedLaunchedHandles.remove(handle.id) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) - interactiveSizingHandles.remove(handle.id) + pendingSizingByHandle.removeValue(forKey: handle.id) terminalCoordinator.removeSurface(for: surfaceKey(handle)) } func hasLaunched(_ handle: BorrowedTmuxSessionHandle) -> Bool { - launchedHandles.contains(handle.id) + guard let attachment = attachments[handle.id] else { return false } + return launchedAttachmentIDs[handle.id] == attachment.id + } + + func closedAttachmentHadLaunched( + _ handle: BorrowedTmuxSessionHandle + ) -> Bool { + closedLaunchedHandles.contains(handle.id) } func isProvisioning(_ handle: BorrowedTmuxSessionHandle) -> Bool { @@ -610,10 +709,18 @@ final class NativeTmuxSessionCoordinator { func enableInteractiveSizing( for handle: BorrowedTmuxSessionHandle + ) async -> TmuxClientSizingTransitionResult { + await serializeSizingTransition(for: handle) { [self] in + await performEnableInteractiveSizing(for: handle) + } + } + + private func performEnableInteractiveSizing( + for handle: BorrowedTmuxSessionHandle ) async -> TmuxClientSizingTransitionResult { guard var attachment = attachments[handle.id] else { if provisioningHandles.contains(handle.id) { - interactiveSizingHandles.insert(handle.id) + pendingSizingByHandle[handle.id] = .interactive return .pending } return .failure(TmuxPaneSplitFailure( @@ -625,7 +732,7 @@ final class NativeTmuxSessionCoordinator { )) } guard attachment.ignoresClientSize else { return .applied } - guard launchedHandles.contains(handle.id) else { + guard launchedAttachmentIDs[handle.id] == attachment.id else { attachment.ignoresClientSize = false attachment.previewGridSize = nil attachments[handle.id] = attachment @@ -683,11 +790,20 @@ final class NativeTmuxSessionCoordinator { func restorePreviewSizing( _ gridSize: TmuxGridSize?, for handle: BorrowedTmuxSessionHandle + ) async -> TmuxClientSizingTransitionResult { + await serializeSizingTransition(for: handle) { [self] in + await performRestorePreviewSizing(gridSize, for: handle) + } + } + + private func performRestorePreviewSizing( + _ gridSize: TmuxGridSize?, + for handle: BorrowedTmuxSessionHandle ) async -> TmuxClientSizingTransitionResult { guard var attachment = attachments[handle.id] else { if provisioningHandles.contains(handle.id) { - interactiveSizingHandles.remove(handle.id) - return .applied + pendingSizingByHandle[handle.id] = .preview(gridSize) + return .pending } return .failure(TmuxPaneSplitFailure( host: targetHostsByHandle[handle.id]?.displayName @@ -697,13 +813,24 @@ final class NativeTmuxSessionCoordinator { diagnostic: "The tmux attachment is unavailable." )) } + guard attachment.supportsClientSizing else { + return .failure(TmuxPaneSplitFailure( + host: attachment.host.displayName, + sessionName: handle.name, + status: 75, + diagnostic: "The tmux version cannot safely update one client." + )) + } if attachment.ignoresClientSize { attachment.previewGridSize = gridSize attachments[handle.id] = attachment applyPreviewGridSize(gridSize, for: handle) return .applied } - guard launchedHandles.contains(handle.id) else { + guard launchedAttachmentIDs[handle.id] == attachment.id else { + // A local flag is only honored by a direct attach. Kwt's client + // must be hidden on the exact client after it launches. + guard !attachment.usesKwtWorkspaceAttach else { return .pending } attachment.ignoresClientSize = true attachment.previewGridSize = gridSize attachments[handle.id] = attachment @@ -731,7 +858,6 @@ final class NativeTmuxSessionCoordinator { return .failure(failure) } } - applyPreviewGridSize(gridSize, for: handle) let failure = await paneSplitter.disableSizing(target: target) guard !Task.isCancelled, attachments[handle.id]?.id == attachmentID @@ -740,11 +866,94 @@ final class NativeTmuxSessionCoordinator { attachment.ignoresClientSize = true attachment.previewGridSize = gridSize attachments[handle.id] = attachment + applyPreviewGridSize(gridSize, for: handle) return .applied } return .failure(failure) } + private func serializeSizingTransition( + for handle: BorrowedTmuxSessionHandle, + operation: @escaping @MainActor () async + -> TmuxClientSizingTransitionResult + ) async -> TmuxClientSizingTransitionResult { + let predecessor = sizingTransitionTails[handle.id] + let transitionID = UUID() + let transition = Task { @MainActor in + if let predecessor { + _ = await predecessor.value + } + guard !Task.isCancelled else { + return TmuxClientSizingTransitionResult.stale + } + return await operation() + } + sizingTransitionTails[handle.id] = transition + sizingTransitionTasks[handle.id, default: [:]][transitionID] = + transition + let result = await withTaskCancellationHandler { + await transition.value + } onCancel: { + transition.cancel() + } + sizingTransitionTasks[handle.id]?.removeValue(forKey: transitionID) + if sizingTransitionTasks[handle.id]?.isEmpty == true { + sizingTransitionTasks.removeValue(forKey: handle.id) + } + if sizingTransitionTails[handle.id] == transition { + sizingTransitionTails.removeValue(forKey: handle.id) + } + return result + } + + private func cancelSizingTransitions( + handleID: UUID + ) -> [SizingTransitionTask] { + sizingTransitionTails.removeValue(forKey: handleID) + let transitions = sizingTransitionTasks + .removeValue(forKey: handleID) + .map { Array($0.values) } ?? [] + transitions.forEach { $0.cancel() } + return transitions + } + + private func cancelSizingTransitionsAndRelease( + handleID: UUID, + key: NativeTmuxSessionKey, + attachment: NativeTmuxAttachment?, + provisioningTask: Task? = nil, + invalidatesConnection: Bool = false, + removesRemoteExitStatus: Bool = false + ) { + let sizingTransitions = cancelSizingTransitions(handleID: handleID) + let predecessor = attachmentCleanupTails[key]?.task + let remoteExitStatusStore = remoteExitStatusStore + let cleanupID = UUID() + let cleanup = Task { [weak self] in + await predecessor?.value + await provisioningTask?.value + for transition in sizingTransitions { + _ = await transition.value + } + if invalidatesConnection { + await attachment?.sshConnection?.invalidate() + } + if removesRemoteExitStatus { + remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) + } + try? await attachment?.sshConnection?.release() + if self?.attachmentCleanupTails[key]?.id == cleanupID { + self?.attachmentCleanupTails.removeValue(forKey: key) + } + self?.sizingTransitionCleanupTasks.removeValue(forKey: cleanupID) + } + attachmentCleanupTails[key] = AttachmentCleanupTail( + id: cleanupID, + task: cleanup + ) + sizingTransitionCleanupTasks[cleanupID] = cleanup + } + private func applyPreviewGridSize( _ gridSize: TmuxGridSize?, for handle: BorrowedTmuxSessionHandle @@ -770,7 +979,7 @@ final class NativeTmuxSessionCoordinator { let presentationStyle = appliesPresentationStyle ? presentationStyleProvider() : nil - let isFirstLaunch = !launchedHandles.contains(handle.id) + let isFirstLaunch = launchedAttachmentIDs[handle.id] != attachment.id let surfaceKey = surfaceKey(handle) let previousSurfaceIdentity = terminalCoordinator .paneSurfaceIfPresent(for: surfaceKey) @@ -877,7 +1086,7 @@ final class NativeTmuxSessionCoordinator { ) } ) - launchedHandles.insert(handle.id) + launchedAttachmentIDs[handle.id] = attachment.id startPaneSplitClientBinding( target: splitTarget, handle: handle, @@ -976,7 +1185,7 @@ final class NativeTmuxSessionCoordinator { let request = requests.removeFirst() paneSplitRequests[handle.id] = requests guard attachments[handle.id]?.id == request.attachmentID, - launchedHandles.contains(handle.id) + launchedAttachmentIDs[handle.id] == request.attachmentID else { continue } clearPaneSplitError( @@ -1087,7 +1296,7 @@ final class NativeTmuxSessionCoordinator { attachmentID: UUID ) { guard attachments[handle.id]?.id == attachmentID, - launchedHandles.contains(handle.id), + launchedAttachmentIDs[handle.id] == attachmentID, paneSplitClients[handle.id] == nil, paneSplitClientBindings[handle.id] == nil else { return } @@ -1231,15 +1440,24 @@ final class NativeTmuxSessionCoordinator { ) cancelPaneSplits(handleID: handle.id) attachmentClosures[handle.id] = closure + let failureID = UUID() + surfaceLaunchFailureIDs[handle.id] = failureID let attachment = attachments.removeValue(forKey: handle.id) - remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) - Task { try? await attachment?.sshConnection?.release() } + launchedAttachmentIDs.removeValue(forKey: handle.id) + closedLaunchedHandles.remove(handle.id) + cancelSizingTransitionsAndRelease( + handleID: handle.id, + key: sessionKey(handle), + attachment: attachment, + removesRemoteExitStatus: true + ) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) terminalCoordinator.removeSurface(for: surfaceKey(handle)) reportSurfaceStateLater( handle, - state: .disconnected(reason: reason) + state: .disconnected(reason: reason), + requiredLaunchFailureID: failureID ) } @@ -1251,11 +1469,15 @@ final class NativeTmuxSessionCoordinator { attachments[handle.id]?.supportsPaneSplitting == true } + func supportsClientSizing(_ handle: BorrowedTmuxSessionHandle) -> Bool { + attachments[handle.id]?.supportsClientSizing == true + } + func attachedSessionIdentity( _ handle: BorrowedTmuxSessionHandle ) -> TmuxSessionIdentity? { guard attachments[handle.id] != nil, - launchedHandles.contains(handle.id) + hasLaunched(handle) else { return nil } return paneSplitClients[handle.id]?.sessionIdentity } @@ -1264,7 +1486,7 @@ final class NativeTmuxSessionCoordinator { _ handle: BorrowedTmuxSessionHandle ) -> TmuxAttachedSessionIdentityResolution { guard attachments[handle.id] != nil, - launchedHandles.contains(handle.id) + hasLaunched(handle) else { return .pending } if let identity = paneSplitClients[handle.id]?.sessionIdentity { return .resolved(identity) @@ -1277,10 +1499,11 @@ final class NativeTmuxSessionCoordinator { func requestAttachedSessionIdentity( _ handle: BorrowedTmuxSessionHandle ) { + guard handlesByKey[sessionKey(handle)] == handle else { return } + previewIdentityRetryHandles.insert(handle.id) guard let attachment = attachments[handle.id], - launchedHandles.contains(handle.id) + launchedAttachmentIDs[handle.id] == attachment.id else { return } - previewIdentityRetryHandles.insert(handle.id) startPaneSplitClientBinding( target: paneSplitTarget( handle: handle, @@ -1297,7 +1520,7 @@ final class NativeTmuxSessionCoordinator { ) async -> TmuxSessionIdentity? { guard let attachment = attachments[handle.id], attachment.supportsPaneSplitting, - launchedHandles.contains(handle.id), + launchedAttachmentIDs[handle.id] == attachment.id, paneSplitClients[handle.id] != nil else { return nil } let attachmentID = attachment.id @@ -1311,7 +1534,7 @@ final class NativeTmuxSessionCoordinator { ) guard !Task.isCancelled, attachments[handle.id]?.id == attachmentID, - launchedHandles.contains(handle.id), + launchedAttachmentIDs[handle.id] == attachmentID, case let .success(client) = result else { return nil } return client.sessionIdentity @@ -1333,7 +1556,8 @@ final class NativeTmuxSessionCoordinator { private func reportSurfaceStateLater( _ handle: BorrowedTmuxSessionHandle, state: ConnectionState, - requiredAttachmentID: UUID? = nil + requiredAttachmentID: UUID? = nil, + requiredLaunchFailureID: UUID? = nil ) { Task { [weak self] in guard let self, !isShuttingDown else { return } @@ -1341,10 +1565,17 @@ final class NativeTmuxSessionCoordinator { guard handlesByKey[key] == handle else { return } if let requiredAttachmentID { guard attachments[handle.id]?.id == requiredAttachmentID, - launchedHandles.contains(handle.id), + launchedAttachmentIDs[handle.id] + == requiredAttachmentID, attachmentClosures[handle.id] == nil else { return } } + if let requiredLaunchFailureID { + guard surfaceLaunchFailureIDs[handle.id] + == requiredLaunchFailureID + else { return } + surfaceLaunchFailureIDs.removeValue(forKey: handle.id) + } onStateChanged?(handle, state) } } @@ -1357,6 +1588,14 @@ final class NativeTmuxSessionCoordinator { let key = sessionKey(handle) guard handlesByKey[key] == handle else { return } let attachment = attachments.removeValue(forKey: handle.id) + let launchedAttachmentID = launchedAttachmentIDs.removeValue( + forKey: handle.id + ) + if attachment?.id == launchedAttachmentID { + closedLaunchedHandles.insert(handle.id) + } else { + closedLaunchedHandles.remove(handle.id) + } cancelPaneSplits(handleID: handle.id) let recordedExitCode = remoteExitStatusStore.consume( attachment?.remoteExitStatusURL @@ -1375,12 +1614,12 @@ final class NativeTmuxSessionCoordinator { recordedExitCode: recordedExitCode, childExitCode: childExitCode ) - Task { - if connectionUnusable { - await attachment?.sshConnection?.invalidate() - } - try? await attachment?.sshConnection?.release() - } + cancelSizingTransitionsAndRelease( + handleID: handle.id, + key: key, + attachment: attachment, + invalidatesConnection: connectionUnusable + ) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) terminalCoordinator.removeSurface(for: surfaceKey(handle)) @@ -1396,6 +1635,12 @@ final class NativeTmuxSessionCoordinator { isShuttingDown = true let handles = Array(handlesByKey.values) let connections = attachments.values.compactMap(\.sshConnection) + let sizingTransitions = sizingTransitionTasks.values.flatMap(\.values) + let sizingCleanups = Array(sizingTransitionCleanupTasks.values) + sizingTransitions.forEach { $0.cancel() } + sizingCleanups.forEach { $0.cancel() } + sizingTransitionTasks.removeAll() + sizingTransitionTails.removeAll() provisioningTasks.values.forEach { $0.cancel() } paneSplitClientBindings.values.forEach { $0.task.cancel() } paneSplitWorkers.values.forEach { $0.task.cancel() } @@ -1416,10 +1661,20 @@ final class NativeTmuxSessionCoordinator { } attachments.removeAll() attachmentClosures.removeAll() - launchedHandles.removeAll() + surfaceLaunchFailureIDs.removeAll() + launchedAttachmentIDs.removeAll() + closedLaunchedHandles.removeAll() reportedConnectedAttachmentIDs.removeAll() deferredPresentationStyleHandles.removeAll() - interactiveSizingHandles.removeAll() + pendingSizingByHandle.removeAll() + for transition in sizingTransitions { + _ = await transition.value + } + for cleanup in sizingCleanups { + await cleanup.value + } + attachmentCleanupTails.removeAll() + sizingTransitionCleanupTasks.removeAll() for handle in handles { terminalCoordinator.removeSurface(for: surfaceKey(handle)) } diff --git a/Sources/App/TmuxPaneSplitter.swift b/Sources/App/TmuxPaneSplitter.swift index 6a78134a..b1669c0d 100644 --- a/Sources/App/TmuxPaneSplitter.swift +++ b/Sources/App/TmuxPaneSplitter.swift @@ -88,6 +88,20 @@ struct TmuxPaneSplitter: Sendable { static func supportsPaneSplitting( version: String, host: CommandHost + ) -> Bool { + supportsExactClientTargeting(version: version, host: host) + } + + static func supportsClientSizing( + version: String, + host: CommandHost + ) -> Bool { + supportsExactClientTargeting(version: version, host: host) + } + + private static func supportsExactClientTargeting( + version: String, + host: CommandHost ) -> Bool { guard platform(for: host) == .posix else { return false } let fields = version.split(whereSeparator: \.isWhitespace) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index d2926589..bf7d203c 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -506,6 +506,10 @@ final class WorkspaceSceneModel: ObservableObject { ].joined(separator: ":") } } + private enum RetainedTmuxSizingIntent { + case interactive + case hidden + } private final class RetainedTmuxPresentation { var selection: WorkspaceTmuxSessionSelection var handle: BorrowedTmuxSessionHandle @@ -522,6 +526,17 @@ final class WorkspaceSceneModel: ObservableObject { var previewPromotionTask: Task? var previewPromotionNavigationRevision: UInt64? var pendingPreviewPromotionNavigationRevision: UInt64? + var sizingIntent: RetainedTmuxSizingIntent = .interactive + var sizingTransitionID: UUID? + var sizingTransitionTask: Task? + var pendingSizingActivationNavigationRevision: UInt64? + var hiddenSizingReconnectPending = false + var hiddenSizingProvisioningPending = false + /// Fixed per attachment: kwt launches the client and cannot apply + /// hidden sizing first, so it must be applied after launch. The + /// reconnect phase is not a substitute because discovery can advance + /// it while the attachment is still provisioning. + var launchesThroughKwtWorkspace = false var previewPromotionIsPending: Bool { previewPromotionTask != nil @@ -2473,6 +2488,10 @@ final class WorkspaceSceneModel: ObservableObject { presentation.previewPromotionNavigationRevision = nil presentation.previewPromotionTask?.cancel() presentation.previewPromotionTask = nil + presentation.sizingTransitionID = nil + presentation.pendingSizingActivationNavigationRevision = nil + presentation.sizingTransitionTask?.cancel() + presentation.sizingTransitionTask = nil tmuxSessionPreviewCoordinator.remove( TmuxPresentationKey(presentation.selection).previewKey, reason: .close @@ -2820,8 +2839,6 @@ final class WorkspaceSceneModel: ObservableObject { Set? var requiresWorkspaceReestablishment = false var terminatedSession = false - var killedSessionReestablishmentTarget: - WorkspaceTmuxSessionSelection? invalidateKwtInventoryRefresh() defer { ownsWorktreeMutation = false @@ -2833,14 +2850,6 @@ final class WorkspaceSceneModel: ObservableObject { requiresWorkspaceReestablishment: requiresWorkspaceReestablishment ) - if let killedSessionReestablishmentTarget { - _ = presentTmuxSession( - killedSessionReestablishmentTarget, - launchMode: .attach, - intent: .userInitiated, - activatesPresentation: false - ) - } } let preflight: KwtHostInventory @@ -3011,8 +3020,6 @@ final class WorkspaceSceneModel: ObservableObject { removalHostEndpointMatches(request), !terminatedSession || killedRestorationTarget != nil, changes.hasUncommittedChanges { - killedSessionReestablishmentTarget = - killedRestorationTarget throw KwtWorktreeError.removalChangesChanged } throw removalError @@ -4787,7 +4794,8 @@ final class WorkspaceSceneModel: ObservableObject { requiresWorkspaceEstablishment: presentation.reconnectContext?.phase == .establishingWorkspace, - wasActive: activeBorrowedTmuxHandle == presentation.handle, + wasActive: activeBorrowedTmuxHandle == presentation.handle + || tmuxPresentationActivationIsPending(presentation), userNavigationRevision: userNavigationRevision ) invalidateBorrowedTmuxSession(presentation.selection) @@ -4818,15 +4826,23 @@ final class WorkspaceSceneModel: ObservableObject { ) else { continue } let establishesWorkspace = requiresWorkspaceReestablishment || presentation.requiresWorkspaceEstablishment + let activatesPresentation = presentation.wasActive + && presentation.userNavigationRevision + == userNavigationRevision + // Kwt cannot establish a missing workspace with a non-sizing + // client. Leave inactive establishment for an explicit open, + // which can safely use an interactive kwt attachment. + guard activatesPresentation || !establishesWorkspace else { + continue + } _ = presentTmuxSession( selection, launchMode: establishesWorkspace ? .attach : presentation.launchMode, intent: establishesWorkspace ? .userInitiated : .restoreOnly, - activatesPresentation: presentation.wasActive - && presentation.userNavigationRevision - == userNavigationRevision + activatesPresentation: activatesPresentation, + startsHidden: !activatesPresentation ) } } @@ -5620,6 +5636,9 @@ final class WorkspaceSceneModel: ObservableObject { presentation.reconnectContext = context presentation.establishmentConfirmationTask?.cancel() presentation.establishmentConfirmationTask = nil + releaseProtectedTmuxAttachmentScope( + handleID: presentation.handle.id + ) } if publish { applyRuntimeInventoryOverlayIfNeeded(hostID: hostID) @@ -9404,6 +9423,7 @@ final class WorkspaceSceneModel: ObservableObject { commandReplayAuthorized: Bool = false, intent: TmuxPresentationIntent = .userInitiated, activatesPresentation: Bool = true, + startsHidden: Bool = false, ignoresClientSize: Bool = false, previewGridSize: TmuxGridSize? = nil ) -> BorrowedTmuxSessionHandle? { @@ -9516,7 +9536,7 @@ final class WorkspaceSceneModel: ObservableObject { let attachmentHost = CommandHostResolver.resolve(host) else { if activatesPresentation { - prepareActiveTmuxPreviewForDeactivation() + prepareActiveTmuxPresentationForDeactivation(excluding: nil) activeBorrowedTmuxSelection = selection activeBorrowedTmuxHandle = nil activeBorrowedTmuxLaunchMode = effectiveLaunchMode @@ -9525,6 +9545,22 @@ final class WorkspaceSceneModel: ObservableObject { } return nil } + // Psmux has no equivalent to tmux's exact-client ignore-size flag. + // An inactive Windows attachment must wait for an explicit open. + guard !startsHidden || host.platform != .windows else { return nil } + // A protected worktree lives on its own tmux socket. Without that + // socket a direct attach would target the default server, which never + // owns a protected session, so leave restoration to an explicit open. + if startsHidden, + selection.tmuxAttachMode == .protected, + selection.socketName == nil { + return nil + } + // Kwt owns workspace establishment, but its tmux attach cannot apply + // client flags before joining the session. Hidden restoration never + // establishes a workspace, so attach directly with ignore-size. + let attachmentLaunchMode: TmuxAttachmentLaunchMode = + startsHidden ? .attachOnly : effectiveLaunchMode let knownSessions = tmuxSessionsByHost[selection.hostID] ?? host.tmuxSessions let sessionIsDiscovered = selection.socketName == nil @@ -9532,12 +9568,12 @@ final class WorkspaceSceneModel: ObservableObject { let managedKwtUnavailable = kwtAvailabilityByHost[selection.hostID] == false let openWorkspace = intent == .userInitiated - && effectiveLaunchMode == .attach + && attachmentLaunchMode == .attach && selection.tmuxAttachMode == .direct && selection.workspacePath != nil && (!sessionIsDiscovered || !managedKwtUnavailable) let protectedSessionNeedsEstablishment = intent == .userInitiated - && effectiveLaunchMode == .attach + && attachmentLaunchMode == .attach && selection.tmuxAttachMode == .protected && selection.workspacePath != nil let discoveredIdentity = Self.discoveredTmuxSessionIdentity( @@ -9550,20 +9586,21 @@ final class WorkspaceSceneModel: ObservableObject { host: attachmentHost, socketName: selection.socketName, tmuxAttachMode: selection.tmuxAttachMode, - launchMode: effectiveLaunchMode, - initialCommand: effectiveLaunchMode == .create + launchMode: attachmentLaunchMode, + initialCommand: attachmentLaunchMode == .create ? initialCommand : nil, workingDirectory: selection.workspacePath, openWorkspace: openWorkspace, sessionIdentity: discoveredIdentity, - ignoresClientSize: ignoresClientSize, - previewGridSize: previewGridSize + ignoresClientSize: startsHidden || ignoresClientSize, + previewGridSize: startsHidden + ? self.previewGridSize(for: selection) : previewGridSize ) let phase: RemoteTmuxEstablishmentPhase if openWorkspace || protectedSessionNeedsEstablishment { phase = .establishingWorkspace - } else if effectiveLaunchMode == .create, + } else if attachmentLaunchMode == .create, let initialCommand, !initialCommand.isEmpty { phase = .establishingProfile(initialCommand: initialCommand) @@ -9587,7 +9624,7 @@ final class WorkspaceSceneModel: ObservableObject { let presentation = RetainedTmuxPresentation( selection: selection, handle: handle, - launchMode: effectiveLaunchMode, + launchMode: attachmentLaunchMode, reconnectContext: reconnectContext, reconnectSupervisor: SessionReconnectSupervisor( intervals: tmuxReconnectIntervals, @@ -9595,6 +9632,9 @@ final class WorkspaceSceneModel: ObservableObject { ), verifiedPreviewIdentity: nil ) + presentation.sizingIntent = startsHidden ? .hidden : .interactive + presentation.launchesThroughKwtWorkspace = + openWorkspace || protectedSessionNeedsEstablishment presentation.reconnectExpectedIdentity = discoveredIdentity objectWillChange.send() retainedTmuxPresentations[key] = presentation @@ -9610,7 +9650,7 @@ final class WorkspaceSceneModel: ObservableObject { if activatesPresentation { activateTmuxPresentation(presentation) } - if effectiveLaunchMode == .create { + if attachmentLaunchMode == .create { transferPendingCreation( for: PendingTmuxSessionCreation( request: WorkspaceTmuxSessionCreationRequest( @@ -9783,6 +9823,7 @@ final class WorkspaceSceneModel: ObservableObject { private func previewGridSize( for selection: WorkspaceTmuxSessionSelection ) -> TmuxGridSize? { + guard selection.socketName == nil else { return nil } let sessions = tmuxSessionsByHost[selection.hostID] ?? snapshot.host(id: selection.hostID)?.tmuxSessions return sessions?.first { @@ -9907,6 +9948,12 @@ final class WorkspaceSceneModel: ObservableObject { presentation.reconnectContext?.routeIdentity = routeIdentity } let key = TmuxPresentationKey(presentation.selection) + if presentation.sizingIntent == .hidden, + presentation.hiddenSizingProvisioningPending { + presentation.hiddenSizingProvisioningPending = false + invalidateBorrowedTmuxSession(presentation.selection) + return + } if alwaysLiveManagedTmuxPresentationKeys.contains(key) { excludeAlwaysLiveTmuxPresentation(presentation, key: key) return @@ -10005,6 +10052,30 @@ final class WorkspaceSceneModel: ObservableObject { presentation.reconnectContext?.routeIdentity = routeIdentity } let key = TmuxPresentationKey(presentation.selection) + if presentation.pendingSizingActivationNavigationRevision != nil { + guard presentation.sizingTransitionTask == nil else { return } + activateTmuxPresentation(presentation) + return + } + if presentation.hiddenSizingReconnectPending { + guard presentation.sizingTransitionTask == nil else { return } + presentation.hiddenSizingReconnectPending = false + } + if presentation.sizingIntent == .hidden, + !nativeTmuxSessionCoordinator.supportsClientSizing(handle) { + invalidateBorrowedTmuxSession(presentation.selection) + return + } + if presentation.hiddenSizingProvisioningPending { + guard presentation.sizingTransitionTask == nil else { return } + guard nativeTmuxSessionCoordinator.hasLaunched(handle) else { + finishTmuxSurfaceReadiness(handle) + return + } + presentation.hiddenSizingProvisioningPending = false + hideTmuxPresentationSizing(presentation) + return + } // Resume a pending user promotion before the preview-support // filter: a session the user explicitly opened during provisioning // must become an ordinary interactive attachment even when the @@ -10305,6 +10376,122 @@ final class WorkspaceSceneModel: ObservableObject { _ presentation: RetainedTmuxPresentation ) { let key = TmuxPresentationKey(presentation.selection) + let resumesPendingSizing = presentation + .pendingSizingActivationNavigationRevision != nil + guard presentation.sizingIntent == .hidden || resumesPendingSizing + else { + activateTmuxPresentationAfterSizing(presentation, key: key) + return + } + guard let host = snapshot.host(id: presentation.selection.hostID) + else { + invalidateBorrowedTmuxSession(presentation.selection) + return + } + guard host.platform != .windows else { + presentation.sizingIntent = .interactive + presentation.pendingSizingActivationNavigationRevision = nil + activateTmuxPresentationAfterSizing(presentation, key: key) + return + } + if nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle + ) { + presentation.sizingIntent = .interactive + presentation.pendingSizingActivationNavigationRevision = nil + activateTmuxPresentationAfterSizing(presentation, key: key) + return + } + + stageTmuxPresentationActivation(presentation) + presentation.sizingIntent = .interactive + presentation.hiddenSizingReconnectPending = false + presentation.hiddenSizingProvisioningPending = false + let navigationRevision = userNavigationRevision + presentation.pendingSizingActivationNavigationRevision = + navigationRevision + let predecessor = presentation.sizingTransitionTask + let transitionID = UUID() + presentation.sizingTransitionID = transitionID + presentation.sizingTransitionTask = Task { @MainActor [weak self, weak presentation] in + guard let self, let presentation else { return } + defer { + if presentation.sizingTransitionID == transitionID { + presentation.sizingTransitionID = nil + presentation.sizingTransitionTask = nil + let resumesCurrentActivation = presentation + .pendingSizingActivationNavigationRevision + == userNavigationRevision + && tmuxPresentationActivationIsPending(presentation) + if resumesCurrentActivation { + if !nativeTmuxSessionCoordinator.isProvisioning( + presentation.handle + ), !nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle + ) { + tmuxSurfaceBecameReady(presentation.handle) + } + } else if presentation + .pendingSizingActivationNavigationRevision != nil { + presentation + .pendingSizingActivationNavigationRevision = nil + } + } + } + if let predecessor { + await predecessor.value + } + guard !Task.isCancelled, + retainedTmuxPresentations[key] === presentation, + presentation.sizingIntent == .interactive, + presentation.pendingSizingActivationNavigationRevision + == navigationRevision, + userNavigationRevision == navigationRevision, + tmuxPresentationActivationIsPending(presentation) + else { return } + + var result: TmuxClientSizingTransitionResult + repeat { + result = await nativeTmuxSessionCoordinator + .enableInteractiveSizing(for: presentation.handle) + guard !Task.isCancelled, + retainedTmuxPresentations[key] === presentation, + presentation.sizingIntent == .interactive, + presentation.pendingSizingActivationNavigationRevision + == navigationRevision, + userNavigationRevision == navigationRevision, + tmuxPresentationActivationIsPending(presentation) + else { return } + } while result == .stale + + switch result { + case .applied: + presentation.pendingSizingActivationNavigationRevision = nil + activateTmuxPresentationAfterSizing(presentation, key: key) + case .pending, .stale: + return + case let .failure(failure): + presentation.pendingSizingActivationNavigationRevision = nil + let selection = presentation.selection + invalidateBorrowedTmuxSession(selection) + AppLogger.shared.error( + "tmux interactive sizing: " + + failure.localizedDescription, + context: "tmux" + ) + if userNavigationRevision == navigationRevision, + activeBorrowedTmuxSelection == selection, + activeBorrowedTmuxHandle == nil { + openBorrowedTmuxSession(selection) + } + } + } + } + + private func activateTmuxPresentationAfterSizing( + _ presentation: RetainedTmuxPresentation, + key: TmuxPresentationKey + ) { tmuxSessionPreviewCoordinator.prepareToActivate( key.previewKey, activate: { [weak self, weak presentation] in @@ -10319,10 +10506,9 @@ final class WorkspaceSceneModel: ObservableObject { private func stageTmuxPresentationActivation( _ presentation: RetainedTmuxPresentation ) { - if let activeHandle = activeBorrowedTmuxHandle, - activeHandle != presentation.handle { - prepareActiveTmuxPreviewForDeactivation() - } + prepareActiveTmuxPresentationForDeactivation( + excluding: presentation.handle + ) activeBorrowedTmuxSelection = presentation.selection activeBorrowedTmuxHandle = nil activeBorrowedTmuxLaunchMode = presentation.launchMode @@ -10340,10 +10526,9 @@ final class WorkspaceSceneModel: ObservableObject { private func commitTmuxPresentationActivation( _ presentation: RetainedTmuxPresentation ) { - if let activeHandle = activeBorrowedTmuxHandle, - activeHandle != presentation.handle { - prepareActiveTmuxPreviewForDeactivation() - } + prepareActiveTmuxPresentationForDeactivation( + excluding: presentation.handle + ) activeBorrowedTmuxSelection = presentation.selection activeBorrowedTmuxHandle = presentation.handle activeBorrowedTmuxLaunchMode = presentation.launchMode @@ -10365,7 +10550,7 @@ final class WorkspaceSceneModel: ObservableObject { _ selection: WorkspaceTmuxSessionSelection ) { guard activeBorrowedTmuxSelection == selection else { return } - prepareActiveTmuxPreviewForDeactivation() + prepareActiveTmuxPresentationForDeactivation(excluding: nil) activeBorrowedTmuxSelection = nil activeBorrowedTmuxHandle = nil activeBorrowedTmuxLaunchMode = nil @@ -10373,6 +10558,120 @@ final class WorkspaceSceneModel: ObservableObject { sessionConnectionRecoveryRequest = nil } + private func prepareActiveTmuxPresentationForDeactivation( + excluding retainedHandle: BorrowedTmuxSessionHandle? + ) { + guard let activeSelection = activeBorrowedTmuxSelection, + let presentation = retainedTmuxPresentation( + for: activeSelection + ), + presentation.handle != retainedHandle + else { return } + prepareActiveTmuxPreviewForDeactivation() + hideTmuxPresentationSizing(presentation) + } + + private func hideTmuxPresentationSizing( + _ presentation: RetainedTmuxPresentation + ) { + let key = TmuxPresentationKey(presentation.selection) + guard !alwaysLiveManagedTmuxPresentationKeys.contains(key) else { + return + } + guard let host = snapshot.host(id: presentation.selection.hostID) + else { + invalidateBorrowedTmuxSession(presentation.selection) + return + } + guard host.platform != .windows else { return } + // Protected sessions can live on a nondefault server selected by + // kwt. Without its socket, an exact-client command would target the + // default server and could mutate an unrelated client. + if presentation.selection.tmuxAttachMode == .protected, + presentation.selection.socketName == nil { + invalidateBorrowedTmuxSession(presentation.selection) + return + } + + presentation.sizingIntent = .hidden + presentation.pendingSizingActivationNavigationRevision = nil + guard !nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle + ) else { return } + if !nativeTmuxSessionCoordinator.isProvisioning( + presentation.handle + ), !nativeTmuxSessionCoordinator.supportsClientSizing( + presentation.handle + ) { + invalidateBorrowedTmuxSession(presentation.selection) + return + } + let predecessor = presentation.sizingTransitionTask + let transitionID = UUID() + let gridSize = previewGridSize(for: presentation.selection) + presentation.sizingTransitionID = transitionID + presentation.sizingTransitionTask = Task { @MainActor [weak self, weak presentation] in + guard let self, let presentation else { return } + defer { + if presentation.sizingTransitionID == transitionID { + presentation.sizingTransitionID = nil + presentation.sizingTransitionTask = nil + if presentation.hiddenSizingReconnectPending + || presentation.hiddenSizingProvisioningPending, + !nativeTmuxSessionCoordinator.isProvisioning( + presentation.handle + ), + !nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle + ) { + tmuxSurfaceBecameReady(presentation.handle) + } + } + } + if let predecessor { + await predecessor.value + } + guard !Task.isCancelled, + retainedTmuxPresentations[key] === presentation, + presentation.sizingIntent == .hidden + else { return } + + var result: TmuxClientSizingTransitionResult + repeat { + result = await nativeTmuxSessionCoordinator + .restorePreviewSizing( + gridSize, + for: presentation.handle + ) + guard !Task.isCancelled, + retainedTmuxPresentations[key] === presentation, + presentation.sizingIntent == .hidden + else { return } + if result == .stale, + presentation.hiddenSizingReconnectPending { + return + } + } while result == .stale + + if result == .pending, + presentation.launchesThroughKwtWorkspace { + presentation.hiddenSizingProvisioningPending = true + nativeTmuxSessionCoordinator.requestAttachedSessionIdentity( + presentation.handle + ) + } else if case let .failure(failure) = result { + guard !presentation.hiddenSizingReconnectPending else { + return + } + invalidateBorrowedTmuxSession(presentation.selection) + AppLogger.shared.error( + "tmux hidden sizing: " + failure.localizedDescription, + context: "tmux" + ) + } + } + } + var retainedBorrowedTmuxPresentationCount: Int { retainedTmuxPresentations.count } @@ -10406,6 +10705,13 @@ final class WorkspaceSceneModel: ObservableObject { return borrowedTmuxConnectionStates[handle.id] == .connected } + func retainedBorrowedTmuxSessionHasPendingHiddenSizing( + _ selection: WorkspaceTmuxSessionSelection + ) -> Bool { + retainedTmuxPresentation(for: selection)? + .hiddenSizingProvisioningPending == true + } + private static func sameTmuxSession( _ lhs: WorkspaceTmuxSessionSelection, _ rhs: WorkspaceTmuxSessionSelection @@ -10522,6 +10828,11 @@ final class WorkspaceSceneModel: ObservableObject { .previewPromotionNavigationRevision = nil retainedTmuxPresentations[key]?.previewPromotionTask?.cancel() retainedTmuxPresentations[key]?.previewPromotionTask = nil + retainedTmuxPresentations[key]?.sizingTransitionID = nil + retainedTmuxPresentations[key]? + .pendingSizingActivationNavigationRevision = nil + retainedTmuxPresentations[key]?.sizingTransitionTask?.cancel() + retainedTmuxPresentations[key]?.sizingTransitionTask = nil if activeBorrowedTmuxSelection == selection { prepareActiveTmuxPreviewForDeactivation() } @@ -10557,7 +10868,9 @@ final class WorkspaceSceneModel: ObservableObject { confirmedEndedTmuxSessionHandles.remove(handle.id) borrowedTmuxConnectionStates.removeValue(forKey: handle.id) if var pending = pendingCreatedTmuxSessions[handle.id] { - if nativeTmuxSessionCoordinator.hasLaunched(handle) { + if nativeTmuxSessionCoordinator.hasLaunched(handle) + || nativeTmuxSessionCoordinator + .closedAttachmentHadLaunched(handle) { pending.commandReplayAuthorized = false pendingCreatedTmuxSessions[handle.id] = pending endedCreatedTmuxSessionHandles.insert(handle.id) @@ -10816,9 +11129,14 @@ final class WorkspaceSceneModel: ObservableObject { return } let key = TmuxPresentationKey(presentation.selection) + if case .disconnected = state, + presentation.sizingIntent == .interactive, + presentation.pendingSizingActivationNavigationRevision != nil { + presentation.sizingTransitionTask?.cancel() + } if case .disconnected = state, alwaysLiveManagedTmuxPresentationKeys.contains(key), - !nativeTmuxSessionCoordinator.hasLaunched(handle), + !nativeTmuxSessionCoordinator.closedAttachmentHadLaunched(handle), nativeTmuxSessionCoordinator.attachmentClosure(handle) != .surfaceUnavailable, nativeTmuxSessionCoordinator.attachmentClosure(handle) @@ -10831,6 +11149,13 @@ final class WorkspaceSceneModel: ObservableObject { return } if case .disconnected = state { + if presentation.sizingIntent == .hidden, + presentation.sizingTransitionTask != nil, + presentation.reconnectContext?.handleID == handle.id, + case .some(.processExited) = nativeTmuxSessionCoordinator + .attachmentClosure(handle) { + presentation.hiddenSizingReconnectPending = true + } beginTmuxPreviewReconnect(presentation) } else if case .reconnecting = state { if previousState == .connected { @@ -10905,16 +11230,17 @@ final class WorkspaceSceneModel: ObservableObject { return } if case .disconnected = state, - nativeTmuxSessionCoordinator.hasLaunched(handle) { + nativeTmuxSessionCoordinator.closedAttachmentHadLaunched(handle) { cancelTmuxPresentationTasks(handleID: handle.id) if var context = presentation.reconnectContext, context.handleID == handle.id, - context.host.isRemote, case let .processExited(code) = nativeTmuxSessionCoordinator.attachmentClosure(handle) { presentation.establishmentConfirmationTask?.cancel() presentation.establishmentConfirmationTask = nil - if code == 127, context.usesKwtWorkspaceCommand { + if context.host.isRemote, + code == 127, + context.usesKwtWorkspaceCommand { markRemoteKwtUnavailable( hostID: context.selection.hostID ) @@ -10933,7 +11259,8 @@ final class WorkspaceSceneModel: ObservableObject { case .connected: reconcileCreatedTmuxSession(handleID: handle.id) case .disconnected: - guard nativeTmuxSessionCoordinator.hasLaunched(handle) else { + guard nativeTmuxSessionCoordinator + .closedAttachmentHadLaunched(handle) else { if pending.initialCommand != nil { pending.commandReplayAuthorized = true pendingCreatedTmuxSessions[handle.id] = pending @@ -12241,7 +12568,7 @@ final class WorkspaceSceneModel: ObservableObject { else { stopTmuxReconnectWithUnableToAttach( presentation, - "The remote tmux client exited before it could attach." + "The tmux client exited before it could attach." ) return .stop } @@ -12266,7 +12593,7 @@ final class WorkspaceSceneModel: ObservableObject { else { stopTmuxReconnectWithUnableToAttach( presentation, - "The remote workspace could not be established." + "The workspace could not be established." ) return .stop } @@ -12371,9 +12698,23 @@ final class WorkspaceSceneModel: ObservableObject { let presentationKey = TmuxPresentationKey(selection) let isAlwaysLiveManaged = alwaysLiveManagedTmuxPresentationKeys .contains(presentationKey) - let previewGridSize = (tmuxSessionsByHost[selection.hostID] - ?? host.tmuxSessions).first { $0.name == selection.name }? - .previewClientSize + let reconnectsNonSizing = host.platform != .windows + && (isAlwaysLiveManaged || presentation.sizingIntent == .hidden) + // Kwt creates the tmux client as part of workspace establishment and + // cannot apply client flags first. Keep that attach interactive long + // enough for kwt to establish the workspace, then restore preview + // sizing after the exact client is available. + let usesKwtWorkspaceEstablishment = launchMode == .attach + && (openWorkspace || protectedSessionNeedsEstablishment) + let defersHiddenSizingForWorkspaceEstablishment = + presentation.sizingIntent == .hidden + && usesKwtWorkspaceEstablishment + presentation.hiddenSizingProvisioningPending = + defersHiddenSizingForWorkspaceEstablishment + presentation.launchesThroughKwtWorkspace = usesKwtWorkspaceEstablishment + let startsNonSizing = reconnectsNonSizing + && !defersHiddenSizingForWorkspaceEstablishment + let previewGridSize = previewGridSize(for: selection) let handle = nativeTmuxSessionCoordinator.attach( hostID: selection.hostID, name: selection.name, @@ -12386,10 +12727,12 @@ final class WorkspaceSceneModel: ObservableObject { openWorkspace: openWorkspace, sessionIdentity: presentation.reconnectExpectedIdentity, expectedRouteIdentity: routeIdentity, - ignoresClientSize: isAlwaysLiveManaged - && host.platform != .windows, - previewGridSize: isAlwaysLiveManaged ? previewGridSize : nil + ignoresClientSize: startsNonSizing, + previewGridSize: startsNonSizing ? previewGridSize : nil ) + if defersHiddenSizingForWorkspaceEstablishment { + nativeTmuxSessionCoordinator.requestAttachedSessionIdentity(handle) + } if handle.id != previousHandle.id { retainedTmuxPresentationKeysByHandle.removeValue( forKey: previousHandle.id diff --git a/Sources/Tmux/TmuxAttachmentInfo.swift b/Sources/Tmux/TmuxAttachmentInfo.swift index b0f65867..4237eeb3 100644 --- a/Sources/Tmux/TmuxAttachmentInfo.swift +++ b/Sources/Tmux/TmuxAttachmentInfo.swift @@ -377,7 +377,8 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ) } let attach: String - if let protectedWorkspacePath, launchMode != .attachOnly { + if let protectedWorkspacePath, + launchMode != .attachOnly { let protectedAttach: String if let remoteKwtCommandPrelude { let kwtAttach = remoteKwtCommandPrelude diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index cd5d272b..e7786a13 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -16,6 +16,13 @@ private let coordinatorSplitClientOutput = "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY\t123\t789\t321" + "\t/dev/ttys001\t$7\t456\t%9\n" +enum SizingTeardown: Sendable { + case detach + case launchFailure + case shutdown + case surfaceClose +} + private func supportedPaneSplitter( _ runner: @escaping TmuxPaneSplitter.Runner ) -> TmuxPaneSplitter { @@ -385,6 +392,8 @@ struct NativeTmuxSessionCoordinatorTests { let close = try #require(store.surface.closeObservers[handle.id]) close(false, 255) + #expect(!coordinator.hasLaunched(handle)) + #expect(coordinator.closedAttachmentHadLaunched(handle)) let reattached = coordinator.attach( hostID: hostID, name: "release-work", @@ -1352,6 +1361,77 @@ struct NativeTmuxSessionCoordinatorTests { #expect(store.requestedKeys.count == requestCount) } + @Test("host replacement waits for stale provisioning release") + func hostReplacementWaitsForStaleProvisioningRelease() async throws { + let events = LockedValue<[String]>([]) + let originalResolutionStarted = LockedValue(false) + let releaseOriginalResolution = DispatchSemaphore(value: 0) + defer { releaseOriginalResolution.signal() } + let originalHost = SSHHostInfo( + user: "user", + hostname: "old.example.test", + port: nil + ) + let replacementHost = SSHHostInfo( + user: "user", + hostname: "new.example.test", + port: nil + ) + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: RecordingNativeSessionSurfaceStore(), + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + remoteTmuxPathProvider: { host, _ in + if host == originalHost { + originalResolutionStarted.store(true) + _ = releaseOriginalResolution.wait(timeout: .now() + 5) + } + return successfulTmuxResolution("/usr/bin/tmux") + }, + remoteConnectionProvider: { _, host in + let label = host == originalHost ? "original" : "replacement" + events.withLock { $0.append("\(label)-acquire") } + return testKwtSSHAttachment(release: { + events.withLock { $0.append("\(label)-release") } + }) + } + ) + let hostID = UUID() + let original = coordinator.attach( + hostID: hostID, + name: "stale-provisioning", + host: .ssh(originalHost), + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { originalResolutionStarted.load() } + let unblockResolution = Task.detached { + try? await Task.sleep(for: .milliseconds(250)) + releaseOriginalResolution.signal() + } + + let replacement = coordinator.attach( + hostID: hostID, + name: original.name, + host: .ssh(replacementHost), + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { + events.load().contains("original-release") + && events.load().contains("replacement-acquire") + } + await unblockResolution.value + + #expect(replacement.id != original.id) + let completedEvents = events.load() + let release = try #require( + completedEvents.firstIndex(of: "original-release") + ) + let replacementAcquire = try #require( + completedEvents.firstIndex(of: "replacement-acquire") + ) + #expect(release < replacementAcquire) + await coordinator.shutdown() + } + @Test("rejected terminal surfaces never report command launch") func rejectedSurfaceDoesNotLaunch() async { let store = RecordingNativeSessionSurfaceStore( @@ -1396,6 +1476,47 @@ struct NativeTmuxSessionCoordinatorTests { #expect(store.removedKeys.count == 1) } + @Test("a replacement attach outlives the failed launch's disconnect") + func replacementAttachIgnoresStaleLaunchFailure() async { + let store = RecordingNativeSessionSurfaceStore( + launchError: SurfaceLaunchTestError.rejected + ) + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") } + ) + var states: [ConnectionState] = [] + var readyCount = 0 + coordinator.onStateChanged = { _, state in states.append(state) } + coordinator.onSurfaceReady = { _ in readyCount += 1 } + let hostID = UUID() + let handle = coordinator.attach( + hostID: hostID, + name: "release-work", + host: .local, + sessionIdentity: coordinatorSplitIdentity + ) + + await waitUntilMainActor { readyCount == 1 } + _ = coordinator.surface(handle: handle) + #expect(coordinator.attachmentClosure(handle) == .launchFailed) + let replacement = coordinator.attach( + hostID: hostID, + name: "release-work", + host: .local, + sessionIdentity: coordinatorSplitIdentity + ) + #expect(replacement == handle) + + await waitUntilMainActor { readyCount == 2 } + for _ in 0 ..< 20 { + await Task.yield() + } + #expect(states == [.connecting, .connecting]) + #expect(coordinator.attachmentClosure(handle) == nil) + await coordinator.shutdown() + } + @Test("a missing terminal surface is a launch failure") func missingSurfaceDoesNotLaunch() async { let store = MissingTmuxSurfaceStore() @@ -1558,7 +1679,11 @@ struct NativeTmuxSessionCoordinatorTests { let lookup = identityLookups.load() if lookup == 2 { promotionStarted.withLock { $0 = true } - releasePromotion.wait() + while !withUnsafeCurrentTask(body: { + $0?.isCancelled == true + }), + releasePromotion.wait(timeout: .now() + 0.005) + == .timedOut {} } return (0, coordinatorSplitClientOutput) } @@ -1614,6 +1739,435 @@ struct NativeTmuxSessionCoordinatorTests { #expect(promotionMutations.load() == 1) } + @Test("interactive sizing waits for an in-flight preview sizing mutation") + func interactiveSizingWaitsForPreviewSizing() async { + let events = LockedValue<[String]>([]) + let previewStarted = LockedValue(false) + let releasePreview = DispatchSemaphore(value: 0) + defer { releasePreview.signal() } + let store = RecordingNativeSessionSurfaceStore() + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + paneSplitter: supportedPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return (0, coordinatorSplitClientOutput) + } + if command.contains("'!ignore-size'") { + events.withLock { $0.append("interactive") } + } else if command.contains("'ignore-size'") { + events.withLock { $0.append("hidden-start") } + previewStarted.store(true) + _ = releasePreview.wait(timeout: .now() + 5) + events.withLock { $0.append("hidden-end") } + } + return (0, "") + } + ) + var isSurfaceReady = false + coordinator.onSurfaceReady = { _ in isSurfaceReady = true } + let handle = coordinator.attach( + hostID: UUID(), + name: "sizing-order", + host: .local, + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { isSurfaceReady } + _ = coordinator.surface(handle: handle) + + let hide = Task { @MainActor in + await coordinator.restorePreviewSizing(nil, for: handle) + } + await waitUntilMainActor { previewStarted.load() } + let reopen = Task { @MainActor in + await coordinator.enableInteractiveSizing(for: handle) + } + for _ in 0 ..< 20 { + await Task.yield() + } + + #expect(events.load() == ["hidden-start"]) + releasePreview.signal() + #expect(await hide.value == .applied) + #expect(await reopen.value == .applied) + #expect(events.load() == [ + "hidden-start", "hidden-end", "interactive", + ]) + } + + @Test("a cancelled sizing transition does not run after its predecessor") + func cancelledSizingTransitionDoesNotRun() async { + let events = LockedValue<[String]>([]) + let previewStarted = LockedValue(false) + let releasePreview = DispatchSemaphore(value: 0) + defer { releasePreview.signal() } + let store = RecordingNativeSessionSurfaceStore() + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + paneSplitter: supportedPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return (0, coordinatorSplitClientOutput) + } + if command.contains("'!ignore-size'") { + events.withLock { $0.append("interactive") } + } else if command.contains("'ignore-size'") { + events.withLock { $0.append("hidden-start") } + previewStarted.store(true) + _ = releasePreview.wait(timeout: .now() + 5) + events.withLock { $0.append("hidden-end") } + } + return (0, "") + } + ) + var isSurfaceReady = false + coordinator.onSurfaceReady = { _ in isSurfaceReady = true } + let handle = coordinator.attach( + hostID: UUID(), + name: "cancelled-sizing", + host: .local, + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { isSurfaceReady } + _ = coordinator.surface(handle: handle) + + let hide = Task { @MainActor in + await coordinator.restorePreviewSizing(nil, for: handle) + } + await waitUntilMainActor { previewStarted.load() } + let reopen = Task { @MainActor in + await coordinator.enableInteractiveSizing(for: handle) + } + reopen.cancel() + releasePreview.signal() + + #expect(await hide.value == .applied) + #expect(await reopen.value == .stale) + #expect(events.load() == ["hidden-start", "hidden-end"]) + } + + @Test( + "teardown cancels sizing before releasing its SSH attachment", + arguments: [ + SizingTeardown.detach, + .launchFailure, + .shutdown, + .surfaceClose, + ] + ) + func teardownCancelsSizingBeforeRelease( + _ teardown: SizingTeardown + ) async throws { + let events = LockedValue<[String]>([]) + let allowSizingCompletion = DispatchSemaphore(value: 0) + let store = RecordingNativeSessionSurfaceStore() + let hostID = UUID() + let host = SSHHostInfo( + user: "operator", + hostname: "build.example.test", + port: nil + ) + let connectionRequests = LockedValue(0) + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/bin/tmux") + }, + remoteConnectionProvider: { _, _ in + connectionRequests.withLock { $0 += 1 } + if connectionRequests.load() > 1 { + events.withLock { $0.append("replacement-acquire") } + } + return testKwtSSHAttachment( + release: { + events.withLock { $0.append("release") } + } + ) + }, + paneSplitter: supportedPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return (0, coordinatorSplitClientOutput) + } + if command.contains("'ignore-size'") { + events.withLock { $0.append("sizing-start") } + while !withUnsafeCurrentTask(body: { + $0?.isCancelled == true + }), + allowSizingCompletion.wait(timeout: .now() + 0.005) + == .timedOut {} + let wasCancelled = withUnsafeCurrentTask(body: { + $0?.isCancelled == true + }) + events.withLock { + $0.append( + wasCancelled ? "sizing-cancel" : "sizing-finish" + ) + } + } + return (0, "") + } + ) + var isSurfaceReady = false + coordinator.onSurfaceReady = { _ in isSurfaceReady = true } + coordinator.onStateChanged = { _, state in + if case .disconnected = state { + events.withLock { $0.append("disconnected") } + } + } + let handle = coordinator.attach( + hostID: hostID, + name: "teardown-sizing", + host: .ssh(host), + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { isSurfaceReady } + _ = coordinator.surface(handle: handle) + + let sizing = Task { @MainActor in + await coordinator.restorePreviewSizing(nil, for: handle) + } + await waitUntilMainActor { + events.load() == ["sizing-start"] + } + let expectsDisconnected = switch teardown { + case .detach, .shutdown: false + case .launchFailure, .surfaceClose: true + } + + switch teardown { + case .detach: + coordinator.detach( + hostID: hostID, + name: handle.name + ) + case .launchFailure: + store.surface.launchError = SurfaceLaunchTestError.rejected + _ = coordinator.surface(handle: handle) + case .shutdown: + await coordinator.shutdown() + case .surfaceClose: + let close = try #require( + store.surface.closeObservers[handle.id] + ) + close(true, nil) + } + if expectsDisconnected { + _ = coordinator.attach( + hostID: hostID, + name: handle.name, + host: .ssh(host), + sessionIdentity: coordinatorSplitIdentity + ) + } + await waitUntilMainActor { + events.load().contains("release") + && (!expectsDisconnected + || events.load().contains("replacement-acquire")) + } + + switch teardown { + case .detach, .shutdown: + #expect(events.load() == [ + "sizing-start", "sizing-cancel", "release", + ]) + case .launchFailure, .surfaceClose: + let completedEvents = events.load() + let cancellation = try #require( + completedEvents.firstIndex(of: "sizing-cancel") + ) + let release = try #require( + completedEvents.firstIndex(of: "release") + ) + let replacement = try #require( + completedEvents.firstIndex(of: "replacement-acquire") + ) + #expect(cancellation < release) + #expect(cancellation < replacement) + } + allowSizingCompletion.signal() + _ = await sizing.value + } + + @Test("host replacement waits for prior sizing cleanup") + func hostReplacementWaitsForPriorSizingCleanup() async throws { + let events = LockedValue<[String]>([]) + let connectionRequests = LockedValue(0) + let releaseSizingDrain = DispatchSemaphore(value: 0) + defer { releaseSizingDrain.signal() } + let store = RecordingNativeSessionSurfaceStore() + let hostID = UUID() + let originalHost = SSHHostInfo( + user: "operator", + hostname: "build.example.test", + port: nil + ) + let replacementHost = SSHHostInfo( + user: "operator", + hostname: "replacement.example.test", + port: nil + ) + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/bin/tmux") + }, + remoteConnectionProvider: { _, _ in + connectionRequests.withLock { $0 += 1 } + let label = connectionRequests.load() == 1 + ? "original" : "replacement" + events.withLock { $0.append("\(label)-acquire") } + return testKwtSSHAttachment(release: { + events.withLock { $0.append("\(label)-release") } + }) + }, + paneSplitter: supportedPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return (0, coordinatorSplitClientOutput) + } + if command.contains("'ignore-size'") { + events.withLock { $0.append("sizing-start") } + while !withUnsafeCurrentTask(body: { + $0?.isCancelled == true + }) { + Thread.sleep(forTimeInterval: 0.001) + } + events.withLock { $0.append("sizing-cancel") } + releaseSizingDrain.wait() + events.withLock { $0.append("sizing-drained") } + } + return (0, "") + } + ) + var readyCount = 0 + coordinator.onSurfaceReady = { _ in readyCount += 1 } + let handle = coordinator.attach( + hostID: hostID, + name: "replacement-sizing", + host: .ssh(originalHost), + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { readyCount == 1 } + _ = coordinator.surface(handle: handle) + events.store([]) + + let sizing = Task { @MainActor in + await coordinator.restorePreviewSizing(nil, for: handle) + } + await waitUntilMainActor { + events.load() == ["sizing-start"] + } + let unblockDrain = Task.detached { + try? await Task.sleep(for: .milliseconds(250)) + releaseSizingDrain.signal() + } + let replacement = coordinator.attach( + hostID: hostID, + name: handle.name, + host: .ssh(replacementHost), + sessionIdentity: coordinatorSplitIdentity + ) + + await waitUntilMainActor { + events.load().contains("original-release") + && events.load().contains("replacement-acquire") + } + await unblockDrain.value + _ = await sizing.value + #expect(replacement.id != handle.id) + let completedEvents = events.load() + let release = try #require( + completedEvents.firstIndex(of: "original-release") + ) + let replacementAcquire = try #require( + completedEvents.firstIndex(of: "replacement-acquire") + ) + #expect(release < replacementAcquire) + await coordinator.shutdown() + } + + @Test("shutdown waits for a prior close sizing drain") + func shutdownWaitsForPriorCloseSizingDrain() async throws { + let events = LockedValue<[String]>([]) + let sizingCancelled = LockedValue(false) + let releaseSizingDrain = DispatchSemaphore(value: 0) + defer { releaseSizingDrain.signal() } + let shutdownFinished = LockedValue(false) + let store = RecordingNativeSessionSurfaceStore() + let host = SSHHostInfo( + user: "operator", + hostname: "build.example.test", + port: nil + ) + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/bin/tmux") + }, + remoteConnectionProvider: { _, _ in + testKwtSSHAttachment(release: { + events.withLock { $0.append("release") } + }) + }, + paneSplitter: supportedPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return (0, coordinatorSplitClientOutput) + } + if command.contains("'ignore-size'") { + events.withLock { $0.append("sizing-start") } + while !withUnsafeCurrentTask(body: { + $0?.isCancelled == true + }) { + Thread.sleep(forTimeInterval: 0.001) + } + sizingCancelled.store(true) + events.withLock { $0.append("sizing-cancel") } + releaseSizingDrain.wait() + } + return (0, "") + } + ) + var isSurfaceReady = false + coordinator.onSurfaceReady = { _ in isSurfaceReady = true } + let handle = coordinator.attach( + hostID: UUID(), + name: "close-then-shutdown", + host: .ssh(host), + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { isSurfaceReady } + _ = coordinator.surface(handle: handle) + let sizing = Task { @MainActor in + await coordinator.restorePreviewSizing(nil, for: handle) + } + await waitUntilMainActor { + events.load() == ["sizing-start"] + } + + let close = try #require(store.surface.closeObservers[handle.id]) + close(true, nil) + await waitUntilMainActor { sizingCancelled.load() } + let shutdown = Task { @MainActor in + await coordinator.shutdown() + events.withLock { $0.append("shutdown-finish") } + shutdownFinished.store(true) + } + for _ in 0 ..< 20 { + await Task.yield() + } + + #expect(!shutdownFinished.load()) + + releaseSizingDrain.signal() + await shutdown.value + _ = await sizing.value + #expect(events.load() == [ + "sizing-start", "sizing-cancel", "release", "shutdown-finish", + ]) + } + @Test("interactive sizing refreshes geometry before clearing ignore-size") func interactiveSizingRefreshesGeometryBeforePromotion() async { let store = RecordingNativeSessionSurfaceStore() @@ -1654,8 +2208,8 @@ struct NativeTmuxSessionCoordinatorTests { #expect(store.surface.clearPreviewGridCount == 2) } - @Test("stale promotion restore recovers the tmux window dimensions") - func stalePromotionRestoreRecoversTmuxWindowDimensions() async throws { + @Test("preview sizing sets ignore-size before changing the local grid") + func previewSizingSetsIgnoreSizeBeforeChangingGrid() async throws { guard case let .success(binary) = TmuxBinaryResolver() .resolveTmuxBinary(), TmuxPaneSplitter.supportsPaneSplitting( @@ -1729,31 +2283,21 @@ struct NativeTmuxSessionCoordinatorTests { #expect(interactiveResize.status == 0) let store = RecordingNativeSessionSurfaceStore() - var previewResizeStatus: Int32? - store.surface.onPreviewGridSize = { gridSize in + var previewGridWasIgnored = false + store.surface.onPreviewGridSize = { _ in let clients = AccountCommandRunner.runProcess( executable: binary.path, arguments: server.connectionArguments + [ "list-clients", "-F", - "#{client_tty}\t#{client_flags}", + "#{client_tty}|#{client_flags}", ], timeout: 5 ) let clientFlags = clients.stdout.split(whereSeparator: \.isNewline) .map(String.init) - .first { $0.hasPrefix(clientIdentity.clientTTY + "\t") } - guard clientFlags?.contains("ignore-size") == false else { - return - } - previewResizeStatus = AccountCommandRunner.runProcess( - executable: binary.path, - arguments: server.connectionArguments + [ - "resize-window", "-t", "restored:", - "-x", String(gridSize.columns), - "-y", String(gridSize.rows), - ], - timeout: 5 - ).status + .first { $0.hasPrefix(clientIdentity.clientTTY + "|") } + previewGridWasIgnored = + clientFlags?.contains("ignore-size") == true } let coordinator = NativeTmuxSessionCoordinator( terminalCoordinator: store, @@ -1795,12 +2339,12 @@ struct NativeTmuxSessionCoordinatorTests { timeout: 5 ) - #expect(previewResizeStatus == 0) + #expect(previewGridWasIgnored) #expect(measured.status == 0) #expect( measured.stdout.trimmingCharacters( in: CharacterSet.whitespacesAndNewlines - ) == "120x37" + ) == "80x24" ) } @@ -1990,6 +2534,114 @@ struct NativeTmuxSessionCoordinatorTests { ) #expect(command.contains("ignore-size")) } + + @Test("preview sizing requested during provisioning changes the attach") + func previewSizingDuringProvisioningChangesAttach() async throws { + let resolutionStarted = LockedValue(false) + let releaseResolution = DispatchSemaphore(value: 0) + defer { releaseResolution.signal() } + let store = RecordingNativeSessionSurfaceStore() + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { + resolutionStarted.store(true) + _ = releaseResolution.wait(timeout: .now() + 5) + return successfulTmuxResolution("/usr/bin/tmux") + } + ) + var isSurfaceReady = false + coordinator.onSurfaceReady = { _ in isSurfaceReady = true } + let handle = coordinator.attach( + hostID: UUID(), + name: "provisioning-preview", + host: .local, + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { resolutionStarted.load() } + + let transition = await coordinator.restorePreviewSizing( + nil, + for: handle + ) + #expect(transition == .pending) + releaseResolution.signal() + await waitUntilMainActor { isSurfaceReady } + _ = coordinator.surface(handle: handle) + + let command = try #require( + store.requestedConfigurations.last?.command + ) + #expect(command.contains("ignore-size")) + } + + @Test("hidden sizing before a kwt launch stays pending") + func hiddenSizingBeforeKwtLaunchStaysPending() async throws { + let store = RecordingNativeSessionSurfaceStore() + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + localKwtPathProvider: { + "/Applications/Ghosthub.app/Contents/Helpers/kwt" + } + ) + var isReady = false + coordinator.onSurfaceReady = { _ in isReady = true } + let handle = coordinator.attach( + hostID: UUID(), + name: "kwt-widget-feature", + host: .local, + workingDirectory: "/worktrees/widget", + openWorkspace: true, + sessionIdentity: coordinatorSplitIdentity + ) + await waitUntilMainActor { isReady } + + let transition = await coordinator.restorePreviewSizing( + TmuxGridSize(columns: 120, rows: 37), + for: handle + ) + #expect(transition == .pending) + _ = coordinator.surface(handle: handle) + + let command = try #require( + store.requestedConfigurations.last?.command + ) + #expect(command.contains("'open'")) + #expect(!command.contains("ignore-size")) + #expect(!command.contains("stty")) + } + + @Test("unsupported tmux ignores non-sizing attachment requests") + func unsupportedTmuxIgnoresNonSizingAttachRequest() async throws { + let store = RecordingNativeSessionSurfaceStore() + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { + successfulTmuxResolution( + "/usr/bin/tmux", + version: "tmux 3.3" + ) + } + ) + var isSurfaceReady = false + coordinator.onSurfaceReady = { _ in isSurfaceReady = true } + let handle = coordinator.attach( + hostID: UUID(), + name: "unsupported-sizing", + host: .local, + sessionIdentity: coordinatorSplitIdentity, + ignoresClientSize: true, + previewGridSize: TmuxGridSize(columns: 120, rows: 37) + ) + await waitUntilMainActor { isSurfaceReady } + _ = coordinator.surface(handle: handle) + + let command = try #require( + store.requestedConfigurations.last?.command + ) + #expect(!command.contains("ignore-size")) + #expect(store.surface.previewGridSizes.isEmpty) + } } private enum SurfaceLaunchTestError: LocalizedError { diff --git a/Tests/App/WorkspaceTmuxDiscoveryTests.swift b/Tests/App/WorkspaceTmuxDiscoveryTests.swift index 5e84fabc..18adaed2 100644 --- a/Tests/App/WorkspaceTmuxDiscoveryTests.swift +++ b/Tests/App/WorkspaceTmuxDiscoveryTests.swift @@ -13,6 +13,61 @@ import Testing @Suite("Workspace tmux discovery", .serialized) struct WorkspaceTmuxDiscoveryTests { + @Test("hiding a named socket does not use a default-socket preview grid") + @MainActor + func namedSocketHidingDoesNotUseDefaultSocketPreviewGrid() async throws { + let environment = try setupStandardEnvironment() + var snapshot = environment.snapshot + snapshot.hosts[0].tmuxSessions = [ + TmuxSessionSummary( + name: "build", + managed: false, + windows: [], + previewClientSize: TmuxGridSize(columns: 132, rows: 41) + ), + ] + let surfaceStore = SceneTmuxSurfaceStoreStub() + let hiddenSizingApplied = LockedValue(false) + let splitter = TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("ignore-size"), + !command.contains("!ignore-size") { + hiddenSizingApplied.withLock { $0 = true } + } + return (0, "") + } + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: splitter, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "build", + socketName: "private-build" + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { hiddenSizingApplied.load() } + + #expect(surfaceStore.surface.previewGridSizes.isEmpty) + await model.shutdown() + } + @Test("Always Live activation waits for exact-client verification") @MainActor func alwaysLiveActivationWaitsForExactClientVerification() async throws { @@ -2172,6 +2227,12 @@ struct WorkspaceTmuxDiscoveryTests { localHostID: environment.localHostID, snapshot: environment.snapshot, nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPaneSplitter: WorkspaceTmuxTestSupport + .previewPaneSplitter(identity: TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + )), remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") }, diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 8ee218d8..51a5e210 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -125,6 +125,561 @@ extension WorkspaceTmuxDiscoveryTests { await model.shutdown() } + @MainActor + @Test("hiding an ordinary tmux presentation disables client sizing") + func hiddenTmuxPresentationDisablesClientSizing() async throws { + let environment = try setupHostEnvironment() + var snapshot = environment.snapshot + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + let previewGrid = TmuxGridSize(columns: 120, rows: 37) + snapshot.hosts[0].tmuxSessions = [.init( + name: "ordinary", + managed: false, + windows: [], + serverPID: identity.serverPID, + sessionID: identity.sessionID, + createdAt: identity.createdAt, + previewClientSize: previewGrid + )] + let hideMutations = LockedValue(0) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hideMutations.withLock { $0 += 1 } + } + return (0, "") + }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "ordinary" + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + let handle = try #require( + model.retainedBorrowedTmuxHandle(for: selection) + ) + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { hideMutations.load() == 1 } + + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(model.retainedBorrowedTmuxHandle(for: selection) == handle) + #expect(surfaceStore.removedKeys.isEmpty) + await model.shutdown() + } + + @MainActor + @Test("latest reopen wins while the hidden sizing transition is pending") + func reopeningWaitsForHiddenSizingTransition() async throws { + let environment = try setupHostEnvironment() + var snapshot = environment.snapshot + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + snapshot.hosts[0].tmuxSessions = [.init( + name: "ordinary", + managed: false, + windows: [], + serverPID: identity.serverPID, + sessionID: identity.sessionID, + createdAt: identity.createdAt, + previewClientSize: TmuxGridSize(columns: 120, rows: 37) + )] + let events = LockedValue<[String]>([]) + let hideStarted = LockedValue(false) + let releaseHide = DispatchSemaphore(value: 0) + defer { releaseHide.signal() } + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'!ignore-size'") { + events.withLock { $0.append("interactive") } + } else if command.contains("'ignore-size'") { + events.withLock { $0.append("hidden-start") } + hideStarted.store(true) + _ = releaseHide.wait(timeout: .now() + 5) + events.withLock { $0.append("hidden-end") } + } + return (0, "") + }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "ordinary" + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + let handle = try #require( + model.retainedBorrowedTmuxHandle(for: selection) + ) + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { hideStarted.load() } + + model.openBorrowedTmuxSession(selection) + for _ in 0 ..< 20 { + await Task.yield() + } + #expect(!model.activeBorrowedTmuxSessionIsConnected) + #expect(events.load() == ["hidden-start"]) + + model.openBorrowedTmuxSession(selection) + + releaseHide.signal() + await waitUntilMainActor { + model.activeBorrowedTmuxSessionIsConnected + } + #expect(events.load() == [ + "hidden-start", "hidden-end", "interactive", + ]) + #expect(model.retainedBorrowedTmuxHandle(for: selection) == handle) + #expect(surfaceStore.removedKeys.isEmpty) + await model.shutdown() + } + + @MainActor + @Test("hiding unsupported tmux detaches without a sizing command") + func hidingUnsupportedTmuxSkipsSizingMutation() async throws { + let environment = try setupHostEnvironment() + let commands = LockedValue<[String]>([]) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution( + "/usr/bin/tmux", + version: "tmux 3.3" + ) + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + commands.withLock { $0.append(command) } + return (1, "unsupported sizing command") + }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "ordinary" + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { + model.retainedBorrowedTmuxHandle(for: selection) == nil + } + + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(commands.load().isEmpty) + #expect(!surfaceStore.removedKeys.isEmpty) + await model.shutdown() + } + + @MainActor + @Test("hiding a socketless protected session never sizes the default server") + func hidingSocketlessProtectedSessionDetachesWithoutSizing() async throws { + let environment = try setupStandardEnvironment() + let hiddenSizingMutations = LockedValue(0) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hiddenSizingMutations.withLock { $0 += 1 } + } + return (0, "") + }, + localKwtPathProvider: { "/test/kwt" }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "kwt-ghosthub-main", + worktreeID: environment.worktree.id, + worktreePath: environment.worktree.path, + tmuxAttachMode: .protected + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + await waitUntilMainActor { + model.retainedBorrowedTmuxSessionIsConnected(selection) + } + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { + hiddenSizingMutations.load() > 0 + || model.retainedBorrowedTmuxHandle(for: selection) == nil + } + + #expect(hiddenSizingMutations.load() == 0) + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(model.retainedBorrowedTmuxHandle(for: selection) == nil) + #expect(!surfaceStore.removedKeys.isEmpty) + await model.shutdown() + } + + @MainActor + @Test("reopening during provisioning resumes activation when ready") + func reopeningDuringProvisioningResumesActivation() async throws { + let environment = try setupHostEnvironment() + let resolutionStarted = LockedValue(false) + let releaseResolution = DispatchSemaphore(value: 0) + defer { releaseResolution.signal() } + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + resolutionStarted.store(true) + _ = releaseResolution.wait(timeout: .now() + 5) + return successfulTmuxResolution("/usr/bin/tmux") + } + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "ordinary" + ) + + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { resolutionStarted.load() } + + model.hideBorrowedTmuxSession(selection) + model.openBorrowedTmuxSession(selection) + for _ in 0 ..< 20 { + await Task.yield() + } + #expect(model.activeBorrowedTmuxSelection == selection) + #expect(!model.activeBorrowedTmuxSessionIsConnected) + + releaseResolution.signal() + await waitUntilMainActor(timeout: .seconds(2)) { + model.prepareActiveBorrowedTmuxSurface() + return model.activeBorrowedTmuxSessionIsConnected + } + #expect(model.activeBorrowedTmuxSelection == selection) + #expect(model.activeBorrowedTmuxSessionIsConnected) + await model.shutdown() + } + + @MainActor + @Test("hiding workspace provisioning preserves kwt establishment") + func hidingWorkspaceProvisioningPreservesKwtEstablishment() async throws { + let environment = try setupStandardEnvironment() + let resolutionStarted = LockedValue(false) + let releaseResolution = DispatchSemaphore(value: 0) + defer { releaseResolution.signal() } + let hiddenSizingMutations = LockedValue(0) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + resolutionStarted.store(true) + _ = releaseResolution.wait(timeout: .now() + 5) + return successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hiddenSizingMutations.withLock { $0 += 1 } + } + return (0, "") + }, + localKwtPathProvider: { "/test/kwt" }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "kwt-ghosthub-main", + worktreeID: environment.worktree.id, + worktreePath: environment.worktree.path, + tmuxAttachMode: .direct + ) + + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { resolutionStarted.load() } + model.hideBorrowedTmuxSession(selection) + // Let the hide request record itself before kwt can launch. + for _ in 0 ..< 20 { + await Task.yield() + } + releaseResolution.signal() + await waitUntilMainActor(timeout: .seconds(2)) { + surfaceStore.requestCount == 1 + } + let command = try #require(surfaceStore.lastConfiguration?.command) + #expect(command.contains("/test/kwt")) + #expect(command.contains("'open'")) + #expect(!command.contains("ignore-size")) + await waitUntilMainActor(timeout: .seconds(1)) { + hiddenSizingMutations.load() == 1 + } + + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(model.retainedBorrowedTmuxHandle(for: selection) != nil) + await model.shutdown() + } + + @MainActor + @Test("hiding kwt provisioning survives discovery advancing the phase") + func hidingKwtProvisioningSurvivesDiscoveryAdvancingPhase() async throws { + let environment = try setupStandardEnvironment() + let resolutionStarted = LockedValue(false) + let releaseResolution = DispatchSemaphore(value: 0) + defer { releaseResolution.signal() } + let hiddenSizingMutations = LockedValue(0) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + resolutionStarted.store(true) + _ = releaseResolution.wait(timeout: .now() + 5) + return successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hiddenSizingMutations.withLock { $0 += 1 } + } + return (0, "") + }, + localKwtPathProvider: { "/test/kwt" }, + tmuxSessionDiscovery: { _ in + .success([ + DiscoveredTmuxSession( + name: "kwt-ghosthub-main", + windowCount: 1, + createdAt: nil, + managed: true + ), + ]) + }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "kwt-ghosthub-main", + worktreeID: environment.worktree.id, + worktreePath: environment.worktree.path, + tmuxAttachMode: .direct + ) + + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { resolutionStarted.load() } + // Discovery sees the session while kwt is still provisioning, which + // completes workspace establishment before the client launches. + model.startTmuxSessionDiscovery() + await waitUntilMainActor { + model.snapshot.host(id: environment.host.id)? + .tmuxSessions.contains { $0.name == selection.name } == true + } + model.hideBorrowedTmuxSession(selection) + for _ in 0 ..< 20 { + await Task.yield() + } + releaseResolution.signal() + await waitUntilMainActor(timeout: .seconds(2)) { + surfaceStore.requestCount == 1 + } + let command = try #require(surfaceStore.lastConfiguration?.command) + #expect(command.contains("/test/kwt")) + #expect(command.contains("'open'")) + #expect(!command.contains("ignore-size")) + await waitUntilMainActor(timeout: .seconds(1)) { + hiddenSizingMutations.load() == 1 + } + + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(model.retainedBorrowedTmuxHandle(for: selection) != nil) + await model.shutdown() + } + + @MainActor + @Test("hidden workspace provisioning detaches without a client identity") + func hiddenWorkspaceProvisioningDetachesWithoutIdentity() async throws { + let environment = try setupStandardEnvironment() + let resolutionStarted = LockedValue(false) + let releaseResolution = DispatchSemaphore(value: 0) + defer { releaseResolution.signal() } + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + resolutionStarted.store(true) + _ = releaseResolution.wait(timeout: .now() + 5) + return successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, _ in + (1, "client identity unavailable") + }, + localKwtPathProvider: { "/test/kwt" }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "kwt-ghosthub-main", + worktreeID: environment.worktree.id, + worktreePath: environment.worktree.path, + tmuxAttachMode: .direct + ) + + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { resolutionStarted.load() } + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { + model.retainedBorrowedTmuxSessionHasPendingHiddenSizing(selection) + } + releaseResolution.signal() + + await waitUntilMainActor(timeout: .seconds(3)) { + model.retainedBorrowedTmuxHandle(for: selection) == nil + } + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(!surfaceStore.removedKeys.isEmpty) + await model.shutdown() + } + + @MainActor + @Test("a failed hidden sizing transition detaches the client") + func failedHiddenSizingTransitionDetachesClient() async throws { + let environment = try setupHostEnvironment() + var snapshot = environment.snapshot + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + snapshot.hosts[0].tmuxSessions = [.init( + name: "ordinary", + managed: false, + windows: [], + serverPID: identity.serverPID, + sessionID: identity.sessionID, + createdAt: identity.createdAt, + previewClientSize: TmuxGridSize(columns: 120, rows: 37) + )] + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + return (1, "tmux rejected the sizing change") + } + return (0, "") + }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "ordinary" + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { + model.retainedBorrowedTmuxHandle(for: selection) == nil + } + + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(!surfaceStore.removedKeys.isEmpty) + await model.shutdown() + } + @MainActor @Test("retained tmux activation unparks before publishing the active handle") func retainedTmuxActivationUnparksBeforePublishingHandle() async throws { @@ -159,6 +714,7 @@ extension WorkspaceTmuxDiscoveryTests { unpark: { _ in events.append( "unpark:\(weakModel?.activeBorrowedTmuxSelection?.name ?? "none")" + + ":\(weakModel?.activeBorrowedTmuxSessionIsConnected ?? false)" ) }, isKeyWindow: { true } @@ -210,7 +766,11 @@ extension WorkspaceTmuxDiscoveryTests { model.openBorrowedTmuxSession(first) - #expect(events == ["park:second", "unpark:second"]) + await waitUntilMainActor { + events == ["park:second", "unpark:first:false"] + && model.activeBorrowedTmuxSessionIsConnected + } + #expect(events == ["park:second", "unpark:first:false"]) #expect(model.activeBorrowedTmuxSelection == first) #expect(!budget.granted.contains(LivePreviewRequestID( sceneID: previewCoordinator.sceneID, @@ -363,14 +923,6 @@ extension WorkspaceTmuxDiscoveryTests { lastKnownReachable: true ) snapshot.hosts.append(secondHost) - let surfaceStore = SceneTmuxSurfaceStoreStub() - let model = try makeModel( - database: environment.database, - localHostID: environment.localHostID, - snapshot: snapshot, - nativeTmuxSurfaceStore: surfaceStore, - remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") } - ) let first = WorkspaceTmuxSessionSelection( hostID: environment.remoteHost.id, name: "release-work" @@ -379,6 +931,22 @@ extension WorkspaceTmuxDiscoveryTests { hostID: secondHost.id, name: "deploy-work" ) + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.localHostID, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPaneSplitter: WorkspaceTmuxTestSupport.previewPaneSplitter( + identity: identity + ), + remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") } + ) model.openBorrowedTmuxSession(first) await launchActiveTmuxSurface(model, store: surfaceStore) @@ -1195,3 +1763,72 @@ extension WorkspaceTmuxDiscoveryTests { } } + +extension WorkspaceTmuxDiscoveryTests { + @MainActor + @Test("activating an unresolvable host hides the previous client") + func unresolvableHostActivationHidesPreviousClient() async throws { + let environment = try setupHostEnvironment() + var snapshot = environment.snapshot + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + snapshot.hosts[0].tmuxSessions = [.init( + name: "ordinary", + managed: false, + windows: [], + serverPID: identity.serverPID, + sessionID: identity.sessionID, + createdAt: identity.createdAt, + previewClientSize: TmuxGridSize(columns: 120, rows: 37) + )] + let hideMutations = LockedValue(0) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hideMutations.withLock { $0 += 1 } + } + return (0, "") + }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "ordinary" + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + let handle = try #require( + model.retainedBorrowedTmuxHandle(for: selection) + ) + let unknownHost = WorkspaceTmuxSessionSelection( + hostID: UUID(), + name: "elsewhere" + ) + model.openBorrowedTmuxSession(unknownHost) + await waitUntilMainActor { hideMutations.load() == 1 } + + #expect(model.activeBorrowedTmuxSelection == unknownHost) + #expect(model.retainedBorrowedTmuxHandle(for: selection) == handle) + await model.shutdown() + } +} diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index 6314fc5e..4db0cc59 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -862,12 +862,27 @@ extension WorkspaceTmuxDiscoveryTests { ), ] let surfaceStore = SceneTmuxSurfaceStoreStub() + let hiddenSizingMutations = LockedValue(0) let model = try makeModel( database: environment.database, localHostID: environment.localHostID, snapshot: snapshot, nativeTmuxSurfaceStore: surfaceStore, nativeTmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hiddenSizingMutations.withLock { $0 += 1 } + } + return (0, "") + }, remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") }, tmuxSessionDiscovery: { host in if host.isRemote { @@ -875,7 +890,13 @@ extension WorkspaceTmuxDiscoveryTests { DiscoveredTmuxSession( name: "release-work", windowCount: 1, - createdAt: nil, + serverPID: "101", + sessionID: "$1", + createdAt: "1000", + previewClientSize: TmuxGridSize( + columns: 120, + rows: 37 + ), managed: false ), ]) @@ -905,6 +926,7 @@ extension WorkspaceTmuxDiscoveryTests { model.prepareActiveBorrowedTmuxSurface() return surfaceStore.requestCount == 2 } + await waitUntilMainActor { hiddenSizingMutations.load() == 1 } remoteClose(false, 255) @@ -914,14 +936,468 @@ extension WorkspaceTmuxDiscoveryTests { } #expect(model.activeBorrowedTmuxSelection == local) #expect(model.retainedBorrowedTmuxHandle(for: remote) == remoteHandle) + #expect( + try #require(surfaceStore.lastConfiguration?.command) + .contains("ignore-size") + ) model.openBorrowedTmuxSession(remote) model.prepareActiveBorrowedTmuxSurface() + await waitUntilMainActor { + model.activeBorrowedTmuxSessionIsConnected + } #expect(model.activeBorrowedTmuxSessionIsConnected) #expect(surfaceStore.requestCount == 3) await model.shutdown() } + @MainActor + @Test("named-socket hidden reconnect ignores default-server preview grid") + func namedSocketHiddenReconnectIgnoresDefaultPreviewGrid() async throws { + let environment = try setupRemoteTmuxEnvironment() + var snapshot = environment.snapshot + let remoteIndex = try #require(snapshot.hosts.firstIndex { + $0.id == environment.remoteHost.id + }) + snapshot.hosts[remoteIndex].tmuxSessions = [ + TmuxSessionSummary( + name: "release-work", + managed: false, + windows: [], + previewClientSize: TmuxGridSize(columns: 132, rows: 41) + ), + ] + let surfaceStore = SceneTmuxSurfaceStoreStub() + let hiddenSizingMutations = LockedValue(0) + let model = try makeModel( + database: environment.database, + localHostID: environment.localHostID, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hiddenSizingMutations.withLock { $0 += 1 } + } + return (0, "") + }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/bin/tmux") + }, + tmuxExactSessionProbe: { _ in .success(true) }, + tmuxReconnectIntervals: [.milliseconds(1)] + ) + let remote = WorkspaceTmuxSessionSelection( + hostID: environment.remoteHost.id, + name: "release-work", + socketName: "private-build" + ) + let local = WorkspaceTmuxSessionSelection( + hostID: environment.localHostID, + name: "local-work" + ) + + model.openBorrowedTmuxSession(remote) + await launchActiveTmuxSurface(model, store: surfaceStore) + let remoteHandle = try #require( + model.retainedBorrowedTmuxHandle(for: remote) + ) + let remoteClose = try #require( + surfaceStore.surface.closeObservers[remoteHandle.id] + ) + model.openBorrowedTmuxSession(local) + await waitUntilMainActor { + model.prepareActiveBorrowedTmuxSurface() + return surfaceStore.requestCount == 2 + && hiddenSizingMutations.load() == 1 + } + + remoteClose(false, 255) + + await waitUntilMainActor { + surfaceStore.requestCount == 3 + && model.retainedBorrowedTmuxSessionIsConnected(remote) + } + let reconnectCommand = try #require( + surfaceStore.lastConfiguration?.command + ) + #expect(reconnectCommand.contains("ignore-size")) + #expect(!reconnectCommand.contains("stty columns 132 rows 41")) + #expect(surfaceStore.surface.previewGridSizes.isEmpty) + #expect(model.activeBorrowedTmuxSelection == local) + await model.shutdown() + } + + @MainActor + @Test("disconnect during hidden sizing resumes through reconnect readiness") + func disconnectDuringHiddenSizingResumesOnReconnect() async throws { + let environment = try setupRemoteTmuxEnvironment() + var snapshot = environment.snapshot + let remoteIndex = try #require(snapshot.hosts.firstIndex { + $0.id == environment.remoteHost.id + }) + let previewGrid = TmuxGridSize(columns: 120, rows: 37) + snapshot.hosts[remoteIndex].tmuxSessions = [ + TmuxSessionSummary( + name: "release-work", + managed: false, + windows: [], + serverPID: "101", + sessionID: "$1", + createdAt: "1000", + previewClientSize: previewGrid + ), + ] + let hideStarted = LockedValue(false) + let hideFinished = LockedValue(false) + let releaseHide = DispatchSemaphore(value: 0) + let reconnectGate = BlockingGate() + defer { + releaseHide.signal() + reconnectGate.release() + } + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.localHostID, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hideStarted.store(true) + _ = releaseHide.wait(timeout: .now() + 5) + hideFinished.store(true) + } + return (0, "") + }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/bin/tmux") + }, + tmuxSessionValidationDiscovery: { _, _ in + reconnectGate.wait() + return .success([ + DiscoveredTmuxSession( + name: "release-work", + windowCount: 1, + serverPID: "101", + sessionID: "$1", + createdAt: "1000", + previewClientSize: previewGrid, + managed: false + ), + ]) + }, + tmuxReconnectIntervals: [.milliseconds(1)] + ) + let remote = WorkspaceTmuxSessionSelection( + hostID: environment.remoteHost.id, + name: "release-work" + ) + let local = WorkspaceTmuxSessionSelection( + hostID: environment.localHostID, + name: "local-work" + ) + + model.openBorrowedTmuxSession(remote) + await launchActiveTmuxSurface(model, store: surfaceStore) + let remoteHandle = try #require( + model.retainedBorrowedTmuxHandle(for: remote) + ) + let remoteClose = try #require( + surfaceStore.surface.closeObservers[remoteHandle.id] + ) + model.openBorrowedTmuxSession(local) + await waitUntilMainActor { + model.prepareActiveBorrowedTmuxSurface() + return surfaceStore.requestCount == 2 && hideStarted.load() + } + + remoteClose(false, 255) + await waitUntilMainActor { reconnectGate.didStart } + releaseHide.signal() + await waitUntilMainActor { hideFinished.load() } + try await Task.sleep(for: .milliseconds(25)) + + #expect(model.retainedBorrowedTmuxHandle(for: remote) != nil) + + reconnectGate.release() + await waitUntilMainActor { + surfaceStore.requestCount == 3 + && model.retainedBorrowedTmuxSessionIsConnected(remote) + } + #expect(model.activeBorrowedTmuxSelection == local) + #expect( + model.retainedBorrowedTmuxHandle(for: remote) == remoteHandle + ) + #expect(surfaceStore.surface.previewGridSizes == [previewGrid]) + #expect( + try #require(surfaceStore.lastConfiguration?.command) + .contains("ignore-size") + ) + await model.shutdown() + } + + @MainActor + @Test("disconnect during interactive sizing resumes pending activation") + func disconnectDuringInteractiveSizingResumesActivation() async throws { + let environment = try setupRemoteTmuxEnvironment() + var snapshot = environment.snapshot + let remoteIndex = try #require(snapshot.hosts.firstIndex { + $0.id == environment.remoteHost.id + }) + let previewGrid = TmuxGridSize(columns: 120, rows: 37) + snapshot.hosts[remoteIndex].tmuxSessions = [ + TmuxSessionSummary( + name: "release-work", + managed: false, + windows: [], + serverPID: "101", + sessionID: "$1", + createdAt: "1000", + previewClientSize: previewGrid + ), + ] + let hiddenSizingMutations = LockedValue(0) + let interactiveSizingStarted = LockedValue(false) + let interactiveSizingFinished = LockedValue(false) + let releaseInteractiveSizing = DispatchSemaphore(value: 0) + let reconnectGate = BlockingGate() + defer { + releaseInteractiveSizing.signal() + reconnectGate.release() + } + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.localHostID, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'!ignore-size'") { + interactiveSizingStarted.store(true) + _ = releaseInteractiveSizing.wait(timeout: .now() + 5) + interactiveSizingFinished.store(true) + } else if command.contains("'ignore-size'") { + hiddenSizingMutations.withLock { $0 += 1 } + } + return (0, "") + }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/bin/tmux") + }, + tmuxSessionValidationDiscovery: { _, _ in + reconnectGate.wait() + return .success([ + DiscoveredTmuxSession( + name: "release-work", + windowCount: 1, + serverPID: "101", + sessionID: "$1", + createdAt: "1000", + previewClientSize: previewGrid, + managed: false + ), + ]) + }, + tmuxReconnectIntervals: [.milliseconds(1)] + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.remoteHost.id, + name: "release-work" + ) + + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + let handle = try #require( + model.retainedBorrowedTmuxHandle(for: selection) + ) + let close = try #require( + surfaceStore.surface.closeObservers[handle.id] + ) + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { hiddenSizingMutations.load() == 1 } + + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { interactiveSizingStarted.load() } + close(false, 255) + await waitUntilMainActor { reconnectGate.didStart } + releaseInteractiveSizing.signal() + await waitUntilMainActor { interactiveSizingFinished.load() } + try await Task.sleep(for: .milliseconds(25)) + + #expect(model.retainedBorrowedTmuxHandle(for: selection) == handle) + #expect(model.activeBorrowedTmuxSelection == selection) + + reconnectGate.release() + await waitUntilMainActor { + model.prepareActiveBorrowedTmuxSurface() + return surfaceStore.requestCount == 2 + && model.activeBorrowedTmuxSessionIsConnected + } + #expect(model.retainedBorrowedTmuxHandle(for: selection) == handle) + #expect(model.activeBorrowedTmuxSelection == selection) + await model.shutdown() + } + + @MainActor + @Test("local disconnect during hidden sizing resumes on reconnect") + func localDisconnectDuringHiddenSizingResumesOnReconnect() async throws { + let environment = try setupStandardEnvironment() + var snapshot = environment.snapshot + let previewGrid = TmuxGridSize(columns: 120, rows: 37) + snapshot.hosts[0].tmuxSessions = [ + TmuxSessionSummary( + name: "kwt-ghosthub-main", + managed: true, + windows: [], + serverPID: "101", + sessionID: "$1", + createdAt: "1000", + previewClientSize: previewGrid + ), + ] + let hideStarted = LockedValue(false) + let hideFinished = LockedValue(false) + let releaseHide = DispatchSemaphore(value: 0) + let reconnectGate = BlockingGate() + let blocksDiscovery = LockedValue(false) + defer { + releaseHide.signal() + reconnectGate.release() + } + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'ignore-size'"), + !command.contains("'!ignore-size'") { + hideStarted.store(true) + _ = releaseHide.wait(timeout: .now() + 5) + hideFinished.store(true) + } + return (0, "") + }, + localKwtPathProvider: { "/test/kwt" }, + tmuxSessionDiscovery: { _ in + if blocksDiscovery.load() { + reconnectGate.wait() + } + return .success([ + DiscoveredTmuxSession( + name: "kwt-ghosthub-main", + windowCount: 1, + serverPID: "101", + sessionID: "$1", + createdAt: "1000", + previewClientSize: previewGrid, + managed: true + ), + ]) + }, + tmuxReconnectIntervals: [.milliseconds(1)] + ) + let worktree = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "kwt-ghosthub-main", + worktreeID: environment.worktree.id, + worktreePath: environment.worktree.path, + tmuxAttachMode: .direct + ) + let other = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "other" + ) + + model.openBorrowedTmuxSession(worktree) + await launchActiveTmuxSurface(model, store: surfaceStore) + let worktreeHandle = try #require( + model.retainedBorrowedTmuxHandle(for: worktree) + ) + let worktreeClose = try #require( + surfaceStore.surface.closeObservers[worktreeHandle.id] + ) + model.openBorrowedTmuxSession(other) + await waitUntilMainActor { + model.prepareActiveBorrowedTmuxSurface() + return surfaceStore.requestCount == 2 && hideStarted.load() + } + + blocksDiscovery.store(true) + worktreeClose(false, 255) + await waitUntilMainActor(timeout: .seconds(2)) { + reconnectGate.didStart + } + releaseHide.signal() + await waitUntilMainActor { hideFinished.load() } + try await Task.sleep(for: .milliseconds(25)) + + let retainedHandle = model.retainedBorrowedTmuxHandle(for: worktree) + #expect(retainedHandle != nil) + + reconnectGate.release() + if retainedHandle != nil { + await waitUntilMainActor { + surfaceStore.requestCount == 3 + && model.retainedBorrowedTmuxSessionIsConnected(worktree) + } + #expect(model.activeBorrowedTmuxSelection == other) + #expect( + model.retainedBorrowedTmuxHandle(for: worktree) + == worktreeHandle + ) + #expect(surfaceStore.surface.previewGridSizes == [previewGrid]) + #expect( + try #require(surfaceStore.lastConfiguration?.command) + .contains("ignore-size") + ) + } + await model.shutdown() + } + @MainActor @Test("interrupted profile creation never replays its command automatically") func interruptedProfileCreationRequiresExplicitRetry() async throws { @@ -2209,12 +2685,19 @@ extension WorkspaceTmuxDiscoveryTests { hostID: environment.host.id, name: "release-work" ) + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) let surfaceStore = SceneTmuxSurfaceStoreStub() let model = try makeModel( database: environment.database, localHostID: UUID(), snapshot: snapshot, nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPaneSplitter: WorkspaceTmuxTestSupport + .previewPaneSplitter(identity: identity), remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") }, @@ -2619,4 +3102,95 @@ extension WorkspaceTmuxDiscoveryTests { await model.shutdown() } + @MainActor + @Test("invalidating a closed created client reconciles instead of discarding") + func invalidatingClosedCreatedClientReconciles() async throws { + let environment = try setupRemoteEnvironment() + var worktree = try #require(environment.snapshot.worktrees.first) + worktree.tmuxSessionName = "created-work" + var snapshot = environment.snapshot + snapshot.worktrees = [worktree] + let emptyInventory = KwtHostInventory(projects: [ + KwtProjectInventory( + project: KwtProjectRecord( + repository: environment.project.scopedKey, + name: environment.project.name, + path: environment.project.rootPath, + lastTouched: nil + ), + worktrees: [], + warning: nil + ), + ]) + let inventories = LockedValue(inventory(environment, including: worktree)) + let inventoryLoads = LockedValue(0) + let discoveryReleased = LockedValue(false) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") }, + kwtInventoryLoader: { _ in + inventoryLoads.withLock { $0 += 1 } + return inventories.load() + }, + tmuxSessionDiscovery: { _ in + while !discoveryReleased.load() { + try? await Task.sleep(for: .milliseconds(10)) + } + return .success([ + DiscoveredTmuxSession( + name: "created-work", + windowCount: 1, + createdAt: nil, + managed: false + ), + ]) + }, + createdSessionDiscoveryDelays: [.seconds(10)], + tmuxReconnectIntervals: [.seconds(10)] + ) + defer { discoveryReleased.store(true) } + model.startKwtInventory() + await waitUntilMainActor { inventoryLoads.load() == 1 } + let selection = try #require( + WorkspaceSidebarModel.tmuxSessionSelection(for: worktree) + ) + model.createTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + #expect(model.pendingCreatedTmuxSessionCount == 1) + let createCommand = try #require(surfaceStore.lastConfiguration?.command) + #expect(createCommand.contains("new-session")) + + surfaceStore.surface.closeObservers.values.first?(false, 255) + await waitUntilMainActor { + model.activeBorrowedTmuxRecoveryState?.isReconnecting == true + } + + inventories.store(emptyInventory) + model.refreshKwtInventory() + await waitUntilMainActor { inventoryLoads.load() == 2 } + await waitUntilMainActor { + model.retainedBorrowedTmuxHandle(for: selection) == nil + } + + #expect(model.pendingCreatedTmuxSessionCount == 1) + #expect( + model.snapshot.host(id: environment.host.id)? + .tmuxSessions.contains { $0.name == "created-work" } == true + ) + + discoveryReleased.store(true) + await waitUntilMainActor { + model.pendingCreatedTmuxSessionCount == 0 + } + #expect( + model.snapshot.host(id: environment.host.id)? + .tmuxSessions.contains { $0.name == "created-work" } == true + ) + await model.shutdown() + } + } diff --git a/Tests/App/WorkspaceTmuxTestSupport.swift b/Tests/App/WorkspaceTmuxTestSupport.swift index c9a6211e..8a4acf9d 100644 --- a/Tests/App/WorkspaceTmuxTestSupport.swift +++ b/Tests/App/WorkspaceTmuxTestSupport.swift @@ -126,8 +126,15 @@ final class SceneTmuxPaneSurfaceStub: NativeSessionPaneSurfacing { var childExitCode: UInt32? private(set) var closeObservers: [UUID: (Bool, UInt32?) -> Void] = [:] private(set) var lastObserverID: UUID? + private(set) var previewGridSizes: [TmuxGridSize] = [] private(set) var clearPreviewGridCount = 0 + @discardableResult + func sizeForPreviewGrid(columns: Int, rows: Int) -> Bool { + previewGridSizes.append(TmuxGridSize(columns: columns, rows: rows)) + return true + } + func clearPreviewGridSize() { clearPreviewGridCount += 1 } diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index b5377a7a..4c28161e 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -928,19 +928,22 @@ extension WorkspaceWorktreeRemovalTests { } @MainActor - @Test("failed removal restores an inactive retained presentation") - func failedRemovalRestoresInactivePresentation() async throws { + @Test("failed removal leaves suppressed establishment explicit") + func failedRemovalLeavesSuppressedEstablishmentExplicit() async throws { let fixture = try removalFixture() let environment = fixture.environment let removable = fixture.removable let snapshot = fixture.snapshot let beforeRemoval = fixture.beforeRemoval let removerHold = RemovalPreflightHold() + let surfaces = RecordingNativeSessionSurfaceStore() let model = try makeModel( database: environment.database, localHostID: environment.host.id, snapshot: snapshot, + nativeTmuxSurfaceStore: surfaces, nativeTmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + localKwtPathProvider: { "/test/kwt" }, kwtInventoryLoader: { _ in beforeRemoval }, kwtWorktreeRemover: { _, _, _, _, _ in _ = await removerHold.load(beforeRemoval) @@ -975,6 +978,9 @@ extension WorkspaceWorktreeRemovalTests { let activeHandle = try #require( model.retainedBorrowedTmuxHandle(for: other) ) + await waitUntilMainActor { + surfaces.requestedConfigurations.count == 2 + } #expect(model.retainedBorrowedTmuxPresentationCount == 2) #expect(model.activeBorrowedTmuxSelection == other) @@ -1000,12 +1006,20 @@ extension WorkspaceWorktreeRemovalTests { try await removal.value } #expect(!model.suppressesSelectedWorktreeSessionOpen) - #expect(model.retainedBorrowedTmuxPresentationCount == 2) - #expect( - model.retainedBorrowedTmuxHandle(for: selection) != removedHandle - ) + #expect(model.retainedBorrowedTmuxPresentationCount == 1) + #expect(model.retainedBorrowedTmuxHandle(for: selection) == nil) #expect(model.retainedBorrowedTmuxHandle(for: other) == activeHandle) #expect(model.activeBorrowedTmuxSelection == other) + + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { + surfaces.requestedConfigurations.count == 3 + } + let restoredCommand = try #require(surfaces.lastCommand) + #expect(restoredCommand.contains("/test/kwt")) + #expect(restoredCommand.contains("open")) + #expect(!restoredCommand.contains("attach-session")) + #expect(removedHandle != model.retainedBorrowedTmuxHandle(for: selection)) await model.shutdown() } @@ -1063,9 +1077,10 @@ extension WorkspaceWorktreeRemovalTests { try await removal.value } - #expect(model.retainedBorrowedTmuxPresentationCount == 2) + #expect(model.retainedBorrowedTmuxPresentationCount == 1) #expect(model.activeBorrowedTmuxSelection == newer) #expect(model.retainedBorrowedTmuxHandle(for: newer) == newerHandle) + #expect(model.retainedBorrowedTmuxHandle(for: removed) == nil) await model.shutdown() } @@ -1136,7 +1151,7 @@ extension WorkspaceWorktreeRemovalTests { @MainActor @Test( - "a dirty rejection after session termination restores the killed session", + "dirty rejection never establishes a hidden sizing client", arguments: [true, false] ) func dirtyRejectionAfterSessionTerminationRestoresSession( @@ -1197,8 +1212,10 @@ extension WorkspaceWorktreeRemovalTests { await model.shutdown() return } - await waitUntilMainActor { - surfaces.requestedConfigurations.count > initialRequestCount + if wasOpened { + await waitUntilMainActor { + surfaces.requestedConfigurations.count > initialRequestCount + } } #expect(updatedRequest.forceRemoval) @@ -1207,17 +1224,28 @@ extension WorkspaceWorktreeRemovalTests { model.activeBorrowedTmuxSelection == (wasOpened ? selection : nil) ) - let restoredCommand = try #require( - surfaces.requestedConfigurations.last?.command - ) - #expect(restoredCommand.contains("kwt")) - #expect(restoredCommand.contains("open")) + if wasOpened { + let restoredCommand = try #require( + surfaces.requestedConfigurations.last?.command + ) + #expect(restoredCommand.contains("/test/kwt")) + #expect(restoredCommand.contains("open")) + } else { + #expect(surfaces.requestedConfigurations.isEmpty) + #expect(model.retainedBorrowedTmuxPresentationCount == 0) + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { + surfaces.requestedConfigurations.count == 1 + } + #expect(surfaces.lastCommand?.contains("/test/kwt") == true) + #expect(surfaces.lastCommand?.contains("open") == true) + } await model.shutdown() } @MainActor - @Test("failed removal preserves pending workspace establishment") - func failedRemovalPreservesPendingEstablishment() async throws { + @Test("failed removal leaves inactive workspace establishment explicit") + func failedRemovalLeavesInactiveEstablishmentExplicit() async throws { let environment = try setupRemoteEnvironment() var removable = try #require(environment.snapshot.worktrees.first) removable.generation = stableWorktreeGeneration @@ -1269,17 +1297,19 @@ extension WorkspaceWorktreeRemovalTests { await #expect(throws: KwtWorktreeError.self) { try await model.removeWorktree(request) } + #expect(surfaces.requestedConfigurations.count == initialRequestCount) + #expect(model.retainedBorrowedTmuxPresentationCount == 1) + model.openBorrowedTmuxSession(selection) await waitUntilMainActor { - surfaces.requestedConfigurations.count > initialRequestCount + surfaces.requestedConfigurations.count == initialRequestCount + 1 } - #expect(surfaces.lastCommand?.contains("'open'") == true) await model.shutdown() } @MainActor - @Test("failed removal preserves interrupted local establishment") - func failedRemovalPreservesInterruptedLocalEstablishment() async throws { + @Test("failed removal leaves interrupted local establishment explicit") + func failedRemovalLeavesInterruptedLocalExplicit() async throws { let fixture = try removalFixture() let environment = fixture.environment let removable = fixture.removable @@ -1330,11 +1360,13 @@ extension WorkspaceWorktreeRemovalTests { await #expect(throws: KwtWorktreeError.self) { try await model.removeWorktree(request) } + #expect(surfaces.requestedConfigurations.count == initialRequestCount) + #expect(model.retainedBorrowedTmuxPresentationCount == 1) + model.openBorrowedTmuxSession(selection) await waitUntilMainActor { - surfaces.requestedConfigurations.count > initialRequestCount + surfaces.requestedConfigurations.count == initialRequestCount + 1 } - - #expect(surfaces.lastCommand?.contains("kwt") == true) + #expect(surfaces.lastCommand?.contains("/test/kwt") == true) #expect(surfaces.lastCommand?.contains("open") == true) await model.shutdown() } @@ -1539,4 +1571,356 @@ extension WorkspaceWorktreeRemovalTests { await model.shutdown() } + @MainActor + @Test("failed removal restores a presentation whose activation is pending") + func failedRemovalRestoresPendingActivation() async throws { + let fixture = try removalFixture() + let environment = fixture.environment + let removable = fixture.removable + let surfaceStore = SceneTmuxSurfaceStoreStub() + let removerHold = RemovalPreflightHold() + let hiddenSizingMutations = LockedValue(0) + let interactiveSizingStarted = LockedValue(false) + let releaseInteractiveSizing = DispatchSemaphore(value: 0) + defer { releaseInteractiveSizing.signal() } + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: fixture.snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + nativeTmuxPaneSplitter: TmuxPaneSplitter { _, _, command in + if command.contains("GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY") { + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + + "\t101\t789\t321\t/dev/ttys001\t$1\t1000\t%9\n" + ) + } + if command.contains("'!ignore-size'") { + interactiveSizingStarted.store(true) + _ = releaseInteractiveSizing.wait(timeout: .now() + 5) + } else if command.contains("'ignore-size'") { + hiddenSizingMutations.withLock { $0 += 1 } + } + return (0, "") + }, + localKwtPathProvider: { "/test/kwt" }, + kwtInventoryLoader: { _ in fixture.beforeRemoval }, + kwtWorktreeRemover: { _, _, _, _, _ in + _ = await removerHold.load(fixture.beforeRemoval) + throw KwtWorktreeError.removalFailed( + host: "Local", + status: 1 + ) + }, + tmuxSessionIdentityReader: { selection, host in + throw TmuxSessionKillError.sessionNotRunning( + host: host.displayName, + session: selection.name + ) + }, + sessionPreviewCoordinator: TmuxSessionPreviewCoordinator(mode: .off) + ) + let selection = try #require( + WorkspaceSidebarModel.tmuxSessionSelection(for: removable) + ) + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: surfaceStore) + await waitUntilMainActor { model.activeBorrowedTmuxSessionIsConnected } + model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { hiddenSizingMutations.load() == 1 } + + // Reactivating a hidden client stages the selection while the + // interactive sizing change is still in flight. + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { interactiveSizingStarted.load() } + #expect(model.activeBorrowedTmuxSelection == selection) + #expect(!model.activeBorrowedTmuxSessionIsConnected) + + let request = try await model.prepareWorktreeRemoval(removable.id) + let removal = Task { @MainActor in + try await model.removeWorktree(request) + } + await waitUntilMainActor { await removerHold.started } + #expect(model.retainedBorrowedTmuxPresentationCount == 0) + releaseInteractiveSizing.signal() + await removerHold.release() + await #expect(throws: KwtWorktreeError.self) { + try await removal.value + } + + await waitUntilMainActor { + model.retainedBorrowedTmuxHandle(for: selection) != nil + } + #expect(model.activeBorrowedTmuxSelection == selection) + await model.shutdown() + } + + @MainActor + @Test("failed removal restores an inactive protected client on its socket") + func failedRemovalRestoresInactiveProtectedClientOnSocket() async throws { + let environment = try setupRemoteEnvironment() + let removable = protectedRemovableWorktree( + environment, + socketName: "kwt-pr-94" + ) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let mutations = WorktreeMutationCoordinator() + let model = try makeProtectedRemovalModel( + environment: environment, + removable: removable, + surfaceStore: surfaceStore, + mutations: mutations, + tmuxExactSessionProbe: { _ in .success(true) }, + tmuxSessionDiscovery: { _ in .success([]) } + ) + let selection = try #require( + WorkspaceSidebarModel.tmuxSessionSelection(for: removable) + ) + let other = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "other" + ) + try await openProtectedThenOther( + model, + store: surfaceStore, + mutations: mutations, + selection: selection, + other: other + ) + + let request = try await model.prepareWorktreeRemoval(removable.id) + await #expect(throws: KwtWorktreeError.self) { + try await model.removeWorktree(request) + } + await waitUntilMainActor { surfaceStore.requestCount == 3 } + + let restored = try #require(surfaceStore.lastConfiguration?.command) + #expect(restored.contains("attach-session")) + #expect(restored.contains("kwt-pr-94")) + #expect(!restored.contains("ghosthub_kwt_path")) + #expect(model.retainedBorrowedTmuxHandle(for: selection) != nil) + #expect(model.activeBorrowedTmuxSelection == other) + await model.shutdown() + } + + @MainActor + @Test("failed removal never restores a socketless protected client hidden") + func failedRemovalLeavesSocketlessProtectedClientExplicit() async throws { + let environment = try setupRemoteEnvironment() + let removable = protectedRemovableWorktree( + environment, + socketName: nil + ) + let sessionName = try #require(removable.tmuxSessionName) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let mutations = WorktreeMutationCoordinator() + let model = try makeProtectedRemovalModel( + environment: environment, + removable: removable, + surfaceStore: surfaceStore, + mutations: mutations, + tmuxExactSessionProbe: { _ in .success(false) }, + tmuxSessionDiscovery: { _ in + .success([ + DiscoveredTmuxSession( + name: sessionName, + windowCount: 1, + createdAt: nil, + managed: true + ), + ]) + } + ) + let selection = try #require( + WorkspaceSidebarModel.tmuxSessionSelection(for: removable) + ) + #expect(selection.socketName == nil) + let other = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "other" + ) + try await openProtectedThenOther( + model, + store: surfaceStore, + mutations: mutations, + selection: selection, + other: other, + expectedPresentationCount: 1 + ) + + let request = try await model.prepareWorktreeRemoval(removable.id) + await #expect(throws: KwtWorktreeError.self) { + try await model.removeWorktree(request) + } + try await Task.sleep(for: .milliseconds(100)) + + #expect(surfaceStore.requestCount == 2) + #expect(model.retainedBorrowedTmuxHandle(for: selection) == nil) + #expect(model.retainedBorrowedTmuxPresentationCount == 1) + #expect(model.activeBorrowedTmuxSelection == other) + + model.openBorrowedTmuxSession(selection) + await waitUntilMainActor { + model.prepareActiveBorrowedTmuxSurface() + return surfaceStore.requestCount == 3 + } + let reopened = try #require(surfaceStore.lastConfiguration?.command) + #expect(reopened.contains("ghosthub_kwt_path")) + #expect(!reopened.contains("attach-session")) + await model.shutdown() + } + + @MainActor + @Test("failed removal leaves an inactive Windows client explicit") + func failedRemovalLeavesInactiveWindowsClientExplicit() async throws { + let environment = try setupRemoteEnvironment() + let removable = protectedRemovableWorktree( + environment, + socketName: nil + ) + let surfaceStore = SceneTmuxSurfaceStoreStub() + let mutations = WorktreeMutationCoordinator() + let model = try makeProtectedRemovalModel( + environment: environment, + removable: removable, + surfaceStore: surfaceStore, + mutations: mutations, + platform: .windows, + tmuxExactSessionProbe: { _ in .success(true) }, + tmuxSessionDiscovery: { _ in .success([]) } + ) + let selection = try #require( + WorkspaceSidebarModel.tmuxSessionSelection(for: removable) + ) + let inactiveSelection = WorkspaceTmuxSessionSelection( + hostID: selection.hostID, + name: selection.name, + tmuxAttachMode: .protected + ) + let other = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "other" + ) + model.openBorrowedTmuxSession(inactiveSelection) + await launchActiveTmuxSurface(model, store: surfaceStore) + await waitUntilMainActor { + model.retainedBorrowedTmuxSessionIsConnected(inactiveSelection) + } + model.openBorrowedTmuxSession(other) + await waitUntilMainActor { + model.prepareActiveBorrowedTmuxSurface() + return surfaceStore.requestCount == 2 + && model.retainedBorrowedTmuxSessionIsConnected(other) + } + + let request = try await model.prepareWorktreeRemoval(removable.id) + await #expect(throws: KwtWorktreeError.self) { + try await model.removeWorktree(request) + } + try await Task.sleep(for: .milliseconds(100)) + + #expect(surfaceStore.requestCount == 2) + #expect(model.retainedBorrowedTmuxHandle(for: inactiveSelection) == nil) + #expect(model.retainedBorrowedTmuxPresentationCount == 1) + #expect(model.activeBorrowedTmuxSelection == other) + await model.shutdown() + } + + private func protectedRemovableWorktree( + _ environment: RemoteEnvironment, + socketName: String? + ) -> WorktreeSummary { + var removable = environment.snapshot.worktrees[0] + removable.generation = stableWorktreeGeneration + removable.scopedKey = removable.path + removable.tmuxSessionName = "kwt-ghosthub-pr-94" + removable.tmuxSocketName = socketName + removable.tmuxAttachMode = .protected + return removable + } + + @MainActor + private func makeProtectedRemovalModel( + environment: RemoteEnvironment, + removable: WorktreeSummary, + surfaceStore: SceneTmuxSurfaceStoreStub, + mutations: WorktreeMutationCoordinator, + platform: HostPlatform = .linux, + tmuxExactSessionProbe: @escaping WorkspaceSceneModel.TmuxSessionExactProbe, + tmuxSessionDiscovery: @escaping WorkspaceSceneModel.TmuxSessionDiscovery + ) throws -> WorkspaceSceneModel { + var snapshot = environment.snapshot + snapshot.hosts[0].platform = platform + snapshot.worktrees = [removable] + let beforeRemoval = inventory(environment, including: removable) + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + return try makeModel( + database: environment.database, + localHostID: UUID(), + snapshot: snapshot, + nativeTmuxSurfaceStore: surfaceStore, + nativeTmuxPaneSplitter: WorkspaceTmuxTestSupport.previewPaneSplitter( + identity: identity + ), + remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") }, + kwtInventoryLoader: { _ in beforeRemoval }, + kwtWorktreeRemover: { _, _, _, _, _ in + throw KwtWorktreeError.removalFailed( + host: "Office Linux", + status: 1 + ) + }, + worktreeMutationCoordinator: mutations, + tmuxSessionDiscovery: tmuxSessionDiscovery, + tmuxExactSessionProbe: tmuxExactSessionProbe, + tmuxSessionIdentityReader: { selection, host in + throw TmuxSessionKillError.sessionNotRunning( + host: host.displayName, + session: selection.name + ) + }, + createdSessionDiscoveryDelays: [.milliseconds(10)] + ) + } + + /// Opens the protected worktree, waits for kwt establishment to be + /// confirmed, then makes another session active so the protected client + /// is retained hidden. + @MainActor + private func openProtectedThenOther( + _ model: WorkspaceSceneModel, + store: SceneTmuxSurfaceStoreStub, + mutations: WorktreeMutationCoordinator, + selection: WorkspaceTmuxSessionSelection, + other: WorkspaceTmuxSessionSelection, + expectedPresentationCount: Int = 2 + ) async throws { + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: store) + await waitUntilMainActor { + model.connectedBorrowedTmuxSessionIDs.contains(selection.id) + } + let opened = try #require(store.lastConfiguration?.command) + #expect(opened.contains("ghosthub_kwt_path")) + #expect(!opened.contains("attach-session")) + await waitUntilMainActor { mutations.scopes.isEmpty } + + model.openBorrowedTmuxSession(other) + await waitUntilMainActor { + model.prepareActiveBorrowedTmuxSurface() + return store.requestCount == 2 + && model.retainedBorrowedTmuxSessionIsConnected(other) + } + #expect( + model.retainedBorrowedTmuxPresentationCount + == expectedPresentationCount + ) + } + } diff --git a/Tests/Tmux/TmuxAttachmentInfoTests.swift b/Tests/Tmux/TmuxAttachmentInfoTests.swift index da9761b3..13df8b92 100644 --- a/Tests/Tmux/TmuxAttachmentInfoTests.swift +++ b/Tests/Tmux/TmuxAttachmentInfoTests.swift @@ -602,6 +602,25 @@ struct TmuxAttachmentInfoTests { #expect(tmuxCommands.contains("@2 window-active-style")) } + @Test("non-sizing workspace attach still establishes through kwt") + func nonSizingWorkspaceAttachUsesKwt() { + let command = TmuxAttachmentInfo( + sessionName: "kwt-widget-feature", + host: .local, + workspacePath: "/worktrees/widget", + ignoresClientSize: true + ).attachCommand( + tmuxPath: "/usr/bin/tmux", + kwtPath: "/Applications/Ghosthub.app/Contents/Helpers/kwt" + ) + + #expect(command.contains("/Contents/Helpers/kwt")) + #expect(command.contains("'open'")) + #expect(!command.contains("attach-session")) + #expect(!command.contains("ignore-size")) + #expect(!command.contains("refresh-client")) + } + @Test("worktree attachment survives destroy-unattached") func localWorktreeSurvivesDestroyUnattached() throws { let tmuxPath = ProcessInfo.processInfo.environment["PATH"]? diff --git a/docs/architecture.md b/docs/architecture.md index c966b996..8c8d7c23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -848,6 +848,14 @@ client. Ghosthub never projects tmux windows or panes into a Swift split tree. Changing selection only hides the previous retained client. Pressing Cmd-W closes the active client, while closing its workspace window or the app closes every client retained by that scene; none of these paths runs `kill-session`. +On POSIX hosts, every hidden retained tmux client uses tmux's client-local +`ignore-size` flag, including after reconnect. Ghosthub clears that flag on the +exact client before making its surface interactive. If the exact-client +transition fails while hiding, Ghosthub detaches the client instead of leaving +an invisible sizing client attached. A resolved tmux version without safe +exact-client targeting remains available for interactive use, but Ghosthub +detaches it instead of retaining it while hidden. Ghosthub never changes the +tmux session's global `window-size` policy. Optional sidebar previews are GPU-native. Libghostty renders each retained client into its Metal-backed IOSurface; Ghosthub uses a Metal-backed Core Image context to scale a changed frame into a width-bounded preview IOSurface, then diff --git a/docs/terminal-sessions.md b/docs/terminal-sessions.md index 0a24e23a..eac8afd8 100644 --- a/docs/terminal-sessions.md +++ b/docs/terminal-sessions.md @@ -172,7 +172,8 @@ clients ignore tmux window sizing and render at the dimensions discovery reported for the session's active window; selecting one promotes that retained client in place by clearing `ignore-size` before interaction. Windows/psmux sessions are not attached automatically because psmux has no non-sizing client -mode. +mode. A POSIX tmux version without safe exact-client targeting remains usable +interactively, but Ghosthub detaches it instead of retaining it while hidden. Navigating away, pressing Cmd-W, closing a window, or quitting closes only the client. Ghosthub never reconstructs or otherwise controls Herdr themes, workspaces, tabs, panes, agents, plugins, installation, updates, configuration,