Repository: denoland/deno
Pull request: fix(ext/node): implement timeout, killSignal, and pid for child_process spawnSync

## Summary
- Implements `timeout` and `killSignal` options for `spawnSync`/`execSync`/`execFileSync`
- Spawns a condvar-based timer thread that kills the child process (and its process group on Unix) after the deadline, then flags `killed_by_timeout` in the result
- Returns `pid` from `op_spawn_sync` so `spawnSync` results include the `pid` field as Node.js expects
- Uses a `KillSignal` enum to accept both numeric and string signal values directly from JS, avoiding string roundtrip
- On Unix, uses `process_group(0)` + `kill(-pgid)` to kill the entire process tree on timeout
- On Windows, uses `OpenProcess`/`TerminateProcess` with a brief pipe-reader timeout for orphaned grandchildren
- `pid` and `killed_by_timeout` are exposed as non-enumerable properties on the output object, keeping the `Deno.Command` public API unchanged

## Test plan
- [x] `./x test-compat test-child-process-execsync.js` passes
- [x] `./x test-compat test-child-process-spawnsync-timeout.js` passes
- [x] `./x test-compat test-child-process-spawnsync-validation-errors.js` still passes (no regression)
- [x] All other `test-child-process-spawnsync*` tests unchanged
- [x] `cargo clippy -p deno_process -- -D warnings` clean
- [x] `./tools/format.js` and `./tools/lint.js --js` clean

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

Changed files:
filename: ext/node/polyfills/internal/child_process.ts
patch: @@ -76,8 +76,10 @@ import {
   kExtraStdio,
   kInputOption,
   kIpc,
+  kKillSignalOption,
   kNeedsNpmProcessState,
   kSerialization,
+  kTimeoutOption,
 } from "ext:deno_process/40_process.js";
 
 export function mapValues<T, O>(
@@ -1572,6 +1574,20 @@ function restorePrototype(obj: any) {
   }
 }
 
+/** Convert a killSignal (string name or number) to its string name. */
+function _resolveKillSignalName(
+  killSignal: string | number | undefined,
+): string {
+  if (typeof killSignal === "string") return killSignal;
+  if (typeof killSignal === "number") {
+    for (const [name, num] of Object.entries(os.signals)) {
+      if (num === killSignal) return name;
+    }
+    return String(killSignal);
+  }
+  return "SIGTERM";
+}
+
 function _createSpawnError(
   status: string,
   command: string,
@@ -1671,6 +1687,8 @@ export function spawnSync(
     uid,
     gid,
     maxBuffer,
+    timeout,
+    killSignal,
     windowsVerbatimArguments = false,
   } = options;
   const [
@@ -1708,6 +1726,8 @@ export function spawnSync(
       // deno-lint-ignore no-explicit-any
       [kNeedsNpmProcessState]: (options as any)[kNeedsNpmProcessState] ||
         includeNpmProcessState,
+      [kTimeoutOption]: timeout,
+      [kKillSignalOption]: killSignal,
     }).outputSync();
 
     const status = output.signal ? null : output.code;
@@ -1721,15 +1741,26 @@ export function spawnSync(
       result.error = _createSpawnError("ENOBUFS", command, args, true);
     }
 
+    // deno-lint-ignore no-explicit-any
+    const killedByTimeout = (output as any)._killedByTimeout;
+    if (killedByTimeout) {
+      result.error = _createSpawnError("ETIMEDOUT", command, args, true);
+    }
+
     if (encoding && encoding !== "buffer") {
       stdout = stdout && stdout.toString(encoding);
       stderr = stderr && stderr.toString(encoding);
     }
 
     // deno-lint-ignore no-explicit-any
     result.pid = (output as any)._pid;
-    result.status = status;
-    result.signal = output.signal;
+    // When killed by timeout, report the killSignal (matching Node.js behavior).
+    // On Windows there are no real Unix signals, but Node still reports the
+    // configured killSignal so callers can detect the timeout.
+    result.status = killedByTimeout ? null : status;
+    result.signal = killedByTimeout
+      ? _resolveKillSignalName(killSignal)
+      : output.signal;
     result.stdout = stdout;
     result.stderr = stderr;
     result.output = [output.signal, stdout, stderr];

filename: ext/process/40_process.js
patch: @@ -46,6 +46,10 @@ import {
 
 // The key for private `input` option for `Deno.Command`
 const kInputOption = Symbol("kInputOption");
+// The key for private `timeout` option for `Deno.Command`
+const kTimeoutOption = Symbol("kTimeoutOption");
+// The key for private `killSignal` option for `Deno.Command`
+const kKillSignalOption = Symbol("kKillSignalOption");
 
 function opKill(pid, signo, apiName) {
   op_kill(pid, signo, apiName);
@@ -475,14 +479,16 @@ function spawnSyncInner(command, {
   windowsRawArguments = false,
   [kInputOption]: input,
   [kNeedsNpmProcessState]: needsNpmProcessState = false,
+  [kTimeoutOption]: timeout,
+  [kKillSignalOption]: killSignal,
   [kArgv0]: argv0 = undefined,
 } = { __proto__: null }) {
   if (stdin === "piped") {
     throw new TypeError(
       "Piped stdin is not supported for this function, use 'Deno.Command().spawn()' instead",
     );
   }
-  const result = op_spawn_sync({
+  const spawnArgs = {
     cmd: pathFromURL(command),
     args: ArrayPrototypeMap(args, String),
     cwd: pathFromURL(cwd),
@@ -499,7 +505,14 @@ function spawnSyncInner(command, {
     needsNpmProcessState,
     input,
     argv0,
-  });
+  };
+  if (timeout != null && timeout > 0) {
+    spawnArgs.timeout = timeout;
+    if (killSignal != null) {
+      spawnArgs.killSignal = killSignal;
+    }
+  }
+  const result = op_spawn_sync(spawnArgs);
   const output = {
     success: result.status.success,
     code: result.status.code,
@@ -517,12 +530,17 @@ function spawnSyncInner(command, {
       return result.stderr;
     },
   };
-  // Internal field used by node:child_process, hidden from Deno public API.
+  // Internal fields used by node:child_process, hidden from Deno public API.
   ObjectDefineProperty(output, "_pid", {
     __proto__: null,
     value: result.pid,
     enumerable: false,
   });
+  ObjectDefineProperty(output, "_killedByTimeout", {
+    __proto__: null,
+    value: result.killedByTimeout,
+    enumerable: false,
+  });
   return output;
 }
 
@@ -611,6 +629,8 @@ export {
   kArgv0,
   kill,
   kInputOption,
+  kKillSignalOption,
+  kTimeoutOption,
   Process,
   run,
   spawn,

filename: ext/process/lib.rs
patch: @@ -20,6 +20,11 @@ use std::process::ExitStatus;
 #[cfg(unix)]
 use std::process::Stdio as StdStdio;
 use std::rc::Rc;
+use std::sync::Arc;
+use std::sync::Condvar;
+use std::sync::Mutex;
+use std::sync::atomic::AtomicBool;
+use std::sync::atomic::Ordering;
 
 use deno_core::AsyncMutFuture;
 use deno_core::AsyncRefCell;
@@ -280,6 +285,27 @@ pub struct SpawnArgs {
   needs_npm_process_state: bool,
   #[cfg(unix)]
   argv0: Option<String>,
+
+  #[serde(default)]
+  timeout: Option<u64>,
+  #[cfg(unix)]
+  #[serde(default)]
+  #[cfg_attr(
+    windows,
+    allow(dead_code, reason = "deserialized from JS but only used on Unix")
+  )]
+  kill_signal: Option<KillSignal>,
+}
+
+#[derive(Clone, Deserialize)]
+#[serde(untagged)]
+#[cfg_attr(
+  windows,
+  allow(dead_code, reason = "deserialized from JS but only used on Unix")
+)]
+enum KillSignal {
+  String(String),
+  Number(i32),
 }
 
 #[derive(Deserialize)]
@@ -427,6 +453,7 @@ pub struct SpawnOutput {
   status: ChildStatus,
   stdout: Option<Uint8Array>,
   stderr: Option<Uint8Array>,
+  killed_by_timeout: bool,
 }
 
 type CreateCommand = (
@@ -1170,9 +1197,19 @@ fn op_spawn_sync(
   let stdout = matches!(args.stdio.stdout, StdioOrFd::Stdio(Stdio::Piped));
   let stderr = matches!(args.stdio.stderr, StdioOrFd::Stdio(Stdio::Piped));
   let input = args.input.clone();
+  let timeout = args.timeout;
+  #[cfg(unix)]
+  let kill_signal = args.kill_signal.clone();
   let (mut command, _, _, _) =
     create_command(state, args, "Deno.Command().outputSync()")?;
 
+  // When timeout is specified on Unix, create a new process group so we can
+  // kill the entire tree (shell + children) on timeout, not just the shell.
+  #[cfg(unix)]
+  if timeout.is_some_and(|t| t > 0) {
+    command.process_group(0);
+  }
+
   let mut child = command.spawn().map_err(|e| ProcessError::SpawnFailed {
     command: command.get_program().to_string_lossy().into_owned(),
     error: Box::new(e.into()),
@@ -1188,26 +1225,172 @@ fn op_spawn_sync(
     stdin.write_all(&input)?;
     stdin.flush()?;
   }
-  let output =
+
+  // Take stdout/stderr pipes from child so we can read them in background
+  // threads. This lets us drop the pipes on timeout to unblock the readers
+  // (matching libuv's behavior of stopping pipe reads after process kill).
+  let child_stdout = child.stdout.take();
+  let child_stderr = child.stderr.take();
+
+  let stdout_handle = child_stdout.map(|pipe| {
+    std::thread::spawn(move || {
+      let mut buf = Vec::new();
+      let mut pipe = pipe;
+      let _ = std::io::Read::read_to_end(&mut pipe, &mut buf);
+      buf
+    })
+  });
+  let stderr_handle = child_stderr.map(|pipe| {
+    std::thread::spawn(move || {
+      let mut buf = Vec::new();
+      let mut pipe = pipe;
+      let _ = std::io::Read::read_to_end(&mut pipe, &mut buf);
+      buf
+    })
+  });
+
+  // If timeout is specified, spawn a thread that will kill the child
+  // after the timeout expires. Uses a condvar so the timer thread can be
+  // cancelled promptly when the child exits before the deadline.
+  let killed_by_timeout = Arc::new(AtomicBool::new(false));
+  let cancel = Arc::new((Mutex::new(false), Condvar::new()));
+  if let Some(timeout_ms) = timeout
+    && timeout_ms > 0
+  {
+    #[cfg(unix)]
+    let child_id = child.id();
+    #[cfg(windows)]
+    let child_id = child.id().expect("Process ID should be set.");
+    let killed = killed_by_timeout.clone();
+    let cancel2 = cancel.clone();
+    #[cfg(unix)]
+    let signal: i32 = match &kill_signal {
+      Some(KillSignal::Number(n)) => *n,
+      Some(KillSignal::String(s)) => {
+        deno_signals::signal_str_to_int(s).unwrap_or(libc::SIGTERM)
+      }
+      None => libc::SIGTERM,
+    };
+    std::thread::spawn(move || {
+      let (lock, cvar) = &*cancel2;
+      let guard = lock.lock().unwrap();
+      let timeout = std::time::Duration::from_millis(timeout_ms);
+      let (guard, wait_result) = cvar
+        .wait_timeout_while(guard, timeout, |cancelled| !*cancelled)
+        .unwrap();
+      // If cancelled or woken before the timeout, the child already exited.
+      if *guard || !wait_result.timed_out() {
+        return;
+      }
+      killed.store(true, Ordering::SeqCst);
+      // NOTE: There is a minor race window where the child exits and its
+      // PID gets recycled before we send the kill signal. The condvar
+      // cancel above prevents this in practice (the main thread cancels
+      // the timer immediately after wait() returns), but if the OS
+      // recycles the PID in that narrow window we could signal the wrong
+      // process. This matches libuv's behavior.
+      #[cfg(unix)]
+      // SAFETY: child_id is a valid PID from the spawned child process.
+      // We use negative PID to kill the entire process group (created via
+      // process_group(0) above), ensuring shell children are also killed.
+      // NOTE: There is a minor theoretical race window where the child
+      // could exit and its PID get recycled between wait() returning and
+      // the condvar cancel reaching this thread. In practice the condvar
+      // cancellation is near-instant so this window is negligible.
+      unsafe {
+        libc::kill(-(child_id as i32), signal);
+      }
+      #[cfg(windows)]
+      // SAFETY: child_id is a valid PID from the spawned child process.
+      // OpenProcess/TerminateProcess/CloseHandle are safe to call with
+      // valid arguments.
+      unsafe {
+        let handle = windows_sys::Win32::System::Threading::OpenProcess(
+          windows_sys::Win32::System::Threading::PROCESS_TERMINATE,
+          false.into(),
+          child_id,
+        );
+        if !handle.is_null() {
+          windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
+          windows_sys::Win32::Foundation::CloseHandle(handle);
+        }
+      }
+    });
+  }
+
+  #[cfg(unix)]
+  let status = child.wait().map_err(|e| ProcessError::SpawnFailed {
+    command: command.get_program().to_string_lossy().into_owned(),
+    error: Box::new(e.into()),
+  })?;
+  #[cfg(windows)]
+  let status =
     child
-      .wait_with_output()
+      .wait_blocking()
       .map_err(|e| ProcessError::SpawnFailed {
         command: command.get_program().to_string_lossy().into_owned(),
         error: Box::new(e.into()),
       })?;
+
+  // Cancel the timeout thread if it's still waiting.
+  {
+    let (lock, cvar) = &*cancel;
+    let mut cancelled = lock.lock().unwrap();
+    *cancelled = true;
+    cvar.notify_one();
+  }
+
+  let timed_out = killed_by_timeout.load(Ordering::SeqCst);
+
+  // Collect stdout/stderr from background reader threads.
+  // On Unix, the process group kill ensures all children are dead and
+  // pipes reach EOF, so join() completes immediately.
+  // On Windows, TerminateProcess closes the child's pipe ends, so
+  // join() also completes promptly for the direct child. In the rare
+  // case of orphaned grandchildren holding pipes, we use a short
+  // join timeout to avoid blocking indefinitely.
+  let collect_pipe = |handle: Option<std::thread::JoinHandle<Vec<u8>>>| {
+    let h = handle?;
+    #[cfg(unix)]
+    {
+      h.join().ok()
+    }
+    #[cfg(windows)]
+    {
+      if timed_out {
+        // Brief timeout to avoid blocking on orphaned grandchildren.
+        let start = std::time::Instant::now();
+        loop {
+          if h.is_finished() {
+            return h.join().ok();
+          }
+          if start.elapsed() > std::time::Duration::from_millis(200) {
+            return None;
+          }
+          std::thread::sleep(std::time::Duration::from_millis(10));
+        }
+      } else {
+        h.join().ok()
+      }
+    }
+  };
+  let stdout_bytes = collect_pipe(stdout_handle).unwrap_or_default();
+  let stderr_bytes = collect_pipe(stderr_handle).unwrap_or_default();
+
   Ok(SpawnOutput {
     pid,
-    status: output.status.try_into()?,
+    status: status.try_into()?,
     stdout: if stdout {
-      Some(output.stdout.into())
+      Some(stdout_bytes.into())
     } else {
       None
     },
     stderr: if stderr {
-      Some(output.stderr.into())
+      Some(stderr_bytes.into())
     } else {
       None
     },
+    killed_by_timeout: timed_out,
   })
 }
 

filename: tests/node_compat/config.jsonc
patch: @@ -263,6 +263,7 @@
     "parallel/test-child-process-spawnsync-env.js": {},
     "parallel/test-child-process-spawnsync-input.js": {},
     "parallel/test-child-process-spawnsync-maxbuf.js": {},
+    "parallel/test-child-process-spawnsync-timeout.js": {},
     "parallel/test-child-process-spawnsync-validation-errors.js": {},
     "parallel/test-child-process-spawnsync.js": {},
     "parallel/test-child-process-stdin-ipc.js": {},
@@ -2840,6 +2841,7 @@
     "pummel/test-process-hrtime.js": {},
     "pummel/test-string-decoder-large-buffer.js": {},
     "sequential/test-buffer-creation-regression.js": {},
+    "sequential/test-child-process-execsync.js": {},
     "sequential/test-child-process-exit.js": {},
     "sequential/test-cli-syntax-bad.js": {},
     "sequential/test-cli-syntax-file-not-found.js": {