Implement hermes_napi_host for async work and thread-safe functions - #398
Implement hermes_napi_host for async work and thread-safe functions#398kraenhansen wants to merge 4 commits into
Conversation
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
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF
kraenhansen
left a comment
There was a problem hiding this comment.
Read this against the Hermes sources at the pinned commit (facebook/hermes@5a795c9, API/napi/hermes_napi.h, hermes_napi_async_work.cpp, hermes_napi_tsfn.cpp, hermes_napi.cpp) and against the RuntimeNodeApiAsync.cpp it replaces. The direction is right and the changeset's claim holds up — the old implementation really did run execute on the JS thread via invokeAsync, so this is a genuine fix, not a refactor.
What I verified upstream (so it doesn't have to be re-derived):
- The mirrored
hermes_napi_hoststruct matchesAPI/napi/hermes_napi.hat the pin exactly — member order, signatures, and theuv_loop_sforward declaration. The re-diff instruction added tohermes.tsis the right guard for it. napi_queue_async_work/napi_cancel_async_worknull-check onlyenv->host_, never the individual function pointers — so the comment onhost_'s initialiser is correct thatpost_workandcancel_workcan't be left null. (napi_create_threadsafe_functiondoes additionally checkpost_task.)napi_env__::releaseTsfnLoopRef()dereferenceshost_unconditionally to readunref_loop, andshutdown()callshermes_napi_cleanup_tsfnsafter the cleanup hooks — so the "no cleanup hook can tell us when the last env is done with the struct" reasoning behindretainForProcessLifetimeis sound.tsfnDispatch'sdispatch_pendingprotocol does require that apost_taskis never dropped while the env lives;postTask's comment is right to call that out.kThreadCount = 4is genuinely load-bearing fortests/async'sTestCancel, which saturates withMAX_CANCEL_THREADS - 1 == 5blocking jobs. The "keep this below 5" comment is accurate.
The main thing to fix. retainForProcessLifetime holds a strong shared_ptr in a process-lifetime static, so the std::weak_ptr<HostContext> in WorkItem can never expire. Every teardown path keyed off it is dead code, and in cancelWork that turns into observable behaviour: napi_cancel_async_work returns napi_ok while the completion is dropped inside the dispatcher a moment later, so the addon is told the cancel succeeded and never receives complete(work, napi_cancelled). Details inline, along with why the test that covers this can't catch it.
Two others worth acting on before merge: the double-queue warning path in WorkerPool::enqueue currently enqueues the duplicate anyway (which breaks the exactly-one-completion invariant and lands on a use-after-free), and the weak_ptr<CallInvoker>-as-runtime-liveness assumption in CxxNodeApiHostModule deserves an explicit justification since no test can reach it.
The rest — fatal_exception aborting, the blanket test timeout, deleting HostContext's copy/move — are smaller and can go either way.
Test coverage is otherwise good: porting the upstream tsfn suite with an honest note about what didn't port, and adding the off-JS-thread and cancel-while-running assertions to the async suite, are exactly the right supplements.
Generated by Claude Code
| // The dispatcher may expire while an item is in flight (React Native | ||
| // reload); the completion is then dropped, which is safe because the env it | ||
| // targets is torn down with its runtime. | ||
| std::weak_ptr<HostContext> context; |
There was a problem hiding this comment.
This weak_ptr can never expire in production, so both of the drop paths it guards are dead code.
CxxNodeApiHostModule's constructor calls HostContext::retainForProcessLifetime(hostContext_), which pushes a strong shared_ptr into a process-lifetime static that nothing ever erases. So item.context.lock() succeeds for every context ever created, forever:
workerMain()'selsebranch ("dropping an async work completion posted after runtime teardown") is unreachable.cancelWork()'s "the runtime is being torn down" fallback is unreachable — and that one has a user-visible consequence, see the separate comment there.- The only test covering these paths (
test_hermes_napi_host.cpp, "work completing after its context died") gets there by dropping the last strong ref, which production never does.
The object whose liveness actually matters is the CallInvoker, and that check lives one level down, inside the JsDispatcher lambda — where the pool can't see its result. Either drop the weak indirection entirely (hold the context strongly; the dispatcher already handles teardown) or give HostContext a predicate/return value the pool can consult before it commits to an outcome.
Generated by Claude Code
| if (auto context = item.context.lock()) { | ||
| // Deliver the cancelled completion asynchronously, matching Node, where a | ||
| // cancelled complete callback still runs on a later loop tick. | ||
| context->dispatchToJs([workData = item.workData, complete = item.complete] { | ||
| complete(workData, napi_cancelled); | ||
| }); | ||
| return true; | ||
| } | ||
| // The runtime is being torn down; the complete callback can never run, so | ||
| // report the cancellation as failed. | ||
| return false; |
There was a problem hiding this comment.
Following on from the WorkItem::context comment: this is where the unreachable branch bites. Because the context is retained for the process lifetime, the teardown sequence is:
tryRemovesucceeds — the item is off the queue.item.context.lock()succeeds (strong ref held forever).dispatchToJs→ theCxxNodeApiHostModulelambda'sweakInvoker.lock()fails →log_warning, function dropped.cancelWorkhas already returnedtrue, so Hermes'napi_cancel_async_workreturnsnapi_ok(hermes_napi_async_work.cpp:121-125).
The addon is told the cancellation succeeded and then never gets complete(work, napi_cancelled) — so its napi_delete_async_work never runs and whatever the work was backing never settles. That's precisely the outcome the comment on line 214-216 says it wants to avoid; the check is just on the wrong object.
Making HostContext::dispatchToJs return bool (i.e. "the dispatcher accepted this") and returning that from cancelWork would make the documented behaviour real, and would also let workerMain log the drop accurately.
Generated by Claude Code
| std::lock_guard lock(mutex_); | ||
| for (const WorkItem &queued : queue_) { | ||
| if (queued.loopData == item.loopData && | ||
| queued.workData == item.workData) { | ||
| // Queueing the same napi_async_work twice is undefined behavior in | ||
| // Node (libuv asserts); warn instead of crashing. | ||
| log_warning( | ||
| "NapiHost: napi_async_work %p was queued while already queued", | ||
| item.workData); | ||
| break; | ||
| } | ||
| } | ||
| queue_.push_back(std::move(item)); |
There was a problem hiding this comment.
Detecting the double-queue and then enqueueing it anyway is the worst of the three options. With two entries for the same (loopData, workData):
tryRemoveerases the first match and postscomplete(work, napi_cancelled), while the second copy still runsexecuteand postscomplete(work, napi_ok). That's two completions for onenapi_async_work— directly contradicting the "never both" invariant asserted inworkerMain(lines 92-94) and the "exactly one completion callback guaranteed" claim in the PR description.- Node addons overwhelmingly call
napi_delete_async_workfrom the completion callback, so the second completion is a use-after-free on anapi_async_work__Hermes has alreadydeleted.
libuv aborts on this; warning and continuing converts an assert into a UAF. continue/return before the push_back — log it and drop the duplicate — preserves the one-completion invariant and is strictly safer than either extreme.
Separately, and independent of the above: this is a linear scan of the entire process-global queue, under the process-global mutex, on every napi_queue_async_work in the app. If the warning stays, consider guarding the scan with #ifndef NDEBUG so release builds don't pay for a diagnostic.
Generated by Claude Code
| [weakInvoker = std::weak_ptr(callInvoker_)](std::function<void()> &&fn) { | ||
| if (auto invoker = weakInvoker.lock()) { | ||
| invoker->invokeAsync(std::move(fn)); | ||
| } else { | ||
| log_warning( | ||
| "NapiHost: dropping a task posted after runtime teardown"); | ||
| } |
There was a problem hiding this comment.
This is the load-bearing assumption of the whole change, and it's the one thing the C++ tests can't reach (they use FakeJsQueue), so it's worth writing down why it holds.
The direction this guards is the safe one: if the shared_ptr<CallInvoker> is already gone, dropping is fine — the env died with the runtime that owned it. The risky direction is the opposite, a CallInvoker that is still alive while the jsi::Runtime is not, or a RuntimeScheduler that accepts invokeAsync during shutdown and then drops the task:
- For async work,
complete→asyncWorkCompleteTrampoline→napi_open_handle_scope(env, …)against anapi_envthat Hermes destroyed with itsRuntime. - For thread-safe functions, a silently dropped dispatch is unrecoverable:
napi_threadsafe_function__::dispatch_pendingstaystrue, so no producer ever posts again (hermes_napi_tsfn.cpp:451-455), the tsfn is never finalized andfinalize_cbnever runs.postTask's own comment inHermesNapiHost.cppnames this failure mode but the guarantee it relies on is implemented here.
Is CallInvoker release ordered before ~Runtime in bridgeless RN? If yes, a one-line note pointing at where that ordering comes from would settle it. If it isn't guaranteed, the drop probably needs to hang off something with the runtime's lifetime rather than the invoker's.
Generated by Claude Code
|
|
||
| // Simulates a React Native runtime teardown: in production the context is | ||
| // retained for the process lifetime, but the dispatcher's CallInvoker — | ||
| // modelled here by the context itself — can die while work is in flight. | ||
| context.reset(); | ||
| work->openGate(); | ||
|
|
||
| // The completion cannot be delivered anywhere; give the worker a moment to | ||
| // hit the drop path and assert nothing was queued and nothing crashed. | ||
| std::this_thread::sleep_for(100ms); | ||
| REQUIRE(js.size() == 0); | ||
| REQUIRE(work->completions.load() == 0); |
There was a problem hiding this comment.
This is the suite's only teardown test, and it exercises a state production can't reach. retainForProcessLifetime means the last strong ref to a HostContext is never released, so context.reset() here has no production analogue and item.context.lock() never fails in a real app.
The comment is candid about the modelling, but the two paths aren't equivalent in the way that matters: in production the pool does call dispatchToJs, and the drop happens inside the CxxNodeApiHostModule lambda — after cancelWork has already returned true. So the assertions here (js.size() == 0, no completion) pass for a reason that doesn't apply to shipped code, and the real behaviour (told-cancelled-but-never-completed) goes untested.
A dispatcher that models an expired CallInvoker — accepts the call and drops the function on the floor — would exercise the actual path, and would fail today on the cancel_work return value. Worth adding alongside this one.
Generated by Claude Code
| JsDispatcher dispatchToJs_; | ||
| hermes_napi_host host_; |
There was a problem hiding this comment.
host_.data is initialised to this, so a copy or move of a HostContext yields a struct whose data points at the other object — and since postWork/postTask recover self by casting loop_data, every callback would then run against the wrong instance. create() is the only intended construction path (the constructor is private), but copy and move assignment/construction are still implicitly available on the resulting object.
= delete-ing them makes the "data always points at me" invariant compiler-enforced rather than convention.
Generated by Claude Code
| void HostContext::fatalException(void *, napi_env env, | ||
| napi_value err) noexcept { | ||
| // Called on the JS thread by napi_fatal_exception(). Node routes this to | ||
| // process.emit('uncaughtException'); with no process object we log the | ||
| // error and abort — the same observable outcome as Hermes' null-host | ||
| // default, but surfaced through the host logger. `err` is only valid for | ||
| // the duration of this call, so it is stringified before returning. | ||
| log_error("napi_fatal_exception: %s", describeError(env, err).c_str()); | ||
| abort(); |
There was a problem hiding this comment.
The comment is accurate — I checked, and Hermes' documented null-host default is "prints the error and calls abort() via hermes_fatal()". So this is behaviour-preserving relative to not providing the callback at all.
Worth considering whether that's the behaviour we want now that we're providing one, though. In Node this routes to process.emit('uncaughtException'), which is observable and handleable; node-addon-api reaches for napi_fatal_exception whenever an exception escapes a thread-safe-function callback. So under this implementation a single throwing JS tsfn callback hard-kills the app with no LogBox, no RN error handler, and no JS stack in the crash report beyond what describeError managed to stringify.
Routing through RN's error handling (with abort() as the fallback when that isn't available) would be closer to Node. Fine to defer, but if so a // TODO: here would be better than leaving it looking settled.
Generated by Claude Code
| // Identifies the HostContext that posted the item; matched together with | ||
| // workData on cancellation, since the pool is shared by all runtimes and a | ||
| // freed napi_async_work address could be reused by another env. |
There was a problem hiding this comment.
Small accuracy point: the loopData half of the match doesn't disambiguate envs. CxxNodeApiHostModule creates exactly one HostContext and passes the same hostContext_->host() to hermes_napi_create_env for every addon in the runtime, so all envs in a runtime share one loopData. The pairing only separates contexts across runtimes/reloads.
That's still worth having (it's the reload case that motivates it), the comment just claims a bit more than the code delivers.
Generated by Claude Code
| it(exampleName, async function () { | ||
| // Some examples (the threadsafe-function suite in particular) | ||
| // marshal thousands of values across threads. | ||
| this.timeout(30_000); |
There was a problem hiding this comment.
This raises the timeout for every example in every suite, not just the one that needs it. The cost lands later: a suite that genuinely deadlocks — and this PR adds two new ways to deadlock, a saturated 4-thread pool and a wedged tsfn — now takes 30s to report instead of the default, on every CI device lane.
Both suiteName and exampleName are in scope here, so gating the bump (or letting a suite declare its own timeout in node-addon-examples/src/index.ts) keeps the rest tight while still giving the tsfn suite the room it needs.
Generated by Claude Code
- 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
|
Addressed in 1b432c8 — all findings confirmed against the code, and the two substantive ones are fixed rather than papered over:
Linux Catch2 lane is green locally (6/6); the labeled CI lanes will re-run on the push. Generated by Claude Code |
Provides a complete
hermes_napi_hostimplementation to enable async work (napi_queue_async_work,napi_cancel_async_work) and thread-safe functions (napi_create_threadsafe_functionand related APIs) in Hermes Node-API environments.Key Changes
New
HermesNapiHostimplementation (packages/host/cpp/HermesNapiHost.{hpp,cpp}):hermes_napi_hoststruct required by Hermes' Node-API integrationpost_workcallback for queuing async work on a shared worker thread poolcancel_workcallback for cancelling queued work itemspost_taskcallback for dispatching thread-safe function calls to the JS threadWorkerPoolwith 4 worker threads (matching libuv's default)Removed legacy async implementation (
RuntimeNodeApiAsync.{hpp,cpp}):Comprehensive test coverage:
test_hermes_napi_host.cppwith Catch2 tests exercising work posting, cancellation, and task dispatchpackages/node-addon-examples/tests/threadsafe-function/) ported from Node.js test suiteIntegration updates:
CxxNodeApiHostModuleto instantiate and passHermesNapiHostto HermesImplementation Details
The worker pool uses a shared queue protected by a mutex and condition variable. Work items track their originating context via weak pointer to handle runtime reloads. Cancellation is atomic: an item is either removed from the queue (cancelled) or already executing (cannot be cancelled), with exactly one completion callback guaranteed in either case. Task dispatch for thread-safe functions posts to the JS dispatcher without blocking, enabling non-blocking queue modes.
https://claude.ai/code/session_01Y6xHHeMFF853z5R6th5CkF