Repository: denoland/deno
Pull request: fix(ext/node): napi_set_instance_data on exit

Closes https://github.com/denoland/deno/issues/30529

Changed files:
filename: ext/napi/lib.rs
patch: @@ -306,8 +306,13 @@ impl<T> PendingNapiAsyncWork for T where T: FnOnce() + Send + 'static {}
 pub struct NapiState {
   // Thread safe functions.
   pub env_cleanup_hooks: Rc<RefCell<Vec<(napi_cleanup_hook, *mut c_void)>>>,
+  pub env_shared_ptrs: Vec<*mut EnvShared>,
 }
 
+// SAFETY: finalizer pointers in env_shared_ptrs are only accessed during Drop
+// on the same thread that created them.
+unsafe impl Send for NapiState {}
+
 impl Drop for NapiState {
   fn drop(&mut self) {
     let hooks = {
@@ -339,6 +344,32 @@ impl Drop for NapiState {
         });
       }
     }
+
+    // Call instance data finalize callbacks for all registered EnvShared instances.
+    // Each entry should be unique since each op_napi_open creates a fresh EnvShared.
+    debug_assert!(
+      {
+        let mut seen = std::collections::HashSet::new();
+        self.env_shared_ptrs.iter().all(|p| seen.insert(*p))
+      },
+      "env_shared_ptrs contains duplicate entries"
+    );
+    for env_shared_ptr in &self.env_shared_ptrs {
+      // SAFETY: env_shared_ptr was created via Box::into_raw in op_napi_open
+      // and the native module library is kept alive (via std::mem::forget).
+      let env_shared = unsafe { &mut **env_shared_ptr };
+      if let Some(instance_data) = env_shared.instance_data.take()
+        && let Some(cb) = instance_data.finalize_cb
+      {
+        unsafe {
+          cb(
+            std::ptr::null_mut(),
+            instance_data.data,
+            instance_data.finalize_hint,
+          );
+        }
+      }
+    }
   }
 }
 
@@ -514,6 +545,7 @@ deno_core::extension!(deno_napi,
   state = |state, options| {
     state.put(NapiState {
       env_cleanup_hooks: Rc::new(RefCell::new(vec![])),
+      env_shared_ptrs: vec![],
     });
     if let Some(loader) = options.deno_rt_native_addon_loader {
       state.put(loader);
@@ -589,6 +621,14 @@ fn op_napi_open<'scope>(
     external_ops_tracker,
   );
   env.shared = Box::into_raw(Box::new(env_shared));
+  // Track the EnvShared pointer so we can call instance data finalize
+  // callbacks when the runtime exits. Each op_napi_open call creates a
+  // fresh EnvShared, so entries are always unique.
+  op_state
+    .borrow_mut()
+    .borrow_mut::<NapiState>()
+    .env_shared_ptrs
+    .push(env.shared);
   let env_ptr = Box::into_raw(Box::new(env)) as _;
 
   #[cfg(unix)]

filename: tests/napi/instance_data_test.js
patch: @@ -0,0 +1,35 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+
+// deno-lint-ignore-file no-console
+
+import { assertEquals, loadTestLibrary } from "./common.js";
+
+const properties = loadTestLibrary();
+
+if (import.meta.main) {
+  properties.setInstanceData();
+  console.log("set instance data");
+} else {
+  Deno.test("napi instance data Release", async () => {
+    const { stdout, stderr, code } = await new Deno.Command(Deno.execPath(), {
+      args: [
+        "run",
+        "--config",
+        Deno.realPathSync("../config/deno.json"),
+        "--no-lock",
+        "-A",
+        "--unstable-ffi",
+        import.meta.url,
+      ],
+    }).output();
+
+    assertEquals(new TextDecoder().decode(stderr), "");
+    assertEquals(code, 0);
+
+    const stdoutText = new TextDecoder().decode(stdout);
+    const stdoutLines = stdoutText.split("\n");
+    assertEquals(stdoutLines.length, 3);
+    assertEquals(stdoutLines[0], "set instance data");
+    assertEquals(stdoutLines[1], "instance_data_free(42)");
+  });
+}

filename: tests/napi/src/instance_data.rs
patch: @@ -0,0 +1,50 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+
+use std::ffi::c_void;
+
+use napi_sys::*;
+
+use crate::assert_napi_ok;
+use crate::napi_get_callback_info;
+use crate::napi_new_property;
+
+unsafe extern "C" fn instance_data_finalizer(
+  _env: napi_env,
+  data: *mut c_void,
+  _hint: *mut c_void,
+) {
+  println!("instance_data_free({})", data as i64);
+}
+
+extern "C" fn set_instance_data(
+  env: napi_env,
+  info: napi_callback_info,
+) -> napi_value {
+  let (_args, argc, _) = napi_get_callback_info!(env, info, 1);
+  assert_eq!(argc, 0);
+
+  let data = 42 as *mut c_void;
+  assert_napi_ok!(napi_set_instance_data(
+    env,
+    data,
+    Some(instance_data_finalizer),
+    std::ptr::null_mut(),
+  ));
+
+  std::ptr::null_mut()
+}
+
+pub fn init(env: napi_env, exports: napi_value) {
+  let properties = &[napi_new_property!(
+    env,
+    "setInstanceData",
+    set_instance_data
+  )];
+
+  assert_napi_ok!(napi_define_properties(
+    env,
+    exports,
+    properties.len(),
+    properties.as_ptr()
+  ));
+}

filename: tests/napi/src/lib.rs
patch: @@ -22,6 +22,7 @@ pub mod date;
 pub mod env;
 pub mod error;
 pub mod finalizer;
+pub mod instance_data;
 pub mod make_callback;
 pub mod mem;
 pub mod numbers;
@@ -184,6 +185,8 @@ unsafe extern "C" fn napi_register_module_v1(
   object::init(env, exports);
   uv::init(env, exports);
 
+  instance_data::init(env, exports);
+
   init_cleanup_hook(env, exports);
 
   exports