From 755fcdbc663f0b2d474ce546bb6c7c6514c4cc72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 18:15:04 +0200 Subject: [PATCH 1/2] fix(codegen): root child_process command/file across their optional operands Closes the last real sites of this shape in perry-codegen. exec/execFile/execFileSync each unboxed the command or file to a raw StringHeader pointer and then lowered options/args/callback -- arbitrary user code -- before the runtime call used it. The intermediate operands have the same problem: args_v crosses the options lowering, opts_v crosses the callback lowering. Each operand is now rooted as it is produced and reloaded after the last lowering. One truncate per arm releases them all: js_gc_temp_root_truncate is s.truncate(base), so truncating to the outermost slot drops every slot pushed after it -- verified in the runtime rather than assumed, since "8 pushes / 3 truncates" reads like a leak otherwise. Validate ordering is preserved: emit_cp_validate_command still runs on the freshly lowered value, before the root is taken. An arm-aware rescan of the whole crate now reports ONE remaining site, in native_ui_widgets_branch.rs, and it is not a bug: perry/ui widgets are 1-based registry handles below the handle band, which the collector never moves. The earlier scan also flagged i32_fast_path.rs, which was an artifact of the scan treating a free function's body as a match arm. 5/5 child_process gap tests byte-identical to node; an execFileSync repro with an allocating argument matches node and is clean under PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1. --- crates/perry-codegen/src/expr/child_proc.rs | 83 ++++++++++++++++----- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/crates/perry-codegen/src/expr/child_proc.rs b/crates/perry-codegen/src/expr/child_proc.rs index 11ac3b3f55..e9e50f7180 100644 --- a/crates/perry-codegen/src/expr/child_proc.rs +++ b/crates/perry-codegen/src/expr/child_proc.rs @@ -268,22 +268,37 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let cmd_box = lower_expr(ctx, command)?; // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string command. emit_cp_validate_command(ctx, &cmd_box, "command"); - let cmd_str = unbox_to_i64(ctx.block(), &cmd_box); - let arg1 = if let Some(o) = options { - lower_expr(ctx, o)? + // `cmd_box` is a heap string and the `options`/`callback` + // lowerings below run arbitrary user code, so the raw pointer must + // not be taken until after them. Root the validated command, lower + // the rest, then unbox from the reload. `arg1` needs the same + // treatment: it is a NaN-boxed heap value crossing the `callback` + // lowering. Truncating to `cmd_slot` drops both (temp roots are a + // stack), so one release covers them. + let cmd_slot = super::temp_root::temp_root_push_double(ctx, &cmd_box); + let arg1_slot = if let Some(o) = options { + let v = lower_expr(ctx, o)?; + Some(super::temp_root::temp_root_push_double(ctx, &v)) } else { - undef.clone() + None }; let arg2 = if let Some(cb) = callback { lower_expr(ctx, cb)? } else { undef.clone() }; + let arg1 = match &arg1_slot { + Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), + None => undef.clone(), + }; + let cmd_box = super::temp_root::temp_root_get_double(ctx, &cmd_slot); + let cmd_str = unbox_to_i64(ctx.block(), &cmd_box); let result = ctx.block().call( DOUBLE, "js_child_process_exec", &[(I64, &cmd_str), (DOUBLE, &arg1), (DOUBLE, &arg2)], ); + super::temp_root::temp_root_truncate(ctx, &cmd_slot); Ok(result) } @@ -302,24 +317,39 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let file_box = lower_expr(ctx, file)?; // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string file. emit_cp_validate_command(ctx, &file_box, "file"); - let file_str = unbox_to_i64(ctx.block(), &file_box); - let args_v = if let Some(a) = args { + // Same window as the `exec` arm above: `file_box` is a heap string + // and every optional operand below lowers arbitrary user code. Root + // each as it is produced, lower the rest, then reload. One truncate + // to `file_slot` releases them all -- temp roots are a stack. + let file_slot = super::temp_root::temp_root_push_double(ctx, &file_box); + let args_slot = if let Some(a) = args { let v = lower_expr(ctx, a)?; emit_cp_validate_args(ctx, &v); - v + Some(super::temp_root::temp_root_push_double(ctx, &v)) } else { - undef.clone() + None }; - let opts_v = if let Some(o) = options { - lower_expr(ctx, o)? + let opts_slot = if let Some(o) = options { + let v = lower_expr(ctx, o)?; + Some(super::temp_root::temp_root_push_double(ctx, &v)) } else { - undef.clone() + None }; let cb_v = if let Some(c) = callback { lower_expr(ctx, c)? } else { undef.clone() }; + let args_v = match &args_slot { + Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), + None => undef.clone(), + }; + let opts_v = match &opts_slot { + Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), + None => undef.clone(), + }; + let file_box = super::temp_root::temp_root_get_double(ctx, &file_slot); + let file_str = unbox_to_i64(ctx.block(), &file_box); let result = ctx.block().call( DOUBLE, "js_child_process_exec_file", @@ -330,6 +360,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &cb_v), ], ); + super::temp_root::temp_root_truncate(ctx, &file_slot); Ok(result) } @@ -346,24 +377,40 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let file_box = lower_expr(ctx, file)?; // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string file. emit_cp_validate_command(ctx, &file_box, "file"); - let file_str = unbox_to_i64(ctx.block(), &file_box); - let args_v = if let Some(a) = args { + // Same window as the `exec` arm above: `file_box` is a heap string + // and every optional operand below lowers arbitrary user code. Root + // each as it is produced, lower the rest, then reload. One truncate + // to `file_slot` releases them all -- temp roots are a stack. + let file_slot = super::temp_root::temp_root_push_double(ctx, &file_box); + let args_slot = if let Some(a) = args { let v = lower_expr(ctx, a)?; emit_cp_validate_args(ctx, &v); - v + Some(super::temp_root::temp_root_push_double(ctx, &v)) } else { - undef.clone() + None }; - let opts_v = if let Some(o) = options { - lower_expr(ctx, o)? + let opts_slot = if let Some(o) = options { + let v = lower_expr(ctx, o)?; + Some(super::temp_root::temp_root_push_double(ctx, &v)) } else { - undef.clone() + None + }; + let args_v = match &args_slot { + Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), + None => undef.clone(), }; + let opts_v = match &opts_slot { + Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), + None => undef.clone(), + }; + let file_box = super::temp_root::temp_root_get_double(ctx, &file_slot); + let file_str = unbox_to_i64(ctx.block(), &file_box); let result = ctx.block().call( DOUBLE, "js_child_process_exec_file_sync", &[(I64, &file_str), (DOUBLE, &args_v), (DOUBLE, &opts_v)], ); + super::temp_root::temp_root_truncate(ctx, &file_slot); Ok(result) } From 1fbff50eb11658b3e0820a1e5a34e31d7aff91e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 18:15:20 +0200 Subject: [PATCH 2/2] docs: changelog fragment for 7465 --- changelog.d/7465-child-proc-rooting.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7465-child-proc-rooting.md diff --git a/changelog.d/7465-child-proc-rooting.md b/changelog.d/7465-child-proc-rooting.md new file mode 100644 index 0000000000..d209dc72d0 --- /dev/null +++ b/changelog.d/7465-child-proc-rooting.md @@ -0,0 +1 @@ +- **`child_process.exec` / `execFile` / `execFileSync` no longer hold the command across their optional operands.** Each unboxed the command or file to a raw `StringHeader` pointer and then lowered `options`/`args`/`callback` — arbitrary user code — before the runtime call used it; the intermediate operands had the same problem (`args_v` crossing the `options` lowering, `opts_v` crossing `callback`'s). Every operand is now rooted as produced and reloaded after the last lowering, with one truncate per arm — `js_gc_temp_root_truncate` is `s.truncate(base)`, so truncating to the outermost slot releases all of them. With this, an arm-aware scan of `perry-codegen` finds **no remaining** raw-pointer-across-lowering sites: the single hit left is a `perry/ui` widget handle, a registry id below the handle band that the collector never moves. 5/5 `child_process` gap tests byte-identical to node; clean under forced evacuation. (#7465)