Skip to content

Commit 87f54e2

Browse files
kraenhansenclaude
andcommitted
Implement hermes_napi_host for async work and thread-safe functions (#398)
* Implement hermes_napi_host and pass it to hermes_napi_create_env Provide the Phase 3 host integration for Hermes' first-party Node-API: - New HermesNapiHost.{hpp,cpp}: a mirror of the hermes_napi_host struct (pinned to HERMES_GIT_SHA) and a HostContext per React Native runtime, backed by a process-global 4-thread worker pool (post_work / cancel_work) and the runtime's CallInvoker behind a type-erased JS dispatcher (post_task and work completions). fatal_exception stringifies the error, logs and aborts; uv_loop and ref_loop/unref_loop stay null by design. Contexts are retained for the process lifetime because the env reads the struct during Runtime teardown after env cleanup hooks have run. - CxxNodeApiHostModule passes the host at env creation - before the addon's init runs, fixing init-time async work - and drops setCallInvoker. - Delete the RuntimeNodeApiAsync overrides: async work falls through to Hermes' implementation, so execute now runs on a worker thread instead of the JS thread, and thread-safe functions work for the first time. - tests/async: execute/complete thread-identity assertions, a gated blocking execute (deadlock-proof that execute is off the JS thread) and a deterministic cancel-of-running-work case. - tests/threadsafe-function: port of Node's test_threadsafe_function (pthread shim for uv threads, upstream assertions restored) plus JS-thread and never-inline supplements; re-enable the async_work_thread_safe_function example (its SIGABRT was the null host). - packages/host/tests: Catch2 suite exercising the worker pool, cancellation atomicity, post_task ordering/reentrancy and the teardown drop path on plain Linux, with a host-cpp-tests CI job mirroring weak-node-api-tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF * Trigger CI for the label-gated device lanes The Check workflow only reacts to opened/synchronize/reopened, so the Apple and Android labels added to the PR need a synchronize event to be seen by the job conditions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF * Trigger CI with the weak-node-api and host labels applied Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF * Address review: truthful teardown outcomes, duplicate-queue drop - JsDispatcher now reports acceptance and WorkItem holds its HostContext strongly: the weak_ptr could never expire (contexts are retained for the process lifetime), so the pool's drop branches were dead code and napi_cancel_async_work could claim success for a completion the dispatcher was about to drop. cancel_work now returns the dispatcher's verdict, and workerMain/postTask log drops where they actually happen. - WorkerPool::enqueue drops a double-queued (loopData, workData) instead of enqueueing it: a second entry meant two completions for one napi_async_work and a use-after-free once the addon deletes the work inside the first. Covered by a new Catch2 test; the saturation helper now uses distinct jobs per worker so it does not trip the detection. - Delete HostContext copy/move: host_.data points at this. - Justify the CallInvoker-liveness assumption at the dispatcher site (RuntimeSchedulerCallInvoker holds a weak RuntimeScheduler owned together with the runtime, so accepted work cannot outlive it) and correct the WorkItem comment: the (loopData, workData) pair separates runtimes/reloads, not envs. - Rework the Catch2 teardown test to model an expired CallInvoker (the state production reaches) instead of dropping the last context ref (which it never does), and cover the rejected post_task path. - Scope the 30s mocha timeout to the threadsafe-function suite so a genuine deadlock elsewhere still fails fast; add a TODO on fatal_exception about routing through RN error handling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bfb433d commit 87f54e2

26 files changed

Lines changed: 1913 additions & 252 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-native-node-api": minor
3+
---
4+
5+
Provide a `hermes_napi_host` implementation to the Hermes Node-API environments. This enables thread-safe functions (`napi_create_threadsafe_function` and friends) and moves `napi_async_work` execution onto a worker pool — previously the `execute` callback ran on the JavaScript thread, blocking it for the duration of the work. The host is also in place before an addon's module init runs, so async work and thread-safe functions can now be created during initialization.

.github/workflows/check.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,42 @@ jobs:
143143
cmake --build build
144144
ctest --test-dir build --output-on-failure
145145
working-directory: packages/weak-node-api
146+
host-cpp-tests:
147+
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'host')
148+
strategy:
149+
fail-fast: false
150+
matrix:
151+
runner:
152+
- ubuntu-latest
153+
- windows-latest
154+
- macos-latest
155+
runs-on: ${{ matrix.runner }}
156+
name: Host C++ tests (${{ matrix.runner }})
157+
steps:
158+
- uses: actions/checkout@v4
159+
- uses: pnpm/action-setup@v4
160+
- uses: actions/setup-node@v6
161+
with:
162+
node-version: lts/krypton
163+
cache: pnpm
164+
- name: Setup cpp tools
165+
uses: aminya/setup-cpp@v1
166+
with:
167+
clang-format: true
168+
- name: ccache
169+
uses: hendrikmuhs/ccache-action@v1.2
170+
with:
171+
key: ${{ github.job }}-${{ runner.os }}
172+
- run: pnpm install
173+
- run: pnpm run build
174+
- name: Prepare weak-node-api
175+
run: pnpm --filter weak-node-api run prebuild:prepare
176+
- name: Build and run react-native-node-api host C++ tests
177+
run: |
178+
cmake -S tests -B tests/build
179+
cmake --build tests/build
180+
ctest --test-dir tests/build --output-on-failure
181+
working-directory: packages/host
146182
test-ios:
147183
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'Apple 🍎')
148184
name: Test app (iOS)

apps/test-app/App.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ function loadTests({
3838
)) {
3939
describe(suiteName, () => {
4040
for (const [exampleName, requireExample] of Object.entries(examples)) {
41-
it(exampleName, async () => {
41+
it(exampleName, async function () {
42+
if (exampleName === "threadsafe-function") {
43+
// The ported Node.js suite marshals thousands of values across
44+
// threads; every other example keeps the default timeout so a
45+
// genuine deadlock still fails fast.
46+
this.timeout(30_000);
47+
}
4248
const test = requireExample();
4349
if (test instanceof Function) {
4450
const result = test();

docs/HOW-IT-WORKS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Hermes implements both halves of Node-API: the engine-specific functions (see [j
5454
- `ref_loop` / `unref_loop` — keep the event loop alive while a thread-safe function is referenced, modelling libuv's "ref" semantics.
5555
- `fatal_exception` and, for embedders that have one, a libuv loop pointer for `napi_get_uv_event_loop`.
5656

57-
`react-native-node-api` provides that struct, backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a worker pool for the rest.
57+
`react-native-node-api` provides that struct (see `packages/host/cpp/HermesNapiHost.cpp`), backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a process-global worker pool (four threads, like libuv's default) for the rest. `ref_loop` / `unref_loop` and the libuv loop pointer are deliberately left null: React Native's JavaScript thread has no ref-counted event-loop lifetime to model, so thread-safe function ref/unref are tracked but inert, and `napi_get_uv_event_loop` returns `napi_generic_failure` as upstream documents for hosts without libuv.
5858

5959
## `my-app` regain control and call `add`
6060

eslint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export default tseslint.config(
6262
},
6363
globals: {
6464
...globals.commonjs,
65+
// Timers provided by React Native's runtime, where these files run.
66+
setTimeout: "readonly",
67+
setImmediate: "readonly",
6568
},
6669
},
6770
rules: {

packages/host/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ android/build/
1818

1919
# Generated via `npm run generate-weak-node-api-injector`
2020
/cpp/WeakNodeApiInjector.cpp
21+
22+
# C++ test build artifacts (see `npm run test:configure`)
23+
/tests/build/

packages/host/android/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ add_library(node-api-host SHARED
1414
../cpp/WeakNodeApiInjector.cpp
1515
../cpp/RuntimeNodeApi.cpp
1616
../cpp/RuntimeNodeApi.hpp
17-
../cpp/RuntimeNodeApiAsync.cpp
18-
../cpp/RuntimeNodeApiAsync.hpp
17+
../cpp/HermesNapiHost.cpp
18+
../cpp/HermesNapiHost.hpp
1919
)
2020

2121
target_include_directories(node-api-host PRIVATE

packages/host/cpp/CxxNodeApiHostModule.cpp

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,10 @@
11
#include "CxxNodeApiHostModule.hpp"
22
#include "Logger.hpp"
3-
#include "RuntimeNodeApiAsync.hpp"
43

54
#include <jsi/hermes-interfaces.h>
65

76
using namespace facebook;
87

9-
// Declared by the vendored Hermes in API/napi/hermes_napi.h. We forward declare
10-
// it here (rather than including that header) to avoid pulling in Hermes' own
11-
// node_api.h alongside the weak-node-api copy already included transitively.
12-
//
13-
// The declaration must be `extern "C"`: since facebook/hermes#2106 (included in
14-
// the pinned Hermes commit) the public hermes_napi.h wraps these entry points
15-
// in `extern "C"`, so Hermes exports the unmangled C symbol. Without matching C
16-
// linkage here the reference would be to the C++-mangled name and the app fails
17-
// to link ("Undefined symbol: hermes_napi_create_env"). Passing host as nullptr
18-
// is enough — async work / thread-safe functions will return failure until a
19-
// host integration is wired up (Phase 3).
20-
extern "C" {
21-
struct hermes_napi_host;
22-
napi_env hermes_napi_create_env(void *hermes_runtime, hermes_napi_host *host);
23-
}
24-
258
namespace callstack::react_native_node_api {
269

2710
CxxNodeApiHostModule::CxxNodeApiHostModule(
@@ -31,6 +14,40 @@ CxxNodeApiHostModule::CxxNodeApiHostModule(
3114
MethodMetadata{1, &CxxNodeApiHostModule::requireNodeAddon};
3215

3316
callInvoker_ = std::move(jsInvoker);
17+
18+
// The JS-thread dispatcher behind the hermes_napi_host integration:
19+
// CallInvoker::invokeAsync is callable from any thread, never runs the
20+
// function inline and delivers in order on the JS thread.
21+
//
22+
// Teardown is the load-bearing case. What the host integration needs is
23+
// that a function handed to this dispatcher either runs on the JS thread
24+
// while the runtime is alive, or is dropped — never invoked against a
25+
// destroyed runtime. In bridgeless React Native the CallInvoker received
26+
// here is a RuntimeSchedulerCallInvoker holding a std::weak_ptr to the
27+
// RuntimeScheduler; the ReactInstance owns scheduler and runtime together
28+
// and invokeAsync no-ops once the scheduler is gone, so work cannot outlive
29+
// the runtime it targets. The weak capture below covers the remaining
30+
// window where this module (and its CallInvoker reference) is released
31+
// during instance teardown.
32+
//
33+
// Dropping is safe precisely because a drop implies that teardown: every
34+
// env this host serves is owned by that same runtime and destroyed with it,
35+
// so the completion or tsfn dispatch being dropped has no live observer.
36+
// The one caller that could still see the difference —
37+
// napi_cancel_async_work — receives the verdict through this dispatcher's
38+
// return value (see HostContext::cancelWork).
39+
hostContext_ = HostContext::create(
40+
[weakInvoker = std::weak_ptr(callInvoker_)](std::function<void()> &&fn) {
41+
auto invoker = weakInvoker.lock();
42+
if (!invoker) {
43+
log_warning(
44+
"NapiHost: dropping a task posted after runtime teardown");
45+
return false;
46+
}
47+
invoker->invokeAsync(std::move(fn));
48+
return true;
49+
});
50+
HostContext::retainForProcessLifetime(hostContext_);
3451
}
3552

3653
jsi::Value
@@ -141,7 +158,8 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt,
141158
"create a Node-API environment");
142159
abort();
143160
}
144-
addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), nullptr);
161+
addon.env =
162+
hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), hostContext_->host());
145163
assert(addon.env != nullptr);
146164
}
147165
napi_env env = addon.env;
@@ -163,7 +181,6 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt,
163181
napi_set_named_property(env, global, addon.generatedName.data(), exports);
164182
assert(status == napi_ok);
165183

166-
callstack::react_native_node_api::setCallInvoker(env, callInvoker_);
167184
return true;
168185
}
169186

packages/host/cpp/CxxNodeApiHostModule.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <node_api.h>
66

77
#include "AddonLoaders.hpp"
8+
#include "HermesNapiHost.hpp"
89

910
namespace callstack::react_native_node_api {
1011

@@ -37,6 +38,9 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule {
3738
};
3839
std::unordered_map<std::string, NodeAddon> nodeAddons_;
3940
std::shared_ptr<facebook::react::CallInvoker> callInvoker_;
41+
// The hermes_napi_host integration passed to every env this module creates.
42+
// Also retained process-wide, as the envs outlive this module on teardown.
43+
std::shared_ptr<HostContext> hostContext_;
4044

4145
using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround
4246
// for my lazyness (work on iOS and Android)

0 commit comments

Comments
 (0)