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" 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-codegen/src/expr/child_proc.rs b/crates/perry-codegen/src/expr/child_proc.rs index 2f9baa5c86..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 @@ -48,6 +59,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 +125,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 +195,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 +227,20 @@ 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); + emit_cp_validate_fork_module(ctx, &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..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 @@ -296,6 +296,18 @@ 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_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 ========== 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..53dfc4d63f 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,59 @@ 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 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); + cp_set_field(child.get_nanbox_f64(), b"spawnfile", 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 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.get_nanbox_f64() +} + +/// 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..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() { @@ -89,11 +90,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 @@ -291,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 @@ -468,7 +466,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..3b3209888a 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". @@ -67,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 { @@ -119,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); @@ -141,30 +181,45 @@ 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 launched = 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}"); - 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( + let launch = match cp_apply_live_stdio(&mut command, &stdio_kinds) { + Ok(extra_readers) => fork_launch( cp, - b"__cpError", - crate::value::js_nanbox_pointer(err as i64), + 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(error) => Err(error), + }; + if let Err(error) = launch { + // Spawn failure: emit a deferred `error`, leave `connected` false. + 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); @@ -182,28 +237,30 @@ 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, -) -> bool { + ipc_fd: usize, +) -> 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 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", @@ -211,7 +268,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(()) @@ -230,6 +287,7 @@ fn fork_launch( stdout_obj, stderr_obj, stdin_obj, + extra_pipes, child, Some(parent_sock), advanced, @@ -237,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), } } @@ -249,13 +307,15 @@ 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, -) -> bool { + _ipc_fd: usize, +) -> std::io::Result<()> { let _ = advanced; match command.spawn() { Ok(child) => { @@ -264,6 +324,7 @@ fn fork_launch( stdout_obj, stderr_obj, stdin_obj, + extra_pipes, child, None, false, @@ -271,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), } } diff --git a/crates/perry-runtime/src/child_process/mod.rs b/crates/perry-runtime/src/child_process/mod.rs index 71416bb783..972f3af4c5 100644 --- a/crates/perry-runtime/src/child_process/mod.rs +++ b/crates/perry-runtime/src/child_process/mod.rs @@ -12,7 +12,11 @@ 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_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. // #3680: class-based `v8.Serializer` / `v8.Deserializer` builders. @@ -69,35 +73,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..cd03360677 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 honor explicit descriptors beyond fd 2. +pub(crate) fn cp_apply_live_stdio( + command: &mut Command, + stdio: &[CpStdio], +) -> std::io::Result> { let to_stdio = |kind: CpStdio| match kind { CpStdio::Pipe => Stdio::piped(), CpStdio::Ignore => Stdio::null(), @@ -260,6 +264,78 @@ 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) { + match kind { + CpStdio::Pipe => { + let mut pipe = [0; 2]; + if unsafe { libc::pipe(pipe.as_mut_ptr()) } != 0 { + 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 { + 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(()) + }); + } + readers.push((fd, read)); + } + CpStdio::Ignore => { + let null = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/null")?; + 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 => {} + } + } + return Ok(readers); + } + + #[cfg(not(unix))] + { + Ok(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..b3a414e95a 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,15 +765,25 @@ 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 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, 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 +819,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 +841,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 +883,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 +902,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 +947,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 +970,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 +997,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 +1026,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 +1151,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 +1175,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 +1185,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 +1198,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 +1351,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 +1374,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 +1419,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..fa16d85c20 100644 --- a/crates/perry-runtime/src/child_process/signals.rs +++ b/crates/perry-runtime/src/child_process/signals.rs @@ -106,6 +106,35 @@ 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() { + let n = js.as_int32(); + return n == 0 || cp_signal_number(cp_signal_name(n)) == Some(n); + } + if js.is_number() { + 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()) +} + +/// 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..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 } @@ -160,8 +167,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 +218,10 @@ 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 = 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); @@ -242,6 +257,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 +265,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..f40d2f892b 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -60,6 +60,16 @@ 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_CAUSE: u8 = b'c'; +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'\\'; @@ -134,6 +144,7 @@ fn kind_for_v8_index(idx: u64) -> Option { struct Serializer { out: Vec, depth: u32, + refs: std::collections::HashMap, } const MAX_DEPTH: u32 = 512; @@ -143,6 +154,7 @@ impl Serializer { Serializer { out: Vec::with_capacity(64), depth: 0, + refs: std::collections::HashMap::new(), } } @@ -174,6 +186,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); @@ -212,23 +236,86 @@ 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.depth >= MAX_DEPTH { + self.out.push(TAG_UNDEFINED); + return; + } + 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; } @@ -360,6 +447,86 @@ impl Serializer { self.depth -= 1; } + 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 { + 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); + 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 { + self.write_value(crate::set::js_set_value_at(set, i)); + } + 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) { + 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' => 128, + b'v' => 256, + _ => 0, + }; + } + self.write_varint(bits); + } + + 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 { + 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)); + } + 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)); + self.out.push(TAG_ERROR_END); + self.depth -= 1; + } + fn write_object(&mut self, obj: *const ObjectHeader) { if self.depth >= MAX_DEPTH { self.out.push(TAG_UNDEFINED); @@ -510,6 +677,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 +797,147 @@ 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', 128), + (b'v', 256), + ] { + 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); + crate::fs::validate::throw_type_error_with_code( + "RegExp values are not supported by this build's advanced IPC serializer", + "ERR_INVALID_ARG_VALUE", + ); + } + } + + 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 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(); + 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 => { + let cause = self.read_value()?; + unsafe { + crate::error::error_set_cause( + error.get_raw_mut_ptr::(), + cause, + ) + }; + } + _ => return None, + } + } + self.pos += 1; + 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; + if !stack.is_null() { + unsafe { (*error).stack = stack }; + } + let boxed = cp_box_ptr(error as *const u8); + self.id_table[id] = 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..3a6e4cfb69 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() || cp_is_undefined(value) { + 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. @@ -80,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] @@ -88,6 +337,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 +373,15 @@ 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_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")] +#[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;