From 8fed172cf44fafa5348486a1cd3bfe2c7b45ca4a Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Thu, 30 Jul 2026 20:29:49 +0200 Subject: [PATCH 1/7] fix(child-process): complete Node 26 parity --- crates/perry-codegen/src/expr/child_proc.rs | 41 ++- .../perry-codegen/src/lower_call/builtin.rs | 9 + .../runtime_decls/stdlib_ffi/third_party.rs | 7 + .../src/child_process/builder.rs | 54 +++- .../src/child_process/emitter.rs | 26 +- .../perry-runtime/src/child_process/exec.rs | 14 +- .../perry-runtime/src/child_process/fork.rs | 28 +- crates/perry-runtime/src/child_process/mod.rs | 29 +- .../src/child_process/options.rs | 49 +++- .../perry-runtime/src/child_process/output.rs | 26 +- .../src/child_process/reactor.rs | 227 +++++++++++---- .../src/child_process/signals.rs | 24 ++ .../src/child_process/sync_run.rs | 18 +- .../src/child_process/v8_serde.rs | 205 ++++++++++++++ .../src/child_process/validate.rs | 259 +++++++++++++++++- .../src/child_process/value_util.rs | 18 ++ crates/perry-runtime/src/cluster.rs | 4 +- .../src/object/class_registry.rs | 6 +- .../src/object/class_registry/construct.rs | 9 + .../object/native_module/callable_exports.rs | 53 ++++ .../src/object/native_module_registry.rs | 9 +- 21 files changed, 1007 insertions(+), 108 deletions(-) diff --git a/crates/perry-codegen/src/expr/child_proc.rs b/crates/perry-codegen/src/expr/child_proc.rs index 2f9baa5c86..bd64741ac1 100644 --- a/crates/perry-codegen/src/expr/child_proc.rs +++ b/crates/perry-codegen/src/expr/child_proc.rs @@ -48,6 +48,34 @@ fn emit_cp_validate_args(ctx: &mut FnCtx<'_>, args_box: &str) { ); } +/// Validate a spawn/fork option bag while it is still NaN-boxed. `sync` +/// selects spawnSync's stdio rules and `allow_null` models fork's overload. +fn emit_cp_validate_options(ctx: &mut FnCtx<'_>, opts_box: &str, sync: bool, allow_null: bool) { + let blk = ctx.block(); + let _ = blk.call( + DOUBLE, + "js_child_process_validate_options", + &[ + (DOUBLE, opts_box), + (I32, if sync { "1" } else { "0" }), + (I32, if allow_null { "1" } else { "0" }), + ], + ); +} + +fn emit_cp_validate_spawn_args(ctx: &mut FnCtx<'_>, args_box: &str, sync: bool, allow_null: bool) { + let blk = ctx.block(); + let _ = blk.call( + DOUBLE, + "js_child_process_validate_spawn_args", + &[ + (DOUBLE, args_box), + (I32, if sync { "1" } else { "0" }), + (I32, if allow_null { "1" } else { "0" }), + ], + ); +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::ChildProcessExecSync { command, options } => { @@ -86,13 +114,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let cmd_str = unbox_to_i64(blk, &cmd_box); let args_str = if let Some(a) = args { let v = lower_expr(ctx, a)?; - emit_cp_validate_args(ctx, &v); + emit_cp_validate_spawn_args(ctx, &v, true, false); unbox_to_i64(ctx.block(), &v) } else { "0".to_string() }; let opts_str = if let Some(o) = options { let v = lower_expr(ctx, o)?; + emit_cp_validate_options(ctx, &v, true, false); unbox_to_i64(ctx.block(), &v) } else { "0".to_string() @@ -155,13 +184,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let cmd_str = unbox_to_i64(blk, &cmd_box); let args_str = if let Some(a) = args { let v = lower_expr(ctx, a)?; - emit_cp_validate_args(ctx, &v); + emit_cp_validate_spawn_args(ctx, &v, false, false); unbox_to_i64(ctx.block(), &v) } else { "0".to_string() }; let opts_str = if let Some(o) = options { let v = lower_expr(ctx, o)?; + emit_cp_validate_options(ctx, &v, false, false); unbox_to_i64(ctx.block(), &v) } else { "0".to_string() @@ -186,16 +216,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // runtime wires up an IPC channel + send/disconnect/'message'. The // runtime returns an already-NaN-boxed ChildProcess pointer. #1933. let mod_box = lower_expr(ctx, module)?; - let blk = ctx.block(); - let mod_str = unbox_to_i64(blk, &mod_box); + let mod_str = + ctx.block() + .call(I64, "js_jsvalue_to_string_coerce", &[(DOUBLE, &mod_box)]); let args_str = if let Some(a) = args { let v = lower_expr(ctx, a)?; + emit_cp_validate_spawn_args(ctx, &v, false, true); unbox_to_i64(ctx.block(), &v) } else { "0".to_string() }; let opts_str = if let Some(o) = options { let v = lower_expr(ctx, o)?; + emit_cp_validate_options(ctx, &v, false, true); unbox_to_i64(ctx.block(), &v) } else { "0".to_string() diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 7abad6dd9d..37521c7dba 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -282,6 +282,15 @@ pub(super) fn lower_builtin_new( let handle = blk.call(I64, "js_event_emitter_new_with_options", &[(DOUBLE, &opts)]); Ok(Some(nanbox_pointer_inline(blk, &handle))) } + // The public Node constructor creates an inert ChildProcess whose + // low-level `.spawn(options)` validates its own option bag. Normal + // callers use the dedicated spawn/fork lowering paths instead. + "ChildProcess" => { + for a in args { + let _ = lower_expr(ctx, a)?; + } + Ok(Some(ctx.block().call(DOUBLE, "js_child_process_new", &[]))) + } "EventEmitterAsyncResource" => { let opts = if let Some(a) = args.first() { lower_expr(ctx, a)? diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index bde24923ad..069615139a 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -296,6 +296,13 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { &[DOUBLE, PTR, I32], ); module.declare_function("js_child_process_validate_args", DOUBLE, &[DOUBLE]); + module.declare_function( + "js_child_process_validate_options", + DOUBLE, + &[DOUBLE, I32, I32], + ); + module.declare_function("js_child_process_validate_spawn_args", DOUBLE, &[DOUBLE]); + module.declare_function("js_child_process_new", DOUBLE, &[]); // ========== cheerio ========== module.declare_function("js_cheerio_load", I64, &[I64]); diff --git a/crates/perry-runtime/src/child_process/builder.rs b/crates/perry-runtime/src/child_process/builder.rs index ece660c647..e17dedb8e0 100644 --- a/crates/perry-runtime/src/child_process/builder.rs +++ b/crates/perry-runtime/src/child_process/builder.rs @@ -37,6 +37,8 @@ pub(crate) fn cp_register_arities() { js_register_closure_arity(cp_method_emit as *const u8, 2); js_register_closure_arity(cp_method_this0 as *const u8, 0); js_register_closure_arity(cp_method_this1 as *const u8, 1); + js_register_closure_arity(cp_method_child_spawn as *const u8, 1); + js_register_closure_arity(cp_method_set_encoding as *const u8, 1); js_register_closure_arity(cp_method_remove_listener as *const u8, 2); js_register_closure_arity(cp_method_remove_all_listeners as *const u8, 1); js_register_closure_arity(cp_method_kill as *const u8, 1); @@ -121,7 +123,7 @@ pub(crate) fn cp_build_readable() -> f64 { ("pause", cp_cast0(cp_method_this0)), ("resume", cp_cast0(cp_method_this0)), ("destroy", cp_cast0(cp_method_this0)), - ("setEncoding", cp_cast1(cp_method_this1)), + ("setEncoding", cp_cast1(cp_method_set_encoding)), ("read", cp_cast1(cp_method_read)), ("pipe", cp_cast1(cp_method_pipe)), ]; @@ -163,7 +165,57 @@ pub(crate) fn cp_build_writable() -> f64 { ]; let obj = cp_build_object(&methods, CP_WRITABLE_SHAPE_ID + methods.len() as u32); let val = cp_box_ptr(obj as *const u8); + cp_set_field(val, b"readable", TAG_FALSE_F64); cp_set_field(val, b"writable", TAG_TRUE_F64); cp_set_field(val, b"destroyed", TAG_FALSE_F64); val } + +/// Build the inert public `new ChildProcess()` instance. Normal `spawn()` and +/// `fork()` construct their live variants in the reactor; this low-level Node +/// API only needs the initial observable shape plus its validating `.spawn`. +pub(crate) fn cp_build_unstarted_child_process() -> f64 { + cp_register_arities(); + let methods: [(&str, CpFn); 11] = [ + ("on", cp_cast2(cp_method_on)), + ("once", cp_cast2(cp_method_on)), + ("addListener", cp_cast2(cp_method_on)), + ("removeListener", cp_cast2(cp_method_remove_listener)), + ("off", cp_cast2(cp_method_remove_listener)), + ("emit", cp_cast2(cp_method_emit)), + ( + "removeAllListeners", + cp_cast1(cp_method_remove_all_listeners), + ), + ("kill", cp_cast1(cp_method_kill)), + ("ref", cp_cast0(cp_method_this0)), + ("unref", cp_cast0(cp_method_this0)), + ("spawn", cp_cast1(cp_method_child_spawn)), + ]; + let obj = cp_build_object(&methods, CP_SHAPE_ID + 0x60 + methods.len() as u32); + let child = cp_box_ptr(obj as *const u8); + cp_set_field(child, b"connected", TAG_FALSE_F64); + cp_set_field(child, b"killed", TAG_FALSE_F64); + cp_set_field(child, b"exitCode", TAG_NULL_F64); + cp_set_field(child, b"signalCode", TAG_NULL_F64); + + let constructor = + crate::object::bound_native_callable_export_value("child_process", "ChildProcess"); + let constructor = + unsafe { crate::object::callable_exports::ensure_child_process_prototype(constructor) }; + let raw = (constructor.to_bits() & crate::value::POINTER_MASK) as usize; + let prototype = crate::closure::closure_get_dynamic_prop(raw, "prototype"); + if cp_object_ptr(prototype).is_some() { + crate::object::prototype_chain::object_set_static_prototype( + obj as usize, + prototype.to_bits(), + ); + } + child +} + +/// Public constructor hook for the codegen `new ChildProcess()` fast path. +#[no_mangle] +pub extern "C" fn js_child_process_new() -> f64 { + cp_build_unstarted_child_process() +} diff --git a/crates/perry-runtime/src/child_process/emitter.rs b/crates/perry-runtime/src/child_process/emitter.rs index 85ddcf2dcc..d536c6252c 100644 --- a/crates/perry-runtime/src/child_process/emitter.rs +++ b/crates/perry-runtime/src/child_process/emitter.rs @@ -68,7 +68,9 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { // and that iterator registers its `data`/`end`/`error` listeners in node:stream's // registry rather than the one above. Forward there too, so a `for await` over a // child's output sees the chunks the reactor delivers. - crate::node_stream::emit_to_stream_listeners(target, event.as_bytes(), args); + if !JSValue::from_bits(cp_get_field(target, b"readable").to_bits()).is_undefined() { + crate::node_stream::emit_to_stream_listeners(target, event.as_bytes(), args); + } fired } @@ -102,7 +104,26 @@ pub(crate) extern "C" fn cp_method_this0(closure: *const ClosureHeader) -> f64 { pub(crate) extern "C" fn cp_method_this1(closure: *const ClosureHeader, _a: f64) -> f64 { cp_this(closure) } + +/// Low-level `new ChildProcess().spawn(options)` validation boundary. Node's +/// constructor is public even though normal callers use `spawn()`; keep the +/// constructed idle object inert after its setup checks. +pub(crate) extern "C" fn cp_method_child_spawn(closure: *const ClosureHeader, options: f64) -> f64 { + crate::child_process::validate::cp_validate_child_process_spawn(options); + cp_this(closure) +} +/// `child.stdout.setEncoding(encoding)` switches emitted chunks from Buffers +/// to decoded strings. The reactor reads this field when it delivers data. +pub(crate) extern "C" fn cp_method_set_encoding( + closure: *const ClosureHeader, + encoding: f64, +) -> f64 { + let this = cp_this(closure); + cp_set_field(this, b"__cpEncoding", encoding); + this +} pub(crate) extern "C" fn cp_method_kill(closure: *const ClosureHeader, signal: f64) -> f64 { + cp_validate_signal(signal); let this = cp_this(closure); cp_set_field(this, b"killed", TAG_TRUE_F64); // #1934: signal the live child if one is still running. `__cpHandle` is the @@ -113,7 +134,7 @@ pub(crate) extern "C" fn cp_method_kill(closure: *const ClosureHeader, signal: f return TAG_TRUE_F64; } } - TAG_TRUE_F64 + TAG_FALSE_F64 } /// `child[Symbol.dispose]()` — Node aliases this to `kill()` and returns /// `undefined`, so `using child = spawn(...)` terminates the subprocess on @@ -396,6 +417,7 @@ pub(crate) extern "C" fn cp_method_stdin_end(closure: *const ClosureHeader, chun } reactor::cp_live_stdin_close(handle); } + cp_set_field(this, b"writable", TAG_FALSE_F64); this } diff --git a/crates/perry-runtime/src/child_process/exec.rs b/crates/perry-runtime/src/child_process/exec.rs index 28c88dd2c7..e740d639f9 100644 --- a/crates/perry-runtime/src/child_process/exec.rs +++ b/crates/perry-runtime/src/child_process/exec.rs @@ -89,11 +89,7 @@ pub extern "C" fn js_child_process_spawn_sync( String::from_utf8_lossy(std::slice::from_raw_parts(cmd_data, cmd_len)).into_owned() }; - let opts_val = if options_ptr.is_null() { - cp_undefined() - } else { - cp_box_ptr(options_ptr as *const u8) - }; + let opts_val = cp_options_from_raw_args(args_ptr as i64, options_ptr as i64); let mode = cp_read_output_mode(opts_val, false); // Build command (run the file directly — spawnSync does not use a shell @@ -468,7 +464,13 @@ fn cp_promisified_run(command: Command, cmd_str: String, opts: f64) -> f64 { let cb = js_closure_alloc(cp_promise_settle_cb as *const u8, 1); js_closure_set_capture_ptr(cb, 0, cp_box_ptr(promise as *const u8).to_bits() as i64); let cb_val = crate::value::js_nanbox_pointer(cb as i64); - reactor::cp_exec_async(command, cmd_str, cb_val, run_options, mode); + let child = reactor::cp_exec_async(command, cmd_str, cb_val, run_options, mode); + crate::object::exotic_expando::value_store( + crate::object::exotic_expando::ExoticKind::Promise, + promise as usize, + "child", + child.to_bits(), + ); crate::value::js_nanbox_pointer(promise as i64) } diff --git a/crates/perry-runtime/src/child_process/fork.rs b/crates/perry-runtime/src/child_process/fork.rs index eb4f6073d7..c307256d72 100644 --- a/crates/perry-runtime/src/child_process/fork.rs +++ b/crates/perry-runtime/src/child_process/fork.rs @@ -20,22 +20,30 @@ use super::*; use std::process::Command; use std::time::Duration; -/// `child_process.fork(modulePath[, args][, options])`. `module_ptr`/`args_ptr` -/// are raw (unboxed) `StringHeader` / `ArrayHeader` pointers; `opts_ptr` is a -/// raw heap pointer (or 0). Returns a NaN-boxed ChildProcess. +/// `child_process.fork(modulePath[, args][, options])`. `module_val` stays +/// raw string/array pointers; `opts_ptr` is a raw heap pointer (or 0). The +/// codegen boundary string-coerces a URL module path before calling this. #[no_mangle] pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr: i64) -> f64 { cp_register_arities(); reactor::cp_register_reactor_arities(); - let module = unsafe { cp_read_string_header(module_ptr) }; - let arg_strs = unsafe { cp_read_arg_strings(args_ptr) }; - let opts_val = if opts_ptr > 0x10000 { - cp_box_ptr(opts_ptr as *const u8) + let module_raw = unsafe { cp_read_string_header(module_ptr) }; + if module_raw.contains('\0') { + crate::fs::validate::throw_type_error_with_code( + "The argument must not contain null bytes", + "ERR_INVALID_ARG_VALUE", + ); + } + let module = if module_raw.starts_with("file:") { + crate::url::node_compat::module_base_to_path(cp_box_string(&module_raw)) + .unwrap_or(module_raw) } else { - cp_undefined() + module_raw }; + let arg_strs = unsafe { cp_read_arg_strings(args_ptr) }; + let opts_val = cp_options_from_raw_args(args_ptr, opts_ptr); let abort_signal = cp_read_abort_signal(opts_val); // Launch interpreter: options.execPath → $PERRY_FORK_EXECPATH → "node". @@ -141,7 +149,7 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr cp_apply_argv0(&mut command, opts_val); cp_apply_options(&mut command, opts_val); cp_apply_detached(&mut command, opts_val); - cp_apply_live_stdio(&mut command, &stdio_kinds); + let _ = cp_apply_live_stdio(&mut command, &stdio_kinds); let launched = fork_launch( cp, @@ -230,6 +238,7 @@ fn fork_launch( stdout_obj, stderr_obj, stdin_obj, + Vec::new(), child, Some(parent_sock), advanced, @@ -264,6 +273,7 @@ fn fork_launch( stdout_obj, stderr_obj, stdin_obj, + Vec::new(), child, None, false, diff --git a/crates/perry-runtime/src/child_process/mod.rs b/crates/perry-runtime/src/child_process/mod.rs index 71416bb783..99c52bf8d9 100644 --- a/crates/perry-runtime/src/child_process/mod.rs +++ b/crates/perry-runtime/src/child_process/mod.rs @@ -12,7 +12,10 @@ mod v8_serde; mod sync_run; // #3079: setup-time command/file/args validation (`ERR_INVALID_ARG_TYPE`). mod validate; -pub use validate::{js_child_process_validate_args, js_child_process_validate_command}; +pub use validate::{ + js_child_process_validate_args, js_child_process_validate_command, + js_child_process_validate_options, js_child_process_validate_spawn_args, +}; // #3137: reuse the codec for the public `node:v8` serialize/deserialize API. // #3680: class-based `v8.Serializer` / `v8.Deserializer` builders. @@ -69,35 +72,37 @@ pub use registry::{ // value_util.rs — NaN-box value helpers. pub(crate) use value_util::{ cp_args_from_value, cp_array_ptr, cp_box_ptr, cp_box_string, cp_box_string_bytes, - cp_coerce_string, cp_get_field, cp_make_buffer, cp_object_ptr, cp_read_arg_strings, - cp_read_string_header, cp_set_field, cp_this, cp_undefined, cp_value_to_bytes, - cp_value_to_string, + cp_coerce_string, cp_get_field, cp_make_buffer, cp_object_ptr, cp_options_from_raw_args, + cp_read_arg_strings, cp_read_string_header, cp_set_field, cp_this, cp_undefined, + cp_value_to_bytes, cp_value_to_string, }; // signals.rs — signal name/number mapping + kill/timeout reads. pub(crate) use signals::{ - cp_read_kill_signal, cp_read_timeout, cp_signal_from_value, cp_signal_name, CP_SIGTERM, + cp_read_kill_signal, cp_read_timeout, cp_signal_from_value, cp_signal_is_valid, cp_signal_name, + cp_validate_signal, CP_SIGTERM, }; // emitter.rs — EventEmitter listener registry, method bodies, IPC send/disconnect. pub(crate) use emitter::{ - cp_emit, cp_method_disconnect, cp_method_dispose, cp_method_emit, cp_method_kill, cp_method_on, - cp_method_pipe, cp_method_read, cp_method_remove_all_listeners, cp_method_remove_listener, - cp_method_send, cp_method_stdin_end, cp_method_this0, cp_method_this1, cp_method_write2, - cp_send_callback_thunk, js_fork_child, + cp_emit, cp_method_child_spawn, cp_method_disconnect, cp_method_dispose, cp_method_emit, + cp_method_kill, cp_method_on, cp_method_pipe, cp_method_read, cp_method_remove_all_listeners, + cp_method_remove_listener, cp_method_send, cp_method_set_encoding, cp_method_stdin_end, + cp_method_this0, cp_method_this1, cp_method_write2, cp_send_callback_thunk, js_fork_child, }; // builder.rs — heap object construction + shape ids. pub(crate) use builder::{ - cp_build_object, cp_build_readable, cp_build_writable, cp_cast0, cp_cast1, cp_cast2, cp_cast4, - cp_install_dispose, cp_register_arities, CpFn, CP_SHAPE_ID, + cp_build_object, cp_build_readable, cp_build_unstarted_child_process, cp_build_writable, + cp_cast0, cp_cast1, cp_cast2, cp_cast4, cp_install_dispose, cp_register_arities, CpFn, + CP_SHAPE_ID, }; // options.rs — command option application (cwd/env/uid/gid/argv0/detached/stdio). pub(crate) use options::{ cp_abort_signal_is_aborted, cp_apply_argv0, cp_apply_detached, cp_apply_live_stdio, cp_apply_options, cp_build_command, cp_read_abort_signal, cp_read_stdio, cp_spawnargs_argv0, - cp_stdio_from_fd, cp_stdio_js_value, CpStdio, + cp_stdio_from_fd, cp_stdio_js_value, cp_stdio_stream_fd, CpStdio, }; // output.rs — output encoding, error shape, exit decoding. diff --git a/crates/perry-runtime/src/child_process/options.rs b/crates/perry-runtime/src/child_process/options.rs index 192549960a..57e7b9fd6a 100644 --- a/crates/perry-runtime/src/child_process/options.rs +++ b/crates/perry-runtime/src/child_process/options.rs @@ -186,7 +186,7 @@ fn cp_stdio_number_fd(value: f64) -> Option { } } -fn cp_stdio_stream_fd(value: f64, fd_index: usize) -> Option { +pub(crate) fn cp_stdio_stream_fd(value: f64, fd_index: usize) -> Option { let expected_stream = match fd_index { 0 => crate::fs::is_fs_stream_instance_value(value, "ReadStream"), 1 | 2 => crate::fs::is_fs_stream_instance_value(value, "WriteStream"), @@ -250,7 +250,11 @@ pub(crate) fn cp_stdio_js_value(kind: CpStdio, pipe_obj: f64) -> f64 { } } -pub(crate) fn cp_apply_live_stdio(command: &mut Command, stdio: &[CpStdio]) { +/// Apply stdio 0-2 and create parent-readable pipes for extra `"pipe"` fds. +pub(crate) fn cp_apply_live_stdio( + command: &mut Command, + stdio: &[CpStdio], +) -> Vec<(usize, std::fs::File)> { let to_stdio = |kind: CpStdio| match kind { CpStdio::Pipe => Stdio::piped(), CpStdio::Ignore => Stdio::null(), @@ -260,6 +264,47 @@ pub(crate) fn cp_apply_live_stdio(command: &mut Command, stdio: &[CpStdio]) { command.stdin(to_stdio(stdio.first().copied().unwrap_or(CpStdio::Pipe))); command.stdout(to_stdio(stdio.get(1).copied().unwrap_or(CpStdio::Pipe))); command.stderr(to_stdio(stdio.get(2).copied().unwrap_or(CpStdio::Pipe))); + + #[cfg(unix)] + { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::process::CommandExt; + + let mut readers = Vec::new(); + for (fd, kind) in stdio.iter().copied().enumerate().skip(3) { + if kind != CpStdio::Pipe { + continue; + } + let mut pipe = [0; 2]; + if unsafe { libc::pipe(pipe.as_mut_ptr()) } != 0 { + continue; + } + let read = unsafe { std::fs::File::from_raw_fd(pipe[0]) }; + let write = unsafe { std::fs::File::from_raw_fd(pipe[1]) }; + let write_fd = write.as_raw_fd(); + unsafe { + libc::fcntl(write_fd, libc::F_SETFD, libc::FD_CLOEXEC); + } + unsafe { + command.pre_exec(move || { + // Keep the write end owned by Command until the fork; the + // parent drops it after spawn while FD_CLOEXEC closes the + // original child descriptor on exec. + if libc::dup2(write.as_raw_fd(), fd as i32) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + readers.push((fd, read)); + } + return readers; + } + + #[cfg(not(unix))] + { + Vec::new() + } } #[cfg(unix)] diff --git a/crates/perry-runtime/src/child_process/output.rs b/crates/perry-runtime/src/child_process/output.rs index 6f9ba2a6bf..3d278d0d0b 100644 --- a/crates/perry-runtime/src/child_process/output.rs +++ b/crates/perry-runtime/src/child_process/output.rs @@ -157,11 +157,14 @@ fn cp_make_error_with_class( }; set("name", cp_box_string(name)); set("message", cp_box_string(message)); + let constructor = crate::object::js_get_global_this_builtin_value(name.as_ptr(), name.len()); + set("constructor", constructor); // `name`/`message` are non-enumerable on a Node Error (only the diagnostic // props are enumerable), so keep them out of `Object.keys(err)`. let attrs = crate::object::PropertyAttrs::new(true, false, true); crate::object::set_property_attrs(obj as usize, "name".to_string(), attrs); crate::object::set_property_attrs(obj as usize, "message".to_string(), attrs); + crate::object::set_property_attrs(obj as usize, "constructor".to_string(), attrs); for (k, v) in extra { set(k, *v); } @@ -387,17 +390,18 @@ pub(crate) fn cp_sync_throw_error(run: &CpRun, cmd: &str, stdout: f64, stderr: f }; // Field order matches Node's insertion order (status, signal, output, pid, // stdout, stderr) so `Object.keys(err)` is byte-identical. - let err = cp_make_error( - &message, - &[ - ("status", status), - ("signal", signal), - ("output", output), - ("pid", pid), - ("stdout", stdout), - ("stderr", stderr), - ], - ); + let mut fields = vec![ + ("status", status), + ("signal", signal), + ("output", output), + ("pid", pid), + ("stdout", stdout), + ("stderr", stderr), + ]; + if let Some((code, _)) = &run.spawn_error { + fields.push(("code", cp_box_string(code))); + } + let err = cp_make_error(&message, &fields); crate::exception::js_throw(err) } diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index b66444bba8..1229c59504 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -56,11 +56,11 @@ enum CpEvent { /// A stdout (`stderr == false`) or stderr chunk. Data { handle: u64, - stderr: bool, + fd: usize, bytes: Vec, }, /// End-of-file on a stream — the reader thread finished. - Eof { handle: u64, stderr: bool }, + Eof { handle: u64, fd: usize }, /// The child process terminated (`code` xor `signal`). Exited { handle: u64, @@ -97,6 +97,10 @@ struct LiveChild { stdin: Option, stdout_open: bool, stderr_open: bool, + /// Hold stdout EOF until stderr EOF when both pipes exist. Node drains + /// stderr before stdout for a child that closes both descriptors together. + stdout_eof_pending: bool, + extra_open: Vec, /// Whether the `spawn` event has been emitted yet. spawned: bool, /// `Some((code, signal))` once the waiter reported termination. @@ -222,19 +226,19 @@ fn libc_sigterm() -> i32 { } /// Spawn a reader thread that streams `pipe` to the event queue until EOF. -fn cp_spawn_reader(handle: u64, mut pipe: R, stderr: bool) { +fn cp_spawn_reader(handle: u64, mut pipe: R, fd: usize) { std::thread::spawn(move || { let mut buf = [0u8; 8192]; loop { match pipe.read(&mut buf) { Ok(0) | Err(_) => { - cp_push_event(CpEvent::Eof { handle, stderr }); + cp_push_event(CpEvent::Eof { handle, fd }); break; } Ok(n) => { cp_push_event(CpEvent::Data { handle, - stderr, + fd, bytes: buf[..n].to_vec(), }); } @@ -350,6 +354,7 @@ pub(super) fn cp_register_live_child( stdout_obj: f64, stderr_obj: f64, stdin_obj: f64, + extra_pipes: Vec<(usize, f64, std::fs::File)>, mut child: Child, ipc: Option, ipc_advanced: bool, @@ -375,6 +380,9 @@ pub(super) fn cp_register_live_child( cp_set_field(stdin_obj, b"__cpHandle", handle_f); cp_set_field(stdout_obj, b"__cpHandle", handle_f); cp_set_field(stderr_obj, b"__cpHandle", handle_f); + for (_, stream, _) in &extra_pipes { + cp_set_field(*stream, b"__cpHandle", handle_f); + } // For fork, keep a clone of the IPC socket for send/disconnect; the reader // thread owns the original. @@ -397,6 +405,8 @@ pub(super) fn cp_register_live_child( stdin: stdin_pipe, stdout_open, stderr_open, + stdout_eof_pending: false, + extra_open: extra_pipes.iter().map(|(fd, _, _)| *fd).collect(), spawned: false, exited: None, closed: false, @@ -417,10 +427,13 @@ pub(super) fn cp_register_live_child( CP_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); if let Some(o) = stdout_pipe { - cp_spawn_reader(handle, o, false); + cp_spawn_reader(handle, o, 1); } if let Some(e) = stderr_pipe { - cp_spawn_reader(handle, e, true); + cp_spawn_reader(handle, e, 2); + } + for (fd, _, pipe) in extra_pipes { + cp_spawn_reader(handle, pipe, fd); } cp_spawn_waiter(handle, child); if let Some(timeout) = timeout { @@ -678,18 +691,18 @@ pub extern "C" fn js_child_process_spawn_streams( // `opts_ptr` arrives as a raw (unboxed) heap pointer; re-box it so the // options helpers can read `cwd`/`env`/`shell`. Small values mean // no-options (codegen passes 0) — leave it undefined then. - let opts_val = if opts_ptr > 0x10000 { - cp_box_ptr(opts_ptr as *const u8) - } else { - cp_undefined() - }; + let opts_val = cp_options_from_raw_args(args_ptr, opts_ptr); let abort_signal = cp_read_abort_signal(opts_val); // stdout/stderr Readable + stdin Writable sub-objects. let stdout_obj = cp_build_readable(); let stderr_obj = cp_build_readable(); let stdin_obj = cp_build_writable(); - let stdio_kinds = cp_read_stdio(opts_val, 3); + let stdio_count = cp_array_ptr(cp_get_field(opts_val, b"stdio")) + .map(|arr| crate::array::js_array_length(arr) as usize) + .unwrap_or(3) + .max(3); + let stdio_kinds = cp_read_stdio(opts_val, stdio_count); let timeout = cp_read_timeout(opts_val); let kill_signal = cp_read_kill_signal(opts_val); @@ -725,10 +738,22 @@ pub extern "C" fn js_child_process_spawn_streams( cp_set_field(cp, b"stderr", cp_stdio_js_value(stdio_kinds[2], stderr_obj)); cp_set_field(cp, b"stdin", cp_stdio_js_value(stdio_kinds[0], stdin_obj)); - let mut stdio = crate::array::js_array_alloc(3); + let mut stdio = crate::array::js_array_alloc(stdio_count as u32); stdio = crate::array::js_array_push_f64(stdio, cp_stdio_js_value(stdio_kinds[0], stdin_obj)); stdio = crate::array::js_array_push_f64(stdio, cp_stdio_js_value(stdio_kinds[1], stdout_obj)); stdio = crate::array::js_array_push_f64(stdio, cp_stdio_js_value(stdio_kinds[2], stderr_obj)); + let mut extra_streams = Vec::new(); + for (fd, kind) in stdio_kinds.iter().copied().enumerate().skip(3) { + let stream = if kind == CpStdio::Pipe { + cp_build_readable() + } else { + TAG_NULL_F64 + }; + if kind == CpStdio::Pipe { + extra_streams.push((fd, stream)); + } + stdio = crate::array::js_array_push_f64(stdio, cp_stdio_js_value(kind, stream)); + } cp_set_field(cp, b"stdio", cp_box_ptr(stdio as *const u8)); cp_set_field(cp, b"exitCode", TAG_NULL_F64); @@ -740,7 +765,7 @@ pub extern "C" fn js_child_process_spawn_streams( // Build + launch the child (honoring `shell`/`cwd`/`env`), non-blocking. let mut command = cp_build_command(&cmd_str, &arg_strs, opts_val); - cp_apply_live_stdio(&mut command, &stdio_kinds); + let extra_readers = cp_apply_live_stdio(&mut command, &stdio_kinds); match command.spawn() { Ok(child) => { @@ -749,6 +774,15 @@ pub extern "C" fn js_child_process_spawn_streams( stdout_obj, stderr_obj, stdin_obj, + extra_readers + .into_iter() + .filter_map(|(fd, pipe)| { + extra_streams + .iter() + .find(|(stream_fd, _)| *stream_fd == fd) + .map(|(_, stream)| (fd, *stream, pipe)) + }) + .collect(), child, None, false, @@ -784,10 +818,15 @@ pub extern "C" fn js_child_process_spawn_streams( ], ); cp_set_field(cp, b"__cpError", err); + cp_set_field(cp, b"__cpSpawnErrno", super::cp_errno_number(code)); + cp_set_field(cp, b"exitCode", super::cp_errno_number(code)); let emit_closure = crate::closure::js_closure_alloc(cp_emit_spawn_error as *const u8, 1); crate::closure::js_closure_set_capture_ptr(emit_closure, 0, cp.to_bits() as i64); crate::timer::js_set_immediate_callback(emit_closure as i64); + let close = crate::closure::js_closure_alloc(cp_emit_spawn_close as *const u8, 1); + crate::closure::js_closure_set_capture_ptr(close, 0, cp.to_bits() as i64); + crate::timer::js_set_timeout_callback(close as i64, 1.0); } } @@ -801,12 +840,20 @@ pub(super) extern "C" fn cp_emit_spawn_error(closure: *const ClosureHeader) -> f let err = cp_get_field(cp, b"__cpError"); if !JSValue::from_bits(err.to_bits()).is_undefined() { cp_emit(cp, "error", &[err]); + cp_set_field(cp, b"signalCode", TAG_NULL_F64); } cp_undefined() } +extern "C" fn cp_emit_spawn_close(closure: *const ClosureHeader) -> f64 { + let cp = cp_this(closure); + cp_emit(cp, "close", &[cp_get_field(cp, b"exitCode"), TAG_NULL_F64]); + cp_undefined() +} + pub(super) fn cp_register_reactor_arities() { crate::closure::js_register_closure_arity(cp_emit_spawn_error as *const u8, 0); + crate::closure::js_register_closure_arity(cp_emit_spawn_close as *const u8, 0); crate::closure::js_register_closure_arity(cp_abort_listener as *const u8, 0); crate::closure::js_register_closure_arity(cp_exec_cb_thunk as *const u8, 0); } @@ -835,7 +882,17 @@ pub(super) fn cp_exec_async( // The program actually launched (`sh` for exec, the file for execFile) — // Node's spawn-failure error keys `syscall`/`path`/message off this, not // off the display command string. - let file = command.get_program().to_string_lossy().into_owned(); + let file = { + let program = command.get_program().to_string_lossy(); + #[cfg(unix)] + if program == "sh" { + "/bin/sh".to_string() + } else { + program.into_owned() + } + #[cfg(not(unix))] + program.into_owned() + }; // exec/execFile capture stdout+stderr and never feed stdin. command.stdin(Stdio::null()); @@ -844,6 +901,38 @@ pub(super) fn cp_exec_async( let timeout = run_options.timeout(); let kill_signal = run_options.kill_signal(); + let stdout_obj = cp_build_readable(); + let stderr_obj = cp_build_readable(); + let methods: [(&str, CpFn); 11] = [ + ("on", cp_cast2(cp_method_on)), + ("once", cp_cast2(cp_method_on)), + ("addListener", cp_cast2(cp_method_on)), + ("prependListener", cp_cast2(cp_method_on)), + ("removeListener", cp_cast2(cp_method_remove_listener)), + ("off", cp_cast2(cp_method_remove_listener)), + ( + "removeAllListeners", + cp_cast1(cp_method_remove_all_listeners), + ), + ("emit", cp_cast2(cp_method_emit)), + ("kill", cp_cast1(cp_method_kill)), + ("ref", cp_cast0(cp_method_this0)), + ("unref", cp_cast0(cp_method_this0)), + ]; + let cp = cp_box_ptr(cp_build_object(&methods, CP_SHAPE_ID + methods.len() as u32) as *const u8); + cp_set_field(cp, b"stdout", stdout_obj); + cp_set_field(cp, b"stderr", stderr_obj); + cp_set_field(cp, b"stdin", TAG_NULL_F64); + let mut stdio = crate::array::js_array_alloc(3); + stdio = crate::array::js_array_push_f64(stdio, TAG_NULL_F64); + stdio = crate::array::js_array_push_f64(stdio, stdout_obj); + stdio = crate::array::js_array_push_f64(stdio, stderr_obj); + cp_set_field(cp, b"stdio", cp_box_ptr(stdio as *const u8)); + cp_set_field(cp, b"exitCode", TAG_NULL_F64); + cp_set_field(cp, b"signalCode", TAG_NULL_F64); + cp_set_field(cp, b"killed", TAG_FALSE_F64); + cp_set_field(cp, b"connected", TAG_FALSE_F64); + cp_set_field(cp, b"spawnfile", cp_box_string(&file)); match command.spawn() { Ok(mut child) => { @@ -857,6 +946,10 @@ pub(super) fn cp_exec_async( let stdout_open = stdout_pipe.is_some(); let stderr_open = stderr_pipe.is_some(); let handle = CP_NEXT_LIVE_ID.fetch_add(1, Ordering::SeqCst); + cp_set_field(cp, b"pid", pid as f64); + cp_set_field(cp, b"__cpHandle", handle as f64); + cp_set_field(stdout_obj, b"__cpHandle", handle as f64); + cp_set_field(stderr_obj, b"__cpHandle", handle as f64); let exec = Box::new(CpExecPending { cb_bits: cb_val.to_bits(), @@ -876,12 +969,13 @@ pub(super) fn cp_exec_async( map.insert( handle, LiveChild { - // No JS ChildProcess object for the exec callback form. - cp_bits: TAG_UNDEFINED_BITS, + cp_bits: cp.to_bits(), pid: pid as i32, stdin: None, stdout_open, stderr_open, + stdout_eof_pending: false, + extra_open: Vec::new(), spawned: false, exited: None, closed: false, @@ -902,16 +996,17 @@ pub(super) fn cp_exec_async( CP_LIVE_COUNT.fetch_add(1, Ordering::SeqCst); if let Some(o) = stdout_pipe { - cp_spawn_reader(handle, o, false); + cp_spawn_reader(handle, o, 1); } if let Some(e) = stderr_pipe { - cp_spawn_reader(handle, e, true); + cp_spawn_reader(handle, e, 2); } cp_spawn_waiter(handle, child); if let Some(timeout) = timeout { cp_spawn_timeout(handle, timeout, kill_signal); } crate::event_pump::js_notify_main_thread(); + cp } Err(e) => { // Could not spawn at all (ENOENT, EACCES…). Build the same callback @@ -930,10 +1025,9 @@ pub(super) fn cp_exec_async( let (err, out, errout) = super::cp_exec_callback_args(&run, &run_options, &cmd_str, &file, &mode); cp_defer_exec_callback(cb_val, err, out, errout); + cp } } - - cp_undefined() } /// Append a chunk to an exec child's captured stdout/stderr. Returns @@ -1056,21 +1150,19 @@ fn cp_reactor_pump_inner() { // thread). // exec/execFile children (#4912) have no JS ChildProcess, so they get no // `spawn` event — just mark them spawned so Phase B can close them. - let to_spawn: Vec<(u64, u64, bool)> = { + let to_spawn: Vec<(u64, u64)> = { let guard = cp_live_lock(); match guard.as_ref() { Some(map) => map .iter() .filter(|(_, lc)| !lc.spawned) - .map(|(h, lc)| (*h, lc.cp_bits, lc.exec.is_some())) + .map(|(h, lc)| (*h, lc.cp_bits)) .collect(), None => Vec::new(), } }; - for (handle, cp_bits, is_exec) in to_spawn { - if !is_exec { - cp_emit(f64::from_bits(cp_bits), "spawn", &[]); - } + for (handle, cp_bits) in to_spawn { + cp_emit(f64::from_bits(cp_bits), "spawn", &[]); if let Some(map) = cp_live_lock().as_mut() { if let Some(lc) = map.get_mut(&handle) { lc.spawned = true; @@ -1082,11 +1174,7 @@ fn cp_reactor_pump_inner() { let events = std::mem::take(&mut *cp_queue_lock()); for ev in events { match ev { - CpEvent::Data { - handle, - stderr, - bytes, - } => { + CpEvent::Data { handle, fd, bytes } => { // exec/execFile (#4912): buffer the bytes (off-JS, under the // lock) instead of emitting a stream `data` event. A `maxBuffer` // breach kills the child. @@ -1096,7 +1184,10 @@ fn cp_reactor_pump_inner() { let mut guard = cp_live_lock(); if let Some(lc) = guard.as_mut().and_then(|m| m.get_mut(&handle)) { match lc.exec.as_mut() { - Some(exec) => kill_sig = cp_exec_accumulate(exec, stderr, &bytes), + Some(exec) => { + kill_sig = cp_exec_accumulate(exec, fd == 2, &bytes); + emit_cp_bits = Some(lc.cp_bits); + } None => emit_cp_bits = Some(lc.cp_bits), } } @@ -1106,28 +1197,50 @@ fn cp_reactor_pump_inner() { } if let Some(cp_bits) = emit_cp_bits { let cp = f64::from_bits(cp_bits); - let stream = cp_get_field(cp, cp_stream_field(stderr)); + let stream = cp_stdio_stream(cp, fd); if super::cp_object_ptr(stream).is_some() { - let buf = cp_make_buffer(&bytes); - cp_emit(stream, "data", &[buf]); + let encoding = cp_value_to_string(cp_get_field(stream, b"__cpEncoding")); + let chunk = match encoding { + Some(encoding) => cp_box_output(&bytes, &CpOutput::Text(encoding)), + None => cp_make_buffer(&bytes), + }; + cp_emit(stream, "data", &[chunk]); } } } - CpEvent::Eof { handle, stderr } => { + CpEvent::Eof { handle, fd } => { + let mut end_fds = Vec::new(); if let Some(map) = cp_live_lock().as_mut() { if let Some(lc) = map.get_mut(&handle) { - if stderr { - lc.stderr_open = false; - } else { - lc.stdout_open = false; + match fd { + 1 if lc.stderr_open => lc.stdout_eof_pending = true, + 1 => { + lc.stdout_open = false; + end_fds.push(1); + } + 2 => { + lc.stderr_open = false; + end_fds.push(2); + if lc.stdout_eof_pending { + lc.stdout_eof_pending = false; + lc.stdout_open = false; + end_fds.push(1); + } + } + _ => { + lc.extra_open.retain(|extra_fd| *extra_fd != fd); + end_fds.push(fd); + } } } } if let Some(cp_bits) = cp_lookup_cp_bits(handle) { let cp = f64::from_bits(cp_bits); - let stream = cp_get_field(cp, cp_stream_field(stderr)); - if super::cp_object_ptr(stream).is_some() { - cp_emit(stream, "end", &[]); + for fd in end_fds { + let stream = cp_stdio_stream(cp, fd); + if super::cp_object_ptr(stream).is_some() { + cp_emit(stream, "end", &[]); + } } } } @@ -1237,7 +1350,7 @@ fn cp_reactor_pump_inner() { continue; } if let Some((code, signal)) = lc.exited { - if !lc.stdout_open && !lc.stderr_open { + if !lc.stdout_open && !lc.stderr_open && lc.extra_open.is_empty() { lc.closed = true; out.push(CpCloseItem { handle: *h, @@ -1260,7 +1373,17 @@ fn cp_reactor_pump_inner() { for item in to_close { cp_cleanup_abort_listener(item.abort_signal_bits, item.abort_listener_bits); if let Some(exec) = item.exec { + let cp = f64::from_bits(item.cp_bits); + let code_f = item.code.map(|c| c as f64).unwrap_or(TAG_NULL_F64); + let signal_f = item + .signal + .map(|s| cp_box_string(cp_signal_name(s))) + .unwrap_or(TAG_NULL_F64); + cp_set_field(cp, b"exitCode", code_f); + cp_set_field(cp, b"signalCode", signal_f); + cp_emit(cp, "exit", &[code_f, signal_f]); cp_exec_fire_close(exec, item.code, item.signal, item.pid); + cp_emit(cp, "close", &[code_f, signal_f]); } else { let cp = f64::from_bits(item.cp_bits); let code_f = item.code.map(|c| c as f64).unwrap_or(TAG_NULL_F64); @@ -1295,11 +1418,13 @@ struct CpCloseItem { } #[inline] -fn cp_stream_field(stderr: bool) -> &'static [u8] { - if stderr { - b"stderr" - } else { - b"stdout" +fn cp_stdio_stream(cp: f64, fd: usize) -> f64 { + match fd { + 1 => cp_get_field(cp, b"stdout"), + 2 => cp_get_field(cp, b"stderr"), + _ => cp_array_ptr(cp_get_field(cp, b"stdio")) + .map(|stdio| crate::array::js_array_get_f64(stdio, fd as u32)) + .unwrap_or_else(cp_undefined), } } diff --git a/crates/perry-runtime/src/child_process/signals.rs b/crates/perry-runtime/src/child_process/signals.rs index 9deb6e5ae0..1d279e21a4 100644 --- a/crates/perry-runtime/src/child_process/signals.rs +++ b/crates/perry-runtime/src/child_process/signals.rs @@ -106,6 +106,30 @@ pub(crate) fn cp_signal_from_value(signal: f64) -> i32 { CP_SIGTERM } +/// Strict signal predicate for argument validation. The execution helper keeps +/// its historical SIGTERM fallback, while public APIs must reject unknown, +/// fractional, and non-signal values synchronously like Node. +pub(crate) fn cp_signal_is_valid(signal: f64) -> bool { + let js = JSValue::from_bits(signal.to_bits()); + if js.is_int32() { + return js.as_int32() >= 0; + } + if js.is_number() { + let n = js.as_number(); + return n.is_finite() && n >= 0.0 && n.fract() == 0.0; + } + js.is_any_string() + && cp_value_to_string(signal).is_some_and(|name| cp_signal_number(&name).is_some()) +} + +/// Validate the public `ChildProcess#kill([signal])` input before converting +/// it to an OS signal. `0` is accepted as Node's probe/no-op signal. +pub(crate) fn cp_validate_signal(signal: f64) { + if !JSValue::from_bits(signal.to_bits()).is_undefined() && !cp_signal_is_valid(signal) { + crate::fs::validate::throw_type_error_with_code("Unknown signal", "ERR_UNKNOWN_SIGNAL"); + } +} + pub(crate) fn cp_read_kill_signal(opts_val: f64) -> i32 { if cp_object_ptr(opts_val).is_none() { return CP_SIGTERM; diff --git a/crates/perry-runtime/src/child_process/sync_run.rs b/crates/perry-runtime/src/child_process/sync_run.rs index fe20771c72..daa4ac6040 100644 --- a/crates/perry-runtime/src/child_process/sync_run.rs +++ b/crates/perry-runtime/src/child_process/sync_run.rs @@ -160,8 +160,12 @@ fn cp_read_timing_and_buffer_options(opts_val: f64, options: &mut CpRunOptions) } } - if let Some(max_buffer) = cp_read_option_number(opts_val, b"maxBuffer") { - if max_buffer >= 0.0 { + let max_buffer = JSValue::from_bits(cp_get_field(opts_val, b"maxBuffer").to_bits()); + if !max_buffer.is_undefined() && !max_buffer.is_null() { + let max_buffer = max_buffer.to_number(); + if max_buffer == f64::INFINITY { + options.max_buffer = usize::MAX; + } else if max_buffer.is_finite() && max_buffer >= 0.0 { options.max_buffer = max_buffer.min(usize::MAX as f64) as usize; } } @@ -207,6 +211,12 @@ impl CpRun { /// Piped stdin without input is closed so children that read stdin see EOF /// instead of blocking. Used by synchronous + buffered-callback entry points. pub(super) fn cp_run_to_completion(mut command: Command, options: &CpRunOptions) -> CpRun { + // A shell that has already completed its short command reports its real + // exit status with ENOBUFS; a direct child is still terminable at the + // buffer threshold and reports the configured signal. + let shell_command = std::path::Path::new(command.get_program()) + .file_name() + .is_some_and(|name| name == "sh"); let stdin_piped = matches!(options.stdio[0], CpStdio::Pipe) && options.input.is_some(); let stdout_piped = matches!(options.stdio[1], CpStdio::Pipe); let stderr_piped = matches!(options.stdio[2], CpStdio::Pipe); @@ -242,6 +252,7 @@ pub(super) fn cp_run_to_completion(mut command: Command, options: &CpRunOptions) Ok(o) => { let CpExit { code, signal } = cp_decode_status(&o.status); if run_error.is_none() + && options.max_buffer > 0 && ((stdout_piped && o.stdout.len() > options.max_buffer) || (stderr_piped && o.stderr.len() > options.max_buffer)) { @@ -249,6 +260,9 @@ pub(super) fn cp_run_to_completion(mut command: Command, options: &CpRunOptions) } let (code, signal) = match run_error { Some(CpRunError::Timeout) => (None, Some(options.kill_signal)), + Some(CpRunError::MaxBuffer) if !shell_command => { + (None, Some(options.kill_signal)) + } _ => (code, signal), }; CpRun { diff --git a/crates/perry-runtime/src/child_process/v8_serde.rs b/crates/perry-runtime/src/child_process/v8_serde.rs index 78ebcb52c3..d75f0fe030 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -60,6 +60,15 @@ const TAG_END_SPARSE_ARRAY: u8 = b'@'; const TAG_BEGIN_DENSE_ARRAY: u8 = b'A'; const TAG_END_DENSE_ARRAY: u8 = b'$'; const TAG_DATE: u8 = b'D'; +const TAG_REGEXP: u8 = b'R'; +const TAG_BEGIN_JS_MAP: u8 = b';'; +const TAG_END_JS_MAP: u8 = b':'; +const TAG_BEGIN_JS_SET: u8 = b'\''; +const TAG_END_JS_SET: u8 = b','; +const TAG_ERROR: u8 = b'r'; +const TAG_ERROR_MESSAGE: u8 = b'm'; +const TAG_ERROR_STACK: u8 = b's'; +const TAG_ERROR_END: u8 = b'.'; const TAG_ARRAY_BUFFER: u8 = b'B'; const TAG_HOST_OBJECT: u8 = b'\\'; @@ -219,6 +228,22 @@ impl Serializer { self.write_host_typed_array(value, kind); return; } + if crate::map::is_registered_map(raw) { + self.write_map(raw as *const crate::map::MapHeader); + return; + } + if crate::set::is_registered_set(raw) { + self.write_set(raw as *const crate::set::SetHeader); + return; + } + if crate::regex::regex_header_has_magic(raw as *const crate::regex::RegExpHeader) { + self.write_regexp(raw as *const crate::regex::RegExpHeader); + return; + } + if crate::error::js_error_is_error(value).to_bits() == TAG_TRUE_F64.to_bits() { + self.write_error(raw as *mut crate::error::ErrorHeader); + return; + } if crate::date::is_date_value(value) { self.out.push(TAG_DATE); self.write_double(crate::date::js_date_get_time(value)); @@ -360,6 +385,76 @@ impl Serializer { self.depth -= 1; } + fn write_map(&mut self, map: *const crate::map::MapHeader) { + self.out.push(TAG_BEGIN_JS_MAP); + let size = unsafe { (*map).size }; + for i in 0..size { + self.write_value(crate::map::js_map_entry_key_at(map, i)); + self.write_value(crate::map::js_map_entry_value_at(map, i)); + } + self.out.push(TAG_END_JS_MAP); + self.write_varint((size as u64) * 2); + } + + fn write_set(&mut self, set: *const crate::set::SetHeader) { + self.out.push(TAG_BEGIN_JS_SET); + let size = unsafe { (*set).size }; + for i in 0..size { + self.write_value(crate::set::js_set_value_at(set, i)); + } + self.out.push(TAG_END_JS_SET); + self.write_varint(size as u64); + } + + fn write_regexp(&mut self, re: *const crate::regex::RegExpHeader) { + self.out.push(TAG_REGEXP); + let source = crate::regex::js_regexp_get_source(re); + self.write_string(crate::value::js_nanbox_string(source as i64)); + let flags = crate::regex::js_regexp_get_flags(re); + let flags = string_bytes(crate::value::js_nanbox_string(flags as i64)); + let mut bits = 0u64; + for flag in flags { + bits |= match flag { + b'g' => 1, + b'i' => 2, + b'm' => 4, + b'y' => 8, + b'u' => 16, + b's' => 32, + b'd' => 64, + b'v' => 128, + _ => 0, + }; + } + self.write_varint(bits); + } + + fn write_error(&mut self, error: *mut crate::error::ErrorHeader) { + self.out.push(TAG_ERROR); + let kind = unsafe { (*error).error_kind }; + let type_tag = match kind { + crate::error::ERROR_KIND_TYPE_ERROR => Some(b'T'), + crate::error::ERROR_KIND_RANGE_ERROR => Some(b'R'), + crate::error::ERROR_KIND_REFERENCE_ERROR => Some(b'F'), + crate::error::ERROR_KIND_SYNTAX_ERROR => Some(b'S'), + crate::error::ERROR_KIND_EVAL_ERROR => Some(b'E'), + crate::error::ERROR_KIND_URI_ERROR => Some(b'U'), + _ => None, + }; + if let Some(tag) = type_tag { + self.out.push(tag); + } + if unsafe { (*error).flags & 1 != 0 } { + self.out.push(TAG_ERROR_MESSAGE); + let message = crate::error::js_error_get_message(error); + self.write_string(crate::value::js_nanbox_string(message as i64)); + } + self.out.push(TAG_ERROR_STACK); + let stack = crate::error::js_error_get_stack(error); + self.write_string(crate::value::js_nanbox_string(stack as i64)); + self.out.push(TAG_ERROR_END); + } + fn write_object(&mut self, obj: *const ObjectHeader) { if self.depth >= MAX_DEPTH { self.out.push(TAG_UNDEFINED); @@ -510,6 +605,10 @@ impl<'a> Deserializer<'a> { TAG_BEGIN_JS_OBJECT => self.read_object()?, TAG_BEGIN_DENSE_ARRAY => self.read_dense_array()?, TAG_BEGIN_SPARSE_ARRAY => self.read_sparse_array()?, + TAG_BEGIN_JS_MAP => self.read_map()?, + TAG_BEGIN_JS_SET => self.read_set()?, + TAG_REGEXP => self.read_regexp()?, + TAG_ERROR => self.read_error()?, TAG_ARRAY_BUFFER => self.read_array_buffer()?, TAG_HOST_OBJECT => self.read_host_object()?, TAG_OBJECT_REFERENCE => { @@ -626,6 +725,112 @@ impl<'a> Deserializer<'a> { Some(boxed) } + fn read_map(&mut self) -> Option { + let map = crate::map::js_map_alloc(4); + let boxed = cp_box_ptr(map as *const u8); + self.id_table.push(boxed); + while self.peek_byte() != Some(TAG_END_JS_MAP) { + let key = self.read_value()?; + let value = self.read_value()?; + crate::map::js_map_set(map, key, value); + } + self.pos += 1; + self.read_varint()?; + Some(boxed) + } + + fn read_set(&mut self) -> Option { + let set = crate::set::js_set_alloc(4); + let boxed = cp_box_ptr(set as *const u8); + self.id_table.push(boxed); + while self.peek_byte() != Some(TAG_END_JS_SET) { + crate::set::js_set_add(set, self.read_value()?); + } + self.pos += 1; + self.read_varint()?; + Some(boxed) + } + + fn read_regexp(&mut self) -> Option { + let source = self.read_value()?; + let flags = self.read_varint()?; + let mut flag_bytes = Vec::with_capacity(8); + for (flag, bit) in [ + (b'g', 1), + (b'i', 2), + (b'm', 4), + (b'y', 8), + (b'u', 16), + (b's', 32), + (b'd', 64), + (b'v', 128), + ] { + if flags & bit != 0 { + flag_bytes.push(flag); + } + } + let source = crate::value::js_get_string_pointer_unified(source) as *const StringHeader; + let flags = + crate::string::js_string_from_bytes(flag_bytes.as_ptr(), flag_bytes.len() as u32); + #[cfg(feature = "regex-engine")] + { + let re = crate::regex::js_regexp_new(source, flags); + let boxed = cp_box_ptr(re as *const u8); + self.id_table.push(boxed); + Some(boxed) + } + #[cfg(not(feature = "regex-engine"))] + { + let _ = (source, flags); + Some(cp_undefined()) + } + } + + fn read_error(&mut self) -> Option { + let kind = match self.peek_byte() { + Some(b'T') => { + self.pos += 1; + crate::error::ERROR_KIND_TYPE_ERROR + } + Some(b'R') => { + self.pos += 1; + crate::error::ERROR_KIND_RANGE_ERROR + } + Some(b'F') => { + self.pos += 1; + crate::error::ERROR_KIND_REFERENCE_ERROR + } + Some(b'S') => { + self.pos += 1; + crate::error::ERROR_KIND_SYNTAX_ERROR + } + Some(b'E') => { + self.pos += 1; + crate::error::ERROR_KIND_EVAL_ERROR + } + Some(b'U') => { + self.pos += 1; + crate::error::ERROR_KIND_URI_ERROR + } + _ => crate::error::ERROR_KIND_ERROR, + }; + let mut message = cp_undefined(); + while self.peek_byte() != Some(TAG_ERROR_END) { + match self.read_byte()? { + TAG_ERROR_MESSAGE => message = self.read_value()?, + TAG_ERROR_STACK => { + let _ = self.read_value()?; + } + _ => return None, + } + } + self.pos += 1; + let error = crate::error::js_error_new_kind_from_value(kind, message); + let boxed = cp_box_ptr(error as *const u8); + self.id_table.push(boxed); + Some(boxed) + } + fn read_array_buffer(&mut self) -> Option { let len = self.read_varint()? as usize; let bytes = self.read_raw(len)?; diff --git a/crates/perry-runtime/src/child_process/validate.rs b/crates/perry-runtime/src/child_process/validate.rs index eb39dd5aaf..c077a81427 100644 --- a/crates/perry-runtime/src/child_process/validate.rs +++ b/crates/perry-runtime/src/child_process/validate.rs @@ -12,13 +12,28 @@ use crate::value::JSValue; +use super::{ + cp_array_ptr, cp_get_field, cp_object_ptr, cp_signal_is_valid, cp_stdio_stream_fd, + cp_value_to_string, +}; + +fn cp_throw_null_bytes() -> ! { + crate::fs::validate::throw_type_error_with_code( + "The argument must not contain null bytes", + "ERR_INVALID_ARG_VALUE", + ); +} + /// Validate a `command` / `file` argument. `value` is the original NaN-boxed /// JS value; `name` is `"command"` (exec/execSync) or `"file"` (execFile / /// execFileSync / spawn / spawnSync). Throws `TypeError [ERR_INVALID_ARG_TYPE]` /// with Node's `The "" argument must be of type string. Received …` /// message when `value` is not a string. A no-op for any string. -fn cp_validate_command(value: f64, name: &str) { +pub(crate) fn cp_validate_command(value: f64, name: &str) { if JSValue::from_bits(value.to_bits()).is_any_string() { + if cp_value_to_string(value).is_some_and(|value| value.contains('\0')) { + cp_throw_null_bytes(); + } return; } let message = format!( @@ -49,6 +64,14 @@ fn cp_validate_args(value: f64) { || jv.is_bigint() || unsafe { crate::symbol::js_is_symbol(value) != 0 }; if !is_rejected_primitive { + if let Some(args) = cp_array_ptr(value) { + for i in 0..unsafe { (*args).length } { + let value = crate::array::js_array_get_f64(args, i); + if cp_value_to_string(value).is_some_and(|value| value.contains('\0')) { + cp_throw_null_bytes(); + } + } + } return; } let message = format!( @@ -58,6 +81,209 @@ fn cp_validate_args(value: f64) { crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); } +fn cp_throw_option_type(name: &str, value: f64) -> ! { + let message = format!( + "The \"options.{name}\" property must be of type {}. Received {}", + if name == "shell" { + "boolean or string" + } else { + "boolean" + }, + crate::fs::validate::describe_received(value) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); +} + +fn cp_is_undefined(value: f64) -> bool { + JSValue::from_bits(value.to_bits()).is_undefined() +} + +fn cp_is_number(value: f64) -> bool { + crate::fs::validate::is_numeric(JSValue::from_bits(value.to_bits())) +} + +fn cp_validate_stdio_entry( + value: f64, + sync: bool, + in_array: bool, + fd_index: usize, + ipc_count: &mut u32, +) { + if JSValue::from_bits(value.to_bits()).is_null() { + return; + } + if JSValue::from_bits(value.to_bits()).is_any_string() { + let name = cp_value_to_string(value).unwrap_or_default(); + match name.as_str() { + "pipe" | "ignore" | "inherit" | "overlapped" => return, + "ipc" => { + *ipc_count += 1; + if sync { + crate::fs::validate::throw_error_with_code( + "IPC cannot be used with spawnSync", + "ERR_IPC_SYNC_FORK", + ); + } + if *ipc_count > 1 { + crate::fs::validate::throw_error_with_code( + "Child process can have only one IPC pipe", + "ERR_IPC_ONE_PIPE", + ); + } + return; + } + // Node routes invalid entries in a stdio array through the shared + // getValidStdio error, even for asynchronous spawn. + _ if sync || in_array => crate::fs::validate::throw_type_error_with_code( + "Invalid stdio option", + "ERR_INVALID_SYNC_FORK_INPUT", + ), + _ => crate::fs::validate::throw_type_error_with_code( + "Invalid stdio option", + "ERR_INVALID_ARG_VALUE", + ), + } + } + if in_array && cp_is_number(value) { + let number = JSValue::from_bits(value.to_bits()).to_number(); + if number.is_finite() && number >= 0.0 && number.fract() == 0.0 && number <= i32::MAX as f64 + { + return; + } + } + if cp_stdio_stream_fd(value, fd_index).is_some() { + return; + } + crate::fs::validate::throw_type_error_with_code( + "Invalid stdio option", + "ERR_INVALID_ARG_VALUE", + ); +} + +/// Validate the common spawn/fork option bag before codegen strips NaN-box +/// tags. `sync` selects the Node-specific synchronous stdio error codes; +/// `allow_null` is used by fork, whose options overload accepts `null`. +fn cp_validate_options(value: f64, sync: bool, allow_null: bool) { + let js = JSValue::from_bits(value.to_bits()); + if js.is_undefined() || (allow_null && js.is_null()) { + return; + } + if cp_object_ptr(value).is_none() { + let message = format!( + "The \"options\" argument must be of type object. Received {}", + crate::fs::validate::describe_received(value) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + } + + let required_string = ["cwd", "argv0"]; + for name in required_string { + let item = cp_get_field(value, name.as_bytes()); + if !cp_is_undefined(item) + && !JSValue::from_bits(item.to_bits()).is_null() + && !JSValue::from_bits(item.to_bits()).is_any_string() + { + let message = format!( + "The \"options.{name}\" property must be of type string. Received {}", + crate::fs::validate::describe_received(item) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + } + } + + for name in ["detached"] { + let item = cp_get_field(value, name.as_bytes()); + if !cp_is_undefined(item) && !JSValue::from_bits(item.to_bits()).is_bool() { + cp_throw_option_type(name, item); + } + } + let shell = cp_get_field(value, b"shell"); + if !cp_is_undefined(shell) + && !JSValue::from_bits(shell.to_bits()).is_bool() + && !JSValue::from_bits(shell.to_bits()).is_any_string() + { + cp_throw_option_type("shell", shell); + } + + for name in ["timeout", "maxBuffer"] { + let item = cp_get_field(value, name.as_bytes()); + if cp_is_undefined(item) { + continue; + } + if !cp_is_number(item) { + let message = format!( + "The \"options.{name}\" property must be of type number. Received {}", + crate::fs::validate::describe_received(item) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); + } + let number = JSValue::from_bits(item.to_bits()).to_number(); + if number < 0.0 || number.is_nan() || (name == "timeout" && !number.is_finite()) { + crate::fs::validate::throw_range_error_with_code(&format!( + "The value of \"options.{name}\" is out of range" + )); + } + } + + let signal = cp_get_field(value, b"killSignal"); + if !cp_is_undefined(signal) && !cp_signal_is_valid(signal) { + crate::fs::validate::throw_type_error_with_code("Unknown signal", "ERR_UNKNOWN_SIGNAL"); + } + + let serialization = cp_get_field(value, b"serialization"); + if !cp_is_undefined(serialization) + && (!JSValue::from_bits(serialization.to_bits()).is_any_string() + || !matches!( + cp_value_to_string(serialization).as_deref(), + Some("json" | "advanced") + )) + { + crate::fs::validate::throw_type_error_with_code( + "The \"options.serialization\" property must be one of: 'json', 'advanced'", + "ERR_INVALID_ARG_VALUE", + ); + } + + let stdio = cp_get_field(value, b"stdio"); + if cp_is_undefined(stdio) { + return; + } + let mut ipc_count = 0; + if let Some(arr) = cp_array_ptr(stdio) { + for index in 0..unsafe { (*arr).length } { + cp_validate_stdio_entry( + crate::array::js_array_get_f64(arr, index), + sync, + true, + index as usize, + &mut ipc_count, + ); + } + } else { + cp_validate_stdio_entry(stdio, sync, false, 0, &mut ipc_count); + } +} + +/// `new ChildProcess().spawn(options)` exposes the low-level Node constructor +/// API. It takes one object containing a required string `file` and array +/// `args`; validate this public boundary before the reactor ever sees it. +pub(crate) fn cp_validate_child_process_spawn(value: f64) { + if cp_object_ptr(value).is_none() { + crate::fs::validate::throw_type_error_with_code( + "The \"options\" argument must be of type object", + "ERR_INVALID_ARG_TYPE", + ); + } + cp_validate_command(cp_get_field(value, b"file"), "options.file"); + if cp_array_ptr(cp_get_field(value, b"args")).is_none() { + crate::fs::validate::throw_type_error_with_code( + "The \"options.args\" property must be an array", + "ERR_INVALID_ARG_TYPE", + ); + } + cp_validate_options(value, false, false); +} + /// Codegen-invoked `command`/`file` validator (#3079). `value` is the original /// NaN-boxed JS value; `name_ptr`/`name_len` describe the static argument name /// (`"command"` or `"file"`). Diverges via `js_throw` on a non-string value. @@ -88,6 +314,29 @@ pub extern "C" fn js_child_process_validate_args(value: f64) -> f64 { value } +/// `spawn*`/`fork` interpret a non-array object in the args slot as their +/// options overload. Validate it here before codegen removes its type tag. +#[no_mangle] +pub extern "C" fn js_child_process_validate_spawn_args( + value: f64, + sync: i32, + allow_null: i32, +) -> f64 { + cp_validate_args(value); + if cp_array_ptr(value).is_none() && cp_object_ptr(value).is_some() { + cp_validate_options(value, sync != 0, allow_null != 0); + } + value +} + +/// Codegen-invoked shared option validation. `sync` is non-zero for +/// `spawnSync`; `allow_null` is non-zero for `fork`. +#[no_mangle] +pub extern "C" fn js_child_process_validate_options(value: f64, sync: i32, allow_null: i32) -> f64 { + cp_validate_options(value, sync != 0, allow_null != 0); + value +} + /// Feature-gated (`keepalive-anchors`) `#[used]` anchors so the whole-program /// bitcode-LTO link does not dead-strip these codegen-invoked `#[no_mangle]` /// entry points (see project_auto_optimize_keepalive_3320). They are @@ -101,3 +350,11 @@ static KEEP_JS_CP_VALIDATE_COMMAND: unsafe extern "C" fn(f64, *const u8, u32) -> #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_JS_CP_VALIDATE_ARGS: extern "C" fn(f64) -> f64 = js_child_process_validate_args; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_CP_VALIDATE_OPTIONS: extern "C" fn(f64, i32, i32) -> f64 = + js_child_process_validate_options; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_CP_VALIDATE_SPAWN_ARGS: extern "C" fn(f64, i32, i32) -> f64 = + js_child_process_validate_spawn_args; diff --git a/crates/perry-runtime/src/child_process/value_util.rs b/crates/perry-runtime/src/child_process/value_util.rs index 2ba0064896..5e2b81d544 100644 --- a/crates/perry-runtime/src/child_process/value_util.rs +++ b/crates/perry-runtime/src/child_process/value_util.rs @@ -215,6 +215,24 @@ pub(crate) fn cp_args_from_value(value: f64) -> Vec { } } +/// Normalize `spawn*`/`fork`'s optional `(args, options)` slots after codegen +/// has unboxed them: when the third argument is absent, a plain object in the +/// second slot is the options object, not an argv list. +pub(crate) fn cp_options_from_raw_args(args_ptr: i64, opts_ptr: i64) -> f64 { + if opts_ptr > 0x10000 { + return cp_box_ptr(opts_ptr as *const u8); + } + if args_ptr <= 0x10000 { + return cp_undefined(); + } + let args = cp_box_ptr(args_ptr as *const u8); + if cp_array_ptr(args).is_none() && cp_object_ptr(args).is_some() { + args + } else { + cp_undefined() + } +} + /// Coerce any JS value to an owned Rust string — string fast-path, else /// `js_jsvalue_to_string`. Used for `env` values, which Node stringifies. pub(crate) fn cp_coerce_string(value: f64) -> String { diff --git a/crates/perry-runtime/src/cluster.rs b/crates/perry-runtime/src/cluster.rs index 120dca8f65..d899e46914 100644 --- a/crates/perry-runtime/src/cluster.rs +++ b/crates/perry-runtime/src/cluster.rs @@ -443,14 +443,14 @@ pub extern "C" fn js_cluster_fork(env: f64) -> f64 { let settings = settings_value(); let module = get_field(settings, b"exec"); - let module_ptr = crate::string::js_string_materialize_to_heap(module) as i64; - if module_ptr == 0 { + if crate::value::JSValue::from_bits(module.to_bits()).is_undefined() { return TAG_UNDEFINED_F64; } let args = get_field(settings, b"args"); let args_ptr = array_ptr(args).map(|p| p as i64).unwrap_or(0); let opts = build_fork_options(settings, env); + let module_ptr = crate::string::js_string_materialize_to_heap(module) as i64; let worker = crate::child_process::fork::js_child_process_fork(module_ptr, args_ptr, opts as i64); if object_ptr(worker).is_none() { diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 5129b50dc3..8d5e7a36e4 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -110,9 +110,9 @@ pub use prototype_methods::{ // ── construct.rs ──────────────────────────────────────────────────────────── pub(crate) use construct::{ extends_target_must_throw, function_would_have_own_prototype, is_callable_function_value, - js_value_is_constructor, lookup_prototype_method, nm_ctor_fs, nm_ctor_readline, nm_ctor_repl, - nm_ctor_stream, nm_ctor_tls, nm_ctor_tty, nm_ctor_vm, nm_ctor_wasi, - ordinary_function_prototype_value_for_read, promise_parent_in_chain, + js_value_is_constructor, lookup_prototype_method, nm_ctor_child_process, nm_ctor_fs, + nm_ctor_readline, nm_ctor_repl, nm_ctor_stream, nm_ctor_tls, nm_ctor_tty, nm_ctor_vm, + nm_ctor_wasi, ordinary_function_prototype_value_for_read, promise_parent_in_chain, }; pub use construct::{ js_ctor_return_override, js_function_prototype_value_for_read, js_new_function_construct, diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index bfbed85479..8c40a5d40d 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -60,6 +60,15 @@ pub(crate) unsafe fn nm_ctor_tty( None } +pub(crate) unsafe fn nm_ctor_child_process( + _module: &str, + method: &str, + _args_ptr: *const f64, + _args_len: usize, +) -> Option { + (method == "ChildProcess").then(crate::child_process::cp_build_unstarted_child_process) +} + pub(crate) unsafe fn nm_ctor_fs( _module: &str, method: &str, diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 279d566f99..d095da7bd6 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -1815,6 +1815,59 @@ pub(crate) unsafe fn nm_attach_stream( value } +/// Ensure the public `ChildProcess` constructor has its prototype. The direct +/// codegen path can create the bound constructor without installing the module +/// attach hook first. +pub(crate) unsafe fn ensure_child_process_prototype(value: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let constructor = scope.root_nanbox_f64(value); + let closure_addr = + (constructor.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as usize; + if closure_addr == 0 || !crate::closure::is_closure_ptr(closure_addr) { + return constructor.get_nanbox_f64(); + } + if crate::value::JSValue::from_bits( + crate::closure::closure_get_dynamic_prop(closure_addr, "prototype").to_bits(), + ) + .is_pointer() + { + return constructor.get_nanbox_f64(); + } + let proto = js_object_alloc_with_shape( + 0x7FFF_FDA0, + 1, + b"constructor\0".as_ptr(), + b"constructor\0".len() as u32, + ); + js_object_set_field( + proto, + 0, + JSValue::from_bits(constructor.get_nanbox_f64().to_bits()), + ); + let closure_addr = + (constructor.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as usize; + crate::closure::closure_set_dynamic_prop( + closure_addr, + "prototype", + crate::value::js_nanbox_pointer(proto as i64), + ); + constructor.get_nanbox_f64() +} + +/// Attach the public `ChildProcess.prototype` so low-level constructed +/// instances participate in ordinary `instanceof ChildProcess` checks. +pub(crate) unsafe fn nm_attach_child_process( + property_name: &str, + value: f64, + _closure_addr: usize, +) -> f64 { + if property_name == "ChildProcess" { + ensure_child_process_prototype(value) + } else { + value + } +} + #[allow(unused_mut)] pub(crate) unsafe fn nm_attach_sqlite( property_name: &str, diff --git a/crates/perry-runtime/src/object/native_module_registry.rs b/crates/perry-runtime/src/object/native_module_registry.rs index 60dfd1c3a7..0294f82afe 100644 --- a/crates/perry-runtime/src/object/native_module_registry.rs +++ b/crates/perry-runtime/src/object/native_module_registry.rs @@ -234,10 +234,15 @@ pub extern "C" fn js_nm_install_bun() { } #[no_mangle] pub extern "C" fn js_nm_install_child_process() { + nm_register_attach( + NmBucket::ChildProcess, + super::native_module::callable_exports::nm_attach_child_process, + ); NM_DISPATCH_REGISTRY[NmBucket::ChildProcess as usize].store( nm_dispatch_child_process as NmDispatchFn as *mut (), Ordering::Relaxed, ); + nm_register_ctor(NmBucket::ChildProcess, nm_ctor_child_process); } #[no_mangle] pub extern "C" fn js_nm_install_cluster() { @@ -599,8 +604,8 @@ pub(crate) fn nm_run_install_all_hook() { // the method-dispatch registry: populated by `js_nm_install_()` (only // the 8 ctor-owning buckets register a fn), looked up by `js_new_function_construct`. use super::class_registry::{ - nm_ctor_fs, nm_ctor_readline, nm_ctor_repl, nm_ctor_stream, nm_ctor_tls, nm_ctor_tty, - nm_ctor_vm, nm_ctor_wasi, + nm_ctor_child_process, nm_ctor_fs, nm_ctor_readline, nm_ctor_repl, nm_ctor_stream, nm_ctor_tls, + nm_ctor_tty, nm_ctor_vm, nm_ctor_wasi, }; type NmCtorFn = unsafe fn(&str, &str, *const f64, usize) -> Option; From bc946df69126018077e57a97cdec9d0bf415c178 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Fri, 31 Jul 2026 11:14:52 +0200 Subject: [PATCH 2/7] fix(child-process): address serialization and validation gaps --- crates/perry-codegen/src/expr/child_proc.rs | 12 +++ .../runtime_decls/stdlib_ffi/third_party.rs | 7 +- .../src/child_process/builder.rs | 15 ++-- .../perry-runtime/src/child_process/exec.rs | 6 +- crates/perry-runtime/src/child_process/mod.rs | 3 +- .../src/child_process/options.rs | 76 +++++++++++++------ .../src/child_process/signals.rs | 11 ++- .../src/child_process/sync_run.rs | 11 ++- .../src/child_process/v8_serde.rs | 76 ++++++++++++++++++- .../src/child_process/validate.rs | 27 +++++++ 10 files changed, 199 insertions(+), 45 deletions(-) diff --git a/crates/perry-codegen/src/expr/child_proc.rs b/crates/perry-codegen/src/expr/child_proc.rs index bd64741ac1..11ac3b3f55 100644 --- a/crates/perry-codegen/src/expr/child_proc.rs +++ b/crates/perry-codegen/src/expr/child_proc.rs @@ -35,6 +35,17 @@ fn emit_cp_validate_command(ctx: &mut FnCtx<'_>, cmd_box: &str, name: &str) { ); } +/// `fork()` accepts a module path string, Buffer, or WHATWG URL. Validate the +/// original tagged value before coercing it to the raw string pointer. +fn emit_cp_validate_fork_module(ctx: &mut FnCtx<'_>, module_box: &str) { + let blk = ctx.block(); + let _ = blk.call( + DOUBLE, + "js_child_process_validate_fork_module", + &[(DOUBLE, module_box)], + ); +} + /// #3079: emit a setup-time `args` validation call. `args_box` is the original /// NaN-boxed value passed in the args slot. The runtime throws `TypeError /// [ERR_INVALID_ARG_TYPE]` for a primitive (string/number/boolean/…), accepting @@ -216,6 +227,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // runtime wires up an IPC channel + send/disconnect/'message'. The // runtime returns an already-NaN-boxed ChildProcess pointer. #1933. let mod_box = lower_expr(ctx, module)?; + emit_cp_validate_fork_module(ctx, &mod_box); let mod_str = ctx.block() .call(I64, "js_jsvalue_to_string_coerce", &[(DOUBLE, &mod_box)]); diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 069615139a..7dd47d352d 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -301,7 +301,12 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { DOUBLE, &[DOUBLE, I32, I32], ); - module.declare_function("js_child_process_validate_spawn_args", DOUBLE, &[DOUBLE]); + module.declare_function("js_child_process_validate_fork_module", DOUBLE, &[DOUBLE]); + module.declare_function( + "js_child_process_validate_spawn_args", + DOUBLE, + &[DOUBLE, I32, I32], + ); module.declare_function("js_child_process_new", DOUBLE, &[]); // ========== cheerio ========== diff --git a/crates/perry-runtime/src/child_process/builder.rs b/crates/perry-runtime/src/child_process/builder.rs index e17dedb8e0..da8bedf59f 100644 --- a/crates/perry-runtime/src/child_process/builder.rs +++ b/crates/perry-runtime/src/child_process/builder.rs @@ -193,11 +193,12 @@ pub(crate) fn cp_build_unstarted_child_process() -> f64 { ("spawn", cp_cast1(cp_method_child_spawn)), ]; let obj = cp_build_object(&methods, CP_SHAPE_ID + 0x60 + methods.len() as u32); - let child = cp_box_ptr(obj as *const u8); - cp_set_field(child, b"connected", TAG_FALSE_F64); - cp_set_field(child, b"killed", TAG_FALSE_F64); - cp_set_field(child, b"exitCode", TAG_NULL_F64); - cp_set_field(child, b"signalCode", TAG_NULL_F64); + let scope = crate::gc::RuntimeHandleScope::new(); + let child = scope.root_nanbox_f64(cp_box_ptr(obj as *const u8)); + cp_set_field(child.get_nanbox_f64(), b"connected", TAG_FALSE_F64); + cp_set_field(child.get_nanbox_f64(), b"killed", TAG_FALSE_F64); + cp_set_field(child.get_nanbox_f64(), b"exitCode", TAG_NULL_F64); + cp_set_field(child.get_nanbox_f64(), b"signalCode", TAG_NULL_F64); let constructor = crate::object::bound_native_callable_export_value("child_process", "ChildProcess"); @@ -205,13 +206,13 @@ pub(crate) fn cp_build_unstarted_child_process() -> f64 { unsafe { crate::object::callable_exports::ensure_child_process_prototype(constructor) }; let raw = (constructor.to_bits() & crate::value::POINTER_MASK) as usize; let prototype = crate::closure::closure_get_dynamic_prop(raw, "prototype"); - if cp_object_ptr(prototype).is_some() { + if let Some(obj) = cp_object_ptr(child.get_nanbox_f64()) { crate::object::prototype_chain::object_set_static_prototype( obj as usize, prototype.to_bits(), ); } - child + child.get_nanbox_f64() } /// Public constructor hook for the codegen `new ChildProcess()` fast path. diff --git a/crates/perry-runtime/src/child_process/exec.rs b/crates/perry-runtime/src/child_process/exec.rs index e740d639f9..6b6eed5c51 100644 --- a/crates/perry-runtime/src/child_process/exec.rs +++ b/crates/perry-runtime/src/child_process/exec.rs @@ -58,7 +58,8 @@ pub extern "C" fn js_child_process_exec_sync( }; cp_apply_options(&mut command, opts_val); - let run_options = cp_read_sync_stdio_run_options(opts_val); + let mut run_options = cp_read_sync_stdio_run_options(opts_val); + run_options.mark_shell_command(); let run = cp_run_to_completion(command, &run_options); let stdout_box = cp_box_run_output(&run.stdout, run.stdout_piped, &mode); if run.success() { @@ -287,7 +288,8 @@ pub extern "C" fn js_child_process_exec(cmd_ptr: *const StringHeader, arg1: f64, c }; cp_apply_options(&mut command, arg1); - let run_options = cp_read_async_run_options(arg1); + let mut run_options = cp_read_async_run_options(arg1); + run_options.mark_shell_command(); if cb.is_null() { // Legacy no-callback shape — run synchronously and return stdout diff --git a/crates/perry-runtime/src/child_process/mod.rs b/crates/perry-runtime/src/child_process/mod.rs index 99c52bf8d9..972f3af4c5 100644 --- a/crates/perry-runtime/src/child_process/mod.rs +++ b/crates/perry-runtime/src/child_process/mod.rs @@ -14,7 +14,8 @@ mod sync_run; mod validate; pub use validate::{ js_child_process_validate_args, js_child_process_validate_command, - js_child_process_validate_options, js_child_process_validate_spawn_args, + js_child_process_validate_fork_module, js_child_process_validate_options, + js_child_process_validate_spawn_args, }; // #3137: reuse the codec for the public `node:v8` serialize/deserialize API. diff --git a/crates/perry-runtime/src/child_process/options.rs b/crates/perry-runtime/src/child_process/options.rs index 57e7b9fd6a..f854ce2a76 100644 --- a/crates/perry-runtime/src/child_process/options.rs +++ b/crates/perry-runtime/src/child_process/options.rs @@ -250,7 +250,7 @@ pub(crate) fn cp_stdio_js_value(kind: CpStdio, pipe_obj: f64) -> f64 { } } -/// Apply stdio 0-2 and create parent-readable pipes for extra `"pipe"` fds. +/// Apply stdio 0-2 and honor explicit descriptors beyond fd 2. pub(crate) fn cp_apply_live_stdio( command: &mut Command, stdio: &[CpStdio], @@ -272,31 +272,59 @@ pub(crate) fn cp_apply_live_stdio( let mut readers = Vec::new(); for (fd, kind) in stdio.iter().copied().enumerate().skip(3) { - if kind != CpStdio::Pipe { - continue; - } - let mut pipe = [0; 2]; - if unsafe { libc::pipe(pipe.as_mut_ptr()) } != 0 { - continue; - } - let read = unsafe { std::fs::File::from_raw_fd(pipe[0]) }; - let write = unsafe { std::fs::File::from_raw_fd(pipe[1]) }; - let write_fd = write.as_raw_fd(); - unsafe { - libc::fcntl(write_fd, libc::F_SETFD, libc::FD_CLOEXEC); - } - unsafe { - command.pre_exec(move || { - // Keep the write end owned by Command until the fork; the - // parent drops it after spawn while FD_CLOEXEC closes the - // original child descriptor on exec. - if libc::dup2(write.as_raw_fd(), fd as i32) < 0 { - return Err(std::io::Error::last_os_error()); + match kind { + CpStdio::Pipe => { + let mut pipe = [0; 2]; + if unsafe { libc::pipe(pipe.as_mut_ptr()) } != 0 { + continue; + } + let read = unsafe { std::fs::File::from_raw_fd(pipe[0]) }; + let write = unsafe { std::fs::File::from_raw_fd(pipe[1]) }; + let write_fd = write.as_raw_fd(); + unsafe { + libc::fcntl(write_fd, libc::F_SETFD, libc::FD_CLOEXEC); + command.pre_exec(move || { + if libc::dup2(write.as_raw_fd(), fd as i32) < 0 + || libc::fcntl(fd as i32, libc::F_SETFD, 0) < 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); } - Ok(()) - }); + readers.push((fd, read)); + } + CpStdio::Ignore => { + let Ok(null) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/null") + else { + continue; + }; + unsafe { + command.pre_exec(move || { + if libc::dup2(null.as_raw_fd(), fd as i32) < 0 + || libc::fcntl(fd as i32, libc::F_SETFD, 0) < 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + CpStdio::Fd(source) => unsafe { + command.pre_exec(move || { + if libc::dup2(source, fd as i32) < 0 + || libc::fcntl(fd as i32, libc::F_SETFD, 0) < 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + }, + CpStdio::Inherit => {} } - readers.push((fd, read)); } return readers; } diff --git a/crates/perry-runtime/src/child_process/signals.rs b/crates/perry-runtime/src/child_process/signals.rs index 1d279e21a4..fa16d85c20 100644 --- a/crates/perry-runtime/src/child_process/signals.rs +++ b/crates/perry-runtime/src/child_process/signals.rs @@ -112,11 +112,16 @@ pub(crate) fn cp_signal_from_value(signal: f64) -> i32 { pub(crate) fn cp_signal_is_valid(signal: f64) -> bool { let js = JSValue::from_bits(signal.to_bits()); if js.is_int32() { - return js.as_int32() >= 0; + let n = js.as_int32(); + return n == 0 || cp_signal_number(cp_signal_name(n)) == Some(n); } if js.is_number() { - let n = js.as_number(); - return n.is_finite() && n >= 0.0 && n.fract() == 0.0; + let number = js.as_number(); + let n = number as i32; + return number.is_finite() + && number >= 0.0 + && number.fract() == 0.0 + && (n == 0 || cp_signal_number(cp_signal_name(n)) == Some(n)); } js.is_any_string() && cp_value_to_string(signal).is_some_and(|name| cp_signal_number(&name).is_some()) diff --git a/crates/perry-runtime/src/child_process/sync_run.rs b/crates/perry-runtime/src/child_process/sync_run.rs index daa4ac6040..2d6c07a583 100644 --- a/crates/perry-runtime/src/child_process/sync_run.rs +++ b/crates/perry-runtime/src/child_process/sync_run.rs @@ -16,6 +16,7 @@ pub(crate) struct CpRunOptions { input: Option>, timeout: Option, kill_signal: i32, + shell_command: bool, pub(super) max_buffer: usize, stdio: [CpStdio; 3], } @@ -32,6 +33,10 @@ impl CpRunOptions { pub(super) fn timeout(&self) -> Option { self.timeout } + + pub(super) fn mark_shell_command(&mut self) { + self.shell_command = true; + } } impl Default for CpRunOptions { @@ -40,6 +45,7 @@ impl Default for CpRunOptions { input: None, timeout: None, kill_signal: CP_SIGTERM, + shell_command: false, max_buffer: CP_DEFAULT_MAX_BUFFER, stdio: [CpStdio::Pipe; 3], } @@ -123,6 +129,7 @@ pub(super) fn cp_read_spawn_sync_run_options(opts_val: f64) -> CpRunOptions { stdio.get(1).copied().unwrap_or(CpStdio::Pipe), stdio.get(2).copied().unwrap_or(CpStdio::Pipe), ]; + options.shell_command = crate::value::js_is_truthy(cp_get_field(opts_val, b"shell")) != 0; options } @@ -214,9 +221,7 @@ pub(super) fn cp_run_to_completion(mut command: Command, options: &CpRunOptions) // A shell that has already completed its short command reports its real // exit status with ENOBUFS; a direct child is still terminable at the // buffer threshold and reports the configured signal. - let shell_command = std::path::Path::new(command.get_program()) - .file_name() - .is_some_and(|name| name == "sh"); + let shell_command = options.shell_command; let stdin_piped = matches!(options.stdio[0], CpStdio::Pipe) && options.input.is_some(); let stdout_piped = matches!(options.stdio[1], CpStdio::Pipe); let stderr_piped = matches!(options.stdio[2], CpStdio::Pipe); diff --git a/crates/perry-runtime/src/child_process/v8_serde.rs b/crates/perry-runtime/src/child_process/v8_serde.rs index d75f0fe030..8a497375b5 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -143,6 +143,7 @@ fn kind_for_v8_index(idx: u64) -> Option { struct Serializer { out: Vec, depth: u32, + refs: std::collections::HashMap, } const MAX_DEPTH: u32 = 512; @@ -152,6 +153,7 @@ impl Serializer { Serializer { out: Vec::with_capacity(64), depth: 0, + refs: std::collections::HashMap::new(), } } @@ -183,6 +185,18 @@ impl Serializer { self.out.extend_from_slice(&value.to_bits().to_le_bytes()); } + fn write_reference_or_register(&mut self, raw: usize) -> bool { + if let Some(&id) = self.refs.get(&raw) { + self.out.push(TAG_OBJECT_REFERENCE); + self.write_varint(id); + true + } else { + let id = self.refs.len() as u64; + self.refs.insert(raw, id); + false + } + } + fn write_value(&mut self, value: f64) { let bits = value.to_bits(); let jsval = JSValue::from_bits(bits); @@ -221,39 +235,82 @@ impl Serializer { let raw = (bits & crate::value::POINTER_MASK) as usize; if raw >= 0x10000 { if crate::buffer::is_registered_buffer(raw) { + if self.write_reference_or_register(raw) { + return; + } self.write_host_buffer(value); return; } if let Some(kind) = crate::typedarray::lookup_typed_array_kind(raw) { + if self.write_reference_or_register(raw) { + return; + } self.write_host_typed_array(value, kind); return; } if crate::map::is_registered_map(raw) { + if self.depth >= MAX_DEPTH { + self.out.push(TAG_UNDEFINED); + return; + } + if self.write_reference_or_register(raw) { + return; + } self.write_map(raw as *const crate::map::MapHeader); return; } if crate::set::is_registered_set(raw) { + if self.depth >= MAX_DEPTH { + self.out.push(TAG_UNDEFINED); + return; + } + if self.write_reference_or_register(raw) { + return; + } self.write_set(raw as *const crate::set::SetHeader); return; } if crate::regex::regex_header_has_magic(raw as *const crate::regex::RegExpHeader) { + if self.write_reference_or_register(raw) { + return; + } self.write_regexp(raw as *const crate::regex::RegExpHeader); return; } if crate::error::js_error_is_error(value).to_bits() == TAG_TRUE_F64.to_bits() { + if self.write_reference_or_register(raw) { + return; + } self.write_error(raw as *mut crate::error::ErrorHeader); return; } if crate::date::is_date_value(value) { + if self.write_reference_or_register(raw) { + return; + } self.out.push(TAG_DATE); self.write_double(crate::date::js_date_get_time(value)); return; } if let Some(arr) = cp_array_ptr(value) { + if self.depth >= MAX_DEPTH { + self.out.push(TAG_UNDEFINED); + return; + } + if self.write_reference_or_register(raw) { + return; + } self.write_dense_array(arr); return; } if let Some(obj) = cp_object_ptr(value) { + if self.depth >= MAX_DEPTH { + self.out.push(TAG_UNDEFINED); + return; + } + if self.write_reference_or_register(raw) { + return; + } self.write_object(obj); return; } @@ -386,6 +443,7 @@ impl Serializer { } fn write_map(&mut self, map: *const crate::map::MapHeader) { + self.depth += 1; self.out.push(TAG_BEGIN_JS_MAP); let size = unsafe { (*map).size }; for i in 0..size { @@ -394,9 +452,11 @@ impl Serializer { } self.out.push(TAG_END_JS_MAP); self.write_varint((size as u64) * 2); + self.depth -= 1; } fn write_set(&mut self, set: *const crate::set::SetHeader) { + self.depth += 1; self.out.push(TAG_BEGIN_JS_SET); let size = unsafe { (*set).size }; for i in 0..size { @@ -404,6 +464,7 @@ impl Serializer { } self.out.push(TAG_END_JS_SET); self.write_varint(size as u64); + self.depth -= 1; } fn write_regexp(&mut self, re: *const crate::regex::RegExpHeader) { @@ -782,7 +843,10 @@ impl<'a> Deserializer<'a> { #[cfg(not(feature = "regex-engine"))] { let _ = (source, flags); - Some(cp_undefined()) + crate::fs::validate::throw_type_error_with_code( + "RegExp values are not supported by this build's advanced IPC serializer", + "ERR_INVALID_ARG_VALUE", + ); } } @@ -815,17 +879,21 @@ impl<'a> Deserializer<'a> { _ => crate::error::ERROR_KIND_ERROR, }; let mut message = cp_undefined(); + let mut stack = cp_undefined(); while self.peek_byte() != Some(TAG_ERROR_END) { match self.read_byte()? { TAG_ERROR_MESSAGE => message = self.read_value()?, - TAG_ERROR_STACK => { - let _ = self.read_value()?; - } + TAG_ERROR_STACK => stack = self.read_value()?, _ => return None, } } self.pos += 1; let error = crate::error::js_error_new_kind_from_value(kind, message); + let stack = + crate::value::js_get_string_pointer_unified(stack) as *mut crate::string::StringHeader; + if !stack.is_null() { + unsafe { (*error).stack = stack }; + } let boxed = cp_box_ptr(error as *const u8); self.id_table.push(boxed); Some(boxed) diff --git a/crates/perry-runtime/src/child_process/validate.rs b/crates/perry-runtime/src/child_process/validate.rs index c077a81427..e1a3af918c 100644 --- a/crates/perry-runtime/src/child_process/validate.rs +++ b/crates/perry-runtime/src/child_process/validate.rs @@ -306,6 +306,29 @@ pub unsafe extern "C" fn js_child_process_validate_command( value } +/// `fork()` accepts strings, Buffers, and WHATWG URL objects. This runs before +/// codegen converts the value to a raw string pointer so primitives cannot be +/// silently string-coerced into module paths. +#[no_mangle] +pub extern "C" fn js_child_process_validate_fork_module(value: f64) -> f64 { + let js = JSValue::from_bits(value.to_bits()); + let raw = (value.to_bits() & crate::value::POINTER_MASK) as usize; + let is_url = cp_object_ptr(value).is_some_and(crate::url::is_url_object_shape); + if js.is_any_string() + || (raw >= 0x10000 + && crate::buffer::is_registered_buffer(raw) + && !crate::buffer::is_any_array_buffer(raw)) + || is_url + { + return value; + } + let message = format!( + "The \"modulePath\" argument must be of type string or an instance of Buffer or URL. Received {}", + crate::fs::validate::describe_received(value) + ); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); +} + /// Codegen-invoked `args` validator (#3079). `value` is the original NaN-boxed /// JS value passed in the args slot. Diverges via `js_throw` on a primitive. #[no_mangle] @@ -352,6 +375,10 @@ static KEEP_JS_CP_VALIDATE_COMMAND: unsafe extern "C" fn(f64, *const u8, u32) -> static KEEP_JS_CP_VALIDATE_ARGS: extern "C" fn(f64) -> f64 = js_child_process_validate_args; #[cfg(feature = "keepalive-anchors")] #[used] +static KEEP_JS_CP_VALIDATE_FORK_MODULE: extern "C" fn(f64) -> f64 = + js_child_process_validate_fork_module; +#[cfg(feature = "keepalive-anchors")] +#[used] static KEEP_JS_CP_VALIDATE_OPTIONS: extern "C" fn(f64, i32, i32) -> f64 = js_child_process_validate_options; #[cfg(feature = "keepalive-anchors")] From 5dc8e638a53a6b12a293a8132c0bdc92ed356ceb Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Fri, 31 Jul 2026 12:35:06 +0200 Subject: [PATCH 3/7] fix(child-process): cover remaining review cases --- .../src/child_process/builder.rs | 1 + .../perry-runtime/src/child_process/fork.rs | 27 +++++++++---------- .../src/child_process/options.rs | 16 +++++++---- .../src/child_process/reactor.rs | 7 ++--- .../src/child_process/v8_serde.rs | 18 ++++++++++--- .../src/child_process/validate.rs | 2 +- 6 files changed, 44 insertions(+), 27 deletions(-) diff --git a/crates/perry-runtime/src/child_process/builder.rs b/crates/perry-runtime/src/child_process/builder.rs index da8bedf59f..53dfc4d63f 100644 --- a/crates/perry-runtime/src/child_process/builder.rs +++ b/crates/perry-runtime/src/child_process/builder.rs @@ -199,6 +199,7 @@ pub(crate) fn cp_build_unstarted_child_process() -> f64 { cp_set_field(child.get_nanbox_f64(), b"killed", TAG_FALSE_F64); cp_set_field(child.get_nanbox_f64(), b"exitCode", TAG_NULL_F64); cp_set_field(child.get_nanbox_f64(), b"signalCode", TAG_NULL_F64); + cp_set_field(child.get_nanbox_f64(), b"spawnfile", TAG_NULL_F64); let constructor = crate::object::bound_native_callable_export_value("child_process", "ChildProcess"); diff --git a/crates/perry-runtime/src/child_process/fork.rs b/crates/perry-runtime/src/child_process/fork.rs index c307256d72..6a4091e4fc 100644 --- a/crates/perry-runtime/src/child_process/fork.rs +++ b/crates/perry-runtime/src/child_process/fork.rs @@ -149,20 +149,19 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr cp_apply_argv0(&mut command, opts_val); cp_apply_options(&mut command, opts_val); cp_apply_detached(&mut command, opts_val); - let _ = cp_apply_live_stdio(&mut command, &stdio_kinds); - - let launched = fork_launch( - cp, - stdout_obj, - stderr_obj, - stdin_obj, - command, - advanced, - timeout, - kill_signal, - abort_signal, - opts_val, - ); + let launched = cp_apply_live_stdio(&mut command, &stdio_kinds).is_ok() + && fork_launch( + cp, + stdout_obj, + stderr_obj, + stdin_obj, + command, + advanced, + timeout, + kill_signal, + abort_signal, + opts_val, + ); if !launched { // Spawn failure: emit a deferred `error`, leave `connected` false. let msg = format!("fork failed: {exec_path}"); diff --git a/crates/perry-runtime/src/child_process/options.rs b/crates/perry-runtime/src/child_process/options.rs index f854ce2a76..4a708c33f9 100644 --- a/crates/perry-runtime/src/child_process/options.rs +++ b/crates/perry-runtime/src/child_process/options.rs @@ -254,7 +254,7 @@ pub(crate) fn cp_stdio_js_value(kind: CpStdio, pipe_obj: f64) -> f64 { pub(crate) fn cp_apply_live_stdio( command: &mut Command, stdio: &[CpStdio], -) -> Vec<(usize, std::fs::File)> { +) -> std::io::Result> { let to_stdio = |kind: CpStdio| match kind { CpStdio::Pipe => Stdio::piped(), CpStdio::Ignore => Stdio::null(), @@ -276,13 +276,19 @@ pub(crate) fn cp_apply_live_stdio( CpStdio::Pipe => { let mut pipe = [0; 2]; if unsafe { libc::pipe(pipe.as_mut_ptr()) } != 0 { - continue; + return Err(std::io::Error::last_os_error()); } let read = unsafe { std::fs::File::from_raw_fd(pipe[0]) }; let write = unsafe { std::fs::File::from_raw_fd(pipe[1]) }; + if unsafe { libc::fcntl(read.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 + { + return Err(std::io::Error::last_os_error()); + } let write_fd = write.as_raw_fd(); + if unsafe { libc::fcntl(write_fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { + return Err(std::io::Error::last_os_error()); + } unsafe { - libc::fcntl(write_fd, libc::F_SETFD, libc::FD_CLOEXEC); command.pre_exec(move || { if libc::dup2(write.as_raw_fd(), fd as i32) < 0 || libc::fcntl(fd as i32, libc::F_SETFD, 0) < 0 @@ -326,12 +332,12 @@ pub(crate) fn cp_apply_live_stdio( CpStdio::Inherit => {} } } - return readers; + return Ok(readers); } #[cfg(not(unix))] { - Vec::new() + Ok(Vec::new()) } } diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index 1229c59504..b3a414e95a 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -765,10 +765,11 @@ pub extern "C" fn js_child_process_spawn_streams( // Build + launch the child (honoring `shell`/`cwd`/`env`), non-blocking. let mut command = cp_build_command(&cmd_str, &arg_strs, opts_val); - let extra_readers = cp_apply_live_stdio(&mut command, &stdio_kinds); + let launch = cp_apply_live_stdio(&mut command, &stdio_kinds) + .and_then(|extra_readers| command.spawn().map(|child| (child, extra_readers))); - match command.spawn() { - Ok(child) => { + match launch { + Ok((child, extra_readers)) => { let handle = cp_register_live_child( cp, stdout_obj, diff --git a/crates/perry-runtime/src/child_process/v8_serde.rs b/crates/perry-runtime/src/child_process/v8_serde.rs index 8a497375b5..5690c3923e 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -67,6 +67,7 @@ const TAG_BEGIN_JS_SET: u8 = b'\''; const TAG_END_JS_SET: u8 = b','; const TAG_ERROR: u8 = b'r'; const TAG_ERROR_MESSAGE: u8 = b'm'; +const TAG_ERROR_CAUSE: u8 = b'c'; const TAG_ERROR_STACK: u8 = b's'; const TAG_ERROR_END: u8 = b'.'; const TAG_ARRAY_BUFFER: u8 = b'B'; @@ -482,8 +483,8 @@ impl Serializer { b'y' => 8, b'u' => 16, b's' => 32, - b'd' => 64, - b'v' => 128, + b'd' => 128, + b'v' => 256, _ => 0, }; } @@ -510,6 +511,10 @@ impl Serializer { let message = crate::error::js_error_get_message(error); self.write_string(crate::value::js_nanbox_string(message as i64)); } + if unsafe { crate::error::js_error_has_own_property(error, "cause") } { + self.out.push(TAG_ERROR_CAUSE); + self.write_value(crate::error::js_error_get_cause(error)); + } self.out.push(TAG_ERROR_STACK); let stack = crate::error::js_error_get_stack(error); self.write_string(crate::value::js_nanbox_string(stack as i64)); @@ -823,8 +828,8 @@ impl<'a> Deserializer<'a> { (b'y', 8), (b'u', 16), (b's', 32), - (b'd', 64), - (b'v', 128), + (b'd', 128), + (b'v', 256), ] { if flags & bit != 0 { flag_bytes.push(flag); @@ -880,15 +885,20 @@ impl<'a> Deserializer<'a> { }; let mut message = cp_undefined(); let mut stack = cp_undefined(); + let mut cause = None; while self.peek_byte() != Some(TAG_ERROR_END) { match self.read_byte()? { TAG_ERROR_MESSAGE => message = self.read_value()?, TAG_ERROR_STACK => stack = self.read_value()?, + TAG_ERROR_CAUSE => cause = Some(self.read_value()?), _ => return None, } } self.pos += 1; let error = crate::error::js_error_new_kind_from_value(kind, message); + if let Some(cause) = cause { + unsafe { crate::error::error_set_cause(error, cause) }; + } let stack = crate::value::js_get_string_pointer_unified(stack) as *mut crate::string::StringHeader; if !stack.is_null() { diff --git a/crates/perry-runtime/src/child_process/validate.rs b/crates/perry-runtime/src/child_process/validate.rs index e1a3af918c..3a6e4cfb69 100644 --- a/crates/perry-runtime/src/child_process/validate.rs +++ b/crates/perry-runtime/src/child_process/validate.rs @@ -109,7 +109,7 @@ fn cp_validate_stdio_entry( fd_index: usize, ipc_count: &mut u32, ) { - if JSValue::from_bits(value.to_bits()).is_null() { + if JSValue::from_bits(value.to_bits()).is_null() || cp_is_undefined(value) { return; } if JSValue::from_bits(value.to_bits()).is_any_string() { From 099349adb67cb4df4dd40dcea40069f90c458a48 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Fri, 31 Jul 2026 12:54:04 +0200 Subject: [PATCH 4/7] fix(child-process): preserve fork stdio and error causes --- .../perry-runtime/src/child_process/fork.rs | 72 +++++++++++++++---- .../src/child_process/v8_serde.rs | 41 +++++++++-- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/crates/perry-runtime/src/child_process/fork.rs b/crates/perry-runtime/src/child_process/fork.rs index 6a4091e4fc..bd34a5900d 100644 --- a/crates/perry-runtime/src/child_process/fork.rs +++ b/crates/perry-runtime/src/child_process/fork.rs @@ -75,12 +75,26 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr let stdout_obj = cp_build_readable(); let stderr_obj = cp_build_readable(); let stdin_obj = cp_build_writable(); - let mut stdio_kinds = cp_read_stdio(opts_val, 3); + let stdio_field = cp_get_field(opts_val, b"stdio"); + let ipc_fd = cp_array_ptr(stdio_field) + .and_then(|stdio| { + (0..crate::array::js_array_length(stdio)).find(|&fd| { + cp_value_to_string(crate::array::js_array_get_f64(stdio, fd)).as_deref() + == Some("ipc") + }) + }) + .unwrap_or(3) as usize; + let stdio_count = cp_array_ptr(stdio_field) + .map(|stdio| crate::array::js_array_length(stdio) as usize) + .unwrap_or(4) + .max(4); + let mut stdio_kinds = cp_read_stdio(opts_val, stdio_count); + // The IPC socket is installed by fork_launch after live stdio setup. + stdio_kinds[ipc_fd] = CpStdio::Ignore; // Node fork semantics: `silent: false` (cluster.fork's default) inherits // stdin/stdout/stderr from the parent; `silent: true` pipes them. Only an // explicit `silent: false` overrides — an absent `silent` keeps Perry's // historical pipe default, and an explicit `stdio` option wins (#4914). - let stdio_field = cp_get_field(opts_val, b"stdio"); if crate::value::JSValue::from_bits(stdio_field.to_bits()).is_undefined() && cp_get_field(opts_val, b"silent").to_bits() == crate::value::TAG_FALSE { @@ -127,11 +141,29 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr cp_set_field(cp, b"stdout", cp_stdio_js_value(stdio_kinds[1], stdout_obj)); cp_set_field(cp, b"stderr", cp_stdio_js_value(stdio_kinds[2], stderr_obj)); cp_set_field(cp, b"stdin", cp_stdio_js_value(stdio_kinds[0], stdin_obj)); - let mut stdio = crate::array::js_array_alloc(4); + let mut stdio = crate::array::js_array_alloc(stdio_count as u32); stdio = crate::array::js_array_push_f64(stdio, cp_stdio_js_value(stdio_kinds[0], stdin_obj)); stdio = crate::array::js_array_push_f64(stdio, cp_stdio_js_value(stdio_kinds[1], stdout_obj)); stdio = crate::array::js_array_push_f64(stdio, cp_stdio_js_value(stdio_kinds[2], stderr_obj)); - stdio = crate::array::js_array_push_f64(stdio, TAG_NULL_F64); // fd 3 = ipc + let mut extra_streams = Vec::new(); + for (fd, kind) in stdio_kinds.iter().copied().enumerate().skip(3) { + let stream = if fd != ipc_fd && kind == CpStdio::Pipe { + cp_build_readable() + } else { + TAG_NULL_F64 + }; + if fd != ipc_fd && kind == CpStdio::Pipe { + extra_streams.push((fd, stream)); + } + stdio = crate::array::js_array_push_f64( + stdio, + if fd == ipc_fd { + TAG_NULL_F64 + } else { + cp_stdio_js_value(kind, stream) + }, + ); + } cp_set_field(cp, b"stdio", cp_box_ptr(stdio as *const u8)); cp_set_field(cp, b"exitCode", TAG_NULL_F64); cp_set_field(cp, b"signalCode", TAG_NULL_F64); @@ -149,19 +181,31 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr cp_apply_argv0(&mut command, opts_val); cp_apply_options(&mut command, opts_val); cp_apply_detached(&mut command, opts_val); - let launched = cp_apply_live_stdio(&mut command, &stdio_kinds).is_ok() - && fork_launch( + let launched = match cp_apply_live_stdio(&mut command, &stdio_kinds) { + Ok(extra_readers) => fork_launch( cp, stdout_obj, stderr_obj, stdin_obj, + extra_readers + .into_iter() + .filter_map(|(fd, pipe)| { + extra_streams + .iter() + .find(|(stream_fd, _)| *stream_fd == fd) + .map(|(_, stream)| (fd, *stream, pipe)) + }) + .collect(), command, advanced, timeout, kill_signal, abort_signal, opts_val, - ); + ipc_fd, + ), + Err(_) => false, + }; if !launched { // Spawn failure: emit a deferred `error`, leave `connected` false. let msg = format!("fork failed: {exec_path}"); @@ -189,12 +233,14 @@ fn fork_launch( stdout_obj: f64, stderr_obj: f64, stdin_obj: f64, + extra_pipes: Vec<(usize, f64, std::fs::File)>, mut command: Command, advanced: bool, timeout: Option, kill_signal: i32, abort_signal: Option, opts_val: f64, + ipc_fd: usize, ) -> bool { use std::os::unix::io::AsRawFd; use std::os::unix::net::UnixStream; @@ -205,12 +251,12 @@ fn fork_launch( Err(_) => return false, }; - // The child inherits `child_sock` across fork; dup it onto fd 3 (which + // The child inherits `child_sock` across fork; dup it onto the IPC fd (which // `dup2` leaves without CLOEXEC, so it survives exec) and advertise it via // NODE_CHANNEL_FD — the convention a Node child reads to enable // `process.send` / `process.on('message')`. let child_fd = child_sock.as_raw_fd(); - command.env("NODE_CHANNEL_FD", "3"); + command.env("NODE_CHANNEL_FD", ipc_fd.to_string()); // #2130: tell a node child to use V8 structured-clone framing on the channel. command.env( "NODE_CHANNEL_SERIALIZATION_MODE", @@ -218,7 +264,7 @@ fn fork_launch( ); unsafe { command.pre_exec(move || { - if libc::dup2(child_fd, 3) < 0 { + if libc::dup2(child_fd, ipc_fd as i32) < 0 { return Err(std::io::Error::last_os_error()); } Ok(()) @@ -237,7 +283,7 @@ fn fork_launch( stdout_obj, stderr_obj, stdin_obj, - Vec::new(), + extra_pipes, child, Some(parent_sock), advanced, @@ -257,12 +303,14 @@ fn fork_launch( stdout_obj: f64, stderr_obj: f64, stdin_obj: f64, + extra_pipes: Vec<(usize, f64, std::fs::File)>, mut command: Command, advanced: bool, timeout: Option, kill_signal: i32, abort_signal: Option, opts_val: f64, + _ipc_fd: usize, ) -> bool { let _ = advanced; match command.spawn() { @@ -272,7 +320,7 @@ fn fork_launch( stdout_obj, stderr_obj, stdin_obj, - Vec::new(), + extra_pipes, child, None, false, diff --git a/crates/perry-runtime/src/child_process/v8_serde.rs b/crates/perry-runtime/src/child_process/v8_serde.rs index 5690c3923e..f40d2f892b 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -279,6 +279,10 @@ impl Serializer { return; } if crate::error::js_error_is_error(value).to_bits() == TAG_TRUE_F64.to_bits() { + if self.depth >= MAX_DEPTH { + self.out.push(TAG_UNDEFINED); + return; + } if self.write_reference_or_register(raw) { return; } @@ -492,6 +496,7 @@ impl Serializer { } fn write_error(&mut self, error: *mut crate::error::ErrorHeader) { + self.depth += 1; self.out.push(TAG_ERROR); let kind = unsafe { (*error).error_kind }; let type_tag = match kind { @@ -519,6 +524,7 @@ impl Serializer { let stack = crate::error::js_error_get_stack(error); self.write_string(crate::value::js_nanbox_string(stack as i64)); self.out.push(TAG_ERROR_END); + self.depth -= 1; } fn write_object(&mut self, obj: *const ObjectHeader) { @@ -883,21 +889,44 @@ impl<'a> Deserializer<'a> { } _ => crate::error::ERROR_KIND_ERROR, }; + let scope = crate::gc::RuntimeHandleScope::new(); + let error = scope.root_raw_mut_ptr(crate::error::js_error_new_kind_from_value( + kind, + cp_undefined(), + )); + let id = self.id_table.len(); + self.id_table.push(cp_box_ptr( + error.get_raw_mut_ptr::() as *const u8, + )); let mut message = cp_undefined(); let mut stack = cp_undefined(); - let mut cause = None; while self.peek_byte() != Some(TAG_ERROR_END) { match self.read_byte()? { TAG_ERROR_MESSAGE => message = self.read_value()?, TAG_ERROR_STACK => stack = self.read_value()?, - TAG_ERROR_CAUSE => cause = Some(self.read_value()?), + TAG_ERROR_CAUSE => { + let cause = self.read_value()?; + unsafe { + crate::error::error_set_cause( + error.get_raw_mut_ptr::(), + cause, + ) + }; + } _ => return None, } } self.pos += 1; - let error = crate::error::js_error_new_kind_from_value(kind, message); - if let Some(cause) = cause { - unsafe { crate::error::error_set_cause(error, cause) }; + let error = error.get_raw_mut_ptr::(); + if !JSValue::from_bits(message.to_bits()).is_undefined() { + let message = crate::value::js_get_string_pointer_unified(message) + as *mut crate::string::StringHeader; + if !message.is_null() { + unsafe { + (*error).message = message; + (*error).flags |= 1; + } + } } let stack = crate::value::js_get_string_pointer_unified(stack) as *mut crate::string::StringHeader; @@ -905,7 +934,7 @@ impl<'a> Deserializer<'a> { unsafe { (*error).stack = stack }; } let boxed = cp_box_ptr(error as *const u8); - self.id_table.push(boxed); + self.id_table[id] = boxed; Some(boxed) } From d33d61a833b6f82a5f44a21a0c8066250d771481 Mon Sep 17 00:00:00 2001 From: TheHypnoo Date: Fri, 31 Jul 2026 13:17:43 +0200 Subject: [PATCH 5/7] fix(child-process): preserve fork setup errors --- .../perry-runtime/src/child_process/fork.rs | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/crates/perry-runtime/src/child_process/fork.rs b/crates/perry-runtime/src/child_process/fork.rs index bd34a5900d..3b3209888a 100644 --- a/crates/perry-runtime/src/child_process/fork.rs +++ b/crates/perry-runtime/src/child_process/fork.rs @@ -181,7 +181,7 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr cp_apply_argv0(&mut command, opts_val); cp_apply_options(&mut command, opts_val); cp_apply_detached(&mut command, opts_val); - let launched = match cp_apply_live_stdio(&mut command, &stdio_kinds) { + let launch = match cp_apply_live_stdio(&mut command, &stdio_kinds) { Ok(extra_readers) => fork_launch( cp, stdout_obj, @@ -204,18 +204,22 @@ pub extern "C" fn js_child_process_fork(module_ptr: i64, args_ptr: i64, opts_ptr opts_val, ipc_fd, ), - Err(_) => false, + Err(error) => Err(error), }; - if !launched { + if let Err(error) = launch { // Spawn failure: emit a deferred `error`, leave `connected` false. - let msg = format!("fork failed: {exec_path}"); - let mp = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let err = crate::error::js_error_new_with_message(mp); - cp_set_field( - cp, - b"__cpError", - crate::value::js_nanbox_pointer(err as i64), + let code = cp_io_error_code(&error); + let syscall = format!("spawn {exec_path}"); + let err = cp_make_error( + &format!("{syscall} {code}"), + &[ + ("errno", cp_errno_number(code)), + ("code", cp_box_string(code)), + ("syscall", cp_box_string(&syscall)), + ("path", cp_box_string(&exec_path)), + ], ); + cp_set_field(cp, b"__cpError", err); let emit_closure = crate::closure::js_closure_alloc(reactor::cp_emit_spawn_error as *const u8, 1); crate::closure::js_closure_set_capture_ptr(emit_closure, 0, cp.to_bits() as i64); @@ -241,14 +245,14 @@ fn fork_launch( abort_signal: Option, opts_val: f64, ipc_fd: usize, -) -> bool { +) -> std::io::Result<()> { use std::os::unix::io::AsRawFd; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; let (parent_sock, child_sock) = match UnixStream::pair() { Ok(p) => p, - Err(_) => return false, + Err(error) => return Err(error), }; // The child inherits `child_sock` across fork; dup it onto the IPC fd (which @@ -291,9 +295,9 @@ fn fork_launch( kill_signal, ); reactor::cp_install_abort_signal(handle, abort_signal, opts_val); - true + Ok(()) } - Err(_) => false, + Err(error) => Err(error), } } @@ -311,7 +315,7 @@ fn fork_launch( abort_signal: Option, opts_val: f64, _ipc_fd: usize, -) -> bool { +) -> std::io::Result<()> { let _ = advanced; match command.spawn() { Ok(child) => { @@ -328,8 +332,8 @@ fn fork_launch( kill_signal, ); reactor::cp_install_abort_signal(handle, abort_signal, opts_val); - true + Ok(()) } - Err(_) => false, + Err(error) => Err(error), } } From 02c30e9e56c9bae32bf84f4fb69c414fc0cf4daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 07:38:27 +0200 Subject: [PATCH 6/7] fix(child-process): propagate extra stdio setup failures --- changelog.d/7089-child-process-node-26-parity.md | 1 + crates/perry-runtime/src/child_process/options.rs | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) create mode 100644 changelog.d/7089-child-process-node-26-parity.md diff --git a/changelog.d/7089-child-process-node-26-parity.md b/changelog.d/7089-child-process-node-26-parity.md new file mode 100644 index 0000000000..6fc302e025 --- /dev/null +++ b/changelog.d/7089-child-process-node-26-parity.md @@ -0,0 +1 @@ +Completed Node.js 26 child-process parity across validation, stdio, asynchronous lifecycle ordering, synchronous result shapes, fork handling, and advanced IPC serialization for maps, sets, regular expressions, and errors. diff --git a/crates/perry-runtime/src/child_process/options.rs b/crates/perry-runtime/src/child_process/options.rs index 4a708c33f9..cd03360677 100644 --- a/crates/perry-runtime/src/child_process/options.rs +++ b/crates/perry-runtime/src/child_process/options.rs @@ -301,13 +301,10 @@ pub(crate) fn cp_apply_live_stdio( readers.push((fd, read)); } CpStdio::Ignore => { - let Ok(null) = std::fs::OpenOptions::new() + let null = std::fs::OpenOptions::new() .read(true) .write(true) - .open("/dev/null") - else { - continue; - }; + .open("/dev/null")?; unsafe { command.pre_exec(move || { if libc::dup2(null.as_raw_fd(), fd as i32) < 0 From df7214b0dfbd64aa0ba78df87684f1b16f9cef1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 07:38:27 +0200 Subject: [PATCH 7/7] chore: bump version for PR 7089 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 96432bade7..a3322fd0ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1268 +**Current Version:** 0.5.1269 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 898549f72a..1538167ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5503,7 +5503,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "base64", @@ -5563,14 +5563,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "cc", "libc", @@ -5578,7 +5578,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "log", @@ -5592,7 +5592,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-hir", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-hir", @@ -5608,7 +5608,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-dispatch", @@ -5617,7 +5617,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-hir", @@ -5625,7 +5625,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "base64", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-hir", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "async-trait", @@ -5674,14 +5674,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "serde", "serde_json", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1268" +version = "0.5.1269" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5700,7 +5700,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "clap", @@ -5715,7 +5715,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "block2", "objc2", @@ -5725,7 +5725,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "argon2", "perry-ffi", @@ -5733,7 +5733,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "reqwest", @@ -5742,7 +5742,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "bcrypt", "perry-ffi", @@ -5750,7 +5750,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "rusqlite", @@ -5758,7 +5758,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "scraper", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "perry-runtime", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "chrono", "cron", @@ -5784,7 +5784,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "chrono", "perry-ffi", @@ -5792,7 +5792,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "rust_decimal", @@ -5800,7 +5800,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "serde_json", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "perry-runtime", @@ -5824,14 +5824,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "bytes", "http-body-util", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "bytes", "lazy_static", @@ -5862,7 +5862,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "bytes", "h2", @@ -5886,7 +5886,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "lazy_static", "perry-ffi", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "jsonwebtoken", @@ -5907,7 +5907,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "lru", "perry-ffi", @@ -5915,7 +5915,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "chrono", "perry-ffi", @@ -5923,7 +5923,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "bson", "futures-util", @@ -5935,7 +5935,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "chrono", "perry-ffi", @@ -5945,7 +5945,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "nanoid", "perry-ffi", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "bytes", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "lettre", "perry-ffi", @@ -5996,7 +5996,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "printpdf", @@ -6004,7 +6004,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "sqlx", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "governor", "perry-ffi", @@ -6021,7 +6021,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "fast_image_resize", "image", @@ -6031,14 +6031,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "lazy_static", "perry-ffi", @@ -6047,7 +6047,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "perry-runtime", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "uuid", @@ -6064,7 +6064,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ffi", "regex", @@ -6074,7 +6074,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "futures-util", "lazy_static", @@ -6087,7 +6087,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "brotli", "flate2", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "dashmap", "once_cell", @@ -6106,7 +6106,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-api-manifest", @@ -6124,7 +6124,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-diagnostics", @@ -6136,7 +6136,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "base64", @@ -6177,14 +6177,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6279,14 +6279,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "perry-hir", @@ -6295,14 +6295,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "itoa", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "rand 0.10.1", "serde", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6352,7 +6352,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "block2", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "block2", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1268" +version = "0.5.1269" [[package]] name = "perry-ui-test" @@ -6394,11 +6394,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1268" +version = "0.5.1269" [[package]] name = "perry-ui-tvos" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "block2", @@ -6414,7 +6414,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "block2", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "block2", "libc", @@ -6443,7 +6443,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "base64", "libc", @@ -6460,14 +6460,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "anyhow", "base64", @@ -6483,7 +6483,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1268" +version = "0.5.1269" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 231f5711d4..b0edceeceb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -292,7 +292,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1268" +version = "0.5.1269" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"