Skip to content

Errors thrown in JS callbacks are not catchable from Swift #1481

Description

@puckey

(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

  1. 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).
  2. 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]] {
  ...
  throw std::runtime_error("Failed to call " + typeName + " - the Dispatcher has already been destroyed!");
}
  1. But the Swift-facing wrapper nitrogen emits for that function type is noexcept (packages/nitrogen/src/syntax/swift/SwiftCxxTypeHelper.ts, ~line 430):
inline std::shared_ptr<Promise<double>> call() const noexcept {
  auto __result = _function->operator()();   // ← AsyncJSCallback::call throws here
  return __result;
}

(e.g. packages/react-native-nitro-test/nitrogen/generated/ios/NitroTest-Swift-Cxx-Bridge.hpp:1017 on main.)

  1. 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:

interface Config {
  formatError?: (params: ErrorParams) => Promise<FormattedError | null>
}

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

  1. 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?)
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    nitrogenIssue is related to the code-generator "Nitrogen"swiftIssue regarding the Swift part of Nitro/Nitrogen

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions