Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9566-linux-callback-deopt-runtime.md
Original file line number Diff line number Diff line change
@@ -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).
33 changes: 33 additions & 0 deletions changelog.d/9567-mixin-of-a-mixin-dynamic-parent.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions changelog.d/9568-child-process-null-bytes.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions crates/perry-hir/src/lower/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-hir/src/lower/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions crates/perry-hir/src/lower/tests/mixin_parent_chain.rs
Original file line number Diff line number Diff line change
@@ -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<usize> {
init.iter().position(|stmt| {
matches!(
stmt,
Stmt::Expr(Expr::RegisterClassParentDynamic { class_name: n, .. }) if n == class_name
)
})
}

/// Index of the value binding (`const M = <class>`) for `name` in `init`.
fn binding_at(init: &[Stmt], name: &str) -> Option<usize> {
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
);
}
11 changes: 11 additions & 0 deletions crates/perry-runtime/src/child_process/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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::<StringHeader>());
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand Down
16 changes: 13 additions & 3 deletions crates/perry-runtime/src/child_process/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
}
}
}
Expand All @@ -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<String> {
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<f64> {
Expand Down Expand Up @@ -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: "<path>"` 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();
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/child_process/reactor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading