Repository: vercel/next.js
Pull request: fix(turbopack): unref ThreadsafeFunctions to allow Node.js exit after build

## What?

Calls `unref()` on the `ThreadsafeFunction`s created in `NapiNextTurbopackCallbacks::from_js` (`throw_turbopack_internal_error` and `on_before_deferred_entries`).

## Why?

These `ThreadsafeFunction`s are not unref'd, which means they keep the Node.js event loop alive even after `project_shutdown` completes.

Currently this doesn't cause a visible hang in Next.js because turbopack runs inside a worker thread (via `packages/next/src/lib/worker.ts`), and the worker thread is terminated externally. However, if turbopack were ever run in a child process or directly in the main process, the build would hang indefinitely after completion — the Rust side of `stop_and_wait()` finishes, but the Node.js process never exits because the ref'd `ThreadsafeFunction`s prevent the event loop from draining. This happened in `utoo`, caused some CI hang forever, the same fix commit: https://github.com/utooland/utoo/pull/2670/changes/9e8fd08e84470dce6ac59e976db1b556ce7fece8.

This fix makes the shutdown behavior more robust by following the same pattern already used in `register_worker_scheduler` in `turbopack-node/src/worker_pool/worker_thread.rs`, where both creator and terminator `ThreadsafeFunction`s are explicitly unref'd.

## How?

- Added `Env` parameter to `NapiNextTurbopackCallbacks::from_js()`
- Called `unref(&env)` on both `throw_turbopack_internal_error` and `on_before_deferred_entries` after creation
- Updated the call site in `project_new` to pass `&env`


Changed files:
filename: crates/next-napi-bindings/src/next_api/project.rs
patch: @@ -421,7 +421,7 @@ pub fn project_new(
     turbo_engine_options: NapiTurboEngineOptions,
     napi_callbacks: NapiNextTurbopackCallbacksJsObject,
 ) -> napi::Result<JsObject> {
-    let napi_callbacks = NapiNextTurbopackCallbacks::from_js(napi_callbacks)?;
+    let napi_callbacks = NapiNextTurbopackCallbacks::from_js(&env, napi_callbacks)?;
     let (exit, exit_receiver) = ExitHandler::new_receiver();
 
     if let Some(dhat_profiler) = DhatProfilerGuard::try_init() {

filename: crates/next-napi-bindings/src/next_api/turbopack_ctx.rs
patch: @@ -11,7 +11,7 @@ use std::{
 
 use anyhow::Result;
 use either::Either;
-use napi::{JsFunction, bindgen_prelude::Promise, threadsafe_function::ThreadsafeFunction};
+use napi::{Env, JsFunction, bindgen_prelude::Promise, threadsafe_function::ThreadsafeFunction};
 use napi_derive::napi;
 use once_cell::sync::Lazy;
 use owo_colors::OwoColorize;
@@ -179,22 +179,31 @@ pub struct TurbopackInternalErrorOpts {
 }
 
 impl NapiNextTurbopackCallbacks {
-    pub fn from_js(obj: NapiNextTurbopackCallbacksJsObject) -> napi::Result<Self> {
-        Ok(NapiNextTurbopackCallbacks {
-            throw_turbopack_internal_error: obj
-                .throw_turbopack_internal_error
+    pub fn from_js(env: &Env, obj: NapiNextTurbopackCallbacksJsObject) -> napi::Result<Self> {
+        let mut throw_turbopack_internal_error: ThreadsafeFunction<TurbopackInternalErrorOpts> =
+            obj.throw_turbopack_internal_error
                 .create_threadsafe_function(0, |ctx| {
                     // Avoid unpacking the struct into positional arguments, we really want to make
                     // sure we don't incorrectly order arguments and accidentally log a potentially
                     // PII-containing message in anonymized telemetry.
                     Ok(vec![ctx.value])
-                })?,
-            on_before_deferred_entries: obj
-                .on_before_deferred_entries
-                .map(|callback| {
-                    callback.create_threadsafe_function(0, |_| Ok::<Vec<()>, _>(vec![]))
-                })
-                .transpose()?,
+                })?;
+        // Unref so this ThreadsafeFunction doesn't keep the Node.js event loop alive
+        // after shutdown.
+        let _ = throw_turbopack_internal_error.unref(env);
+
+        let on_before_deferred_entries = obj
+            .on_before_deferred_entries
+            .map(|callback| {
+                let mut f = callback.create_threadsafe_function(0, |_| Ok::<Vec<()>, _>(vec![]))?;
+                let _ = f.unref(env);
+                Ok::<_, napi::Error>(f)
+            })
+            .transpose()?;
+
+        Ok(NapiNextTurbopackCallbacks {
+            throw_turbopack_internal_error,
+            on_before_deferred_entries,
         })
     }
 }