Repository: denoland/deno
Pull request: fix(ext/ffi): retain backing store references in nonblocking calls

Nonblocking FFI calls extract raw pointers from ArrayBuffer backing stores and send them to a background thread via spawn_blocking, but without retaining a reference to the backing store. If V8's garbage collector runs before the background thread completes, the backing store can be freed, leaving the native code with a dangling pointer.

Fix by collecting SharedRef<BackingStore> references alongside the raw pointers for buffer, struct, and out_buffer arguments in nonblocking calls, and moving them into the spawn_blocking closure so they are kept alive for the duration of the native call.

Changed files:
filename: ext/ffi/call.rs
patch: @@ -320,19 +320,29 @@ where
   };
 
   let symbol = PtrSymbol::new(pointer, &def)?;
-  let call_args = ffi_parse_args(scope, parameters, &def.parameters)?;
-  let out_buffer_ptr = out_buffer_as_ptr(scope, out_buffer);
+  let mut backing_store_holder = BackingStoreHolder::new();
+  let call_args = ffi_parse_args_nonblocking(
+    scope,
+    parameters,
+    &def.parameters,
+    &mut backing_store_holder,
+  )?;
+  let out_buffer_ptr =
+    out_buffer_as_ptr_nonblocking(scope, out_buffer, &mut backing_store_holder);
 
   let join_handle = spawn_blocking(move || {
     let PtrSymbol { cif, ptr } = symbol.clone();
-    ffi_call(
+    let result = ffi_call(
       call_args,
       &cif,
       ptr,
       &def.parameters,
       def.result,
       out_buffer_ptr,
-    )
+    );
+    // prevent backing stores from being dropped before the FFI call completes
+    drop(backing_store_holder);
+    result
   });
 
   Ok(async move {
@@ -366,8 +376,15 @@ pub fn op_ffi_call_nonblocking(
       .clone()
   };
 
-  let call_args = ffi_parse_args(scope, parameters, &symbol.parameter_types)?;
-  let out_buffer_ptr = out_buffer_as_ptr(scope, out_buffer);
+  let mut backing_store_holder = BackingStoreHolder::new();
+  let call_args = ffi_parse_args_nonblocking(
+    scope,
+    parameters,
+    &symbol.parameter_types,
+    &mut backing_store_holder,
+  )?;
+  let out_buffer_ptr =
+    out_buffer_as_ptr_nonblocking(scope, out_buffer, &mut backing_store_holder);
 
   let join_handle = spawn_blocking(move || {
     let Symbol {
@@ -377,14 +394,17 @@ pub fn op_ffi_call_nonblocking(
       result_type,
       ..
     } = symbol.clone();
-    ffi_call(
+    let result = ffi_call(
       call_args,
       &cif,
       ptr,
       &parameter_types,
       result_type,
       out_buffer_ptr,
-    )
+    );
+    // prevent backing stores from being dropped before the FFI call completes
+    drop(backing_store_holder);
+    result
   });
 
   Ok(async move {

filename: ext/ffi/ir.rs
patch: @@ -8,6 +8,24 @@ use libffi::middle::Arg;
 
 use crate::symbol::NativeType;
 
+/// Holds V8 backing store references to prevent GC from freeing ArrayBuffer
+/// memory while nonblocking FFI calls are in progress on background threads.
+pub struct BackingStoreHolder(Vec<v8::SharedRef<v8::BackingStore>>);
+
+// SAFETY: v8::SharedRef<v8::BackingStore> is ref-counted and safe to send
+// across threads. We hold these references solely to prevent deallocation.
+unsafe impl Send for BackingStoreHolder {}
+
+impl BackingStoreHolder {
+  pub fn new() -> Self {
+    Self(Vec::new())
+  }
+
+  fn push(&mut self, store: v8::SharedRef<v8::BackingStore>) {
+    self.0.push(store);
+  }
+}
+
 #[derive(Debug, thiserror::Error, deno_error::JsError)]
 #[class(type)]
 pub enum IRError {
@@ -75,6 +93,24 @@ pub fn out_buffer_as_ptr(
   }
 }
 
+/// Like `out_buffer_as_ptr` but also returns the backing store reference
+/// to prevent GC from freeing the buffer during nonblocking FFI calls.
+pub fn out_buffer_as_ptr_nonblocking(
+  scope: &mut v8::PinScope<'_, '_>,
+  out_buffer: Option<v8::Local<v8::TypedArray>>,
+  holder: &mut BackingStoreHolder,
+) -> Option<OutBuffer> {
+  match out_buffer {
+    Some(out_buffer) => {
+      let ab = out_buffer.buffer(scope).unwrap();
+      holder.push(ab.get_backing_store());
+      ab.data()
+        .map(|non_null| OutBuffer(non_null.as_ptr() as *mut u8))
+    }
+    None => None,
+  }
+}
+
 /// Intermediate format for easy translation from NativeType + V8 value
 /// to libffi argument types.
 #[repr(C)]
@@ -412,6 +448,27 @@ pub fn ffi_parse_buffer_arg(
   Ok(NativeValue { pointer })
 }
 
+/// Like `ffi_parse_buffer_arg` but also retains the backing store reference
+/// in the holder to prevent GC from freeing the buffer during nonblocking
+/// FFI calls.
+#[inline]
+pub fn ffi_parse_buffer_arg_nonblocking(
+  scope: &mut v8::PinScope<'_, '_>,
+  arg: v8::Local<v8::Value>,
+  holder: &mut BackingStoreHolder,
+) -> Result<NativeValue, IRError> {
+  // Retain the backing store before extracting the raw pointer.
+  if let Ok(value) = v8::Local::<v8::ArrayBuffer>::try_from(arg) {
+    holder.push(value.get_backing_store());
+  } else if let Ok(value) = v8::Local::<v8::ArrayBufferView>::try_from(arg)
+    && let Some(ab) = value.buffer(scope)
+  {
+    holder.push(ab.get_backing_store());
+  }
+  let pointer = parse_buffer_arg(arg)?;
+  Ok(NativeValue { pointer })
+}
+
 #[inline]
 pub fn ffi_parse_struct_arg(
   scope: &mut v8::PinScope<'_, '_>,
@@ -446,6 +503,24 @@ pub fn ffi_parse_struct_arg(
   Ok(NativeValue { pointer })
 }
 
+/// Like `ffi_parse_struct_arg` but also retains the backing store reference
+/// to prevent GC from freeing the buffer during nonblocking FFI calls.
+#[inline]
+pub fn ffi_parse_struct_arg_nonblocking(
+  scope: &mut v8::PinScope<'_, '_>,
+  arg: v8::Local<v8::Value>,
+  holder: &mut BackingStoreHolder,
+) -> Result<NativeValue, IRError> {
+  if let Ok(value) = v8::Local::<v8::ArrayBuffer>::try_from(arg) {
+    holder.push(value.get_backing_store());
+  } else if let Ok(value) = v8::Local::<v8::ArrayBufferView>::try_from(arg)
+    && let Some(ab) = value.buffer(scope)
+  {
+    holder.push(ab.get_backing_store());
+  }
+  ffi_parse_struct_arg(scope, arg)
+}
+
 #[inline]
 pub fn ffi_parse_function_arg(
   _scope: &mut v8::PinScope<'_, '_>,
@@ -538,3 +613,86 @@ where
 
   Ok(ffi_args)
 }
+
+/// Like `ffi_parse_args` but also retains backing store references for buffer
+/// and struct arguments, preventing GC from freeing the underlying memory
+/// while nonblocking FFI calls are in progress on background threads.
+pub fn ffi_parse_args_nonblocking<'scope>(
+  scope: &mut v8::PinScope<'scope, '_>,
+  args: v8::Local<v8::Array>,
+  parameter_types: &[NativeType],
+  holder: &mut BackingStoreHolder,
+) -> Result<Vec<NativeValue>, IRError>
+where
+  'scope: 'scope,
+{
+  if parameter_types.is_empty() {
+    return Ok(vec![]);
+  }
+
+  let mut ffi_args: Vec<NativeValue> =
+    Vec::with_capacity(parameter_types.len());
+
+  for (index, native_type) in parameter_types.iter().enumerate() {
+    let value = args.get_index(scope, index as u32).unwrap();
+    match native_type {
+      NativeType::Bool => {
+        ffi_args.push(ffi_parse_bool_arg(value)?);
+      }
+      NativeType::U8 => {
+        ffi_args.push(ffi_parse_u8_arg(value)?);
+      }
+      NativeType::I8 => {
+        ffi_args.push(ffi_parse_i8_arg(value)?);
+      }
+      NativeType::U16 => {
+        ffi_args.push(ffi_parse_u16_arg(value)?);
+      }
+      NativeType::I16 => {
+        ffi_args.push(ffi_parse_i16_arg(value)?);
+      }
+      NativeType::U32 => {
+        ffi_args.push(ffi_parse_u32_arg(value)?);
+      }
+      NativeType::I32 => {
+        ffi_args.push(ffi_parse_i32_arg(value)?);
+      }
+      NativeType::U64 => {
+        ffi_args.push(ffi_parse_u64_arg(scope, value)?);
+      }
+      NativeType::I64 => {
+        ffi_args.push(ffi_parse_i64_arg(scope, value)?);
+      }
+      NativeType::USize => {
+        ffi_args.push(ffi_parse_usize_arg(scope, value)?);
+      }
+      NativeType::ISize => {
+        ffi_args.push(ffi_parse_isize_arg(scope, value)?);
+      }
+      NativeType::F32 => {
+        ffi_args.push(ffi_parse_f32_arg(value)?);
+      }
+      NativeType::F64 => {
+        ffi_args.push(ffi_parse_f64_arg(value)?);
+      }
+      NativeType::Buffer => {
+        ffi_args.push(ffi_parse_buffer_arg_nonblocking(scope, value, holder)?);
+      }
+      NativeType::Struct(_) => {
+        // Struct args also extract pointers from ArrayBuffers
+        ffi_args.push(ffi_parse_struct_arg_nonblocking(scope, value, holder)?);
+      }
+      NativeType::Pointer => {
+        ffi_args.push(ffi_parse_pointer_arg(scope, value)?);
+      }
+      NativeType::Function => {
+        ffi_args.push(ffi_parse_function_arg(scope, value)?);
+      }
+      NativeType::Void => {
+        unreachable!();
+      }
+    }
+  }
+
+  Ok(ffi_args)
+}

filename: tests/ffi/src/lib.rs
patch: @@ -154,6 +154,39 @@ pub unsafe extern "C" fn nonblocking_buffer(ptr: *const u8, len: usize) {
   }
 }
 
+/// Verify that a buffer filled with a specific byte pattern remains valid after
+/// a delay. Used to test that nonblocking FFI calls keep the backing store alive
+/// even when GC is forced via --expose-gc. Sleeps to give JS time to call gc(),
+/// then validates the buffer contents. Returns the checksum of the buffer.
+///
+/// # Safety
+///
+/// The pointer to the buffer must be valid and initialized, and the length must
+/// not be longer than the buffer's allocation.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn nonblocking_buffer_checksum(
+  ptr: *const u8,
+  len: usize,
+  fill_value: u8,
+) -> u64 {
+  // Sleep to give JS time to call gc() and force collection
+  sleep(Duration::from_millis(100));
+  unsafe {
+    let buf = std::slice::from_raw_parts(ptr, len);
+    let mut checksum: u64 = 0;
+    for &byte in buf {
+      // If GC freed the buffer, at least some bytes won't match fill_value
+      assert_eq!(
+        byte, fill_value,
+        "Buffer corrupted: expected 0x{fill_value:02x}, got 0x{byte:02x}. \
+         Backing store was likely freed by GC."
+      );
+      checksum += byte as u64;
+    }
+    checksum
+  }
+}
+
 #[unsafe(no_mangle)]
 pub extern "C" fn get_add_u32_ptr() -> *const c_void {
   add_u32 as *const c_void

filename: tests/ffi/testdata/nonblocking_buffer_gc_test.js
patch: @@ -0,0 +1,55 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+// deno-lint-ignore-file
+
+// Test that nonblocking FFI calls retain ArrayBuffer backing stores
+// so they are not freed by GC while the native call is in progress.
+// Run with: --v8-flags=--expose-gc
+
+const targetDir = Deno.execPath().replace(/[^\/\\]+$/, "");
+const [libPrefix, libSuffix] = {
+  darwin: ["lib", "dylib"],
+  linux: ["lib", "so"],
+  windows: ["", "dll"],
+}[Deno.build.os];
+const libPath = `${targetDir}/${libPrefix}test_ffi.${libSuffix}`;
+
+const dylib = Deno.dlopen(libPath, {
+  nonblocking_buffer_checksum: {
+    parameters: ["buffer", "usize", "u8"],
+    result: "u64",
+    nonblocking: true,
+  },
+});
+
+const BUF_SIZE = 256;
+const FILL_VALUE = 0x42;
+
+// Launch the nonblocking FFI call from a function scope so the buffer
+// becomes unreachable after the call is dispatched.
+function launchNonblockingCall() {
+  const buf = new Uint8Array(BUF_SIZE);
+  buf.fill(FILL_VALUE);
+  return dylib.symbols.nonblocking_buffer_checksum(
+    buf,
+    BUF_SIZE,
+    FILL_VALUE,
+  );
+}
+
+const promise = launchNonblockingCall();
+
+// Force garbage collection while the native function is sleeping.
+// Without the fix, this would free the ArrayBuffer backing store,
+// causing the native function to read freed memory.
+gc();
+
+const checksum = await promise;
+const expected = BigInt(FILL_VALUE * BUF_SIZE);
+
+if (checksum !== expected) {
+  console.log(`FAIL: checksum ${checksum} !== expected ${expected}`);
+  Deno.exit(1);
+}
+
+console.log("nonblocking_buffer_gc_test passed");
+dylib.close();

filename: tests/integration/ffi_tests.rs
patch: @@ -138,6 +138,37 @@ fn ffi_basic() {
   assert_eq!(stderr, "");
 }
 
+#[test_util::test]
+fn ffi_nonblocking_buffer_gc() {
+  build();
+
+  let output = deno_cmd()
+    .current_dir(ffi_tests_path())
+    .arg("run")
+    .arg("--config")
+    .arg(deno_config_path())
+    .arg("--no-lock")
+    .arg("--allow-ffi")
+    .arg("--allow-read")
+    .arg("--unstable-ffi")
+    .arg("--quiet")
+    .arg("--v8-flags=--expose-gc")
+    .arg("testdata/nonblocking_buffer_gc_test.js")
+    .env("NO_COLOR", "1")
+    .output()
+    .unwrap();
+  let stdout = std::str::from_utf8(&output.stdout).unwrap();
+  let stderr = std::str::from_utf8(&output.stderr).unwrap();
+  if !output.status.success() {
+    println!("stdout {stdout}");
+    println!("stderr {stderr}");
+  }
+  println!("{:?}", output.status);
+  assert!(output.status.success());
+  assert_eq!(stdout, "nonblocking_buffer_gc_test passed\n");
+  assert_eq!(stderr, "");
+}
+
 #[test_util::test]
 fn ffi_symbol_types() {
   build();