Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
107 changes: 107 additions & 0 deletions example/src/reproducers/Issue433ExceptionPtr.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>; 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 (
<View style={styles.container}>
<Pressable
style={styles.button}
onPress={() => setRunning((r) => !r)}
testID="issue433-toggle"
>
<Text style={styles.buttonText}>{running ? 'Stop' : 'Start'}</Text>
</Pressable>
<Text style={styles.counter} testID="issue433-passed">
rejected as expected: {passed}
</Text>
<Text style={styles.counter} testID="issue433-failed">
unexpected: {failed}
</Text>
<Text style={styles.message}>last: {lastMessage}</Text>
</View>
);
}

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' },
});
86 changes: 86 additions & 0 deletions ios/LibcxxExceptionPtrBackport.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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 <Foundation/Foundation.h>

#include <algorithm>
#include <cstdlib>
#include <cxxabi.h>
#include <exception>
#include <typeinfo>

#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; entries are keyed by object because that constructor may itself call `std::make_exception_ptr`.
// 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.
takePendingException(object, &stale);
if (pendingExceptionCount == kMaxPendingExceptions) {
// Only entries left by throwing constructors can be this old.
removePendingException(0);
Comment thread
mfazekas marked this conversation as resolved.
}
pendingExceptions[pendingExceptionCount++] = {object, type, destructor};
return nullptr;
}
} // namespace __cxxabiv1

namespace std {
exception_ptr exception_ptr::__from_native_exception_pointer(void* object) noexcept {
PendingException pending;
if (!takePendingException(object, &pending)) {
std::abort();
}
try {
__cxxabiv1::__cxa_throw(object, pending.type, pending.destructor);
} catch (...) {
return std::current_exception();
}
}
} // namespace std

#endif
33 changes: 33 additions & 0 deletions scripts/__tests__/libcxx-backport-removal.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
}
});
Loading