Repository: denoland/deno
Pull request: fix(watch): dispatch unload and process exit events on restart

## Summary

- Fix `unload` event not firing when watch mode restarts a script blocked on a top-level await
- Fix Node.js `process.on("exit")` handlers not firing on watch restart

### What changed

In `FileWatcherModuleExecutor::execute()`, `pending_unload` was previously set
*after* `execute_main_module()` and `dispatch_load_event()`. This meant that if
the operation future was cancelled during a top-level await (the module never
finished evaluating), `pending_unload` stayed `false` and the `Drop` impl never
dispatched the `unload` event.

Now `pending_unload` is set *before* `execute_main_module()` and reset to
`false` only on module evaluation error (syntax errors, import failures). When
the future is cancelled (dropped), `pending_unload` remains `true` and `Drop`
correctly dispatches both the `unload` event and the `process exit` event.

The `Drop` impl also now calls `dispatch_process_exit_event()` alongside
`dispatch_unload_event()`, so Node.js `process.on("exit")` handlers fire on
restart — matching the normal shutdown path.

Towards #30912

## Test plan

- Added `run_watch_unload_on_restart` integration test — verifies `addEventListener("unload", ...)` fires on file change restart
- Added `run_watch_process_exit_on_restart` integration test — verifies `process.on("exit", ...)` fires on file change restart
- Manually verified: unload fires during blocking top-level await
- Manually verified: both unload and process exit fire on Ctrl+C (after grace period)
- Manually verified: unload does NOT fire on module evaluation errors (syntax errors)

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

Changed files:
filename: cli/worker.rs
patch: @@ -194,9 +194,16 @@ impl CliMainWorker {
       /// Execute the given main module emitting load and unload events before and after execution
       /// respectively.
       pub async fn execute(&mut self) -> Result<(), CoreError> {
-        self.inner.execute_main_module().await?;
-        self.inner.worker.dispatch_load_event()?;
+        // Set pending_unload before module execution so that if the future
+        // is cancelled during a top-level await, Drop will still dispatch
+        // the unload event for any handlers registered during partial
+        // module evaluation.
         self.pending_unload = true;
+        if let Err(e) = self.inner.execute_main_module().await {
+          self.pending_unload = false;
+          return Err(e);
+        }
+        self.inner.worker.dispatch_load_event()?;
 
         let result = loop {
           match self.inner.worker.run_event_loop(false).await {
@@ -228,6 +235,7 @@ impl CliMainWorker {
       fn drop(&mut self) {
         if self.pending_unload {
           let _ = self.inner.worker.dispatch_unload_event();
+          let _ = self.inner.worker.dispatch_process_exit_event();
         }
       }
     }

filename: tests/integration/watcher_tests.rs
patch: @@ -1347,6 +1347,103 @@ async fn test_watch_sigint() {
   assert_eq!(exit_status.code(), Some(130));
 }
 
+/// Test that the "unload" event fires on watch restart when the
+/// event loop is running (e.g. with setInterval keeping it alive).
+#[test(flaky)]
+async fn run_watch_unload_on_restart() {
+  let t = TempDir::new();
+  let file_to_watch = t.path().join("file_to_watch.js");
+  file_to_watch.write(
+    r#"
+      addEventListener("unload", () => {
+        console.log("unload event fired");
+      });
+      setInterval(() => {}, 1000);
+    "#,
+  );
+
+  let mut child = util::deno_cmd()
+    .current_dir(t.path())
+    .arg("run")
+    .arg("--watch")
+    .arg("-L")
+    .arg("debug")
+    .arg("--allow-all")
+    .arg(&file_to_watch)
+    .env("NO_COLOR", "1")
+    .piped_output()
+    .spawn()
+    .unwrap();
+  let (mut stdout_lines, mut stderr_lines) = child_lines(&mut child);
+
+  wait_for_watcher("file_to_watch.js", &mut stderr_lines).await;
+
+  // Trigger a restart by modifying the file
+  file_to_watch.write(
+    r#"
+      addEventListener("unload", () => {
+        console.log("unload event fired");
+      });
+      setInterval(() => {}, 1000);
+      // changed
+    "#,
+  );
+
+  // The unload handler should fire before the process restarts
+  wait_contains("unload event fired", &mut stdout_lines).await;
+  wait_contains("Restarting", &mut stderr_lines).await;
+  check_alive_then_kill(child);
+}
+
+/// Test that Node.js process "exit" event fires on watch restart.
+#[test(flaky)]
+async fn run_watch_process_exit_on_restart() {
+  let t = TempDir::new();
+  let file_to_watch = t.path().join("file_to_watch.js");
+  file_to_watch.write(
+    r#"
+      import process from "node:process";
+      process.on("exit", () => {
+        console.log("process exit fired");
+      });
+      setInterval(() => {}, 1000);
+    "#,
+  );
+
+  let mut child = util::deno_cmd()
+    .current_dir(t.path())
+    .arg("run")
+    .arg("--watch")
+    .arg("-L")
+    .arg("debug")
+    .arg("--allow-all")
+    .arg(&file_to_watch)
+    .env("NO_COLOR", "1")
+    .piped_output()
+    .spawn()
+    .unwrap();
+  let (mut stdout_lines, mut stderr_lines) = child_lines(&mut child);
+
+  wait_for_watcher("file_to_watch.js", &mut stderr_lines).await;
+
+  // Trigger a restart by modifying the file
+  file_to_watch.write(
+    r#"
+      import process from "node:process";
+      process.on("exit", () => {
+        console.log("process exit fired");
+      });
+      setInterval(() => {}, 1000);
+      // changed
+    "#,
+  );
+
+  // The process exit handler should fire before the process restarts
+  wait_contains("process exit fired", &mut stdout_lines).await;
+  wait_contains("Restarting", &mut stderr_lines).await;
+  check_alive_then_kill(child);
+}
+
 #[test(flaky)]
 async fn bench_watch_basic() {
   let t = TempDir::new();