diff --git a/changelog.d/9566-linux-callback-deopt-runtime.md b/changelog.d/9566-linux-callback-deopt-runtime.md new file mode 100644 index 0000000000..8a09b8dec5 --- /dev/null +++ b/changelog.d/9566-linux-callback-deopt-runtime.md @@ -0,0 +1 @@ +test: the versioned indexed-loop callback-deopt fixture now links Perry's shipped `panic=abort` runtime, so its ordinary and forced-evacuation GC paths run on Linux again instead of aborting behind an ignore (#9482). diff --git a/changelog.d/9567-mixin-of-a-mixin-dynamic-parent.md b/changelog.d/9567-mixin-of-a-mixin-dynamic-parent.md new file mode 100644 index 0000000000..26d794f348 --- /dev/null +++ b/changelog.d/9567-mixin-of-a-mixin-dynamic-parent.md @@ -0,0 +1,33 @@ +**A mixin applied to a mixin no longer crashes.** `const Mixed2 = mixin(Mixed)` +— the second level of a mixin chain — SIGSEGVed as soon as anything derived +from it was constructed (`exit 139` where node printed the value). Mixin +composition is normally written as a chain, so this was the common shape +rather than an edge case, and it was a crash rather than a wrong value. + +The HIR mixin fast path synthesizes a real class for `const M = mixinFn(Base)`. +At the second level the base is a lexical VALUE binding, so the parent is +correctly captured as `extends_expr` — a dynamic parent — instead of a static +class link. The missing half was the registration: unlike the sibling +`const X = class {…}` path in the same function, this arm bound the +synthesized class without emitting the declaration-time +`RegisterClassParentDynamic`. Its constructor therefore asked +`js_get_dynamic_parent_value` for a class id nothing had registered, and with +an undefined parent `js_fetch_or_value_super` fell back to the most-derived +receiver, re-selected the same class, and recursed until the stack overflowed. + +The registration is now emitted here too, in source order after the parent's +own value binding and before the synthesized class's. A single-level +`mixin(Root)` extends a real class, keeps `extends_expr` at `None`, and is +unchanged — which is why one level already worked (#9073) and two did not. + +Pinned in both directions. The gap fixture keeps the issue's reproducer +verbatim and adds what it left open: inherited base state and the mixin method +through both synthesized levels, `instanceof` across the whole chain, a leaf +with no own constructor, the one-level case #9073 fixed, and a three-level +chain built from three distinct mixins so a dropped level shows as a missing +method rather than being masked by identical bodies. A lowering unit test +asserts the registration lands between the parent's binding and its own, and +asserts the negative for level 1; it was confirmed to fail against the +unpatched lowering. The compiled fixture's LLVM now has a matching +`js_register_class_parent_dynamic` for every `js_get_dynamic_parent_value` in +the module — zero orphans. diff --git a/changelog.d/9568-child-process-null-bytes.md b/changelog.d/9568-child-process-null-bytes.md new file mode 100644 index 0000000000..87626eef03 --- /dev/null +++ b/changelog.d/9568-child-process-null-bytes.md @@ -0,0 +1,11 @@ +### Fixed + +- **`child_process` now rejects embedded null bytes synchronously through both + direct imports and CommonJS-default namespaces (#9537).** The CJS method-call + dispatcher bypassed codegen-only validators, so `spawn`, `spawnSync`, + `execFile`, and related calls handed invalid file/argument strings to the OS + and later reported `UNKNOWN`. Runtime entry points now validate command, + file, and indexed argument strings before constructing a process, and + OS-facing `cwd`, `argv0`, `shell`, and environment strings use Node's exact + `ERR_INVALID_ARG_VALUE` message, including the property name and escaped + received value. diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 5acbea4c5a..b9b3a3276a 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -913,7 +913,47 @@ pub(crate) fn lower_stmt( .clone() .unwrap_or_default(), ); + // Issue #9079: `const Mixed2 = + // mixin(Mixed)` — a mixin applied to a + // previous mixin's RESULT. That base is + // a lexical VALUE binding, so + // `lower_class_from_ast` captures it as + // `extends_expr` (a dynamic parent) + // instead of a static class link. This + // arm bound the synthesized class + // WITHOUT the decl-time + // `RegisterClassParentDynamic` its + // sibling `const X = class {…}` path + // above emits, so the class id got a + // `js_get_dynamic_parent_value` in its + // constructor and no registration to + // answer it: `js_fetch_or_value_super` + // fell back to the most-derived + // receiver, re-selected the same class, + // and recursed until the stack + // overflowed — a SIGSEGV, not a wrong + // value. Emit it here too, in source + // order before the value binding, and + // clone the extends expression before + // `push_class_dedup` moves the class + // out. A single-level `mixin(Root)` + // extends a real class, keeps + // `extends_expr` None, and is unchanged. + let parent_register = lowered_class + .extends_expr + .clone() + .map(|parent_expr| { + Stmt::Expr( + Expr::RegisterClassParentDynamic { + class_name: bind_name.clone(), + parent_expr, + }, + ) + }); push_class_dedup(module, lowered_class); + if let Some(reg) = parent_register { + module.init.push(reg); + } ctx.class_expr_aliases.insert( bind_name.clone(), bind_name.clone(), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 4d882e6959..e887d7a56a 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1944,6 +1944,7 @@ fn unresolved_new_names_the_identifier_and_defers_to_a_runtime_global_lookup() { } mod capture_stash; +mod mixin_parent_chain; /// `const masks = opts?.masks ?? null` must not be declared `Null`. The /// AST-level `??` rule used to answer the right operand's type whenever the diff --git a/crates/perry-hir/src/lower/tests/mixin_parent_chain.rs b/crates/perry-hir/src/lower/tests/mixin_parent_chain.rs new file mode 100644 index 0000000000..db09aae397 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/mixin_parent_chain.rs @@ -0,0 +1,74 @@ +//! Dynamic-parent registration for a mixin applied to a mixin (#9079). Split +//! from `tests.rs` for the 2000-line file cap. + +use super::*; + +/// Index of the `RegisterClassParentDynamic` for `class_name` in `init`. +fn register_at(init: &[Stmt], class_name: &str) -> Option { + init.iter().position(|stmt| { + matches!( + stmt, + Stmt::Expr(Expr::RegisterClassParentDynamic { class_name: n, .. }) if n == class_name + ) + }) +} + +/// Index of the value binding (`const M = `) for `name` in `init`. +fn binding_at(init: &[Stmt], name: &str) -> Option { + init.iter() + .position(|stmt| matches!(stmt, Stmt::Let { name: n, .. } if n == name)) +} + +/// #9079: `const Mixed2 = mixin(Mixed)` — a mixin applied to a previous +/// mixin's RESULT — synthesizes a class whose parent is a lexical value +/// binding, so it lowers with `extends_expr` (a dynamic parent). The mixin +/// fast path bound that class without the declaration-time +/// `RegisterClassParentDynamic` its sibling `const X = class …` path emits, so +/// the class had no registered parent at all: `js_fetch_or_value_super` fell +/// back to the most-derived receiver, re-selected the same class, and recursed +/// until the stack overflowed (SIGSEGV, not a wrong value). +/// +/// The registration must sit between the PARENT's binding (it reads that +/// local) and the synthesized class's own binding, exactly where the sibling +/// class-expression path puts it. +#[test] +fn mixin_of_a_mixin_registers_its_dynamic_parent_before_its_own_binding() { + let source = r#" + class Root { r = 1; } + function mixin(Base: any) { return class extends Base { m() { return 1; } }; } + const Mixed = mixin(Root); + const Mixed2 = mixin(Mixed); + class Deep extends Mixed2 { d = 4; constructor() { super(); } } + console.log(new Deep().d); + "#; + let module = perry_parser::parse_typescript(source, "mixin-chain.ts").expect("source parses"); + let hir = super::super::lower_module(&module, "mixin-chain", "mixin-chain.ts") + .expect("source lowers"); + + let mixed_binding = + binding_at(&hir.init, "Mixed").expect("`Mixed` gets a class-expression value binding"); + let mixed2_binding = + binding_at(&hir.init, "Mixed2").expect("`Mixed2` gets a class-expression value binding"); + let mixed2_register = register_at(&hir.init, "Mixed2").unwrap_or_else(|| { + panic!( + "`Mixed2` extends the lexical value `Mixed` and must register that parent \ + at declaration time; module init was:\n{:#?}", + hir.init + ) + }); + + assert!( + mixed_binding < mixed2_register && mixed2_register < mixed2_binding, + "the registration reads the parent local and must precede its own binding: \ + Mixed@{mixed_binding} register@{mixed2_register} Mixed2@{mixed2_binding}" + ); + + // Level 1's parent is the real class `Root`, which resolves statically, so + // no dynamic parent is captured and none is registered. Asserting the + // negative keeps the fix scoped to the case that needs it. + assert!( + register_at(&hir.init, "Mixed").is_none(), + "`Mixed` extends the static class `Root`; it must not gain a dynamic parent:\n{:#?}", + hir.init + ); +} diff --git a/crates/perry-runtime/src/child_process/exec.rs b/crates/perry-runtime/src/child_process/exec.rs index f0f22016ce..adfc1ac7bc 100644 --- a/crates/perry-runtime/src/child_process/exec.rs +++ b/crates/perry-runtime/src/child_process/exec.rs @@ -42,6 +42,7 @@ pub extern "C" fn js_child_process_exec_sync( let cmd_bytes = std::slice::from_raw_parts(data_ptr, len); String::from_utf8_lossy(cmd_bytes).into_owned() }; + validate::cp_validate_no_null_bytes("command", &cmd_str); // Execute the command using the shell, honoring `cwd`/`env` options. #[cfg(unix)] @@ -92,6 +93,8 @@ pub extern "C" fn js_child_process_spawn_sync( let cmd_data = (cmd_ptr as *const u8).add(std::mem::size_of::()); String::from_utf8_lossy(std::slice::from_raw_parts(cmd_data, cmd_len)).into_owned() }; + validate::cp_validate_no_null_bytes("file", &cmd_str); + unsafe { validate::cp_validate_raw_args(args_ptr as i64) }; 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); @@ -258,6 +261,7 @@ pub extern "C" fn js_child_process_exec(cmd_ptr: *const StringHeader, arg1: f64, let cmd_bytes = std::slice::from_raw_parts(data_ptr, len); String::from_utf8_lossy(cmd_bytes).into_owned() }; + validate::cp_validate_no_null_bytes("command", &cmd_str); if abort_signal.is_some_and(cp_abort_signal_is_aborted) { let stdout_box = cp_box_output(b"", &mode); @@ -338,6 +342,8 @@ pub extern "C" fn js_child_process_exec_file( }; let file_str = unsafe { cp_read_string_header(file_ptr) }; + validate::cp_validate_no_null_bytes("file", &file_str); + validate::cp_validate_args(args_val); let arg_strs = cp_args_from_value(args_val); // execFile defaults to utf8 (callback stdout/stderr are strings). let mode = cp_read_output_mode(opts_val, true); @@ -394,6 +400,8 @@ pub extern "C" fn js_child_process_exec_file_sync( opts_val: f64, ) -> f64 { let file_str = unsafe { cp_read_string_header(file_ptr) }; + validate::cp_validate_no_null_bytes("file", &file_str); + validate::cp_validate_args(args_val); let mode = cp_read_output_mode(opts_val, false); if file_str.is_empty() { return cp_box_output(b"", &mode); @@ -483,6 +491,7 @@ fn cp_promisified_run(command: Command, cmd_str: String, opts: f64) -> f64 { } extern "C" fn cp_promisified_exec(_closure: *const ClosureHeader, cmd_val: f64, opts: f64) -> f64 { + validate::cp_validate_command(cmd_val, "command"); let cmd = cp_value_to_string(cmd_val).unwrap_or_default(); #[cfg(unix)] let mut command = { @@ -508,6 +517,8 @@ extern "C" fn cp_promisified_exec_file( file_val: f64, args_val: f64, ) -> f64 { + validate::cp_validate_command(file_val, "file"); + validate::cp_validate_args(args_val); let file = cp_value_to_string(file_val).unwrap_or_default(); let arg_strs = cp_args_from_value(args_val); // The 2-arg promisify(execFile) wrapper has no options slot; resolve a bare diff --git a/crates/perry-runtime/src/child_process/options.rs b/crates/perry-runtime/src/child_process/options.rs index 41fe9cf91a..2fd591a54b 100644 --- a/crates/perry-runtime/src/child_process/options.rs +++ b/crates/perry-runtime/src/child_process/options.rs @@ -52,6 +52,7 @@ pub(crate) fn cp_apply_options(command: &mut Command, opts_val: f64) { } if let Some(dir) = cp_value_to_string(cp_get_field(opts_val, b"cwd")) { + validate::cp_validate_path_no_null_bytes("options.cwd", &dir); if !dir.is_empty() { command.current_dir(dir); } @@ -72,7 +73,11 @@ pub(crate) fn cp_apply_options(command: &mut Command, opts_val: f64) { if JSValue::from_bits(v.to_bits()).is_undefined() { continue; // Node omits keys whose value is `undefined`. } - command.env(&key, cp_coerce_string(v)); + let property = format!("options.env['{key}']"); + validate::cp_validate_no_null_bytes(&property, &key); + let value = cp_coerce_string(v); + validate::cp_validate_no_null_bytes(&property, &value); + command.env(&key, value); } } } @@ -82,7 +87,9 @@ pub(crate) fn cp_apply_options(command: &mut Command, opts_val: f64) { pub(crate) fn cp_read_argv0(opts_val: f64) -> Option { cp_object_ptr(opts_val)?; - cp_value_to_string(cp_get_field(opts_val, b"argv0")) + let argv0 = cp_value_to_string(cp_get_field(opts_val, b"argv0"))?; + validate::cp_validate_no_null_bytes("options.argv0", &argv0); + Some(argv0) } pub(crate) fn cp_read_abort_signal(opts_val: f64) -> Option { @@ -513,7 +520,10 @@ pub(crate) fn cp_build_command(cmd: &str, args: &[String], opts_val: f64) -> Com let mut command = if crate::value::js_is_truthy(shell) != 0 { // `shell: ""` picks the binary; `shell: true` uses the default. let shell_bin = match cp_value_to_string(shell) { - Some(s) if !s.is_empty() => s, + Some(s) if !s.is_empty() => { + validate::cp_validate_no_null_bytes("options.shell", &s); + s + } _ => cp_default_shell(), }; let mut line = program.clone(); diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index 9d54b0302c..65cda0298e 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -1041,6 +1041,8 @@ pub extern "C" fn js_child_process_spawn_streams( cp_read_arg_strings(args_ptr), ) }; + validate::cp_validate_no_null_bytes("file", &cmd_str); + unsafe { validate::cp_validate_raw_args(args_ptr) }; // `opts_ptr` arrives as a raw (unboxed) heap pointer; re-box it so the // options helpers can read `cwd`/`env`/`shell`. Small values mean diff --git a/crates/perry-runtime/src/child_process/validate.rs b/crates/perry-runtime/src/child_process/validate.rs index d1314fb3f5..91d059fe5e 100644 --- a/crates/perry-runtime/src/child_process/validate.rs +++ b/crates/perry-runtime/src/child_process/validate.rs @@ -13,15 +13,82 @@ use crate::value::JSValue; use super::{ - cp_array_ptr, cp_get_field, cp_object_ptr, cp_signal_is_valid, cp_stdio_stream_fd, + cp_array_ptr, cp_box_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", - ); +fn cp_inspect_received_string(received: &str) -> String { + // Node's ERR_INVALID_ARG_VALUE uses util.inspect's string quoting: prefer + // a delimiter absent from the value, and escape controls/backslashes. + let quote = if !received.contains('\'') { + '\'' + } else if !received.contains('"') { + '"' + } else if !received.contains('`') && !received.contains("${") { + '`' + } else { + '\'' + }; + let mut display = String::with_capacity(received.len() + 2); + display.push(quote); + for ch in received.chars() { + match ch { + '\0' => display.push_str("\\x00"), + '\x08' => display.push_str("\\b"), + '\x0c' => display.push_str("\\f"), + '\n' => display.push_str("\\n"), + '\r' => display.push_str("\\r"), + '\t' => display.push_str("\\t"), + '\x0b' => display.push_str("\\v"), + '\\' => display.push_str("\\\\"), + ch if ch == quote => { + display.push('\\'); + display.push(ch); + } + ch if ch.is_ascii_control() => { + use std::fmt::Write; + let _ = write!(display, "\\x{:02x}", ch as u32); + } + ch => display.push(ch), + } + } + display.push(quote); + display +} + +fn cp_throw_null_bytes(name: &str, received: &str, path_like: bool) -> ! { + let subject = if name.starts_with("options.") { + "property" + } else { + "argument" + }; + let expected = if path_like { + "a string, Uint8Array, or URL without null bytes" + } else { + "a string without null bytes" + }; + let display = cp_inspect_received_string(received); + let message = format!("The {subject} '{name}' must be {expected}. Received {display}"); + crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_VALUE"); +} + +/// Reject a string before it crosses into `std::process::Command`. This lives +/// in the runtime, rather than only in the direct-import lowering, so CJS +/// namespace dispatch and future call paths cannot bypass the check. Node's +/// `ERR_INVALID_ARG_VALUE` distinguishes top-level arguments from `options.*` +/// properties in the message. +pub(super) fn cp_validate_no_null_bytes(name: &str, received: &str) { + if received.contains('\0') { + cp_throw_null_bytes(name, received, false); + } +} + +/// `options.cwd` is PathLike, so Node's expected-type clause also names +/// Uint8Array and URL even when the received value is a string. +pub(super) fn cp_validate_path_no_null_bytes(name: &str, received: &str) { + if received.contains('\0') { + cp_throw_null_bytes(name, received, true); + } } /// Validate a `command` / `file` argument. `value` is the original NaN-boxed @@ -31,8 +98,8 @@ fn cp_throw_null_bytes() -> ! { /// message when `value` is not a string. A no-op for any string. 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(); + if let Some(received) = cp_value_to_string(value) { + cp_validate_no_null_bytes(name, &received); } return; } @@ -48,7 +115,7 @@ pub(crate) fn cp_validate_command(value: f64, name: &str) { /// (arrays and plain objects), but throws `TypeError [ERR_INVALID_ARG_TYPE]` /// with `The "args" argument must be of type object. Received …` for a /// primitive such as a string, number, boolean, bigint, or symbol. -fn cp_validate_args(value: f64) { +pub(super) fn cp_validate_args(value: f64) { let jv = JSValue::from_bits(value.to_bits()); // Node's `normalizeSpawnArguments` / `normalizeExecFileArgs` reject the // args slot only when it is a non-nullish *primitive* (string, number, @@ -67,8 +134,8 @@ fn cp_validate_args(value: f64) { 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(); + if let Some(received) = cp_value_to_string(value) { + cp_validate_no_null_bytes(&format!("args[{i}]"), &received); } } } @@ -81,6 +148,21 @@ fn cp_validate_args(value: f64) { crate::fs::validate::throw_type_error_with_code(&message, "ERR_INVALID_ARG_TYPE"); } +/// Validate a raw args slot after codegen/native-module dispatch has stripped +/// its NaN-box tag. Only real heap pointers can name an args array; nullish and +/// options-overload sentinels are intentionally ignored. Re-boxing lets the +/// shared validator preserve the original array index in its error message. +/// +/// # Safety +/// +/// A non-sentinel `args_ptr` must be a runtime-managed heap pointer. +pub(super) unsafe fn cp_validate_raw_args(args_ptr: i64) { + if args_ptr <= 0 || !crate::value::addr_class::is_above_handle_band(args_ptr as usize) { + return; + } + cp_validate_args(cp_box_ptr(args_ptr as *const u8)); +} + fn cp_throw_option_type(name: &str, value: f64) -> ! { let message = format!( "The \"options.{name}\" property must be of type {}. Received {}", diff --git a/crates/perry/tests/versioned_indexed_loop_callback_deopt.rs b/crates/perry/tests/versioned_indexed_loop_callback_deopt.rs index 647ca7f7d2..971748e914 100644 --- a/crates/perry/tests/versioned_indexed_loop_callback_deopt.rs +++ b/crates/perry/tests/versioned_indexed_loop_callback_deopt.rs @@ -41,15 +41,40 @@ fn workspace_root() -> PathBuf { .expect("canonicalize workspace root") } +/// Perry ships a `panic=abort` runtime. A debug `panic=unwind` archive plants +/// abort-on-unwind guards in `extern "C"` helpers, so the raw JS exceptions in +/// this fixture cannot reach their generated catch landing pads on Linux. +/// Relative Cargo target overrides are rooted at the nested build's workspace. +fn target_runtime_dir() -> PathBuf { + let target = match std::env::var_os("CARGO_TARGET_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + { + Some(path) if path.is_absolute() => path, + Some(path) => workspace_root().join(path), + None => workspace_root().join("target"), + }; + if cfg!(windows) { + target.join("x86_64-pc-windows-msvc").join("release") + } else { + target.join("release") + } +} + fn runtime_dir() -> PathBuf { static BUILD_RUNTIME: Once = Once::new(); BUILD_RUNTIME.call_once(|| { let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); let mut command = Command::new(cargo); - command.current_dir(workspace_root()).arg("build"); + command + .current_dir(workspace_root()) + .arg("build") + // `panic` is profile-level; this must match `target_runtime_dir()` + // and the runtime Perry actually ships. + .arg("--release"); remove_gc_env_overrides(&mut command); - if !cfg!(debug_assertions) { - command.arg("--release"); + if cfg!(windows) { + command.arg("--target").arg("x86_64-pc-windows-msvc"); } let build = command .args(["-p", "perry-runtime-static"]) @@ -63,10 +88,7 @@ fn runtime_dir() -> PathBuf { ); }); - perry_bin() - .parent() - .expect("Perry binary directory") - .to_path_buf() + target_runtime_dir() } fn run_fixture(binary: &Path, force_evacuation: bool) -> Output { @@ -93,12 +115,6 @@ fn llvm_function_body(ir: &str, symbol: &str) -> String { } #[test] -// #9482: aborts on Linux with `panic in a function that cannot unwind` inside -// the force_evacuation=false GC fixture — consistent there, never observed -// green; passes 3/3 on macOS at the same pin. Deferred to unblock releases, -// NOT shown pre-existing; the diagnosis and Linux repro live in #9482, and -// re-enabling is deleting this attribute. -#[cfg_attr(target_os = "linux", ignore = "#9482: Linux-only GC deopt abort")] fn cold_callback_arms_resume_once_at_the_next_index() { let dir = tempfile::tempdir().expect("tempdir"); let entry = dir.path().join("main.ts"); diff --git a/test-files/test_gap_9079_dynamic_parent_chain_own_ctor.ts b/test-files/test_gap_9079_dynamic_parent_chain_own_ctor.ts new file mode 100644 index 0000000000..8bd9e11444 --- /dev/null +++ b/test-files/test_gap_9079_dynamic_parent_chain_own_ctor.ts @@ -0,0 +1,65 @@ +// Issue #9079: two levels of dynamic parent — a mixin applied to a mixin — +// SIGSEGVed when the leaf class had its own constructor. +// +// `const Mixed2 = mixin(Mixed)` extends a lexical VALUE binding, so the class +// the HIR mixin fast path synthesizes carries a DYNAMIC parent (extends_expr). +// That path bound the class without emitting the declaration-time +// `RegisterClassParentDynamic` its sibling `const X = class …` path emits, so +// the synthesized class had no registered parent: `js_fetch_or_value_super` +// fell back to the most-derived receiver, re-selected the same class, and +// recursed until the stack overflowed. One level was already correct (#9073), +// which is why the failure looked arbitrary. + +// --- the exact reproducer from the issue ------------------------------------ +class Root { r = 1; } +function mixin(Base: any) { return class extends Base { m() { return 1; } }; } +const Mixed = mixin(Root); +const Mixed2 = mixin(Mixed); +class Deep extends Mixed2 { d = 4; constructor() { super(); } } +console.log(new Deep().d); + +// --- the chain is WALKED, not merely survived ------------------------------- +// Root's field initializer must have run, and the mixin method must be +// reachable through both synthesized levels. +const deep: any = new Deep(); +console.log("state:", deep.r, deep.d, "method:", deep.m()); +console.log( + "instanceof:", + deep instanceof Deep, + deep instanceof Mixed2, + deep instanceof Mixed, + deep instanceof Root, +); + +// The issue left open whether the leaf's OWN constructor was required. It is +// not the only shape that must work: an implicit constructor over the same +// two-level chain has to reach Root too. +class DeepImplicit extends Mixed2 { d2 = 5; } +const implicit: any = new DeepImplicit(); +console.log("implicit:", implicit.r, implicit.d2, implicit.m()); + +// One level still works — it did before this fix; keep it pinned so the new +// registration cannot regress the shape #9073 fixed. +class One extends Mixed { x = 2; constructor() { super(); } } +const one: any = new One(); +console.log("one-level:", one.r, one.x, one.m(), one instanceof Mixed, one instanceof Root); + +// --- every level of a longer chain contributes ------------------------------ +// Distinct mixins so a missing level is visible as a missing METHOD rather +// than being masked by an identical body at each level. +function withA(Base: any) { return class extends Base { a() { return "a"; } }; } +function withB(Base: any) { return class extends Base { b() { return "b"; } }; } +function withC(Base: any) { return class extends Base { c() { return "c"; } }; } +const A = withA(Root); +const B = withB(A); +const C = withC(B); +class Leaf extends C { n = 9; constructor() { super(); } } +const leaf: any = new Leaf(); +console.log("three-level:", leaf.r, leaf.n, leaf.a(), leaf.b(), leaf.c()); +console.log( + "three-level instanceof:", + leaf instanceof C, + leaf instanceof B, + leaf instanceof A, + leaf instanceof Root, +); diff --git a/test-files/test_gap_9537_child_process_null_bytes.ts b/test-files/test_gap_9537_child_process_null_bytes.ts new file mode 100644 index 0000000000..910b02983f --- /dev/null +++ b/test-files/test_gap_9537_child_process_null_bytes.ts @@ -0,0 +1,55 @@ +// #9537 — every child_process spelling must reject embedded null bytes +// synchronously, before a command reaches the OS. The direct-import lowering +// already called setup validators, but CJS-default namespace dispatch skipped +// them and surfaced an asynchronous `UNKNOWN` spawn error instead. +// +// Byte-for-byte vs `node --experimental-strip-types`. +import * as direct from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const cjs: any = require("child_process"); + +function probe(label: string, run: () => any): void { + try { + const value = run(); + // Keep the broken async-spawn path from turning the fixture's intended + // mismatch into an unhandled `error` event on a pre-fix binary. + if (value && typeof value.on === "function") value.on("error", () => {}); + console.log(label, "NO_THROW"); + } catch (e: any) { + console.log(label, "|", e.name, "|", e.code, "|", e.message); + } +} + +// Existing direct-import path: pin the corrected Node message, including the +// argument name, original array index, escaped received value, class and code. +probe("direct spawn file", () => direct.spawn("/bin/tr\x00ue", [])); +probe("direct spawn args[1]", () => direct.spawn("/bin/true", ["ok", "a\x00b"])); +probe("direct spawn escaped received", () => + direct.spawn("/bin/true", ["a'\\\n\x00b"])); +probe("direct exec command", () => direct.exec("echo\x00x", () => {})); +probe("direct execFileSync args[1]", () => + direct.execFileSync("/bin/true", ["ok", "a\x00b"])); + +// Regression path: fused method calls on require("child_process") route +// through the native-module dispatcher and then the runtime entry points. +probe("cjs spawn file", () => cjs.spawn("/bin/tr\x00ue", [])); +probe("cjs spawn args[1]", () => cjs.spawn("/bin/true", ["ok", "a\x00b"])); +probe("cjs spawnSync args[1]", () => cjs.spawnSync("/bin/true", ["ok", "a\x00b"])); +probe("cjs exec command", () => cjs.exec("echo\x00x", () => {})); +probe("cjs execSync command", () => cjs.execSync("echo\x00x")); +probe("cjs execFile args[1]", () => + cjs.execFile("/bin/true", ["ok", "a\x00b"], () => {})); +probe("cjs execFileSync file", () => cjs.execFileSync("/bin/tr\x00ue", [])); + +// OS-facing option strings use Node's `property` wording. `cwd` is PathLike, +// so its expected-type clause additionally names Uint8Array and URL. +probe("cjs cwd", () => cjs.spawn("/bin/true", [], { cwd: "/tmp/a\x00b" })); +probe("cjs argv0", () => cjs.spawn("/bin/true", [], { argv0: "a\x00b" })); +probe("cjs shell", () => cjs.spawn("true", [], { shell: "/bin/s\x00h" })); +probe("cjs env value", () => cjs.spawn("/bin/true", [], { env: { A: "a\x00b" } })); +probe("cjs env undefined key", () => + cjs.spawn("/bin/true", [], { env: { ["A\x00B"]: undefined } })); + +console.log("done");