macOS: fix crash when adding tab from tab overview (#11009)

(moved from #11008)

I have this branch up to fix #10252. It was written with AI (in Cursor),
but only after I made sure I thoroughly understood what was happening
(almost to an excessive extent). I had already determined that an
Objective-C helper was necessary, I just asked GPT 5.3 Codex in Cursor
to write it for me.

**TL;DR: deep within AppKit, there is an Objective-C exception that is
_always_ thrown when opening a new tab from the visual tab picker ("tab
overview"). [Objective-C exceptions *cannot* be safely recovered from in
Swift.](http://developer.apple.com/documentation/swift/handling-cocoa-errors-in-swift#Handle-Exceptions-in-Objective-C-Only)
As Ghostty is primarily Swift, we must introduce some Objective-C
wrapper around tab creation to safely swallow this exception.**

There is a lot more I know about this than the above, and can discuss it
at length if desired. Interestingly, it seems debug builds of Ghostty
(`zig build run`) *do* gracefully recover and don't crash. Release
builds (`zig build run -Doptimize=ReleaseFast`), however, *do* crash.
The crashing seems to be expected behavior and **_I don't think there's
any feasible way to get release builds to recover as debug builds do._**
The debug builds do, arguably, have better animation behavior. Not sure
how I can approach that part.

Release build off of my commit:


https://github.com/user-attachments/assets/c81927be-b2d2-48b3-a18f-30b389a90f04


Debug build off `db1e31c7a69924913e8faafcedb290de3cb4a8b6` (current
`main`, as of writing):


https://github.com/user-attachments/assets/76367154-b039-4453-8d39-8a0465973deb
This commit is contained in:
Mitchell Hashimoto
2026-02-25 09:13:13 -08:00
committed by GitHub
7 changed files with 74 additions and 10 deletions

View File

@@ -231,6 +231,7 @@
Helpers/LastWindowPosition.swift,
Helpers/MetalView.swift,
Helpers/NonDraggableHostingView.swift,
Helpers/ObjCExceptionCatcher.m,
Helpers/PermissionRequest.swift,
Helpers/Private/CGS.swift,
Helpers/Private/Dock.swift,

View File

@@ -1,3 +1,4 @@
// C imports here are exposed to Swift.
#import "ObjCExceptionCatcher.h"
#import "VibrantLayer.h"

View File

@@ -411,14 +411,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// If we already have a tab group and we want the new tab to open at the end,
// then we use the last window in the tab group as the parent.
if let last = parent.tabGroup?.windows.last {
last.addTabbedWindow(window, ordered: .above)
last.addTabbedWindowSafely(window, ordered: .above)
} else {
fallthrough
}
case "current": fallthrough
default:
parent.addTabbedWindow(window, ordered: .above)
parent.addTabbedWindowSafely(window, ordered: .above)
}
}
@@ -863,7 +863,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
controller.showWindow(nil)
if let firstWindow = firstController.window,
let newWindow = controller.window {
firstWindow.addTabbedWindow(newWindow, ordered: .above)
firstWindow.addTabbedWindowSafely(newWindow, ordered: .above)
}
}
@@ -952,9 +952,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
if tabIndex < tabGroup.windows.count {
// Find the window that is currently at that index
let currentWindow = tabGroup.windows[tabIndex]
currentWindow.addTabbedWindow(window, ordered: .below)
currentWindow.addTabbedWindowSafely(window, ordered: .below)
} else {
tabGroup.windows.last?.addTabbedWindow(window, ordered: .above)
tabGroup.windows.last?.addTabbedWindowSafely(window, ordered: .above)
}
// Make it the key window
@@ -1386,7 +1386,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
if #available(macOS 26, *) {
if window is TitlebarTabsTahoeTerminalWindow {
tabGroup.removeWindow(selectedWindow)
targetWindow.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above)
targetWindow.addTabbedWindowSafely(selectedWindow, ordered: action.amount < 0 ? .below : .above)
DispatchQueue.main.async {
selectedWindow.makeKey()
}
@@ -1401,7 +1401,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Remove and re-add the window in the correct position
tabGroup.removeWindow(selectedWindow)
targetWindow.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above)
targetWindow.addTabbedWindowSafely(selectedWindow, ordered: action.amount < 0 ? .below : .above)
// Ensure our window remains selected
selectedWindow.makeKey()

View File

@@ -39,6 +39,23 @@ extension NSWindow {
guard let firstWindow = tabGroup?.windows.first else { return true }
return firstWindow === self
}
/// Wraps `addTabbedWindow` with an Objective-C exception catcher because AppKit can
/// throw NSExceptions in visual tab picker flows. Swift cannot safely recover from
/// those exceptions, so we route through Obj-C and log a recoverable failure.
@discardableResult
func addTabbedWindowSafely(
_ child: NSWindow,
ordered: NSWindow.OrderingMode
) -> Bool {
var error: NSError?
let success = GhosttyAddTabbedWindowSafely(self, child, ordered.rawValue, &error)
if let error {
Ghostty.logger.error("addTabbedWindow failed: \(error.localizedDescription)")
}
return success
}
}
/// Native tabbing private API usage. :(

View File

@@ -296,13 +296,13 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
if tabIndex == 0 {
// We were previously the first tab. Add it before ("below")
// the first window in the tab group currently.
tabGroup.windows.first!.addTabbedWindow(window, ordered: .below)
tabGroup.windows.first!.addTabbedWindowSafely(window, ordered: .below)
} else if tabIndex <= tabGroup.windows.count {
// We were somewhere in the middle
tabGroup.windows[tabIndex - 1].addTabbedWindow(window, ordered: .above)
tabGroup.windows[tabIndex - 1].addTabbedWindowSafely(window, ordered: .above)
} else {
// We were at the end
tabGroup.windows.last!.addTabbedWindow(window, ordered: .below)
tabGroup.windows.last!.addTabbedWindowSafely(window, ordered: .below)
}
}

View File

@@ -0,0 +1,13 @@
#import <Foundation/Foundation.h>
/// This file contains wrappers around various ObjC functions so we can catch
/// exceptions, since you can't natively catch ObjC exceptions from Swift
/// (at least at the time of writing this comment).
/// NSWindow.addTabbedWindow wrapper
FOUNDATION_EXPORT BOOL GhosttyAddTabbedWindowSafely(
id _Nonnull parent,
id _Nonnull child,
NSInteger ordered,
NSError * _Nullable * _Nullable error
);

View File

@@ -0,0 +1,32 @@
#import "ObjCExceptionCatcher.h"
#import <AppKit/AppKit.h>
BOOL GhosttyAddTabbedWindowSafely(
id parent,
id child,
NSInteger ordered,
NSError * _Nullable * _Nullable error
) {
// AppKit occasionally throws NSException while adding tabbed windows,
// in particular when creating tabs from the tab overview page since some
// macOS update recently in 2025/2026 (unclear).
//
// We must catch it in Objective-C; letting this cross into Swift is unsafe.
@try {
[((NSWindow *)parent) addTabbedWindow:(NSWindow *)child ordered:(NSWindowOrderingMode)ordered];
return YES;
} @catch (NSException *exception) {
if (error != NULL) {
NSString *reason = exception.reason ?: @"Unknown Objective-C exception";
*error = [NSError errorWithDomain:@"Ghostty.ObjCException"
code:1
userInfo:@{
NSLocalizedDescriptionKey: reason,
@"exception_name": exception.name,
}];
}
return NO;
}
}