From dd4e36f921e479386a44a12c931bc21528ce683c Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Tue, 24 Feb 2026 18:08:45 -0500 Subject: [PATCH 1/4] macOS: fix crash when adding tab from tab overview --- .../App/macOS/ghostty-bridging-header.h | 1 + .../Terminal/TerminalController.swift | 14 +++---- .../Extensions/NSWindow+Extension.swift | 18 ++++++++ macos/Sources/Helpers/Fullscreen.swift | 6 +-- macos/Sources/Helpers/ObjCExceptionCatcher.h | 9 ++++ macos/Sources/Helpers/ObjCExceptionCatcher.m | 41 +++++++++++++++++++ 6 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 macos/Sources/Helpers/ObjCExceptionCatcher.h create mode 100644 macos/Sources/Helpers/ObjCExceptionCatcher.m diff --git a/macos/Sources/App/macOS/ghostty-bridging-header.h b/macos/Sources/App/macOS/ghostty-bridging-header.h index fc654ad3f..aa6b1442b 100644 --- a/macos/Sources/App/macOS/ghostty-bridging-header.h +++ b/macos/Sources/App/macOS/ghostty-bridging-header.h @@ -1,3 +1,4 @@ // C imports here are exposed to Swift. #import "VibrantLayer.h" +#import "ObjCExceptionCatcher.h" diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 6573ac7fc..c5c003459 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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() diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 0fa330f1b..4821e5b46 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -39,6 +39,24 @@ 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 + /// occasionally throw NSExceptions in visual tab picker flows. + @discardableResult + func addTabbedWindowSafely( + _ child: NSWindow, + ordered: NSWindow.OrderingMode + ) -> Bool { + var error: NSError? + let success = GhosttyAddTabbedWindowSafely(self, child, ordered.rawValue, &error) + + if let error { + let reason = error.localizedDescription + Ghostty.logger.error("addTabbedWindow failed: \(reason)") + } + + return success + } } /// Native tabbing private API usage. :( diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6773b6f0c..139059190 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -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) } } diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.h b/macos/Sources/Helpers/ObjCExceptionCatcher.h new file mode 100644 index 000000000..d435615c5 --- /dev/null +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.h @@ -0,0 +1,9 @@ +#import + +/// Minimal Objective-C exception bridge for AppKit tabbing APIs. +FOUNDATION_EXPORT BOOL GhosttyAddTabbedWindowSafely( + id parent, + id child, + NSInteger ordered, + NSError * _Nullable * _Nullable error +); diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.m b/macos/Sources/Helpers/ObjCExceptionCatcher.m new file mode 100644 index 000000000..6def44945 --- /dev/null +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.m @@ -0,0 +1,41 @@ +#import "ObjCExceptionCatcher.h" +#import + +#if TARGET_OS_OSX +#import +#endif + +BOOL GhosttyAddTabbedWindowSafely( + id parent, + id child, + NSInteger ordered, + NSError * _Nullable * _Nullable error +) { +#if TARGET_OS_OSX + @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; + } +#else + if (error != NULL) { + *error = [NSError errorWithDomain:@"Ghostty.ObjCException" + code:2 + userInfo:@{ + NSLocalizedDescriptionKey: @"GhosttyAddTabbedWindowSafely is unavailable on this platform.", + }]; + } + return NO; +#endif +} From 304823d560450ccde4e0f582a68fc88241eb76bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Feb 2026 08:27:10 -0800 Subject: [PATCH 2/4] macos: just some textual cleanup --- macos/Sources/App/macOS/ghostty-bridging-header.h | 2 +- .../Helpers/Extensions/NSWindow+Extension.swift | 7 +++---- macos/Sources/Helpers/ObjCExceptionCatcher.h | 10 +++++++--- macos/Sources/Helpers/ObjCExceptionCatcher.m | 2 ++ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/macos/Sources/App/macOS/ghostty-bridging-header.h b/macos/Sources/App/macOS/ghostty-bridging-header.h index aa6b1442b..44781cbe9 100644 --- a/macos/Sources/App/macOS/ghostty-bridging-header.h +++ b/macos/Sources/App/macOS/ghostty-bridging-header.h @@ -1,4 +1,4 @@ // C imports here are exposed to Swift. -#import "VibrantLayer.h" #import "ObjCExceptionCatcher.h" +#import "VibrantLayer.h" diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 018530cef..3c5cbd23a 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -41,7 +41,8 @@ extension NSWindow { } /// Wraps `addTabbedWindow` with an Objective-C exception catcher because AppKit can - /// occasionally throw NSExceptions in visual tab picker flows. + /// 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, @@ -49,10 +50,8 @@ extension NSWindow { ) -> Bool { var error: NSError? let success = GhosttyAddTabbedWindowSafely(self, child, ordered.rawValue, &error) - if let error { - let reason = error.localizedDescription - Ghostty.logger.error("addTabbedWindow failed: \(reason)") + Ghostty.logger.error("addTabbedWindow failed: \(error.localizedDescription)") } return success diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.h b/macos/Sources/Helpers/ObjCExceptionCatcher.h index d435615c5..7906b5945 100644 --- a/macos/Sources/Helpers/ObjCExceptionCatcher.h +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.h @@ -1,9 +1,13 @@ #import -/// Minimal Objective-C exception bridge for AppKit tabbing APIs. +/// 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 parent, - id child, + id _Nonnull parent, + id _Nonnull child, NSInteger ordered, NSError * _Nullable * _Nullable error ); diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.m b/macos/Sources/Helpers/ObjCExceptionCatcher.m index 6def44945..0a2e04d52 100644 --- a/macos/Sources/Helpers/ObjCExceptionCatcher.m +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.m @@ -12,6 +12,8 @@ BOOL GhosttyAddTabbedWindowSafely( NSError * _Nullable * _Nullable error ) { #if TARGET_OS_OSX + // AppKit occasionally throws NSException while reparenting tabbed windows. + // 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; From da045d2fb3d7d7139a927d7f41f9db44ad7b8fd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Feb 2026 09:00:51 -0800 Subject: [PATCH 3/4] Remove ObjCExceptionCatcher from iOS target --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + macos/Sources/Helpers/ObjCExceptionCatcher.m | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 8871343c3..9ab2cac74 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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, diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.m b/macos/Sources/Helpers/ObjCExceptionCatcher.m index 0a2e04d52..16db75ddc 100644 --- a/macos/Sources/Helpers/ObjCExceptionCatcher.m +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.m @@ -1,9 +1,6 @@ #import "ObjCExceptionCatcher.h" -#import -#if TARGET_OS_OSX #import -#endif BOOL GhosttyAddTabbedWindowSafely( id parent, @@ -11,7 +8,6 @@ BOOL GhosttyAddTabbedWindowSafely( NSInteger ordered, NSError * _Nullable * _Nullable error ) { -#if TARGET_OS_OSX // AppKit occasionally throws NSException while reparenting tabbed windows. // We must catch it in Objective-C; letting this cross into Swift is unsafe. @try { @@ -30,14 +26,4 @@ BOOL GhosttyAddTabbedWindowSafely( return NO; } -#else - if (error != NULL) { - *error = [NSError errorWithDomain:@"Ghostty.ObjCException" - code:2 - userInfo:@{ - NSLocalizedDescriptionKey: @"GhosttyAddTabbedWindowSafely is unavailable on this platform.", - }]; - } - return NO; -#endif } From 26146f54c5c739c72ad11c774caff2826cfd7eb5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Feb 2026 09:04:19 -0800 Subject: [PATCH 4/4] update comments --- macos/Sources/Helpers/ObjCExceptionCatcher.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.m b/macos/Sources/Helpers/ObjCExceptionCatcher.m index 16db75ddc..e91fb14a7 100644 --- a/macos/Sources/Helpers/ObjCExceptionCatcher.m +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.m @@ -8,7 +8,10 @@ BOOL GhosttyAddTabbedWindowSafely( NSInteger ordered, NSError * _Nullable * _Nullable error ) { - // AppKit occasionally throws NSException while reparenting tabbed windows. + // 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];