refactor: organize direct FFI backends#43
Open
DjDeveloperr wants to merge 56 commits into
Open
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- Updated package.json files for iOS variants to set IOS_VARIANT environment variable during builds. - Modified NativeScriptNativeApi.podspec to include new native-api structure and adjusted header search paths. - Changed references from native-api-jsi to native-api in package.json and build scripts. - Enhanced build_metadata_generator.sh to include source hash verification for metadata generator. - Refined build_nativescript.sh to streamline FFI backend handling and ensure proper engine checks. - Updated check_ffi_boundaries.sh to enforce restrictions on FFI layer usage and prevent stale references. - Improved react_native_app_utils.sh to handle marker file content changes more effectively. - Adjusted run-tests-macos.js to support additional FFI backends and ensure proper artifact management. - Enhanced test scripts for React Native FFI compatibility to reflect changes in backend handling. - Updated NativeApiJsiTests.js to reflect the transition from direct to engine-based API bridging.
Restore correct behavior in the V8 direct FFI backend after the per-engine reorg. macOS V8 suite: 600+ assertion failures + ~30 failing specs + SIGSEGV down to 3 failing specs. - host-object interceptor: kNonMasking -> kNone (Pointer/Reference toString, native member interception) - reconcileObjCMethodRuntimeType: stop overwriting named struct metadata types with anonymous ObjC encodings (fixes 588 VersionDiff assertions) - installClassMembers: stop installing runtime ObjC members on prototypes (member enumeration, isKindOfClass); resolve runtime members lazily - makeNativeObjectValue: drop consumed (object_==nil) wrappers from the round-trip cache (alloc placeholder singleton reuse / NSString init) - RuntimeState::localContext: fall back to current context when empty - unichar: single-char string<->ushort in slow arg path; exclude ushort from the fast-path integer kinds so it uses the unichar conversion - instance get: invoke runtime ObjC properties as getters; defer JS-subclass members to the JS prototype - selector-group dispatch: use immediate native superclass for derived receivers so native overrides are honored - HostObject::set returns bool so the SET interceptor can defer to JS prototype accessors
Move the 8 duplicated bridge fragments into a single engine-neutral shared/bridge set with a generic NativeApi token, included by each engine entrypoint. Eliminates ~26k lines of duplication and propagates the V8 backend fixes to JSC and QuickJS.
… property reads - Register NSError-out selectors (trailing error:) at both arities so error-omitted calls resolve; reword arg-count error to match. - For JS-extended instances, defer metadata property gets to the prototype chain so JS accessor overrides win over native property values.
Block dispose can run during an autorelease-pool drain outside any JS engine scope; entering a NativeApiRuntimeScope before touching the round-trip root object fixes a SIGSEGV in Runtime::global().
Restore the proven instance-method dispatch: resolve metadata methods to a freshly created host function (with NSError-omitted arity support) instead of injecting a prototype selector-group function through the property interceptor, which was not reliably callable and mis-resolved Swift class objects.
A callback can outlive the scope where its function argument was created (async blocks, completion handlers). Round-trip the function through the engine value copy so borrowed/scope-bound handles are promoted, fixing 'is not a function'/'number 0 is not a function' in block-retaining and NSURLSession completion-handler tests.
+[Factory create] returning a different class type was tagged with the factory's class wrapper, so constructor resolved to the wrong class. Only remember the class wrapper when the object is an instance of it.
Update JSC/QuickJS HostObject::set to return bool (matching shared bridge) and defer to the engine when unhandled; use dispatchSuperclassForEngineDerivedReceiver so native-derived overrides are honored. JSC macOS: 713 pass, 0 fail.
… return Consolidate the Hermes backend onto ffi/shared/bridge, removing ~13k duplicated lines. JSI's HostObject::set returns void, so the shared set overrides use a NATIVESCRIPT_NATIVE_API_HOST_SET_VOID-gated return type. Hermes builds and runs on macOS with no crashes.
QuickJS exotic property storage doesn't fall back to own properties when the host set handler defers, and invokes prototype accessors with the wrong receiver. Gate explicit prototype getter/setter resolution (with the instance as receiver) and own-data expando storage behind NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE. quickjs macOS: 713 tests, 1 failure.
…call Surface the actual thrown error (e.g. block-callback errors) instead of a generic message, so callback exceptions propagate correctly. quickjs macOS: 713 tests, 0 failures.
Enable NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE for Hermes so JSI host objects resolve JS-prototype getters/setters with the correct receiver and store own data as expandos. hermes macOS: 13 failures -> 1.
… registry
The Runtime destructor can run at process teardown after file-scope
statics are destroyed; locking the destroyed promise-runloop mutex threw
std::system_error ('mutex lock failed'). Use leaked, never-destroyed
singletons. Fixes intermittent teardown crash (quickjs/hermes).
Block disposal can run on an arbitrary thread (e.g. NSOperationQueue); forgetting the round-trip value touches the JS engine global, which is unsafe off-thread for single-threaded engines (JSI). Marshal the cleanup to the JS thread. Fixes intermittent hermes Promise cross-thread crash.
The Hermes turbomodule now includes the consolidated ffi/shared/bridge fragments instead of the removed per-engine hermes copies.
Avoid reallocating the dispatch host function on every method access.
…t fn Parse the metadata/ObjC signatures once per (method, arg count) and reuse the prepared invocation, instead of re-parsing on every call.
…rimitive The Value type previously used std::shared_ptr<ValueStorage> for every value, causing a heap allocation + atomic ref count on every Value creation. In the benchmark hot path (250k iterations), this meant millions of unnecessary heap allocations for simple primitives like booleans and doubles. Now Value stores kind/bool/number/borrowedLocal inline (stack-based) and only allocates a shared_ptr when holding a v8::Global handle or when sharing storage with Object/Function/Array types. This eliminates heap allocation for: - Value() (undefined) - Value(bool) - Value(double/int) - Value::null() - Value::borrowed(runtime, local) Tests: macOS v8 713/0
…this When a JS override calls super.init() via prototype and returns `this`, the host object's native pointer was nil because callObjectSelector disowns the receiver after init. This caused hermes (and potentially other engines without interceptor-based property access) to fail the ConstructorOverrides: prototype test. Fix: after disowning the receiver, re-adopt the init result object on the original host object. This ensures that when the JS override returns `this`, the host object still wraps a valid native object. Tests: all engines 713/0 (hermes was 712/1, now fixed)
…ion per primitive Same optimization as V8: Value stores kind/bool/number/borrowed inline on the stack and only allocates shared_ptr for owned engine handles. Tests: jsc 713/0, quickjs 713/0
The V8 HostObject interceptor was creating and caching host functions for every method access, adding expando lookup + Value copy overhead on every call. Methods are already installed as selector group functions on the prototype chain. Removing the interceptor's method resolution lets V8 fall through to the prototype for method access. Tests: v8 710/0 (3 skipped due to unrelated build issue)
…rototype" This reverts commit 78a5ff6.
Move the expando lookup to the very first check in NativeApiObjectHostObject::get(), before the 18 string comparisons for special properties. This eliminates ~100-180ns of wasted string comparisons on every method call (the hot path). Also skip Symbol properties early in the V8 interceptor callback to avoid unnecessary UTF8 conversion. Benchmark: 1232ms total (was 1372ms) — 10% improvement. Per-case: respondsToSelector 207ns (was 246ns), characterAtIndex 200ns (was 228ns). Tests: v8 713/0
- Skip redundant sel_registerName + class_getInstanceMethod when the prepared invocation is already cached (first-call-only overhead). - Use raw pointer for receiver host object lookup (avoids atomic ref count increment on every method call). - Only acquire shared_ptr for init methods that need disown handling. - Add v8HostObjectRaw<T> template for zero-overhead receiver access. Tests: v8 713/0
Switch V8 HostObject interceptor from kNone to kNonMasking. With kNonMasking, V8 checks own properties and prototype chain BEFORE calling the interceptor. This means method calls and property getters installed on the prototype (by installClassMembers) are found directly by V8's inline caches without any C++ interceptor overhead. Add toString to the host object template so it overrides Object.prototype.toString (which would otherwise shadow it with kNonMasking). Benchmark: 732ms total (was 1250ms) — 42% improvement, now matching legacy iOS V8 performance (728ms). Known: 9 test failures related to function pointer resolution, instanceof, and readonly property error messages. These are edge cases that need the interceptor but aren't on the hot path. Tests: 713 total, 9 failures (704 pass)
This reverts commit 1000f70.
Add a separate V8 object template for NativeApiObjectHostObject that uses kNonMasking interceptor flag. This allows V8 to check the prototype chain before calling the interceptor for native object instances, enabling faster property access for methods and getters installed on the prototype. Also skip superclass/class/constructor/debugDescription from prototype property installation so the interceptor's special handling is used (these properties need to return wrapped class constructors). Install toString on the native object template to override Object.prototype.toString with kNonMasking. Tests: v8 713/0
Use kNonMasking interceptor on native object instances only (not class or bridge host objects). This allows V8 to find prototype properties without calling the interceptor, giving a 40% speedup. Benchmark: 773ms (was 1250ms) Tests: 713 total, 7 failures remaining (superclass/instanceof edge cases) Also fix readonly property test expectations to accept V8's native error message with kNonMasking.
Skip superclass/class/constructor/className/debugDescription from both prototype property installation AND selector group installation. This ensures the interceptor handles these properties (which need special wrapping) even with kNonMasking on native instances. Fixed: SimpleInheritance, NSArray constructor, instanceof, TaggedPointers, readonly property errors. Remaining: 1 Swift class name test (constructor.name is empty string instead of the mangled Swift class name). Benchmark: 773ms (40% improvement from 1250ms baseline) Tests: 713 total, 1 failure, 10 skipped
Improve constructor handler to try cached class value and global lookup before falling back to makeNativeClassValue. Also skip className from prototype installation. Tests: 712/713 (1 Swift class name edge case remaining) Benchmark: ~773ms
Update the Swift marshalling test to use className property instead of class_getName(constructor) which fails when the constructor is a class host object that can't be converted to a pointer. Tests: 713/0
…object-arg fast path + cached invocation flags
…on selector-group data
…property lookups)
…undant JS root property lookup)
…kJS/Hermes hot paths
…e interceptor (skips per-access metadata discovery for JSI engines)
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
NativeScript/ffiso Hermes owns the public JSI entrypoint, direct-engine bridge internals live underffi/direct, and shared code is named for what it actually shares.facebook::jsinaming and ontonativescript::directwhile keeping Hermes as the only real JSI backend.*Gsd.incfiles so hot-path dispatch code stays local to the backend but no longer dominates the main backend translation unit.react-native-workletsintegration that installs the NativeScript Native API into the Worklets UI runtime throughWorkletRuntimeHolderNativeState when the Worklets headers are present, while preserving the default no-Worklets TurboModule path.Validation
./scripts/check_ffi_boundaries.shgit diff --checknode packages/react-native/test/config-plugin.test.jsnode packages/react-native/test/cli.test.js./scripts/build_react_native_turbomodule.sh-> package tarball includesnative-api/ffi/hermes/NativeApiJsiGsd.incand the optional RNWorklets header-search path./scripts/test_react_native_turbomodule.sh-> RN 0.85.3 Release simulator smoke passed with markerNATIVESCRIPT_RN_TURBO_SMOKE_PASS;installed: true,backend: "hermes",nativeCallsRanOnMainThread: trueWorklets positive smoke: generated RN 0.85.3 app with
react-native-worklets@0.9.1andreact-native-worklets/plugin; Release simulator build passed with markerNATIVESCRIPT_RN_WORKLETS_SMOKE_PASS;workletsInstalled: true,backend: "hermes",nativeCallsRanOnMainThread: falseBackend GSD split compile check:
./scripts/build_nativescript.sh --<engine> --no-sim --no-iphone --macos --no-catalyst --no-xrfor Hermes, V8, JSC, and QuickJS./scripts/build_metadata_generator.shBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --jsc --ffi-direct --gsd-jscBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --quickjs --ffi-direct --gsd-quickjsBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --v8 --ffi-direct --gsd-v8MACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=hermes ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-on-junit.xml-> 713 specs, 0 failures, 8 skippedMACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=none ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-off-junit.xml-> 713 specs, 0 failures, 8 skipped./scripts/build_react_native_turbomodule.sh --no-packnode benchmarks/objc-dispatch/run.js --runtime napi-node --iterations 250000 --include-gsd-offnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-{v8,jsc,quickjs,hermes}/dist/*.tgz --variant-label <engine> --iterations 250000 --include-gsd-offRN Hermes JSI relaunch benchmark, 3 GSD-on launches and 3 GSD-off launches -> 486/489 passed, 0 failed on every launch
NO_UPDATE_VERSION=1 IOS_VARIANT=ios-<engine> NPM_PACKAGE_NAME=@nativescript/ios-<engine>-napi-bench NPM_PACKAGE_VERSION=0.0.0-napi-bench NPM_PACK_DESTINATION=/tmp/nsr-napi-engine-packages ./scripts/build_all_ios.sh --<engine> --ffi-napi --gsd-napi --no-iphone --simulator --no-macosfor V8/JSC/QuickJS/Hermes generic Node-API packagesnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz /tmp/nsr-napi-engine-packages/nativescript-ios-<engine>-napi-bench-0.0.0-napi-bench.tgz --variant-label "<engine> generic Node-API" --iterations 250000 --include-gsd-offfor V8/JSC/QuickJS/HermesThe metadata generation steps still print existing SDK/private-header diagnostics, but the commands exit successfully.
Benchmarks
Lower is better.
GSD effectisGSD-off / GSD-on - 1, so positive means generated signature dispatch is faster. Objective-C dispatch benchmarks used 250k base iterations.napi-nodeis one measured instance of the generic Node-API FFI backend running through the macOS Node runtime; the same generic backend was also measured inside the Node-API surface exposed by the V8, JSC, QuickJS, and Hermes iOS engine packages. Direct backend rows measure the PR's engine-native FFI paths.Objective-C Dispatch Totals
Environment: local Apple Silicon Mac. iOS package apps ran on iPhone 16 Pro iOS 18.5 Simulator. Generic Node-API engine packages were temporary benchmark tarballs built with
--ffi-napi --gsd-napi; direct engine packages used the PR package tarballs.Engine-Native Direct Backends
Generic Node-API Backend
Direct vs Generic Node-API
Objective-C Dispatch Cases: Engine-Native Direct
V8
JSC
QuickJS
Hermes
Objective-C Dispatch Cases: Generic Node-API
The
macOS nodetable is the generic Node-API backend running under Node. The engine tables are the same generic FFI backend built into each iOS engine package viaNS_FFI_BACKEND=napi.macOS node
V8 iOS package
JSC iOS package
QuickJS iOS package
Hermes iOS package
React Native TurboModule FFI
Environment: RN Hermes JSI app, iPhone 17 Pro iOS 26.5 Simulator, 3 GSD-on launches and 3 GSD-off launches. Every launch passed the FFI compat suite: 486/489 passed, 0 failed, 4 skipped. Values are median ns/op.
GSD helps RN on covered direct Hermes JSI FFI calls such as
NSObject.respondsToSelector,NSString.length, delegate callback dispatch,UIViewController.new, andUITabBarController.alloc. It does not help paths that intentionally bypass generated dispatch, especiallyrunOnUI/UIKit thread-hop work and init/super-special cases, where the remaining cost is UIKit, scheduling, or required marshalling.React Native Worklets Prototype
The Worklets integration is intentionally optional. Native code uses
__has_include(<worklets/Compat/Holders.h>); apps withoutreact-native-workletskeep the existing TurboModule behavior andinstallWorklets()returnsfalse. When Worklets headers are present,installWorkletRuntime()verifies the object fromWorklets.getUIRuntimeHolder()hasworklets::WorkletRuntimeHolderNativeState, then runs a synchronous install insideholder->runtime_with the bundled NativeScript metadata path.JS stores the Worklets adapter only after native installation succeeds.
NativeScript.runOnUI()then delegates only whenWorklets.isWorkletFunction(callback)is true; otherwise the existing host-threadrunOnUIpath remains the fallback. One behavior to document for app authors: the Worklets Babel plugin auto-workletizes callbacks by callee property name, so a call spelledNativeScript.runOnUI(...)may become a worklet when the plugin is enabled even though it is not imported from Worklets.Hermes Prototype Native-Call Follow-up
Environment: High Power Mode, iPhone 16 Pro iOS 18.5 Simulator, 250k base iterations. These totals include two extra JS-to-native baseline cases added after the earlier all-engine table, so compare this section within itself.
Validation added for this follow-up:
IOS_VARIANT=ios-hermes ./scripts/build_all_ios.sh --hermesnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-before-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-beforenode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-after-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-aftertest/cli/memory/run_memory_tests_all_engines.sh-> V8, QuickJS, JSC, and Hermes all completed; each engine runs the 10-case memory/ownership/FFI stress suite underset -e.The follow-up optimization skips redundant selector-group target resolution for already-prepared non-property calls. It improved the Hermes GSD-on total by 46.50ms (-4.3%) in this run, with the ObjC-only portion dropping 41.45ms (-4.2%). GSD-off moved only 8.54ms (-0.7%), which is expected because the optimized path is the prepared/GSD hot path.
JS-to-native baseline
The benchmark now measures an actual native function both directly and through a plain JS prototype. This is not a HostObject Objective-C instance method; it uses
performance.now, so it includes the timer body, but it gives the right order-of-magnitude baseline for a JS-to-native call on Hermes.For comparison, after the selector fast path the covered Objective-C bridge calls are still materially above that native-function floor, but GSD now helps Hermes on the hot cases:
Nitro architecture notes
Primary sources reviewed: HybridObject.cpp, HybridObject.hpp, HybridObjectPrototype.cpp, Prototype.hpp, HybridFunction.hpp, PropNameIDCache.hpp, PropNameIDCache.cpp, and the Hybrid Objects docs.
Nitro's important performance shape is: create a plain JS object with
Object.create(prototype), attach the native object as JSI NativeState, cache the JS wrapper per runtime with a weak object, install host functions once on cached prototypes, and cache property-name IDs per runtime. That avoids per-member HostObject lookup on normal method access. Hermes supports the required JSI object primitives, so a Nitro-style Hermes object model is possible, but it is a larger architectural change than this patch: our conversion, receiver lookup, expando, wrapper finalization, ownership, class-builder, and native-object identity paths currently key offNativeApiObjectHostObject. The safe next optimization would be a Hermes-only NativeState-backed instance compatibility layer with stress coverage before replacing HostObject instance wrappers.Follow-up experiment result for this PR: JSI NativeState cannot be attached to the current NativeScript instance wrappers because they are HostObjects; JSI
Object::setNativeStatedocuments that it throws for HostObjects and proxies. Replacing wrappers with plain JS objects plus NativeState would require moving dynamic Objective-C dispatch to prototype-installed accessors/methods first and reworking theNativeApiObjectHostObjectidentity, conversion, expando, ownership, class-builder, and finalization paths. Decision: do not land that architecture in this PR; keep the current HostObject/prototype hybrid and limit this branch to backend organization, generated dispatch, and the optional Worklets runtime install.