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, then
21//!     delivers what happened on a later turn of the loop, as events on the
22//!     ChildProcess it returned: `'spawn'`, `'data'`/`'end'` on the
23//!     `child.stdout`/`child.stderr` streams (or the output written straight
24//!     through under `stdio: 'inherit'`), `'exit'`, `'close'`; a binary that
25//!     cannot start is an `'error'` event. LIMITATION: the child has finished
26//!     before `spawn` returns, so a long-running one blocks the caller and its
27//!     output arrives as one chunk per stream.
28//!
29//! `fork(modulePath)` is the exception: it spawns THIS `node` executable on
30//! `modulePath` as a genuinely live child and returns a live ChildProcess
31//! emitter that fires `exit`/`close` when the process terminates and supports
32//! `.kill()`. Its IPC channel (`.send()` / `process.on('message')`) is NOT
33//! implemented — see the `fork` fn doc for why.
34
35use super::arg_str;
36use crate::host::{with_host, IoTask, JsObj};
37use fusevm::Value;
38use indexmap::IndexMap;
39use std::process::{Child, Command, Stdio};
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::mpsc::Sender;
42use std::sync::{Arc, Mutex};
43
44pub const METHODS: &[&str] = &[
45    "execSync",
46    "spawnSync",
47    "execFileSync",
48    "exec",
49    "execFile",
50    "spawn",
51    "fork",
52];
53
54/// Instance method names for the `ChildProcess` `@@native` tag, exposed to
55/// `stdlib::instance_has_method` (property reads that yield a bound method).
56pub const CHILD_PROCESS_METHODS: &[&str] = &["kill", "send", "disconnect", "ref", "unref"];
57
58pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
59    Some(match method {
60        "execSync" => exec_sync(args),
61        "spawnSync" => spawn_sync(args),
62        "execFileSync" => exec_file_sync(args),
63        "exec" => exec(args),
64        "execFile" => exec_file(args),
65        "spawn" => spawn(args),
66        "@@childEvents" => {
67            let child = args.first().cloned().unwrap_or(Value::Undef);
68            child_events(&child).map(|_| Value::Undef)
69        }
70        "fork" => fork(args),
71        _ => return None,
72    })
73}
74
75// ── live ChildProcess registry (used by `fork`) ──────────────────────────────
76
77/// Process-global id source for live (`fork`ed) children.
78static NEXT_CHILD_ID: AtomicU64 = AtomicU64::new(1);
79
80/// Main-thread record for a live child: its emitter object and a shared handle
81/// the waiter thread polls (`try_wait`) and `kill` signals through.
82struct ChildRec {
83    emitter: Value,
84    handle: Arc<Mutex<Option<Child>>>,
85}
86
87thread_local! {
88    static CHILDREN: std::cell::RefCell<std::collections::HashMap<u64, ChildRec>> =
89        std::cell::RefCell::new(std::collections::HashMap::new());
90}
91
92/// Build a `ChildProcess`-shaped emitter object (tagged `@@native = "ChildProcess"`)
93/// carrying the given extra properties, sharing the EventEmitter shape.
94fn child_object(extra: IndexMap<String, Value>) -> Value {
95    super::net::new_emitter_object("ChildProcess", extra)
96}
97
98/// Result of running a child to completion: exit code (`None` if terminated by a
99/// signal), captured stdout, captured stderr.
100struct Run {
101    status: Option<i32>,
102    /// The signal that terminated the child, when one did.
103    signal: Option<i32>,
104    stdout: Vec<u8>,
105    stderr: Vec<u8>,
106    pid: u32,
107}
108
109/// The options a *Sync call passes through to the child.
110#[derive(Default)]
111struct SpawnOpts {
112    input: Option<Vec<u8>>,
113    /// `env` REPLACES the environment rather than extending it, as in node.
114    env: Option<Vec<(String, String)>>,
115    cwd: Option<String>,
116}
117
118/// Read `input`, `env` and `cwd` out of the options argument.
119///
120/// `env` and `cwd` were being ignored entirely: the child inherited this
121/// process's environment and working directory, so `spawnSync(cmd, args,
122/// { cwd })` silently ran somewhere else and `{ env }` silently saw the wrong
123/// variables.
124fn spawn_opts(args: &[Value], idx: usize) -> SpawnOpts {
125    let Some(opts) = args.get(idx) else {
126        return SpawnOpts::default();
127    };
128    let read = |k: &str| crate::builtins::get_property(opts, k).ok();
129    let input = match read("input") {
130        Some(Value::Undef) | None => None,
131        Some(v) => Some(super::arg_str(&[v], 0).into_bytes()),
132    };
133    let cwd = match read("cwd") {
134        Some(Value::Undef) | None => None,
135        Some(v) => Some(with_host(|h| h.str_of(&v))),
136    };
137    let env = match read("env") {
138        Some(v) if with_host(|h| matches!(h.get(&v), Some(JsObj::Object(_)))) => {
139            let keys = with_host(|h| match h.get(&v) {
140                Some(JsObj::Object(m)) => m
141                    .keys()
142                    .filter(|k| !k.starts_with("@@"))
143                    .cloned()
144                    .collect::<Vec<_>>(),
145                _ => Vec::new(),
146            });
147            Some(
148                keys.into_iter()
149                    .filter_map(|k| {
150                        let val = crate::builtins::get_property(&v, &k).ok()?;
151                        Some((k, with_host(|h| h.str_of(&val))))
152                    })
153                    .collect(),
154            )
155        }
156        _ => None,
157    };
158    SpawnOpts { input, env, cwd }
159}
160
161/// Spawn `program` with `args`, capture both pipes, optionally feed `input` to
162/// stdin, and wait for exit.
163fn run(program: &str, args: &[String], opts: &SpawnOpts) -> std::io::Result<Run> {
164    let input = opts.input.as_deref();
165    let mut cmd = Command::new(program);
166    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
167    if let Some(dir) = &opts.cwd {
168        cmd.current_dir(dir);
169    }
170    if let Some(vars) = &opts.env {
171        cmd.env_clear();
172        for (k, v) in vars {
173            cmd.env(k, v);
174        }
175    }
176    cmd.stdin(if input.is_some() {
177        Stdio::piped()
178    } else {
179        Stdio::inherit()
180    });
181    let mut child = cmd.spawn()?;
182    let pid = child.id();
183    if let Some(bytes) = input {
184        if let Some(mut stdin) = child.stdin.take() {
185            use std::io::Write as _;
186            let _ = stdin.write_all(bytes);
187            // Drop stdin to send EOF so the child (e.g. `cat`/`wc`) can finish.
188        }
189    }
190    let out = child.wait_with_output()?;
191    #[cfg(unix)]
192    let signal = std::os::unix::process::ExitStatusExt::signal(&out.status);
193    #[cfg(not(unix))]
194    let signal = None;
195    Ok(Run {
196        status: out.status.code(),
197        signal,
198        stdout: out.stdout,
199        stderr: out.stderr,
200        pid,
201    })
202}
203
204/// `execSync`/`execFileSync` send the child's stderr on to the PARENT's stderr
205/// as well as capturing it — that is their documented DEFAULT stdio, and it is
206/// how a build script's diagnostics reach the terminal. `spawnSync` does not,
207/// and must not.
208///
209/// "Default" is the operative word: node echoes only when the caller left
210/// `stdio` unspecified. Echoing regardless meant a caller that had asked for
211/// the pipes explicitly still saw the child's stderr on its own.
212fn echo_stderr(args: &[Value], opts_idx: usize, bytes: &[u8]) {
213    if bytes.is_empty() {
214        return;
215    }
216    let explicit_stdio = args
217        .get(opts_idx)
218        .and_then(|o| crate::builtins::get_property(o, "stdio").ok())
219        .is_some_and(|v| !matches!(v, Value::Undef));
220    if explicit_stdio {
221        return;
222    }
223    let text = String::from_utf8_lossy(bytes).into_owned();
224    with_host(|h| h.write_out(&text, true));
225}
226
227/// The error a failing `execSync` throws.
228///
229/// Node throws a real Error carrying `status`, `signal`, `pid`, `stdout` and
230/// `stderr`, and the standard shape of a caller is to read `e.status` or
231/// `e.stderr`. This used to throw a bare message string, so every one of those
232/// read back as undefined and the exit code was unrecoverable.
233fn command_failed(cmd: &str, r: &Run, enc: Option<&str>) -> String {
234    let tail = String::from_utf8_lossy(&r.stderr).into_owned();
235    let msg = format!("Command failed: {cmd}\n{tail}");
236    // Build the pipe values before the allocating `with_host` below; each takes
237    // its own borrow.
238    let stdout = output_value(&r.stdout, enc);
239    let stderr = output_value(&r.stderr, enc);
240    // Each of these takes its own host borrow, so none may be built inside
241    // another's `with_host` closure.
242    let e = crate::builtins::make_error_pub("Error", &msg);
243    let null = with_host(|h| h.null());
244    let status = r
245        .status
246        .map(|c| Value::Float(c as f64))
247        .unwrap_or_else(|| null.clone());
248    for (k, v) in [
249        ("status", status),
250        ("signal", null),
251        ("pid", Value::Float(r.pid as f64)),
252        ("stdout", stdout),
253        ("stderr", stderr),
254    ] {
255        let _ = crate::builtins::set_property_pub(&e, k, v);
256    }
257    with_host(|h| h.exc = Some(e));
258    format!("Error: {msg}")
259}
260
261/// The Error `exec`'s callback is handed for a non-zero exit: node's message is
262/// `Command failed: <cmd>\n<stderr>`, and the fields are `cmd`, `code` (the
263/// exit status), `killed` and `signal` — nothing else.
264fn exec_error(cmd: &str, r: &Run) -> Value {
265    let tail = String::from_utf8_lossy(&r.stderr).into_owned();
266    let e = crate::builtins::make_error_pub("Error", &format!("Command failed: {cmd}\n{tail}"));
267    let null = with_host(|h| h.null());
268    let cmd_v = with_host(|h| h.new_str(cmd.to_string()));
269    for (k, v) in [
270        ("killed", Value::Bool(false)),
271        ("code", Value::Float(r.status.unwrap_or(-1) as f64)),
272        ("signal", null),
273        ("cmd", cmd_v),
274    ] {
275        let _ = crate::builtins::set_property_pub(&e, k, v);
276    }
277    e
278}
279
280/// The Error a failed SPAWN is reported with — node never throws here, it hands
281/// the error-first callback an `ENOENT` carrying `errno`, `syscall`, `path` and
282/// `spawnargs`, so `err.code === 'ENOENT'` distinguishes "no such binary" from
283/// "the binary ran and failed".
284fn spawn_error(file: &str, argv: &[String], e: &std::io::Error) -> Value {
285    let code = super::fs::libuv_code(e);
286    let err = crate::builtins::make_error_pub("Error", &format!("spawn {file} {code}"));
287    let errno = -f64::from(e.raw_os_error().unwrap_or(5));
288    let (code_v, syscall, path, spawnargs) = with_host(|h| {
289        let items = argv.iter().map(|a| h.new_str(a.clone())).collect();
290        (
291            h.new_str(code.to_string()),
292            h.new_str(format!("spawn {file}")),
293            h.new_str(file.to_string()),
294            h.new_array(items),
295        )
296    });
297    for (k, v) in [
298        ("errno", Value::Float(errno)),
299        ("code", code_v),
300        ("syscall", syscall),
301        ("path", path),
302        ("spawnargs", spawnargs),
303    ] {
304        let _ = crate::builtins::set_property_pub(&err, k, v);
305    }
306    err
307}
308
309/// `execSync(command[, options])` — run `sh -c <command>`, return stdout, and
310/// throw when the command exits non-zero (matching Node's `execSync`).
311fn exec_sync(args: &[Value]) -> Result<Value, String> {
312    let cmd = arg_str(args, 0);
313    let enc = opts_encoding(args, 1);
314    let r = run("sh", &["-c".to_string(), cmd.clone()], &spawn_opts(args, 1))
315        .map_err(|e| format!("Error: {e}"))?;
316    echo_stderr(args, 1, &r.stderr);
317    if r.status != Some(0) {
318        return Err(command_failed(&cmd, &r, enc.as_deref()));
319    }
320    Ok(output_value(&r.stdout, enc.as_deref()))
321}
322
323/// `spawnSync(command, args[, options])` — return
324/// `{ status, signal, pid, stdout, stderr }` (never throws on non-zero exit).
325fn spawn_sync(args: &[Value]) -> Result<Value, String> {
326    let cmd = arg_str(args, 0);
327    let cmd_args = arg_array(args, 1);
328    let enc = opts_encoding(args, 2);
329    match run(&cmd, &cmd_args, &spawn_opts(args, 2)) {
330        Ok(r) => {
331            // Build the stdout/stderr values FIRST (each allocates via its own
332            // `with_host`); inserting them inside the outer `with_host` below would
333            // re-enter the host borrow and panic.
334            let stdout = output_value(&r.stdout, enc.as_deref());
335            let stderr = output_value(&r.stderr, enc.as_deref());
336            Ok(with_host(|h| {
337                let mut m = IndexMap::new();
338                m.insert("pid".into(), Value::Float(r.pid as f64));
339                m.insert(
340                    "status".into(),
341                    r.status
342                        .map(|c| Value::Float(c as f64))
343                        .unwrap_or_else(|| h.null()),
344                );
345                // A signal name is not recovered here; report null (as when the
346                // child exited normally).
347                m.insert("signal".into(), h.null());
348                m.insert("stdout".into(), stdout);
349                m.insert("stderr".into(), stderr);
350                h.new_object(m)
351            }))
352        }
353        // Failure to launch (e.g. ENOENT): Node populates `error` and leaves
354        // status/stdout/stderr null.
355        Err(e) => Ok(with_host(|h| {
356            let mut m = IndexMap::new();
357            m.insert("pid".into(), Value::Float(0.0));
358            m.insert("status".into(), h.null());
359            m.insert("signal".into(), h.null());
360            m.insert("stdout".into(), h.null());
361            m.insert("stderr".into(), h.null());
362            m.insert("error".into(), h.new_str(format!("Error: spawn {cmd} {e}")));
363            h.new_object(m)
364        })),
365    }
366}
367
368/// `execFileSync(file, args[, options])` — like `spawnSync` but returns stdout
369/// and throws on a non-zero exit.
370fn exec_file_sync(args: &[Value]) -> Result<Value, String> {
371    let file = arg_str(args, 0);
372    let cmd_args = arg_array(args, 1);
373    let enc = opts_encoding(args, 2);
374    let r = run(&file, &cmd_args, &spawn_opts(args, 2))
375        .map_err(|e| format!("Error: spawn {file} {e}"))?;
376    echo_stderr(args, 2, &r.stderr);
377    if r.status != Some(0) {
378        // Same rich error `execSync` throws: a caller reads `e.status` and
379        // `e.stderr` here exactly as it does there, and this path was still
380        // handing back a bare message string.
381        return Err(command_failed(&file, &r, enc.as_deref()));
382    }
383    Ok(output_value(&r.stdout, enc.as_deref()))
384}
385
386/// `exec(command[, options], callback)` — run `sh -c <command>` synchronously,
387/// then fire `callback(error, stdout, stderr)` as a microtask. Node's `exec`
388/// defaults to string output, so stdout/stderr are passed as strings.
389fn exec(args: &[Value]) -> Result<Value, String> {
390    let cmd = arg_str(args, 0);
391    // Callback is the last function-shaped argument.
392    let Some(cb) = args.last().cloned() else {
393        return Ok(Value::Undef);
394    };
395    let (err, out, errout) = match run("sh", &["-c".to_string(), cmd.clone()], &spawn_opts(args, 1))
396    {
397        Ok(r) => {
398            let stdout = String::from_utf8_lossy(&r.stdout).into_owned();
399            let stderr = String::from_utf8_lossy(&r.stderr).into_owned();
400            // An error-first callback receives an ERROR OBJECT carrying node's
401            // fields — `err.code` is the exit STATUS, and `cmd`, `killed` and
402            // `signal` ride along. This handed over a message string, so
403            // `err.code` was `undefined` and the exit status was unrecoverable;
404            // the message was this module's own wording too, where node appends
405            // the command's STDERR.
406            let err = if r.status == Some(0) {
407                with_host(|h| h.null())
408            } else {
409                exec_error(&cmd, &r)
410            };
411            (err, stdout, stderr)
412        }
413        Err(e) => (
414            with_host(|h| crate::builtins::synth_error(h, &format!("Error: {e}"))),
415            String::new(),
416            String::new(),
417        ),
418    };
419    with_host(|h| {
420        let so = h.new_str(out);
421        let se = h.new_str(errout);
422        h.queue_micro(cb, vec![err, so, se]);
423    });
424    Ok(Value::Undef)
425}
426
427/// `spawn(command, args[, options])` — see the module doc comment: runs the
428/// child synchronously and returns a minimal, non-live ChildProcess-shaped
429/// object exposing the collected result. Event listeners do not fire.
430fn spawn(args: &[Value]) -> Result<Value, String> {
431    let cmd = arg_str(args, 0);
432    let cmd_args = arg_array(args, 1);
433    let disp = stdio_dispositions(args.get(2));
434    let null = with_host(|h| h.null());
435    let pipe = |d: Disposition| match d {
436        Disposition::Pipe => child_pipe(),
437        _ => null.clone(),
438    };
439    let (stdout, stderr) = (pipe(disp[1]), pipe(disp[2]));
440    let mut m = IndexMap::new();
441    let (status, signal, out, err, spawn_err) = match run(&cmd, &cmd_args, &spawn_opts(args, 2)) {
442        Ok(r) => {
443            m.insert("pid".into(), Value::Float(r.pid as f64));
444            (r.status, r.signal, r.stdout, r.stderr, None)
445        }
446        // A binary that cannot be started is an `'error'` EVENT, followed by
447        // `'close'` with code -2, not a throw: `spawn` is asynchronous in node.
448        Err(e) => {
449            m.insert("pid".into(), Value::Undef);
450            (
451                None,
452                None,
453                Vec::new(),
454                Vec::new(),
455                Some(spawn_error(&cmd, &cmd_args, &e)),
456            )
457        }
458    };
459    // Not exited yet, as far as the caller can tell: `exitCode` is `null` until
460    // `'exit'` fires.
461    m.insert("exitCode".into(), null.clone());
462    m.insert("signalCode".into(), null.clone());
463    m.insert("killed".into(), Value::Bool(false));
464    m.insert("connected".into(), Value::Bool(false));
465    m.insert("stdout".into(), stdout);
466    m.insert("stderr".into(), stderr);
467    let child = child_object(m);
468    PENDING.with(|p| {
469        p.borrow_mut().push(Finished {
470            child: child.clone(),
471            status,
472            signal,
473            out,
474            err,
475            disp,
476            spawn_err,
477        })
478    });
479    with_host(|h| {
480        let cb = h.alloc(JsObj::Builtin("child_process.@@childEvents".into()));
481        h.add_timer(-1.0, cb, vec![child.clone()], None);
482    });
483    Ok(child)
484}
485
486/// Where a spawned child's stdout or stderr goes (`options.stdio`).
487#[derive(Clone, Copy, PartialEq)]
488enum Disposition {
489    /// A `child.stdout`/`child.stderr` stream carries it.
490    Pipe,
491    /// Straight to this process's own stream; the property is `null`.
492    Inherit,
493    Ignore,
494}
495
496/// Read `options.stdio`: one string for all three streams, or an array of
497/// per-stream entries, where a descriptor number or a stream object
498/// (`process.stdout`) means the parent's own.
499fn stdio_dispositions(opts: Option<&Value>) -> [Disposition; 3] {
500    let Some(stdio) = opts.and_then(|o| crate::builtins::get_property(o, "stdio").ok()) else {
501        return [Disposition::Pipe; 3];
502    };
503    let one = |v: &Value| -> Disposition {
504        match v {
505            Value::Float(_) | Value::Int(_) => Disposition::Inherit,
506            Value::Undef => Disposition::Pipe,
507            _ => with_host(|h| {
508                if h.is_null(v) {
509                    return Disposition::Pipe;
510                }
511                if matches!(h.get(v), Some(JsObj::Object(_))) {
512                    return Disposition::Inherit;
513                }
514                match h.str_of(v).as_str() {
515                    "inherit" => Disposition::Inherit,
516                    "ignore" => Disposition::Ignore,
517                    _ => Disposition::Pipe,
518                }
519            }),
520        }
521    };
522    let items = with_host(|h| match h.get(&stdio) {
523        Some(JsObj::Array(items)) => Some(items.clone()),
524        _ => None,
525    });
526    match items {
527        Some(items) => {
528            let at = |i: usize| one(items.get(i).unwrap_or(&Value::Undef));
529            [at(0), at(1), at(2)]
530        }
531        None => [one(&stdio); 3],
532    }
533}
534
535/// A finished child whose events have not been delivered yet.
536struct Finished {
537    child: Value,
538    status: Option<i32>,
539    signal: Option<i32>,
540    out: Vec<u8>,
541    err: Vec<u8>,
542    disp: [Disposition; 3],
543    spawn_err: Option<Value>,
544}
545
546thread_local! {
547    static PENDING: std::cell::RefCell<Vec<Finished>> = const { std::cell::RefCell::new(Vec::new()) };
548}
549
550/// A `child.stdout`/`child.stderr` stream: an emitter with the readable
551/// surface programs use on it.
552fn child_pipe() -> Value {
553    super::net::new_emitter_object("ChildPipe", IndexMap::new())
554}
555
556/// The methods of a `ChildPipe`, besides the EventEmitter ones.
557pub const CHILD_PIPE_METHODS: &[&str] = &["setEncoding", "pipe", "resume", "pause", "destroy"];
558
559pub fn pipe_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
560    if !CHILD_PIPE_METHODS.contains(&method) && super::events::METHODS.contains(&method) {
561        return super::events::instance_call(recv, method, args);
562    }
563    match method {
564        "setEncoding" => {
565            let enc = match args.first() {
566                Some(v) if !matches!(v, Value::Undef) => arg_str(&args, 0),
567                _ => "utf8".to_string(),
568            };
569            set_prop(recv, "@@encoding", with_host(|h| h.new_str(enc)));
570            Ok(recv.clone())
571        }
572        "pipe" => {
573            let dest = args.first().cloned().unwrap_or(Value::Undef);
574            set_prop(recv, "@@pipeDest", dest.clone());
575            Ok(dest)
576        }
577        "resume" | "pause" | "destroy" => Ok(recv.clone()),
578        _ => Err(crate::host::type_error(&format!(
579            "{method} is not a function"
580        ))),
581    }
582}
583
584fn prop(v: &Value, key: &str) -> Option<Value> {
585    with_host(|h| match h.get(v) {
586        Some(JsObj::Object(p)) => p.get(key).cloned(),
587        _ => None,
588    })
589}
590
591fn emit(target: &Value, event: &str, mut args: Vec<Value>) -> Result<(), String> {
592    args.insert(0, with_host(|h| h.new_str(event)));
593    super::events::instance_call(target, "emit", args).map(|_| ())
594}
595
596/// Deliver a pipe's bytes: one `'data'` chunk (a Buffer, or a string after
597/// `setEncoding`), written on to a `pipe` destination too, then `'end'`.
598fn pipe_output(pipe: &Value, bytes: &[u8]) -> Result<(), String> {
599    let dest = prop(pipe, "@@pipeDest");
600    if !bytes.is_empty() {
601        let enc = prop(pipe, "@@encoding").map(|v| with_host(|h| h.str_of(&v)));
602        let chunk = output_value(bytes, enc.as_deref());
603        if let Some(dest) = &dest {
604            crate::host::call_method(dest, "write", vec![chunk.clone()])?;
605        }
606        emit(pipe, "data", vec![chunk])?;
607    }
608    if let Some(dest) = &dest {
609        let is_std = super::native_tag(dest).as_deref() == Some("WriteStream");
610        if !is_std {
611            crate::host::call_method(dest, "end", Vec::new())?;
612        }
613    }
614    emit(pipe, "end", Vec::new())
615}
616
617/// The events of a spawned child, delivered on a later turn of the loop as
618/// node delivers them: `'spawn'`, each pipe's output, `'exit'` (with
619/// `exitCode`/`signalCode` set), `'close'`, then the pipes' own `'close'`. A
620/// child that could not start emits `'error'` and `'close'` with code -2.
621fn child_events(child: &Value) -> Result<(), String> {
622    let found = PENDING.with(|p| {
623        let mut p = p.borrow_mut();
624        let i = p
625            .iter()
626            .position(|f| with_host(|h| h.strict_eq(&f.child, child)))?;
627        Some(p.remove(i))
628    });
629    let Some(f) = found else { return Ok(()) };
630    let null = with_host(|h| h.null());
631    if let Some(err) = f.spawn_err {
632        emit(child, "error", vec![err])?;
633        return emit(child, "close", vec![Value::Float(-2.0), null]);
634    }
635    emit(child, "spawn", Vec::new())?;
636    let pipes = [prop(child, "stdout"), prop(child, "stderr")];
637    for (i, bytes) in [(1, &f.out), (2, &f.err)] {
638        match f.disp[i] {
639            Disposition::Pipe => {
640                if let Some(p) = &pipes[i - 1] {
641                    pipe_output(p, bytes)?;
642                }
643            }
644            Disposition::Inherit => with_host(|h| h.write_out_bytes(bytes, i == 2)),
645            Disposition::Ignore => {}
646        }
647    }
648    let code = f
649        .status
650        .map_or_else(|| null.clone(), |c| Value::Float(c as f64));
651    let signal = match f.signal.and_then(signal_name) {
652        Some(name) => with_host(|h| h.new_str(name)),
653        None => null.clone(),
654    };
655    set_prop(child, "exitCode", code.clone());
656    set_prop(child, "signalCode", signal.clone());
657    emit(child, "exit", vec![code.clone(), signal.clone()])?;
658    emit(child, "close", vec![code, signal])?;
659    for (i, p) in pipes.iter().enumerate() {
660        if let (Some(p), Disposition::Pipe) = (p, f.disp[i + 1]) {
661            emit(p, "close", Vec::new())?;
662        }
663    }
664    Ok(())
665}
666
667/// The name of signal number `n` (`SIGTERM`), from the same table
668/// `process.kill` reads.
669fn signal_name(n: i32) -> Option<&'static str> {
670    const NAMES: &[&str] = &[
671        "SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGTRAP", "SIGABRT", "SIGBUS", "SIGFPE",
672        "SIGKILL", "SIGUSR1", "SIGSEGV", "SIGUSR2", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGCHLD",
673        "SIGCONT", "SIGSTOP", "SIGTSTP", "SIGWINCH",
674    ];
675    NAMES
676        .iter()
677        .copied()
678        .find(|name| super::process::signal_number(name) == Some(n))
679}
680
681/// `execFile(file[, args][, options][, callback])` — like `exec` but WITHOUT a
682/// shell: `file` is run directly with the `args` array. Runs to completion, fires
683/// `callback(error, stdout, stderr)` (strings) as a microtask, and returns a
684/// (non-live) ChildProcess-shaped object carrying the collected result.
685fn exec_file(args: &[Value]) -> Result<Value, String> {
686    let file = arg_str(args, 0);
687    let cmd_args = arg_array(args, 1);
688    // Callback is the last function-shaped argument, if any.
689    let cb = args
690        .iter()
691        .rev()
692        .find(|v| with_host(|h| crate::host::is_callable(h, v)))
693        .cloned();
694
695    let full_cmd = std::iter::once(file.clone())
696        .chain(cmd_args.iter().cloned())
697        .collect::<Vec<_>>()
698        .join(" ");
699
700    match run(&file, &cmd_args, &spawn_opts(args, 2)) {
701        Ok(r) => {
702            let stdout_buf = super::buffer::from_bytes(&r.stdout);
703            let stderr_buf = super::buffer::from_bytes(&r.stderr);
704            let null = with_host(|h| h.null());
705            if let Some(cb) = cb {
706                let so = String::from_utf8_lossy(&r.stdout).into_owned();
707                let se = String::from_utf8_lossy(&r.stderr).into_owned();
708                // As in `exec`, the callback takes an ERROR OBJECT. This built
709                // a STRING, so `err instanceof Error` was false and every field
710                // a caller reads — `code`, `cmd`, `killed`, `signal` — was
711                // `undefined`; the wording was this module's own too. Node's
712                // `cmd` here is the file and its arguments joined, since there
713                // is no shell command line to quote.
714                let err = if r.status == Some(0) {
715                    null.clone()
716                } else {
717                    exec_error(&full_cmd, &r)
718                };
719                with_host(|h| {
720                    let so = h.new_str(so);
721                    let se = h.new_str(se);
722                    h.queue_micro(cb, vec![err, so, se]);
723                });
724            }
725            let mut m = IndexMap::new();
726            m.insert("pid".into(), Value::Float(r.pid as f64));
727            m.insert(
728                "exitCode".into(),
729                r.status
730                    .map(|c| Value::Float(c as f64))
731                    .unwrap_or_else(|| null.clone()),
732            );
733            m.insert("signalCode".into(), null);
734            m.insert("killed".into(), Value::Bool(false));
735            m.insert("connected".into(), Value::Bool(false));
736            m.insert("stdout".into(), stdout_buf);
737            m.insert("stderr".into(), stderr_buf);
738            Ok(child_object(m))
739        }
740        // A missing binary is reported THROUGH the callback — `execFile` is
741        // async, so it does not throw. Throwing here meant the caller's
742        // error-first handler never ran and the whole script died instead.
743        Err(e) => {
744            let err = spawn_error(&file, &cmd_args, &e);
745            if let Some(cb) = cb {
746                let (empty1, empty2) = with_host(|h| (h.new_str(""), h.new_str("")));
747                with_host(|h| h.queue_micro(cb, vec![err, empty1, empty2]));
748                let null = with_host(|h| h.null());
749                let mut m = IndexMap::new();
750                m.insert("pid".into(), Value::Undef);
751                m.insert("exitCode".into(), null.clone());
752                m.insert("signalCode".into(), null.clone());
753                m.insert("killed".into(), Value::Bool(false));
754                m.insert("connected".into(), Value::Bool(false));
755                m.insert("stdout".into(), null.clone());
756                m.insert("stderr".into(), null);
757                return Ok(child_object(m));
758            }
759            Err(format!("Error: spawn {file} {e}"))
760        }
761    }
762}
763
764/// `fork(modulePath[, args][, options])` — spawn THIS `node` executable on
765/// `modulePath` as a live child (inheriting stdio), returning a live
766/// ChildProcess emitter that fires `exit`/`close` when the child terminates.
767///
768/// LIMITATION: Node's `fork` also opens an IPC channel so parent and child can
769/// exchange messages via `child.send()` / `process.on('message')`. That requires
770/// the child `node` process to detect and bind an inherited IPC file descriptor,
771/// which this runtime does not implement — so `child.send()` is a no-op that
772/// returns `false`, `child.connected` is `false`, and no `'message'` event fires.
773/// The process itself is real and live (`exit`/`close`/`kill` all work).
774fn fork(args: &[Value]) -> Result<Value, String> {
775    let module = arg_str(args, 0);
776    let extra_args = arg_array(args, 1);
777    let exe = std::env::current_exe().map_err(|e| format!("Error: fork: {e}"))?;
778
779    let mut cmd = Command::new(exe);
780    cmd.arg(&module).args(&extra_args);
781    cmd.stdin(Stdio::inherit())
782        .stdout(Stdio::inherit())
783        .stderr(Stdio::inherit());
784    let child = cmd
785        .spawn()
786        .map_err(|e| format!("Error: fork {module} {e}"))?;
787    let pid = child.id();
788
789    let id = NEXT_CHILD_ID.fetch_add(1, Ordering::Relaxed);
790    let handle = Arc::new(Mutex::new(Some(child)));
791
792    let mut extra = IndexMap::new();
793    extra.insert("@@childid".into(), Value::Float(id as f64));
794    extra.insert("pid".into(), Value::Float(pid as f64));
795    extra.insert("connected".into(), Value::Bool(false));
796    extra.insert("killed".into(), Value::Bool(false));
797    extra.insert("exitCode".into(), with_host(|h| h.null()));
798    extra.insert("signalCode".into(), with_host(|h| h.null()));
799    let emitter = child_object(extra);
800    CHILDREN.with(|c| {
801        c.borrow_mut().insert(
802            id,
803            ChildRec {
804                emitter: emitter.clone(),
805                handle: handle.clone(),
806            },
807        );
808    });
809    with_host(|h| h.incr_handle());
810
811    let io_tx = with_host(|h| h.io_sender());
812    std::thread::spawn(move || wait_child(id, handle, io_tx));
813    Ok(emitter)
814}
815
816/// Background waiter for a `fork`ed child: polls `try_wait` (so `kill` can still
817/// acquire the shared handle between polls) and posts an `IoTask` emitting
818/// `exit`/`close` once the child terminates.
819fn wait_child(id: u64, handle: Arc<Mutex<Option<Child>>>, io_tx: Sender<IoTask>) {
820    loop {
821        std::thread::sleep(std::time::Duration::from_millis(20));
822        let status = {
823            let mut g = match handle.lock() {
824                Ok(g) => g,
825                Err(_) => return,
826            };
827            match g.as_mut() {
828                Some(child) => match child.try_wait() {
829                    Ok(Some(status)) => {
830                        *g = None;
831                        Some(status.code())
832                    }
833                    Ok(None) => None,
834                    Err(_) => {
835                        *g = None;
836                        Some(None)
837                    }
838                },
839                // Handle already taken (killed + reaped elsewhere): stop polling.
840                None => return,
841            }
842        };
843        if let Some(code) = status {
844            let _ = io_tx.send(Box::new(move || on_child_exit(id, code)));
845            return;
846        }
847    }
848}
849
850/// Main-thread handler: emit `exit` then `close` on a terminated child, mark it,
851/// release its event-loop handle, and drop its registry record.
852fn on_child_exit(id: u64, code: Option<i32>) -> Result<(), String> {
853    let emitter = CHILDREN.with(|c| c.borrow().get(&id).map(|r| r.emitter.clone()));
854    let Some(emitter) = emitter else {
855        return Ok(());
856    };
857    let (code_val, null1, null2) = with_host(|h| {
858        let cv = code
859            .map(|c| Value::Float(c as f64))
860            .unwrap_or_else(|| h.null());
861        (cv, h.null(), h.null())
862    });
863    set_prop(&emitter, "exitCode", code_val.clone());
864    set_prop(&emitter, "killed", Value::Bool(true));
865    let ev_exit = with_host(|h| h.new_str("exit"));
866    let ev_close = with_host(|h| h.new_str("close"));
867    super::events::instance_call(&emitter, "emit", vec![ev_exit, code_val.clone(), null1])?;
868    super::events::instance_call(&emitter, "emit", vec![ev_close, code_val, null2])?;
869    CHILDREN.with(|c| c.borrow_mut().remove(&id));
870    with_host(|h| h.decr_handle());
871    let _ = with_host(|h| h.io_sender()).send(Box::new(|| Ok(())));
872    Ok(())
873}
874
875fn set_prop(recv: &Value, key: &str, val: Value) {
876    with_host(|h| {
877        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
878            p.insert(key.to_string(), val);
879        }
880    });
881}
882
883// ── ChildProcess instance methods (tag `@@native = "ChildProcess"`) ──────────
884
885/// `stdlib::instance_call` entry for a `ChildProcess` receiver. EventEmitter
886/// methods delegate to `events`; process-control methods act on the live child
887/// (only `fork`ed children are live — a `spawn`/`execFile` result has already
888/// exited, so `kill` is a no-op there).
889pub fn instance_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
890    if super::events::METHODS.contains(&method) {
891        return super::events::instance_call(recv, method, args);
892    }
893    match method {
894        "kill" => Ok(Value::Bool(kill_child(recv))),
895        // IPC is not implemented (see `fork` doc): `send` cannot deliver a message.
896        "send" => Ok(Value::Bool(false)),
897        "disconnect" => {
898            set_prop(recv, "connected", Value::Bool(false));
899            Ok(Value::Undef)
900        }
901        "ref" | "unref" => Ok(recv.clone()),
902        _ => Err(crate::host::type_error(&format!(
903            "child.{method} is not a function"
904        ))),
905    }
906}
907
908/// Terminate a live (`fork`ed) child. The signal argument is accepted for API
909/// compatibility but ignored — `std::process::Child::kill` always sends `SIGKILL`.
910/// Returns `true` if a live child was signalled.
911fn kill_child(recv: &Value) -> bool {
912    let id = with_host(|h| match h.get(recv) {
913        Some(JsObj::Object(p)) => p.get("@@childid").map(|v| h.to_number(v) as u64),
914        _ => None,
915    });
916    let Some(id) = id else { return false };
917    let handle = CHILDREN.with(|c| c.borrow().get(&id).map(|r| r.handle.clone()));
918    let Some(handle) = handle else { return false };
919    if let Ok(mut g) = handle.lock() {
920        if let Some(child) = g.as_mut() {
921            let _ = child.kill();
922            return true;
923        }
924    }
925    false
926}
927
928/// Bytes → a `Buffer` value (default) or a decoded string when `encoding` is set
929/// to anything other than `"buffer"`. Buffers are built exactly like `fs`
930/// returns them, via `buffer::from_bytes`.
931fn output_value(bytes: &[u8], encoding: Option<&str>) -> Value {
932    match encoding {
933        Some(enc) if !enc.eq_ignore_ascii_case("buffer") => {
934            with_host(|h| h.new_str(String::from_utf8_lossy(bytes).into_owned()))
935        }
936        _ => super::buffer::from_bytes(bytes),
937    }
938}
939
940/// The array argument at `args[i]` as a list of stringified elements (empty when
941/// the argument is absent or not an array).
942fn arg_array(args: &[Value], i: usize) -> Vec<String> {
943    with_host(|h| match args.get(i).and_then(|v| h.get(v)) {
944        Some(crate::host::JsObj::Array(items)) => items.iter().map(|v| h.str_of(v)).collect(),
945        _ => Vec::new(),
946    })
947}
948
949/// Read `.encoding` from the options object at `args[i]`, if present and a
950/// non-empty string.
951fn opts_encoding(args: &[Value], i: usize) -> Option<String> {
952    with_host(|h| match args.get(i).and_then(|v| h.get(v)) {
953        Some(crate::host::JsObj::Object(p)) => p
954            .get("encoding")
955            .map(|v| h.str_of(v))
956            .filter(|s| !s.is_empty() && s != "undefined" && s != "null"),
957        _ => None,
958    })
959}