macos: cancel deferred tab presentation on close (#12119)

The 👻 Ghost Tab Issue


https://github.com/user-attachments/assets/cb91cd85-4a08-4c16-9efb-1a9ab30fc2bc

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.

## AI Usage

I used GPT 5.4 to find the initial issue and fix it. I cleaned up and
narrowed down the commit afterwards.

-----

Additional Notes:

I use `cmd+o` to `close_tab:other`

https://github.com/jamylak/dotfiles/blob/main/ghostty/config#L106C1-L106C34

Try it for your self if you want to reproduce, just do a quick `cmd+t`
`cmd+o` and you will see
This commit is contained in:
Mitchell Hashimoto
2026-04-07 05:41:23 -07:00
committed by GitHub

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