Repository: denoland/deno
Pull request: fix(inspector): send executionContextDestroyed on process.exit()

## Summary

- When `process.exit()` is called during an inspector debugging session, broadcast `Runtime.executionContextDestroyed` CDP notification to all connected sessions before exiting
- Wait for debugger sessions to disconnect after sending the notification, matching Node.js behavior
- Align the "waiting for disconnect" stderr message with Node.js's expected format (`"Waiting for the debugger to disconnect..."`)
- Enable `test-inspector-esm.js` and `test-inspector-console.js` node compat tests

## Details

Previously, `process.exit()` → `Deno.exit()` → `std::process::exit()` would terminate immediately, skipping the normal event-loop shutdown path where V8's `context_destroyed` sends `Runtime.executionContextDestroyed`. Debugger clients (like the Node.js test suite's inspector helper) rely on this notification to know when execution is complete.

The fix adds an `OpExitCallback` in the CLI worker that:
1. Checks if any inspector session with `wait_for_disconnect` is active
2. Broadcasts `Runtime.executionContextDestroyed` via session send callbacks (no V8 scope needed)
3. Prints the disconnect message matching Node.js format
4. Blocks until all sessions disconnect

This builds on #32714 which implemented the `NodeRuntime` CDP domain.

## Test plan

- [x] `./x test-compat test-inspector-esm` passes
- [x] `./x test-compat test-inspector-console` passes
- [x] `cargo test inspector_test` passes (existing inspector unit tests)
- [x] `./x test-compat test-inspector-open-dispose` passes (existing compat test)
- [x] `./tools/lint.js` and `./tools/format.js` pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: cli/worker.rs
patch: @@ -94,6 +94,7 @@ impl CliMainWorker {
     {
       let coverage_for_exit = coverage_cell.clone();
       let profiler_for_exit = profiler_cell.clone();
+      let inspector = self.worker.js_runtime().inspector();
       let mut cbs = OpExitCallbacks::default();
       cbs.push(Box::new(move || {
         if let Some(mut cc) = coverage_for_exit.borrow_mut().take() {
@@ -105,6 +106,21 @@ impl CliMainWorker {
           let _ = cp.stop_profiling();
         }
       }));
+      // When an inspector session is connected, notify it that the
+      // execution context is being destroyed before exiting. Without this,
+      // process.exit() would call std::process::exit() immediately,
+      // skipping the normal event-loop shutdown path where V8's
+      // context_destroyed is called and Runtime.executionContextDestroyed
+      // is sent to debuggers.
+      cbs.push(Box::new(move || {
+        let sessions_state = inspector.sessions_state();
+        if sessions_state.has_nonblocking_wait_for_disconnect {
+          inspector.broadcast_context_destroyed();
+          // Match Node.js message format that debugger clients rely on
+          log::info!("Waiting for the debugger to disconnect...");
+          inspector.wait_for_sessions_disconnect();
+        }
+      }));
       self.worker.js_runtime().op_state().borrow_mut().put(cbs);
     }
 

filename: libs/core/inspector.rs
patch: @@ -678,6 +678,45 @@ impl JsRuntimeInspector {
     );
   }
 
+  /// Broadcast `Runtime.executionContextDestroyed` to all connected sessions
+  /// without requiring a V8 scope. This is used during `process.exit()` to
+  /// notify debuggers that the execution context is being torn down before
+  /// calling `std::process::exit()`, which would skip the normal event-loop
+  /// shutdown path where V8's `context_destroyed` is called.
+  pub fn broadcast_context_destroyed(&self) {
+    let sessions = self.state.sessions.borrow();
+    for session in sessions.local.values() {
+      (session.state.send)(InspectorMsg::notification(json!({
+        "method": "Runtime.executionContextDestroyed",
+        "params": { "executionContextId": Self::CONTEXT_GROUP_ID }
+      })));
+    }
+  }
+
+  /// Block the current thread until all inspector sessions have disconnected.
+  /// Used after `broadcast_context_destroyed` to give debuggers a chance to
+  /// process the notification before the process exits.
+  ///
+  /// Note: this intentionally does not set a blocking flag on `poll_sessions`.
+  /// Unlike `wait_for_session` (which blocks waiting for a *new* WS
+  /// connection handled by a separate server thread), disconnect detection
+  /// requires the async I/O reactor to process WebSocket close frames.
+  /// Parking the thread would prevent those events from being delivered.
+  /// The loop spins with `poll_sessions(None)` which returns quickly when
+  /// idle, yielding a brief busy-wait that is acceptable given this only
+  /// runs during process exit.
+  pub fn wait_for_sessions_disconnect(&self) {
+    loop {
+      {
+        let sessions = self.state.sessions.borrow();
+        if sessions.local.is_empty() && sessions.established.is_empty() {
+          break;
+        }
+      }
+      let _ = self.state.poll_sessions(None);
+    }
+  }
+
   pub fn sessions_state(&self) -> SessionsState {
     self.state.sessions.borrow().sessions_state()
   }

filename: runtime/worker.rs
patch: @@ -131,9 +131,8 @@ pub fn make_wait_for_inspector_disconnect_callback() -> Box<dyn Fn()> {
     if !has_notified_of_inspector_disconnect
       .swap(true, std::sync::atomic::Ordering::SeqCst)
     {
-      log::info!(
-        "Program finished. Waiting for inspector to disconnect to exit the process..."
-      );
+      // Match Node.js message format that debugger clients rely on
+      log::info!("Waiting for the debugger to disconnect...");
     }
   })
 }

filename: tests/node_compat/config.jsonc
patch: @@ -1326,9 +1326,8 @@
     // "parallel/test-inspector-network-arbitrary-data.js": {},
     // "parallel/test-inspector-emit-protocol-event.js": {},
 
-    // TODO(bartlomieju): depends on `NodeRuntime` CDP domain
-    // "parallel/test-inspector-esm.js": {},
-    // "parallel/test-inspector-console.js": {},
+    "parallel/test-inspector-esm.js": {},
+    "parallel/test-inspector-console.js": {},
     // "parallel/test-inspector-async-stack-traces-promise-then.js": {},
     // "parallel/test-inspector-exception.js": {},
     // "parallel/test-inspector-async-hook-setup-at-inspect-brk.js": {},