Skip to content

fix(ext/napi): run async work execute callback on a worker thread#32560

Merged
bartlomieju merged 2 commits intodenoland:mainfrom
bartlomieju:fix/napi-async-work-worker-thread
Mar 10, 2026
Merged

fix(ext/napi): run async work execute callback on a worker thread#32560
bartlomieju merged 2 commits intodenoland:mainfrom
bartlomieju:fix/napi-async-work-worker-thread

Conversation

@bartlomieju
Copy link
Copy Markdown
Member

Summary

  • Fixes napi_queue_async_work to run the execute callback on a worker
    thread (via std::thread::spawn) instead of the V8 main thread
  • Dispatches complete back to the main thread via
    async_work_sender.spawn() where it can safely access V8
  • Uses ExternalOpsTracker (ref_op/unref_op) to keep the event loop
    alive while the worker thread is running
  • Fixes uv_async_send to dispatch directly to the main thread instead of
    going through napi_queue_async_work (since uv_async callbacks need V8
    access)
  • Adds a test that calls a threadsafe function from the execute callback —
    the exact pattern that caused the deadlock

Background

Per the NAPI spec, napi_queue_async_work's execute callback must run on a
worker thread, while complete runs on the main thread. Previously both ran on
the main thread via env.add_async_work()async_work_sender.spawn(). This
caused deadlocks when native addons (like lmdb-js) called
napi_call_threadsafe_function from execute — the threadsafe function queues
work to the main thread and waits, but the main thread is blocked running
execute.

Closes #23268

Test plan

  • cargo test napi — all NAPI tests pass (49 passed, 0 failed, 3 ignored)
  • New test test_async_work_with_tsfn exercises the tsfn-from-execute
    pattern
  • Manual test with lmdb-js: sync put/get, async put, and async
    transaction() all pass (previously segfaulted with exit code 139)

🤖 Generated with Claude Code

Per the NAPI spec, `napi_queue_async_work`'s `execute` callback must
run on a worker thread while `complete` runs on the main thread.
Previously both ran on the main thread via `env.add_async_work()`,
which caused deadlocks when `execute` called threadsafe functions
(e.g. lmdb-js async transactions).

- Spawn `execute` on `std::thread::spawn`, dispatch `complete` back
  to main thread via `async_work_sender.spawn()`
- Use `ExternalOpsTracker` to keep event loop alive during execution
- Fix `uv_async_send` to dispatch directly to main thread instead of
  going through `napi_queue_async_work`
- Add test exercising threadsafe function call from execute callback

Closes denoland#23268

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bartlomieju
Copy link
Copy Markdown
Member Author

Note to self: after this gets released open a PR to lmdb repo to update README to note that transactions work correctly in Deno now

Copy link
Copy Markdown
Contributor

@kajukitli kajukitli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm — this is a critical fix for NAPI compatibility

the change is correct per NAPI spec:

  • execute runs on worker thread via std::thread::spawn
  • complete dispatched to main thread via async_work_sender.spawn()
  • ExternalOpsTracker ref/unref keeps event loop alive during worker execution

the uv_async_send fix is also correct — those callbacks need V8 access so they must run on main thread directly

the test case exercising tsfn-from-execute is exactly the pattern that was deadlocking. closes the lmdb-js issue

@bartlomieju bartlomieju changed the title fix(napi): run async work execute callback on a worker thread fix(ext/napi): run async work execute callback on a worker thread Mar 9, 2026
Copy link
Copy Markdown
Member

@littledivy littledivy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Copy Markdown
Contributor

@kajukitli kajukitli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main change looks good - moving execute to a worker thread fixes the deadlock when calling threadsafe functions from within execute.

One observation: uv_async_send now spawns directly via async_work_sender.spawn() without the ref_op()/unref_op() pattern that napi_queue_async_work uses. This means if uv_async_send is called when nothing else keeps the event loop alive, the callback could theoretically be dropped.

However, this is likely a pre-existing limitation (the old add_async_work also didn't ref-count), and the typical usage pattern has other things keeping the loop alive. The PR correctly keeps uv_async_send callbacks on the main thread since they need V8 access.

The deadlock fix is the important part and the tests validate it well.

@bartlomieju bartlomieju merged commit 4c548d4 into denoland:main Mar 10, 2026
112 checks passed
@bartlomieju bartlomieju deleted the fix/napi-async-work-worker-thread branch March 10, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support lmdb-js

3 participants