From 646efdb6f59585cf41c70c4b4d9ea6a9deab3cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 02:45:09 +0200 Subject: [PATCH 1/3] refactor(codegen): migrate expr/url_main.rs onto the Layer 1 rooting API (#7615) Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-codegen/src/expr/url_main.rs | 394 ++++++++++-------- crates/perry-codegen/src/rooting.rs | 323 ++++++++++++-- docs/engine-plan.md | 18 +- .../internals/rfc-rooting-by-construction.md | 68 ++- 4 files changed, 592 insertions(+), 211 deletions(-) diff --git a/crates/perry-codegen/src/expr/url_main.rs b/crates/perry-codegen/src/expr/url_main.rs index 94b9a9fdae..921f1285e6 100644 --- a/crates/perry-codegen/src/expr/url_main.rs +++ b/crates/perry-codegen/src/expr/url_main.rs @@ -1,13 +1,35 @@ //! URL / URLSearchParams + FsRmRecursive. //! //! Extracted from `expr/mod.rs` to keep that file under the 2000-line cap. -//! Pure mechanical move — match arm bodies are verbatim copies, called from -//! `lower_expr`'s outer dispatch. +//! +//! # Layer 1 reference module (#7459 / #7461) +//! +//! This is the first module migrated end to end onto the rooting-by- +//! construction API, and it is the template the remaining slices copy. Two +//! rules, both checkable: +//! +//! 1. **Nothing in here names `expr::temp_root`.** The raw push/get/set/ +//! truncate API is the escape hatch; every bug in the #7341 family was an +//! ordering mistake against it. `crate::rooting::migration_ledger` fails the +//! build if this module reaches back into it. +//! 2. **No raw heap pointer exists as a value the lowering can hold across a +//! collection point.** [`crate::rooting::call_rooted`] returns a slot rather +//! than a register, and [`crate::rooting::call_with_roots`] re-reads each +//! slot as part of emitting the consuming call — so "load early, use late" +//! is not a sequence expressible here. +//! +//! What the migration found, which is the argument for an API over a checklist: +//! `URL.canParse(input, base)` and `URL.parse(input, base)` still carried +//! #7453's exact window — a `*mut StringHeader` from `js_url_coerce_string` +//! held in an SSA register across the lowering of `base` and across a second +//! coercion that allocates. #7453 fixed `new URL(input, base)` and #7461 +//! migrated it; these two are the same three lines and nobody looked at them. use anyhow::Result; use perry_hir::Expr; use crate::nanbox::double_literal; +use crate::rooting::{self, Arg}; use crate::types::{DOUBLE, I1, I32, I64}; use super::{ @@ -35,47 +57,52 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // string-pointer extraction, which dropped non-string values to a // null/garbage pointer. let url_v = lower_expr(ctx, url)?; - let url_ptr = ctx - .block() - .call(I64, "js_url_coerce_string", &[(DOUBLE, &url_v)]); let obj = if let Some(base) = base { // `js_url_coerce_string` returns a RAW `StringHeader` pointer, // not a NaN-boxed value, so nothing else keeps it alive. Two // collection points then stand between it and its use: // lowering `base` runs arbitrary user code, and the second // coercion allocates whenever `base` is not already a string. - // An evacuating cycle in either window leaves `url_ptr` - // pointing at a forwarded object and - // `js_url_new_with_base` parses freed bytes. + // An evacuating cycle in either window leaves the coerced input + // pointing at a forwarded object and `js_url_new_with_base` + // parses freed bytes (#7453). // - // Root before the first collection point and re-read after the - // last, per `docs/src/internals/gc-rooting-invariant.md` — the - // ordering is the whole fix; adding the root after the coercion - // would root an already-stale pointer. - let url_slot = super::temp_root::temp_root_push_i64(ctx, &url_ptr); + // Neither pointer is ever a value this lowering holds: + // `call_rooted` emits the coercion and its root store as one + // step, and `call_with_roots` re-reads both slots as part of + // emitting the consuming call. The ordering that #7453 got + // wrong — root store after the collection point — has no + // spelling here. + let url_slot = rooting::call_rooted( + ctx, + I64, + "js_url_coerce_string", + &[Arg::Plain(DOUBLE, &url_v)], + ); let base_v = lower_expr(ctx, base)?; - // Layer 1 migration (#7459): `call_rooted` emits the collecting - // call and roots its result in one step, so no unrooted - // register for `base_ptr` ever exists to be held across a later - // collection point. The window that made #7453 a bug is not - // expressible here. - let base_slot = crate::rooting::call_rooted( + let base_slot = rooting::call_rooted( ctx, I64, "js_url_coerce_string", - &[(DOUBLE, &base_v)], + &[Arg::Plain(DOUBLE, &base_v)], ); - let url_ptr = super::temp_root::temp_root_get_i64(ctx, &url_slot); - let base_ptr = base_slot.read(ctx); - let obj = ctx.block().call( + let obj = rooting::call_with_roots( + ctx, I64, "js_url_new_with_base", - &[(I64, &url_ptr), (I64, &base_ptr)], + &[Arg::Root(&url_slot), Arg::Root(&base_slot)], ); + // Reverse acquisition order: a release is a stack cut, so + // releasing `url_slot` first would drop `base_slot` with it. base_slot.release(ctx); - super::temp_root::temp_root_truncate(ctx, &url_slot); + url_slot.release(ctx); obj } else { + // No window: the coerced pointer is consumed by the very next + // emission, as an argument to it. + let url_ptr = ctx + .block() + .call(I64, "js_url_coerce_string", &[(DOUBLE, &url_v)]); ctx.block().call(I64, "js_url_new", &[(I64, &url_ptr)]) }; Ok(nanbox_pointer_inline(ctx.block(), &obj)) @@ -84,27 +111,29 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::UrlPatternNew { input, base } => { // Same window as `UrlNew` above: `input_v` is a NaN-boxed heap // value held in a register while `base` lowers, which can run user - // code and collect. `lower_exprs_rooted` protects each operand - // whose later siblings may trigger GC and hands back reloaded - // values, so nothing crosses the window in a bare register. - let (input_v, base_v, operand_guard) = if let Some(base) = base { - let (vals, guard) = super::temp_root::lower_exprs_rooted(ctx, &[input, base])?; - (vals[0].clone(), vals[1].clone(), guard) - } else { - let input_v = lower_expr(ctx, input)?; - ( - input_v, - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), - None, - ) + // code and collect. `with_operands_rooted` protects each operand + // whose later siblings may trigger GC, hands back reloaded values, + // and owns the release — so nothing crosses the window in a bare + // register and no path leaves the group pushed. + let obj = match base { + Some(base) => rooting::with_operands_rooted(ctx, &[input, base], |ctx, vals| { + Ok(ctx.block().call( + I64, + "js_url_pattern_new", + &[(DOUBLE, &vals[0]), (DOUBLE, &vals[1])], + )) + })?, + None => { + // One operand, so there is no window to protect. + let input_v = lower_expr(ctx, input)?; + let base_v = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.block().call( + I64, + "js_url_pattern_new", + &[(DOUBLE, &input_v), (DOUBLE, &base_v)], + ) + } }; - let obj = ctx.block().call( - I64, - "js_url_pattern_new", - &[(DOUBLE, &input_v), (DOUBLE, &base_v)], - ); - // Released only after the last use of both operands. - super::temp_root::temp_root_release(ctx, operand_guard); Ok(nanbox_pointer_inline(ctx.block(), &obj)) } @@ -162,14 +191,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // covers all nine setters at once: `url_handle` is a raw heap // pointer and lowering `value` runs arbitrary user code that can // collect. Root both, unbox from the reloaded receiver. - let (vals, operand_guard) = super::temp_root::lower_exprs_rooted(ctx, &[url, value])?; - let (url_v, val_v) = (vals[0].clone(), vals[1].clone()); - let url_handle = unbox_to_i64(ctx.block(), &url_v); - ctx.block() - .call_void(runtime_fn, &[(I64, &url_handle), (DOUBLE, &val_v)]); - super::temp_root::temp_root_release(ctx, operand_guard); - // Assignment expression evaluates to the value on the RHS. - Ok(val_v) + rooting::with_operands_rooted(ctx, &[url, value], |ctx, vals| { + let (url_v, val_v) = (vals[0].clone(), vals[1].clone()); + let url_handle = unbox_to_i64(ctx.block(), &url_v); + ctx.block() + .call_void(runtime_fn, &[(I64, &url_handle), (DOUBLE, &val_v)]); + // Assignment expression evaluates to the value on the RHS. + Ok(val_v) + }) } // Issue #650: URL.canParse(s) -> boolean. Runtime returns 1/0 as i32; @@ -197,19 +226,35 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::UrlCanParseWithBase { input, base } => { // #3054: coerce input + base via `String(value)` (Symbols throw). + // + // #7453's window, still open here when this module was migrated. + // `js_url_coerce_string` hands back a raw `*mut StringHeader`; + // lowering `base` runs arbitrary user code, and the second coercion + // allocates whenever `base` is not already a string. #7453 fixed + // `new URL(input, base)` and left the two static forms — which are + // the same three lines — untouched. let input_v = lower_expr(ctx, input)?; - let input_ptr = ctx - .block() - .call(I64, "js_url_coerce_string", &[(DOUBLE, &input_v)]); + let input_slot = rooting::call_rooted( + ctx, + I64, + "js_url_coerce_string", + &[Arg::Plain(DOUBLE, &input_v)], + ); let base_v = lower_expr(ctx, base)?; - let base_ptr = ctx - .block() - .call(I64, "js_url_coerce_string", &[(DOUBLE, &base_v)]); - let result_i32 = ctx.block().call( + let base_slot = rooting::call_rooted( + ctx, + I64, + "js_url_coerce_string", + &[Arg::Plain(DOUBLE, &base_v)], + ); + let result_i32 = rooting::call_with_roots( + ctx, I32, "js_url_can_parse_with_base", - &[(I64, &input_ptr), (I64, &base_ptr)], + &[Arg::Root(&input_slot), Arg::Root(&base_slot)], ); + base_slot.release(ctx); + input_slot.release(ctx); let blk = ctx.block(); let is_true = blk.icmp_ne(I32, &result_i32, "0"); let tagged = blk.select( @@ -244,19 +289,31 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::UrlParseWithBase { input, base } => { // #3054: coerce input + base via `String(value)` (Symbols throw). + // + // Identical to `UrlCanParseWithBase` above, and identically stale + // before this migration: #7453's window, in the other static form. let input_v = lower_expr(ctx, input)?; - let input_ptr = ctx - .block() - .call(I64, "js_url_coerce_string", &[(DOUBLE, &input_v)]); + let input_slot = rooting::call_rooted( + ctx, + I64, + "js_url_coerce_string", + &[Arg::Plain(DOUBLE, &input_v)], + ); let base_v = lower_expr(ctx, base)?; - let base_ptr = ctx - .block() - .call(I64, "js_url_coerce_string", &[(DOUBLE, &base_v)]); - let obj = ctx.block().call( + let base_slot = rooting::call_rooted( + ctx, + I64, + "js_url_coerce_string", + &[Arg::Plain(DOUBLE, &base_v)], + ); + let obj = rooting::call_with_roots( + ctx, I64, "js_url_parse_with_base", - &[(I64, &input_ptr), (I64, &base_ptr)], + &[Arg::Root(&input_slot), Arg::Root(&base_slot)], ); + base_slot.release(ctx); + input_slot.release(ctx); let blk = ctx.block(); let is_null = blk.icmp_eq(I64, &obj, "0"); let success = nanbox_pointer_inline(blk, &obj); @@ -305,18 +362,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // is a heap pointer, and lowering `name` runs arbitrary user code // that can collect. Root both operands first and unbox from the // reloaded receiver, so neither crosses the window in a register. - // The guard lives to the end of the arm: every use below is a use - // of one of the two rooted values. - let (vals, operand_guard) = super::temp_root::lower_exprs_rooted(ctx, &[params, name])?; - let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); - let p_ptr = unbox_to_i64(ctx.block(), &p_v); - let str_ptr = ctx.block().call( - I64, - "js_url_search_params_get", - &[(I64, &p_ptr), (DOUBLE, &n_v)], - ); - // Released after the consuming call, which itself allocates. - super::temp_root::temp_root_release(ctx, operand_guard); + // The rooted region ends with the consuming call, which itself + // allocates; everything below it is pure. + let str_ptr = rooting::with_operands_rooted(ctx, &[params, name], |ctx, vals| { + let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); + let p_ptr = unbox_to_i64(ctx.block(), &p_v); + Ok(ctx.block().call( + I64, + "js_url_search_params_get", + &[(I64, &p_ptr), (DOUBLE, &n_v)], + )) + })?; // Runtime returns a null pointer when the key is absent; // JS expects `null` in that case, not an empty string. let blk = ctx.block(); @@ -348,28 +404,29 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(v_expr) = value { operand_exprs.push(v_expr); } - let (vals, operand_guard) = super::temp_root::lower_exprs_rooted(ctx, &operand_exprs)?; - let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); - let p_ptr = unbox_to_i64(ctx.block(), &p_v); - // Runtime returns 0.0 / 1.0 as a plain f64 — not NaN-boxed. - // Translate to TAG_TRUE / TAG_FALSE so `typeof` and strict-eq - // behave correctly. - let raw = if value.is_some() { - let v_v = vals[2].clone(); - ctx.block().call( - DOUBLE, - "js_url_search_params_has2", - &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &v_v)], - ) - } else { - ctx.block().call( - DOUBLE, - "js_url_search_params_has", - &[(I64, &p_ptr), (DOUBLE, &n_v)], - ) - }; - // Released after the consuming call, which itself allocates. - super::temp_root::temp_root_release(ctx, operand_guard); + // Both arms are INSIDE the rooted region, so the release cannot be + // attached to one of them — #7462's mistake in the sibling arm. + let raw = rooting::with_operands_rooted(ctx, &operand_exprs, |ctx, vals| { + let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); + let p_ptr = unbox_to_i64(ctx.block(), &p_v); + // Runtime returns 0.0 / 1.0 as a plain f64 — not NaN-boxed. + // Translate to TAG_TRUE / TAG_FALSE so `typeof` and strict-eq + // behave correctly. + Ok(if value.is_some() { + let v_v = vals[2].clone(); + ctx.block().call( + DOUBLE, + "js_url_search_params_has2", + &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &v_v)], + ) + } else { + ctx.block().call( + DOUBLE, + "js_url_search_params_has", + &[(I64, &p_ptr), (DOUBLE, &n_v)], + ) + }) + })?; let blk = ctx.block(); let is_true = blk.fcmp("une", &raw, &double_literal(0.0)); let tagged = blk.select( @@ -397,16 +454,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // nothing crosses it in a register. #7462 rooted only // `params`+`name`, which left the three-operand path with the same // window it was meant to close. - let (vals, operand_guard) = - super::temp_root::lower_exprs_rooted(ctx, &[params, name, value])?; - let (p_v, n_v, val_v) = (vals[0].clone(), vals[1].clone(), vals[2].clone()); - let p_ptr = unbox_to_i64(ctx.block(), &p_v); - ctx.block().call_void( - "js_url_search_params_set", - &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &val_v)], - ); - // Released after the consuming call, which itself allocates. - super::temp_root::temp_root_release(ctx, operand_guard); + rooting::with_operands_rooted(ctx, &[params, name, value], |ctx, vals| { + let (p_v, n_v, val_v) = (vals[0].clone(), vals[1].clone(), vals[2].clone()); + let p_ptr = unbox_to_i64(ctx.block(), &p_v); + ctx.block().call_void( + "js_url_search_params_set", + &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &val_v)], + ); + Ok(()) + })?; Ok(ctx .block() .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64)) @@ -427,16 +483,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // nothing crosses it in a register. #7462 rooted only // `params`+`name`, which left the three-operand path with the same // window it was meant to close. - let (vals, operand_guard) = - super::temp_root::lower_exprs_rooted(ctx, &[params, name, value])?; - let (p_v, n_v, val_v) = (vals[0].clone(), vals[1].clone(), vals[2].clone()); - let p_ptr = unbox_to_i64(ctx.block(), &p_v); - ctx.block().call_void( - "js_url_search_params_append", - &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &val_v)], - ); - // Released after the consuming call, which itself allocates. - super::temp_root::temp_root_release(ctx, operand_guard); + rooting::with_operands_rooted(ctx, &[params, name, value], |ctx, vals| { + let (p_v, n_v, val_v) = (vals[0].clone(), vals[1].clone(), vals[2].clone()); + let p_ptr = unbox_to_i64(ctx.block(), &p_v); + ctx.block().call_void( + "js_url_search_params_append", + &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &val_v)], + ); + Ok(()) + })?; Ok(ctx .block() .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64)) @@ -461,26 +516,29 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(v_expr) = value { operand_exprs.push(v_expr); } - let (vals, operand_guard) = super::temp_root::lower_exprs_rooted(ctx, &operand_exprs)?; - let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); - let p_ptr = unbox_to_i64(ctx.block(), &p_v); - if value.is_some() { - let v_v = vals[2].clone(); - ctx.block().call_void( - "js_url_search_params_delete2", - &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &v_v)], - ); - } else { - ctx.block().call_void( - "js_url_search_params_delete", - &[(I64, &p_ptr), (DOUBLE, &n_v)], - ); - } - // Released after the consuming call on BOTH arms. #7462's automated - // placement put this inside the `else` only, so the with-value path - // pushed two temp roots per execution and never truncated them — - // unbounded growth in a loop, and it compiled without a warning. - super::temp_root::temp_root_release(ctx, operand_guard); + // THE reason `with_operands_rooted` owns the release. #7462's + // automated placement put `temp_root_release` inside the `else` + // only, so the with-value path pushed two temp roots per execution + // and never truncated them — unbounded growth in a loop, and it + // compiled without a warning. The caller no longer holds a guard, + // so there is nothing to attach to one arm. + rooting::with_operands_rooted(ctx, &operand_exprs, |ctx, vals| { + let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); + let p_ptr = unbox_to_i64(ctx.block(), &p_v); + if value.is_some() { + let v_v = vals[2].clone(); + ctx.block().call_void( + "js_url_search_params_delete2", + &[(I64, &p_ptr), (DOUBLE, &n_v), (DOUBLE, &v_v)], + ); + } else { + ctx.block().call_void( + "js_url_search_params_delete", + &[(I64, &p_ptr), (DOUBLE, &n_v)], + ); + } + Ok(()) + })?; Ok(ctx .block() .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64)) @@ -545,19 +603,20 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(this_arg) = this_arg { operand_exprs.push(this_arg); } - let (vals, operand_guard) = super::temp_root::lower_exprs_rooted(ctx, &operand_exprs)?; - let (p_v, cb_v) = (vals[0].clone(), vals[1].clone()); - let p_ptr = unbox_to_i64(ctx.block(), &p_v); - let this_v = if this_arg.is_some() { - vals[2].clone() - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - ctx.block().call_void( - "js_url_search_params_for_each", - &[(I64, &p_ptr), (DOUBLE, &cb_v), (DOUBLE, &this_v)], - ); - super::temp_root::temp_root_release(ctx, operand_guard); + rooting::with_operands_rooted(ctx, &operand_exprs, |ctx, vals| { + let (p_v, cb_v) = (vals[0].clone(), vals[1].clone()); + let p_ptr = unbox_to_i64(ctx.block(), &p_v); + let this_v = if this_arg.is_some() { + vals[2].clone() + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }; + ctx.block().call_void( + "js_url_search_params_for_each", + &[(I64, &p_ptr), (DOUBLE, &cb_v), (DOUBLE, &this_v)], + ); + Ok(()) + })?; Ok(ctx .block() .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64)) @@ -570,18 +629,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // reloaded receiver, so neither crosses the window in a register. // The guard lives to the end of the arm: every use below is a use // of one of the two rooted values. - let (vals, operand_guard) = super::temp_root::lower_exprs_rooted(ctx, &[params, name])?; - let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); - let p_ptr = unbox_to_i64(ctx.block(), &p_v); - // Returns f64 with the raw array pointer bit-cast in; the runtime - // does not NaN-box it, so tag it here with POINTER_TAG. - let raw_f64 = ctx.block().call( - DOUBLE, - "js_url_search_params_get_all", - &[(I64, &p_ptr), (DOUBLE, &n_v)], - ); - // Released after the consuming call, which itself allocates. - super::temp_root::temp_root_release(ctx, operand_guard); + let raw_f64 = rooting::with_operands_rooted(ctx, &[params, name], |ctx, vals| { + let (p_v, n_v) = (vals[0].clone(), vals[1].clone()); + let p_ptr = unbox_to_i64(ctx.block(), &p_v); + // Returns f64 with the raw array pointer bit-cast in; the + // runtime does not NaN-box it, so tag it here with POINTER_TAG. + Ok(ctx.block().call( + DOUBLE, + "js_url_search_params_get_all", + &[(I64, &p_ptr), (DOUBLE, &n_v)], + )) + })?; let bits = ctx.block().bitcast_double_to_i64(&raw_f64); Ok(nanbox_pointer_inline(ctx.block(), &bits)) } diff --git a/crates/perry-codegen/src/rooting.rs b/crates/perry-codegen/src/rooting.rs index ea3a037aaf..136ea6c6b0 100644 --- a/crates/perry-codegen/src/rooting.rs +++ b/crates/perry-codegen/src/rooting.rs @@ -1,11 +1,24 @@ -//! Layer 1 prototype: rooting by construction (`docs/src/internals/rfc-rooting-by-construction.md`). +//! Layer 1: rooting by construction (`docs/src/internals/rfc-rooting-by-construction.md`). //! -//! **Status: prototype, not yet on any lowering path.** It exists to settle the -//! one question the RFC could not answer on paper — *does the borrow checker -//! actually reject the bug shape?* — before anyone pays the migration cost of -//! threading these types through `perry-codegen`. The `compile_fail` doctests -//! below are the answer, and they are executed by `cargo test`, so this claim -//! cannot rot into prose the way the RFC's example could. +//! **Status: migration under way, one module at a time.** The ledger at the +//! bottom of this file names the modules that have finished; the campaign's +//! ordering lives on the Layer 1 tracking issue. +//! +//! This file has two halves and they answer different questions. +//! +//! The **first half** ([`RootingEmitter`], [`Raw`], [`Rooted`], [`Plain`]) is the +//! RFC's design as written, against a hypothetical emitter with interior +//! mutability. It exists to settle the one question the RFC could not answer on +//! paper — *does the borrow checker actually reject the bug shape?* The +//! `compile_fail` doctests below are the answer, and `cargo test` executes them, +//! so the claim cannot rot into prose the way the RFC's own example did (its +//! constructor was `E0499`, #7459). +//! +//! The **second half** is what runs. `FnCtx` has no interior mutability, so the +//! borrow formulation cannot be built on it; the combinators there get the same +//! guarantees by never handing out an unrooted register in the first place. The +//! gap between the two is stated exactly, and honestly, where the second half +//! begins. //! //! # The shape it has to reject //! @@ -49,6 +62,22 @@ //! # } //! ``` //! +//! #7192 is the same rule read from the other end — the value is materialised, +//! a call that allocates is emitted, and only *then* is the root store taken. +//! Rooting an already-stale pointer is indistinguishable from rooting a live +//! one at runtime; here it is a borrow error, because `root` consumes a handle +//! whose borrow the intervening `&mut` emission already ended: +//! +//! ```compile_fail,E0499 +//! # use perry_codegen::rooting::RootingEmitter; +//! # fn demo(e: &mut RootingEmitter) { +//! let obj = e.emit_collecting("js_object_alloc"); +//! e.emit_collecting("js_closure_callN"); // allocates; may move `obj` +//! // ERROR[E0499]: the root store is BELOW the collection point. +//! let _root = obj.root(); +//! # } +//! ``` +//! //! The correct code is also the shortest way out of that error — root it, then //! re-read after the window: //! @@ -249,51 +278,285 @@ mod tests { // // The shape that DOES work against a `&mut`-only emitter is the combinator, and // it is the same one the runtime settled on for layer 3 (`RuntimeHandle:: -// across_*`): never hand out an unrooted handle at all. `call_rooted` emits the -// collecting call and roots its result in one step, so there is no window in -// which an unrooted register exists to be misused, and `read` re-reads through -// the slot every time. +// across_*`): never hand out an unrooted handle at all. +// +// HOW MUCH WEAKER, MEASURED RATHER THAN ASSERTED. +// +// The first module migrated (`expr/url_main.rs`) was sabotaged four ways, each +// reintroducing a historic bug shape, and each result recorded: +// +// arm compiles? caught by +// -------------------------------------------------------- --------- --------- +// #7192 in the BORROW form (the doctests above) NO (E0499) rustc +// hold the `call_with_roots` result across a lowering yes nothing +// the verbatim pre-#7453 code, via bare `ctx.block()` yes nothing +// reach back into `expr::temp_root` yes ledger test +// hold the operand guard so it can be released on one arm yes ledger test // -// This is weaker than the borrow formulation -- it prevents the bug rather than -// detecting attempts to write it -- but it needs no emitter rewrite, which is -// what makes it migratable one call site at a time. +// So state it plainly: **on the real emitter this API does not make the bug +// fail to compile.** It removes the bug from the path of least resistance -- +// there is no expression in it that yields an unrooted register, and no guard +// for a caller to mis-release -- and the ledger test denies the escape hatch. +// A lowering that reaches past the API into `ctx.block()` is exactly as +// writable as it was before. +// +// The third row is the one worth reading twice. Reintroducing #7453 verbatim +// produced IR that `gc_root_dominance_check.py` reports as CLEAN in all three +// of its modes -- dominance 0, unrooted-allocas 0, stale-registers identical to +// the control. Its `--moving-only` filter discards the window because +// `js_url_coerce_string` is absent from `POLL_CAPABLE_RUNTIME`, even though +// #7453's own fix added it to `ALLOC_RE`. Dropping that filter surfaces 11 +// stale uses at `js_url_new_with_base` in the sabotaged arm and 0 in the +// migrated one, so the shape IS expressible -- the gate just cannot see it. +// Filed separately; not fixed here, because widening a gate is its own change +// with its own corpora to measure. +// +// Which is the real argument for the migration rather than for the checker: +// for the raw-register shape there is currently no automated defence at all, +// and the API is the only thing that makes the correct form the easy one. // --------------------------------------------------------------------------- +use anyhow::Result; +use perry_hir::Expr; + use crate::expr::FnCtx; +use crate::types::LlvmType; -/// A slot holding a GC-managed pointer for the duration of a lowering. -#[derive(Debug, Clone)] -pub struct RootedSlot { +/// A slot the collector knows about, holding a GC-managed pointer for the +/// duration of a lowering. +/// +/// There is deliberately **no way to read one into a register**. #7461 shipped +/// a `read(&self, ctx) -> String` and it reintroduced the second half of the +/// bug the slot exists to prevent: a register loaded from a root is stale the +/// moment anything else collects (#7114, #7375), and a `String` remembers +/// nothing about when it was loaded. [`call_with_roots`] fuses the re-read to +/// the use instead, so "load early, use late" is not a sequence this API can +/// express. +#[derive(Debug)] +pub(crate) struct RootedSlot { idx: String, } impl RootedSlot { - /// Re-read the slot. Called afresh at every use: the returned register is - /// only valid until the next emission that can collect, and re-reading is - /// cheaper than reasoning about whether one has happened. - pub fn read(&self, ctx: &mut FnCtx<'_>) -> String { - crate::expr::temp_root::temp_root_get_i64(ctx, &self.idx) - } - - /// Release the slot. Call after the last [`RootedSlot::read`]. - pub fn release(self, ctx: &mut FnCtx<'_>) { + /// Release the slot. + /// + /// `temp_root_truncate` is a stack CUT, not a pop: releasing a slot drops + /// every slot acquired after it. Release in reverse acquisition order, as + /// the un-migrated callers already had to. + pub(crate) fn release(self, ctx: &mut FnCtx<'_>) { crate::expr::temp_root::temp_root_truncate(ctx, &self.idx); } } +/// One argument to [`call_rooted`], [`call_with_roots`] or +/// [`call_void_with_roots`]. +/// +/// The split is the whole point: a `Root` is re-read from its slot at the +/// instant the call is emitted, and a `Plain` is a register the caller is +/// asserting the collector does not manage — an `i32`, a length, a literal, or +/// a value another combinator has already re-read below the last collection +/// point. +pub(crate) enum Arg<'a> { + /// Re-read this slot immediately before the call. The register never + /// exists as a value the caller can hold. + Root(&'a RootedSlot), + /// A value the collector does not manage in this window. + Plain(LlvmType, &'a str), +} + +/// Materialise each argument in order, re-reading every rooted slot. +/// +/// Order matters and is asserted by the IR-identity check: the re-reads are +/// emitted left to right, immediately before the call, which is exactly the +/// sequence the hand-written `temp_root_get_i64` callers emitted. +fn materialize<'a>(ctx: &mut FnCtx<'_>, args: &'a [Arg<'a>]) -> Vec<(LlvmType, String)> { + args.iter() + .map(|arg| match arg { + Arg::Root(slot) => ( + crate::types::I64, + crate::expr::temp_root::temp_root_get_i64(ctx, &slot.idx), + ), + Arg::Plain(ty, reg) => (*ty, (*reg).to_string()), + }) + .collect() +} + +fn borrow_args(args: &[(LlvmType, String)]) -> Vec<(LlvmType, &str)> { + args.iter().map(|(ty, reg)| (*ty, reg.as_str())).collect() +} + /// Emit a call that can collect and root its result in one step. /// /// The point is what this function does NOT return: an unrooted register. A /// caller cannot hold the result across a later collection point because it /// never has the result -- only a slot -- which is what makes the #7453 shape /// unwritable here rather than merely reviewable. -pub fn call_rooted( +pub(crate) fn call_rooted( ctx: &mut FnCtx<'_>, - ret_ty: crate::types::LlvmType, + ret_ty: LlvmType, callee: &str, - args: &[(crate::types::LlvmType, &str)], + args: &[Arg<'_>], ) -> RootedSlot { - let reg = ctx.block().call(ret_ty, callee, args); + let materialized = materialize(ctx, args); + let reg = ctx + .block() + .call(ret_ty, callee, &borrow_args(&materialized)); let idx = crate::expr::temp_root::temp_root_push_i64(ctx, ®); RootedSlot { idx } } + +// A `root_i64(ctx, reg) -> RootedSlot` combinator -- "root a raw pointer some +// earlier emission produced" -- was written for this slice and then deleted +// unused. It is recorded here because it is the ONE addition that would reopen +// the window the API closes: taking a bare register and rooting it puts the +// ordering back in the author's hands, which is #7192 exactly. If a later slice +// genuinely needs it (a receiver unboxed from a NaN-boxed operand is the likely +// case), it should arrive with its caller and with a written argument for why +// `call_rooted` cannot serve -- not ahead of one. + +/// Emit a call whose rooted arguments are re-read as part of the emission. +/// +/// Returns the call's own result register. That register is raw, and holding it +/// across a later collection point is still writable — see the module-level +/// note on what this API does not catch. +pub(crate) fn call_with_roots( + ctx: &mut FnCtx<'_>, + ret_ty: LlvmType, + callee: &str, + args: &[Arg<'_>], +) -> String { + let materialized = materialize(ctx, args); + ctx.block() + .call(ret_ty, callee, &borrow_args(&materialized)) +} + +/// Lower `exprs` with every already-evaluated operand rooted across the +/// evaluation of the ones that follow, run `body` over the re-read values, and +/// release the group **on every path out**. +/// +/// The release is the half nobody gets wrong in the happy case and everybody +/// gets wrong in a branch. #7462 placed `temp_root_release` inside one arm of +/// an `if`, so `URLSearchParams.delete(name, value)` pushed two temp roots per +/// execution and truncated none — unbounded growth inside a loop, compiled +/// without a warning. Owning the guard here rather than handing it back makes +/// "released on one arm" not a program: the caller never holds the guard, and +/// `body`'s `?` returns through the same release as its `Ok`. +pub(crate) fn with_operands_rooted<'f, R>( + ctx: &mut FnCtx<'f>, + exprs: &[&Expr], + body: impl FnOnce(&mut FnCtx<'f>, &[String]) -> Result, +) -> Result { + let (values, guard) = crate::expr::temp_root::lower_exprs_rooted(ctx, exprs)?; + let out = body(ctx, &values); + // Released after `body`'s consuming call, which itself allocates -- and on + // the error path too, so a lowering that bails does not leave the group + // pushed. + crate::expr::temp_root::temp_root_release(ctx, guard); + out +} + +// --------------------------------------------------------------------------- +// The per-module migration ledger (RFC step 3). +// +// "Migrate one family at a time [...] `#[deny]` the escape hatch per-module as +// each module finishes, so migrated code cannot regress." Rust has no attribute +// that denies calling a `pub(crate)` function from one module, so the deny is +// spelled as a test over the module's own source, inlined at COMPILE time by +// `include_str!` -- no path, no working directory, no stale checkout. +// +// `expr::temp_root` IS the escape hatch. It is the raw, order-sensitive API +// (push / get / set / truncate, guards the caller must remember to release), +// and every bug in the #7341 family was an ordering mistake against it. A +// migrated module names `crate::rooting` and nothing else. +// --------------------------------------------------------------------------- + +/// Modules that have completed the Layer 1 migration, with their source +/// inlined at compile time. +/// +/// Adding a line here is how a migration slice finishes. Removing one is a +/// regression, not a cleanup. +#[cfg(test)] +const MIGRATED_MODULES: &[(&str, &str)] = &[( + "crates/perry-codegen/src/expr/url_main.rs", + include_str!("expr/url_main.rs"), +)]; + +/// Lines in `src` that reach past [`crate::rooting`] into the raw rooting API. +#[cfg(test)] +fn escape_hatch_uses(src: &str) -> Vec<(usize, String)> { + src.lines() + .enumerate() + .filter(|(_, line)| { + let code = line.split("//").next().unwrap_or(line); + code.contains("temp_root") || code.contains("rooted_handle") + }) + .map(|(i, line)| (i + 1, line.trim().to_string())) + .collect() +} + +#[cfg(test)] +mod migration_ledger { + use super::{escape_hatch_uses, MIGRATED_MODULES}; + + /// An empty ledger passes vacuously, which is hazard 4 in CLAUDE.md applied + /// to this test. Assert the subject exists before asserting it is clean. + #[test] + fn the_ledger_is_not_empty() { + assert!( + !MIGRATED_MODULES.is_empty(), + "the Layer 1 ledger is empty; a clean verdict over nothing is not a check" + ); + } + + #[test] + fn migrated_modules_do_not_reach_past_the_rooting_api() { + for (path, src) in MIGRATED_MODULES { + let hits = escape_hatch_uses(src); + assert!( + hits.is_empty(), + "{path} has completed the Layer 1 migration, so it must root only \ + through crate::rooting. Reaching back into expr::temp_root \ + restores the ordering hazard the migration removed:\n{}", + hits.iter() + .map(|(n, l)| format!(" {path}:{n}: {l}")) + .collect::>() + .join("\n") + ); + } + } + + /// Sabotage duty: a ledger that cannot report a violation is documentation. + /// Plant each escape-hatch spelling and require the checker to name it. + #[test] + fn the_ledger_check_still_reports_a_planted_violation() { + let planted = "\ +fn lower(ctx: &mut FnCtx<'_>) { + let p = ctx.block().call(I64, \"js_url_coerce_string\", &[]); + let slot = super::temp_root::temp_root_push_i64(ctx, &p); + let h = super::temp_root::rooted_handle_begin(ctx, &p, true); +} +"; + let hits = escape_hatch_uses(planted); + assert_eq!( + hits.len(), + 2, + "planted escape-hatch uses must be reported, got {hits:?}" + ); + assert!(hits[0].1.contains("temp_root_push_i64")); + assert!(hits[1].1.contains("rooted_handle_begin")); + } + + /// ...and must NOT report the migrated form, or the check would make the + /// migration impossible to finish. + #[test] + fn the_ledger_check_clears_the_migrated_form() { + let clean = "\ +fn lower(ctx: &mut FnCtx<'_>) { + let slot = crate::rooting::call_rooted(ctx, I64, \"js_url_coerce_string\", &[]); + let obj = crate::rooting::call_with_roots(ctx, I64, \"js_url_new\", &[Arg::Root(&slot)]); + slot.release(ctx); +} +"; + assert!(escape_hatch_uses(clean).is_empty()); + } +} diff --git a/docs/engine-plan.md b/docs/engine-plan.md index bcb8c1eb50..bded91b1bc 100644 --- a/docs/engine-plan.md +++ b/docs/engine-plan.md @@ -15,7 +15,8 @@ closed on the verdict; owner action: promote to required after its first green `json_pipeline` 500k copies the 268 MB cohort ONCE — wall −24.6% AND peak RSS −21%, the first change to improve both goal axes at once. #7592 total: **60.4 s → 3.86 s (~6× bun)**, `JSON.parse` (~742 ms) is the remaining tail. -The last unstarted track is the **Layer-1 emitter migration**. The v0.5.1299 public-baseline sweep is +The Layer-1 emitter migration is **started** (#7615: campaign map, per-module ledger, +1 of 88 modules done). The v0.5.1299 public-baseline sweep is kept as the baseline measurement event; rows fixed since are annotated in place rather than overwritten, because they were measured individually rather than in a fresh sweep. @@ -66,7 +67,8 @@ subclasses, static-method GET form, `instanceof` a subclass (#7575). promotion copies 268 MB twice — promote-on-first-copy design is on the issue with the fixed-point trap named; and `JSON.parse` 742 ms); class-field-store barriers (the half #7602 could not reach); #7480 repsel element-shape proofs; -Layer-1 emitter migration (not started). +Layer-1 emitter migration (#7615 — campaign map published, template slice landed, +1 of 88 modules). **Gate debt still open:** #7554 (gc-ratchet CI has measured nothing since 2026-08-05 — REPAIR THIS BEFORE the next GC-pacing change, which needs it), @@ -83,7 +85,7 @@ has three homes, each needing its own mechanism.* | Layer | Home | Mechanism | Status | |---|---|---|---| | **0** | *enabler* | in-process LLVM | ✅ shipped (#7301), default cargo feature (#7353) | -| **1** | `perry-codegen` lowering code | `Raw`/`Rooted` discipline | design **validated & corrected** (#7459 — the RFC's own constructor was `E0499`); combinator form proven on the real emitter (#7461); the raw-pointer-across-lowering bug shape **eliminated crate-wide** (#7453, #7462–#7465); full emitter migration **not started** | +| **1** | `perry-codegen` lowering code | `Raw`/`Rooted` discipline | design **validated & corrected** (#7459 — the RFC's own constructor was `E0499`); combinator form proven on the real emitter (#7461); the raw-pointer-across-lowering bug shape **eliminated crate-wide** (#7453, #7462–#7465). **Migration started**: campaign map + per-module ledger in **#7615**; `expr/url_main.rs` migrated end to end as the template slice (#7617), which found `URL.canParse`/`URL.parse` still carrying #7453's window. 1 of 88 modules; 262 hazard sites remain. **Measured limit, stated once: on the real emitter this does NOT make the bug fail to compile** — `FnCtx` has no interior mutability, so the borrow form is unbuildable on it; the combinator removes the bug from the path of least resistance and the ledger denies the escape hatch, and that is all | | **2** | emitted code's liveness | statepoints | ✅ **the default**, target-aware (#7370): native roots where the runtime can walk frames, shadow stack elsewhere | | **3** | `perry-runtime` hand-written Rust | `RuntimeHandleScope`, non-optional | per-module ceilings (#7457): **595 of 705 modules locked at zero**, 107 listed with ceilings, 999 sites, and the list can only shrink — a cleaned module cannot regress (#7458). `across_*` combinators are the prescribed form (#7455). **End state not reached:** the raw accessor is still reachable inside listed modules | @@ -420,9 +422,13 @@ now collector behaviour rather than tape design. the bookkeeping levers: element reads are 13% of `churn` at 4.3×, the best ratio in the table, so this is an RSS/footprint play more than a time one. 7. **Layer 1** — migrate remaining lowerings onto the rooted-combinator API - (`crates/perry-codegen/src/rooting.rs`); the arm-aware scan is the - worklist tool. **Layer 3** — shrink the 107-module ceiling list toward - empty; the end state is the raw accessor unreachable, not counted. + (`crates/perry-codegen/src/rooting.rs`). **#7615 is the ordered worklist**: + 88 modules, 694 raw-pointer sites, 262 hazard sites, grouped into ten + slices by hazard density. A slice finishes by adding its modules to + `MIGRATED_MODULES`, which denies `expr::temp_root` in them. The terminal + condition is `expr/temp_root.rs` going `pub(in crate::rooting)` — the raw + accessor unreachable, not merely uncounted. **Layer 3** — shrink the + 107-module ceiling list toward empty; same end state, same reason. 8. **Statepoint-side static checker** — teach `gc_root_dominance_check.py` to read relocation bundles, closing the gap the #7452/#7460 repairs named. 9. **RSS re-derivation under the statepoint default** (#7056) — the earlier diff --git a/docs/src/internals/rfc-rooting-by-construction.md b/docs/src/internals/rfc-rooting-by-construction.md index 16176f40b9..5105250a01 100644 --- a/docs/src/internals/rfc-rooting-by-construction.md +++ b/docs/src/internals/rfc-rooting-by-construction.md @@ -1,6 +1,12 @@ # RFC: rooting by construction -**Status:** proposal. Nothing in this document is implemented. +**Status:** adopted, migrating. The design below is the *borrow* formulation and +it is executed as `compile_fail` doctests in `crates/perry-codegen/src/rooting.rs` +— but it is **not what runs**. `FnCtx` has no interior mutability, so what runs +is the combinator formulation in the second half of that file, and the gap +between the two is measured rather than asserted: see +["What the combinator form does NOT catch"](#what-the-combinator-form-does-not-catch) +below. Campaign map and per-module ledger: **#7615**. **Problem:** [The GC rooting invariant](gc-rooting-invariant.md) — #7154, #7184, #7192, #7206, #7211. @@ -222,7 +228,10 @@ The honest number is large but the distribution is favourable. 2. Migrate one family at a time, highest-risk first: `expr/temp_root.rs`'s clients, then `lower_call/*`, then the literal paths. Each is its own PR. 3. `#[deny]` the escape hatch per-module as each module finishes, so migrated - code cannot regress. + code cannot regress. **Done, as a test rather than an attribute**: Rust has + no `#[deny]` for "do not call this `pub(crate)` function from this module", + so `rooting::migration_ledger` `include_str!`s each finished module and + fails the build if it names `expr::temp_root`. 4. Keep `gc_root_dominance_check.py` in CI permanently as the backstop for whatever still goes through the escape hatch — and as the check on the `NON_COLLECTING` table itself, which the type system trusts and cannot @@ -250,6 +259,42 @@ a half-migrated tree is worse than today's. be measured on the benchmark suite after the first family migrates, not waved through. +## What the combinator form does NOT catch + +**This section supersedes the next one for anything actually shipping**, because +the `Raw<'e>`/`Rooted` design above cannot be built on `FnCtx` — `ctx.block()` +needs `&mut`, so a handle that carries a shared reborrow of the emitter cannot +exist (#7459 found the same `E0499` in this document's own constructor; #7461 +settled the shape that does work). What runs is the combinator form: never hand +out an unrooted register at all. + +Measured on the first fully migrated module (`expr/url_main.rs`, #7615's slice 0) +by reintroducing a historic bug shape four ways and recording each outcome: + +| reintroduced shape | compiles? | caught by | +|---|---|---| +| #7192 in the **borrow** form (`RootingEmitter`) | **no — `E0499`** | rustc, via `compile_fail` doctest | +| hold the `call_with_roots` result across a later lowering | yes | nothing | +| the **verbatim pre-#7453 code**, via a bare `ctx.block().call` | yes | **nothing** — including all three `gc_root_dominance_check.py` modes (#7616) | +| reach back into `expr::temp_root` | yes | the ledger test | +| hold the operand guard so it can be released on one arm (#7462) | yes | the ledger test | + +So the honest claim is narrower than "the mistake fails to compile": + +- The API **produces no unrooted register**, so the correct form is the only one + it can express and the wrong one requires leaving it. +- `RootedSlot` has **no `read`**. Fusing the re-read into the consuming call is + what makes "load early, use late" — the second half of #7114/#7375 — + unwritable rather than merely discouraged. +- `with_operands_rooted` **owns the release on every path including `?`**, so + #7462's release-on-one-arm is not a program. +- The escape hatch is **denied per module** by a `cargo test` ledger, which is + the checkable form of this document's step 3. + +Getting an actual compile error for the ordering mistake still requires step 1's +`RefCell`'d emitter. That is a real, separable piece of work and nothing below +should be read as claiming it is done. + ## What it cannot catch Stating these plainly, because a safety mechanism believed to be total is worse @@ -378,8 +423,17 @@ own emitted calls. No amount of care or review reliably catches that. A type that makes the value unusable after the call does, and it does so at the moment the mistake is made rather than five GC cycles later in someone else's program. -**Not prototyped here.** `crates/perry-codegen/src/expr/` and `lower_call/` are -under concurrent edit (#7206 and the `js_closure_callN` work), and a -proof-of-concept worth anything has to touch exactly those files. The right -sequencing is: land the CI gate, let the in-flight lowering fixes merge, then -open step 1 as its own PR against a quiet tree. +**Prototyped and adopted since.** The design is in +`crates/perry-codegen/src/rooting.rs`, its `compile_fail` doctests are executed +by `cargo test`, and `expr/url_main.rs` is migrated end to end as the template +every subsequent slice copies. Steps 3 and 4 of the incremental path above are +in place: the escape hatch is denied per module by the ledger test, and +`gc_root_dominance_check.py` stays as the backstop — though #7616 records that +it is blind to precisely the shape this RFC exists for, which is an argument for +finishing the migration rather than for trusting the checker. + +Step 1 is *not* done and should not be assumed: the greppable escape-hatch +types were never needed, because the combinator form migrates one call site at a +time without them. The `RefCell`'d emitter that would make the ordering mistake +a compile error remains unbuilt. **#7615 is the campaign map** — 88 modules, +694 raw-pointer sites, 262 hazard sites, ten slices. From b928ec81dd2c0a1ca79499ae97b7f98472b7ffd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 03:38:42 +0200 Subject: [PATCH 2/3] docs(codegen): changelog fragment for the Layer 1 template slice Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- changelog.d/7617-layer1-url-main-migration.md | 3 +++ crates/perry-codegen/src/rooting.rs | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelog.d/7617-layer1-url-main-migration.md diff --git a/changelog.d/7617-layer1-url-main-migration.md b/changelog.d/7617-layer1-url-main-migration.md new file mode 100644 index 0000000000..60653dbe35 --- /dev/null +++ b/changelog.d/7617-layer1-url-main-migration.md @@ -0,0 +1,3 @@ +- **The Layer 1 emitter migration is started, and the first module migrated end to end shows both what the discipline buys and what it does not.** `expr/url_main.rs` is now the template slice: it names no `expr::temp_root` symbol, and no raw heap pointer exists in it as a value a lowering can hold — `rooting::call_rooted` returns a slot rather than a register, `rooting::call_with_roots` re-reads each slot as part of emitting the consuming call (so #7461's `RootedSlot::read` is deleted; a register loaded from a root is stale the moment anything else collects, #7114/#7375), and `rooting::with_operands_rooted` owns the operand-group release on every path including `?`, which makes #7462's release-on-one-arm not a program. The migration found `URL.canParse(input, base)` and `URL.parse(input, base)` still carrying #7453's window — the same three lines #7453 fixed in `new URL(input, base)` and #7461 migrated, in the two static forms nobody re-read. IR is byte-identical on 16 of the 18 sources that exercise the module; the two that differ do so in 11 functions, every one of which calls `js_url_can_parse_with_base` or `js_url_parse_with_base`, adding only shadow-frame and root plumbing. The arms `test-files/` does not reach — `URLPattern` in both forms, `searchParams.forEach` in both forms, the typed `keys`/`values`/`entries`/`sort`/`getAll` family and all nine setters — were covered by a purpose-built probe and are byte-identical too, so the identity claim is not an artefact of what the corpus happens to compile. **Stated plainly because a partial mechanism believed total is worse than one known partial: on the real emitter this does NOT make the bug fail to compile.** `FnCtx` has no interior mutability, so the RFC's borrow-carrying `Raw<'e>` cannot be built on it (#7459, #7461); the four sabotage arms are recorded in `rooting.rs` with their measured outcomes — the borrow form rejects #7192 with `E0499` (a new `compile_fail` doctest), the two escape-hatch arms fail the new per-module ledger test, and the two bare-builder arms compile silently. The ordered inventory of the remaining 87 modules — 694 raw-pointer sites, 262 hazard sites, ten slices — is #7615, linked from #7294. (#7617) + +- **A sabotage arm found that `gc-root-dominance` cannot see the shape it exists for.** Reintroducing the verbatim pre-#7453 code produced IR the checker reports as clean in all three of its modes: dominance 0 (there is no root store to be late), `--unrooted-allocas` 0 (the value is in an SSA register, not an alloca), and `--stale-registers --moving-only` identical to the control. Without `--moving-only` the same corpus reports 11 stale uses at `js_url_new_with_base` in the sabotaged arm and 0 in the migrated one, so the shape is expressible — the window is classified non-MOVING because `js_url_coerce_string` is in `ALLOC_RE` but absent from `POLL_CAPABLE_RUNTIME`, and #7453's own fix added it to one list and stopped. Filed as #7616 with the one-line fix measured (curated corpus unchanged at 23 against a budget of 39) rather than applied, because widening a gate has to measure the dependency-scale corpus too. (#7617) diff --git a/crates/perry-codegen/src/rooting.rs b/crates/perry-codegen/src/rooting.rs index 136ea6c6b0..a3ccdaa45b 100644 --- a/crates/perry-codegen/src/rooting.rs +++ b/crates/perry-codegen/src/rooting.rs @@ -95,7 +95,13 @@ //! Anything not expressed through this emitter: runtime-side Rust (layer 3), a //! raw pointer cached in a side table, or a value the collector moves that never //! passes through a `Raw`. The RFC's "What it cannot catch" section is the -//! authority; this prototype does not widen it. +//! authority; this half does not widen it. +//! +//! And note which half these doctests are about. **They prove the DESIGN, not +//! the shipped code.** What the migrated lowerings actually get is the +//! combinator form below, which is measurably weaker — the block comment where +//! it starts records each sabotage arm and its outcome, including the two that +//! compile silently. /// A register holding something the collector does not manage — an `i32`, a /// length, a slot index. No borrow, freely cloneable. From c0e4ebf99df75dd68ffa77ee48ce09c68760b924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 04:00:14 +0200 Subject: [PATCH 3/3] chore(version): bump to 0.5.1352 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4802c5e169..af92082fad 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.1351 +**Current Version:** 0.5.1352 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e1625f626b..6735c7a533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1351" +version = "0.5.1352" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1351" +version = "0.5.1352" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1351" +version = "0.5.1352" [[package]] name = "perry-ui-tvos" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1351" +version = "0.5.1352" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 931a0d4664..82446c8456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1351" +version = "0.5.1352" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"