diff --git a/ext/node/polyfills/internal/child_process.ts b/ext/node/polyfills/internal/child_process.ts index 4c7124bc7afae7..fc19767d2460fa 100644 --- a/ext/node/polyfills/internal/child_process.ts +++ b/ext/node/polyfills/internal/child_process.ts @@ -74,8 +74,10 @@ import { kExtraStdio, kInputOption, kIpc, + kKillSignalOption, kNeedsNpmProcessState, kSerialization, + kTimeoutOption, } from "ext:deno_process/40_process.js"; export function mapValues( @@ -1640,6 +1642,8 @@ export function spawnSync( uid, gid, maxBuffer, + timeout, + killSignal, windowsVerbatimArguments = false, } = options; const [ @@ -1674,6 +1678,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; @@ -1687,11 +1693,18 @@ export function spawnSync( result.error = _createSpawnError("ENOBUFS", command, args, true); } + // deno-lint-ignore no-explicit-any + if ((output as any)._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; result.stdout = stdout; diff --git a/ext/process/40_process.js b/ext/process/40_process.js index b1a2c5bf85ac50..d0785774802c01 100644 --- a/ext/process/40_process.js +++ b/ext/process/40_process.js @@ -17,6 +17,7 @@ const { ArrayPrototypeMap, ArrayPrototypeSlice, TypeError, + ObjectDefineProperty, ObjectEntries, SafeArrayIterator, String, @@ -45,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); @@ -471,13 +476,15 @@ function spawnSyncInner(command, { windowsRawArguments = false, [kInputOption]: input, [kNeedsNpmProcessState]: needsNpmProcessState = false, + [kTimeoutOption]: timeout, + [kKillSignalOption]: killSignal, } = { __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), @@ -493,8 +500,17 @@ function spawnSyncInner(command, { detached: false, needsNpmProcessState, input, - }); - return { + }; + if (timeout != null && timeout > 0) { + spawnArgs.timeout = timeout; + if (killSignal != null) { + spawnArgs.killSignal = typeof killSignal === "number" + ? String(killSignal) + : killSignal; + } + } + const result = op_spawn_sync(spawnArgs); + const output = { success: result.status.success, code: result.status.code, signal: result.status.signal, @@ -511,6 +527,18 @@ function spawnSyncInner(command, { return result.stderr; }, }; + // 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; } class Command { @@ -597,6 +625,8 @@ export { Command, kill, kInputOption, + kKillSignalOption, + kTimeoutOption, Process, run, spawn, diff --git a/ext/process/lib.rs b/ext/process/lib.rs index 2090487b21df47..d2c69b68e9071a 100644 --- a/ext/process/lib.rs +++ b/ext/process/lib.rs @@ -18,6 +18,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; @@ -250,6 +255,11 @@ pub struct SpawnArgs { extra_stdio: Vec, detached: bool, needs_npm_process_state: bool, + + #[serde(default)] + timeout: Option, + #[serde(default)] + kill_signal: Option, } #[derive(Deserialize)] @@ -393,9 +403,11 @@ impl TryFrom for ChildStatus { #[derive(ToV8)] pub struct SpawnOutput { + pid: u32, status: ChildStatus, stdout: Option, stderr: Option, + killed_by_timeout: bool, } type CreateCommand = ( @@ -1110,6 +1122,8 @@ fn op_spawn_sync( let stdout = matches!(args.stdio.stdout, StdioOrRid::Stdio(Stdio::Piped)); let stderr = matches!(args.stdio.stderr, StdioOrRid::Stdio(Stdio::Piped)); let input = args.input.clone(); + let timeout = args.timeout; + let kill_signal_str = args.kill_signal.clone(); let (mut command, _, _, _) = create_command(state, args, "Deno.Command().outputSync()")?; @@ -1117,6 +1131,7 @@ fn op_spawn_sync( command: command.get_program().to_string_lossy().into_owned(), error: Box::new(e.into()), })?; + let pid = child.id(); if let Some(input) = input { let mut stdin = child.stdin.take().ok_or_else(|| { ProcessError::Io(std::io::Error::other("stdin is not available")) @@ -1124,6 +1139,59 @@ fn op_spawn_sync( stdin.write_all(&input)?; stdin.flush()?; } + + // 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 + { + let child_id = child.id(); + let killed = killed_by_timeout.clone(); + let cancel2 = cancel.clone(); + let kill_signal = kill_signal_str.as_deref().unwrap_or("SIGTERM"); + #[cfg(unix)] + let signal = deno_signals::signal_str_to_int(kill_signal) + .ok() + .or_else(|| kill_signal.parse::().ok()) + .unwrap_or(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); + #[cfg(unix)] + // SAFETY: child_id is a valid PID from the spawned child process. + 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); + } + } + }); + } + let output = child .wait_with_output() @@ -1131,7 +1199,18 @@ fn op_spawn_sync( 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); Ok(SpawnOutput { + pid, status: output.status.try_into()?, stdout: if stdout { Some(output.stdout.into()) @@ -1143,6 +1222,7 @@ fn op_spawn_sync( } else { None }, + killed_by_timeout: timed_out, }) } diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 4bc269515ad20a..0b219646f77ef6 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -254,6 +254,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": {}, @@ -2547,6 +2548,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": {