macos: cancel deferred tab presentation on close

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.
pull/12119/head
jamylak 2026-04-03 20:07:25 +11:00
parent 0790937d03
commit 355aecb6ba
1 changed files with 35 additions and 3 deletions

View File

@ -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<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
@ -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