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