From 355aecb6ba26584c4430377dc0f6e9a0b0d59fe0 Mon Sep 17 00:00:00 2001 From: jamylak Date: Fri, 3 Apr 2026 20:07:25 +1100 Subject: [PATCH] macos: cancel deferred tab presentation on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 👻 Ghost Tab Issue Previous failure scenario (User perspective): 1. Open a new tab 2. Instantly trigger close other tabs (eg. through custom user keyboard shortcut) 3. Now you will see an empty Ghost Tab (Only a window bar with empty content) The previous failure mode is: 1. Create a tab or window now in `newTab(...)` / `newWindow(...)`. 2. Queue its initial show/focus work with `DispatchQueue.main.async`. 3. Close that tab or window with `closeTabImmediately()` / `closeWindowImmediately()` before the queued callback runs. 4. The queued callback still runs anyway and calls `showWindow(...)` / `makeKeyAndOrderFront(...)` on stale state. 5. The tab can be resurrected as a half-closed blank ghost tab. The fix: - Store deferred presentation work in a cancellable DispatchWorkItem and cancel it from the close paths before AppKit finishes tearing down the tab or window. - This prevents the stale show/focus callback from running after close. --- .../Terminal/TerminalController.swift | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 56b0b40ad..9866e0deb 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -46,6 +46,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// changes in the list. private var tabWindowsHash: Int = 0 + /// The initial window presentation is deferred by one runloop turn in a few places so + /// AppKit can settle tab/window state first. Close actions must cancel it to avoid + /// re-showing a tab that was already closed. + private var pendingInitialPresentation: DispatchWorkItem? + /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true @@ -140,6 +145,27 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr center.removeObserver(self) } + private func cancelPendingInitialPresentation() { + pendingInitialPresentation?.cancel() + pendingInitialPresentation = nil + } + + private func scheduleInitialPresentation(_ block: @escaping () -> Void) { + cancelPendingInitialPresentation() + + var scheduledWorkItem: DispatchWorkItem? + scheduledWorkItem = DispatchWorkItem { [weak self] in + guard let self else { return } + defer { self.pendingInitialPresentation = nil } + guard scheduledWorkItem?.isCancelled == false else { return } + block() + } + + let workItem = scheduledWorkItem! + pendingInitialPresentation = workItem + DispatchQueue.main.async(execute: workItem) + } + // MARK: Base Controller Overrides override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { @@ -257,7 +283,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We're dispatching this async because otherwise the lastCascadePoint doesn't // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. - DispatchQueue.main.async { + c.scheduleInitialPresentation { c.showWindow(self) // Only cascade if we aren't fullscreen. @@ -319,7 +345,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Calculate the target frame based on the tree's view bounds let treeSize: CGSize? = tree.root?.viewBounds() - DispatchQueue.main.async { + c.scheduleInitialPresentation { c.showWindow(self) if let window = c.window { // If we have a tree size, resize the window's content to match @@ -434,7 +460,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We're dispatching this async because otherwise the lastCascadePoint doesn't // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. - DispatchQueue.main.async { + controller.scheduleInitialPresentation { // Only cascade if we aren't fullscreen and are alone in the tab group. if !window.styleMask.contains(.fullScreen) && window.tabGroup?.windows.count ?? 1 == 1 { @@ -650,6 +676,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return } + cancelPendingInitialPresentation() + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -768,6 +796,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr func closeWindowImmediately() { guard let window = window else { return } + cancelPendingInitialPresentation() + registerUndoForCloseWindow() if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { @@ -776,6 +806,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // This prevents unnecessary undos registered since AppKit may // process them on later ticks so we can't just disable undo registration. if let controller = window.windowController as? TerminalController { + controller.cancelPendingInitialPresentation() controller.surfaceTree = .init() } @@ -1142,6 +1173,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) + cancelPendingInitialPresentation() self.relabelTabs() // If we remove a window, we reset the cascade point to the key window so that