Skip to main content

nodejs/stdlib/
cluster.rs

1//! Node `cluster` — real process-fork model over `std::process::Command`.
2//!
3//! # What is real
4//!
5//! * **`cluster.fork([env])`** spawns a genuine OS child process. It re-launches
6//!   the SAME runtime binary (`std::env::current_exe()`) on the SAME entry script
7//!   (`process.argv[1]`, i.e. `settings.exec`), with an env marker
8//!   (`CLUSTER_WORKER=<id>` + Node's own `NODE_UNIQUE_ID=<id>`) plus any caller
9//!   `env` overrides. The child therefore runs the whole program again, but this
10//!   time in worker mode. This is the actual Unix master/worker fork model, not a
11//!   simulation.
12//! * **`cluster.isPrimary`/`isMaster`/`isWorker`** are derived from that env
13//!   marker: the primary has neither `CLUSTER_WORKER` nor `NODE_UNIQUE_ID` set; a
14//!   forked child has one set, so it reports `isWorker === true`.
15//! * **Worker lifecycle events** are real, best-effort: `'fork'` fires
16//!   synchronously from `fork()`, `'online'` is posted onto the event loop
17//!   immediately after the child launches, and `'exit'` fires when a background
18//!   reaper thread observes the child process actually exit (via `Child::wait`).
19//!   Each live worker `incr_handle`s the loop so the primary stays alive while
20//!   workers run, and `'exit'` `decr_handle`s it.
21//! * **`Worker.kill([signal])`** delivers a real signal to the child pid
22//!   (`libc::kill`), so `cluster.workers[id].kill()` truly terminates the process.
23//! * **`cluster.workers`** maps live worker id → `Worker`, and **`cluster.worker`**
24//!   in a forked child is a `Worker` for itself (id from the env marker).
25//!
26//! # Documented limitations (honest, never a silent fake)
27//!
28//! * **No primary↔worker IPC channel.** Node connects each fork over a pipe and
29//!   ships `worker.send(msg)` / `process.on('message')` across it. node-js does
30//!   not wire a cross-process pipe here, so `Worker.send()` is a documented no-op
31//!   that returns `false`, and there is no `'message'` delivery between primary
32//!   and cluster workers. (In-process message passing exists in
33//!   `worker_threads`, which shares one address space; cluster workers are
34//!   separate OS processes and would need a real socket/pipe channel.)
35//! * **No shared listening socket.** Node's primary opens the listen socket once
36//!   and hands the same file descriptor to every worker so N workers accept on ONE
37//!   port (SO_REUSEPORT / fd passing). node-js does not pass fds across the fork,
38//!   so each worker that calls `server.listen(port)` binds its OWN socket — true
39//!   round-robin load balancing across workers on a single port is NOT provided.
40//!   Consequently `'listening'` is not emitted (no fd hand-off to observe) and
41//!   `Worker.disconnect()` cannot gracefully drain an IPC/socket channel: it marks
42//!   the worker disconnected and emits `'disconnect'`, but the real way to stop a
43//!   worker is `Worker.kill()`.
44
45use super::arg_str;
46use crate::host::{with_host, IoTask, JsObj};
47use fusevm::Value;
48use indexmap::IndexMap;
49use std::cell::RefCell;
50use std::collections::HashMap;
51use std::process::{Command, Stdio};
52use std::sync::atomic::{AtomicU64, Ordering};
53
54/// Callable module members. The EventEmitter surface is included so that
55/// `cluster.on('exit', …)` / `cluster.emit(…)` route through `stdlib::call`
56/// (`cluster.<method>`) to the process-wide cluster emitter.
57pub const METHODS: &[&str] = &[
58    "fork",
59    "setupPrimary",
60    "setupMaster",
61    "disconnect",
62    // EventEmitter surface (delegated to the cluster emitter).
63    "on",
64    "addListener",
65    "prependListener",
66    "once",
67    "prependOnceListener",
68    "emit",
69    "removeListener",
70    "off",
71    "removeAllListeners",
72    "listenerCount",
73    "listeners",
74    "eventNames",
75    "setMaxListeners",
76    "getMaxListeners",
77];
78
79/// Instance methods on a `Worker` (`@@native` tag `"ClusterWorker"`), beyond the
80/// shared EventEmitter surface.
81pub const WORKER_METHODS: &[&str] = &[
82    "send",
83    "kill",
84    "destroy",
85    "disconnect",
86    "isConnected",
87    "isDead",
88];
89
90/// The shared EventEmitter method names delegated to `events` — the one
91/// definition, so this dispatcher cannot drift from the emitter surface.
92const EMITTER_METHODS: &[&str] = super::events::METHODS;
93
94/// Monotonic worker-id source (matches Node: ids start at 1 and count up).
95static NEXT_WORKER_ID: AtomicU64 = AtomicU64::new(1);
96
97/// Stored `setupPrimary` settings. `None` fields fall back to the current
98/// process's `argv` at fork time (Node's defaults).
99#[derive(Default, Clone)]
100struct Settings {
101    /// The worker entry script (`settings.exec`); defaults to `process.argv[1]`.
102    exec: Option<String>,
103    /// Args passed to the worker (`settings.args`); defaults to `argv[2..]`.
104    args: Option<Vec<String>>,
105    /// Runtime flags (`settings.execArgv`); node-js has none meaningful, kept for
106    /// surface parity.
107    exec_argv: Option<Vec<String>>,
108    /// Whether to silence the worker's stdio (`settings.silent`).
109    silent: bool,
110}
111
112thread_local! {
113    /// Live workers owned by the primary, keyed by worker id. Only touched on the
114    /// main (primary) thread.
115    static WORKERS: RefCell<HashMap<u64, Value>> = RefCell::new(HashMap::new());
116    /// The process-wide `cluster` EventEmitter (cluster IS an emitter in Node);
117    /// created lazily and cached so `cluster.on(...)` and the fired lifecycle
118    /// events share one object.
119    static CLUSTER_EMITTER: RefCell<Option<Value>> = const { RefCell::new(None) };
120    /// The cached `cluster.worker` (self) object inside a forked child.
121    static SELF_WORKER: RefCell<Option<Value>> = const { RefCell::new(None) };
122    /// The stored `setupPrimary` settings.
123    static SETTINGS: RefCell<Settings> = RefCell::new(Settings::default());
124}
125
126// ── primary/worker detection ─────────────────────────────────────────────────
127
128/// The env marker used to mark a forked child as a cluster worker. Node uses
129/// `NODE_UNIQUE_ID`; we honor both it and our explicit `CLUSTER_WORKER`.
130fn worker_id_from_env() -> Option<u64> {
131    std::env::var("CLUSTER_WORKER")
132        .ok()
133        .or_else(|| std::env::var("NODE_UNIQUE_ID").ok())
134        .and_then(|s| s.trim().parse::<u64>().ok())
135}
136
137/// True in the primary process (no worker env marker set).
138fn is_primary() -> bool {
139    worker_id_from_env().is_none()
140}
141
142// ── module dispatch ──────────────────────────────────────────────────────────
143
144pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
145    // EventEmitter methods route to the process-wide cluster emitter.
146    if EMITTER_METHODS.contains(&method) {
147        let em = cluster_emitter();
148        return Some(super::events::instance_call(&em, method, args.to_vec()));
149    }
150    Some(match method {
151        "fork" => fork(args),
152        "setupPrimary" | "setupMaster" => setup_primary(args),
153        "disconnect" => disconnect(args),
154        _ => return None,
155    })
156}
157
158/// Non-function members of the `cluster` namespace.
159pub fn constant(name: &str) -> Option<Value> {
160    Some(match name {
161        "isPrimary" | "isMaster" => Value::Bool(is_primary()),
162        "isWorker" => Value::Bool(!is_primary()),
163        "workers" => workers_object(),
164        "worker" => {
165            if is_primary() {
166                with_host(|h| h.null())
167            } else {
168                self_worker()
169            }
170        }
171        "settings" => settings_object(),
172        // Node exposes SCHED_RR/SCHED_NONE; node-js does no cross-worker load
173        // balancing (see header), so report SCHED_NONE.
174        "SCHED_NONE" => Value::Float(1.0),
175        "SCHED_RR" => Value::Float(2.0),
176        "schedulingPolicy" => Value::Float(1.0),
177        _ => return None,
178    })
179}
180
181// ── fork ─────────────────────────────────────────────────────────────────────
182
183/// `cluster.fork([env])` — spawn a new OS process re-running the entry script in
184/// worker mode. Primary-only.
185fn fork(args: &[Value]) -> Result<Value, String> {
186    if !is_primary() {
187        return Err("Error: cluster.fork() can only be called from the primary process".into());
188    }
189
190    let s = SETTINGS.with(|s| s.borrow().clone());
191    let exec = s
192        .exec
193        .clone()
194        .or_else(|| std::env::args().nth(1))
195        .unwrap_or_default();
196    if exec.is_empty() {
197        return Err(
198            "Error: cluster.fork() requires a main script (process.argv[1]); none was found".into(),
199        );
200    }
201    let fwd_args: Vec<String> = s
202        .args
203        .clone()
204        .unwrap_or_else(|| std::env::args().skip(2).collect());
205    let exe = std::env::current_exe().map_err(|e| format!("Error: cluster.fork(): {e}"))?;
206
207    // Read caller `env` overrides off the JS heap before touching the OS.
208    let overrides = args.first().map(env_overrides).unwrap_or_default();
209
210    let id = NEXT_WORKER_ID.fetch_add(1, Ordering::SeqCst);
211
212    let mut cmd = Command::new(exe);
213    cmd.arg(&exec);
214    cmd.args(&fwd_args);
215    cmd.env("CLUSTER_WORKER", id.to_string());
216    cmd.env("NODE_UNIQUE_ID", id.to_string());
217    for (k, v) in overrides {
218        cmd.env(k, v);
219    }
220    if s.silent {
221        cmd.stdout(Stdio::null()).stderr(Stdio::null());
222    } else {
223        cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
224    }
225
226    let child = cmd
227        .spawn()
228        .map_err(|e| format!("Error: cluster.fork(): {e}"))?;
229    let pid = child.id();
230
231    // Build + register the Worker, keep the loop alive, then wire events.
232    let worker = new_worker(id, pid);
233    WORKERS.with(|w| {
234        w.borrow_mut().insert(id, worker.clone());
235    });
236    with_host(|h| h.incr_handle());
237
238    // `'fork'` fires synchronously (Node parity), on the cluster emitter only.
239    let _ = emit_on(&cluster_emitter(), "fork", vec![worker.clone()]);
240
241    // `'online'` is best-effort: posted onto the loop right after launch (no IPC
242    // "online" handshake exists — see header).
243    let io_online = with_host(|h| h.io_sender());
244    let _ = io_online.send(Box::new(move || dispatch_online(id)));
245
246    // Reaper: wait for the real child exit on a background thread, then post the
247    // `'exit'` event onto the main loop.
248    let io_exit: std::sync::mpsc::Sender<IoTask> = with_host(|h| h.io_sender());
249    std::thread::spawn(move || {
250        let mut child = child;
251        let code = child.wait().ok().and_then(|st| st.code()).unwrap_or(0);
252        let _ = io_exit.send(Box::new(move || dispatch_exit(id, code)));
253    });
254
255    Ok(worker)
256}
257
258/// Read a caller `env` object into `(key, value)` string pairs (skipping the
259/// hidden `@@`-prefixed internal keys).
260fn env_overrides(v: &Value) -> Vec<(String, String)> {
261    with_host(|h| match h.get(v) {
262        Some(JsObj::Object(p)) => p
263            .iter()
264            .filter(|(k, _)| !k.starts_with("@@"))
265            .map(|(k, val)| (k.clone(), h.str_of(val)))
266            .collect(),
267        _ => Vec::new(),
268    })
269}
270
271// ── setupPrimary / settings ──────────────────────────────────────────────────
272
273/// `cluster.setupPrimary(opts)` (alias `setupMaster`) — merge `opts` into the
274/// stored settings. Returns `undefined`.
275fn setup_primary(args: &[Value]) -> Result<Value, String> {
276    if let Some(opts) = args.first() {
277        let exec = str_prop(opts, "exec");
278        let arr = arr_prop(opts, "args");
279        let ea = arr_prop(opts, "execArgv");
280        let silent = bool_prop(opts, "silent");
281        SETTINGS.with(|s| {
282            let mut s = s.borrow_mut();
283            if exec.is_some() {
284                s.exec = exec;
285            }
286            if arr.is_some() {
287                s.args = arr;
288            }
289            if ea.is_some() {
290                s.exec_argv = ea;
291            }
292            if let Some(b) = silent {
293                s.silent = b;
294            }
295        });
296    }
297    Ok(Value::Undef)
298}
299
300/// `cluster.settings` — the effective settings object (stored values, with
301/// `argv` fallbacks resolved like Node).
302fn settings_object() -> Value {
303    let s = SETTINGS.with(|s| s.borrow().clone());
304    let exec = s
305        .exec
306        .clone()
307        .unwrap_or_else(|| std::env::args().nth(1).unwrap_or_default());
308    let args_vec = s
309        .args
310        .clone()
311        .unwrap_or_else(|| std::env::args().skip(2).collect());
312    let exec_argv = s.exec_argv.clone().unwrap_or_default();
313    with_host(|h| {
314        let arg_items: Vec<Value> = args_vec.into_iter().map(|a| h.new_str(a)).collect();
315        let args_arr = h.new_array(arg_items);
316        let ea_items: Vec<Value> = exec_argv.into_iter().map(|a| h.new_str(a)).collect();
317        let ea_arr = h.new_array(ea_items);
318        let exec_v = h.new_str(exec);
319        let mut m = IndexMap::new();
320        m.insert("exec".into(), exec_v);
321        m.insert("args".into(), args_arr);
322        m.insert("execArgv".into(), ea_arr);
323        m.insert("silent".into(), Value::Bool(s.silent));
324        h.new_object(m)
325    })
326}
327
328// ── disconnect (module-level) ────────────────────────────────────────────────
329
330/// `cluster.disconnect([callback])` — mark every live worker disconnected and
331/// emit `'disconnect'`. Without an IPC channel this cannot gracefully drain a
332/// worker; `Worker.kill()` is the real termination path (see header). If a
333/// callback is supplied it is invoked once, synchronously, after signalling.
334fn disconnect(args: &[Value]) -> Result<Value, String> {
335    let workers: Vec<Value> = WORKERS.with(|w| w.borrow().values().cloned().collect());
336    for wk in workers {
337        mark_disconnected(&wk);
338    }
339    if let Some(cb) = args.first() {
340        if with_host(|h| h.type_of(cb)) == "function" {
341            crate::host::invoke(cb, vec![], None)?;
342        }
343    }
344    Ok(Value::Undef)
345}
346
347// ── Worker instances (`@@native` tag "ClusterWorker") ────────────────────────
348
349/// Build a `Worker` emitter object carrying `.id`, `.process` (`{ pid }`) and the
350/// hidden bookkeeping props.
351fn new_worker(id: u64, pid: u32) -> Value {
352    let proc_obj = with_host(|h| {
353        let mut p = IndexMap::new();
354        p.insert("pid".into(), Value::Float(pid as f64));
355        p.insert("connected".into(), Value::Bool(true));
356        h.new_object(p)
357    });
358    let mut extra = IndexMap::new();
359    extra.insert("id".into(), Value::Float(id as f64));
360    extra.insert("process".into(), proc_obj);
361    extra.insert("@@cwid".into(), Value::Float(id as f64));
362    extra.insert("@@pid".into(), Value::Float(pid as f64));
363    extra.insert("@@connected".into(), Value::Bool(true));
364    super::net::new_emitter_object("ClusterWorker", extra)
365}
366
367/// The forked child's own `Worker` (`cluster.worker`), created once and cached.
368fn self_worker() -> Value {
369    if let Some(v) = SELF_WORKER.with(|c| c.borrow().clone()) {
370        return v;
371    }
372    let id = worker_id_from_env().unwrap_or(0);
373    let w = new_worker(id, std::process::id());
374    SELF_WORKER.with(|c| *c.borrow_mut() = Some(w.clone()));
375    w
376}
377
378/// `cluster.workers` — an object mapping id (string key) → live `Worker`.
379fn workers_object() -> Value {
380    let entries: Vec<(String, Value)> = WORKERS.with(|w| {
381        w.borrow()
382            .iter()
383            .map(|(id, wk)| (id.to_string(), wk.clone()))
384            .collect()
385    });
386    with_host(|h| {
387        let mut m = IndexMap::new();
388        for (k, v) in entries {
389            m.insert(k, v);
390        }
391        h.new_object(m)
392    })
393}
394
395pub fn instance_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
396    if EMITTER_METHODS.contains(&method) {
397        return super::events::instance_call(recv, method, args);
398    }
399    match method {
400        // No cross-process IPC channel exists (see header): `send` is a documented
401        // no-op returning `false` (Node returns a boolean write-queued flag).
402        "send" => Ok(Value::Bool(false)),
403        "kill" | "destroy" => {
404            let sig = signal_number(args.first());
405            if let Some(pid) = pid_of(recv) {
406                // SAFETY: `kill` is a plain syscall on a pid + signal number.
407                unsafe {
408                    libc::kill(pid as libc::pid_t, sig);
409                }
410            }
411            mark_disconnected(recv);
412            Ok(Value::Undef)
413        }
414        // Best-effort: mark disconnected + emit `'disconnect'`; cannot drain an IPC
415        // channel (none exists).
416        "disconnect" => {
417            mark_disconnected(recv);
418            Ok(recv.clone())
419        }
420        "isConnected" => Ok(Value::Bool(bool_prop(recv, "@@connected").unwrap_or(false))),
421        "isDead" => {
422            let id = pid_or_id(recv, "@@cwid");
423            let alive = id
424                .map(|i| WORKERS.with(|w| w.borrow().contains_key(&i)))
425                .unwrap_or(false);
426            Ok(Value::Bool(!alive))
427        }
428        _ => Err(crate::host::type_error(&format!(
429            "worker.{method} is not a function"
430        ))),
431    }
432}
433
434/// Mark a worker `@@connected = false` (and its `process.connected`), then emit
435/// `'disconnect'` on the worker and on the cluster emitter.
436fn mark_disconnected(worker: &Value) {
437    with_host(|h| {
438        if let Some(JsObj::Object(p)) = h.get_mut(worker) {
439            p.insert("@@connected".into(), Value::Bool(false));
440        }
441    });
442    let proc = with_host(|h| match h.get(worker) {
443        Some(JsObj::Object(p)) => p.get("process").cloned(),
444        _ => None,
445    });
446    if let Some(proc) = proc {
447        with_host(|h| {
448            if let Some(JsObj::Object(p)) = h.get_mut(&proc) {
449                p.insert("connected".into(), Value::Bool(false));
450            }
451        });
452    }
453    let _ = emit_on(worker, "disconnect", vec![]);
454    let _ = emit_on(&cluster_emitter(), "disconnect", vec![worker.clone()]);
455}
456
457// ── event delivery (run on the main loop) ────────────────────────────────────
458
459/// Fire `'online'` on the worker and the cluster emitter for `id`.
460fn dispatch_online(id: u64) -> Result<(), String> {
461    let Some(worker) = WORKERS.with(|w| w.borrow().get(&id).cloned()) else {
462        return Ok(());
463    };
464    emit_on(&worker, "online", vec![])?;
465    emit_on(&cluster_emitter(), "online", vec![worker])
466}
467
468/// Fire `'exit'` for `id` (worker: `(code, signal)`; cluster: `(worker, code,
469/// signal)`), drop it from the registry, and release the loop handle.
470fn dispatch_exit(id: u64, code: i32) -> Result<(), String> {
471    let Some(worker) = WORKERS.with(|w| w.borrow().get(&id).cloned()) else {
472        return Ok(());
473    };
474    let null_sig = with_host(|h| h.null());
475    emit_on(
476        &worker,
477        "exit",
478        vec![Value::Float(code as f64), null_sig.clone()],
479    )?;
480    emit_on(
481        &cluster_emitter(),
482        "exit",
483        vec![worker, Value::Float(code as f64), null_sig],
484    )?;
485    WORKERS.with(|w| {
486        w.borrow_mut().remove(&id);
487    });
488    with_host(|h| h.decr_handle());
489    Ok(())
490}
491
492// ── helpers ──────────────────────────────────────────────────────────────────
493
494/// The process-wide `cluster` EventEmitter, created once and cached.
495fn cluster_emitter() -> Value {
496    if let Some(v) = CLUSTER_EMITTER.with(|c| c.borrow().clone()) {
497        return v;
498    }
499    let e = super::events::new_emitter();
500    CLUSTER_EMITTER.with(|c| *c.borrow_mut() = Some(e.clone()));
501    e
502}
503
504/// Emit `name` (with `args`) on `emitter`, releasing the host borrow before
505/// dispatch (listeners re-enter the host).
506fn emit_on(emitter: &Value, name: &str, mut args: Vec<Value>) -> Result<(), String> {
507    let mut a = vec![with_host(|h| h.new_str(name))];
508    a.append(&mut args);
509    super::events::instance_call(emitter, "emit", a).map(|_| ())
510}
511
512/// The child pid recorded on a `Worker` (`@@pid`).
513fn pid_of(worker: &Value) -> Option<u32> {
514    pid_or_id(worker, "@@pid").map(|n| n as u32)
515}
516
517/// Read a numeric hidden prop off a `Worker`.
518fn pid_or_id(worker: &Value, key: &str) -> Option<u64> {
519    with_host(|h| match h.get(worker) {
520        Some(JsObj::Object(p)) => p.get(key).map(|v| h.to_number(v) as u64),
521        _ => None,
522    })
523}
524
525/// Map a kill signal argument (a name like `"SIGKILL"` or a number) to its signal
526/// number; defaults to `SIGTERM`. The name table lives in `process`, so this
527/// dispatcher and `process.kill` cannot recognize different signal sets.
528fn signal_number(arg: Option<&Value>) -> libc::c_int {
529    let Some(v) = arg else { return libc::SIGTERM };
530    let n = with_host(|h| h.to_number(v));
531    if n.is_finite() && n != 0.0 {
532        return n as libc::c_int;
533    }
534    super::process::signal_number(&arg_str(std::slice::from_ref(v), 0)).unwrap_or(libc::SIGTERM)
535}
536
537/// Read a string property off an options object (`None` if absent/empty).
538fn str_prop(obj: &Value, key: &str) -> Option<String> {
539    with_host(|h| match h.get(obj) {
540        Some(JsObj::Object(p)) => p
541            .get(key)
542            .map(|v| h.str_of(v))
543            .filter(|s| !s.is_empty() && s != "undefined"),
544        _ => None,
545    })
546}
547
548/// Read a boolean property off an options object.
549fn bool_prop(obj: &Value, key: &str) -> Option<bool> {
550    with_host(|h| match h.get(obj) {
551        Some(JsObj::Object(p)) => p.get(key).map(|v| h.truthy(v)),
552        _ => None,
553    })
554}
555
556/// Read an array-of-strings property off an options object.
557fn arr_prop(obj: &Value, key: &str) -> Option<Vec<String>> {
558    with_host(|h| match h.get(obj) {
559        Some(JsObj::Object(p)) => match p.get(key).and_then(|v| h.get(v)) {
560            Some(JsObj::Array(items)) => Some(items.iter().map(|v| h.str_of(v)).collect()),
561            _ => None,
562        },
563        _ => None,
564    })
565}