denoland/deno · #37026

fix(core): keep op contexts alive until the isolate is disposed

nightness · merged Oct 20, 202611 files · 192 + / 12 −
cli/tools/lint/plugins.rs57 + / 1 −
@@ -103,6 +103,53 @@ v8_static_strings! { pub struct PluginHostProxy {   tx: mpsc::Sender<PluginHostRequest>,   pub(crate) plugin_info: Arc<Mutex<Vec<PluginInfo>>>,+  // Must be declared after `tx`: fields are dropped in declaration order, and+  // the plugin host's run loop only ends once `tx` has been dropped.+  _thread: PluginHostThread,+}++/// How long dropping a [`PluginHostProxy`] waits for the plugin host to tear+/// down its worker. Teardown normally takes milliseconds; the bound only+/// guarantees that `deno lint` can never hang on it.+const PLUGIN_HOST_TEARDOWN_TIMEOUT: std::time::Duration =+  std::time::Duration::from_secs(10);++/// The thread running the [`PluginHost`].+///+/// On drop, waits until the plugin host has torn down its worker, and with it+/// the V8 isolate. Without this the thread was detached, so the process could+/// exit while V8 was still disposing of the isolate on that thread.+#[derive(Debug)]+struct PluginHostThread {+  join_handle: Option<std::thread::JoinHandle<Result<(), AnyError>>>,+  /// Disconnected once the plugin host future, and the worker it owns, have+  /// been dropped. (In a `Mutex` only because `PluginHostProxy` is `Sync`.)+  worker_dropped_rx: Mutex<std::sync::mpsc::Receiver<()>>,+}++impl Drop for PluginHostThread {+  fn drop(&mut self) {+    match self+      .worker_dropped_rx+      .get_mut()+      .recv_timeout(PLUGIN_HOST_TEARDOWN_TIMEOUT)+    {+      Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {}+      Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {+        log::debug!("Timed out waiting for the lint PluginHost to shut down");+        return;+      }+    }+    // The worker is gone; what remains is shutting down the thread's tokio+    // runtime, which waits for any blocking task a plugin left running. Join+    // only if that has already finished so a stuck task can't hang us.+    if let Some(join_handle) = self.join_handle.take()+      && join_handle.is_finished()+      && let Ok(Err(err)) = join_handle.join()+    {+      log::debug!("Lint PluginHost thread failed: {err:#}");+    }+  } }  impl PluginHostProxy {@@ -229,13 +276,18 @@ impl PluginHost {   fn create(logger: PluginLogger) -> Result<PluginHostProxy, AnyError> {     let (tx_req, rx_req) = mpsc::channel(10); +    let (worker_dropped_tx, worker_dropped_rx) = std::sync::mpsc::channel();+     let logger_ = logger.clone();-    let _join_handle: std::thread::JoinHandle<Result<(), AnyError>> =+    let join_handle: std::thread::JoinHandle<Result<(), AnyError>> =       std::thread::spawn(move || {         let logger = logger_;         log::debug!("Lint PluginHost thread spawned");         let start = std::time::Instant::now();         let fut = async move {+          // Declared first so that it is dropped last, after the runner (and+          // its worker) on every path out of this future.+          let _worker_dropped_tx = worker_dropped_tx;           let runner =             create_plugin_runner_inner(logger.clone(), rx_req).await?;           log::debug!("Lint PlugibnHost running loop");@@ -253,6 +305,10 @@ impl PluginHost {     let proxy = PluginHostProxy {       tx: tx_req,       plugin_info: Arc::new(Mutex::new(vec![])),+      _thread: PluginHostThread {+        join_handle: Some(join_handle),+        worker_dropped_rx: Mutex::new(worker_dropped_rx),+      },     };      Ok(proxy)
libs/core/ops.rs4 + / 2 −
@@ -116,8 +116,10 @@ pub struct OpCtx {   /// The fast-call overload passed to `FunctionTemplate::build_fast`. Stored   /// here (rather than synthesized on the stack) because V8 150.x keeps the raw   /// `CFunction` pointers directly inside `FunctionTemplateInfo`, so the slice-  /// must outlive every template built from this op. `OpCtx` lives until isolate-  /// disposal, which satisfies that.+  /// must outlive every template built from this op, including any concurrent+  /// TurboFan job that inlines the fast call. The runtime keeps its `OpCtxs`+  /// alive until after the isolate has been disposed (see+  /// `JsRealmInner::destroy`), which satisfies that.   ///   /// The single element is also the op's `CFunctionInfo` source (see   /// [`OpCtx::fast_fn_info`]).
libs/core/runtime/bindings.rs4 + / 2 −
@@ -1029,8 +1029,10 @@ pub(crate) fn op_ctx_template<'s, 'i>(     // SAFETY: V8 150.x stores the raw `CFunction` pointers from `overloads`     // directly in the `FunctionTemplateInfo`, so `build_fast` requires a     // `'static` slice. The backing storage lives in `op_ctx`, which outlives-    // every `FunctionTemplate` created from it (both are torn down at isolate-    // disposal), so extending the borrow to `'static` is sound.+    // every `FunctionTemplate` created from it (the runtime frees the op+    // contexts only after disposing of the isolate, which also joins any+    // concurrent compilation job reading them), so extending the borrow to+    // `'static` is sound.     let overloads: &'static [v8::fast_api::CFunction] = unsafe {       std::mem::transmute::<&[_], &'static [_]>(overloads.as_slice())     };
libs/core/runtime/jsrealm.rs19 + / 4 −
@@ -100,8 +100,12 @@ pub struct ContextState {   pub(crate) activity_traces: RuntimeActivityTraces,   pub(crate) pending_ops: Rc<OpDriverImpl>,   // We don't explicitly re-read this prop but need the slice to live alongside-  // the context-  pub(crate) op_ctxs: OpCtxs,+  // the context. It is reference counted because it must also outlive the+  // isolate: V8 keeps raw pointers to each op's fast-call `CFunction`+  // overloads (stored in `OpCtx`) and concurrent TurboFan jobs may read them+  // until the isolate is disposed. `JsRealmInner::destroy` hands a reference+  // back to the runtime, which drops it only after disposing of the isolate.+  pub(crate) op_ctxs: Rc<OpCtxs>,   pub(crate) op_method_decls: Vec<OpMethodDecl>,   pub(crate) methods_ctx_offset: usize,   /// Snapshots built against V8 14.9+ bake the *slow* version of each op@@ -214,7 +218,7 @@ impl ContextState {       wasm_instance_fn: Default::default(),       wasm_instances_map: Default::default(),       activity_traces: Default::default(),-      op_ctxs,+      op_ctxs: Rc::new(op_ctxs),       op_method_decls,       methods_ctx_offset,       fast_ops_upgraded: Cell::new(false),@@ -313,8 +317,17 @@ impl JsRealmInner {     self.function_templates.clone()   } -  pub fn destroy(self) {+  /// Tears down the realm's context state.+  ///+  /// Returns the realm's op contexts, which the caller must keep alive until+  /// the isolate has been disposed: function templates built with fast-call+  /// overloads point into them, and V8 may still read those pointers from+  /// concurrent compilation jobs until `Isolate::Dispose` cancels and joins+  /// them.+  #[must_use = "the op contexts must outlive the isolate"]+  pub fn destroy(self) -> Rc<OpCtxs> {     let state = self.state();+    let op_ctxs = state.op_ctxs.clone();     let raw_ptr = self.state().isolate.unwrap();     // SAFETY: We know the isolate outlives the realm     let mut isolate = unsafe { v8::Isolate::from_raw_isolate_ptr(raw_ptr) };@@ -388,6 +401,8 @@ impl JsRealmInner {     // Expect that this context is dead (we only check this in debug mode)     // TODO(mmastrac): This check fails for some tests, will need to fix this     // debug_assert_eq!(Rc::strong_count(&self.context), 1, "Realm was still alive when we wanted to destroy it. Not dropped?");++    op_ctxs   } } 
libs/core/runtime/jsruntime.rs21 + / 3 −
@@ -120,6 +120,7 @@ use crate::modules::SideModuleKind; use crate::modules::ValidateImportAttributesCb; use crate::modules::recursive_load::RecursiveModuleLoad; use crate::modules::script_origin;+use crate::ops::OpCtxs; use crate::ops_metrics::OpMetricsFactoryFn; use crate::ops_metrics::dispatch_metrics_async; use crate::runtime::ContextState;@@ -218,6 +219,11 @@ pub(crate) struct InnerIsolateState {   source_count: usize,   addl_refs_count: usize,   main_realm: ManuallyDrop<JsRealm>,+  /// The main realm's op contexts, handed back by `JsRealmInner::destroy`.+  /// Function templates with fast-call overloads hold raw pointers into+  /// them, which concurrent TurboFan jobs may read until the isolate is+  /// disposed, so they are dropped only after `v8_isolate`.+  retired_op_ctxs: Option<Rc<OpCtxs>>,   pub(crate) state: ManuallyDropRc<JsRuntimeState>,   v8_isolate: ManuallyDrop<v8::OwnedIsolate>, }@@ -260,9 +266,12 @@ impl InnerIsolateState {     // release the Global<Context> stored in loop.data. If we clear     // op_state first, the Box<UvLoop> is freed and destroy() would     // dereference a dangling pointer (use-after-free).-    unsafe {-      ManuallyDrop::take(&mut self.main_realm).0.destroy();-    }+    //+    // The op contexts it returns are kept until the isolate has been disposed+    // (see `retired_op_ctxs`).+    // SAFETY: `main_realm` is taken exactly once, here, and never used again.+    let main_realm = unsafe { ManuallyDrop::take(&mut self.main_realm) };+    self.retired_op_ctxs = Some(main_realm.0.destroy());      // Now safe to clear op_state (drops Box<UvLoop> etc.) since     // destroy() has already released the Global from loop.data.@@ -280,6 +289,10 @@ impl InnerIsolateState {    pub fn prepare_for_snapshot(mut self) -> v8::OwnedIsolate {     self.cleanup();+    // Templates built for a snapshot have no fast-call overloads (see+    // `op_ctx_template`), so nothing in the isolate points into the op+    // contexts. Drop them here rather than leaking them with `self`.+    drop(self.retired_op_ctxs.take());      // SAFETY: We're copying out of self and then immediately forgetting self     unsafe {@@ -314,6 +327,10 @@ impl Drop for InnerIsolateState {         ManuallyDrop::drop(&mut self.v8_isolate);       }     }+    // Only now that the isolate is disposed (and with it every concurrent+    // compilation job that could read a fast-call `CFunction`) is it safe to+    // free the op contexts.+    drop(self.retired_op_ctxs.take());   } } @@ -1237,6 +1254,7 @@ impl JsRuntime {         source_count,         addl_refs_count,         main_realm: ManuallyDrop::new(main_realm),+        retired_op_ctxs: None,         state: ManuallyDropRc(ManuallyDrop::new(state_rc)),         v8_isolate: ManuallyDrop::new(isolate),       },
libs/core/runtime/tests/misc.rs55 + / 0 −
@@ -2192,3 +2192,58 @@ fn foreground_tasks_delivered_without_tokio_handle() {     runtime.resolve_value(promise).await.unwrap();   }); }++/// Function templates built with fast-call overloads keep raw pointers to the+/// `CFunction`s stored in the op contexts, and a concurrent TurboFan job that+/// inlines a fast call reads them until the isolate is disposed. The op+/// contexts must therefore still be alive while the isolate is being dropped,+/// and be released afterwards.+#[test]+fn op_ctxs_outlive_isolate() {+  struct OpCtxsProbe {+    op_ctxs: std::rc::Weak<crate::ops::OpCtxs>,+    alive_at_isolate_drop: Rc<std::cell::Cell<Option<bool>>>,+  }++  impl Drop for OpCtxsProbe {+    fn drop(&mut self) {+      self+        .alive_at_isolate_drop+        .set(Some(self.op_ctxs.strong_count() > 0));+    }+  }++  let mut runtime = JsRuntime::new(Default::default());+  runtime+    .execute_script(+      "a.js",+      "for (let i = 0; i < 10_000; i++) Deno.core.ops.op_add(i, 1);",+    )+    .unwrap();++  let op_ctxs = Rc::downgrade(&runtime.main_realm().0.context_state.op_ctxs);+  assert!(+    op_ctxs+      .upgrade()+      .unwrap()+      .iter()+      .any(|op_ctx| op_ctx.fast_fn_info().is_some())+  );++  // Isolate slots are dropped by `OwnedIsolate::drop`, i.e. only once the+  // runtime has finished its own cleanup and starts disposing the isolate.+  let alive_at_isolate_drop = Rc::new(std::cell::Cell::new(None));+  runtime.v8_isolate().set_slot(OpCtxsProbe {+    op_ctxs: op_ctxs.clone(),+    alive_at_isolate_drop: alive_at_isolate_drop.clone(),+  });++  drop(runtime);++  assert_eq!(+    alive_at_isolate_drop.get(),+    Some(true),+    "op contexts were freed before the isolate was disposed"+  );+  assert_eq!(op_ctxs.strong_count(), 0, "op contexts were leaked");+}
tests/specs/lint/lint_plugin_shutdown/__test__.jsoncadded10 + / 0 −
@@ -0,0 +1,10 @@+{+  // `deno lint` waits for the plugin host to shut down before exiting. Work a+  // plugin leaves pending must not keep it from exiting.+  "steps": [+    {+      "args": "lint a.ts",+      "output": "lint.out"+    }+  ]+}
tests/specs/lint/lint_plugin_shutdown/a.tsadded1 + / 0 −
@@ -0,0 +1 @@+const _a = "foo";
tests/specs/lint/lint_plugin_shutdown/deno.jsonadded5 + / 0 −
@@ -0,0 +1,5 @@+{+  "lint": {+    "plugins": ["./plugin.ts"]+  }+}
tests/specs/lint/lint_plugin_shutdown/lint.outadded1 + / 0 −
@@ -0,0 +1 @@+Checked 1 file
tests/specs/lint/lint_plugin_shutdown/plugin.tsadded15 + / 0 −
@@ -0,0 +1,15 @@+export default {+  name: "test-plugin",+  rules: {+    "my-rule": {+      create() {+        // The plugin host doesn't run its event loop after linting a file, so+        // this work is still pending when `deno lint` shuts the host down.+        setTimeout(() => {}, 1_000_000);+        setInterval(() => {}, 1_000);+        Deno.readTextFile(import.meta.filename!);+        return {};+      },+    },+  },+};