From 628f358d23fcaed7159b02d88dc24a5b94ae3fec Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 13:36:09 -0500 Subject: [PATCH 01/28] Serialize tmux client sizing transitions A hidden client can race an immediate reopen while tmux updates its client flags. Main actor isolation does not preserve command order across those suspended operations. Queue each attachment's sizing changes and retain the latest intent during provisioning. An older hide can no longer leave a reopened client in non-sizing mode. Generated with Codex Co-authored-by: Codex --- .../App/NativeTmuxSessionCoordinator.swift | 87 ++++++++++++++--- .../NativeTmuxSessionCoordinatorTests.swift | 95 +++++++++++++++++++ 2 files changed, 167 insertions(+), 15 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 91939f07..6b0a06f8 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -119,6 +119,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 @@ -178,7 +183,8 @@ 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: Task] = [:] private var interactiveSizingTransitionHandles: Set = [] private var isShuttingDown = false @@ -404,9 +410,22 @@ final class NativeTmuxSessionCoordinator { switch resolution { case let .success(resolved): let attachmentID = UUID() - let enablesInteractiveSizing = interactiveSizingHandles.remove( - handle.id - ) != nil + let pendingSizing = pendingSizingByHandle.removeValue( + forKey: handle.id + ) + let effectiveIgnoresClientSize: Bool + let effectivePreviewGridSize: TmuxGridSize? + switch pendingSizing { + case .interactive: + effectiveIgnoresClientSize = false + effectivePreviewGridSize = nil + case let .preview(gridSize): + effectiveIgnoresClientSize = true + effectivePreviewGridSize = gridSize + case nil: + effectiveIgnoresClientSize = ignoresClientSize + effectivePreviewGridSize = previewGridSize + } let protectedWorkspacePath = tmuxAttachMode == .protected ? workingDirectory : nil @@ -435,10 +454,8 @@ final class NativeTmuxSessionCoordinator { .appendingPathComponent( "tmux-clients", isDirectory: true ).path, - ignoresClientSize: enablesInteractiveSizing - ? false : ignoresClientSize, - previewGridSize: enablesInteractiveSizing - ? nil : previewGridSize, + ignoresClientSize: effectiveIgnoresClientSize, + previewGridSize: effectivePreviewGridSize, supportsPaneSplitting: TmuxPaneSplitter .supportsPaneSplitting( version: resolved.version, @@ -464,7 +481,7 @@ final class NativeTmuxSessionCoordinator { default: .launchFailed } - interactiveSizingHandles.remove(handle.id) + pendingSizingByHandle.removeValue(forKey: handle.id) onStateChanged?( handle, .disconnected(reason: error.localizedDescription) @@ -483,7 +500,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) @@ -536,7 +553,8 @@ final class NativeTmuxSessionCoordinator { launchedHandles.remove(handle.id) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) - interactiveSizingHandles.remove(handle.id) + pendingSizingByHandle.removeValue(forKey: handle.id) + sizingTransitionTails.removeValue(forKey: handle.id) terminalCoordinator.removeSurface(for: surfaceKey(handle)) } @@ -610,10 +628,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( @@ -683,11 +709,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 @@ -745,6 +780,27 @@ final class NativeTmuxSessionCoordinator { return .failure(failure) } + private func serializeSizingTransition( + for handle: BorrowedTmuxSessionHandle, + operation: @escaping @MainActor () async + -> TmuxClientSizingTransitionResult + ) async -> TmuxClientSizingTransitionResult { + let predecessor = sizingTransitionTails[handle.id] + let transition = Task { @MainActor in + if let predecessor { + await predecessor.value + } + return await operation() + } + let tail = Task { _ = await transition.value } + sizingTransitionTails[handle.id] = tail + let result = await transition.value + if sizingTransitionTails[handle.id] == tail { + sizingTransitionTails.removeValue(forKey: handle.id) + } + return result + } + private func applyPreviewGridSize( _ gridSize: TmuxGridSize?, for handle: BorrowedTmuxSessionHandle @@ -1419,7 +1475,8 @@ final class NativeTmuxSessionCoordinator { launchedHandles.removeAll() reportedConnectedAttachmentIDs.removeAll() deferredPresentationStyleHandles.removeAll() - interactiveSizingHandles.removeAll() + pendingSizingByHandle.removeAll() + sizingTransitionTails.removeAll() for handle in handles { terminalCoordinator.removeSurface(for: surfaceKey(handle)) } diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index cd5d272b..0384a171 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -1614,6 +1614,62 @@ 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("interactive sizing refreshes geometry before clearing ignore-size") func interactiveSizingRefreshesGeometryBeforePromotion() async { let store = RecordingNativeSessionSurfaceStore() @@ -1990,6 +2046,45 @@ 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")) + } } private enum SurfaceLaunchTestError: LocalizedError { From bdd19acd5f2174080c66c07cb996ecbe0234ecf1 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 14:02:49 -0500 Subject: [PATCH 02/28] Keep hidden tmux clients out of window sizing An ordinary tmux client stayed attached as a sizing authority after its presentation was hidden. That invisible client could shrink the shared session viewport even when previews were off. Make hidden POSIX clients non-sizing and restore interactive sizing before reuse. If Ghosthub cannot update the exact client safely, detach it instead of retaining an invisible sizing client. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 218 +++++++++++++++++- Tests/App/WorkspaceTmuxDiscoveryTests.swift | 6 + .../App/WorkspaceTmuxPresentationTests.swift | 217 ++++++++++++++++- Tests/App/WorkspaceTmuxRecoveryTests.swift | 27 ++- 4 files changed, 457 insertions(+), 11 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index d2926589..a273d6df 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,10 @@ 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 previewPromotionIsPending: Bool { previewPromotionTask != nil @@ -2473,6 +2481,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 @@ -10005,6 +10017,11 @@ 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 + } // 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 +10322,111 @@ 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 + let navigationRevision = presentation + .pendingSizingActivationNavigationRevision ?? 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 + if presentation + .pendingSizingActivationNavigationRevision != nil, + !nativeTmuxSessionCoordinator.isProvisioning( + presentation.handle + ) { + tmuxSurfaceBecameReady(presentation.handle) + } + } + } + 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 +10441,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 +10461,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 +10485,7 @@ final class WorkspaceSceneModel: ObservableObject { _ selection: WorkspaceTmuxSessionSelection ) { guard activeBorrowedTmuxSelection == selection else { return } - prepareActiveTmuxPreviewForDeactivation() + prepareActiveTmuxPresentationForDeactivation(excluding: nil) activeBorrowedTmuxSelection = nil activeBorrowedTmuxHandle = nil activeBorrowedTmuxLaunchMode = nil @@ -10373,6 +10493,81 @@ 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 } + + presentation.sizingIntent = .hidden + presentation.pendingSizingActivationNavigationRevision = nil + guard !nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle + ) else { 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 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 } + } while result == .stale + + if case let .failure(failure) = result { + invalidateBorrowedTmuxSession(presentation.selection) + AppLogger.shared.error( + "tmux hidden sizing: " + failure.localizedDescription, + context: "tmux" + ) + } + } + } + var retainedBorrowedTmuxPresentationCount: Int { retainedTmuxPresentations.count } @@ -10522,6 +10717,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() } diff --git a/Tests/App/WorkspaceTmuxDiscoveryTests.swift b/Tests/App/WorkspaceTmuxDiscoveryTests.swift index 5e84fabc..8e949273 100644 --- a/Tests/App/WorkspaceTmuxDiscoveryTests.swift +++ b/Tests/App/WorkspaceTmuxDiscoveryTests.swift @@ -2172,6 +2172,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..390122f0 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -125,6 +125,216 @@ 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("reopening waits for the hidden sizing transition") + 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"]) + + 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("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 +369,7 @@ extension WorkspaceTmuxDiscoveryTests { unpark: { _ in events.append( "unpark:\(weakModel?.activeBorrowedTmuxSelection?.name ?? "none")" + + ":\(weakModel?.activeBorrowedTmuxSessionIsConnected ?? false)" ) }, isKeyWindow: { true } @@ -210,7 +421,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, diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index 6314fc5e..0639bf1d 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) @@ -917,6 +939,9 @@ extension WorkspaceTmuxDiscoveryTests { model.openBorrowedTmuxSession(remote) model.prepareActiveBorrowedTmuxSurface() + await waitUntilMainActor { + model.activeBorrowedTmuxSessionIsConnected + } #expect(model.activeBorrowedTmuxSessionIsConnected) #expect(surfaceStore.requestCount == 3) await model.shutdown() From 2f92a5e82302a12195216b09ffbb27ca4896ebc6 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 14:33:31 -0500 Subject: [PATCH 03/28] Preserve hidden tmux sizing across reconnects A reconnect could restore sizing authority to an ordinary hidden tmux client. That invisible client could then resize the shared session before it was shown again. Carry the retained presentation's sizing intent into every reconnect. Windows clients remain user-owned, and Ghosthub does not change the global window-size policy. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 7 ++++--- Tests/App/WorkspaceTmuxRecoveryTests.swift | 4 ++++ docs/architecture.md | 6 ++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index a273d6df..a4aa7545 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -12571,6 +12571,8 @@ final class WorkspaceSceneModel: ObservableObject { let presentationKey = TmuxPresentationKey(selection) let isAlwaysLiveManaged = alwaysLiveManagedTmuxPresentationKeys .contains(presentationKey) + let reconnectsNonSizing = host.platform != .windows + && (isAlwaysLiveManaged || presentation.sizingIntent == .hidden) let previewGridSize = (tmuxSessionsByHost[selection.hostID] ?? host.tmuxSessions).first { $0.name == selection.name }? .previewClientSize @@ -12586,9 +12588,8 @@ final class WorkspaceSceneModel: ObservableObject { openWorkspace: openWorkspace, sessionIdentity: presentation.reconnectExpectedIdentity, expectedRouteIdentity: routeIdentity, - ignoresClientSize: isAlwaysLiveManaged - && host.platform != .windows, - previewGridSize: isAlwaysLiveManaged ? previewGridSize : nil + ignoresClientSize: reconnectsNonSizing, + previewGridSize: reconnectsNonSizing ? previewGridSize : nil ) if handle.id != previousHandle.id { retainedTmuxPresentationKeysByHandle.removeValue( diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index 0639bf1d..121c1604 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -936,6 +936,10 @@ 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() diff --git a/docs/architecture.md b/docs/architecture.md index c966b996..99cf06dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -848,6 +848,12 @@ 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. 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 From 2e33cb88ad7a58125824b39efb24e732947afa9c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 14:51:45 -0500 Subject: [PATCH 04/28] Cancel obsolete tmux sizing transitions A canceled scene operation could leave its queued sizing transition running. That obsolete work could issue a tmux command after a newer presentation state had taken over. Propagate caller cancellation into the serialized transition and stop before the tmux operation starts. This keeps the queue ordered without reviving work that its owner canceled. Generated with Codex Co-authored-by: Codex --- .../App/NativeTmuxSessionCoordinator.swift | 9 +++- .../NativeTmuxSessionCoordinatorTests.swift | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 6b0a06f8..8fc32ae5 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -790,11 +790,18 @@ final class NativeTmuxSessionCoordinator { if let predecessor { await predecessor.value } + guard !Task.isCancelled else { + return TmuxClientSizingTransitionResult.stale + } return await operation() } let tail = Task { _ = await transition.value } sizingTransitionTails[handle.id] = tail - let result = await transition.value + let result = await withTaskCancellationHandler { + await transition.value + } onCancel: { + transition.cancel() + } if sizingTransitionTails[handle.id] == tail { sizingTransitionTails.removeValue(forKey: handle.id) } diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index 0384a171..9dc4289b 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -1670,6 +1670,57 @@ struct NativeTmuxSessionCoordinatorTests { ]) } + @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("interactive sizing refreshes geometry before clearing ignore-size") func interactiveSizingRefreshesGeometryBeforePromotion() async { let store = RecordingNativeSessionSurfaceStore() From 7405062831dfdf68c8a51b37ee8117def8f0635e Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 18:58:17 -0500 Subject: [PATCH 05/28] Keep the latest tmux activation intent A second reopen could leave a retained tmux presentation tied to an older navigation revision. Its sizing task then rejected its own activation and repeatedly restarted without publishing the session. Make each reopen replace the pending revision and retire obsolete cleanup. Give the affected remote tests an exact-client response so they exercise the successful hidden-sizing path instead of racing an intentional detach. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 11 ++++++-- .../App/WorkspaceTmuxPresentationTests.swift | 28 +++++++++++++------ Tests/App/WorkspaceTmuxRecoveryTests.swift | 7 +++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index a4aa7545..261be668 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -10351,8 +10351,7 @@ final class WorkspaceSceneModel: ObservableObject { stageTmuxPresentationActivation(presentation) presentation.sizingIntent = .interactive - let navigationRevision = presentation - .pendingSizingActivationNavigationRevision ?? userNavigationRevision + let navigationRevision = userNavigationRevision presentation.pendingSizingActivationNavigationRevision = navigationRevision let predecessor = presentation.sizingTransitionTask @@ -10365,11 +10364,17 @@ final class WorkspaceSceneModel: ObservableObject { presentation.sizingTransitionID = nil presentation.sizingTransitionTask = nil if presentation - .pendingSizingActivationNavigationRevision != nil, + .pendingSizingActivationNavigationRevision + == userNavigationRevision, + tmuxPresentationActivationIsPending(presentation), !nativeTmuxSessionCoordinator.isProvisioning( presentation.handle ) { tmuxSurfaceBecameReady(presentation.handle) + } else if presentation + .pendingSizingActivationNavigationRevision != nil { + presentation + .pendingSizingActivationNavigationRevision = nil } } } diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 390122f0..980be8b0 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -191,7 +191,7 @@ extension WorkspaceTmuxDiscoveryTests { } @MainActor - @Test("reopening waits for the hidden sizing transition") + @Test("latest reopen wins while the hidden sizing transition is pending") func reopeningWaitsForHiddenSizingTransition() async throws { let environment = try setupHostEnvironment() var snapshot = environment.snapshot @@ -262,6 +262,8 @@ extension WorkspaceTmuxDiscoveryTests { #expect(!model.activeBorrowedTmuxSessionIsConnected) #expect(events.load() == ["hidden-start"]) + model.openBorrowedTmuxSession(selection) + releaseHide.signal() await waitUntilMainActor { model.activeBorrowedTmuxSessionIsConnected @@ -578,14 +580,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" @@ -594,6 +588,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) diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index 121c1604..d06b8c96 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -2238,12 +2238,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") }, From 86002b48257e59c3f05eacedd12f265268a8117c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 20:12:01 -0500 Subject: [PATCH 06/28] Resume tmux activation after provisioning Reopening a hidden session while its attachment was still resolving could leave it selected without an active client. The sizing transition correctly deferred to provisioning, but its scene marker disappeared before readiness could resume it. Keep current activation intent until surface readiness and discard only obsolete intent. This lets the selected client become interactive without reviving stale navigation. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 12 ++--- .../App/WorkspaceTmuxPresentationTests.swift | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 261be668..608d0420 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -10363,14 +10363,16 @@ final class WorkspaceSceneModel: ObservableObject { if presentation.sizingTransitionID == transitionID { presentation.sizingTransitionID = nil presentation.sizingTransitionTask = nil - if presentation + let resumesCurrentActivation = presentation .pendingSizingActivationNavigationRevision - == userNavigationRevision, - tmuxPresentationActivationIsPending(presentation), - !nativeTmuxSessionCoordinator.isProvisioning( + == userNavigationRevision + && tmuxPresentationActivationIsPending(presentation) + if resumesCurrentActivation { + if !nativeTmuxSessionCoordinator.isProvisioning( presentation.handle ) { - tmuxSurfaceBecameReady(presentation.handle) + tmuxSurfaceBecameReady(presentation.handle) + } } else if presentation .pendingSizingActivationNavigationRevision != nil { presentation diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 980be8b0..47c478f7 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -276,6 +276,51 @@ extension WorkspaceTmuxDiscoveryTests { 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("a failed hidden sizing transition detaches the client") func failedHiddenSizingTransitionDetachesClient() async throws { From 55da47ac9ec8996ab91df99d0fc530c0ed1315ab Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 21:47:12 -0500 Subject: [PATCH 07/28] Drain tmux sizing before attachment release A hidden client could apply its preview grid before tmux stopped using that client for shared window sizing. Detach and shutdown could also release an attachment while its last sizing command was still running. Set ignore-size before changing local preview geometry. Keep ownership of every sizing task through teardown, cancel it, and wait before releasing the SSH attachment. Generated with Codex Co-authored-by: Codex --- .../App/NativeTmuxSessionCoordinator.swift | 56 ++++++-- .../NativeTmuxSessionCoordinatorTests.swift | 121 +++++++++++++++--- 2 files changed, 149 insertions(+), 28 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 8fc32ae5..abfa06ce 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -146,6 +146,9 @@ final class NativeTmuxSessionCoordinator { var task: Task } + private typealias SizingTransitionTask = + Task + private let terminalCoordinator: any NativeSessionSurfaceStoring private let tmuxPathProvider: @Sendable () -> Result @@ -184,7 +187,9 @@ final class NativeTmuxSessionCoordinator { private var unavailablePreviewIdentityHandles: Set = [] private var deferredPresentationStyleHandles: Set = [] private var pendingSizingByHandle: [UUID: PendingTmuxClientSizing] = [:] - private var sizingTransitionTails: [UUID: Task] = [:] + private var sizingTransitionTails: [UUID: SizingTransitionTask] = [:] + private var sizingTransitionTasks: + [UUID: [UUID: SizingTransitionTask]] = [:] private var interactiveSizingTransitionHandles: Set = [] private var isShuttingDown = false @@ -544,17 +549,25 @@ final class NativeTmuxSessionCoordinator { } provisioningTasks.removeValue(forKey: handle.id)?.cancel() cancelPaneSplits(handleID: handle.id) + let sizingTransitions = cancelSizingTransitions( + 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() } + let remoteExitStatusStore = remoteExitStatusStore + Task { + for transition in sizingTransitions { + _ = await transition.value + } + remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) + try? await attachment?.sshConnection?.release() + } attachmentClosures.removeValue(forKey: handle.id) launchedHandles.remove(handle.id) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) pendingSizingByHandle.removeValue(forKey: handle.id) - sizingTransitionTails.removeValue(forKey: handle.id) terminalCoordinator.removeSurface(for: surfaceKey(handle)) } @@ -766,7 +779,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 @@ -775,6 +787,7 @@ final class NativeTmuxSessionCoordinator { attachment.ignoresClientSize = true attachment.previewGridSize = gridSize attachments[handle.id] = attachment + applyPreviewGridSize(gridSize, for: handle) return .applied } return .failure(failure) @@ -786,28 +799,45 @@ final class NativeTmuxSessionCoordinator { -> TmuxClientSizingTransitionResult ) async -> TmuxClientSizingTransitionResult { let predecessor = sizingTransitionTails[handle.id] + let transitionID = UUID() let transition = Task { @MainActor in if let predecessor { - await predecessor.value + _ = await predecessor.value } guard !Task.isCancelled else { return TmuxClientSizingTransitionResult.stale } return await operation() } - let tail = Task { _ = await transition.value } - sizingTransitionTails[handle.id] = tail + sizingTransitionTails[handle.id] = transition + sizingTransitionTasks[handle.id, default: [:]][transitionID] = + transition let result = await withTaskCancellationHandler { await transition.value } onCancel: { transition.cancel() } - if sizingTransitionTails[handle.id] == tail { + 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 applyPreviewGridSize( _ gridSize: TmuxGridSize?, for handle: BorrowedTmuxSessionHandle @@ -1459,6 +1489,10 @@ final class NativeTmuxSessionCoordinator { isShuttingDown = true let handles = Array(handlesByKey.values) let connections = attachments.values.compactMap(\.sshConnection) + let sizingTransitions = sizingTransitionTasks.values.flatMap(\.values) + sizingTransitions.forEach { $0.cancel() } + sizingTransitionTasks.removeAll() + sizingTransitionTails.removeAll() provisioningTasks.values.forEach { $0.cancel() } paneSplitClientBindings.values.forEach { $0.task.cancel() } paneSplitWorkers.values.forEach { $0.task.cancel() } @@ -1483,7 +1517,9 @@ final class NativeTmuxSessionCoordinator { reportedConnectedAttachmentIDs.removeAll() deferredPresentationStyleHandles.removeAll() pendingSizingByHandle.removeAll() - sizingTransitionTails.removeAll() + for transition in sizingTransitions { + _ = await transition.value + } for handle in handles { terminalCoordinator.removeSurface(for: surfaceKey(handle)) } diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index 9dc4289b..d148ef26 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -16,6 +16,11 @@ 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 shutdown +} + private func supportedPaneSplitter( _ runner: @escaping TmuxPaneSplitter.Runner ) -> TmuxPaneSplitter { @@ -1721,6 +1726,96 @@ struct NativeTmuxSessionCoordinatorTests { #expect(events.load() == ["hidden-start", "hidden-end"]) } + @Test( + "teardown cancels sizing before releasing its SSH attachment", + arguments: [SizingTeardown.detach, .shutdown] + ) + func teardownCancelsSizingBeforeRelease( + _ teardown: SizingTeardown + ) async { + 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 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 + }), + 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 } + 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"] + } + + switch teardown { + case .detach: + coordinator.detach( + hostID: hostID, + name: handle.name + ) + case .shutdown: + await coordinator.shutdown() + } + await waitUntilMainActor { + events.load().contains("release") + } + + #expect(events.load() == [ + "sizing-start", "sizing-cancel", "release", + ]) + allowSizingCompletion.signal() + _ = await sizing.value + } + @Test("interactive sizing refreshes geometry before clearing ignore-size") func interactiveSizingRefreshesGeometryBeforePromotion() async { let store = RecordingNativeSessionSurfaceStore() @@ -1761,8 +1856,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( @@ -1836,8 +1931,8 @@ 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 + [ @@ -1849,18 +1944,8 @@ struct NativeTmuxSessionCoordinatorTests { 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 + previewGridWasIgnored = + clientFlags?.contains("ignore-size") == true } let coordinator = NativeTmuxSessionCoordinator( terminalCoordinator: store, @@ -1902,12 +1987,12 @@ struct NativeTmuxSessionCoordinatorTests { timeout: 5 ) - #expect(previewResizeStatus == 0) + #expect(previewGridWasIgnored) #expect(measured.status == 0) #expect( measured.stdout.trimmingCharacters( in: CharacterSet.whitespacesAndNewlines - ) == "120x37" + ) == "80x24" ) } From f8a19ad8e32869cc5bf91b97deff17505dd6e4a0 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 1 Sep 2026 23:11:47 -0500 Subject: [PATCH 08/28] Fence tmux sizing teardown and unsupported clients A closing surface could start its replacement while an exact-client sizing command was still running. Older tmux versions could also receive sizing operations that depend on safe client targeting they do not provide. Make replacement attachment work wait for canceled sizing transitions. Gate hidden and reconnect sizing on the resolved capability, while keeping older tmux versions available for ordinary interactive use. Generated with Codex Co-authored-by: Codex --- .../App/NativeTmuxSessionCoordinator.swift | 99 +++++++++++++---- Sources/App/TmuxPaneSplitter.swift | 14 +++ Sources/App/WorkspaceSceneModel.swift | 13 +++ .../NativeTmuxSessionCoordinatorTests.swift | 105 ++++++++++++++++-- .../App/WorkspaceTmuxPresentationTests.swift | 41 +++++++ docs/architecture.md | 6 +- docs/terminal-sessions.md | 3 +- 7 files changed, 249 insertions(+), 32 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index abfa06ce..29dd5e86 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -97,6 +97,7 @@ private struct NativeTmuxAttachment { var clientTTYDirectory: String? var ignoresClientSize: Bool var previewGridSize: TmuxGridSize? + var supportsClientSizing: Bool var supportsPaneSplitting: Bool var remoteExitStatusURL: URL? } @@ -190,6 +191,7 @@ final class NativeTmuxSessionCoordinator { private var sizingTransitionTails: [UUID: SizingTransitionTask] = [:] private var sizingTransitionTasks: [UUID: [UUID: SizingTransitionTask]] = [:] + private var sizingTransitionDrains: [UUID: Task] = [:] private var interactiveSizingTransitionHandles: Set = [] private var isShuttingDown = false @@ -317,8 +319,11 @@ final class NativeTmuxSessionCoordinator { let tmuxPathProvider = tmuxPathProvider let remoteTmuxPathProvider = remoteTmuxPathProvider let remoteConnectionProvider = remoteConnectionProvider + let sizingTransitionDrain = sizingTransitionDrains[handle.id] provisioningTasks[handle.id] = Task { [weak self] in do { + await sizingTransitionDrain?.value + try Task.checkCancellation() let sshConnection: KwtSSHConnection? let sshConnectionSnapshot: SSHConnectionArgumentsSnapshot if case let .ssh(info) = host { @@ -415,6 +420,11 @@ final class NativeTmuxSessionCoordinator { switch resolution { case let .success(resolved): let attachmentID = UUID() + let supportsClientSizing = TmuxPaneSplitter + .supportsClientSizing( + version: resolved.version, + host: host + ) let pendingSizing = pendingSizingByHandle.removeValue( forKey: handle.id ) @@ -424,12 +434,17 @@ final class NativeTmuxSessionCoordinator { case .interactive: effectiveIgnoresClientSize = false effectivePreviewGridSize = nil - case let .preview(gridSize): + case let .preview(gridSize) where supportsClientSizing: effectiveIgnoresClientSize = true effectivePreviewGridSize = gridSize + case .preview: + effectiveIgnoresClientSize = false + effectivePreviewGridSize = nil case nil: - effectiveIgnoresClientSize = ignoresClientSize - effectivePreviewGridSize = previewGridSize + effectiveIgnoresClientSize = supportsClientSizing + && ignoresClientSize + effectivePreviewGridSize = effectiveIgnoresClientSize + ? previewGridSize : nil } let protectedWorkspacePath = tmuxAttachMode == .protected ? workingDirectory @@ -461,6 +476,7 @@ final class NativeTmuxSessionCoordinator { ).path, ignoresClientSize: effectiveIgnoresClientSize, previewGridSize: effectivePreviewGridSize, + supportsClientSizing: supportsClientSizing, supportsPaneSplitting: TmuxPaneSplitter .supportsPaneSplitting( version: resolved.version, @@ -549,20 +565,14 @@ final class NativeTmuxSessionCoordinator { } provisioningTasks.removeValue(forKey: handle.id)?.cancel() cancelPaneSplits(handleID: handle.id) - let sizingTransitions = cancelSizingTransitions( - handleID: handle.id - ) provisioningHandles.remove(handle.id) targetHostsByHandle.removeValue(forKey: handle.id) let attachment = attachments.removeValue(forKey: handle.id) - let remoteExitStatusStore = remoteExitStatusStore - Task { - for transition in sizingTransitions { - _ = await transition.value - } - remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) - try? await attachment?.sshConnection?.release() - } + cancelSizingTransitionsAndRelease( + handleID: handle.id, + attachment: attachment, + removesRemoteExitStatus: true + ) attachmentClosures.removeValue(forKey: handle.id) launchedHandles.remove(handle.id) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) @@ -745,6 +755,14 @@ 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 @@ -838,6 +856,37 @@ final class NativeTmuxSessionCoordinator { return transitions } + private func cancelSizingTransitionsAndRelease( + handleID: UUID, + attachment: NativeTmuxAttachment?, + invalidatesConnection: Bool = false, + removesRemoteExitStatus: Bool = false + ) { + let sizingTransitions = cancelSizingTransitions(handleID: handleID) + let predecessor = sizingTransitionDrains[handleID] + let drain = Task { + await predecessor?.value + for transition in sizingTransitions { + _ = await transition.value + } + } + sizingTransitionDrains[handleID] = drain + let remoteExitStatusStore = remoteExitStatusStore + Task { [weak self] in + await drain.value + if self?.sizingTransitionDrains[handleID] == drain { + self?.sizingTransitionDrains.removeValue(forKey: handleID) + } + if invalidatesConnection { + await attachment?.sshConnection?.invalidate() + } + if removesRemoteExitStatus { + remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) + } + try? await attachment?.sshConnection?.release() + } + } + private func applyPreviewGridSize( _ gridSize: TmuxGridSize?, for handle: BorrowedTmuxSessionHandle @@ -1325,8 +1374,11 @@ final class NativeTmuxSessionCoordinator { cancelPaneSplits(handleID: handle.id) attachmentClosures[handle.id] = closure let attachment = attachments.removeValue(forKey: handle.id) - remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) - Task { try? await attachment?.sshConnection?.release() } + cancelSizingTransitionsAndRelease( + handleID: handle.id, + attachment: attachment, + removesRemoteExitStatus: true + ) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) terminalCoordinator.removeSurface(for: surfaceKey(handle)) @@ -1344,6 +1396,10 @@ final class NativeTmuxSessionCoordinator { attachments[handle.id]?.supportsPaneSplitting == true } + func supportsClientSizing(_ handle: BorrowedTmuxSessionHandle) -> Bool { + attachments[handle.id]?.supportsClientSizing == true + } + func attachedSessionIdentity( _ handle: BorrowedTmuxSessionHandle ) -> TmuxSessionIdentity? { @@ -1468,12 +1524,11 @@ final class NativeTmuxSessionCoordinator { recordedExitCode: recordedExitCode, childExitCode: childExitCode ) - Task { - if connectionUnusable { - await attachment?.sshConnection?.invalidate() - } - try? await attachment?.sshConnection?.release() - } + cancelSizingTransitionsAndRelease( + handleID: handle.id, + attachment: attachment, + invalidatesConnection: connectionUnusable + ) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) 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 608d0420..4249eec0 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -10045,6 +10045,11 @@ final class WorkspaceSceneModel: ObservableObject { excludeAlwaysLiveTmuxPresentation(presentation, key: key) return } + if presentation.sizingIntent == .hidden, + !nativeTmuxSessionCoordinator.supportsClientSizing(handle) { + invalidateBorrowedTmuxSession(presentation.selection) + return + } if alwaysLiveManagedTmuxPresentationKeys.contains(key), activeBorrowedTmuxHandle != handle, !nativeTmuxSessionCoordinator.hasLaunched(handle) { @@ -10532,6 +10537,14 @@ final class WorkspaceSceneModel: ObservableObject { 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) diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index d148ef26..283bf95c 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -18,7 +18,9 @@ private let coordinatorSplitClientOutput = enum SizingTeardown: Sendable { case detach + case launchFailure case shutdown + case surfaceClose } private func supportedPaneSplitter( @@ -1563,7 +1565,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) } @@ -1728,11 +1734,16 @@ struct NativeTmuxSessionCoordinatorTests { @Test( "teardown cancels sizing before releasing its SSH attachment", - arguments: [SizingTeardown.detach, .shutdown] + arguments: [ + SizingTeardown.detach, + .launchFailure, + .shutdown, + .surfaceClose, + ] ) func teardownCancelsSizingBeforeRelease( _ teardown: SizingTeardown - ) async { + ) async throws { let events = LockedValue<[String]>([]) let allowSizingCompletion = DispatchSemaphore(value: 0) let store = RecordingNativeSessionSurfaceStore() @@ -1742,6 +1753,7 @@ struct NativeTmuxSessionCoordinatorTests { hostname: "build.example.test", port: nil ) + let connectionRequests = LockedValue(0) let coordinator = NativeTmuxSessionCoordinator( terminalCoordinator: store, tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, @@ -1749,7 +1761,11 @@ struct NativeTmuxSessionCoordinatorTests { successfulTmuxResolution("/usr/bin/tmux") }, remoteConnectionProvider: { _, _ in - testKwtSSHAttachment( + connectionRequests.withLock { $0 += 1 } + if connectionRequests.load() > 1 { + events.withLock { $0.append("replacement-acquire") } + } + return testKwtSSHAttachment( release: { events.withLock { $0.append("release") } } @@ -1780,6 +1796,11 @@ struct NativeTmuxSessionCoordinatorTests { ) 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", @@ -1795,6 +1816,10 @@ struct NativeTmuxSessionCoordinatorTests { await waitUntilMainActor { events.load() == ["sizing-start"] } + let expectsDisconnected = switch teardown { + case .detach, .shutdown: false + case .launchFailure, .surfaceClose: true + } switch teardown { case .detach: @@ -1802,16 +1827,50 @@ struct NativeTmuxSessionCoordinatorTests { 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")) } - #expect(events.load() == [ - "sizing-start", "sizing-cancel", "release", - ]) + 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 } @@ -2221,6 +2280,38 @@ struct NativeTmuxSessionCoordinatorTests { ) #expect(command.contains("ignore-size")) } + + @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/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 47c478f7..2e9bb320 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -276,6 +276,47 @@ extension WorkspaceTmuxDiscoveryTests { 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("reopening during provisioning resumes activation when ready") func reopeningDuringProvisioningResumesActivation() async throws { diff --git a/docs/architecture.md b/docs/architecture.md index 99cf06dd..8c8d7c23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -852,8 +852,10 @@ 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. Ghosthub never changes the tmux session's -global `window-size` policy. +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, From 90c0f8bf588eac6e8e092fbda157a94a66d9cc14 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 04:55:39 -0500 Subject: [PATCH 09/28] Keep hidden kwt tmux clients out of window sizing Kwt owns atomic worktree session creation and attachment, so its client is not available for the ordinary attach command's ignore-size option. Apply the flag to the exact client that kwt created after Ghosthub detects its launch PTY. This keeps retained hidden POSIX clients from resizing shared windows without changing global tmux options or Windows behavior. Generated with Codex Co-authored-by: Codex --- Sources/Tmux/TmuxAttachmentInfo.swift | 16 +++++ Tests/Tmux/TmuxAttachmentInfoTests.swift | 84 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/Sources/Tmux/TmuxAttachmentInfo.swift b/Sources/Tmux/TmuxAttachmentInfo.swift index b0f65867..0c0b3fd3 100644 --- a/Sources/Tmux/TmuxAttachmentInfo.swift +++ b/Sources/Tmux/TmuxAttachmentInfo.swift @@ -333,6 +333,11 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { presentationCommand.bestEffortCommand(tmuxPath: tmuxPath) ) } + if let clientSizeSetupCommand = clientSizeSetupCommand( + tmuxPath: tmuxPath + ) { + setupCommands.append(clientSizeSetupCommand) + } let sessionSetup = setupCommands.joined(separator: "; ") let listClientTTYs = tmuxArguments( tmuxPath, @@ -361,6 +366,17 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ].joined(separator: "; ") } + private func clientSizeSetupCommand(tmuxPath: String) -> String? { + guard ignoresClientSize else { return nil } + let beforeClientTTY = tmuxArguments( + tmuxPath, "refresh-client", "-t" + ).map(shellQuotedCommandArgument).joined(separator: " ") + let afterClientTTY = ["-f", "ignore-size"] + .map(shellQuotedCommandArgument) + .joined(separator: " ") + return beforeClientTTY + " \"$ghosthub_kwt_tty\" " + afterClientTTY + } + private func remoteAttachCommand( info: SSHHostInfo, tmuxPath: String, diff --git a/Tests/Tmux/TmuxAttachmentInfoTests.swift b/Tests/Tmux/TmuxAttachmentInfoTests.swift index da9761b3..cb25fd06 100644 --- a/Tests/Tmux/TmuxAttachmentInfoTests.swift +++ b/Tests/Tmux/TmuxAttachmentInfoTests.swift @@ -602,6 +602,90 @@ struct TmuxAttachmentInfoTests { #expect(tmuxCommands.contains("@2 window-active-style")) } + @Test(arguments: [true, false]) + func kwtAttachmentSetsIgnoreSizeOnItsDetectedClient( + ignoresClientSize: Bool + ) throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let kwt = directory.appendingPathComponent("kwt") + let tmux = directory.appendingPathComponent("tmux") + let log = directory.appendingPathComponent("tmux.log") + let clientTTY = directory.appendingPathComponent("client.tty") + let refreshed = directory.appendingPathComponent("refreshed") + try """ + #!/bin/sh + exec "$GHOSTHUB_TMUX" attach-session + """.write(to: kwt, atomically: true, encoding: .utf8) + try """ + #!/bin/sh + case " $* " in + *" attach-session "*) + tty > "$GHOSTHUB_TMUX_CLIENT_TTY" + if [ "$GHOSTHUB_EXPECT_REFRESH" = 1 ]; then + ghosthub_attempts=0 + while [ ! -f "$GHOSTHUB_TMUX_REFRESHED" ]; do + ghosthub_attempts=$((ghosthub_attempts + 1)) + [ "$ghosthub_attempts" -ge 100 ] && break + sleep 0.01 + done + fi + ;; + *" list-clients "*) + [ -f "$GHOSTHUB_TMUX_CLIENT_TTY" ] && cat "$GHOSTHUB_TMUX_CLIENT_TTY" + ;; + *" refresh-client "*) + printf '%s\\n' "$*" >> "$GHOSTHUB_TMUX_LOG" + : > "$GHOSTHUB_TMUX_REFRESHED" + ;; + esac + """.write(to: tmux, atomically: true, encoding: .utf8) + for executable in [kwt, tmux] { + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: executable.path + ) + } + + let command = TmuxAttachmentInfo( + sessionName: "kwt-widget-feature", + host: .local, + workspacePath: "/worktrees/widget", + ignoresClientSize: ignoresClientSize + ).attachCommand(tmuxPath: tmux.path, kwtPath: kwt.path) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/script") + process.arguments = ["-q", "/dev/null", "/bin/sh", "-c", command] + process.environment = ProcessInfo.processInfo.environment.merging([ + "GHOSTHUB_TMUX": tmux.path, + "GHOSTHUB_TMUX_CLIENT_TTY": clientTTY.path, + "GHOSTHUB_TMUX_LOG": log.path, + "GHOSTHUB_TMUX_REFRESHED": refreshed.path, + "GHOSTHUB_EXPECT_REFRESH": ignoresClientSize ? "1" : "0", + "TERM": "xterm-256color", + ]) { _, new in new } + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + + #expect(process.terminationStatus == 0) + let tmuxCommands = (try? String(contentsOf: log, encoding: .utf8)) ?? "" + let attachedTTY = try String( + contentsOf: clientTTY, encoding: .utf8 + ).trimmingCharacters(in: .whitespacesAndNewlines) + if ignoresClientSize { + #expect(tmuxCommands.contains( + "refresh-client -t \(attachedTTY) -f ignore-size" + )) + } else { + #expect(tmuxCommands.isEmpty) + } + } + @Test("worktree attachment survives destroy-unattached") func localWorktreeSurvivesDestroyUnattached() throws { let tmuxPath = ProcessInfo.processInfo.environment["PATH"]? From 46e4443f788ba717e1237a3c4362c922a1fb1165 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 05:12:59 -0500 Subject: [PATCH 10/28] Start inactive restored tmux clients hidden Failed worktree removal can restore a tmux client after the user moved to a different session. That background client must not control the shared tmux window size before it is opened again. Start non-activating removal recovery clients with exact-client non-sizing state on POSIX and retain hidden sizing intent for their later user-driven promotion. Keep Windows and Always Live behavior unchanged. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 22 ++++++++++++++----- ...orkspaceWorktreeRemovalRecoveryTests.swift | 10 +++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 4249eec0..072e548d 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -2850,7 +2850,8 @@ final class WorkspaceSceneModel: ObservableObject { killedSessionReestablishmentTarget, launchMode: .attach, intent: .userInitiated, - activatesPresentation: false + activatesPresentation: false, + startsHidden: true ) } } @@ -4830,15 +4831,17 @@ final class WorkspaceSceneModel: ObservableObject { ) else { continue } let establishesWorkspace = requiresWorkspaceReestablishment || presentation.requiresWorkspaceEstablishment + let activatesPresentation = presentation.wasActive + && presentation.userNavigationRevision + == userNavigationRevision _ = presentTmuxSession( selection, launchMode: establishesWorkspace ? .attach : presentation.launchMode, intent: establishesWorkspace ? .userInitiated : .restoreOnly, - activatesPresentation: presentation.wasActive - && presentation.userNavigationRevision - == userNavigationRevision + activatesPresentation: activatesPresentation, + startsHidden: !activatesPresentation ) } } @@ -9416,6 +9419,7 @@ final class WorkspaceSceneModel: ObservableObject { commandReplayAuthorized: Bool = false, intent: TmuxPresentationIntent = .userInitiated, activatesPresentation: Bool = true, + startsHidden: Bool = false, ignoresClientSize: Bool = false, previewGridSize: TmuxGridSize? = nil ) -> BorrowedTmuxSessionHandle? { @@ -9556,6 +9560,10 @@ final class WorkspaceSceneModel: ObservableObject { selection, hostSummary: host ) + let startsHiddenOnPOSIX = startsHidden && host.platform != .windows + let hiddenPreviewGridSize = (tmuxSessionsByHost[selection.hostID] + ?? host.tmuxSessions).first { $0.name == selection.name }? + .previewClientSize let handle = nativeTmuxSessionCoordinator.attach( hostID: selection.hostID, name: selection.name, @@ -9569,8 +9577,9 @@ final class WorkspaceSceneModel: ObservableObject { workingDirectory: selection.workspacePath, openWorkspace: openWorkspace, sessionIdentity: discoveredIdentity, - ignoresClientSize: ignoresClientSize, - previewGridSize: previewGridSize + ignoresClientSize: startsHiddenOnPOSIX || ignoresClientSize, + previewGridSize: startsHiddenOnPOSIX + ? hiddenPreviewGridSize : previewGridSize ) let phase: RemoteTmuxEstablishmentPhase if openWorkspace || protectedSessionNeedsEstablishment { @@ -9607,6 +9616,7 @@ final class WorkspaceSceneModel: ObservableObject { ), verifiedPreviewIdentity: nil ) + presentation.sizingIntent = startsHiddenOnPOSIX ? .hidden : .interactive presentation.reconnectExpectedIdentity = discoveredIdentity objectWillChange.send() retainedTmuxPresentations[key] = presentation diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index b5377a7a..d91282f6 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -936,11 +936,14 @@ extension WorkspaceWorktreeRemovalTests { 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) @@ -1006,6 +1012,10 @@ extension WorkspaceWorktreeRemovalTests { ) #expect(model.retainedBorrowedTmuxHandle(for: other) == activeHandle) #expect(model.activeBorrowedTmuxSelection == other) + await waitUntilMainActor { + surfaces.requestedConfigurations.count == 3 + } + #expect(surfaces.lastCommand?.contains("ignore-size") == true) await model.shutdown() } From 76a0d1921919d8e82179cee3e7358aa27ee40924 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 05:46:23 -0500 Subject: [PATCH 11/28] Keep named-socket previews independent Tmux inventory describes only the default server. A named-socket client with the same session name must not inherit that server's preview dimensions when Ghosthub hides it. Use the shared inventory lookup for hidden startup and make the lookup reject named sockets. This keeps their clients non-sizing without applying unrelated terminal geometry. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 6 +-- Tests/App/WorkspaceTmuxDiscoveryTests.swift | 55 +++++++++++++++++++++ Tests/App/WorkspaceTmuxTestSupport.swift | 7 +++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 072e548d..c63ea239 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -9561,9 +9561,6 @@ final class WorkspaceSceneModel: ObservableObject { hostSummary: host ) let startsHiddenOnPOSIX = startsHidden && host.platform != .windows - let hiddenPreviewGridSize = (tmuxSessionsByHost[selection.hostID] - ?? host.tmuxSessions).first { $0.name == selection.name }? - .previewClientSize let handle = nativeTmuxSessionCoordinator.attach( hostID: selection.hostID, name: selection.name, @@ -9579,7 +9576,7 @@ final class WorkspaceSceneModel: ObservableObject { sessionIdentity: discoveredIdentity, ignoresClientSize: startsHiddenOnPOSIX || ignoresClientSize, previewGridSize: startsHiddenOnPOSIX - ? hiddenPreviewGridSize : previewGridSize + ? self.previewGridSize(for: selection) : previewGridSize ) let phase: RemoteTmuxEstablishmentPhase if openWorkspace || protectedSessionNeedsEstablishment { @@ -9805,6 +9802,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 { diff --git a/Tests/App/WorkspaceTmuxDiscoveryTests.swift b/Tests/App/WorkspaceTmuxDiscoveryTests.swift index 8e949273..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 { 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 } From a7fff91700874e41aade8086f6402d264b6116db Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 06:36:25 -0500 Subject: [PATCH 12/28] Keep hidden tmux clients non-sizing from attach Hidden worktree recovery could resize the shared tmux window before Ghosthub set ignore-size on the kwt-created client. Kwt has no creation-time client flag contract, so hidden POSIX recovery now attaches directly to an existing session with ignore-size in the attach command. Keep reconnect sizing scoped to the selected socket. Preserve a pending hidden sizing transition when its old client disconnects so reconnect readiness can finish the non-sizing restoration instead of discarding the presentation. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 55 ++++- Sources/Tmux/TmuxAttachmentInfo.swift | 28 +-- Tests/App/WorkspaceTmuxRecoveryTests.swift | 207 ++++++++++++++++++ ...orkspaceWorktreeRemovalRecoveryTests.swift | 39 +++- Tests/Tmux/TmuxAttachmentInfoTests.swift | 88 +------- 5 files changed, 299 insertions(+), 118 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index c63ea239..0144a9c2 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -530,6 +530,7 @@ final class WorkspaceSceneModel: ObservableObject { var sizingTransitionID: UUID? var sizingTransitionTask: Task? var pendingSizingActivationNavigationRevision: UInt64? + var hiddenSizingReconnectPending = false var previewPromotionIsPending: Bool { previewPromotionTask != nil @@ -9541,6 +9542,12 @@ final class WorkspaceSceneModel: ObservableObject { } return nil } + let startsHiddenOnPOSIX = startsHidden && host.platform != .windows + // 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 = + startsHiddenOnPOSIX ? .attachOnly : effectiveLaunchMode let knownSessions = tmuxSessionsByHost[selection.hostID] ?? host.tmuxSessions let sessionIsDiscovered = selection.socketName == nil @@ -9548,27 +9555,26 @@ 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( selection, hostSummary: host ) - let startsHiddenOnPOSIX = startsHidden && host.platform != .windows let handle = nativeTmuxSessionCoordinator.attach( hostID: selection.hostID, name: selection.name, host: attachmentHost, socketName: selection.socketName, tmuxAttachMode: selection.tmuxAttachMode, - launchMode: effectiveLaunchMode, - initialCommand: effectiveLaunchMode == .create + launchMode: attachmentLaunchMode, + initialCommand: attachmentLaunchMode == .create ? initialCommand : nil, workingDirectory: selection.workspacePath, @@ -9581,7 +9587,7 @@ final class WorkspaceSceneModel: ObservableObject { let phase: RemoteTmuxEstablishmentPhase if openWorkspace || protectedSessionNeedsEstablishment { phase = .establishingWorkspace - } else if effectiveLaunchMode == .create, + } else if attachmentLaunchMode == .create, let initialCommand, !initialCommand.isEmpty { phase = .establishingProfile(initialCommand: initialCommand) @@ -9605,7 +9611,7 @@ final class WorkspaceSceneModel: ObservableObject { let presentation = RetainedTmuxPresentation( selection: selection, handle: handle, - launchMode: effectiveLaunchMode, + launchMode: attachmentLaunchMode, reconnectContext: reconnectContext, reconnectSupervisor: SessionReconnectSupervisor( intervals: tmuxReconnectIntervals, @@ -9629,7 +9635,7 @@ final class WorkspaceSceneModel: ObservableObject { if activatesPresentation { activateTmuxPresentation(presentation) } - if effectiveLaunchMode == .create { + if attachmentLaunchMode == .create { transferPendingCreation( for: PendingTmuxSessionCreation( request: WorkspaceTmuxSessionCreationRequest( @@ -10030,6 +10036,10 @@ final class WorkspaceSceneModel: ObservableObject { activateTmuxPresentation(presentation) return } + if presentation.hiddenSizingReconnectPending { + guard presentation.sizingTransitionTask == nil else { return } + presentation.hiddenSizingReconnectPending = false + } // 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 @@ -10364,6 +10374,7 @@ final class WorkspaceSceneModel: ObservableObject { stageTmuxPresentationActivation(presentation) presentation.sizingIntent = .interactive + presentation.hiddenSizingReconnectPending = false let navigationRevision = userNavigationRevision presentation.pendingSizingActivationNavigationRevision = navigationRevision @@ -10563,6 +10574,15 @@ final class WorkspaceSceneModel: ObservableObject { if presentation.sizingTransitionID == transitionID { presentation.sizingTransitionID = nil presentation.sizingTransitionTask = nil + if presentation.hiddenSizingReconnectPending, + !nativeTmuxSessionCoordinator.isProvisioning( + presentation.handle + ), + !nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle + ) { + tmuxSurfaceBecameReady(presentation.handle) + } } } if let predecessor { @@ -10584,9 +10604,16 @@ final class WorkspaceSceneModel: ObservableObject { retainedTmuxPresentations[key] === presentation, presentation.sizingIntent == .hidden else { return } + if result == .stale, + presentation.hiddenSizingReconnectPending { + return + } } while result == .stale if case let .failure(failure) = result { + guard !presentation.hiddenSizingReconnectPending else { + return + } invalidateBorrowedTmuxSession(presentation.selection) AppLogger.shared.error( "tmux hidden sizing: " + failure.localizedDescription, @@ -11059,6 +11086,14 @@ final class WorkspaceSceneModel: ObservableObject { return } if case .disconnected = state { + if presentation.sizingIntent == .hidden, + presentation.sizingTransitionTask != nil, + presentation.reconnectContext?.handleID == handle.id, + presentation.reconnectContext?.host.isRemote == true, + case .some(.processExited) = nativeTmuxSessionCoordinator + .attachmentClosure(handle) { + presentation.hiddenSizingReconnectPending = true + } beginTmuxPreviewReconnect(presentation) } else if case .reconnecting = state { if previousState == .connected { @@ -12601,9 +12636,7 @@ final class WorkspaceSceneModel: ObservableObject { .contains(presentationKey) let reconnectsNonSizing = host.platform != .windows && (isAlwaysLiveManaged || presentation.sizingIntent == .hidden) - let previewGridSize = (tmuxSessionsByHost[selection.hostID] - ?? host.tmuxSessions).first { $0.name == selection.name }? - .previewClientSize + let previewGridSize = previewGridSize(for: selection) let handle = nativeTmuxSessionCoordinator.attach( hostID: selection.hostID, name: selection.name, diff --git a/Sources/Tmux/TmuxAttachmentInfo.swift b/Sources/Tmux/TmuxAttachmentInfo.swift index 0c0b3fd3..36d92734 100644 --- a/Sources/Tmux/TmuxAttachmentInfo.swift +++ b/Sources/Tmux/TmuxAttachmentInfo.swift @@ -128,6 +128,7 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ) } } else if launchMode != .attachOnly, + !ignoresClientSize, protectedWorkspacePath == nil, workspacePath != nil { command = remoteWorkspaceAttachCommand( @@ -180,7 +181,10 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { } switch launchMode { case .attach: - if let protectedWorkspacePath { + // Kwt does not expose tmux attach client flags. A non-sizing + // client must therefore use the direct attach below so + // ignore-size is present when tmux creates the client. + if !ignoresClientSize, let protectedWorkspacePath { guard let kwtPath, !kwtPath.isEmpty else { commands.append( "printf 'Ghosthub: bundled kwt is unavailable\\n' >&2" @@ -209,7 +213,7 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ) ) } else { - if let workspacePath { + if !ignoresClientSize, let workspacePath { guard let kwtPath, !kwtPath.isEmpty else { commands.append( "printf 'Ghosthub: bundled kwt is unavailable\\n' >&2" @@ -333,11 +337,6 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { presentationCommand.bestEffortCommand(tmuxPath: tmuxPath) ) } - if let clientSizeSetupCommand = clientSizeSetupCommand( - tmuxPath: tmuxPath - ) { - setupCommands.append(clientSizeSetupCommand) - } let sessionSetup = setupCommands.joined(separator: "; ") let listClientTTYs = tmuxArguments( tmuxPath, @@ -366,17 +365,6 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ].joined(separator: "; ") } - private func clientSizeSetupCommand(tmuxPath: String) -> String? { - guard ignoresClientSize else { return nil } - let beforeClientTTY = tmuxArguments( - tmuxPath, "refresh-client", "-t" - ).map(shellQuotedCommandArgument).joined(separator: " ") - let afterClientTTY = ["-f", "ignore-size"] - .map(shellQuotedCommandArgument) - .joined(separator: " ") - return beforeClientTTY + " \"$ghosthub_kwt_tty\" " + afterClientTTY - } - private func remoteAttachCommand( info: SSHHostInfo, tmuxPath: String, @@ -393,7 +381,9 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ) } let attach: String - if let protectedWorkspacePath, launchMode != .attachOnly { + if let protectedWorkspacePath, + launchMode != .attachOnly, + !ignoresClientSize { let protectedAttach: String if let remoteKwtCommandPrelude { let kwtAttach = remoteKwtCommandPrelude diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index d06b8c96..217d9feb 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -951,6 +951,213 @@ extension WorkspaceTmuxDiscoveryTests { 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("interrupted profile creation never replays its command automatically") func interruptedProfileCreationRequiresExplicitRetry() async throws { diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index d91282f6..bea34ead 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -1015,7 +1015,11 @@ extension WorkspaceWorktreeRemovalTests { await waitUntilMainActor { surfaces.requestedConfigurations.count == 3 } - #expect(surfaces.lastCommand?.contains("ignore-size") == true) + let restoredCommand = try #require(surfaces.lastCommand) + #expect(restoredCommand.contains("attach-session")) + #expect(restoredCommand.contains("ignore-size")) + #expect(!restoredCommand.contains("/test/kwt")) + #expect(!restoredCommand.contains("'open'")) await model.shutdown() } @@ -1146,7 +1150,7 @@ extension WorkspaceWorktreeRemovalTests { @MainActor @Test( - "a dirty rejection after session termination restores the killed session", + "dirty rejection preserves active and non-sizing hidden recovery", arguments: [true, false] ) func dirtyRejectionAfterSessionTerminationRestoresSession( @@ -1220,14 +1224,21 @@ extension WorkspaceWorktreeRemovalTests { let restoredCommand = try #require( surfaces.requestedConfigurations.last?.command ) - #expect(restoredCommand.contains("kwt")) - #expect(restoredCommand.contains("open")) + if wasOpened { + #expect(restoredCommand.contains("/test/kwt")) + #expect(restoredCommand.contains("open")) + } else { + #expect(restoredCommand.contains("attach-session")) + #expect(restoredCommand.contains("ignore-size")) + #expect(!restoredCommand.contains("/test/kwt")) + #expect(!restoredCommand.contains("'open'")) + } await model.shutdown() } @MainActor - @Test("failed removal preserves pending workspace establishment") - func failedRemovalPreservesPendingEstablishment() async throws { + @Test("failed removal restores pending workspace without a sizing client") + func failedRemovalRestoresPendingWorkspaceWithoutSizing() async throws { let environment = try setupRemoteEnvironment() var removable = try #require(environment.snapshot.worktrees.first) removable.generation = stableWorktreeGeneration @@ -1283,13 +1294,16 @@ extension WorkspaceWorktreeRemovalTests { surfaces.requestedConfigurations.count > initialRequestCount } - #expect(surfaces.lastCommand?.contains("'open'") == true) + let restoredCommand = try #require(surfaces.lastCommand) + #expect(restoredCommand.contains("attach-session")) + #expect(restoredCommand.contains("ignore-size")) + #expect(!restoredCommand.contains("'open'")) await model.shutdown() } @MainActor - @Test("failed removal preserves interrupted local establishment") - func failedRemovalPreservesInterruptedLocalEstablishment() async throws { + @Test("failed removal restores interrupted local workspace as non-sizing") + func failedRemovalRestoresInterruptedLocalAsNonSizing() async throws { let fixture = try removalFixture() let environment = fixture.environment let removable = fixture.removable @@ -1344,8 +1358,11 @@ extension WorkspaceWorktreeRemovalTests { surfaces.requestedConfigurations.count > initialRequestCount } - #expect(surfaces.lastCommand?.contains("kwt") == true) - #expect(surfaces.lastCommand?.contains("open") == true) + let restoredCommand = try #require(surfaces.lastCommand) + #expect(restoredCommand.contains("attach-session")) + #expect(restoredCommand.contains("ignore-size")) + #expect(!restoredCommand.contains("/test/kwt")) + #expect(!restoredCommand.contains("'open'")) await model.shutdown() } diff --git a/Tests/Tmux/TmuxAttachmentInfoTests.swift b/Tests/Tmux/TmuxAttachmentInfoTests.swift index cb25fd06..92f673d5 100644 --- a/Tests/Tmux/TmuxAttachmentInfoTests.swift +++ b/Tests/Tmux/TmuxAttachmentInfoTests.swift @@ -602,88 +602,22 @@ struct TmuxAttachmentInfoTests { #expect(tmuxCommands.contains("@2 window-active-style")) } - @Test(arguments: [true, false]) - func kwtAttachmentSetsIgnoreSizeOnItsDetectedClient( - ignoresClientSize: Bool - ) throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true - ) - defer { try? FileManager.default.removeItem(at: directory) } - let kwt = directory.appendingPathComponent("kwt") - let tmux = directory.appendingPathComponent("tmux") - let log = directory.appendingPathComponent("tmux.log") - let clientTTY = directory.appendingPathComponent("client.tty") - let refreshed = directory.appendingPathComponent("refreshed") - try """ - #!/bin/sh - exec "$GHOSTHUB_TMUX" attach-session - """.write(to: kwt, atomically: true, encoding: .utf8) - try """ - #!/bin/sh - case " $* " in - *" attach-session "*) - tty > "$GHOSTHUB_TMUX_CLIENT_TTY" - if [ "$GHOSTHUB_EXPECT_REFRESH" = 1 ]; then - ghosthub_attempts=0 - while [ ! -f "$GHOSTHUB_TMUX_REFRESHED" ]; do - ghosthub_attempts=$((ghosthub_attempts + 1)) - [ "$ghosthub_attempts" -ge 100 ] && break - sleep 0.01 - done - fi - ;; - *" list-clients "*) - [ -f "$GHOSTHUB_TMUX_CLIENT_TTY" ] && cat "$GHOSTHUB_TMUX_CLIENT_TTY" - ;; - *" refresh-client "*) - printf '%s\\n' "$*" >> "$GHOSTHUB_TMUX_LOG" - : > "$GHOSTHUB_TMUX_REFRESHED" - ;; - esac - """.write(to: tmux, atomically: true, encoding: .utf8) - for executable in [kwt, tmux] { - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], ofItemAtPath: executable.path - ) - } - + @Test("non-sizing worktree attachments bypass kwt") + func nonSizingWorktreeAttachmentUsesDirectAttach() { let command = TmuxAttachmentInfo( sessionName: "kwt-widget-feature", host: .local, workspacePath: "/worktrees/widget", - ignoresClientSize: ignoresClientSize - ).attachCommand(tmuxPath: tmux.path, kwtPath: kwt.path) - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/script") - process.arguments = ["-q", "/dev/null", "/bin/sh", "-c", command] - process.environment = ProcessInfo.processInfo.environment.merging([ - "GHOSTHUB_TMUX": tmux.path, - "GHOSTHUB_TMUX_CLIENT_TTY": clientTTY.path, - "GHOSTHUB_TMUX_LOG": log.path, - "GHOSTHUB_TMUX_REFRESHED": refreshed.path, - "GHOSTHUB_EXPECT_REFRESH": ignoresClientSize ? "1" : "0", - "TERM": "xterm-256color", - ]) { _, new in new } - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - try process.run() - process.waitUntilExit() + ignoresClientSize: true + ).attachCommand( + tmuxPath: "/usr/bin/tmux", + kwtPath: "/Applications/Ghosthub.app/Contents/Helpers/kwt" + ) - #expect(process.terminationStatus == 0) - let tmuxCommands = (try? String(contentsOf: log, encoding: .utf8)) ?? "" - let attachedTTY = try String( - contentsOf: clientTTY, encoding: .utf8 - ).trimmingCharacters(in: .whitespacesAndNewlines) - if ignoresClientSize { - #expect(tmuxCommands.contains( - "refresh-client -t \(attachedTTY) -f ignore-size" - )) - } else { - #expect(tmuxCommands.isEmpty) - } + #expect(command.contains("attach-session")) + #expect(command.contains("ignore-size")) + #expect(!command.contains("/Contents/Helpers/kwt")) + #expect(!command.contains("refresh-client")) } @Test("worktree attachment survives destroy-unattached") From 3eb9bd0475cb48447aee69f3a6e8f5f2aa621962 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 06:45:20 -0500 Subject: [PATCH 13/28] Leave hidden workspace establishment explicit Avoid starting a hidden client when failed-removal recovery still needs kwt to establish the workspace. Active recovery and later explicit opens remain interactive kwt operations, while inactive recovery attaches only to sessions that are already available. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 19 ++--- ...orkspaceWorktreeRemovalRecoveryTests.swift | 81 ++++++++++--------- 2 files changed, 49 insertions(+), 51 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 0144a9c2..20b2c288 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -2833,8 +2833,6 @@ final class WorkspaceSceneModel: ObservableObject { Set? var requiresWorkspaceReestablishment = false var terminatedSession = false - var killedSessionReestablishmentTarget: - WorkspaceTmuxSessionSelection? invalidateKwtInventoryRefresh() defer { ownsWorktreeMutation = false @@ -2846,15 +2844,6 @@ final class WorkspaceSceneModel: ObservableObject { requiresWorkspaceReestablishment: requiresWorkspaceReestablishment ) - if let killedSessionReestablishmentTarget { - _ = presentTmuxSession( - killedSessionReestablishmentTarget, - launchMode: .attach, - intent: .userInitiated, - activatesPresentation: false, - startsHidden: true - ) - } } let preflight: KwtHostInventory @@ -3025,8 +3014,6 @@ final class WorkspaceSceneModel: ObservableObject { removalHostEndpointMatches(request), !terminatedSession || killedRestorationTarget != nil, changes.hasUncommittedChanges { - killedSessionReestablishmentTarget = - killedRestorationTarget throw KwtWorktreeError.removalChangesChanged } throw removalError @@ -4835,6 +4822,12 @@ final class WorkspaceSceneModel: ObservableObject { 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 diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index bea34ead..74a41f80 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -928,8 +928,8 @@ 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 @@ -1006,20 +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("attach-session")) - #expect(restoredCommand.contains("ignore-size")) - #expect(!restoredCommand.contains("/test/kwt")) - #expect(!restoredCommand.contains("'open'")) + #expect(restoredCommand.contains("/test/kwt")) + #expect(restoredCommand.contains("open")) + #expect(!restoredCommand.contains("attach-session")) + #expect(removedHandle != model.retainedBorrowedTmuxHandle(for: selection)) await model.shutdown() } @@ -1077,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() } @@ -1150,7 +1151,7 @@ extension WorkspaceWorktreeRemovalTests { @MainActor @Test( - "dirty rejection preserves active and non-sizing hidden recovery", + "dirty rejection never establishes a hidden sizing client", arguments: [true, false] ) func dirtyRejectionAfterSessionTerminationRestoresSession( @@ -1211,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) @@ -1221,24 +1224,28 @@ extension WorkspaceWorktreeRemovalTests { model.activeBorrowedTmuxSelection == (wasOpened ? selection : nil) ) - let restoredCommand = try #require( - surfaces.requestedConfigurations.last?.command - ) if wasOpened { + let restoredCommand = try #require( + surfaces.requestedConfigurations.last?.command + ) #expect(restoredCommand.contains("/test/kwt")) #expect(restoredCommand.contains("open")) } else { - #expect(restoredCommand.contains("attach-session")) - #expect(restoredCommand.contains("ignore-size")) - #expect(!restoredCommand.contains("/test/kwt")) - #expect(!restoredCommand.contains("'open'")) + #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 restores pending workspace without a sizing client") - func failedRemovalRestoresPendingWorkspaceWithoutSizing() 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 @@ -1290,20 +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 } - - let restoredCommand = try #require(surfaces.lastCommand) - #expect(restoredCommand.contains("attach-session")) - #expect(restoredCommand.contains("ignore-size")) - #expect(!restoredCommand.contains("'open'")) + #expect(surfaces.lastCommand?.contains("'open'") == true) await model.shutdown() } @MainActor - @Test("failed removal restores interrupted local workspace as non-sizing") - func failedRemovalRestoresInterruptedLocalAsNonSizing() 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 @@ -1354,15 +1360,14 @@ 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 } - - let restoredCommand = try #require(surfaces.lastCommand) - #expect(restoredCommand.contains("attach-session")) - #expect(restoredCommand.contains("ignore-size")) - #expect(!restoredCommand.contains("/test/kwt")) - #expect(!restoredCommand.contains("'open'")) + #expect(surfaces.lastCommand?.contains("/test/kwt") == true) + #expect(surfaces.lastCommand?.contains("open") == true) await model.shutdown() } From 9263544ee6d37b4f9f1970f339e8fc11ce5a48d0 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 07:22:58 -0500 Subject: [PATCH 14/28] Preserve hidden worktree lifecycle User-initiated worktree opens must stay kwt-backed when the scene hides them before provisioning finishes. A direct attach cannot establish an absent workspace. Retained local worktree clients also need the reconnect handoff when a disconnect races hidden sizing. Shutdown must wait for close-triggered sizing cleanup so an attachment is not released after its owner returns. Generated with Codex Co-authored-by: Codex --- .../App/NativeTmuxSessionCoordinator.swift | 26 +++- Sources/App/WorkspaceSceneModel.swift | 44 ++++-- Sources/Tmux/TmuxAttachmentInfo.swift | 11 +- .../NativeTmuxSessionCoordinatorTests.swift | 81 +++++++++++ .../App/WorkspaceTmuxPresentationTests.swift | 63 +++++++++ Tests/App/WorkspaceTmuxRecoveryTests.swift | 128 ++++++++++++++++++ Tests/Tmux/TmuxAttachmentInfoTests.swift | 11 +- 7 files changed, 333 insertions(+), 31 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 29dd5e86..7102fd1a 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -192,6 +192,7 @@ final class NativeTmuxSessionCoordinator { private var sizingTransitionTasks: [UUID: [UUID: SizingTransitionTask]] = [:] private var sizingTransitionDrains: [UUID: Task] = [:] + private var sizingTransitionCleanupTasks: [UUID: Task] = [:] private var interactiveSizingTransitionHandles: Set = [] private var isShuttingDown = false @@ -428,13 +429,19 @@ final class NativeTmuxSessionCoordinator { let pendingSizing = pendingSizingByHandle.removeValue( forKey: handle.id ) + let protectedWorkspacePath = tmuxAttachMode == .protected + ? workingDirectory + : nil + let usesKwtWorkspaceAttach = launchMode == .attach + && (openWorkspace || protectedWorkspacePath != nil) let effectiveIgnoresClientSize: Bool let effectivePreviewGridSize: TmuxGridSize? switch pendingSizing { case .interactive: effectiveIgnoresClientSize = false effectivePreviewGridSize = nil - case let .preview(gridSize) where supportsClientSizing: + case let .preview(gridSize) + where supportsClientSizing && !usesKwtWorkspaceAttach: effectiveIgnoresClientSize = true effectivePreviewGridSize = gridSize case .preview: @@ -446,9 +453,6 @@ final class NativeTmuxSessionCoordinator { effectivePreviewGridSize = effectiveIgnoresClientSize ? previewGridSize : nil } - let protectedWorkspacePath = tmuxAttachMode == .protected - ? workingDirectory - : nil attachments[handle.id] = NativeTmuxAttachment( id: attachmentID, host: host, @@ -872,7 +876,8 @@ final class NativeTmuxSessionCoordinator { } sizingTransitionDrains[handleID] = drain let remoteExitStatusStore = remoteExitStatusStore - Task { [weak self] in + let cleanupID = UUID() + let cleanup = Task { [weak self] in await drain.value if self?.sizingTransitionDrains[handleID] == drain { self?.sizingTransitionDrains.removeValue(forKey: handleID) @@ -884,7 +889,9 @@ final class NativeTmuxSessionCoordinator { remoteExitStatusStore.remove(attachment?.remoteExitStatusURL) } try? await attachment?.sshConnection?.release() + self?.sizingTransitionCleanupTasks.removeValue(forKey: cleanupID) } + sizingTransitionCleanupTasks[cleanupID] = cleanup } private func applyPreviewGridSize( @@ -1545,7 +1552,11 @@ final class NativeTmuxSessionCoordinator { let handles = Array(handlesByKey.values) let connections = attachments.values.compactMap(\.sshConnection) let sizingTransitions = sizingTransitionTasks.values.flatMap(\.values) + let sizingDrains = Array(sizingTransitionDrains.values) + let sizingCleanups = Array(sizingTransitionCleanupTasks.values) sizingTransitions.forEach { $0.cancel() } + sizingDrains.forEach { $0.cancel() } + sizingCleanups.forEach { $0.cancel() } sizingTransitionTasks.removeAll() sizingTransitionTails.removeAll() provisioningTasks.values.forEach { $0.cancel() } @@ -1575,6 +1586,11 @@ final class NativeTmuxSessionCoordinator { for transition in sizingTransitions { _ = await transition.value } + for cleanup in sizingCleanups { + await cleanup.value + } + sizingTransitionDrains.removeAll() + sizingTransitionCleanupTasks.removeAll() for handle in handles { terminalCoordinator.removeSurface(for: surfaceKey(handle)) } diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 20b2c288..d8bbc799 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -531,6 +531,7 @@ final class WorkspaceSceneModel: ObservableObject { var sizingTransitionTask: Task? var pendingSizingActivationNavigationRevision: UInt64? var hiddenSizingReconnectPending = false + var hiddenSizingProvisioningPending = false var previewPromotionIsPending: Bool { previewPromotionTask != nil @@ -10033,6 +10034,17 @@ final class WorkspaceSceneModel: ObservableObject { guard presentation.sizingTransitionTask == nil else { return } presentation.hiddenSizingReconnectPending = false } + if presentation.hiddenSizingProvisioningPending { + guard presentation.sizingTransitionTask == nil else { return } + presentation.hiddenSizingProvisioningPending = false + finishTmuxSurfaceReadiness(handle) + guard retainedTmuxPresentations[key] === presentation, + presentation.sizingIntent == .hidden, + nativeTmuxSessionCoordinator.hasLaunched(handle) + else { return } + 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 @@ -10368,6 +10380,7 @@ final class WorkspaceSceneModel: ObservableObject { stageTmuxPresentationActivation(presentation) presentation.sizingIntent = .interactive presentation.hiddenSizingReconnectPending = false + presentation.hiddenSizingProvisioningPending = false let navigationRevision = userNavigationRevision presentation.pendingSizingActivationNavigationRevision = navigationRevision @@ -10567,13 +10580,14 @@ final class WorkspaceSceneModel: ObservableObject { if presentation.sizingTransitionID == transitionID { presentation.sizingTransitionID = nil presentation.sizingTransitionTask = nil - if presentation.hiddenSizingReconnectPending, - !nativeTmuxSessionCoordinator.isProvisioning( - presentation.handle - ), - !nativeTmuxSessionCoordinator.hasClosedAttachment( - presentation.handle - ) { + if presentation.hiddenSizingReconnectPending + || presentation.hiddenSizingProvisioningPending, + !nativeTmuxSessionCoordinator.isProvisioning( + presentation.handle + ), + !nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle + ) { tmuxSurfaceBecameReady(presentation.handle) } } @@ -10603,7 +10617,11 @@ final class WorkspaceSceneModel: ObservableObject { } } while result == .stale - if case let .failure(failure) = result { + if result == .pending, + presentation.reconnectContext?.phase + == .establishingWorkspace { + presentation.hiddenSizingProvisioningPending = true + } else if case let .failure(failure) = result { guard !presentation.hiddenSizingReconnectPending else { return } @@ -11082,7 +11100,6 @@ final class WorkspaceSceneModel: ObservableObject { if presentation.sizingIntent == .hidden, presentation.sizingTransitionTask != nil, presentation.reconnectContext?.handleID == handle.id, - presentation.reconnectContext?.host.isRemote == true, case .some(.processExited) = nativeTmuxSessionCoordinator .attachmentClosure(handle) { presentation.hiddenSizingReconnectPending = true @@ -11165,12 +11182,13 @@ final class WorkspaceSceneModel: ObservableObject { 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 ) @@ -12497,7 +12515,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 } @@ -12522,7 +12540,7 @@ final class WorkspaceSceneModel: ObservableObject { else { stopTmuxReconnectWithUnableToAttach( presentation, - "The remote workspace could not be established." + "The workspace could not be established." ) return .stop } diff --git a/Sources/Tmux/TmuxAttachmentInfo.swift b/Sources/Tmux/TmuxAttachmentInfo.swift index 36d92734..4237eeb3 100644 --- a/Sources/Tmux/TmuxAttachmentInfo.swift +++ b/Sources/Tmux/TmuxAttachmentInfo.swift @@ -128,7 +128,6 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ) } } else if launchMode != .attachOnly, - !ignoresClientSize, protectedWorkspacePath == nil, workspacePath != nil { command = remoteWorkspaceAttachCommand( @@ -181,10 +180,7 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { } switch launchMode { case .attach: - // Kwt does not expose tmux attach client flags. A non-sizing - // client must therefore use the direct attach below so - // ignore-size is present when tmux creates the client. - if !ignoresClientSize, let protectedWorkspacePath { + if let protectedWorkspacePath { guard let kwtPath, !kwtPath.isEmpty else { commands.append( "printf 'Ghosthub: bundled kwt is unavailable\\n' >&2" @@ -213,7 +209,7 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { ) ) } else { - if !ignoresClientSize, let workspacePath { + if let workspacePath { guard let kwtPath, !kwtPath.isEmpty else { commands.append( "printf 'Ghosthub: bundled kwt is unavailable\\n' >&2" @@ -382,8 +378,7 @@ public struct TmuxAttachmentInfo: Equatable, Sendable { } let attach: String if let protectedWorkspacePath, - launchMode != .attachOnly, - !ignoresClientSize { + launchMode != .attachOnly { let protectedAttach: String if let remoteKwtCommandPrelude { let kwtAttach = remoteKwtCommandPrelude diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index 283bf95c..71f5279e 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -1875,6 +1875,87 @@ struct NativeTmuxSessionCoordinatorTests { _ = await sizing.value } + @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() diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 2e9bb320..d8529d2f 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -362,6 +362,69 @@ extension WorkspaceTmuxDiscoveryTests { 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) + 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'")) + await waitUntilMainActor(timeout: .seconds(1)) { + hiddenSizingMutations.load() == 1 + } + + #expect(model.activeBorrowedTmuxSelection == nil) + #expect(model.retainedBorrowedTmuxHandle(for: selection) != nil) + await model.shutdown() + } + @MainActor @Test("a failed hidden sizing transition detaches the client") func failedHiddenSizingTransitionDetachesClient() async throws { diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index 217d9feb..95c925ac 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -1158,6 +1158,134 @@ extension WorkspaceTmuxDiscoveryTests { 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 { diff --git a/Tests/Tmux/TmuxAttachmentInfoTests.swift b/Tests/Tmux/TmuxAttachmentInfoTests.swift index 92f673d5..13df8b92 100644 --- a/Tests/Tmux/TmuxAttachmentInfoTests.swift +++ b/Tests/Tmux/TmuxAttachmentInfoTests.swift @@ -602,8 +602,8 @@ struct TmuxAttachmentInfoTests { #expect(tmuxCommands.contains("@2 window-active-style")) } - @Test("non-sizing worktree attachments bypass kwt") - func nonSizingWorktreeAttachmentUsesDirectAttach() { + @Test("non-sizing workspace attach still establishes through kwt") + func nonSizingWorkspaceAttachUsesKwt() { let command = TmuxAttachmentInfo( sessionName: "kwt-widget-feature", host: .local, @@ -614,9 +614,10 @@ struct TmuxAttachmentInfoTests { kwtPath: "/Applications/Ghosthub.app/Contents/Helpers/kwt" ) - #expect(command.contains("attach-session")) - #expect(command.contains("ignore-size")) - #expect(!command.contains("/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")) } From d802f2d610e963976c1a4a48829f1da9a486c4e2 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 07:55:22 -0500 Subject: [PATCH 15/28] Restore hidden sizing after kwt workspace setup Kwt creates the tmux client while it establishes a missing workspace. The client cannot receive ignore-size until it exists, so a hidden reconnect must not report it as non-sizing before that transition completes. Keep the initial kwt attach free of sizing state. Retain the hidden sizing request until the terminal surface has launched, then update the exact client. This preserves kwt workspace recovery without letting the retained client control the shared window size. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 34 ++++++++++++------- .../App/WorkspaceTmuxPresentationTests.swift | 1 + 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index d8bbc799..84e497ca 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -10034,14 +10034,18 @@ final class WorkspaceSceneModel: ObservableObject { 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 - finishTmuxSurfaceReadiness(handle) - guard retainedTmuxPresentations[key] === presentation, - presentation.sizingIntent == .hidden, - nativeTmuxSessionCoordinator.hasLaunched(handle) - else { return } hideTmuxPresentationSizing(presentation) return } @@ -10068,11 +10072,6 @@ final class WorkspaceSceneModel: ObservableObject { excludeAlwaysLiveTmuxPresentation(presentation, key: key) return } - if presentation.sizingIntent == .hidden, - !nativeTmuxSessionCoordinator.supportsClientSizing(handle) { - invalidateBorrowedTmuxSession(presentation.selection) - return - } if alwaysLiveManagedTmuxPresentationKeys.contains(key), activeBorrowedTmuxHandle != handle, !nativeTmuxSessionCoordinator.hasLaunched(handle) { @@ -12647,6 +12646,17 @@ final class WorkspaceSceneModel: ObservableObject { .contains(presentationKey) 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 defersHiddenSizingForWorkspaceEstablishment = reconnectsNonSizing + && openWorkspace + if defersHiddenSizingForWorkspaceEstablishment { + presentation.hiddenSizingProvisioningPending = true + } + let startsNonSizing = reconnectsNonSizing + && !defersHiddenSizingForWorkspaceEstablishment let previewGridSize = previewGridSize(for: selection) let handle = nativeTmuxSessionCoordinator.attach( hostID: selection.hostID, @@ -12660,8 +12670,8 @@ final class WorkspaceSceneModel: ObservableObject { openWorkspace: openWorkspace, sessionIdentity: presentation.reconnectExpectedIdentity, expectedRouteIdentity: routeIdentity, - ignoresClientSize: reconnectsNonSizing, - previewGridSize: reconnectsNonSizing ? previewGridSize : nil + ignoresClientSize: startsNonSizing, + previewGridSize: startsNonSizing ? previewGridSize : nil ) if handle.id != previousHandle.id { retainedTmuxPresentationKeysByHandle.removeValue( diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index d8529d2f..b8802328 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -416,6 +416,7 @@ extension WorkspaceTmuxDiscoveryTests { 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 } From 9e2ae0ae5b28d608d77a982af6ada502a22c801c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 08:21:53 -0500 Subject: [PATCH 16/28] Cover every hidden kwt establishment path Treat direct and protected workspace establishment as pending hidden sizing, and replace stale pending state on each reconnect attempt. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 84e497ca..5a6f5af8 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -12650,11 +12650,13 @@ final class WorkspaceSceneModel: ObservableObject { // 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 defersHiddenSizingForWorkspaceEstablishment = reconnectsNonSizing - && openWorkspace - if defersHiddenSizingForWorkspaceEstablishment { - presentation.hiddenSizingProvisioningPending = true - } + let usesKwtWorkspaceEstablishment = launchMode == .attach + && (openWorkspace || protectedSessionNeedsEstablishment) + let defersHiddenSizingForWorkspaceEstablishment = + presentation.sizingIntent == .hidden + && usesKwtWorkspaceEstablishment + presentation.hiddenSizingProvisioningPending = + defersHiddenSizingForWorkspaceEstablishment let startsNonSizing = reconnectsNonSizing && !defersHiddenSizingForWorkspaceEstablishment let previewGridSize = previewGridSize(for: selection) From 6cc047163370c76b003adeefd7f49dd9612650e9 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 08:29:16 -0500 Subject: [PATCH 17/28] Detach hidden kwt clients when identity is unavailable Do not retain an interactive kwt client when its exact tmux identity cannot be resolved for the pending hidden-sizing transition. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 6 +++ .../App/WorkspaceTmuxPresentationTests.swift | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 5a6f5af8..c94d2af5 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -9927,6 +9927,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 diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index b8802328..99ee4ee3 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -426,6 +426,51 @@ extension WorkspaceTmuxDiscoveryTests { 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) + 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 { From ff796a770266b9dfa46e76a6f8314890dc8f010b Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 08:44:18 -0500 Subject: [PATCH 18/28] Prearm hidden tmux identity resolution --- Sources/App/NativeTmuxSessionCoordinator.swift | 3 ++- Sources/App/WorkspaceSceneModel.swift | 13 +++++++++++++ Tests/App/WorkspaceTmuxPresentationTests.swift | 3 +++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 7102fd1a..96159e85 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -1433,10 +1433,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) else { return } - previewIdentityRetryHandles.insert(handle.id) startPaneSplitClientBinding( target: paneSplitTarget( handle: handle, diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index c94d2af5..35fc3a3b 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -10626,6 +10626,9 @@ final class WorkspaceSceneModel: ObservableObject { presentation.reconnectContext?.phase == .establishingWorkspace { presentation.hiddenSizingProvisioningPending = true + nativeTmuxSessionCoordinator.requestAttachedSessionIdentity( + presentation.handle + ) } else if case let .failure(failure) = result { guard !presentation.hiddenSizingReconnectPending else { return @@ -10672,6 +10675,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 @@ -12681,6 +12691,9 @@ final class WorkspaceSceneModel: ObservableObject { ignoresClientSize: startsNonSizing, previewGridSize: startsNonSizing ? previewGridSize : nil ) + if defersHiddenSizingForWorkspaceEstablishment { + nativeTmuxSessionCoordinator.requestAttachedSessionIdentity(handle) + } if handle.id != previousHandle.id { retainedTmuxPresentationKeysByHandle.removeValue( forKey: previousHandle.id diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 99ee4ee3..1fc9aecc 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -461,6 +461,9 @@ extension WorkspaceTmuxDiscoveryTests { model.openBorrowedTmuxSession(selection) await waitUntilMainActor { resolutionStarted.load() } model.hideBorrowedTmuxSession(selection) + await waitUntilMainActor { + model.retainedBorrowedTmuxSessionHasPendingHiddenSizing(selection) + } releaseResolution.signal() await waitUntilMainActor(timeout: .seconds(3)) { From a6c4fd36dc04c49b4f3da279d9d94f5381e8b6d1 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 11:15:22 -0500 Subject: [PATCH 19/28] Serialize tmux replacement cleanup by endpoint A replacement attachment can receive a new handle for the same tmux endpoint. Handle-scoped drains let that client acquire an SSH connection before the old sizing command and connection release finished. Keep cleanup ordering on the stable endpoint identity so replacement clients start only after the prior attachment is fully released. Generated with Codex Co-authored-by: Codex --- .../App/NativeTmuxSessionCoordinator.swift | 42 ++++---- .../NativeTmuxSessionCoordinatorTests.swift | 98 +++++++++++++++++++ 2 files changed, 123 insertions(+), 17 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 96159e85..f168939b 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -147,6 +147,11 @@ final class NativeTmuxSessionCoordinator { var task: Task } + private struct AttachmentCleanupTail { + var id: UUID + var task: Task + } + private typealias SizingTransitionTask = Task @@ -191,7 +196,8 @@ final class NativeTmuxSessionCoordinator { private var sizingTransitionTails: [UUID: SizingTransitionTask] = [:] private var sizingTransitionTasks: [UUID: [UUID: SizingTransitionTask]] = [:] - private var sizingTransitionDrains: [UUID: Task] = [:] + private var attachmentCleanupTails: + [NativeTmuxSessionKey: AttachmentCleanupTail] = [:] private var sizingTransitionCleanupTasks: [UUID: Task] = [:] private var interactiveSizingTransitionHandles: Set = [] private var isShuttingDown = false @@ -320,10 +326,10 @@ final class NativeTmuxSessionCoordinator { let tmuxPathProvider = tmuxPathProvider let remoteTmuxPathProvider = remoteTmuxPathProvider let remoteConnectionProvider = remoteConnectionProvider - let sizingTransitionDrain = sizingTransitionDrains[handle.id] + let attachmentCleanup = attachmentCleanupTails[key]?.task provisioningTasks[handle.id] = Task { [weak self] in do { - await sizingTransitionDrain?.value + await attachmentCleanup?.value try Task.checkCancellation() let sshConnection: KwtSSHConnection? let sshConnectionSnapshot: SSHConnectionArgumentsSnapshot @@ -574,6 +580,7 @@ final class NativeTmuxSessionCoordinator { let attachment = attachments.removeValue(forKey: handle.id) cancelSizingTransitionsAndRelease( handleID: handle.id, + key: key, attachment: attachment, removesRemoteExitStatus: true ) @@ -862,25 +869,19 @@ final class NativeTmuxSessionCoordinator { private func cancelSizingTransitionsAndRelease( handleID: UUID, + key: NativeTmuxSessionKey, attachment: NativeTmuxAttachment?, invalidatesConnection: Bool = false, removesRemoteExitStatus: Bool = false ) { let sizingTransitions = cancelSizingTransitions(handleID: handleID) - let predecessor = sizingTransitionDrains[handleID] - let drain = Task { - await predecessor?.value - for transition in sizingTransitions { - _ = await transition.value - } - } - sizingTransitionDrains[handleID] = drain + let predecessor = attachmentCleanupTails[key]?.task let remoteExitStatusStore = remoteExitStatusStore let cleanupID = UUID() let cleanup = Task { [weak self] in - await drain.value - if self?.sizingTransitionDrains[handleID] == drain { - self?.sizingTransitionDrains.removeValue(forKey: handleID) + await predecessor?.value + for transition in sizingTransitions { + _ = await transition.value } if invalidatesConnection { await attachment?.sshConnection?.invalidate() @@ -889,8 +890,15 @@ final class NativeTmuxSessionCoordinator { 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 } @@ -1383,6 +1391,7 @@ final class NativeTmuxSessionCoordinator { let attachment = attachments.removeValue(forKey: handle.id) cancelSizingTransitionsAndRelease( handleID: handle.id, + key: sessionKey(handle), attachment: attachment, removesRemoteExitStatus: true ) @@ -1534,6 +1543,7 @@ final class NativeTmuxSessionCoordinator { ) cancelSizingTransitionsAndRelease( handleID: handle.id, + key: key, attachment: attachment, invalidatesConnection: connectionUnusable ) @@ -1553,10 +1563,8 @@ final class NativeTmuxSessionCoordinator { let handles = Array(handlesByKey.values) let connections = attachments.values.compactMap(\.sshConnection) let sizingTransitions = sizingTransitionTasks.values.flatMap(\.values) - let sizingDrains = Array(sizingTransitionDrains.values) let sizingCleanups = Array(sizingTransitionCleanupTasks.values) sizingTransitions.forEach { $0.cancel() } - sizingDrains.forEach { $0.cancel() } sizingCleanups.forEach { $0.cancel() } sizingTransitionTasks.removeAll() sizingTransitionTails.removeAll() @@ -1590,7 +1598,7 @@ final class NativeTmuxSessionCoordinator { for cleanup in sizingCleanups { await cleanup.value } - sizingTransitionDrains.removeAll() + attachmentCleanupTails.removeAll() sizingTransitionCleanupTasks.removeAll() for handle in handles { terminalCoordinator.removeSurface(for: surfaceKey(handle)) diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index 71f5279e..9420d276 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -1875,6 +1875,104 @@ struct NativeTmuxSessionCoordinatorTests { _ = 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]>([]) From b71dba2816f77d16f2958ed61e57dd4dbb6bd935 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 13:47:48 -0500 Subject: [PATCH 20/28] Fence tmux attachment generations Replacement tmux attachments could inherit launch readiness from a closed client. Stale provisioning could also release its SSH lease after replacement provisioning acquired a new connection. Bind launch readiness to the attachment generation, preserve the closed generation outcome for recovery, and retain provisioning in endpoint cleanup through its final release. Generated with Codex Co-authored-by: Codex --- .../App/NativeTmuxSessionCoordinator.swift | 78 +++++++++++++------ Sources/App/WorkspaceSceneModel.swift | 7 +- .../NativeTmuxSessionCoordinatorTests.swift | 73 +++++++++++++++++ 3 files changed, 132 insertions(+), 26 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index f168939b..e0bf42dd 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -179,7 +179,8 @@ final class NativeTmuxSessionCoordinator { private var targetHostsByHandle: [UUID: CommandHost] = [:] private var attachments: [UUID: NativeTmuxAttachment] = [:] private var attachmentClosures: [UUID: BorrowedTmuxAttachmentClosure] = [:] - private var launchedHandles: Set = [] + private var launchedAttachmentIDs: [UUID: UUID] = [:] + private var closedLaunchedHandles: Set = [] private var reportedConnectedAttachmentIDs: [UUID: UUID] = [:] private let tmuxResolutionCache = NativeTmuxResolutionCache() private var provisioningHandles: Set = [] @@ -314,6 +315,7 @@ final class NativeTmuxSessionCoordinator { handlesByKey[key] = handle targetHostsByHandle[handle.id] = host attachmentClosures.removeValue(forKey: handle.id) + closedLaunchedHandles.remove(handle.id) guard !isShuttingDown, attachments[handle.id] == nil, @@ -374,7 +376,7 @@ final class NativeTmuxSessionCoordinator { } onCancel: { probe.cancel() } - self?.finishAttach( + await self?.finishAttach( handle: handle, host: host, socketName: socketName, @@ -414,14 +416,17 @@ 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 { @@ -498,13 +503,14 @@ 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 attachmentClosures[handle.id] = switch error { case let .sshConnectionFailed(_, classification) where classification.kind == .transport: @@ -573,7 +579,10 @@ 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) @@ -582,10 +591,12 @@ final class NativeTmuxSessionCoordinator { handleID: handle.id, key: key, attachment: attachment, + provisioningTask: provisioningTask, removesRemoteExitStatus: true ) attachmentClosures.removeValue(forKey: handle.id) - launchedHandles.remove(handle.id) + launchedAttachmentIDs.removeValue(forKey: handle.id) + closedLaunchedHandles.remove(handle.id) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) deferredPresentationStyleHandles.remove(handle.id) pendingSizingByHandle.removeValue(forKey: handle.id) @@ -593,7 +604,14 @@ final class NativeTmuxSessionCoordinator { } 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 { @@ -685,7 +703,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 @@ -780,7 +798,7 @@ final class NativeTmuxSessionCoordinator { applyPreviewGridSize(gridSize, for: handle) return .applied } - guard launchedHandles.contains(handle.id) else { + guard launchedAttachmentIDs[handle.id] == attachment.id else { attachment.ignoresClientSize = true attachment.previewGridSize = gridSize attachments[handle.id] = attachment @@ -871,6 +889,7 @@ final class NativeTmuxSessionCoordinator { handleID: UUID, key: NativeTmuxSessionKey, attachment: NativeTmuxAttachment?, + provisioningTask: Task? = nil, invalidatesConnection: Bool = false, removesRemoteExitStatus: Bool = false ) { @@ -880,6 +899,7 @@ final class NativeTmuxSessionCoordinator { let cleanupID = UUID() let cleanup = Task { [weak self] in await predecessor?.value + await provisioningTask?.value for transition in sizingTransitions { _ = await transition.value } @@ -927,7 +947,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) @@ -1034,7 +1054,7 @@ final class NativeTmuxSessionCoordinator { ) } ) - launchedHandles.insert(handle.id) + launchedAttachmentIDs[handle.id] = attachment.id startPaneSplitClientBinding( target: splitTarget, handle: handle, @@ -1133,7 +1153,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( @@ -1244,7 +1264,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 } @@ -1389,6 +1409,8 @@ final class NativeTmuxSessionCoordinator { cancelPaneSplits(handleID: handle.id) attachmentClosures[handle.id] = closure let attachment = attachments.removeValue(forKey: handle.id) + launchedAttachmentIDs.removeValue(forKey: handle.id) + closedLaunchedHandles.remove(handle.id) cancelSizingTransitionsAndRelease( handleID: handle.id, key: sessionKey(handle), @@ -1420,7 +1442,7 @@ final class NativeTmuxSessionCoordinator { _ handle: BorrowedTmuxSessionHandle ) -> TmuxSessionIdentity? { guard attachments[handle.id] != nil, - launchedHandles.contains(handle.id) + hasLaunched(handle) else { return nil } return paneSplitClients[handle.id]?.sessionIdentity } @@ -1429,7 +1451,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) @@ -1445,7 +1467,7 @@ final class NativeTmuxSessionCoordinator { 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 } startPaneSplitClientBinding( target: paneSplitTarget( @@ -1463,7 +1485,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 @@ -1477,7 +1499,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 @@ -1507,7 +1529,8 @@ 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 } } @@ -1523,6 +1546,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 @@ -1588,7 +1619,8 @@ final class NativeTmuxSessionCoordinator { } attachments.removeAll() attachmentClosures.removeAll() - launchedHandles.removeAll() + launchedAttachmentIDs.removeAll() + closedLaunchedHandles.removeAll() reportedConnectedAttachmentIDs.removeAll() deferredPresentationStyleHandles.removeAll() pendingSizingByHandle.removeAll() diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 35fc3a3b..2a46db82 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -11099,7 +11099,7 @@ final class WorkspaceSceneModel: ObservableObject { let key = TmuxPresentationKey(presentation.selection) if case .disconnected = state, alwaysLiveManagedTmuxPresentationKeys.contains(key), - !nativeTmuxSessionCoordinator.hasLaunched(handle), + !nativeTmuxSessionCoordinator.closedAttachmentHadLaunched(handle), nativeTmuxSessionCoordinator.attachmentClosure(handle) != .surfaceUnavailable, nativeTmuxSessionCoordinator.attachmentClosure(handle) @@ -11193,7 +11193,7 @@ 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, @@ -11222,7 +11222,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 diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index 9420d276..31119a1a 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -392,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", @@ -1359,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( From ac0e198379c75ce4dba9bcdc16179c6e1037bcdb Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 14:51:48 -0500 Subject: [PATCH 21/28] Keep protected and created tmux clients consistent after close A hidden restoration after a failed worktree removal attached protected sessions directly instead of through kwt. When the worktree record has no tmux socket, that direct attach targeted the default server, which never owns a protected session. Skip hidden restoration for a socketless protected worktree and leave it to an explicit open, which still goes through kwt. Discovery could confirm a socketless protected establishment without releasing the protected mutation scope, so worktree removal and creation in that project stayed blocked until the client closed. Release the scope on that path as the probe path already does. Launch readiness now belongs to the attachment generation, so a created session whose client already closed no longer reports as launched. The non-explicit close path then dropped the created session instead of reconciling it against the server. Treat a closed launched attachment the same as a live one when deciding whether to reconcile. Generated with Claude Code Co-authored-by: Claude Fable 5.1 --- Sources/App/WorkspaceSceneModel.swift | 15 +- Tests/App/WorkspaceTmuxRecoveryTests.swift | 91 ++++++++ ...orkspaceWorktreeRemovalRecoveryTests.swift | 203 ++++++++++++++++++ 3 files changed, 308 insertions(+), 1 deletion(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 2a46db82..d93fa43e 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -5630,6 +5630,9 @@ final class WorkspaceSceneModel: ObservableObject { presentation.reconnectContext = context presentation.establishmentConfirmationTask?.cancel() presentation.establishmentConfirmationTask = nil + releaseProtectedTmuxAttachmentScope( + handleID: presentation.handle.id + ) } if publish { applyRuntimeInventoryOverlayIfNeeded(hostID: hostID) @@ -9537,6 +9540,14 @@ final class WorkspaceSceneModel: ObservableObject { return nil } let startsHiddenOnPOSIX = startsHidden && host.platform != .windows + // 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 startsHiddenOnPOSIX, + 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. @@ -10838,7 +10849,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) diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index 95c925ac..c3fd9f9d 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -2990,4 +2990,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/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index 74a41f80..95708c5e 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -1571,4 +1571,207 @@ extension WorkspaceWorktreeRemovalTests { 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 + ) + + 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() + } + + 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, + tmuxExactSessionProbe: @escaping WorkspaceSceneModel.TmuxSessionExactProbe, + tmuxSessionDiscovery: @escaping WorkspaceSceneModel.TmuxSessionDiscovery + ) throws -> WorkspaceSceneModel { + var snapshot = environment.snapshot + 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 + ) 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 == 2) + } + } From f3b4a941b2dc047235b4d38f5ebdda305f33f359 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 17:50:43 -0500 Subject: [PATCH 22/28] Hide kwt-launched tmux clients only after they launch A hidden retained tmux client must not take part in window sizing. For a direct attach Ghosthub passes ignore-size on the attach itself. Kwt starts the tmux client for workspace open and protected attach, so that flag cannot be passed and Ghosthub must instead mark the exact client after it launches. Two paths could still leave a kwt client as a sizing client: - A kwt attachment that was provisioned but not yet launched, for example while a protected attach waited on a worktree mutation scope, accepted the local flag and reported the hide as applied. Kwt never honors that flag, so the client launched visible. Such a hide is now reported as pending and finishes after launch. - The scene recorded a pending hide only while the reconnect phase was still establishing the workspace. Tmux discovery can advance that phase during provisioning, which dropped the marker. The marker now keys off whether the attachment launches through kwt, which is fixed for the attachment's lifetime. The scene tests for these paths yield after the hide so that the request is recorded before provisioning resumes; without that the outcome depended on main-actor scheduling. Generated with Claude Code (claude-fable-5-1) Co-authored-by: Claude Fable 5.1 --- .../App/NativeTmuxSessionCoordinator.swift | 30 ++++++- Sources/App/WorkspaceSceneModel.swift | 11 ++- .../NativeTmuxSessionCoordinatorTests.swift | 37 ++++++++ .../App/WorkspaceTmuxPresentationTests.swift | 88 +++++++++++++++++++ 4 files changed, 162 insertions(+), 4 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index e0bf42dd..dd33c079 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -100,6 +100,25 @@ private struct NativeTmuxAttachment { 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 { @@ -443,8 +462,12 @@ final class NativeTmuxSessionCoordinator { let protectedWorkspacePath = tmuxAttachMode == .protected ? workingDirectory : nil - let usesKwtWorkspaceAttach = launchMode == .attach - && (openWorkspace || protectedWorkspacePath != nil) + let usesKwtWorkspaceAttach = NativeTmuxAttachment + .usesKwtWorkspaceAttach( + launchMode: launchMode, + openWorkspace: openWorkspace, + protectedWorkspacePath: protectedWorkspacePath + ) let effectiveIgnoresClientSize: Bool let effectivePreviewGridSize: TmuxGridSize? switch pendingSizing { @@ -799,6 +822,9 @@ final class NativeTmuxSessionCoordinator { return .applied } 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 diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index d93fa43e..762ca9b3 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -532,6 +532,11 @@ final class WorkspaceSceneModel: ObservableObject { 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 @@ -9625,6 +9630,8 @@ final class WorkspaceSceneModel: ObservableObject { verifiedPreviewIdentity: nil ) presentation.sizingIntent = startsHiddenOnPOSIX ? .hidden : .interactive + presentation.launchesThroughKwtWorkspace = + openWorkspace || protectedSessionNeedsEstablishment presentation.reconnectExpectedIdentity = discoveredIdentity objectWillChange.send() retainedTmuxPresentations[key] = presentation @@ -10634,8 +10641,7 @@ final class WorkspaceSceneModel: ObservableObject { } while result == .stale if result == .pending, - presentation.reconnectContext?.phase - == .establishingWorkspace { + presentation.launchesThroughKwtWorkspace { presentation.hiddenSizingProvisioningPending = true nativeTmuxSessionCoordinator.requestAttachedSessionIdentity( presentation.handle @@ -12687,6 +12693,7 @@ final class WorkspaceSceneModel: ObservableObject { && usesKwtWorkspaceEstablishment presentation.hiddenSizingProvisioningPending = defersHiddenSizingForWorkspaceEstablishment + presentation.launchesThroughKwtWorkspace = usesKwtWorkspaceEstablishment let startsNonSizing = reconnectsNonSizing && !defersHiddenSizingForWorkspaceEstablishment let previewGridSize = previewGridSize(for: selection) diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index 31119a1a..f4ca74a1 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -2533,6 +2533,43 @@ struct NativeTmuxSessionCoordinatorTests { #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() diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 1fc9aecc..9c435614 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -409,6 +409,94 @@ extension WorkspaceTmuxDiscoveryTests { 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 From b9466b3a2cf1154e29b9c5b4e58e85d60a158037 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 18:50:14 -0500 Subject: [PATCH 23/28] Fence the deferred report of a failed tmux surface launch When a tmux surface cannot be created, the coordinator tears the attachment down and reports the disconnect on the next main-actor turn. The scene may re-attach the same session before that turn runs, for example when an Always Live reconcile or a queued presentation relaunches a closed attachment. The handle is reused, so the late report was delivered to the replacement while it was still provisioning. The scene treats a disconnect as authoritative: it releases the protected worktree scope, and in Always Live mode it excludes the session because the replacement has no closure that marks the failure as retryable. The failed generation now carries its own identifier, and the report is dropped if a newer attach has claimed the handle. Generated with Claude Code (claude-fable-5-1) Co-authored-by: Claude Fable 5.1 --- .../App/NativeTmuxSessionCoordinator.swift | 20 ++++++++- .../NativeTmuxSessionCoordinatorTests.swift | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index dd33c079..5acea47a 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -198,6 +198,9 @@ final class NativeTmuxSessionCoordinator { private var targetHostsByHandle: [UUID: CommandHost] = [:] private var attachments: [UUID: NativeTmuxAttachment] = [:] private var attachmentClosures: [UUID: BorrowedTmuxAttachmentClosure] = [:] + /// 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] = [:] @@ -334,6 +337,7 @@ 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, @@ -618,6 +622,7 @@ final class NativeTmuxSessionCoordinator { removesRemoteExitStatus: true ) attachmentClosures.removeValue(forKey: handle.id) + surfaceLaunchFailureIDs.removeValue(forKey: handle.id) launchedAttachmentIDs.removeValue(forKey: handle.id) closedLaunchedHandles.remove(handle.id) reportedConnectedAttachmentIDs.removeValue(forKey: handle.id) @@ -1434,6 +1439,8 @@ 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) launchedAttachmentIDs.removeValue(forKey: handle.id) closedLaunchedHandles.remove(handle.id) @@ -1448,7 +1455,8 @@ final class NativeTmuxSessionCoordinator { terminalCoordinator.removeSurface(for: surfaceKey(handle)) reportSurfaceStateLater( handle, - state: .disconnected(reason: reason) + state: .disconnected(reason: reason), + requiredLaunchFailureID: failureID ) } @@ -1547,7 +1555,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 } @@ -1560,6 +1569,12 @@ final class NativeTmuxSessionCoordinator { attachmentClosures[handle.id] == nil else { return } } + if let requiredLaunchFailureID { + guard surfaceLaunchFailureIDs[handle.id] + == requiredLaunchFailureID + else { return } + surfaceLaunchFailureIDs.removeValue(forKey: handle.id) + } onStateChanged?(handle, state) } } @@ -1645,6 +1660,7 @@ final class NativeTmuxSessionCoordinator { } attachments.removeAll() attachmentClosures.removeAll() + surfaceLaunchFailureIDs.removeAll() launchedAttachmentIDs.removeAll() closedLaunchedHandles.removeAll() reportedConnectedAttachmentIDs.removeAll() diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index f4ca74a1..f35ce556 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -1476,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() From 3599eb7a7bfe3b01260b702ed8535a6faaa27d3a Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 19:21:40 -0500 Subject: [PATCH 24/28] Restore a pending tmux activation after a failed removal Reactivating a hidden retained tmux client stages the selection as active while the interactive sizing change is still in flight; the active handle is only committed once that change lands. A worktree removal that starts in that window recorded the presentation as inactive, so a failed removal either restored it hidden or, for a client that kwt must establish, did not restore it at all. The user's open was silently lost. The removal snapshot now treats a matching pending activation as active, so a failed removal brings the session back in front of the user as long as they have not navigated elsewhere since. Generated with Claude Code (claude-fable-5-1) Co-authored-by: Claude Fable 5.1 --- Sources/App/WorkspaceSceneModel.swift | 3 +- ...orkspaceWorktreeRemovalRecoveryTests.swift | 86 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 762ca9b3..1f1df6fb 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -4794,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) diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index 95708c5e..2885f0da 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -1571,6 +1571,92 @@ 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 { From dc9105990ad08ba57ea44d991e2b7848757f54fb Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 20:32:39 -0500 Subject: [PATCH 25/28] Resume tmux activation after reconnect A tmux disconnect could interrupt interactive sizing while a hidden client was being reopened. The stale sizing attempt then discarded the retained presentation before its replacement attachment was ready. Keep the activation pending across the disconnect and resume sizing only when the replacement attachment can take ownership. Generated with Codex Co-authored-by: Codex --- Sources/App/WorkspaceSceneModel.swift | 7 ++ Tests/App/WorkspaceTmuxRecoveryTests.swift | 112 +++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index 1f1df6fb..faa3faa2 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -10424,6 +10424,8 @@ final class WorkspaceSceneModel: ObservableObject { if resumesCurrentActivation { if !nativeTmuxSessionCoordinator.isProvisioning( presentation.handle + ), !nativeTmuxSessionCoordinator.hasClosedAttachment( + presentation.handle ) { tmuxSurfaceBecameReady(presentation.handle) } @@ -11117,6 +11119,11 @@ 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.closedAttachmentHadLaunched(handle), diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index c3fd9f9d..4db0cc59 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -1158,6 +1158,118 @@ extension WorkspaceTmuxDiscoveryTests { 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 { From 7368598684801265decd5822862a3cb6a1fbefd2 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 21:34:32 -0500 Subject: [PATCH 26/28] Keep unsafe tmux clients out of hidden restoration Detach protected sessions whose socket is unresolved before hidden sizing can target the default server. Leave inactive Windows sessions detached during restoration because psmux cannot opt a client out of shared sizing. --- Sources/App/WorkspaceSceneModel.swift | 11 +++ .../App/WorkspaceTmuxPresentationTests.swift | 57 +++++++++++++++ ...orkspaceWorktreeRemovalRecoveryTests.swift | 69 ++++++++++++++++++- 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index faa3faa2..ca1197b1 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -9545,6 +9545,9 @@ 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 } let startsHiddenOnPOSIX = startsHidden && host.platform != .windows // A protected worktree lives on its own tmux socket. Without that // socket a direct attach would target the default server, which never @@ -10582,6 +10585,14 @@ final class WorkspaceSceneModel: ObservableObject { 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 diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 9c435614..acd8b330 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -317,6 +317,63 @@ extension WorkspaceTmuxDiscoveryTests { 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 { diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index 2885f0da..4c28161e 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -1746,7 +1746,8 @@ extension WorkspaceWorktreeRemovalTests { store: surfaceStore, mutations: mutations, selection: selection, - other: other + other: other, + expectedPresentationCount: 1 ) let request = try await model.prepareWorktreeRemoval(removable.id) @@ -1771,6 +1772,62 @@ extension WorkspaceWorktreeRemovalTests { 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? @@ -1790,10 +1847,12 @@ extension WorkspaceWorktreeRemovalTests { 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( @@ -1839,7 +1898,8 @@ extension WorkspaceWorktreeRemovalTests { store: SceneTmuxSurfaceStoreStub, mutations: WorktreeMutationCoordinator, selection: WorkspaceTmuxSessionSelection, - other: WorkspaceTmuxSessionSelection + other: WorkspaceTmuxSessionSelection, + expectedPresentationCount: Int = 2 ) async throws { model.openBorrowedTmuxSession(selection) await launchActiveTmuxSurface(model, store: store) @@ -1857,7 +1917,10 @@ extension WorkspaceWorktreeRemovalTests { return store.requestCount == 2 && model.retainedBorrowedTmuxSessionIsConnected(other) } - #expect(model.retainedBorrowedTmuxPresentationCount == 2) + #expect( + model.retainedBorrowedTmuxPresentationCount + == expectedPresentationCount + ) } } From 85a0913c403a865d807acc111e54bf18169a9249 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 3 Sep 2026 06:24:23 -0500 Subject: [PATCH 27/28] Hide the previous tmux client when the next host cannot be resolved Opening a session on a host that no longer resolves, such as a removed host or one without an SSH destination, moved the active selection but skipped the hidden-sizing step for the client the user just left. That client stayed retained, invisible, and still sizing the shared tmux session, which is the exact problem this branch removes. The unresolvable-host branch now runs the same deactivation as every other activation path, so the previous client is told to ignore its size before the selection changes. Also stop a launch failure from writing its closure and disconnect report for a handle that was closed while the SSH release was pending, and drop the redundant POSIX-only hidden flag that the Windows guard above it already guarantees. Generated with Claude Code (claude-fable-5-1) Co-authored-by: Claude Fable 5.1 --- .../App/NativeTmuxSessionCoordinator.swift | 1 + Sources/App/WorkspaceSceneModel.swift | 13 ++-- .../App/WorkspaceTmuxPresentationTests.swift | 69 +++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 5acea47a..27472192 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -538,6 +538,7 @@ final class NativeTmuxSessionCoordinator { 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: diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index ca1197b1..bf7d203c 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -9536,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 @@ -9548,11 +9548,10 @@ final class WorkspaceSceneModel: ObservableObject { // 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 } - let startsHiddenOnPOSIX = startsHidden && host.platform != .windows // 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 startsHiddenOnPOSIX, + if startsHidden, selection.tmuxAttachMode == .protected, selection.socketName == nil { return nil @@ -9561,7 +9560,7 @@ final class WorkspaceSceneModel: ObservableObject { // client flags before joining the session. Hidden restoration never // establishes a workspace, so attach directly with ignore-size. let attachmentLaunchMode: TmuxAttachmentLaunchMode = - startsHiddenOnPOSIX ? .attachOnly : effectiveLaunchMode + startsHidden ? .attachOnly : effectiveLaunchMode let knownSessions = tmuxSessionsByHost[selection.hostID] ?? host.tmuxSessions let sessionIsDiscovered = selection.socketName == nil @@ -9594,8 +9593,8 @@ final class WorkspaceSceneModel: ObservableObject { workingDirectory: selection.workspacePath, openWorkspace: openWorkspace, sessionIdentity: discoveredIdentity, - ignoresClientSize: startsHiddenOnPOSIX || ignoresClientSize, - previewGridSize: startsHiddenOnPOSIX + ignoresClientSize: startsHidden || ignoresClientSize, + previewGridSize: startsHidden ? self.previewGridSize(for: selection) : previewGridSize ) let phase: RemoteTmuxEstablishmentPhase @@ -9633,7 +9632,7 @@ final class WorkspaceSceneModel: ObservableObject { ), verifiedPreviewIdentity: nil ) - presentation.sizingIntent = startsHiddenOnPOSIX ? .hidden : .interactive + presentation.sizingIntent = startsHidden ? .hidden : .interactive presentation.launchesThroughKwtWorkspace = openWorkspace || protectedSessionNeedsEstablishment presentation.reconnectExpectedIdentity = discoveredIdentity diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index acd8b330..51a5e210 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -1763,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() + } +} From b39af3c928f065c2c9883dd9ab8cedcbd8161845 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 3 Sep 2026 06:26:14 -0500 Subject: [PATCH 28/28] Read tmux client flags without a tab separator in the sizing test The preview-sizing test listed tmux clients with a tab between the tty and its flags. tmux 3.7 prints that tab as an underscore unless the process runs with a UTF-8 locale, so the test failed on every run in a shell without LANG set even though the client flag was applied. The app is unaffected because it runs tmux through the user's login shell. Use a pipe separator so the check does not depend on the locale. Generated with Claude Code (claude-fable-5-1) Co-authored-by: Claude Fable 5.1 --- Tests/App/NativeTmuxSessionCoordinatorTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index f35ce556..e7786a13 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -2289,13 +2289,13 @@ struct NativeTmuxSessionCoordinatorTests { 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") } + .first { $0.hasPrefix(clientIdentity.clientTTY + "|") } previewGridWasIgnored = clientFlags?.contains("ignore-size") == true }