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/// The options a *Sync call passes through to the child.
102#[derive(Default)]
103struct SpawnOpts {
104    input: Option<Vec<u8>>,
105    /// `env` REPLACES the environment rather than extending it, as in node.
106    env: Option<Vec<(String, String)>>,
107    cwd: Option<String>,
108}
109
110/// Read `input`, `env` and `cwd` out of the options argument.
111///
112/// `env` and `cwd` were being ignored entirely: the child inherited this
113/// process's environment and working directory, so `spawnSync(cmd, args,
114/// { cwd })` silently ran somewhere else and `{ env }` silently saw the wrong
115/// variables.
116fn spawn_opts(args: &[Value], idx: usize) -> SpawnOpts {
117    let Some(opts) = args.get(idx) else {
118        return SpawnOpts::default();
119    };
120    let read = |k: &str| crate::builtins::get_property(opts, k).ok();
121    let input = match read("input") {
122        Some(Value::Undef) | None => None,
123        Some(v) => Some(super::arg_str(&[v], 0).into_bytes()),
124    };
125    let cwd = match read("cwd") {
126        Some(Value::Undef) | None => None,
127        Some(v) => Some(with_host(|h| h.str_of(&v))),
128    };
129    let env = match read("env") {
130        Some(v) if with_host(|h| matches!(h.get(&v), Some(JsObj::Object(_)))) => {
131            let keys = with_host(|h| match h.get(&v) {
132                Some(JsObj::Object(m)) => m
133                    .keys()
134                    .filter(|k| !k.starts_with("@@"))
135                    .cloned()
136                    .collect::<Vec<_>>(),
137                _ => Vec::new(),
138            });
139            Some(
140                keys.into_iter()
141                    .filter_map(|k| {
142                        let val = crate::builtins::get_property(&v, &k).ok()?;
143                        Some((k, with_host(|h| h.str_of(&val))))
144                    })
145                    .collect(),
146            )
147        }
148        _ => None,
149    };
150    SpawnOpts { input, env, cwd }
151}
152
153/// Spawn `program` with `args`, capture both pipes, optionally feed `input` to
154/// stdin, and wait for exit.
155fn run(program: &str, args: &[String], opts: &SpawnOpts) -> std::io::Result<Run> {
156    let input = opts.input.as_deref();
157    let mut cmd = Command::new(program);
158    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
159    if let Some(dir) = &opts.cwd {
160        cmd.current_dir(dir);
161    }
162    if let Some(vars) = &opts.env {
163        cmd.env_clear();
164        for (k, v) in vars {
165            cmd.env(k, v);
166        }
167    }
168    cmd.stdin(if input.is_some() {
169        Stdio::piped()
170    } else {
171        Stdio::inherit()
172    });
173    let mut child = cmd.spawn()?;
174    let pid = child.id();
175    if let Some(bytes) = input {
176        if let Some(mut stdin) = child.stdin.take() {
177            use std::io::Write as _;
178            let _ = stdin.write_all(bytes);
179            // Drop stdin to send EOF so the child (e.g. `cat`/`wc`) can finish.
180        }
181    }
182    let out = child.wait_with_output()?;
183    Ok(Run {
184        status: out.status.code(),
185        stdout: out.stdout,
186        stderr: out.stderr,
187        pid,
188    })
189}
190
191/// `execSync`/`execFileSync` send the child's stderr on to the PARENT's stderr
192/// as well as capturing it — that is their documented DEFAULT stdio, and it is
193/// how a build script's diagnostics reach the terminal. `spawnSync` does not,
194/// and must not.
195///
196/// "Default" is the operative word: node echoes only when the caller left
197/// `stdio` unspecified. Echoing regardless meant a caller that had asked for
198/// the pipes explicitly still saw the child's stderr on its own.
199fn echo_stderr(args: &[Value], opts_idx: usize, bytes: &[u8]) {
200    if bytes.is_empty() {
201        return;
202    }
203    let explicit_stdio = args
204        .get(opts_idx)
205        .and_then(|o| crate::builtins::get_property(o, "stdio").ok())
206        .is_some_and(|v| !matches!(v, Value::Undef));
207    if explicit_stdio {
208        return;
209    }
210    let text = String::from_utf8_lossy(bytes).into_owned();
211    with_host(|h| h.write_out(&text, true));
212}
213
214/// The error a failing `execSync` throws.
215///
216/// Node throws a real Error carrying `status`, `signal`, `pid`, `stdout` and
217/// `stderr`, and the standard shape of a caller is to read `e.status` or
218/// `e.stderr`. This used to throw a bare message string, so every one of those
219/// read back as undefined and the exit code was unrecoverable.
220fn command_failed(cmd: &str, r: &Run, enc: Option<&str>) -> String {
221    let tail = String::from_utf8_lossy(&r.stderr).into_owned();
222    let msg = format!("Command failed: {cmd}\n{tail}");
223    // Build the pipe values before the allocating `with_host` below; each takes
224    // its own borrow.
225    let stdout = output_value(&r.stdout, enc);
226    let stderr = output_value(&r.stderr, enc);
227    // Each of these takes its own host borrow, so none may be built inside
228    // another's `with_host` closure.
229    let e = crate::builtins::make_error_pub("Error", &msg);
230    let null = with_host(|h| h.null());
231    let status = r
232        .status
233        .map(|c| Value::Float(c as f64))
234        .unwrap_or_else(|| null.clone());
235    for (k, v) in [
236        ("status", status),
237        ("signal", null),
238        ("pid", Value::Float(r.pid as f64)),
239        ("stdout", stdout),
240        ("stderr", stderr),
241    ] {
242        let _ = crate::builtins::set_property_pub(&e, k, v);
243    }
244    with_host(|h| h.exc = Some(e));
245    format!("Error: {msg}")
246}
247
248/// The Error `exec`'s callback is handed for a non-zero exit: node's message is
249/// `Command failed: <cmd>\n<stderr>`, and the fields are `cmd`, `code` (the
250/// exit status), `killed` and `signal` — nothing else.
251fn exec_error(cmd: &str, r: &Run) -> Value {
252    let tail = String::from_utf8_lossy(&r.stderr).into_owned();
253    let e = crate::builtins::make_error_pub("Error", &format!("Command failed: {cmd}\n{tail}"));
254    let null = with_host(|h| h.null());
255    let cmd_v = with_host(|h| h.new_str(cmd.to_string()));
256    for (k, v) in [
257        ("killed", Value::Bool(false)),
258        ("code", Value::Float(r.status.unwrap_or(-1) as f64)),
259        ("signal", null),
260        ("cmd", cmd_v),
261    ] {
262        let _ = crate::builtins::set_property_pub(&e, k, v);
263    }
264    e
265}
266
267/// The Error a failed SPAWN is reported with — node never throws here, it hands
268/// the error-first callback an `ENOENT` carrying `errno`, `syscall`, `path` and
269/// `spawnargs`, so `err.code === 'ENOENT'` distinguishes "no such binary" from
270/// "the binary ran and failed".
271fn spawn_error(file: &str, argv: &[String], e: &std::io::Error) -> Value {
272    let code = super::fs::libuv_code(e);
273    let err = crate::builtins::make_error_pub("Error", &format!("spawn {file} {code}"));
274    let errno = -f64::from(e.raw_os_error().unwrap_or(5));
275    let (code_v, syscall, path, spawnargs) = with_host(|h| {
276        let items = argv.iter().map(|a| h.new_str(a.clone())).collect();
277        (
278            h.new_str(code.to_string()),
279            h.new_str(format!("spawn {file}")),
280            h.new_str(file.to_string()),
281            h.new_array(items),
282        )
283    });
284    for (k, v) in [
285        ("errno", Value::Float(errno)),
286        ("code", code_v),
287        ("syscall", syscall),
288        ("path", path),
289        ("spawnargs", spawnargs),
290    ] {
291        let _ = crate::builtins::set_property_pub(&err, k, v);
292    }
293    err
294}
295
296/// `execSync(command[, options])` — run `sh -c <command>`, return stdout, and
297/// throw when the command exits non-zero (matching Node's `execSync`).
298fn exec_sync(args: &[Value]) -> Result<Value, String> {
299    let cmd = arg_str(args, 0);
300    let enc = opts_encoding(args, 1);
301    let r = run("sh", &["-c".to_string(), cmd.clone()], &spawn_opts(args, 1))
302        .map_err(|e| format!("Error: {e}"))?;
303    echo_stderr(args, 1, &r.stderr);
304    if r.status != Some(0) {
305        return Err(command_failed(&cmd, &r, enc.as_deref()));
306    }
307    Ok(output_value(&r.stdout, enc.as_deref()))
308}
309
310/// `spawnSync(command, args[, options])` — return
311/// `{ status, signal, pid, stdout, stderr }` (never throws on non-zero exit).
312fn spawn_sync(args: &[Value]) -> Result<Value, String> {
313    let cmd = arg_str(args, 0);
314    let cmd_args = arg_array(args, 1);
315    let enc = opts_encoding(args, 2);
316    match run(&cmd, &cmd_args, &spawn_opts(args, 2)) {
317        Ok(r) => {
318            // Build the stdout/stderr values FIRST (each allocates via its own
319            // `with_host`); inserting them inside the outer `with_host` below would
320            // re-enter the host borrow and panic.
321            let stdout = output_value(&r.stdout, enc.as_deref());
322            let stderr = output_value(&r.stderr, enc.as_deref());
323            Ok(with_host(|h| {
324                let mut m = IndexMap::new();
325                m.insert("pid".into(), Value::Float(r.pid as f64));
326                m.insert(
327                    "status".into(),
328                    r.status
329                        .map(|c| Value::Float(c as f64))
330                        .unwrap_or_else(|| h.null()),
331                );
332                // A signal name is not recovered here; report null (as when the
333                // child exited normally).
334                m.insert("signal".into(), h.null());
335                m.insert("stdout".into(), stdout);
336                m.insert("stderr".into(), stderr);
337                h.new_object(m)
338            }))
339        }
340        // Failure to launch (e.g. ENOENT): Node populates `error` and leaves
341        // status/stdout/stderr null.
342        Err(e) => Ok(with_host(|h| {
343            let mut m = IndexMap::new();
344            m.insert("pid".into(), Value::Float(0.0));
345            m.insert("status".into(), h.null());
346            m.insert("signal".into(), h.null());
347            m.insert("stdout".into(), h.null());
348            m.insert("stderr".into(), h.null());
349            m.insert("error".into(), h.new_str(format!("Error: spawn {cmd} {e}")));
350            h.new_object(m)
351        })),
352    }
353}
354
355/// `execFileSync(file, args[, options])` — like `spawnSync` but returns stdout
356/// and throws on a non-zero exit.
357fn exec_file_sync(args: &[Value]) -> Result<Value, String> {
358    let file = arg_str(args, 0);
359    let cmd_args = arg_array(args, 1);
360    let enc = opts_encoding(args, 2);
361    let r = run(&file, &cmd_args, &spawn_opts(args, 2))
362        .map_err(|e| format!("Error: spawn {file} {e}"))?;
363    echo_stderr(args, 2, &r.stderr);
364    if r.status != Some(0) {
365        // Same rich error `execSync` throws: a caller reads `e.status` and
366        // `e.stderr` here exactly as it does there, and this path was still
367        // handing back a bare message string.
368        return Err(command_failed(&file, &r, enc.as_deref()));
369    }
370    Ok(output_value(&r.stdout, enc.as_deref()))
371}
372
373/// `exec(command[, options], callback)` — run `sh -c <command>` synchronously,
374/// then fire `callback(error, stdout, stderr)` as a microtask. Node's `exec`
375/// defaults to string output, so stdout/stderr are passed as strings.
376fn exec(args: &[Value]) -> Result<Value, String> {
377    let cmd = arg_str(args, 0);
378    // Callback is the last function-shaped argument.
379    let Some(cb) = args.last().cloned() else {
380        return Ok(Value::Undef);
381    };
382    let (err, out, errout) = match run("sh", &["-c".to_string(), cmd.clone()], &spawn_opts(args, 1))
383    {
384        Ok(r) => {
385            let stdout = String::from_utf8_lossy(&r.stdout).into_owned();
386            let stderr = String::from_utf8_lossy(&r.stderr).into_owned();
387            // An error-first callback receives an ERROR OBJECT carrying node's
388            // fields — `err.code` is the exit STATUS, and `cmd`, `killed` and
389            // `signal` ride along. This handed over a message string, so
390            // `err.code` was `undefined` and the exit status was unrecoverable;
391            // the message was this module's own wording too, where node appends
392            // the command's STDERR.
393            let err = if r.status == Some(0) {
394                with_host(|h| h.null())
395            } else {
396                exec_error(&cmd, &r)
397            };
398            (err, stdout, stderr)
399        }
400        Err(e) => (
401            with_host(|h| crate::builtins::synth_error(h, &format!("Error: {e}"))),
402            String::new(),
403            String::new(),
404        ),
405    };
406    with_host(|h| {
407        let so = h.new_str(out);
408        let se = h.new_str(errout);
409        h.queue_micro(cb, vec![err, so, se]);
410    });
411    Ok(Value::Undef)
412}
413
414/// `spawn(command, args[, options])` — see the module doc comment: runs the
415/// child synchronously and returns a minimal, non-live ChildProcess-shaped
416/// object exposing the collected result. Event listeners do not fire.
417fn spawn(args: &[Value]) -> Result<Value, String> {
418    let cmd = arg_str(args, 0);
419    let cmd_args = arg_array(args, 1);
420    match run(&cmd, &cmd_args, &spawn_opts(args, 2)) {
421        Ok(r) => {
422            // Allocate the Buffers / null before building the map (`from_bytes` and
423            // `null` borrow the host — nesting inside another `with_host` panics).
424            let stdout = super::buffer::from_bytes(&r.stdout);
425            let stderr = super::buffer::from_bytes(&r.stderr);
426            let null = with_host(|h| h.null());
427            let mut m = IndexMap::new();
428            m.insert("pid".into(), Value::Float(r.pid as f64));
429            m.insert(
430                "exitCode".into(),
431                r.status
432                    .map(|c| Value::Float(c as f64))
433                    .unwrap_or_else(|| null.clone()),
434            );
435            m.insert("signalCode".into(), null);
436            m.insert("killed".into(), Value::Bool(false));
437            m.insert("connected".into(), Value::Bool(false));
438            m.insert("stdout".into(), stdout);
439            m.insert("stderr".into(), stderr);
440            Ok(child_object(m))
441        }
442        Err(e) => Err(format!("Error: spawn {cmd} {e}")),
443    }
444}
445
446/// `execFile(file[, args][, options][, callback])` — like `exec` but WITHOUT a
447/// shell: `file` is run directly with the `args` array. Runs to completion, fires
448/// `callback(error, stdout, stderr)` (strings) as a microtask, and returns a
449/// (non-live) ChildProcess-shaped object carrying the collected result.
450fn exec_file(args: &[Value]) -> Result<Value, String> {
451    let file = arg_str(args, 0);
452    let cmd_args = arg_array(args, 1);
453    // Callback is the last function-shaped argument, if any.
454    let cb = args
455        .iter()
456        .rev()
457        .find(|v| with_host(|h| crate::host::is_callable(h, v)))
458        .cloned();
459
460    let full_cmd = std::iter::once(file.clone())
461        .chain(cmd_args.iter().cloned())
462        .collect::<Vec<_>>()
463        .join(" ");
464
465    match run(&file, &cmd_args, &spawn_opts(args, 2)) {
466        Ok(r) => {
467            let stdout_buf = super::buffer::from_bytes(&r.stdout);
468            let stderr_buf = super::buffer::from_bytes(&r.stderr);
469            let null = with_host(|h| h.null());
470            if let Some(cb) = cb {
471                let so = String::from_utf8_lossy(&r.stdout).into_owned();
472                let se = String::from_utf8_lossy(&r.stderr).into_owned();
473                // As in `exec`, the callback takes an ERROR OBJECT. This built
474                // a STRING, so `err instanceof Error` was false and every field
475                // a caller reads — `code`, `cmd`, `killed`, `signal` — was
476                // `undefined`; the wording was this module's own too. Node's
477                // `cmd` here is the file and its arguments joined, since there
478                // is no shell command line to quote.
479                let err = if r.status == Some(0) {
480                    null.clone()
481                } else {
482                    exec_error(&full_cmd, &r)
483                };
484                with_host(|h| {
485                    let so = h.new_str(so);
486                    let se = h.new_str(se);
487                    h.queue_micro(cb, vec![err, so, se]);
488                });
489            }
490            let mut m = IndexMap::new();
491            m.insert("pid".into(), Value::Float(r.pid as f64));
492            m.insert(
493                "exitCode".into(),
494                r.status
495                    .map(|c| Value::Float(c as f64))
496                    .unwrap_or_else(|| null.clone()),
497            );
498            m.insert("signalCode".into(), null);
499            m.insert("killed".into(), Value::Bool(false));
500            m.insert("connected".into(), Value::Bool(false));
501            m.insert("stdout".into(), stdout_buf);
502            m.insert("stderr".into(), stderr_buf);
503            Ok(child_object(m))
504        }
505        // A missing binary is reported THROUGH the callback — `execFile` is
506        // async, so it does not throw. Throwing here meant the caller's
507        // error-first handler never ran and the whole script died instead.
508        Err(e) => {
509            let err = spawn_error(&file, &cmd_args, &e);
510            if let Some(cb) = cb {
511                let (empty1, empty2) = with_host(|h| (h.new_str(""), h.new_str("")));
512                with_host(|h| h.queue_micro(cb, vec![err, empty1, empty2]));
513                let null = with_host(|h| h.null());
514                let mut m = IndexMap::new();
515                m.insert("pid".into(), Value::Undef);
516                m.insert("exitCode".into(), null.clone());
517                m.insert("signalCode".into(), null.clone());
518                m.insert("killed".into(), Value::Bool(false));
519                m.insert("connected".into(), Value::Bool(false));
520                m.insert("stdout".into(), null.clone());
521                m.insert("stderr".into(), null);
522                return Ok(child_object(m));
523            }
524            Err(format!("Error: spawn {file} {e}"))
525        }
526    }
527}
528
529/// `fork(modulePath[, args][, options])` — spawn THIS `node` executable on
530/// `modulePath` as a live child (inheriting stdio), returning a live
531/// ChildProcess emitter that fires `exit`/`close` when the child terminates.
532///
533/// LIMITATION: Node's `fork` also opens an IPC channel so parent and child can
534/// exchange messages via `child.send()` / `process.on('message')`. That requires
535/// the child `node` process to detect and bind an inherited IPC file descriptor,
536/// which this runtime does not implement — so `child.send()` is a no-op that
537/// returns `false`, `child.connected` is `false`, and no `'message'` event fires.
538/// The process itself is real and live (`exit`/`close`/`kill` all work).
539fn fork(args: &[Value]) -> Result<Value, String> {
540    let module = arg_str(args, 0);
541    let extra_args = arg_array(args, 1);
542    let exe = std::env::current_exe().map_err(|e| format!("Error: fork: {e}"))?;
543
544    let mut cmd = Command::new(exe);
545    cmd.arg(&module).args(&extra_args);
546    cmd.stdin(Stdio::inherit())
547        .stdout(Stdio::inherit())
548        .stderr(Stdio::inherit());
549    let child = cmd
550        .spawn()
551        .map_err(|e| format!("Error: fork {module} {e}"))?;
552    let pid = child.id();
553
554    let id = NEXT_CHILD_ID.fetch_add(1, Ordering::Relaxed);
555    let handle = Arc::new(Mutex::new(Some(child)));
556
557    let mut extra = IndexMap::new();
558    extra.insert("@@childid".into(), Value::Float(id as f64));
559    extra.insert("pid".into(), Value::Float(pid as f64));
560    extra.insert("connected".into(), Value::Bool(false));
561    extra.insert("killed".into(), Value::Bool(false));
562    extra.insert("exitCode".into(), with_host(|h| h.null()));
563    extra.insert("signalCode".into(), with_host(|h| h.null()));
564    let emitter = child_object(extra);
565    CHILDREN.with(|c| {
566        c.borrow_mut().insert(
567            id,
568            ChildRec {
569                emitter: emitter.clone(),
570                handle: handle.clone(),
571            },
572        );
573    });
574    with_host(|h| h.incr_handle());
575
576    let io_tx = with_host(|h| h.io_sender());
577    std::thread::spawn(move || wait_child(id, handle, io_tx));
578    Ok(emitter)
579}
580
581/// Background waiter for a `fork`ed child: polls `try_wait` (so `kill` can still
582/// acquire the shared handle between polls) and posts an `IoTask` emitting
583/// `exit`/`close` once the child terminates.
584fn wait_child(id: u64, handle: Arc<Mutex<Option<Child>>>, io_tx: Sender<IoTask>) {
585    loop {
586        std::thread::sleep(std::time::Duration::from_millis(20));
587        let status = {
588            let mut g = match handle.lock() {
589                Ok(g) => g,
590                Err(_) => return,
591            };
592            match g.as_mut() {
593                Some(child) => match child.try_wait() {
594                    Ok(Some(status)) => {
595                        *g = None;
596                        Some(status.code())
597                    }
598                    Ok(None) => None,
599                    Err(_) => {
600                        *g = None;
601                        Some(None)
602                    }
603                },
604                // Handle already taken (killed + reaped elsewhere): stop polling.
605                None => return,
606            }
607        };
608        if let Some(code) = status {
609            let _ = io_tx.send(Box::new(move || on_child_exit(id, code)));
610            return;
611        }
612    }
613}
614
615/// Main-thread handler: emit `exit` then `close` on a terminated child, mark it,
616/// release its event-loop handle, and drop its registry record.
617fn on_child_exit(id: u64, code: Option<i32>) -> Result<(), String> {
618    let emitter = CHILDREN.with(|c| c.borrow().get(&id).map(|r| r.emitter.clone()));
619    let Some(emitter) = emitter else {
620        return Ok(());
621    };
622    let (code_val, null1, null2) = with_host(|h| {
623        let cv = code
624            .map(|c| Value::Float(c as f64))
625            .unwrap_or_else(|| h.null());
626        (cv, h.null(), h.null())
627    });
628    set_prop(&emitter, "exitCode", code_val.clone());
629    set_prop(&emitter, "killed", Value::Bool(true));
630    let ev_exit = with_host(|h| h.new_str("exit"));
631    let ev_close = with_host(|h| h.new_str("close"));
632    super::events::instance_call(&emitter, "emit", vec![ev_exit, code_val.clone(), null1])?;
633    super::events::instance_call(&emitter, "emit", vec![ev_close, code_val, null2])?;
634    CHILDREN.with(|c| c.borrow_mut().remove(&id));
635    with_host(|h| h.decr_handle());
636    let _ = with_host(|h| h.io_sender()).send(Box::new(|| Ok(())));
637    Ok(())
638}
639
640fn set_prop(recv: &Value, key: &str, val: Value) {
641    with_host(|h| {
642        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
643            p.insert(key.to_string(), val);
644        }
645    });
646}
647
648// ── ChildProcess instance methods (tag `@@native = "ChildProcess"`) ──────────
649
650/// `stdlib::instance_call` entry for a `ChildProcess` receiver. EventEmitter
651/// methods delegate to `events`; process-control methods act on the live child
652/// (only `fork`ed children are live — a `spawn`/`execFile` result has already
653/// exited, so `kill` is a no-op there).
654pub fn instance_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
655    if super::events::METHODS.contains(&method) {
656        return super::events::instance_call(recv, method, args);
657    }
658    match method {
659        "kill" => Ok(Value::Bool(kill_child(recv))),
660        // IPC is not implemented (see `fork` doc): `send` cannot deliver a message.
661        "send" => Ok(Value::Bool(false)),
662        "disconnect" => {
663            set_prop(recv, "connected", Value::Bool(false));
664            Ok(Value::Undef)
665        }
666        "ref" | "unref" => Ok(recv.clone()),
667        _ => Err(crate::host::type_error(&format!(
668            "child.{method} is not a function"
669        ))),
670    }
671}
672
673/// Terminate a live (`fork`ed) child. The signal argument is accepted for API
674/// compatibility but ignored — `std::process::Child::kill` always sends `SIGKILL`.
675/// Returns `true` if a live child was signalled.
676fn kill_child(recv: &Value) -> bool {
677    let id = with_host(|h| match h.get(recv) {
678        Some(JsObj::Object(p)) => p.get("@@childid").map(|v| h.to_number(v) as u64),
679        _ => None,
680    });
681    let Some(id) = id else { return false };
682    let handle = CHILDREN.with(|c| c.borrow().get(&id).map(|r| r.handle.clone()));
683    let Some(handle) = handle else { return false };
684    if let Ok(mut g) = handle.lock() {
685        if let Some(child) = g.as_mut() {
686            let _ = child.kill();
687            return true;
688        }
689    }
690    false
691}
692
693/// Bytes → a `Buffer` value (default) or a decoded string when `encoding` is set
694/// to anything other than `"buffer"`. Buffers are built exactly like `fs`
695/// returns them, via `buffer::from_bytes`.
696fn output_value(bytes: &[u8], encoding: Option<&str>) -> Value {
697    match encoding {
698        Some(enc) if !enc.eq_ignore_ascii_case("buffer") => {
699            with_host(|h| h.new_str(String::from_utf8_lossy(bytes).into_owned()))
700        }
701        _ => super::buffer::from_bytes(bytes),
702    }
703}
704
705/// The array argument at `args[i]` as a list of stringified elements (empty when
706/// the argument is absent or not an array).
707fn arg_array(args: &[Value], i: usize) -> Vec<String> {
708    with_host(|h| match args.get(i).and_then(|v| h.get(v)) {
709        Some(crate::host::JsObj::Array(items)) => items.iter().map(|v| h.str_of(v)).collect(),
710        _ => Vec::new(),
711    })
712}
713
714/// Read `.encoding` from the options object at `args[i]`, if present and a
715/// non-empty string.
716fn opts_encoding(args: &[Value], i: usize) -> Option<String> {
717    with_host(|h| match args.get(i).and_then(|v| h.get(v)) {
718        Some(crate::host::JsObj::Object(p)) => p
719            .get("encoding")
720            .map(|v| h.str_of(v))
721            .filter(|s| !s.is_empty() && s != "undefined" && s != "null"),
722        _ => None,
723    })
724}