From 8c67166a146ca505e6de138f19ec57fb12280fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 28 Oct 2026 21:06:05 +0100 Subject: [PATCH 1/4] fix(ios): launch on iOS 17 and older when built with Xcode 27 Swift-compiled Nitro code references two iOS 18-only libc++ symbols when built with Xcode 27, so dyld aborts at launch on iOS < 18 (#433, margelo/nitro#1652). Define fallbacks for deployment targets below 18 so the references resolve inside the app. Dynamic frameworks still need a Podfile setting, documented in the README. --- README.md | 10 ++ .../src/reproducers/Issue433ExceptionPtr.tsx | 107 ++++++++++++++++++ ios/LibcxxExceptionPtrBackport.mm | 58 ++++++++++ 3 files changed, 175 insertions(+) create mode 100644 example/src/reproducers/Issue433ExceptionPtr.tsx create mode 100644 ios/LibcxxExceptionPtrBackport.mm diff --git a/README.md b/README.md index e237e224..0f4c40d0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ ## Known Issues - Error messages on Android in React Native 0.78-0.79 may not be descriptive, this is a [known issue](https://github.com/mrousavy/nitro/issues/382) in React Native and is fixed in RN 0.80 +- Apps built with Xcode 27 using `use_frameworks! :linkage => :dynamic` crash at launch on iOS 17 and older with `Symbol not found: __ZNSt13exception_ptr31__from_native_exception_pointerEPv` ([margelo/nitro#1652](https://github.com/margelo/nitro/issues/1652)). Static linkage, the default, is handled by this library. For dynamic frameworks, add this to your Podfile's `post_install` until Nitro ships the fix: + + ```ruby + installer.pods_project.targets.each do |target| + next unless target.name == 'NitroModules' + target.build_configurations.each do |config| + config.build_settings['SWIFT_ENABLE_EXPLICIT_MODULES'] = 'NO' + end + end + ``` ## Installation diff --git a/example/src/reproducers/Issue433ExceptionPtr.tsx b/example/src/reproducers/Issue433ExceptionPtr.tsx new file mode 100644 index 00000000..f468c4bd --- /dev/null +++ b/example/src/reproducers/Issue433ExceptionPtr.tsx @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { RiveFileFactory } from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Discussion #433 / margelo/nitro#1652: with Xcode 27, Swift-compiled Nitro code turns errors into + * C++ exceptions through `std::make_exception_ptr`, which references libc++ symbols that only exist + * on iOS 18+. Unfixed builds abort at launch on iOS < 18. + * + * Start rejects native promises in a loop, which drives Swift errors through `Error.toCpp()`. + * Expected: every call rejects with the native message and the app keeps running. + */ + +const BATCH = 25; + +const cases: Array<{ run: () => Promise; message: string }> = [ + { + run: () => RiveFileFactory.fromResource('issue433_missing', undefined), + message: 'Could not find Rive file: issue433_missing.riv', + }, + { + run: () => RiveFileFactory.fromFileURL('https://example.com/issue433.riv'), + message: 'fromFileURL: URL must be a file URL', + }, +]; + +export default function Issue433ExceptionPtr() { + const [running, setRunning] = useState(false); + const [passed, setPassed] = useState(0); + const [failed, setFailed] = useState(0); + const [lastMessage, setLastMessage] = useState(''); + const runningRef = useRef(false); + + const runBatch = useCallback(async () => { + for (let i = 0; i < BATCH; i++) { + const testCase = cases[i % cases.length]!; + try { + await testCase.run(); + setFailed((n) => n + 1); + setLastMessage('resolved unexpectedly'); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setLastMessage(message); + if (message.includes(testCase.message)) { + setPassed((n) => n + 1); + } else { + setFailed((n) => n + 1); + } + } + } + }, []); + + useEffect(() => { + if (!running) return; + runningRef.current = true; + let cancelled = false; + (async () => { + while (!cancelled && runningRef.current) { + await runBatch(); + await new Promise((r) => setTimeout(r, 50)); + } + })(); + return () => { + cancelled = true; + runningRef.current = false; + }; + }, [running, runBatch]); + + return ( + + setRunning((r) => !r)} + testID="issue433-toggle" + > + {running ? 'Stop' : 'Start'} + + + rejected as expected: {passed} + + + unexpected: {failed} + + last: {lastMessage} + + ); +} + +Issue433ExceptionPtr.metadata = { + name: 'Issue #433 exception_ptr', + description: + 'Rejects native promises through Swift Error.toCpp() (Xcode 27, iOS < 18)', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { flex: 1, padding: 24, gap: 12 }, + button: { + backgroundColor: '#007AFF', + padding: 12, + borderRadius: 8, + alignItems: 'center', + }, + buttonText: { color: 'white', fontSize: 16, fontWeight: '600' }, + counter: { fontSize: 18 }, + message: { fontSize: 14, color: '#555' }, +}); diff --git a/ios/LibcxxExceptionPtrBackport.mm b/ios/LibcxxExceptionPtrBackport.mm new file mode 100644 index 00000000..58ef2632 --- /dev/null +++ b/ios/LibcxxExceptionPtrBackport.mm @@ -0,0 +1,58 @@ +// With Xcode 27, Swift-compiled Nitro code references these iOS 18+ libc++ symbols even for older deployment +// targets, so dyld aborts at launch on iOS < 18 (#433, margelo/nitro#1652). These fallbacks build the exception +// the way libc++ does for iOS < 18: throw it, then capture it with `std::current_exception`. +#import + +#include +#include +#include +#include + +#if defined(_LIBCPP_AVAILABILITY_HAS_INIT_PRIMARY_EXCEPTION) && \ + defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) && \ + __ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__ < 180000 + +// The class makes `-ObjC` load this object, so its definitions win over libc++ whatever the link order. +@interface RNRiveLibcxxExceptionPtrBackport : NSObject +@end +@implementation RNRiveLibcxxExceptionPtrBackport +@end + +namespace { + +struct PendingException { + void* object; + std::type_info* type; + void (*destructor)(void*); +}; + +// libc++ calls `__cxa_init_primary_exception`, constructs the object, then calls +// `__from_native_exception_pointer` on the same thread. +thread_local PendingException pendingException{}; + +} // namespace + +namespace __cxxabiv1 { +extern "C" __cxa_exception* +__cxa_init_primary_exception(void* object, std::type_info* type, void (*destructor)(void*)) noexcept { + pendingException = {object, type, destructor}; + return nullptr; +} +} // namespace __cxxabiv1 + +namespace std { +exception_ptr exception_ptr::__from_native_exception_pointer(void* object) noexcept { + PendingException pending = pendingException; + pendingException = {}; + if (pending.object != object) { + std::abort(); + } + try { + __cxxabiv1::__cxa_throw(object, pending.type, pending.destructor); + } catch (...) { + return std::current_exception(); + } +} +} // namespace std + +#endif From 317a1f865467a4950597d765c7907d109028e09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:48:16 +0100 Subject: [PATCH 2/4] fix(ios): track pending exceptions per object in the libc++ backport --- ios/LibcxxExceptionPtrBackport.mm | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ios/LibcxxExceptionPtrBackport.mm b/ios/LibcxxExceptionPtrBackport.mm index 58ef2632..7ddfa667 100644 --- a/ios/LibcxxExceptionPtrBackport.mm +++ b/ios/LibcxxExceptionPtrBackport.mm @@ -3,10 +3,12 @@ // the way libc++ does for iOS < 18: throw it, then capture it with `std::current_exception`. #import +#include #include #include #include #include +#include #if defined(_LIBCPP_AVAILABILITY_HAS_INIT_PRIMARY_EXCEPTION) && \ defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) && \ @@ -26,27 +28,33 @@ @implementation RNRiveLibcxxExceptionPtrBackport void (*destructor)(void*); }; -// libc++ calls `__cxa_init_primary_exception`, constructs the object, then calls -// `__from_native_exception_pointer` on the same thread. -thread_local PendingException pendingException{}; +// libc++ calls `__cxa_init_primary_exception`, constructs the object, then calls `__from_native_exception_pointer` on +// the same thread; entries are keyed by object because that constructor may itself call `std::make_exception_ptr`. +thread_local std::vector pendingExceptions; } // namespace namespace __cxxabiv1 { extern "C" __cxa_exception* __cxa_init_primary_exception(void* object, std::type_info* type, void (*destructor)(void*)) noexcept { - pendingException = {object, type, destructor}; + // A reused address means the earlier exception's constructor threw and libc++ freed it. + pendingExceptions.erase(std::remove_if(pendingExceptions.begin(), pendingExceptions.end(), + [object](const PendingException& p) { return p.object == object; }), + pendingExceptions.end()); + pendingExceptions.push_back({object, type, destructor}); return nullptr; } } // namespace __cxxabiv1 namespace std { exception_ptr exception_ptr::__from_native_exception_pointer(void* object) noexcept { - PendingException pending = pendingException; - pendingException = {}; - if (pending.object != object) { + auto it = std::find_if(pendingExceptions.rbegin(), pendingExceptions.rend(), + [object](const PendingException& p) { return p.object == object; }); + if (it == pendingExceptions.rend()) { std::abort(); } + PendingException pending = *it; + pendingExceptions.erase(std::next(it).base()); try { __cxxabiv1::__cxa_throw(object, pending.type, pending.destructor); } catch (...) { From 620a6a04adff3c090454b2fadfd130d5f83a4993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:56:20 +0100 Subject: [PATCH 3/4] fix(ios): keep backport bookkeeping valid during thread-local teardown --- ios/LibcxxExceptionPtrBackport.mm | 40 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/ios/LibcxxExceptionPtrBackport.mm b/ios/LibcxxExceptionPtrBackport.mm index 7ddfa667..c5beb712 100644 --- a/ios/LibcxxExceptionPtrBackport.mm +++ b/ios/LibcxxExceptionPtrBackport.mm @@ -8,7 +8,6 @@ #include #include #include -#include #if defined(_LIBCPP_AVAILABILITY_HAS_INIT_PRIMARY_EXCEPTION) && \ defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) && \ @@ -30,31 +29,50 @@ @implementation RNRiveLibcxxExceptionPtrBackport // libc++ calls `__cxa_init_primary_exception`, constructs the object, then calls `__from_native_exception_pointer` on // the same thread; entries are keyed by object because that constructor may itself call `std::make_exception_ptr`. -thread_local std::vector pendingExceptions; +// Plain arrays have no thread_local destructor, so this stays valid while other thread_locals are torn down. +constexpr size_t kMaxPendingExceptions = 16; +thread_local PendingException pendingExceptions[kMaxPendingExceptions]; +thread_local size_t pendingExceptionCount = 0; + +void removePendingException(size_t index) { + std::copy(pendingExceptions + index + 1, pendingExceptions + pendingExceptionCount, pendingExceptions + index); + --pendingExceptionCount; +} + +bool takePendingException(void* object, PendingException* out) { + for (size_t i = pendingExceptionCount; i-- > 0;) { + if (pendingExceptions[i].object == object) { + *out = pendingExceptions[i]; + removePendingException(i); + return true; + } + } + return false; +} } // namespace namespace __cxxabiv1 { extern "C" __cxa_exception* __cxa_init_primary_exception(void* object, std::type_info* type, void (*destructor)(void*)) noexcept { + PendingException stale; // A reused address means the earlier exception's constructor threw and libc++ freed it. - pendingExceptions.erase(std::remove_if(pendingExceptions.begin(), pendingExceptions.end(), - [object](const PendingException& p) { return p.object == object; }), - pendingExceptions.end()); - pendingExceptions.push_back({object, type, destructor}); + takePendingException(object, &stale); + if (pendingExceptionCount == kMaxPendingExceptions) { + // Only entries left by throwing constructors can be this old. + removePendingException(0); + } + pendingExceptions[pendingExceptionCount++] = {object, type, destructor}; return nullptr; } } // namespace __cxxabiv1 namespace std { exception_ptr exception_ptr::__from_native_exception_pointer(void* object) noexcept { - auto it = std::find_if(pendingExceptions.rbegin(), pendingExceptions.rend(), - [object](const PendingException& p) { return p.object == object; }); - if (it == pendingExceptions.rend()) { + PendingException pending; + if (!takePendingException(object, &pending)) { std::abort(); } - PendingException pending = *it; - pendingExceptions.erase(std::next(it).base()); try { __cxxabiv1::__cxa_throw(object, pending.type, pending.destructor); } catch (...) { From 892d9d274820bb3cb4174a0e71f7c12e6341843d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 15:37:08 +0100 Subject: [PATCH 4/4] chore(ios): fail a test once the libc++ backport can be removed The backport is only needed while the Nitro peer range allows releases without margelo/nitro#1824. --- ios/LibcxxExceptionPtrBackport.mm | 2 ++ .../__tests__/libcxx-backport-removal.test.ts | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 scripts/__tests__/libcxx-backport-removal.test.ts diff --git a/ios/LibcxxExceptionPtrBackport.mm b/ios/LibcxxExceptionPtrBackport.mm index c5beb712..6d9af243 100644 --- a/ios/LibcxxExceptionPtrBackport.mm +++ b/ios/LibcxxExceptionPtrBackport.mm @@ -1,6 +1,8 @@ // With Xcode 27, Swift-compiled Nitro code references these iOS 18+ libc++ symbols even for older deployment // targets, so dyld aborts at launch on iOS < 18 (#433, margelo/nitro#1652). These fallbacks build the exception // the way libc++ does for iOS < 18: throw it, then capture it with `std::current_exception`. +// Delete this file once the react-native-nitro-modules peer range starts at a release with margelo/nitro#1824 +// (expected 0.37.2); scripts/__tests__/libcxx-backport-removal.test.ts fails as a reminder. #import #include diff --git a/scripts/__tests__/libcxx-backport-removal.test.ts b/scripts/__tests__/libcxx-backport-removal.test.ts new file mode 100644 index 00000000..447c1d09 --- /dev/null +++ b/scripts/__tests__/libcxx-backport-removal.test.ts @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; + +const ROOT = resolve(import.meta.dirname, '../..'); +const BACKPORT = resolve(ROOT, 'ios/LibcxxExceptionPtrBackport.mm'); +// First Nitro release expected to ship margelo/nitro#1824. +const FIXED_NITRO = [0, 37, 2]; + +function peerMinimum(range: string): number[] { + const match = range.match(/>=\s*(\d+)\.(\d+)\.(\d+)/); + if (!match) throw new Error(`Unexpected Nitro peer range: ${range}`); + return match.slice(1).map(Number); +} + +function isAtLeast(version: number[], minimum: number[]): boolean { + for (let i = 0; i < minimum.length; i++) { + if (version[i] !== minimum[i]) return version[i]! > minimum[i]!; + } + return true; +} + +test('libc++ exception_ptr backport is removed once Nitro ships the fix', () => { + const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')); + const minimum = peerMinimum( + pkg.peerDependencies['react-native-nitro-modules'] + ); + if (isAtLeast(minimum, FIXED_NITRO) && existsSync(BACKPORT)) { + throw new Error( + 'The Nitro peer minimum includes margelo/nitro#1824: delete ios/LibcxxExceptionPtrBackport.mm, its README note and this test' + ); + } +});