Skip to main content

nodejs/stdlib/
child_process.rs

1//! Node `child_process` module — real subprocess execution via
2//! `std::process::Command`.
3//!
4//! The synchronous entry points (`execSync`, `spawnSync`, `execFileSync`) are
5//! fully implemented: they spawn the child with piped stdio, wait for it, and
6//! return its captured output. `stdout`/`stderr` are returned as `Buffer`s by
7//! default (built through `buffer::from_bytes`, identical to the `fs` module's
8//! byte returns) or as strings when an `encoding` other than `"buffer"` is
9//! given in the options object.
10//!
11//! The asynchronous forms are backed synchronously here because node-js has no
12//! socket-driven child event loop:
13//!   * `exec(cmd, cb)` runs the command to completion, then delivers the result
14//!     through its callback `(error, stdout, stderr)` scheduled as a microtask
15//!     (`queue_micro`), matching Node's "callback fires after the current tick"
16//!     ordering. `stdout`/`stderr` are strings, as Node's `exec` default.
17//!   * `execFile(file, args, cb)` is `exec` without a shell — `file` is run
18//!     directly with the `args` array — and additionally returns a (non-live)
19//!     ChildProcess-shaped object carrying the collected result.
20//!   * `spawn(cmd, args)` runs the command to completion up front and returns a
21//!     minimal ChildProcess-shaped object carrying the already-collected
22//!     `pid`, `exitCode`, `stdout` and `stderr`. LIMITATION: because these run
23//!     synchronously, the returned object is not live — `.on('close'|'exit', …)`
24//!     listeners registered by the caller after the call do not fire (the
25//!     process has already finished and its output is exposed as properties).
26//!
27//! `fork(modulePath)` is the exception: it spawns THIS `node` executable on
28//! `modulePath` as a genuinely live child and returns a live ChildProcess
29//! emitter that fires `exit`/`close` when the process terminates and supports
30//! `.kill()`. Its IPC channel (`.send()` / `process.on('message')`) is NOT
31//! implemented — see the `fork` fn doc for why.
32
33use super::arg_str;
34use crate::host::{with_host, IoTask, JsObj};
35use fusevm::Value;
36use indexmap::IndexMap;
37use std::process::{Child, Command, Stdio};
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::sync::mpsc::Sender;
40use std::sync::{Arc, Mutex};
41
42pub const METHODS: &[&str] = &[
43    "execSync",
44    "spawnSync",
45    "execFileSync",
46    "exec",
47    "execFile",
48    "spawn",
49    "fork",
50];
51
52/// Instance method names for the `ChildProcess` `@@native` tag, exposed to
53/// `stdlib::instance_has_method` (property reads that yield a bound method).
54pub const CHILD_PROCESS_METHODS: &[&str] = &["kill", "send", "disconnect", "ref", "unref"];
55
56pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
57    Some(match method {
58        "execSync" => exec_sync(args),
59        "spawnSync" => spawn_sync(args),
60        "execFileSync" => exec_file_sync(args),
61        "exec" => exec(args),
62        "execFile" => exec_file(args),
63        "spawn" => spawn(args),
64        "fork" => fork(args),
65        _ => return None,
66    })
67}
68
69// ── live ChildProcess registry (used by `fork`) ──────────────────────────────
70
71/// Process-global id source for live (`fork`ed) children.
72static NEXT_CHILD_ID: AtomicU64 = AtomicU64::new(1);
73
74/// Main-thread record for a live child: its emitter object and a shared handle
75/// the waiter thread polls (`try_wait`) and `kill` signals through.
76struct ChildRec {
77    emitter: Value,
78    handle: Arc<Mutex<Option<Child>>>,
79}
80
81thread_local! {
82    static CHILDREN: std::cell::RefCell<std::collections::HashMap<u64, ChildRec>> =
83        std::cell::RefCell::new(std::collections::HashMap::new());
84}
85
86/// Build a `ChildProcess`-shaped emitter object (tagged `@@native = "ChildProcess"`)
87/// carrying the given extra properties, sharing the EventEmitter shape.
88fn child_object(extra: IndexMap<String, Value>) -> Value {
89    super::net::new_emitter_object("ChildProcess", extra)
90}
91
92/// Result of running a child to completion: exit code (`None` if terminated by a
93/// signal), captured stdout, captured stderr.
94struct Run {
95    status: Option<i32>,
96    stdout: Vec<u8>,
97    stderr: Vec<u8>,
98    pid: u32,
99}
100
101/// Spawn `program` with `args`, capture both pipes, optionally feed `input` to
102/// stdin, and wait for exit.
103fn run(program: &str, args: &[String], input: Option<&[u8]>) -> std::io::Result<Run> {
104    let mut cmd = Command::new(program);
105    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
106    cmd.stdin(if input.is_some() {
107        Stdio::piped()
108    } else {
109        Stdio::inherit()
110    });
111    let mut child = cmd.spawn()?;
112    let pid = child.id();
113    if let Some(bytes) = input {
114        if let Some(mut stdin) = child.stdin.take() {
115            use std::io::Write as _;
116            let _ = stdin.write_all(bytes);
117            // Drop stdin to send EOF so the child (e.g. `cat`/`wc`) can finish.
118        }
119    }
120    let out = child.wait_with_output()?;
121    Ok(Run {
122        status: out.status.code(),
123        stdout: out.stdout,
124        stderr: out.stderr,
125        pid,
126    })
127}
128
129/// The `opts.input` (stdin) bytes for a *Sync call, if provided.
130fn opts_input(args: &[Value], idx: usize) -> Option<Vec<u8>> {
131    let opts = args.get(idx)?;
132    match crate::builtins::get_property(opts, "input") {
133        Ok(Value::Undef) => None,
134        Ok(v) => Some(super::arg_str(&[v], 0).into_bytes()),
135        Err(_) => None,
136    }
137}
138
139/// `execSync(command[, options])` — run `sh -c <command>`, return stdout, and
140/// throw when the command exits non-zero (matching Node's `execSync`).
141fn exec_sync(args: &[Value]) -> Result<Value, String> {
142    let cmd = arg_str(args, 0);
143    let enc = opts_encoding(args, 1);
144    let r = run(
145        "sh",
146        &["-c".to_string(), cmd.clone()],
147        opts_input(args, 1).as_deref(),
148    )
149    .map_err(|e| format!("Error: {e}"))?;
150    if r.status != Some(0) {
151        let tail = String::from_utf8_lossy(&r.stderr);
152        return Err(format!("Error: Command failed: {cmd}\n{tail}"));
153    }
154    Ok(output_value(&r.stdout, enc.as_deref()))
155}
156
157/// `spawnSync(command, args[, options])` — return
158/// `{ status, signal, pid, stdout, stderr }` (never throws on non-zero exit).
159fn spawn_sync(args: &[Value]) -> Result<Value, String> {
160    let cmd = arg_str(args, 0);
161    let cmd_args = arg_array(args, 1);
162    let enc = opts_encoding(args, 2);
163    match run(&cmd, &cmd_args, opts_input(args, 2).as_deref()) {
164        Ok(r) => {
165            // Build the stdout/stderr values FIRST (each allocates via its own
166            // `with_host`); inserting them inside the outer `with_host` below would
167            // re-enter the host borrow and panic.
168            let stdout = output_value(&r.stdout, enc.as_deref());
169            let stderr = output_value(&r.stderr, enc.as_deref());
170            Ok(with_host(|h| {
171                let mut m = IndexMap::new();
172                m.insert("pid".into(), Value::Float(r.pid as f64));
173                m.insert(
174                    "status".into(),
175                    r.status
176                        .map(|c| Value::Float(c as f64))
177                        .unwrap_or_else(|| h.null()),
178                );
179                // A signal name is not recovered here; report null (as when the
180                // child exited normally).
181                m.insert("signal".into(), h.null());
182                m.insert("stdout".into(), stdout);
183                m.insert("stderr".into(), stderr);
184                h.new_object(m)
185            }))
186        }
187        // Failure to launch (e.g. ENOENT): Node populates `error` and leaves
188        // status/stdout/stderr null.
189        Err(e) => Ok(with_host(|h| {
190            let mut m = IndexMap::new();
191            m.insert("pid".into(), Value::Float(0.0));
192            m.insert("status".into(), h.null());
193            m.insert("signal".into(), h.null());
194            m.insert("stdout".into(), h.null());
195            m.insert("stderr".into(), h.null());
196            m.insert("error".into(), h.new_str(format!("Error: spawn {cmd} {e}")));
197            h.new_object(m)
198        })),
199    }
200}
201
202/// `execFileSync(file, args[, options])` — like `spawnSync` but returns stdout
203/// and throws on a non-zero exit.
204fn exec_file_sync(args: &[Value]) -> Result<Value, String> {
205    let file = arg_str(args, 0);
206    let cmd_args = arg_array(args, 1);
207    let enc = opts_encoding(args, 2);
208    let r = run(&file, &cmd_args, opts_input(args, 2).as_deref())
209        .map_err(|e| format!("Error: spawn {file} {e}"))?;
210    if r.status != Some(0) {
211        let tail = String::from_utf8_lossy(&r.stderr);
212        return Err(format!("Error: Command failed: {file}\n{tail}"));
213    }
214    Ok(output_value(&r.stdout, enc.as_deref()))
215}
216
217/// `exec(command[, options], callback)` — run `sh -c <command>` synchronously,
218/// then fire `callback(error, stdout, stderr)` as a microtask. Node's `exec`
219/// defaults to string output, so stdout/stderr are passed as strings.
220fn exec(args: &[Value]) -> Result<Value, String> {
221    let cmd = arg_str(args, 0);
222    // Callback is the last function-shaped argument.
223    let Some(cb) = args.last().cloned() else {
224        return Ok(Value::Undef);
225    };
226    let (err, out, errout) = match run("sh", &["-c".to_string(), cmd.clone()], None) {
227        Ok(r) => {
228            let stdout = String::from_utf8_lossy(&r.stdout).into_owned();
229            let stderr = String::from_utf8_lossy(&r.stderr).into_owned();
230            let err = if r.status == Some(0) {
231                with_host(|h| h.null())
232            } else {
233                let code = r.status.unwrap_or(-1);
234                with_host(|h| h.new_str(format!("Error: Command failed: {cmd}\nexit code {code}")))
235            };
236            (err, stdout, stderr)
237        }
238        Err(e) => (
239            with_host(|h| h.new_str(format!("Error: {e}"))),
240            String::new(),
241            String::new(),
242        ),
243    };
244    with_host(|h| {
245        let so = h.new_str(out);
246        let se = h.new_str(errout);
247        h.queue_micro(cb, vec![err, so, se]);
248    });
249    Ok(Value::Undef)
250}
251
252/// `spawn(command, args[, options])` — see the module doc comment: runs the
253/// child synchronously and returns a minimal, non-live ChildProcess-shaped
254/// object exposing the collected result. Event listeners do not fire.
255fn spawn(args: &[Value]) -> Result<Value, String> {
256    let cmd = arg_str(args, 0);
257    let cmd_args = arg_array(args, 1);
258    match run(&cmd, &cmd_args, None) {
259        Ok(r) => {
260            // Allocate the Buffers / null before building the map (`from_bytes` and
261            // `null` borrow the host — nesting inside another `with_host` panics).
262            let stdout = super::buffer::from_bytes(&r.stdout);
263            let stderr = super::buffer::from_bytes(&r.stderr);
264            let null = with_host(|h| h.null());
265            let mut m = IndexMap::new();
266            m.insert("pid".into(), Value::Float(r.pid as f64));
267            m.insert(
268                "exitCode".into(),
269                r.status
270                    .map(|c| Value::Float(c as f64))
271                    .unwrap_or_else(|| null.clone()),
272            );
273            m.insert("signalCode".into(), null);
274            m.insert("killed".into(), Value::Bool(false));
275            m.insert("connected".into(), Value::Bool(false));
276            m.insert("stdout".into(), stdout);
277            m.insert("stderr".into(), stderr);
278            Ok(child_object(m))
279        }
280        Err(e) => Err(format!("Error: spawn {cmd} {e}")),
281    }
282}
283
284/// `execFile(file[, args][, options][, callback])` — like `exec` but WITHOUT a
285/// shell: `file` is run directly with the `args` array. Runs to completion, fires
286/// `callback(error, stdout, stderr)` (strings) as a microtask, and returns a
287/// (non-live) ChildProcess-shaped object carrying the collected result.
288fn exec_file(args: &[Value]) -> Result<Value, String> {
289    let file = arg_str(args, 0);
290    let cmd_args = arg_array(args, 1);
291    // Callback is the last function-shaped argument, if any.
292    let cb = args
293        .iter()
294        .rev()
295        .find(|v| with_host(|h| crate::host::is_callable(h, v)))
296        .cloned();
297
298    match run(&file, &cmd_args, None) {
299        Ok(r) => {
300            let stdout_buf = super::buffer::from_bytes(&r.stdout);
301            let stderr_buf = super::buffer::from_bytes(&r.stderr);
302            let null = with_host(|h| h.null());
303            if let Some(cb) = cb {
304                let so = String::from_utf8_lossy(&r.stdout).into_owned();
305                let se = String::from_utf8_lossy(&r.stderr).into_owned();
306                let err = if r.status == Some(0) {
307                    null.clone()
308                } else {
309                    let code = r.status.unwrap_or(-1);
310                    with_host(|h| {
311                        h.new_str(format!("Error: Command failed: {file}\nexit code {code}"))
312                    })
313                };
314                with_host(|h| {
315                    let so = h.new_str(so);
316                    let se = h.new_str(se);
317                    h.queue_micro(cb, vec![err, so, se]);
318                });
319            }
320            let mut m = IndexMap::new();
321            m.insert("pid".into(), Value::Float(r.pid as f64));
322            m.insert(
323                "exitCode".into(),
324                r.status
325                    .map(|c| Value::Float(c as f64))
326                    .unwrap_or_else(|| null.clone()),
327            );
328            m.insert("signalCode".into(), null);
329            m.insert("killed".into(), Value::Bool(false));
330            m.insert("connected".into(), Value::Bool(false));
331            m.insert("stdout".into(), stdout_buf);
332            m.insert("stderr".into(), stderr_buf);
333            Ok(child_object(m))
334        }
335        Err(e) => {
336            if let Some(cb) = cb {
337                let msg = with_host(|h| h.new_str(format!("Error: spawn {file} {e}")));
338                let empty1 = with_host(|h| h.new_str(""));
339                let empty2 = with_host(|h| h.new_str(""));
340                with_host(|h| h.queue_micro(cb, vec![msg, empty1, empty2]));
341            }
342            Err(format!("Error: spawn {file} {e}"))
343        }
344    }
345}
346
347/// `fork(modulePath[, args][, options])` — spawn THIS `node` executable on
348/// `modulePath` as a live child (inheriting stdio), returning a live
349/// ChildProcess emitter that fires `exit`/`close` when the child terminates.
350///
351/// LIMITATION: Node's `fork` also opens an IPC channel so parent and child can
352/// exchange messages via `child.send()` / `process.on('message')`. That requires
353/// the child `node` process to detect and bind an inherited IPC file descriptor,
354/// which this runtime does not implement — so `child.send()` is a no-op that
355/// returns `false`, `child.connected` is `false`, and no `'message'` event fires.
356/// The process itself is real and live (`exit`/`close`/`kill` all work).
357fn fork(args: &[Value]) -> Result<Value, String> {
358    let module = arg_str(args, 0);
359    let extra_args = arg_array(args, 1);
360    let exe = std::env::current_exe().map_err(|e| format!("Error: fork: {e}"))?;
361
362    let mut cmd = Command::new(exe);
363    cmd.arg(&module).args(&extra_args);
364    cmd.stdin(Stdio::inherit())
365        .stdout(Stdio::inherit())
366        .stderr(Stdio::inherit());
367    let child = cmd
368        .spawn()
369        .map_err(|e| format!("Error: fork {module} {e}"))?;
370    let pid = child.id();
371
372    let id = NEXT_CHILD_ID.fetch_add(1, Ordering::Relaxed);
373    let handle = Arc::new(Mutex::new(Some(child)));
374
375    let mut extra = IndexMap::new();
376    extra.insert("@@childid".into(), Value::Float(id as f64));
377    extra.insert("pid".into(), Value::Float(pid as f64));
378    extra.insert("connected".into(), Value::Bool(false));
379    extra.insert("killed".into(), Value::Bool(false));
380    extra.insert("exitCode".into(), with_host(|h| h.null()));
381    extra.insert("signalCode".into(), with_host(|h| h.null()));
382    let emitter = child_object(extra);
383    CHILDREN.with(|c| {
384        c.borrow_mut().insert(
385            id,
386            ChildRec {
387                emitter: emitter.clone(),
388                handle: handle.clone(),
389            },
390        );
391    });
392    with_host(|h| h.incr_handle());
393
394    let io_tx = with_host(|h| h.io_sender());
395    std::thread::spawn(move || wait_child(id, handle, io_tx));
396    Ok(emitter)
397}
398
399/// Background waiter for a `fork`ed child: polls `try_wait` (so `kill` can still
400/// acquire the shared handle between polls) and posts an `IoTask` emitting
401/// `exit`/`close` once the child terminates.
402fn wait_child(id: u64, handle: Arc<Mutex<Option<Child>>>, io_tx: Sender<IoTask>) {
403    loop {
404        std::thread::sleep(std::time::Duration::from_millis(20));
405        let status = {
406            let mut g = match handle.lock() {
407                Ok(g) => g,
408                Err(_) => return,
409            };
410            match g.as_mut() {
411                Some(child) => match child.try_wait() {
412                    Ok(Some(status)) => {
413                        *g = None;
414                        Some(status.code())
415                    }
416                    Ok(None) => None,
417                    Err(_) => {
418                        *g = None;
419                        Some(None)
420                    }
421                },
422                // Handle already taken (killed + reaped elsewhere): stop polling.
423                None => return,
424            }
425        };
426        if let Some(code) = status {
427            let _ = io_tx.send(Box::new(move || on_child_exit(id, code)));
428            return;
429        }
430    }
431}
432
433/// Main-thread handler: emit `exit` then `close` on a terminated child, mark it,
434/// release its event-loop handle, and drop its registry record.
435fn on_child_exit(id: u64, code: Option<i32>) -> Result<(), String> {
436    let emitter = CHILDREN.with(|c| c.borrow().get(&id).map(|r| r.emitter.clone()));
437    let Some(emitter) = emitter else {
438        return Ok(());
439    };
440    let (code_val, null1, null2) = with_host(|h| {
441        let cv = code
442            .map(|c| Value::Float(c as f64))
443            .unwrap_or_else(|| h.null());
444        (cv, h.null(), h.null())
445    });
446    set_prop(&emitter, "exitCode", code_val.clone());
447    set_prop(&emitter, "killed", Value::Bool(true));
448    let ev_exit = with_host(|h| h.new_str("exit"));
449    let ev_close = with_host(|h| h.new_str("close"));
450    super::events::instance_call(&emitter, "emit", vec![ev_exit, code_val.clone(), null1])?;
451    super::events::instance_call(&emitter, "emit", vec![ev_close, code_val, null2])?;
452    CHILDREN.with(|c| c.borrow_mut().remove(&id));
453    with_host(|h| h.decr_handle());
454    let _ = with_host(|h| h.io_sender()).send(Box::new(|| Ok(())));
455    Ok(())
456}
457
458fn set_prop(recv: &Value, key: &str, val: Value) {
459    with_host(|h| {
460        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
461            p.insert(key.to_string(), val);
462        }
463    });
464}
465
466// ── ChildProcess instance methods (tag `@@native = "ChildProcess"`) ──────────
467
468/// `stdlib::instance_call` entry for a `ChildProcess` receiver. EventEmitter
469/// methods delegate to `events`; process-control methods act on the live child
470/// (only `fork`ed children are live — a `spawn`/`execFile` result has already
471/// exited, so `kill` is a no-op there).
472pub fn instance_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
473    if super::events::METHODS.contains(&method) {
474        return super::events::instance_call(recv, method, args);
475    }
476    match method {
477        "kill" => Ok(Value::Bool(kill_child(recv))),
478        // IPC is not implemented (see `fork` doc): `send` cannot deliver a message.
479        "send" => Ok(Value::Bool(false)),
480        "disconnect" => {
481            set_prop(recv, "connected", Value::Bool(false));
482            Ok(Value::Undef)
483        }
484        "ref" | "unref" => Ok(recv.clone()),
485        _ => Err(crate::host::type_error(&format!(
486            "child.{method} is not a function"
487        ))),
488    }
489}
490
491/// Terminate a live (`fork`ed) child. The signal argument is accepted for API
492/// compatibility but ignored — `std::process::Child::kill` always sends `SIGKILL`.
493/// Returns `true` if a live child was signalled.
494fn kill_child(recv: &Value) -> bool {
495    let id = with_host(|h| match h.get(recv) {
496        Some(JsObj::Object(p)) => p.get("@@childid").map(|v| h.to_number(v) as u64),
497        _ => None,
498    });
499    let Some(id) = id else { return false };
500    let handle = CHILDREN.with(|c| c.borrow().get(&id).map(|r| r.handle.clone()));
501    let Some(handle) = handle else { return false };
502    if let Ok(mut g) = handle.lock() {
503        if let Some(child) = g.as_mut() {
504            let _ = child.kill();
505            return true;
506        }
507    }
508    false
509}
510
511/// Bytes → a `Buffer` value (default) or a decoded string when `encoding` is set
512/// to anything other than `"buffer"`. Buffers are built exactly like `fs`
513/// returns them, via `buffer::from_bytes`.
514fn output_value(bytes: &[u8], encoding: Option<&str>) -> Value {
515    match encoding {
516        Some(enc) if !enc.eq_ignore_ascii_case("buffer") => {
517            with_host(|h| h.new_str(String::from_utf8_lossy(bytes).into_owned()))
518        }
519        _ => super::buffer::from_bytes(bytes),
520    }
521}
522
523/// The array argument at `args[i]` as a list of stringified elements (empty when
524/// the argument is absent or not an array).
525fn arg_array(args: &[Value], i: usize) -> Vec<String> {
526    with_host(|h| match args.get(i).and_then(|v| h.get(v)) {
527        Some(crate::host::JsObj::Array(items)) => items.iter().map(|v| h.str_of(v)).collect(),
528        _ => Vec::new(),
529    })
530}
531
532/// Read `.encoding` from the options object at `args[i]`, if present and a
533/// non-empty string.
534fn opts_encoding(args: &[Value], i: usize) -> Option<String> {
535    with_host(|h| match args.get(i).and_then(|v| h.get(v)) {
536        Some(crate::host::JsObj::Object(p)) => p
537            .get("encoding")
538            .map(|v| h.str_of(v))
539            .filter(|s| !s.is_empty() && s != "undefined" && s != "null"),
540        _ => None,
541    })
542}