Fix dyld crash on macOS < 15 from unguarded quick_exit reference#2564
Conversation
quick_exit only entered libSystem in macOS 15.0, and the SDK declares it
with no availability annotation:
void quick_exit(int) __dead2; // _stdlib.h
It is gated on __DARWIN_C_LEVEL/C11 -- a compile-time feature gate, not an
OS-version gate -- so the compiler has no version information to act on. It
cannot warn (-Wunguarded-availability) and cannot weak-link. Building
against the macOS 15 SDK with an older deployment target therefore emits a
hard reference and links cleanly against the SDK's libSystem stub, but the
binary aborts on load on macOS < 15:
dyld: Symbol not found: _quick_exit
Referenced from: .../lib/libInterface_Application.dylib
Expected in: /usr/lib/libSystem.B.dylib
Xcode 16 ships only the macOS 15 SDK, so on macOS 14 this silently produces
a binary that cannot load on the machine that built it. SCIRun_test hit this
via the two regression-mode exits added in a0e0954.
Extract the call behind Core::quickExit, which falls back to _Exit when the
deployment target is below macOS 15.0. _Exit is C99 and always present, and
since nothing registers at_quick_exit handlers the two are equivalent here.
The literal 150000 is deliberate rather than __MAC_15_0: that constant is
undefined on SDKs older than 15 and would evaluate to 0, inverting the test
and reintroducing the crash on exactly the configurations it guards.
Verified on macOS 14.5 / Xcode 16.2: no _quick_exit references remain in the
build, both preprocessor branches compile (min 14.5 -> _Exit, min 15.0 ->
quick_exit), and lldb confirms the regression-mode path reaches _Exit and
propagates its exit code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note for reviewers: CI cannot validate this fixWorth flagging explicitly — green CI on this PR does not exercise the code path it fixes. I traced the runner images rather than going by job name:
There is no "GUI build on macOS < 15" job, for two independent reasons:
This is exactly how a0e0954 landed with fully green checks while breaking local macOS 14 builds. It also means the What CI does coverOne useful accident: the deployment target is not pinned anywhere, so it auto-detects from the runner. The macOS 26 build log confirms:
Follow-upThe gap is worth closing separately: a GUI build on the oldest supported macOS runner. Note that a build alone is insufficient — this class of bug is a load-time dyld failure, so the job has to actually execute the binary to catch it. 🤖 Generated with Claude Code |
|
#2537 depends on this. #2537 adds a second Once this merges, #2537 should rebase, revert |
CI had no job building the GUI on macOS < 15, for two independent reasons: every mac-gui job runs on macOS-latest (currently the macos-26 image), and the only macOS 14 jobs are headless -- where src/CMakeLists.txt:632 skips ADD_SUBDIRECTORY(Interface) entirely. So nothing compiled src/Interface against an older SDK and deployment target. That gap is what let the previous commit's bug ship green: a macOS-15-only libSystem symbol (quick_exit) linked cleanly on the macOS 26 runners and aborted at load on macOS 14. Add mac-gui-14-arm, pinned to the oldest available image (macos-13 is retired; macos-14 is deprecated but still offered). Pinned rather than tracking macOS-latest on purpose: the newest macOS is already covered several times over, and only the oldest image exercises a lower deployment target. Also add a run-smoke-test input that executes the built binary. A green build cannot detect this class of failure -- the dynamic loader resolves linked libraries before main, so a reference to a symbol the host lacks links cleanly and only aborts on execution. --help is sufficient, since the loader has already resolved every dependency by the time main runs, and it routes to ConsoleApplication (src/Main/scirunMain.cc) so no window server is needed. --version is deliberately not used: it currently falls through to GuiApplication and crashes on exit on macOS. Unlike the existing test steps, the smoke step omits continue-on-error: it must be able to fail the job, or it gates nothing. It selects SCIRun_test where present (built on APPLE only, per src/Main/CMakeLists.txt) and falls back to SCIRun elsewhere, failing loudly if neither exists. Verified against a deliberately reverted build: the smoke test exits 134 with the original "dyld: Symbol not found: _quick_exit", and exits 0 with the fix in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These jobs already build SCIRun_test -- src/Main/CMakeLists.txt adds it unconditionally under IF(APPLE), regardless of BUILD_HEADLESS -- so executing it costs seconds on top of an existing build and catches load-time failures the build itself cannot. Scope note: these jobs cannot catch the quick_exit class of bug on their own. Headless builds skip ADD_SUBDIRECTORY(Interface) (src/CMakeLists.txt), so they never compile the GUI libraries where that symbol was referenced; mac-gui-14-arm remains the only job covering those. What these add is the same load-time check over the Core/Dataflow/Modules dylibs, across macOS 14/15/26 and both arm64 and x86_64. --help routes to ConsoleApplication in headless builds (the BUILD_HEADLESS branch of src/Main/scirunMain.cc), so no window server is involved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The version gate this replaces could not have worked. Nothing in the build sets CMAKE_OSX_DEPLOYMENT_TARGET, so __MAC_OS_X_VERSION_MIN_REQUIRED follows the build host and reads 150000 on exactly the macOS 15 builders that emit the hard _quick_exit reference. The trigger is the SDK, not the host: a macOS 14 machine with Xcode 16 installed builds the broken binary. Both spellings also fail to compile against SDKs older than 15, which do not declare quick_exit at all -- bare quick_exit as an undeclared identifier, std::quick_exit as a reference to an unresolved using declaration, since libc++'s <cstdlib> has no ::quick_exit to pull in. _Exit is equivalent here as nothing registers at_quick_exit handlers, so take it unconditionally on Apple and drop the version arithmetic entirely. Verified against the 13.3 and 15.2 SDKs: compiles on both, and nm -u shows __Exit with no _quick_exit reference. For contrast, std::quick_exit against the 15.2 SDK compiles clean and emits _quick_exit, which is the load-time failure on macOS < 15. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Note on the smoke-test coverage here — and why #2565 is still worth doing. The smoke test builds and runs on the same runner (
The limit is that it makes the effective coverage floor "the oldest available GitHub image", which drifts upward on GitHub's schedule rather than ours — macOS-14 is already deprecated, as the comment in #2565 proposes pinning |
Problem
SCIRun_testaborts on load on macOS 14:Cause
quick_exitonly entered libSystem in macOS 15.0, and the SDK declares it with no availability annotation:It's gated on
__DARWIN_C_LEVEL/C11 — a compile-time feature gate, not an OS-version gate. So even with a correct deployment target (minos 14.5, confirmed viaotool -l), the compiler has no version information to act on: it can't emit-Wunguarded-availability, and it can't weak-link. The result is a hard reference that links cleanly against the SDK's libSystem stub and fails only at load time.libc++ forwards the same un-annotated declaration (
using ::quick_exit _LIBCPP_USING_IF_EXISTS;), sostd::quick_exitinherits it.Xcode 16 ships only the macOS 15 SDK, so on macOS 14 this silently produces a binary that cannot load on the machine that built it — with zero warnings. This reached us through the two regression-mode exits added in a0e0954.
Fix
Extracts the call behind
Core::quickExit(src/Core/Utils/QuickExit.h), falling back to_Exitwhen the deployment target is below macOS 15:_Exitis C99 and always present. Since nothing in the tree registersat_quick_exithandlers, the two are behaviorally equivalent here — that constraint is documented in the header, as it's the one thing that could make the branches diverge.The literal
150000is deliberate rather than the nicer-reading__MAC_15_0: that constant is undefined on pre-15 SDKs and would expand to0, inverting the test and reintroducing the crash on exactly the configurations the guard protects.Verification
On macOS 14.5 / Xcode 16.2:
_quick_exitreferences remaining in the build: 0 (full-buildnmrescan)min 14.5→__Exit,min 15.0→_quick_exit. Thequick_exitbranch is dead on current hardware and would otherwise not compile until someone bumps the deployment target, so this guards against bit-rot.SCIRun_testloads and runs; regression network executes and exits 0Note
Worth a reviewer's judgment:
_Exitis available on every platform SCIRun targets, so the conditional buys intent-signaling and auto-healing when the minimum target rises — not different behavior. Dropping to an unconditional_Exitwould be simpler and equally correct today.🤖 Generated with Claude Code