Repository: denoland/deno
Pull request: fix(ext/napi): run async work execute callback on a worker thread

## 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

- [x] `cargo test napi` — all NAPI tests pass (49 passed, 0 failed, 3 ignored)
- [x] New test `test_async_work_with_tsfn` exercises the tsfn-from-execute
  pattern
- [x] 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](https://claude.com/claude-code)

Changed files:
filename: ext/napi/node_api.rs
patch: @@ -586,8 +586,16 @@ pub(crate) fn napi_queue_async_work(
   }
 
   let work = SendPtr(work);
+  let sender = env.async_work_sender.clone();
+  let tracker = env.external_ops_tracker.clone();
 
-  env.add_async_work(move || {
+  // Keep the event loop alive while async work is pending.
+  tracker.ref_op();
+
+  // Per NAPI spec, `execute` runs on a worker thread and `complete` runs on
+  // the main thread. Previously both ran on the main thread which caused
+  // deadlocks when `execute` called threadsafe functions.
+  std::thread::spawn(move || {
     let work = work.take();
     let work = unsafe { &*work };
 
@@ -612,7 +620,13 @@ pub(crate) fn napi_queue_async_work(
       );
     }
 
-    if let Some(complete) = work.complete {
+    // Capture fields before dispatching to the main thread, since `complete`
+    // may call `napi_delete_async_work` which frees the work struct.
+    let complete = work.complete;
+    let env_ptr = SendPtr(work.env);
+    let data = SendPtr(work.data);
+
+    if let Some(complete) = complete {
       let status = if state.is_ok() {
         napi_ok
       } else if state == Err(AsyncWork::IDLE) {
@@ -621,12 +635,16 @@ pub(crate) fn napi_queue_async_work(
         napi_generic_failure
       };
 
-      unsafe {
-        complete(work.env as _, status, work.data);
-      }
+      // Dispatch `complete` to the main thread where it can safely access V8.
+      sender.spawn(move |_| {
+        unsafe {
+          complete(env_ptr.take() as _, status, data.take() as _);
+        }
+        tracker.unref_op();
+      });
+    } else {
+      tracker.unref_op();
     }
-
-    // `complete` probably deletes this `work`, so don't use it here.
   });
 
   napi_clear_last_error(env)

filename: ext/napi/uv.rs
patch: @@ -5,6 +5,7 @@ use std::ptr::addr_of_mut;
 
 use deno_core::parking_lot::Mutex;
 
+use crate::util::SendPtr;
 use crate::*;
 
 fn assert_ok(res: c_int) -> c_int {
@@ -22,7 +23,6 @@ use std::ffi::c_int;
 use js_native_api::napi_create_string_utf8;
 use node_api::napi_create_async_work;
 use node_api::napi_delete_async_work;
-use node_api::napi_queue_async_work;
 
 const UV_MUTEX_SIZE: usize = {
   #[cfg(unix)]
@@ -179,9 +179,20 @@ unsafe extern "C" fn _napi_uv_async_init(
   }
 }
 
-#[unsafe(export_name = "uv_async_send")]
-unsafe extern "C" fn _napi_uv_async_send(handle: *mut uv_async_t) -> c_int {
-  unsafe { -napi_queue_async_work((*handle).r#loop, (*handle).work) }
+#[unsafe(no_mangle)]
+unsafe extern "C" fn uv_async_send(handle: *mut uv_async_t) -> c_int {
+  // Dispatch directly to the main thread. Unlike napi_queue_async_work (which
+  // runs `execute` on a worker thread), uv_async callbacks need V8 access so
+  // they must run on the main thread.
+  unsafe {
+    let env = &mut *(*handle).r#loop;
+    let handle = SendPtr(handle as *const uv_async_t);
+    env.async_work_sender.spawn(move |_| {
+      let handle = handle.take() as *mut uv_async_t;
+      ((*handle).async_cb)(handle);
+    });
+  }
+  0
 }
 
 type uv_close_cb = unsafe extern "C" fn(*mut uv_handle_t);

filename: tests/napi/async_test.js
patch: @@ -14,3 +14,17 @@ Deno.test("napi async task schedule", async () => {
   });
   assertEquals(called, true);
 });
+
+Deno.test("napi async work with threadsafe function from execute", async () => {
+  // This tests that the execute callback runs on a worker thread by calling
+  // a threadsafe function from it. Previously this would deadlock because
+  // execute ran on the main thread.
+  let called = false;
+  await new Promise((resolve) => {
+    asyncTask.test_async_work_with_tsfn(() => {
+      called = true;
+      resolve();
+    });
+  });
+  assertEquals(called, true);
+});

filename: tests/napi/src/async.rs
patch: @@ -107,9 +107,148 @@ extern "C" fn test_async_work(
   ptr::null_mut()
 }
 
+// Test that async work's execute callback runs on a worker thread by calling
+// a threadsafe function from it. This would deadlock if execute ran on the
+// main thread (the pattern that lmdb-js uses).
+
+struct TsfnBaton {
+  tsfn: napi_threadsafe_function,
+  task: napi_async_work,
+  func: napi_ref,
+}
+
+unsafe extern "C" fn tsfn_execute(_env: napi_env, data: *mut c_void) {
+  unsafe {
+    let baton: &TsfnBaton = &*(data as *const TsfnBaton);
+    // Call the threadsafe function from the execute (worker) thread.
+    // This would deadlock if execute ran on the main thread.
+    assert_eq!(
+      napi_call_threadsafe_function(
+        baton.tsfn,
+        ptr::null_mut(),
+        ThreadsafeFunctionCallMode::blocking,
+      ),
+      napi_ok
+    );
+  }
+}
+
+unsafe extern "C" fn tsfn_call_js(
+  env: napi_env,
+  _js_callback: napi_value,
+  _context: *mut c_void,
+  _data: *mut c_void,
+) {
+  // Release the tsfn from the JS thread after being called.
+  // We get the tsfn from the callback info that was stashed earlier.
+  // For simplicity, just do nothing here — the complete callback handles cleanup.
+  let _ = env;
+}
+
+unsafe extern "C" fn tsfn_complete(
+  env: napi_env,
+  status: napi_status,
+  data: *mut c_void,
+) {
+  unsafe {
+    assert!(status == napi_ok);
+    let baton: Box<TsfnBaton> = Box::from_raw(data as *mut TsfnBaton);
+
+    // Release the threadsafe function
+    assert_eq!(
+      napi_release_threadsafe_function(
+        baton.tsfn,
+        ThreadsafeFunctionReleaseMode::release,
+      ),
+      napi_ok
+    );
+
+    // Call the JS callback to signal completion
+    let mut global: napi_value = ptr::null_mut();
+    assert_napi_ok!(napi_get_global(env, &mut global));
+    let mut callback: napi_value = ptr::null_mut();
+    assert_napi_ok!(napi_get_reference_value(env, baton.func, &mut callback));
+    let mut _result: napi_value = ptr::null_mut();
+    assert_napi_ok!(napi_call_function(
+      env,
+      global,
+      callback,
+      0,
+      ptr::null(),
+      &mut _result
+    ));
+    assert_napi_ok!(napi_delete_reference(env, baton.func));
+    assert_napi_ok!(napi_delete_async_work(env, baton.task));
+  }
+}
+
+extern "C" fn test_async_work_with_tsfn(
+  env: napi_env,
+  info: napi_callback_info,
+) -> napi_value {
+  let (args, argc, _) = napi_get_callback_info!(env, info, 1);
+  assert_eq!(argc, 1);
+
+  let mut resource_name: napi_value = ptr::null_mut();
+  assert_napi_ok!(napi_create_string_utf8(
+    env,
+    "test_async_tsfn".as_ptr() as *const c_char,
+    usize::MAX,
+    &mut resource_name,
+  ));
+
+  // Create a threadsafe function
+  let mut tsfn: napi_threadsafe_function = ptr::null_mut();
+  assert_napi_ok!(napi_create_threadsafe_function(
+    env,
+    ptr::null_mut(),    // func (unused, we use call_js_cb)
+    ptr::null_mut(),    // async_resource
+    resource_name,      // async_resource_name
+    0,                  // max_queue_size (unlimited)
+    1,                  // initial_thread_count
+    ptr::null_mut(),    // thread_finalize_data
+    None,               // thread_finalize_cb
+    ptr::null_mut(),    // context
+    Some(tsfn_call_js), // call_js_cb
+    &mut tsfn,
+  ));
+
+  let mut func: napi_ref = ptr::null_mut();
+  assert_napi_ok!(napi_create_reference(env, args[0], 1, &mut func));
+
+  let baton = Box::new(TsfnBaton {
+    tsfn,
+    task: ptr::null_mut(),
+    func,
+  });
+  let baton_ptr = Box::into_raw(baton) as *mut c_void;
+
+  let mut async_work: napi_async_work = ptr::null_mut();
+  assert_napi_ok!(napi_create_async_work(
+    env,
+    ptr::null_mut(),
+    resource_name,
+    Some(tsfn_execute),
+    Some(tsfn_complete),
+    baton_ptr,
+    &mut async_work,
+  ));
+  let baton = unsafe { &mut *(baton_ptr as *mut TsfnBaton) };
+  baton.task = async_work;
+  assert_napi_ok!(napi_queue_async_work(env, async_work));
+
+  ptr::null_mut()
+}
+
 pub fn init(env: napi_env, exports: napi_value) {
-  let properties =
-    &[napi_new_property!(env, "test_async_work", test_async_work)];
+  let properties = &[
+    napi_new_property!(env, "test_async_work", test_async_work),
+    napi_new_property!(
+      env,
+      "test_async_work_with_tsfn",
+      test_async_work_with_tsfn
+    ),
+  ];
 
   assert_napi_ok!(napi_define_properties(
     env,