Skip to content

Fix dyld crash on macOS < 15 from unguarded quick_exit reference#2564

Merged
jessdtate merged 4 commits into
masterfrom
fix-quick-exit-crash-mac
Jul 17, 2026
Merged

Fix dyld crash on macOS < 15 from unguarded quick_exit reference#2564
jessdtate merged 4 commits into
masterfrom
fix-quick-exit-crash-mac

Conversation

@dcwhite

@dcwhite dcwhite commented Jul 16, 2026

Copy link
Copy Markdown
Member

Problem

SCIRun_test aborts on load on macOS 14:

dyld[48953]: Symbol not found: _quick_exit
  Referenced from: .../bin26/SCIRun/lib/libInterface_Application.dylib
  Expected in:     /usr/lib/libSystem.B.dylib

Cause

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:160

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 via otool -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;), so std::quick_exit inherits 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 _Exit when the deployment target is below macOS 15:

#if defined(__APPLE__) && (__MAC_OS_X_VERSION_MIN_REQUIRED < 150000)
  #define SCIRUN_NO_QUICK_EXIT 1
#endif

_Exit is C99 and always present. Since nothing in the tree registers at_quick_exit handlers, 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 150000 is deliberate rather than the nicer-reading __MAC_15_0: that constant is undefined on pre-15 SDKs and would expand to 0, inverting the test and reintroducing the crash on exactly the configurations the guard protects.

Verification

On macOS 14.5 / Xcode 16.2:

  • _quick_exit references remaining in the build: 0 (full-build nm rescan)
  • Both preprocessor branches compile — min 14.5__Exit, min 15.0_quick_exit. The quick_exit branch is dead on current hardware and would otherwise not compile until someone bumps the deployment target, so this guards against bit-rot.
  • SCIRun_test loads and runs; regression network executes and exits 0
  • lldb confirms the live path rather than just the symbol table:
    frame #0: libsystem_c.dylib`_Exit
    frame #1: libInterface_Application.dylib`SCIRun::Core::quickExit(int) + 12
    Process exited with status = 0
    

Note

Worth a reviewer's judgment: _Exit is 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 _Exit would be simpler and equally correct today.

🤖 Generated with Claude Code

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>
@dcwhite
dcwhite requested a review from jessdtate July 16, 2026 20:35
@dcwhite dcwhite self-assigned this Jul 16, 2026
@dcwhite dcwhite added the Bug label Jul 16, 2026
@dcwhite dcwhite added this to the 5.0.0 milestone Jul 16, 2026
@dcwhite

dcwhite commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Note for reviewers: CI cannot validate this fix

Worth 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:

job runs on builds src/Interface?
mac-gui, mac-gui-nonpython, mac-gui-regression macOS 26 (macOS-latestmacos-26-arm64) yes
mac-gui-26, mac-gui-26-intel macOS 26 yes
mac-headless-14-arm, mac-headless-14-arm-slim macOS 14 no

There is no "GUI build on macOS < 15" job, for two independent reasons:

  1. Every GUI job runs on macOS 26, where quick_exit is present in libSystem — so the crash cannot manifest.
  2. The only macOS 14 jobs are variant: headless, and src/CMakeLists.txt:632 guards ADD_SUBDIRECTORY(Interface) behind IF(NOT BUILD_HEADLESS) — so the code referencing quick_exit is never compiled there.

This is exactly how a0e0954 landed with fully green checks while breaking local macOS 14 builds. It also means the _Exit fallback added here is not covered by any job on this PR; the evidence for it is local (nm showing zero _quick_exit refs, and lldb confirming SCIRun::Core::quickExit(int)libsystem_c.dylib\_Exit` with the exit code propagated on macOS 14.5 / Xcode 16.2).

What CI does cover

One useful accident: the deployment target is not pinned anywhere, so it auto-detects from the runner. The macOS 26 build log confirms:

checking which MACOSX_DEPLOYMENT_TARGET to use... 26.4

260400 >= 150000, so CI compiles the std::quick_exit branch of the new conditional, while the local macOS 14.5 build compiles the _Exit branch. Between the two, both branches are exercised on real hardware — but only by coincidence, not by design.

Follow-up

The 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

@dcwhite

dcwhite commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

#2537 depends on this.

#2537 adds a second quick_exit call site in Core/ConsoleApplication/ConsoleCommands.cc and is currently failing mac-headless-14-arm on it (use of undeclared identifier 'quick_exit'). Its workaround (fe316899e, dropping the std:: qualifier) doesn't hold up: older SDKs don't declare quick_exit in either namespace, so both spellings fail to compile — std::quick_exit as an unresolved using declaration, bare quick_exit as an undeclared identifier.

Once this merges, #2537 should rebase, revert fe316899e, and use quickExit(1) from Core/Utils/QuickExit.h. That clears its macOS-14 builds and keeps the console path from emitting the hard _quick_exit reference into libCore_ConsoleApplication.

dcwhite and others added 3 commits July 16, 2026 18:07
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>
@dcwhite

dcwhite commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

Note on the smoke-test coverage here — and why #2565 is still worth doing.

The smoke test builds and runs on the same runner (reusable-build.yml runs the just-built bin/SCIRun/SCIRun_test --help as a step in the build job). So there's no skew between the build SDK and the runtime OS: a symbol newer than the floor resolves fine at load, because the machine that emitted the reference is the machine executing it. No single job can reproduce the dyld form of this bug, which by construction needs a 15-SDK build to meet a pre-15 machine.

mac-gui-14-arm does catch this bug, but through a different door — as a compile error rather than a load failure. The macOS 14 SDK doesn't declare quick_exit at all, so it dies at the compile step and never reaches the smoke test. Still a real net, and worth having for the src/Interface coverage gap it closes.

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 mac.yml notes. Anything below that floor is never exercised, and users on older Macs sit exactly there.

#2565 proposes pinning CMAKE_OSX_DEPLOYMENT_TARGET so the declared floor is ours and stops moving when an image is retired. Pinned, -Wunguarded-availability fires at compile time on any runner, so catching this class stops depending on having an old enough machine to run the binary on.

@jessdtate
jessdtate merged commit a7bb35d into master Jul 17, 2026
40 checks passed
@jessdtate
jessdtate deleted the fix-quick-exit-crash-mac branch July 17, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants