From 1f05ca75d1464cd1d5202000c9b1b76826cbfb1f Mon Sep 17 00:00:00 2001 From: jasonlu Date: Fri, 17 Jul 2026 13:55:19 +0800 Subject: [PATCH] error: fix macOS terminate hang + dump ObjC NSException details Two related fixes for the terminate handler on Apple platforms, so that uncaught exceptions (especially ObjC NSExceptions crossing the C++ boundary) actually crash with a non-zero exit and are diagnosable. 1. std::terminate() -> std::abort() in terminate_handler. On macOS the default terminate handler is libobjc's _objc_terminate(), which calls __cxa_rethrow on the still-in-flight exception. The rethrow_exception + catch(...) dispatch above does NOT clear the exception's __cxa "uncaught" state, so _objc_terminate would rethrow -> std::terminate -> _objc_terminate ..., looping forever instead of aborting. This affects ALL uncaught exceptions on macOS, not just ObjC. std::abort() terminates unconditionally (SIGABRT, exit 134). On other platforms the default handler already aborts, so the behaviour is equivalent. 2. Dump ObjC NSException details (name/reason/callStack) from catch(...). An NSException thrown across a C++ boundary does not derive from std::exception and lands in catch(...) with its details lost. An ObjC exception preprocessor records the most recent NSException and its throw-time call stack per thread; dump_objc_exception() prints them. Recording at throw time (rather than rethrowing inside catch(...)) avoids disturbing the C++ exception runtime - an earlier rethrow-based attempt turned out to be the same class of bug as #1. The .mm is built only on Apple (enable_language(OBJCXX) guarded by APPLE); other platforms get no-op stubs. Tested on macOS 26.3 / Apple Silicon (LLVM clang 22, Qt 6.11): - uncaught std::runtime_error -> FATAL + exception info + stack, exit 134 - uncaught ObjC NSException -> FATAL + name/reason/callStack, exit 134 Both previously hung in an infinite std::terminate <-> __terminate loop. Co-Authored-By: Claude Fable 5 --- CMakeLists.txt | 7 +++ libopenage/error/CMakeLists.txt | 10 +++ libopenage/error/handlers.cpp | 28 ++++++++- libopenage/error/objc_exception.h | 41 +++++++++++++ libopenage/error/objc_exception.mm | 99 ++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 libopenage/error/objc_exception.h create mode 100644 libopenage/error/objc_exception.mm diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a6a785bf4..a2530e7c0b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,13 @@ If it doesn't, consider reporting the issue, or ask for help: # main buildsystem setup entry point project(openage CXX) +# Objective-C++ is needed on Apple platforms to build the .mm files that +# interoperate with Foundation (e.g. dumping NSException details from the +# terminate handler). Enabled only on Apple so other platforms are unaffected. +if(APPLE) + enable_language(OBJCXX) +endif() + # C++ standard requirement set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/libopenage/error/CMakeLists.txt b/libopenage/error/CMakeLists.txt index b54fa5e524..c4693e404b 100644 --- a/libopenage/error/CMakeLists.txt +++ b/libopenage/error/CMakeLists.txt @@ -6,6 +6,16 @@ add_sources(libopenage stackanalyzer.cpp ) +# Objective-C++ source for Apple-specific exception handling (NSException +# dump from the terminate handler). Only built on Apple platforms; the .mm is +# compiled as Objective-C++ via the OBJCXX language enabled in the top-level +# CMakeLists.txt. +if(APPLE) + add_sources(libopenage + objc_exception.mm + ) +endif() + pxdgen( backtrace.h error.h diff --git a/libopenage/error/handlers.cpp b/libopenage/error/handlers.cpp index b3e61450b2..e16cbcafe9 100644 --- a/libopenage/error/handlers.cpp +++ b/libopenage/error/handlers.cpp @@ -12,6 +12,7 @@ #include "handlers.h" +#include #include #include #include @@ -27,6 +28,7 @@ #include "util/signal.h" #include "error/error.h" +#include "error/objc_exception.h" #include "error/stackanalyzer.h" @@ -52,12 +54,16 @@ sighandler_t old_sigsegv_handler; util::OnInit install_handlers([]() { old_sigsegv_handler = signal(SIGSEGV, sigsegv_handler); old_terminate_handler = std::set_terminate(terminate_handler); + // Install the ObjC exception tracker so the terminate handler can report + // NSExceptions that cross the C++ boundary (Apple only; no-op elsewhere). + install_objc_exception_tracker(); exit_ok = true; atexit(exit_handler); }); util::OnDeInit restore_handlers([]() { + uninstall_objc_exception_tracker(); std::set_terminate(old_terminate_handler); signal(SIGSEGV, old_sigsegv_handler); }); @@ -88,6 +94,15 @@ util::OnDeInit restore_handlers([]() { } catch (...) { std::cout << "non-standard exception object" << std::endl; + + // On Apple platforms, an Objective-C NSException thrown across a + // C++ boundary is not derived from std::exception and lands here + // with its details otherwise lost. Dump the name/reason/callStack + // of the current in-flight NSException (if any). + std::string objc_dump = dump_objc_exception(); + if (!objc_dump.empty()) { + std::cout << objc_dump << std::endl; + } } } @@ -98,12 +113,19 @@ util::OnDeInit restore_handlers([]() { backtrace.analyze(); std::cout << backtrace << std::endl; - // die again to enable debugger functionality. - // that maybe print some additional useful info that we forgot about. + // die again to enable debugger functionality (SIGABRT is catchable by a + // debugger) and to guarantee a non-zero exit status. + // + // We call std::abort() directly rather than std::terminate(): on macOS the + // default terminate handler is libobjc's _objc_terminate(), which calls + // __cxa_rethrow on the still-in-flight exception. The rethrow_exception + + // catch above does NOT clear the exception's __cxa "uncaught" state, so + // _objc_terminate would rethrow -> std::terminate -> _objc_terminate ..., + // looping forever instead of aborting. std::abort() terminates unconditionally. // TODO: we maybe want to prevent that for end-users. std::cout << "\x1b[33mhanding over to the system...\x1b[m\n" << std::endl; - std::terminate(); + std::abort(); } diff --git a/libopenage/error/objc_exception.h b/libopenage/error/objc_exception.h new file mode 100644 index 0000000000..ddc0f14ab8 --- /dev/null +++ b/libopenage/error/objc_exception.h @@ -0,0 +1,41 @@ +// Copyright 2025 the openage authors. See copying.md for legal info. + +#pragma once + +#include + +namespace openage { +namespace error { + +/** + * Install a process-wide Objective-C exception preprocessor (Apple platforms + * only; no-op elsewhere) that records the most recent NSException thrown on + * each thread, so the terminate handler can later report it. + * + * Must be paired with uninstall_objc_exception_tracker(). Typically called + * once at library load and uninstalled at library unload. + * + * The preprocessor only observes exceptions (it returns the exception + * unchanged) so it does not alter program behaviour. + */ +void install_objc_exception_tracker(); + +/** + * Undo install_objc_exception_tracker(). No-op on non-Apple platforms. + */ +void uninstall_objc_exception_tracker(); + +/** + * Return a dump (name, reason, callStackSymbols) of the most recent NSException + * recorded on the current thread, or an empty string if there was none. + * + * This does NOT rethrow or otherwise disturb the in-flight exception, so it is + * safe to call from a catch(...) arm of a terminate handler. (An earlier + * implementation rethrew via objc_exception_rethrow + @catch; that disturbed the + * C++ exception runtime and turned the subsequent std::terminate() into an + * infinite std::terminate <-> __terminate loop instead of aborting.) + */ +std::string dump_objc_exception(); + +} // namespace error +} // namespace openage diff --git a/libopenage/error/objc_exception.mm b/libopenage/error/objc_exception.mm new file mode 100644 index 0000000000..640ae83dc0 --- /dev/null +++ b/libopenage/error/objc_exception.mm @@ -0,0 +1,99 @@ +#include "objc_exception.h" + +#ifdef __APPLE__ + +#import +#include +#include +#include + +namespace openage { +namespace error { + +namespace { + +// The most recent NSException thrown on the current thread, captured by the +// preprocessor at throw time. Held as a raw pointer (non-ARC); while an +// exception is in flight and propagating to terminate(), the runtime keeps it +// alive via the C++ exception object, so this is valid to read in the +// terminate handler. +thread_local NSException *last_objc_exception = nil; + +// Call stack captured at throw time as a plain string (independent of ARC +// reference counting). NSException's own callStackSymbols may not be populated +// yet when the preprocessor runs, so we grab [NSThread callStackSymbols] +// ourselves at the throw point. +thread_local std::string last_objc_callstack; + +// Previous preprocessor, restored on uninstall so we do not clobber a host +// application's own ObjC exception preprocessor. +objc_exception_preprocessor previous_preprocessor = nullptr; + +} // namespace + +// Invoked by objc_exception_throw() for every NSException thrown. We record it +// per-thread (so the terminate handler, which runs on the throwing thread, can +// read it) and return the exception unchanged. Recording at throw time - rather +// than rethrowing inside catch(...) - means we never disturb the C++ exception +// runtime, so std::terminate()/abort() still terminates cleanly. +static id exception_preprocessor(id exception) { + if (exception != nil && [exception isKindOfClass:[NSException class]]) { + last_objc_exception = (NSException *)exception; + + std::string s; + for (NSString *symbol in [NSThread callStackSymbols]) { + s += " "; + s += [symbol UTF8String]; + s += "\n"; + } + last_objc_callstack = std::move(s); + } + return exception; +} + +void install_objc_exception_tracker() { + previous_preprocessor = objc_setExceptionPreprocessor(exception_preprocessor); +} + +void uninstall_objc_exception_tracker() { + objc_setExceptionPreprocessor(previous_preprocessor); +} + +std::string dump_objc_exception() { + NSException *exc = last_objc_exception; + if (exc == nil) { + return ""; + } + + std::ostringstream ss; + ss << "Objective-C NSException caught across C++ boundary:\n"; + ss << " name: " << [[exc name] UTF8String] << "\n"; + ss << " reason: " << [[exc reason] UTF8String] << "\n"; + if (!last_objc_callstack.empty()) { + ss << " callStackSymbols (at throw):\n" << last_objc_callstack; + } + return ss.str(); +} + +} // namespace error +} // namespace openage + +#else // !__APPLE__ + +namespace openage { +namespace error { + +void install_objc_exception_tracker() { +} + +void uninstall_objc_exception_tracker() { +} + +std::string dump_objc_exception() { + return ""; +} + +} // namespace error +} // namespace openage + +#endif // __APPLE__