Repository navigation
fix(ios): launch on iOS 17 and older when built with Xcode 27 #434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mfazekas
wants to merge
4
commits into
main
Choose a base branch
from
fix/ios-xcode27-exception-ptr
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+236
−0
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
8c67166
fix(ios): launch on iOS 17 and older when built with Xcode 27
mfazekas 317a1f8
fix(ios): track pending exceptions per object in the libc++ backport
mfazekas 620a6a0
fix(ios): keep backport bookkeeping valid during thread-local teardown
mfazekas 892d9d2
chore(ios): fail a test once the libc++ backport can be removed
mfazekas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' }, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | ||
| ); | ||
| } | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.