You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
(This issue was investigated and written by Claude Fable (AI), from a real crash in a human-supervised session — the source citations were verified against upstream main before filing.)
Summary
On iOS, invoking a JS-provided promise-returning callback from native after the JS runtime that created it has been torn down (e.g. a dev reload) aborts the process via std::terminate. The throw inside AsyncJSCallback::call crosses the noexcept boundary of the nitrogen-generated Func_*_Wrapper::call, so there is no catchable failure — not in C++, and not in Swift.
Void-returning callbacks are safe (callAndForget logs and drops). The hazard is specific to value/promise-returning callbacks.
Mechanism
A JS function returning a Promise is converted via JSIConverter<std::function<...>>::fromJSI into an AsyncJSCallback holding a weak_ptr<Dispatcher> (packages/react-native-nitro-modules/cpp/jsi/JSIConverter+Function.hpp).
When the runtime dies, the dispatcher weak_ptr dies with it. AsyncJSCallback::call then throws (packages/react-native-nitro-modules/cpp/utils/JSCallback.hpp):
std::shared_ptr<Dispatcher> dispatcher = _dispatcher.lock();
if (dispatcher == nullptr) [[unlikely]] {
...
throwstd::runtime_error("Failed to call " + typeName + " - the Dispatcher has already been destroyed!");
}
But the Swift-facing wrapper nitrogen emits for that function type is noexcept (packages/nitrogen/src/syntax/swift/SwiftCxxTypeHelper.ts, ~line 430):
(e.g. packages/react-native-nitro-test/nitrogen/generated/ios/NitroTest-Swift-Cxx-Bridge.hpp:1017 on main.)
Exception + noexcept ⇒ immediate std::terminate / SIGABRT at that frame. The generated Swift caller (let __result = __wrappedFunction.call(...)) has no way to intervene, and a Swift do/catch around the callback invocation never sees anything.
Repro sketch
Any HybridObject holding a JS callback that native code invokes on its own initiative:
Native (e.g. a CarPlay controller, a media session, any surface that outlives reloads) keeps the config and calls config.formatError!(params) when it needs a value. Trigger a JS reload, then have native invoke the callback → SIGABRT with a stack ending in Func_*_Wrapper::call at the noexcept line in the generated *-Swift-Cxx-Bridge.hpp.
Observed during dev reloads on 0.35.9; verified unchanged in source on v0.36.5 and main.
Relevant log output
Backtrace from the real occurrence (react-native-audio-browser's CarPlay controller invoking its formatNavigationError config callback after a reload; react-native 0.86.0, react-native-nitro-modules 0.35.9):
#9 0x000000010dc83c4c in margelo::nitro::audiobrowser::bridge::swift::Func_std__shared_ptr_Promise_std__optional_FormattedNavigationError____FormatNavigationErrorParams_Wrapper::call at /Users/puckey/rg/_libraries/react-native-audio-browser/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp:1727
#10 0x000000010dc82d64 in closure #1 in closure #1 in closure #1 in margelo.nitro.audiobrowser.NativeBrowserConfiguration.formatNavigationError.getter at /Users/puckey/rg/_libraries/react-native-audio-browser/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift:381
#11 0x000000010db297a4 in RNABCarPlayController.formattedNavigationError(_:path:) at /Users/puckey/rg/_libraries/react-native-audio-browser/ios/CarPlay/CarPlayController.swift:729
#12 0x000000010db2931c in RNABCarPlayController.showNavigationErrorView(_:path:on:) at /Users/puckey/rg/_libraries/react-native-audio-browser/ios/CarPlay/CarPlayController.swift:708
#13 0x000000010db26f30 in RNABCarPlayController.loadContent(for:into:) at /Users/puckey/rg/_libraries/react-native-audio-browser/ios/CarPlay/CarPlayController.swift:694
#14 0x000000010db21cfc in RNABCarPlayController.showTabBar(tabs:) at /Users/puckey/rg/_libraries/react-native-audio-browser/ios/CarPlay/CarPlayController.swift:500
#15 0x000000010db173e4 in RNABCarPlayController.buildInitialInterface() at /Users/puckey/rg/_libraries/react-native-audio-browser/ios/CarPlay/CarPlayController.swift:430
#16 0x000000010db142c0 in closure #3 in RNABCarPlayController.start() at /Users/puckey/rg/_libraries/react-native-audio-browser/ios/CarPlay/CarPlayController.swift:189
Frame #9 is the generated noexcept wrapper (call at AudioBrowser-Swift-Cxx-Bridge.hpp:1727 is the _function->operator() line); the frames above it are the terminate path.
Why consumers can't mitigate
The throw happens synchronously, before any Promise exists, so the "rejected promise" path is never reached.
Swift cannot catch C++ exceptions, and the noexcept guarantees terminate at that exact frame regardless.
There is no Swift-visible liveness check for the underlying dispatcher/runtime, and the docs (docs/docs/types/callbacks.md) state callbacks can be held long-term and called from any thread "safely and without any limitations".
The only workaround is for consumers to null out every stored value-returning callback on runtime teardown themselves (via an RN lifecycle hook nitro doesn't provide) — easy to miss, and racy by nature.
Suggested fixes
Either (or both):
Return a rejected Promise instead of throwing in AsyncJSCallback::call when the dispatcher is gone — this would mirror callAndForget's graceful degradation, keep the generated wrapper's noexcept honest, and the caller already handles rejection. (Promise<T> is pure C++ — Promise<T>::rejected(...) needs no runtime — so this looks implementable as-is. Is there a reason a hard failure would be preferred here?)
Drop noexcept from the generated Func_*_Wrapper::call for value-returning function types. Precedent: fix: Remove noexcept from a method that can throw #938 removed noexcept from the generated hybrid-object get_std__shared_ptr_<HybridSpec>_ bridge functions (the C++→Swift unwrap helpers) for the same reason ("Remove noexcept from a method that can throw"); the function-wrapper call looks non-throwing (_function->operator()) but isn't. On its own this only converts the abort into an unhandleable C++ exception from Swift's perspective, so (1) is the fix that actually helps consumers.
Affected versions
noexcept on the wrapper call shipped in v0.28.1 (#832, "Swift cannot catch C++ exceptions anyways"). Verified present in 0.35.9, v0.36.5, and current main. The AsyncJSCallback throw predates it — before v0.28.1 the exception would still terminate once it unwound into Swift-compiled frames (Swift can't catch it); v0.28.1 just made the terminate deterministic at the wrapper frame. So the crash affects all versions in practice; the deterministic-at-call stack shape is ≥ 0.28.1.
(This issue was investigated and written by Claude Fable (AI), from a real crash in a human-supervised session — the source citations were verified against upstream main before filing.)
Summary
On iOS, invoking a JS-provided promise-returning callback from native after the JS runtime that created it has been torn down (e.g. a dev reload) aborts the process via
std::terminate. The throw insideAsyncJSCallback::callcrosses thenoexceptboundary of the nitrogen-generatedFunc_*_Wrapper::call, so there is no catchable failure — not in C++, and not in Swift.Void-returning callbacks are safe (
callAndForgetlogs and drops). The hazard is specific to value/promise-returning callbacks.Mechanism
JSIConverter<std::function<...>>::fromJSIinto anAsyncJSCallbackholding aweak_ptr<Dispatcher>(packages/react-native-nitro-modules/cpp/jsi/JSIConverter+Function.hpp).AsyncJSCallback::callthen throws (packages/react-native-nitro-modules/cpp/utils/JSCallback.hpp):noexcept(packages/nitrogen/src/syntax/swift/SwiftCxxTypeHelper.ts, ~line 430):(e.g.
packages/react-native-nitro-test/nitrogen/generated/ios/NitroTest-Swift-Cxx-Bridge.hpp:1017on main.)noexcept⇒ immediatestd::terminate/ SIGABRT at that frame. The generated Swift caller (let __result = __wrappedFunction.call(...)) has no way to intervene, and a Swiftdo/catcharound the callback invocation never sees anything.Repro sketch
Any HybridObject holding a JS callback that native code invokes on its own initiative:
Native (e.g. a CarPlay controller, a media session, any surface that outlives reloads) keeps the config and calls
config.formatError!(params)when it needs a value. Trigger a JS reload, then have native invoke the callback → SIGABRT with a stack ending inFunc_*_Wrapper::callat thenoexceptline in the generated*-Swift-Cxx-Bridge.hpp.Observed during dev reloads on 0.35.9; verified unchanged in source on v0.36.5 and main.
Relevant log output
Backtrace from the real occurrence (react-native-audio-browser's CarPlay controller invoking its
formatNavigationErrorconfig callback after a reload; react-native 0.86.0, react-native-nitro-modules 0.35.9):Frame #9 is the generated
noexceptwrapper (callatAudioBrowser-Swift-Cxx-Bridge.hpp:1727is the_function->operator()line); the frames above it are the terminate path.Why consumers can't mitigate
Promiseexists, so the "rejected promise" path is never reached.noexceptguarantees terminate at that exact frame regardless.docs/docs/types/callbacks.md) state callbacks can be held long-term and called from any thread "safely and without any limitations".The only workaround is for consumers to null out every stored value-returning callback on runtime teardown themselves (via an RN lifecycle hook nitro doesn't provide) — easy to miss, and racy by nature.
Suggested fixes
Either (or both):
AsyncJSCallback::callwhen the dispatcher is gone — this would mirrorcallAndForget's graceful degradation, keep the generated wrapper'snoexcepthonest, and the caller already handles rejection. (Promise<T>is pure C++ —Promise<T>::rejected(...)needs no runtime — so this looks implementable as-is. Is there a reason a hard failure would be preferred here?)noexceptfrom the generatedFunc_*_Wrapper::callfor value-returning function types. Precedent: fix: Removenoexceptfrom a method that can throw #938 removednoexceptfrom the generated hybrid-objectget_std__shared_ptr_<HybridSpec>_bridge functions (the C++→Swift unwrap helpers) for the same reason ("Removenoexceptfrom a method that can throw"); the function-wrappercalllooks non-throwing (_function->operator()) but isn't. On its own this only converts the abort into an unhandleable C++ exception from Swift's perspective, so (1) is the fix that actually helps consumers.Affected versions
noexcepton the wrappercallshipped in v0.28.1 (#832, "Swift cannot catch C++ exceptions anyways"). Verified present in 0.35.9, v0.36.5, and current main. TheAsyncJSCallbackthrow predates it — before v0.28.1 the exception would still terminate once it unwound into Swift-compiled frames (Swift can't catch it); v0.28.1 just made the terminate deterministic at the wrapper frame. So the crash affects all versions in practice; the deterministic-at-callstack shape is ≥ 0.28.1.