diff --git a/.github/workflows/gc-root-dominance.yml b/.github/workflows/gc-root-dominance.yml index 8a9928050b..b5ade1e2fd 100644 --- a/.github/workflows/gc-root-dominance.yml +++ b/.github/workflows/gc-root-dominance.yml @@ -667,6 +667,52 @@ jobs: --seeded-violations 40 \ -v + # ★★ The DEPENDENCY-SCALE corpus under the NATIVE lowering (#7803). + # + # The fourth cell of a matrix that had three. #7280 fixed the POPULATION + # (curated files do not contain the shapes a real library produces); + # #7452 fixed the LOWERING (statepoints ship, the shadow corpus contains + # none of that root form). Neither reached the other's cell, so the zod + # corpus compiled the way shipped binaries are compiled had never been + # checked. First measurement: 66 `unrooted` hazards, against a curated + # arm calibrated to ZERO — 24 sinking into js_new_function_construct and + # 39 into the js_closure_call family, i.e. the two shapes #7803 surfaces + # as ("Cannot read properties of undefined" / "value is not a function"). + # + # BUDGET, not allowlist, and it can only go down. 66 -> 3 when the + # callee-outlives-arguments defect was fixed in new_dynamic.rs, + # call_spread.rs and early_branches.rs — the whole + # js_new_function_construct sink (24), the whole + # js_closure_call_apply_with_spread sink (16) and the whole + # js_closure_call1/2 sink (23). The residual 3 are one each of + # js_array_concat, js_rel_ge and js_get_string_pointer_unified: a + # population under triage, not a list anyone has adjudicated one by one + # — same reasoning the `--stale-registers` budget records. + # + # Measured on a CLEAN rebuild. An earlier incremental build read 26 for + # the same source, because the corpus had been emitted before the third + # arm's fix existed; the number in a ratchet has to come from a tree + # someone can reproduce, not from whatever the build directory happened + # to contain. + - name: Emit the dependency-scale NATIVE (statepoint) IR corpus + run: ./scripts/gc_root_dominance_dep_native_corpus.sh ir-corpus-dep-native + + - name: Check GC values across safepoints (dependency-scale, native roots) + run: | + set -euo pipefail + python3 scripts/gc_root_dominance_check.py ir-corpus-dep-native \ + --statepoints \ + --moving-only \ + --min-files 60 --min-funcs 1200 \ + --min-statepoints 15000 \ + --min-live-bundles 8000 \ + --min-relocates 20000 \ + --max-unrooted 3 \ + --max-stale 0 \ + --allowlist scripts/gc_root_dominance_allowlist.json \ + --seeded-violations 40 \ + -v + # ★ The unfiltered arm, as a DIAGNOSTIC rather than a gate. # # `--moving-only` keeps only hazards whose window reaches a safepoint diff --git a/changelog.d/8084-gc-7803-diagnostics-and-rooting.md b/changelog.d/8084-gc-7803-diagnostics-and-rooting.md new file mode 100644 index 0000000000..1eaa8c0cbb --- /dev/null +++ b/changelog.d/8084-gc-7803-diagnostics-and-rooting.md @@ -0,0 +1,134 @@ +### GC: five diagnostics, two rooting fixes, and the corpus/lowering cell nobody gated (#7803) + +`#7803` — the `zod` dependency corpus dying under a seeded GC schedule — is now +localized but **not fixed**. It fails at `zod/src/v4/core/parse.ts:65` +(`result.issues`, where `schema._zod.run({ value, issues: [] }, ctx)` returned +`undefined`), all three observed messages are one loss seen at different points, +and the failure needs the `new Function` path: `jitless` gives 0/16 against +8/16 with it. Five hypotheses were tested and refuted or left unsupported; the +audit trail, including the null results, is in `gc-handoff/ZOD-NOTES.md`. + +**Two rooting defects found and fixed on the way:** + +- **The callee did not outlive the arguments** in three call-lowering arms + (`expr/new_dynamic.rs` ×2, `expr/call_spread.rs`, + `lower_call/early_branches.rs`). Each lowered the callee into a bare + register, lowered the arguments after it — every one of which can allocate — + then handed the consuming call the original register. Under the shipping + statepoint lowering that register is in no live bundle, so nothing marks it + and nothing relocates it. Fixed with `rooting::RootedGroup`; a root rather + than a reload, because JS resolves the callee *before* the arguments and + re-reading below them would pass whatever an argument assigned. +- **A stale argument buffer** in two dispatch arms of `js_native_call_method`, + both with a verified collection point between the handle scope and the + dispatch. `arg_handles` is what the collector rewrites; the caller's + `args_ptr` is not. + +Measured on the dependency corpus under the shipping lowering: **66 → 3** +unrooted hazards (`js_new_function_construct` 24→0, +`js_closure_call_apply_with_spread` 16→0, `js_closure_call1/2` 23→0). + +**`gc-root-dominance.yml` emitted three of four corpus × lowering +combinations.** #7280 fixed the population (curated files lack the shapes real +libraries produce) and #7452 fixed the lowering (statepoints ship; a shadow +corpus contains none of that root form) — neither reached the other's cell, so +the `zod` corpus compiled the way shipped binaries are compiled had never been +checked. It read 66 where the curated arm is calibrated to zero. Now emitted by +`scripts/gc_root_dominance_dep_native_corpus.sh` and gated at +`--max-unrooted 3 --max-stale 0`, a budget that can only go down. + +**The `dyn_eval` interpreter was untestable, not merely unrooted.** It offered +the collector no cooperative safepoints, so `PERRY_GC_ZEAL` and +`PERRY_GC_SCHEDULE_SEED` ran straight past it while the static checker had no +IR to read — leaving `dyn_eval/mod.rs`'s claim that interpreter frames hold +*every* live JSValue in a rooted stack unfalsifiable by anything in the tree. +`PERRY_GC_INTERP_SAFEPOINTS=1` closes that: `loop_polls` 24,029 → 93,210 on one +binary, i.e. the interpreter was ~74% of that workload's potential safepoints. + +**New diagnostics**, all default-off and all parsed by value rather than by +presence (the `PERRY_GC_DIAG=0`-enables-diagnostics footgun of #7993 does not +get repeated): + +| knob | what it does | +|---|---| +| `PERRY_UNCAUGHT_BACKTRACE` | symbolicated native backtrace on the uncaught-throw path | +| `PERRY_KEEP_SYMBOLS` | skip only the final `strip`, leaving `-g` off — `PERRY_DEBUG_SYMBOLS` does both, and `--debug-symbols` SUPPRESSES #7803 (0/13 against 44%), so an instrument that needs symbols must not use it | +| `PERRY_GC_INTERP_SAFEPOINTS` | cooperative GC safepoints at every `eval_expr` / `exec_stmt` | +| `PERRY_GC_POISON_FROMSPACE` | poison retired from-space in place, changing no layout | +| `PERRY_GC_TENURING_SURVIVALS` | pin the promotion age past the adaptive threshold | + +The pin-latch abort now also prints **which copying-minor walk** handed it +the header (`copying walk phase: `) and a mutator backtrace. +On this corpus the latch fires on an *incoherent* header (INTERNED on a +Map, a 2 GiB nursery size) — a stale slot, not a real pin. Seed 3 under +`RATE=0.1 ALLOC_KB=0` is a 3/3 abort; the slot is a native stack-map +root in `Doc.write` / `generateFastpass` / `$ZodObjectJIT.parse`. +No new knob. + +**ROOT CAUSE, FOUND AND FIXED: the spread-`new` bundle wrote through a moved +accumulator.** `Expr::NewDynamicSpread` (`new F(...args, src)`) folded its +arguments into a single array with the accumulator in a bare i64 register. +Every regular argument's lowering and every spread part's +`js_array_like_to_array` can run a moving minor; the following +`js_array_push_f64`/`js_array_concat` then wrote a NaN-boxed element through +the accumulator's pre-move address — into from-space pages the same cycle had +already recycled into Eden, over whatever live young object occupied them. +The element is typically a string, and every garbage header the pin-latch +ever recorded on this bug is the **high half of a NaN-boxed string** (sizes +`0x7FFF02AB/0x7FFF03AF/0x7FFF03FF/0x7FFF02E8/0x7FFF0438`, their low bits +tracking each run's ASLR heap base). zod's `Doc.compile` — `new F(...args, +lines.join("\n"))`, the closing expression of every `generateFastpass` — is +the corridor that hit it: that is why the failure needed the `new Function` +path (jitless 0/16), why `parse.ts:65` read `.issues` off garbage, and why +the victim frame varied (the latch names whoever points into the sprayed +neighborhood; the frame-namer diagnostic placed it in `$ZodObjectJIT.parse`'s +bundle at its `fastpass(payload, ctx)` statepoint, SP+40). The callee had the +same defect — this arm was not among the three `8842a0be4` fixed. + +Both `NewDynamicSpread` and the dynamic `super.m(...spread)` arm (an +identical private copy of the loop) now route through +`call_spread::bundle_args_rooted` (`pub(crate)` so private copies cannot +exist), with the callee in a `RootedGroup` re-read below the bundle. +Regression tests assert the IR ordering that IS the fix — the accumulator +each fold reads and the callee the dispatch reads are defined below the +bundle's last collection point — and were verified to fail against the +pre-fix lowering. The pin-latch abort now also names the owning frame, +statepoint register/offset and slot address of the stale native root +(`native root slot: owner=…`), dladdr-symbolicated. + +**SECOND ROOT CAUSE, FOUND AND FIXED: the compact GC map collapsed RS4GC +(base, derived) pairs — gc_map v4.** The compact stack-map format was built +on the stated premise that "Perry has no interior pointers" and folded every +statepoint (base, derived) pair into one slot. The premise is false: the +RS4GC prelude (`mem2reg,sccp`) hoists for-of element GEPs into values that +live across polls, which LLVM records as DERIVED pointers. With the pairing +discarded, the runtime walker treated `&elements[i]` as an object start — +misreading array element words as a GcHeader (the pin-latch's +INTERNED-on-map / `0x7FFF…`-size aborts were exactly that) — and never +rewrote the cursor as `base' + delta` when the array moved, dangling it. +Format v4 keeps `(base_index, reg, offset)` derived entries (repeat-flag +shared, version-gated fail-closed on both sides), and all three walkers +(Itanium unwind, aarch64 fp-chain, Windows RtlVirtualUnwind) exclude derived +slots from the visited-root set and rewrite them from their base after it +moves, preserving the slot's stored form. Measured on the pinned zod +schedule: seeds 1, 2 and 5 flip from ~2/3 aborting to 0 failures (with +`copying_minors>0` asserted per run); seed 3 retains one residual window, +characterized to the exact slot, record and creation cycle in +`gc-handoff/ZOD-NOTES.md` §40. + +Also landed: the remembered-set rebuild for promoted objects now runs AFTER +the worklist drain (it previously covered only root-phase promotions — +drain-phase promotions, i.e. everything transitively reachable, were +appended to `moved_headers` after the rebuild had already run); the +spread-`new` and dynamic `super.m(...spread)` bundles route through the +rooted accumulator with the callee in a `RootedGroup` (IR-ordering tests, +verified to fail against the pre-fix lowering); the whole-heap from-space +scan stops at an array's length (unused capacity on a hole-reused block +holds the previous occupant's bytes and manufactured a deterministic false +MISSING-REWRITE); and four new #7803 instruments — the pin-latch names the +owning frame, register, offset, slot address and the census-backed enclosing +object of its target; `PERRY_GC_THIS_SET_CHECK` traps incoherent values at +the implicit-this boundary in both directions; `PERRY_GC_NATIVE_SLOT_VERIFY` +aborts on the cycle that CREATES a stale native slot with the rewrite walk's +stats and the collector's own classification of the target. diff --git a/crates/perry-codegen/src/expr/call_spread.rs b/crates/perry-codegen/src/expr/call_spread.rs index 314723b831..10e1bed590 100644 --- a/crates/perry-codegen/src/expr/call_spread.rs +++ b/crates/perry-codegen/src/expr/call_spread.rs @@ -63,7 +63,14 @@ fn call_arg_expr(a: &CallArg) -> &Expr { /// `finish` runs BELOW the last collection point and ABOVE the release, so the /// register it receives is the only one that ever escapes — the same split /// [`rooting::with_rooted_accumulator`] imposes everywhere else. -fn bundle_args_rooted<'f, R>( +/// +/// `pub(crate)` since #7803: `NewDynamicSpread` and the dynamic +/// `super.m(...spread)` arm carried their own copies of this loop with the +/// accumulator in a bare register, and `Doc.compile`'s `new F(...args, src)` +/// is how that copy corrupted the heap — `js_array_push_f64` through the +/// pre-move accumulator writes a NaN-boxed string over whatever the recycled +/// from-space bytes now hold. One rooted implementation, no private copies. +pub(crate) fn bundle_args_rooted<'f, R>( ctx: &mut FnCtx<'f>, args: &[CallArg], spread_only: bool, @@ -455,7 +462,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // The signature must match `runtime_decls.rs`: // fn(closure_box: f64, regs_ptr: ptr, reg_count: i64, // spread_arr_handle: i64) -> f64 + // #7803: the callee outlives everything below it — the register + // buffer stores, `js_array_like_to_array` on the spread source, and + // `bundle_args_rooted`'s concat — all of which allocate. Held in a + // bare register it is in no statepoint live bundle, so the closure + // this helper is handed is a pre-move address; the dependency-scale + // corpus reports 16 of exactly this shape + // (`unrooted:alloc -> js_array_like_to_array`, sinking into + // `js_closure_call_apply_with_spread`). + // + // `open_rooted_group` rather than `with_rooted_group`: the release + // has to sit below the consuming call, which is past the end of the + // spread/regs marshalling block rather than inside it. + let mut callee_group = crate::rooting::open_rooted_group(1); let cb_box = lower_expr(ctx, callee)?; + let cb_root = callee_group.adopt(ctx, callee, &cb_box, true); // `js_closure_call_apply_with_spread` appends the spread array // AFTER the register args, which silently reorders interleaved @@ -531,6 +552,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (regs_ptr, regs_len, spread_handle) }; + // Re-read below every allocation above: the slot is a mutable root + // an evacuating cycle rewrites in place. + let cb_box = callee_group.reread(ctx, cb_root)?; let result = ctx.block().call( DOUBLE, "js_closure_call_apply_with_spread", @@ -541,6 +565,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (I64, &spread_handle), ], ); + // After the call: the helper allocates while it reads the slot. + callee_group.release(ctx); Ok(result) } diff --git a/crates/perry-codegen/src/expr/call_spread_rooting_tests.rs b/crates/perry-codegen/src/expr/call_spread_rooting_tests.rs index 9bcd3aac5d..0ec9f6706c 100644 --- a/crates/perry-codegen/src/expr/call_spread_rooting_tests.rs +++ b/crates/perry-codegen/src/expr/call_spread_rooting_tests.rs @@ -178,6 +178,77 @@ fn a_multi_spread_bundle_rereads_its_accumulator_below_the_array_conversion() { assert_every_fold_rereads_the_accumulator(&ir, "multi-spread bundle"); } +/// #7803 — `new F(...args, src)`, the zod `Doc.compile` shape that corrupted +/// the heap. `NewDynamicSpread` carried a private copy of the bundling loop +/// with the accumulator in a bare register; `js_array_push_f64` through its +/// pre-move address wrote a NaN-boxed string over recycled from-space bytes, +/// which is where every 0x7FFF-high-half garbage header in the #7803 latch +/// reports came from. The arm now routes through `bundle_args_rooted`; this +/// pins the ordering that IS the fix. +#[test] +fn a_spread_new_bundle_rereads_its_accumulator_below_the_array_conversion() { + let ir = compile_body( + "spread_new", + vec![Stmt::Expr(Expr::NewDynamicSpread { + callee: Box::new(allocating("f")), + args: vec![ + CallArg::Spread(allocating("xs")), + CallArg::Expr(allocating("s")), + ], + byte_offset: 0, + })], + ); + // Liveness first: the arm under test must have lowered at all. + assert_eq!( + call_lines(&ir, "js_new_function_construct_apply").len(), + 1, + "spread-new did not dispatch through js_new_function_construct_apply, so \ + nothing below tests the NewDynamicSpread arm.\n{ir}" + ); + assert_every_fold_rereads_the_accumulator(&ir, "spread-new bundle"); +} + +/// #7803's second half in the same arm: the CALLEE outlives the bundle. Same +/// defect `8842a0be4` fixed in this file's two non-spread construct arms — +/// this arm was not among the three. The callee register the apply dispatch +/// reads must be defined BELOW the last collection point of the bundling, +/// which can only happen if it was rooted above the window and re-read below. +#[test] +fn a_spread_new_rereads_its_callee_below_the_bundle() { + let ir = compile_body( + "spread_new_callee", + vec![Stmt::Expr(Expr::NewDynamicSpread { + callee: Box::new(allocating("f")), + args: vec![ + CallArg::Spread(allocating("xs")), + CallArg::Expr(allocating("s")), + ], + byte_offset: 0, + })], + ); + let dispatches = call_lines(&ir, "js_new_function_construct_apply"); + assert_eq!(dispatches.len(), 1, "no spread-new dispatch in:\n{ir}"); + let dispatch = dispatches[0]; + let callee = first_operand(&ir, dispatch); + let def = definition_line(&ir, &callee) + .unwrap_or_else(|| panic!("no definition for the callee {callee} in:\n{ir}")); + let last_window = call_lines(&ir, "js_array_like_to_array") + .into_iter() + .chain(call_lines(&ir, "js_array_concat")) + .chain(call_lines(&ir, "js_array_push_f64")) + .filter(|&l| l < dispatch) + .max() + .unwrap_or_else(|| panic!("no bundling window above the dispatch in:\n{ir}")); + assert!( + def > last_window, + "spread-new reads callee {callee}, defined at line {def}, ABOVE the last \ + bundling collection point at line {last_window}. An evacuating minor \ + anywhere in the bundle relocates the callee and the construct dispatches \ + a from-space address. The callee must be rooted above the window and \ + re-read below it.\n{ir}" + ); +} + /// ★ The operands are INERT — `[1, 2]` and `3` cannot run user code — and the /// accumulator still has to be rooted, because `js_array_like_to_array` itself /// allocates. diff --git a/crates/perry-codegen/src/expr/new_dynamic.rs b/crates/perry-codegen/src/expr/new_dynamic.rs index 72382212e5..c2550271cb 100644 --- a/crates/perry-codegen/src/expr/new_dynamic.rs +++ b/crates/perry-codegen/src/expr/new_dynamic.rs @@ -12,7 +12,7 @@ use crate::lower_call::lower_new; use crate::lower_conditional::lower_conditional; use crate::nanbox::{double_literal, POINTER_MASK_I64}; use crate::native_value::MaterializationReason; -use crate::types::{DOUBLE, I32, I64, PTR}; +use crate::types::{DOUBLE, I64, PTR}; use super::{ downgrade_buffer_aliases_in_expr, lower_expr, lower_js_args_array, nanbox_pointer_inline, @@ -82,39 +82,43 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } } + // #7803 — THE zod corpus corruption. This arm used to thread the + // accumulator through the bundling loop as a bare i64 register: + // every regular argument's lowering and every spread part's + // `js_array_like_to_array` can run a moving minor, after which + // `js_array_push_f64`/`js_array_concat` wrote through the + // accumulator's PRE-MOVE address — into from-space pages the same + // cycle had already recycled into Eden. The element being written + // is typically a NaN-boxed string (tag 0x7FFF), and every garbage + // header the #7803 pin-latch ever recorded is the high half of one + // (sizes 0x7FFF02AB / 0x7FFF03AF / 0x7FFF03FF / 0x7FFF0543). + // zod's `Doc.compile` — `new F(...args, lines.join("\n"))`, run at + // the end of every `generateFastpass` — is the corridor that hit + // it. `bundle_args_rooted` re-reads the accumulator from its temp + // root below each collection point, same as the CallSpread arms. + // + // The CALLEE has the §18/#7803 defect too: this spread arm was not + // among the three `8842a0be4` fixed. A root and not a reload — JS + // resolves the callee before the arguments. + let mut callee_group = crate::rooting::open_rooted_group(1); let func_double = lower_expr(ctx, callee)?; - let mut acc_handle = ctx.block().call(I64, "js_array_alloc", &[(I32, "0")]); - for a in args { - match a { - CallArg::Expr(e) => { - let v = lower_expr(ctx, e)?; - acc_handle = ctx.block().call( - I64, - "js_array_push_f64", - &[(I64, &acc_handle), (DOUBLE, &v)], - ); - } - CallArg::Spread(e) => { - let part_box = lower_expr(ctx, e)?; - let part_handle = - ctx.block() - .call(I64, "js_array_like_to_array", &[(DOUBLE, &part_box)]); - acc_handle = ctx.block().call( - I64, - "js_array_concat", - &[(I64, &acc_handle), (I64, &part_handle)], - ); - } - } - } - let args_box = nanbox_pointer_inline(ctx.block(), &acc_handle); - // #5253: locate the not-a-constructor throw the apply path can raise. - crate::expr::calls::emit_call_location_at(ctx, new_byte_offset); - let result = ctx.block().call( - DOUBLE, - "js_new_function_construct_apply", - &[(DOUBLE, &func_double), (DOUBLE, &args_box)], - ); + let callee_root = callee_group.adopt(ctx, callee, &func_double, true); + let result = + crate::expr::call_spread::bundle_args_rooted(ctx, args, false, |ctx, current| { + let args_box = nanbox_pointer_inline(ctx.block(), current); + // #5253: locate the not-a-constructor throw the apply path + // can raise. + crate::expr::calls::emit_call_location_at(ctx, new_byte_offset); + // Below every collection point in the bundling: the slot is + // a mutable root an evacuating cycle rewrites in place. + let func_double = callee_group.reread(ctx, callee_root)?; + Ok(ctx.block().call( + DOUBLE, + "js_new_function_construct_apply", + &[(DOUBLE, &func_double), (DOUBLE, &args_box)], + )) + })?; + callee_group.release(ctx); // Write-back: when the callee is a statically-known user class, // propagate constructor mutations (e.g. `++called`) back to the // outer captured locals. The runtime construction path stores @@ -688,21 +692,53 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { MaterializationReason::UnknownCallEscape, ); } - let func_double = lower_expr(ctx, callee)?; - let lowered_args: Vec = args - .iter() - .map(|a| lower_expr(ctx, a)) - .collect::>>()?; - let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); - // #5253: locate a not-a-constructor throw from the runtime - // construct path (a `LocalGet` callee holding `undefined`, a - // non-callable value, etc.). - crate::expr::calls::emit_call_location_at(ctx, new_byte_offset); - let result = ctx.block().call( - DOUBLE, - "js_new_function_construct", - &[(DOUBLE, &func_double), (PTR, &args_ptr), (I64, &args_len)], - ); + // #7803: the CALLEE has to outlive the arguments. + // + // This arm used to lower `callee` into a bare register, lower + // every argument, build the argument array — each of which can + // allocate and therefore evacuate — and only then pass the + // original register to the helper. Under the shipping + // (statepoint) lowering that register is in no live bundle, so + // nothing marks it and nothing relocates it, and the constructor + // handed to `js_new_function_construct` is a pre-move address. + // + // It is the largest single population the dependency-scale + // corpus reports: 21 `unrooted:global` hazards in + // `zod/src/v4/classic/schemas.ts` alone (`strictObject`, + // `looseObject`, `union`, `record`, …), every one a + // `load @perry_global_*` held across `js_closure_alloc` / + // `js_closure_call1` / `js_object_alloc`. + // + // A ROOT, not a reload: JS resolves the callee before it + // evaluates the arguments, so re-reading the global below them + // would hand the call whatever an argument assigned — a + // miscompile in place of a rooting bug. That is exactly why + // `operand_is_reloadable` refuses module globals, and the + // group's `operand_protection` answers it the same way here. + let result = crate::rooting::with_rooted_group(ctx, args.len() + 1, |ctx, g| { + let func_double = lower_expr(ctx, callee)?; + let callee_root = g.adopt(ctx, callee, &func_double, true); + let mut arg_ids = Vec::with_capacity(args.len()); + for a in args { + arg_ids.push(g.lower(ctx, a, true)?); + } + let lowered_args: Vec = arg_ids + .iter() + .map(|i| g.reread(ctx, *i)) + .collect::>>()?; + let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); + // #5253: locate a not-a-constructor throw from the runtime + // construct path (a `LocalGet` callee holding `undefined`, a + // non-callable value, etc.). + crate::expr::calls::emit_call_location_at(ctx, new_byte_offset); + // Below `lower_js_args_array`, which allocates. + let func_double = g.reread(ctx, callee_root)?; + Ok(ctx.block().call( + DOUBLE, + "js_new_function_construct", + &[(DOUBLE, &func_double), (PTR, &args_ptr), (I64, &args_len)], + )) + })?; return Ok(result); } @@ -723,21 +759,29 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { MaterializationReason::UnknownCallEscape, ); } - let func_double = lower_expr(ctx, callee)?; - let lowered_args: Vec = args - .iter() - .map(|a| lower_expr(ctx, a)) - .collect::>>()?; - let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); - // #5253: locate the not-a-constructor throw for `new ` / - // `new ` rejected inside the runtime helper. - crate::expr::calls::emit_call_location_at(ctx, new_byte_offset); - let result = ctx.block().call( - DOUBLE, - "js_new_function_construct", - &[(DOUBLE, &func_double), (PTR, &args_ptr), (I64, &args_len)], - ); - Ok(result) + // #7803: same callee-outlives-arguments fix as the arm above. + crate::rooting::with_rooted_group(ctx, args.len() + 1, |ctx, g| { + let func_double = lower_expr(ctx, callee)?; + let callee_root = g.adopt(ctx, callee, &func_double, true); + let mut arg_ids = Vec::with_capacity(args.len()); + for a in args { + arg_ids.push(g.lower(ctx, a, true)?); + } + let lowered_args: Vec = arg_ids + .iter() + .map(|i| g.reread(ctx, *i)) + .collect::>>()?; + let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); + // #5253: locate the not-a-constructor throw for `new ` / + // `new ` rejected inside the runtime helper. + crate::expr::calls::emit_call_location_at(ctx, new_byte_offset); + let func_double = g.reread(ctx, callee_root)?; + Ok(ctx.block().call( + DOUBLE, + "js_new_function_construct", + &[(DOUBLE, &func_double), (PTR, &args_ptr), (I64, &args_len)], + )) + }) } // `this` — load from the topmost `this` slot in the constructor diff --git a/crates/perry-codegen/src/expr/super_method.rs b/crates/perry-codegen/src/expr/super_method.rs index b87546ddfe..cea3a8bb87 100644 --- a/crates/perry-codegen/src/expr/super_method.rs +++ b/crates/perry-codegen/src/expr/super_method.rs @@ -145,50 +145,36 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } return Ok(double_literal(0.0)); } - let this_box = match ctx.this_stack.last().cloned() { - Some(slot) => ctx.block().load(DOUBLE, &slot), - None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), - }; // Build a single args array containing every argument in source - // order, expanding spreads via array-like-to-array + concat (the - // same machinery the CallSpread method-apply path uses). - let mut acc_handle = ctx.block().call(I64, "js_array_alloc", &[(I32, "0")]); - for a in args { - match a { - CallArg::Expr(e) => { - let v = lower_expr(ctx, e)?; - acc_handle = ctx.block().call( - I64, - "js_array_push_f64", - &[(I64, &acc_handle), (DOUBLE, &v)], - ); - } - CallArg::Spread(e) => { - let part_box = lower_expr(ctx, e)?; - let part_handle = - ctx.block() - .call(I64, "js_array_like_to_array", &[(DOUBLE, &part_box)]); - acc_handle = ctx.block().call( - I64, - "js_array_concat", - &[(I64, &acc_handle), (I64, &part_handle)], - ); - } - } - } - let args_array = nanbox_pointer_inline(ctx.block(), &acc_handle); + // order, expanding spreads via array-like-to-array + concat — the + // SAME rooted machinery as the CallSpread method-apply path. + // + // #7803: this arm was a private copy of that loop with the + // accumulator in a bare i64 register, the exact defect that + // corrupted the heap through `NewDynamicSpread` (see + // new_dynamic.rs). The `this` load moves BELOW the bundling for + // the same reason: the register read above it is a copy the + // collector cannot rewrite, and `this` is immutable so the + // re-ordered slot read observes the same binding. let name_global = emit_string_literal_global(ctx, method); - Ok(ctx.block().call( - DOUBLE, - "js_super_method_call_dynamic_apply", - &[ - (I32, &cid.to_string()), - (PTR, &name_global), - (I64, &method.len().to_string()), - (DOUBLE, &this_box), - (DOUBLE, &args_array), - ], - )) + crate::expr::call_spread::bundle_args_rooted(ctx, args, false, |ctx, current| { + let args_array = nanbox_pointer_inline(ctx.block(), current); + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; + Ok(ctx.block().call( + DOUBLE, + "js_super_method_call_dynamic_apply", + &[ + (I32, &cid.to_string()), + (PTR, &name_global), + (I64, &method.len().to_string()), + (DOUBLE, &this_box), + (DOUBLE, &args_array), + ], + )) + }) } // -------- super. as a value (issue #774) -------- diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index 8bae05d886..9b872d66c8 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -58,7 +58,12 @@ use anyhow::{anyhow, Context, Result}; /// Magic at the start of every emitted blob. const GC_MAP_MAGIC: &[u8; 4] = b"PGCM"; /// Format version. Bump on any layout change — the runtime rejects others. -const GC_MAP_VERSION: u8 = 3; +/// v4 (#7803): the record header word gained a has-derived bit and records +/// carry DERIVED (interior) pointer slots tied to their bases. v3 collapsed +/// every statepoint (base, derived) pair to one slot on the false premise +/// that Perry emits no interior pointers; the runtime decoder fails closed on +/// a version mismatch, so both sides bump together. +const GC_MAP_VERSION: u8 = 4; /// Section the compact map is emitted into, and the label it is given. const GC_MAP_LABEL: &str = "_perry_gc_map"; const MACHO_SECTION: &str = "__PERRY_GCMAP,__perry_gcmap"; @@ -103,6 +108,19 @@ struct Record { instruction_offset: String, /// `(dwarf_reg, frame_offset)`, deduplicated and sorted by frame offset. roots: Vec<(u16, i32)>, + /// #7803: DERIVED (interior) pointer slots, each tied to the base root it + /// was derived from — `(index into `roots`, dwarf_reg, frame_offset)`. + /// + /// The v3 format collapsed every statepoint (base, derived) pair to one + /// slot on the stated premise that "Perry has no interior pointers". The + /// premise is false: the RS4GC prelude (`mem2reg,sccp`) hoists for-of + /// element GEPs into values that live across the poll, and LLVM records + /// them as derived pointers. Collapsing the pair made the runtime walker + /// treat `&elements[i]` as an object start — misread as a garbage header + /// by the pin-latch, and never rewritten as `base' + delta` when the + /// array moves, which dangles the cursor. Deduplicated and sorted by + /// frame offset, like `roots`. + derived: Vec<(u32, u16, i32)>, } /// One function's safepoints, keyed by the symbol the linker will relocate. @@ -481,7 +499,12 @@ fn decode_v3(block: &RawBlock) -> Result, String> { as usize; pos += 16; - let mut roots: Vec<(u16, i32)> = Vec::new(); + // Read every location first: the statepoint layout is + // POSITIONAL — three constants (calling convention, flags, + // deopt count), then that many deopt locations, then the GC + // pointer locations in (base, derived) PAIRS — and pairing + // cannot be recovered from a flat filter. + let mut locations: Vec<(u8, u16, u16, i32)> = Vec::with_capacity(location_count); for location in 0..location_count { let kind = *bytes.get(pos).ok_or_else(|| { truncated(&format!("{symbol} record {index} location {location}"), pos) @@ -495,14 +518,67 @@ fn decode_v3(block: &RawBlock) -> Result, String> { let offset = read_u32(bytes, pos + 8).ok_or_else(|| { truncated(&format!("{symbol} record {index} location {location}"), pos) })? as i32; - // Keep exactly what the collector keeps: 8-byte frame - // slots, with the base/derived pair collapsed to one. - if matches!(kind, LOCATION_DIRECT | LOCATION_INDIRECT) && size == 8 { - if !roots.contains(&(dwarf_reg, offset)) { - roots.push((dwarf_reg, offset)); + locations.push((kind, size, dwarf_reg, offset)); + pos += 12; + } + + let is_root_slot = |&(kind, size, _, _): &(u8, u16, u16, i32)| { + matches!(kind, LOCATION_DIRECT | LOCATION_INDIRECT) && size == 8 + }; + let mut roots: Vec<(u16, i32)> = Vec::new(); + // `(base_reg, base_off, derived_reg, derived_off)` until the + // roots list is final and indices can be resolved. + let mut derived_pairs: Vec<(u16, i32, u16, i32)> = Vec::new(); + // The deopt count is the third constant's small value. A + // malformed preamble (fewer than 3 locations, or a non-constant + // where the count belongs) falls back to the v3 flat filter — + // strictly the OLD behavior, never a new failure mode. + const LOCATION_CONSTANT: u8 = 4; + let gc_pairs_start = match locations.get(2) { + Some(&(LOCATION_CONSTANT, _, _, deopt_count)) if deopt_count >= 0 => { + Some(3usize + deopt_count as usize) + } + _ => None, + }; + match gc_pairs_start { + Some(start) + if start <= locations.len() && (locations.len() - start) % 2 == 0 => + { + for pair in locations[start..].chunks_exact(2) { + let (base, derived) = (&pair[0], &pair[1]); + if !is_root_slot(base) || !is_root_slot(derived) { + // A constant/register operand (e.g. a null + // base): keep whichever half IS a frame slot, + // as the flat filter always has. + for loc in pair.iter().filter(|l| is_root_slot(l)) { + if !roots.contains(&(loc.2, loc.3)) { + roots.push((loc.2, loc.3)); + } + } + continue; + } + let base_slot = (base.2, base.3); + let derived_slot = (derived.2, derived.3); + if !roots.contains(&base_slot) { + roots.push(base_slot); + } + if derived_slot != base_slot { + derived_pairs.push(( + base_slot.0, + base_slot.1, + derived_slot.0, + derived_slot.1, + )); + } + } + } + _ => { + for loc in locations.iter().filter(|l| is_root_slot(l)) { + if !roots.contains(&(loc.2, loc.3)) { + roots.push((loc.2, loc.3)); + } } } - pos += 12; } pos = align_up(pos - record_start, 8) + record_start; @@ -519,9 +595,33 @@ fn decode_v3(block: &RawBlock) -> Result, String> { } roots.sort_unstable_by_key(|(_, offset)| *offset); + // Resolve derived pairs against the SORTED roots list, drop + // duplicates, and drop any derived slot that is also a plain + // root (a slot cannot be both an object start and an interior + // pointer; preferring the root keeps v3's behavior for the + // ambiguous shape rather than inventing a new one). + let mut derived: Vec<(u32, u16, i32)> = Vec::new(); + for (base_reg, base_off, d_reg, d_off) in derived_pairs { + if roots.contains(&(d_reg, d_off)) { + continue; + } + let Some(base_index) = + roots.iter().position(|&slot| slot == (base_reg, base_off)) + else { + continue; + }; + // Slot-level dedup: one slot holds one value, so a second + // pairing for the same (reg, offset) — same base or not — + // must not produce a second rewrite of it. + if !derived.iter().any(|&(_, r, o)| r == d_reg && o == d_off) { + derived.push((base_index as u32, d_reg, d_off)); + } + } + derived.sort_unstable_by_key(|&(_, _, offset)| offset); records.push(Record { instruction_offset, roots, + derived, }); } out.push(FunctionMap { @@ -578,17 +678,48 @@ const DWARF_REG_SP_AARCH64: u16 = 31; /// Frame pointer, the other base the single-bit encoding can express. const DWARF_REG_FP_AARCH64: u16 = 29; +/// Emit one root list in the shared tag/delta encoding (see the header-word +/// comment in [`encode_stream`]). Used for both the base roots and the +/// derived slots — the derived list restarts its own delta chain. +fn encode_slots(stream: &mut Vec, slots: impl Iterator) { + let mut previous: Option = None; + for (reg, offset) in slots { + let tag = match reg { + DWARF_REG_FP_AARCH64 => 0u64, + DWARF_REG_SP_AARCH64 => 1, + _ => 2, + }; + let delta = match previous { + None => offset, + Some(prev) => offset.wrapping_sub(prev), + }; + push_varint(stream, (zigzag(delta) << 2) | tag); + if tag == 2 { + push_varint(stream, u64::from(reg)); + } + previous = Some(offset); + } +} + fn encode_stream(functions: &[FunctionMap]) -> Vec { let mut stream = Vec::new(); for function in functions { - let mut previous_roots: Option<&Vec<(u16, i32)>> = None; + let mut previous_record: Option<(&Vec<(u16, i32)>, &Vec<(u32, u16, i32)>)> = None; for record in &function.records { - if previous_roots == Some(&record.roots) { - // Repeat flag: the live set is the previous record's. + if previous_record == Some((&record.roots, &record.derived)) { + // Repeat flag: the live set (bases AND deriveds) is the + // previous record's. push_varint(&mut stream, 1); continue; } - push_varint(&mut stream, (record.roots.len() as u64) << 1); + // v4 header word: (root_count << 2) | (has_derived << 1) | 0. + // Bit 0 stays the repeat flag, so a v3-shaped record (no + // deriveds) costs the same bytes it did. + let has_derived = u64::from(!record.derived.is_empty()); + push_varint( + &mut stream, + ((record.roots.len() as u64) << 2) | (has_derived << 1), + ); // Deltas are zigzagged rather than emitted raw. `decode_v3` sorts // roots so they are non-negative in practice, but a raw negative @@ -602,24 +733,21 @@ fn encode_stream(functions: &[FunctionMap]) -> Vec { // format must not be the reason a root is unrepresentable. // 0 = frame pointer, 1 = stack pointer, 2 = explicit DWARF // register number as a following varint. - let mut previous: Option = None; - for (reg, offset) in &record.roots { - let tag = match *reg { - DWARF_REG_FP_AARCH64 => 0u64, - DWARF_REG_SP_AARCH64 => 1, - _ => 2, - }; - let delta = match previous { - None => *offset, - Some(prev) => offset.wrapping_sub(prev), - }; - push_varint(&mut stream, (zigzag(delta) << 2) | tag); - if tag == 2 { - push_varint(&mut stream, u64::from(*reg)); + encode_slots(&mut stream, record.roots.iter().copied()); + if !record.derived.is_empty() { + push_varint(&mut stream, record.derived.len() as u64); + // Base indices first (into the sorted roots list), then the + // slots themselves in the shared encoding with a fresh delta + // chain. + for &(base_index, _, _) in &record.derived { + push_varint(&mut stream, u64::from(base_index)); } - previous = Some(*offset); + encode_slots( + &mut stream, + record.derived.iter().map(|&(_, reg, off)| (reg, off)), + ); } - previous_roots = Some(&record.roots); + previous_record = Some((&record.roots, &record.derived)); } } stream @@ -660,10 +788,51 @@ fn unzigzag(value: u32) -> i32 { /// Always on. It walks bytes already in cache and is far below the noise floor /// of the LLVM run that produced them, and an assertion that has to be switched /// on is one that is off when it matters. +fn decode_slots( + stream: &[u8], + mut cursor: usize, + count: usize, + where_: &dyn Fn() -> String, +) -> Result<(Vec<(u16, i32)>, usize), String> { + let mut slots = Vec::with_capacity(count); + let mut last: Option = None; + for slot in 0..count { + let (value, next) = read_varint(stream, cursor) + .ok_or_else(|| format!("{}: truncated slot {slot}", where_()))?; + cursor = next; + let dwarf_reg = match value & 3 { + 0 => DWARF_REG_FP_AARCH64, + 1 => DWARF_REG_SP_AARCH64, + 2 => { + let (reg, next) = read_varint(stream, cursor).ok_or_else(|| { + format!("{}: truncated explicit register for slot {slot}", where_()) + })?; + cursor = next; + u16::try_from(reg) + .map_err(|_| format!("{}: slot {slot} register {reg} exceeds u16", where_()))? + } + tag => { + return Err(format!( + "{}: slot {slot} has reserved base tag {tag}", + where_() + )) + } + }; + let delta = unzigzag((value >> 2) as u32); + let offset = match last { + None => delta, + Some(previous_offset) => previous_offset.wrapping_add(delta), + }; + last = Some(offset); + slots.push((dwarf_reg, offset)); + } + Ok((slots, cursor)) +} + fn verify_roundtrip(functions: &[FunctionMap], stream: &[u8]) -> Result<(), String> { let mut cursor = 0usize; for function in functions { - let mut previous: Option> = None; + let mut previous: Option<(Vec<(u16, i32)>, Vec<(u32, u16, i32)>)> = None; for (index, record) in function.records.iter().enumerate() { let where_ = || format!("{} record {index}", function.symbol); let (header, next) = read_varint(stream, cursor) @@ -674,49 +843,50 @@ fn verify_roundtrip(functions: &[FunctionMap], stream: &[u8]) -> Result<(), Stri .clone() .ok_or_else(|| format!("{}: repeat flag with no previous live set", where_()))? } else { - let count = (header >> 1) as usize; - let mut roots = Vec::with_capacity(count); - let mut last: Option = None; - for root in 0..count { - let (value, next) = read_varint(stream, cursor) - .ok_or_else(|| format!("{}: truncated root {root}", where_()))?; + let count = (header >> 2) as usize; + let has_derived = header & 2 != 0; + let (roots, next) = decode_slots(stream, cursor, count, &where_)?; + cursor = next; + let derived = if has_derived { + let (derived_count, next) = read_varint(stream, cursor) + .ok_or_else(|| format!("{}: truncated derived count", where_()))?; cursor = next; - let dwarf_reg = match value & 3 { - 0 => DWARF_REG_FP_AARCH64, - 1 => DWARF_REG_SP_AARCH64, - 2 => { - let (reg, next) = read_varint(stream, cursor).ok_or_else(|| { - format!("{}: truncated explicit register for root {root}", where_()) - })?; - cursor = next; - u16::try_from(reg).map_err(|_| { - format!("{}: root {root} register {reg} exceeds u16", where_()) - })? - } - tag => { + let mut bases = Vec::with_capacity(derived_count as usize); + for entry in 0..derived_count { + let (base_index, next) = read_varint(stream, cursor).ok_or_else(|| { + format!("{}: truncated derived base index {entry}", where_()) + })?; + cursor = next; + if base_index as usize >= roots.len() { return Err(format!( - "{}: root {root} has reserved base tag {tag}", - where_() - )) + "{}: derived entry {entry} names base {base_index} of {} roots", + where_(), + roots.len() + )); } - }; - let delta = unzigzag((value >> 2) as u32); - let offset = match last { - None => delta, - Some(previous_offset) => previous_offset.wrapping_add(delta), - }; - last = Some(offset); - roots.push((dwarf_reg, offset)); - } - roots + bases.push(base_index as u32); + } + let (slots, next) = + decode_slots(stream, cursor, derived_count as usize, &where_)?; + cursor = next; + bases + .into_iter() + .zip(slots) + .map(|(base, (reg, off))| (base, reg, off)) + .collect() + } else { + Vec::new() + }; + (roots, derived) }; - if decoded != record.roots { + if decoded.0 != record.roots || decoded.1 != record.derived { return Err(format!( "{}: the compact stream decodes to {decoded:?} but the stack map recorded \ - {:?}. Re-encoding changed this safepoint's live set, so the collector would \ - scan different words than LLVM described.", + roots {:?} derived {:?}. Re-encoding changed this safepoint's live set, so \ + the collector would scan different words than LLVM described.", where_(), - record.roots + record.roots, + record.derived )); } previous = Some(decoded); @@ -1518,14 +1688,17 @@ mod tests { Record { instruction_offset: "0".to_string(), roots: shared.clone(), + derived: Vec::new(), }, Record { instruction_offset: "8".to_string(), roots: shared.clone(), + derived: Vec::new(), }, Record { instruction_offset: "16".to_string(), roots: shared, + derived: Vec::new(), }, ], }]; @@ -1560,6 +1733,54 @@ mod tests { assert!(out.contains("_perry_gc_map:")); } + /// #7803: derived (interior) slots survive the encode/decode round trip, + /// share the repeat flag with their bases, and a differing derived set + /// breaks the repeat. + #[test] + fn derived_slots_roundtrip_and_share_the_repeat_flag() { + let record = |off: &str, derived: Vec<(u32, u16, i32)>| Record { + instruction_offset: off.to_string(), + roots: vec![(29, -16), (29, -8)], + derived, + }; + let repeated = vec![FunctionMap { + symbol: "probe".to_string(), + stack_size: 96, + records: vec![ + record("0", vec![(1, 31, 24)]), + record("16", vec![(1, 31, 24)]), + ], + }]; + let stream = encode_stream(&repeated); + verify_roundtrip(&repeated, &stream).expect("derived records must round-trip"); + let single = vec![FunctionMap { + symbol: "probe".to_string(), + stack_size: 96, + records: vec![record("0", vec![(1, 31, 24)])], + }]; + assert_eq!( + stream.len(), + encode_stream(&single).len() + 1, + "an identical (roots, derived) pair must cost one repeat byte" + ); + + let differing = vec![FunctionMap { + symbol: "probe".to_string(), + stack_size: 96, + records: vec![ + record("0", vec![(1, 31, 24)]), + record("16", vec![(0, 31, 24)]), + ], + }]; + let stream = encode_stream(&differing); + verify_roundtrip(&differing, &stream) + .expect("a differing derived set must re-encode, not repeat"); + assert!( + stream.len() > encode_stream(&single).len() + 1, + "a record whose derived set differs must not take the repeat flag" + ); + } + /// The round-trip check must be able to FAIL. A verifier that only ever /// agrees with itself is CLAUDE.md's fourth gate-failure mode — the gate /// runs, its subject never did — so plant each way the stream can lie and @@ -1575,10 +1796,12 @@ mod tests { Record { instruction_offset: "0".to_string(), roots: vec![(7, 8), (7, 24), (7, 40)], + derived: Vec::new(), }, Record { instruction_offset: "16".to_string(), roots: vec![(7, 8), (7, 24), (7, 40)], + derived: Vec::new(), }, ], }]; @@ -1587,7 +1810,7 @@ mod tests { // A dropped root: the header's count is the first byte of the stream. let mut short = stream.clone(); - short[0] = 2 << 1; + short[0] = 2 << 2; assert!( verify_roundtrip(&functions, &short).is_err(), "a stream claiming fewer roots than the map recorded must be rejected" diff --git a/crates/perry-codegen/src/lower_call/early_branches.rs b/crates/perry-codegen/src/lower_call/early_branches.rs index 7d13aa535d..d8596b89ab 100644 --- a/crates/perry-codegen/src/lower_call/early_branches.rs +++ b/crates/perry-codegen/src/lower_call/early_branches.rs @@ -383,7 +383,22 @@ pub fn try_lower_closure_typed_local_call( // The checked closure-unbox path below validates the current callee; // the erased type only decides whether to try that guarded dispatch. if matches!(ctx.local_type_hint(id), Some(HirType::Function(_))) { + // #7803: the callee outlives the arguments here too, and this is + // the arm on the failing stack — `core/schemas.ts` closure 138, + // whose callee is a mutable-capture box read (`js_box_get_bits`) + // held across the argument lowering below and then unmasked into + // `closure_handle`. root_reload.rs's #7664 note is about exactly + // that unmask: it is where the value leaves the tracked domain, so + // a stale `recv_box` produces a stale handle no relocation fixes, + // and the sink is `js_closure_call1` — "value is not a function". + // + // A root rather than a reload for the same reason as the other two + // arms: re-lowering `LocalGet` below the arguments re-reads the box + // and would observe an assignment an argument made, when JS + // resolved the callee before them. + let mut callee_group = crate::rooting::open_rooted_group(1); let recv_box = lower_expr(ctx, callee)?; + let callee_root = callee_group.adopt(ctx, callee, &recv_box, true); let mut lowered_args: Vec = Vec::with_capacity(args.len()); for a in args { lowered_args.push(lower_expr(ctx, a)?); @@ -408,6 +423,9 @@ pub fn try_lower_closure_typed_local_call( lowered_args.len() ); } + // Re-read below the argument lowering, THEN unmask: the unmask + // must consume the post-relocation address. + let recv_box = callee_group.reread(ctx, callee_root)?; let closure_handle = { let blk = ctx.block(); unbox_to_i64(blk, &recv_box) @@ -1013,6 +1031,9 @@ pub fn try_lower_closure_typed_local_call( if let Some(prev) = prev_this { crate::rooting::implicit_this_restore(ctx, prev); } + // Below both arms' calls, in the merge that post-dominates + // them — which is why this group is `open_rooted_group`. + callee_group.release(ctx); return Ok(Some(merged)); } } @@ -1028,6 +1049,7 @@ pub fn try_lower_closure_typed_local_call( } let result = ctx.block().call(DOUBLE, &runtime_fn, &call_args); crate::rooting::implicit_this_restore(ctx, prev_this); + callee_group.release(ctx); return Ok(Some(result)); } } diff --git a/crates/perry-runtime/src/arena/reset.rs b/crates/perry-runtime/src/arena/reset.rs index 92e6cd5551..3d729a183e 100644 --- a/crates/perry-runtime/src/arena/reset.rs +++ b/crates/perry-runtime/src/arena/reset.rs @@ -35,6 +35,50 @@ pub fn arena_reset_all_blocks_to_zero() { }); } +/// Is layout-neutral from-space poisoning on? Parsed BY VALUE (#7993). +fn poison_fromspace_enabled() -> bool { + use std::sync::OnceLock; + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_POISON_FROMSPACE").ok().as_deref(), + Some("1") | Some("on") | Some("true") + ) + }) +} + +/// Scribble every live byte of a retiring region with the quarantine's poison +/// word, IN PLACE — no detach, no hold, no `mprotect`, so the blocks recycle +/// into Eden exactly as they would have. +/// +/// Only `[0, offset)` is touched: beyond the bump pointer there was never an +/// object, and writing there would dirty pages the allocator has not faulted +/// in yet, which would be a layout change of its own. +fn poison_region_in_place(arena: &mut Arena) { + for block in arena.blocks.iter_mut() { + if block.data.is_null() || block.offset == 0 { + continue; + } + // SAFETY: `[data, data+offset)` is this block's allocated span, owned + // by the arena and dead at this point in the flip. + unsafe { + let words = block.offset / 8; + let p = block.data as *mut u64; + for i in 0..words { + p.add(i) + .write(crate::arena::quarantine::QUARANTINE_POISON_WORD); + } + let tail = block.offset % 8; + let base = block.data as *mut u8; + for i in 0..tail { + base.add(words * 8 + i).write( + (crate::arena::quarantine::QUARANTINE_POISON_WORD >> (8 * (i % 8))) as u8, + ); + } + } + } +} + fn reset_region_to_zero(arena: &mut Arena) -> (usize, usize) { let mut reset_blocks = 0usize; let mut reusable_bytes = 0usize; @@ -124,11 +168,42 @@ pub(crate) fn copying_reset_from_spaces_and_flip() -> ArenaResetStats { if protect_fromspace_enabled() { return copying_quarantine_from_spaces_and_flip(); } + // #7803: LAYOUT-NEUTRAL from-space poisoning, behind + // `PERRY_GC_POISON_FROMSPACE=1`. + // + // Note what this function does NOT do: `reset_region_to_zero` resets + // `block.offset`, it does not zero the bytes. Retired from-space therefore + // keeps the dead objects intact until new allocations bump over them, and + // THAT is why every existing instrument suppresses #7803: + // + // unprotected pages recycle into Eden, new objects overwrite the dead + // ones, and a stale pointer reads A DIFFERENT OBJECT -> + // property miss -> `undefined`. The failure. + // quarantined pages are held out of Eden, nothing overwrites them, a + // stale pointer reads its own dead object, still intact, + // and the program is CORRECT. The suppression. + // + // So the quarantine does not fail to catch the bug by bad luck; it hides + // it by construction, and `--debug-symbols` hides it for the same family + // of reasons (a different layout reuses different bytes). Both of the + // interventions that make #7803 vanish are LAYOUT interventions, while + // four separate rooting fixes left it untouched. + // + // This mode changes no layout at all — same pages, same order, same + // addresses, recycled at the same moment — and only scribbles the retired + // bytes first. A stale read then finds the poison word rather than a + // plausible object, which turns "wrong answer, cycles later, somewhere + // else" into a value that names itself at the point of use + // (`obj_type == QUARANTINE_POISON_OBJ_TYPE`). + let poison = poison_fromspace_enabled(); sync_inline_arena_state(); let mut reset_blocks = 0usize; let mut reusable_bytes = 0usize; ARENA.with(|arena| unsafe { let arena = &mut *arena.get(); + if poison { + poison_region_in_place(arena); + } let (blocks, bytes) = reset_region_to_zero(arena); reset_blocks += blocks; reusable_bytes = reusable_bytes.saturating_add(bytes); @@ -146,6 +221,12 @@ pub(crate) fn copying_reset_from_spaces_and_flip() -> ArenaResetStats { }); let active = ACTIVE_SURVIVOR.with(|active| active.get()); + if poison { + with_survivor_arena_mut(active, |a| { + poison_region_in_place(a); + (0usize, 0usize) + }); + } let (blocks, bytes) = with_survivor_arena_mut(active, reset_region_to_zero); reset_blocks += blocks; reusable_bytes = reusable_bytes.saturating_add(bytes); diff --git a/crates/perry-runtime/src/dyn_eval/expr.rs b/crates/perry-runtime/src/dyn_eval/expr.rs index 039fd4748b..7dc5e1a946 100644 --- a/crates/perry-runtime/src/dyn_eval/expr.rs +++ b/crates/perry-runtime/src/dyn_eval/expr.rs @@ -19,6 +19,9 @@ use super::interp::{bind_pattern, make_function_value, Ctx}; use super::{env, root_get, root_push, roots_truncate, InterpBody}; pub(crate) fn eval_expr(ctx: &Ctx, expr: &ast::Expr, env_idx: usize) -> f64 { + // #7803: the interpreter's cooperative GC safepoint. Inert unless + // `PERRY_GC_INTERP_SAFEPOINTS=1` — see `super::interp_safepoint`. + super::interp_safepoint(); use ast::Expr::*; match expr { Paren(p) => eval_expr(ctx, &p.expr, env_idx), diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index f824523086..ac77100821 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -425,6 +425,10 @@ fn exec_block_scope(ctx: &Ctx, block: &ast::BlockStmt, env_idx: usize) -> Flow { } pub(crate) fn exec_stmt(ctx: &Ctx, stmt: &ast::Stmt, env_idx: usize) -> Flow { + // #7803: statement-granularity safepoint, for the same reason `eval_expr` + // has one. A loop whose body is a single statement with no sub-expression + // that allocates would otherwise still offer nothing. + super::interp_safepoint(); use ast::Stmt::*; match stmt { Expr(e) => { diff --git a/crates/perry-runtime/src/dyn_eval/mod.rs b/crates/perry-runtime/src/dyn_eval/mod.rs index 3629720a2b..a2822b0334 100644 --- a/crates/perry-runtime/src/dyn_eval/mod.rs +++ b/crates/perry-runtime/src/dyn_eval/mod.rs @@ -179,6 +179,74 @@ pub(crate) fn lookup_fn(id: u32) -> Option> { FN_REGISTRY.with(|r| r.borrow().get(&id).cloned()) } +// ── GC safepoints ────────────────────────────────────────────────────────── + +/// Offer a GC safepoint at an interpreter step, behind +/// `PERRY_GC_INTERP_SAFEPOINTS=1` (#7803 tooling). +/// +/// # The structural gap this closes +/// +/// Compiled code offers the collector cooperative safepoints at loop +/// back-edges (`PERRY_GC_MOVING_LOOP_POLLS`, default on since #7721). The +/// interpreter offers NONE. A collection can therefore only reach interpreted +/// execution at an *allocation* point — and the alloc-point arm forces a +/// conservative stack scan, which finds Rust locals and makes the copying +/// minor ineligible. The consequence is not that the interpreter is safe; it +/// is that the interpreter is **untestable**: +/// +/// * `PERRY_GC_ZEAL` forces collection at safepoints, and there are none here; +/// * `PERRY_GC_SCHEDULE_SEED` selects safepoints, and there are none here; +/// * `gc_root_dominance_check.py` reads emitted LLVM IR, and there is none +/// here. +/// +/// So the one rooting domain with no static checker also has no dynamic one. +/// That is the finding #7803 turned up, independent of what its own root cause +/// turns out to be: `dyn_eval/mod.rs` claims "interpreter frames hold every +/// live JSValue in a rooted thread-local value stack", and nothing in the tree +/// can currently falsify that sentence. +/// +/// This gives the existing instruments a handle. It routes through +/// `js_gc_loop_safepoint`, deliberately, rather than collecting directly: +/// every entry guard (in-alloc, root-lock, unsafe-FFI-zone, budgeted-cycle) +/// and the seeded-schedule ordinal apply exactly as they do to a compiled +/// back-edge, so an interpreter safepoint is the *same* safepoint, not a +/// second kind. +/// +/// # Why it is opt-in rather than on +/// +/// Turning it on lets the precise moving collector run at points where +/// interpreted frames are live. If the interpreter's rooting is complete that +/// is simply better — the copying minor becomes eligible where only a +/// conservative sweep could run before. If it is NOT complete, this converts a +/// latent hole into a live crash for exactly the workloads `dyn_eval` exists +/// to serve (ajv, fast-json-stringify, find-my-way, fastify). Shipping that +/// flip before the rooting is verified would be trading a quiet bug for a loud +/// one in someone else's server. +/// +/// So it lands as an instrument, and the flip to default-on is a separate, +/// evidence-gated decision — the same sequencing `PERRY_GC_MOVING_LOOP_POLLS` +/// had between #7161 and #7721. +/// +/// Parsed BY VALUE (`1`/`on`/`true`), never by presence — see #7993. +#[inline] +pub(crate) fn interp_safepoint() { + if !interp_safepoints_enabled() { + return; + } + crate::gc::js_gc_loop_safepoint(); +} + +fn interp_safepoints_enabled() -> bool { + use std::sync::OnceLock; + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_INTERP_SAFEPOINTS").ok().as_deref(), + Some("1") | Some("on") | Some("true") + ) + }) +} + // ── rooted value stack ───────────────────────────────────────────────────── /// Push a value onto the rooted stack; returns its index. The index stays diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index da59d2eedb..ef184327c1 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -275,6 +275,7 @@ pub extern "C" fn js_throw(value: f64) -> ! { if (*s).try_depth == 0 { print_uncaught(value); + emit_uncaught_backtrace(); std::process::exit(1); } @@ -374,6 +375,7 @@ pub extern "C" fn js_throw(value: f64) -> ! { or an intermediate object was built without unwind tables." ); print_uncaught(value); + emit_uncaught_backtrace(); std::process::abort(); } @@ -437,6 +439,58 @@ pub(crate) unsafe fn string_header_to_string(ptr: *const crate::string::StringHe .to_string() } +/// Emit a symbolicated native backtrace for an UNCAUGHT throw, behind +/// `PERRY_UNCAUGHT_BACKTRACE=1` (#7803 tooling). +/// +/// # Why this exists +/// +/// A #7154-class rooting bug surfaces as an uncaught `TypeError` in a function +/// that is nowhere near the code that lost the value, and the JS-level `stack` +/// this path already prints reads `at ` — one frame, no name. The +/// native stack, in contrast, names every compiled JS frame: `--debug-symbols` +/// keeps 1726 `_perry_fn_*` / `_perry_closure_*` symbols in the corpus binary, +/// so `backtrace_symbols_fd` resolves the whole chain through `dladdr`. +/// +/// The obvious alternative — run the failing binary under a debugger and break +/// on the throw helper — was tried first for #7803 and is NOT equivalent: the +/// failure is intermittent, and under `lldb` the same seeds that fail natively +/// pass. An instrument that only works when the bug does not reproduce is not +/// an instrument. This one runs in the ordinary process, so it observes the +/// run that actually fails. +/// +/// Off by default and read once per uncaught throw, i.e. at most once per +/// process, on a path that is already about to `exit(1)`. +/// +/// Parsed by VALUE, not by presence: `PERRY_GC_DIAG` was `var_os(..).is_some()` +/// for long enough that `PERRY_GC_DIAG=0` ENABLED diagnostics and silently +/// collapsed one arm of an A/B (ZOD-NOTES §3, fixed in #7993). A new knob does +/// not get to repeat that. +fn emit_uncaught_backtrace() { + let on = matches!( + std::env::var("PERRY_UNCAUGHT_BACKTRACE").ok().as_deref(), + Some("1") | Some("on") | Some("true") + ); + if !on { + return; + } + #[cfg(all(unix, any(target_os = "macos", target_os = "linux")))] + { + const MAX_FRAMES: usize = 96; + let mut frames = [std::ptr::null_mut::(); MAX_FRAMES]; + eprintln!("--- native backtrace at the uncaught throw ---"); + // SAFETY: `backtrace` / `backtrace_symbols_fd` are the async-signal-safe + // pair — `_fd` writes to the descriptor directly and does not allocate. + // Same call shape as `arena::quarantine::emit_native_backtrace`. + unsafe { + let n = libc::backtrace(frames.as_mut_ptr(), MAX_FRAMES as libc::c_int); + if n > 0 { + libc::backtrace_symbols_fd(frames.as_ptr(), n, 2); + } + } + eprintln!("--- end native backtrace ---"); + } +} + /// Best-effort display of a thrown value for uncaught-exception reporting. /// Matches Node semantics roughly: Errors print `name: message` + stack, /// regular objects probe for `.message`/`.stack`, everything else goes diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 399e62f8c5..800f9afdf0 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -7,246 +7,6 @@ use super::*; /// silently be left behind in from-space. pub(crate) const MAX_YOUNG_MOVE_BYTES: usize = 1 << 20; // 1 MiB, >> any real young object -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum CopyingPointerKind { - Eden, - FromSurvivor, - ToSurvivor, - Longlived, - Old, - Malloc, - /// On a block this cycle is promoting whole, in place (#7742). Generation - /// is already `Old` — so every barrier predicate reads old-gen semantics — - /// but the object was young when the cycle began and therefore still owes - /// the collector exactly one field scan. - PromotedYoung, -} - -#[derive(Clone, Copy)] -pub(crate) struct CopyingPointer { - pub(crate) header: *mut GcHeader, - pub(super) kind: CopyingPointerKind, -} - -pub(crate) struct CopyingPointerSet { - pub(super) malloc_registry_available: Cell, - pub(super) malloc_registry_empty_at_start: bool, - pub(super) malloc_validation_lookups: Cell, - pub(super) malloc_registry_rebuild_count_start: u64, -} - -impl CopyingPointerSet { - pub(super) fn new() -> Self { - let (malloc_registry_available, malloc_registry_empty_at_start) = MALLOC_STATE.with(|s| { - let mut s = s.borrow_mut(); - // Moving-nursery mode (`PERRY_GC_MOVING_LOOP_POLLS`): eagerly build the - // malloc registry so this copying minor can CLASSIFY malloc-tracked - // objects and evacuate, instead of hitting - // `MallocRegistryUnavailable` and falling back to a non-moving minor - // (which reclaims ~nothing on reallocation-heavy async/Map/generator - // code — measured: broad3 192 MiB / 100 fallbacks). The - // O(malloc-objects) rebuild is paid back by the RSS win. Default - // (non-moving) copied minors keep the lazy behavior — see - // `ensure_set_built`'s "keep copied-minor from rebuilding" note. - if super::gc_moving_loop_polls_enabled() && !s.objects.is_empty() { - super::malloc::ensure_set_built(&mut s); - } - (s.malloc_registry_available(), s.objects.is_empty()) - }); - let malloc_registry_rebuild_count_start = MALLOC_REGISTRY_REBUILD_COUNT.with(|c| c.get()); - Self { - malloc_registry_available: Cell::new(malloc_registry_available), - malloc_registry_empty_at_start, - malloc_validation_lookups: Cell::new(0), - malloc_registry_rebuild_count_start, - } - } - - #[inline] - pub(crate) fn classify(&self, addr: usize) -> Option { - self.classify_arena(addr) - .or_else(|| self.classify_malloc(addr)) - } - - #[inline] - pub(super) fn classify_for_preflight( - &self, - addr: usize, - possible_malloc: bool, - ) -> Result, CopiedMinorFallbackReason> { - if let Some(ptr) = self.classify_arena(addr) { - return Ok(Some(ptr)); - } - if possible_malloc && !self.malloc_registry_available.get() { - // With no malloc-tracked objects, every non-arena candidate is - // exactly rejectable without activating the lazy header registry. - if self.malloc_registry_empty_at_start { - return Ok(None); - } - return Err(CopiedMinorFallbackReason::MallocRegistryUnavailable); - } - Ok(self.classify_malloc(addr)) - } - - #[inline] - pub(super) fn classify_arena(&self, addr: usize) -> Option { - if addr < GC_HEADER_SIZE { - return None; - } - // ONE range lookup answers both classifications this needs. The header - // sits `GC_HEADER_SIZE` below the user pointer and a block always - // begins with a header, so a real object's header is on the same - // registered range as its user pointer; `range_base` is the guard that - // keeps a garbage candidate sitting at the very start of a range from - // becoming a read of the unmapped page below it. Before #7742 this was - // two `classify_heap_space` calls for addresses 8 bytes apart, on - // EVERY visited slot. - let Some((space, range_base)) = crate::arena::classify_heap_space_in_range(addr) else { - return None; - }; - let header_addr = addr - GC_HEADER_SIZE; - if header_addr < range_base { - return None; - } - debug_assert_eq!( - crate::arena::classify_heap_space(header_addr), - space, - "an object's header and user pointer must classify identically" - ); - if !matches!( - space, - crate::arena::HeapSpace::NurseryEden - | crate::arena::HeapSpace::Survivor0 - | crate::arena::HeapSpace::Survivor1 - | crate::arena::HeapSpace::Longlived - | crate::arena::HeapSpace::Old - | crate::arena::HeapSpace::PromotedYoung - ) { - return None; - } - let header = header_addr as *mut GcHeader; - if unsafe { !plausible_gc_header(header, true) } { - return None; - } - // The two survivor-space readings are TLS loads, and Darwin has no - // local-exec TLS — each is a real `_tlv_get_addr` call. Reading them - // eagerly cost two per classified pointer on workloads that never touch - // a survivor at all (`retain.ts` classifies Eden / PromotedYoung / Old - // and nothing else). They can only ever answer `Survivor0`, `Survivor1` - // or `Unknown`, and `space` is already narrowed to the six accepted - // spaces, so hoisting the non-survivor arms above them changes no - // verdict — it just stops paying for an answer the arm does not use. - let kind = match space { - crate::arena::HeapSpace::NurseryEden => CopyingPointerKind::Eden, - crate::arena::HeapSpace::PromotedYoung => CopyingPointerKind::PromotedYoung, - crate::arena::HeapSpace::Longlived => CopyingPointerKind::Longlived, - crate::arena::HeapSpace::Old => CopyingPointerKind::Old, - s if s == crate::arena::active_survivor_space() => CopyingPointerKind::FromSurvivor, - s if s == crate::arena::inactive_survivor_space() => CopyingPointerKind::ToSurvivor, - _ => return None, - }; - Some(CopyingPointer { header, kind }) - } - - #[inline] - pub(super) fn classify_malloc(&self, addr: usize) -> Option { - if addr < GC_HEADER_SIZE || !self.malloc_registry_available.get() { - return None; - } - let header = unsafe { header_from_user_ptr(addr as *const u8) }; - self.malloc_validation_lookups - .set(self.malloc_validation_lookups.get().saturating_add(1)); - MALLOC_STATE.with(|s| { - let mut s = s.borrow_mut(); - if !s.set.contains(&(header as usize)) { - s.record_copied_minor_validation_lookup(None); - return None; - } - let obj_type = - unsafe { plausible_gc_header(header, false).then_some((*header).obj_type) }; - s.record_copied_minor_validation_lookup(obj_type); - obj_type.map(|_| CopyingPointer { - header, - kind: CopyingPointerKind::Malloc, - }) - }) - } - - #[inline] - pub(super) fn raw_pointer_candidate(bits: u64) -> bool { - (0x1000..=POINTER_MASK).contains(&bits) && bits & 0x7 == 0 - } - - #[inline] - pub(super) fn decode_bits(&self, bits: u64) -> Option<(usize, bool, u64)> { - let tag = bits & TAG_MASK; - if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { - let addr = (bits & POINTER_MASK) as usize; - return (addr != 0).then_some((addr, true, tag)); - } - if tag >= 0x7FF8_0000_0000_0000 { - return None; - } - if !Self::raw_pointer_candidate(bits) { - return None; - } - let addr = bits as usize; - self.classify(addr).map(|_| (addr, false, 0)) - } - - #[inline] - pub(super) fn decode_bits_for_preflight( - &self, - bits: u64, - ) -> Result, CopiedMinorFallbackReason> { - let tag = bits & TAG_MASK; - if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { - let addr = (bits & POINTER_MASK) as usize; - if addr == 0 { - return Ok(None); - } - return self - .classify_for_preflight(addr, true) - .map(|ptr| ptr.map(|ptr| (addr, ptr))); - } - if tag >= 0x7FF8_0000_0000_0000 || !Self::raw_pointer_candidate(bits) { - return Ok(None); - } - let addr = bits as usize; - self.classify_for_preflight(addr, true) - .map(|ptr| ptr.map(|ptr| (addr, ptr))) - } - - #[inline] - pub(super) fn malloc_validation_lookups(&self) -> usize { - self.malloc_validation_lookups.get() - } - - #[inline] - pub(super) fn malloc_registry_rebuilds(&self) -> u64 { - MALLOC_REGISTRY_REBUILD_COUNT.with(|c| { - c.get() - .saturating_sub(self.malloc_registry_rebuild_count_start) - }) - } -} - -pub(super) unsafe fn plausible_gc_header(header: *mut GcHeader, arena: bool) -> bool { - if header.is_null() { - return false; - } - let obj_type = (*header).obj_type; - if gc_type_info(obj_type).is_none() { - return false; - } - let size = (*header).size as usize; - if size < GC_HEADER_SIZE || size as u64 > (1u64 << 34) { - return false; - } - let is_arena = (*header).gc_flags & GC_FLAG_ARENA != 0; - is_arena == arena -} - pub(super) struct CopyingNurseryPreflight { pub(super) ptrs: *const CopyingPointerSet, pub(super) fallback_reason: Option, @@ -1471,7 +1231,9 @@ pub(super) fn run_copied_minor_attempt( let native_stack_walk = if untraced { Default::default() } else { + let _phase = super::pin::CopyingWalkPhaseGuard::enter("mutable_root_slots"); visit_mutable_root_slots(|slot| unsafe { + let _kind = super::pin::CopyingWalkPhaseGuard::enter(slot.kind.walk_phase_name()); let bits = slot.read(); if let Some(trace) = trace.as_mut() { let pointer_root = collector.ptrs.decode_bits(bits).is_some(); @@ -1535,6 +1297,7 @@ pub(super) fn run_copied_minor_attempt( }; let before = super::scanner_profile::snapshot_stats(stats); let previous = visitor.set_root_source_stats(stats); + let _phase = super::pin::CopyingWalkPhaseGuard::enter(entry.name); let (_, nanos) = super::scanner_profile::record_scanner(|| { (entry.scanner)(&mut visitor); }); @@ -1557,6 +1320,7 @@ pub(super) fn run_copied_minor_attempt( // cycle — a missing-edge bug one collection later. let snapshot = remembered_dirty_snapshot(); if !untraced { + let _phase = super::pin::CopyingWalkPhaseGuard::enter("remembered_set"); let remembered_stats = scan_remembered_dirty_slots_copying( &snapshot, |slot, header, external, stats| unsafe { @@ -1571,22 +1335,8 @@ pub(super) fn run_copied_minor_attempt( trace.remembered_set = remembered_stats; } } - if !collector.skip_remembering { - let promoted_sticky = - rebuild_evacuated_old_to_young_remembered_set(&collector.moved_headers); - promoted_sticky.restore(); - collector.sticky.extend(promoted_sticky); - } - if gc_verify_evacuation_enabled() { - let phase_start = trace_phase_start(trace); - let old_young_edge_verifier = verify_old_to_young_edges_covered(); - trace_phase_record(trace, "old_young_edge_verify", phase_start); - if let Some(trace) = trace.as_mut() { - trace.old_young_edge_verifier = old_young_edge_verifier; - } - } - unsafe { + let _phase = super::pin::CopyingWalkPhaseGuard::enter("worklist_drain"); collector.drain(); } { @@ -1611,6 +1361,7 @@ pub(super) fn run_copied_minor_attempt( }; let before = super::scanner_profile::snapshot_stats(stats); let previous = visitor.set_root_source_stats(stats); + let _phase = super::pin::CopyingWalkPhaseGuard::enter(entry.name); let (_, nanos) = super::scanner_profile::record_scanner(|| { (entry.scanner)(&mut visitor); }); @@ -1619,6 +1370,51 @@ pub(super) fn run_copied_minor_attempt( } visit_ffi_mutable_registered_roots_with_sources(&mut visitor, root_sources); } + // #7803 THE FIX: rebuild the promoted-object remembered set AFTER the last + // phase that can move an object, not before the drain. + // + // This block used to sit above `collector.drain()`. At that point + // `moved_headers` holds only the objects the ROOT walks and the + // remembered-set scan moved; everything the DRAIN promotes — i.e. every + // transitively-reachable object, which is most of the heap — is appended + // after the rebuild has already run. A parent promoted to Old mid-drain + // whose child stays young therefore had NO remembered-set entry: the + // collector's own drain rewrote its slots (the mutator barrier never + // fires for collector writes, so its page was never dirty), the next + // minor moved the child again without tracing the parent, and the + // parent's slot kept the previous survivor-space address. zod's schema + // metadata — built once at module init, promoted after 2 survivals, + // never written again — is exactly that shape, and the whole-heap + // from-space scan caught it at scheduled collection #2 of every seeded + // run: `owner space=Old +120 bare -> Survivor1 MISSING-REWRITE + // [ever_dirty=false]`, i.e. `never_dirty` — a slot no barrier ever saw. + // + // Down here `moved_headers` is complete and every slot has been + // rewritten to its final address, so the young-pointer classification + // the rebuild performs is exact rather than a from-space + // over-approximation. Headers still carry GC_FLAG_MARKED (clear_marks + // runs later), which the per-object gate requires. + if !collector.skip_remembering { + let promoted_sticky = + rebuild_evacuated_old_to_young_remembered_set(&collector.moved_headers); + promoted_sticky.restore(); + collector.sticky.extend(promoted_sticky); + } + if gc_verify_evacuation_enabled() { + let phase_start = trace_phase_start(trace); + let old_young_edge_verifier = verify_old_to_young_edges_covered(); + trace_phase_record(trace, "old_young_edge_verify", phase_start); + if let Some(trace) = trace.as_mut() { + trace.old_young_edge_verifier = old_young_edge_verifier; + } + } + // #7803: PERRY_GC_NATIVE_SLOT_VERIFY=1 — abort on the cycle that leaves a + // native slot naming from-space, instead of many cycles later at the + // pin-latch. Placed after every rewrite pass, before the from-space flip. + super::roots::stack_maps_publish_rewrite_walk_stats(&native_stack_walk); + super::roots::stack_maps_native_slot_verify(untraced, &|addr| { + format!("{:?}", collector.ptrs.classify(addr).map(|ptr| ptr.kind)) + }); trace_phase_record(trace, "copying_nursery", phase_start); // #7937: the attempt's own trace has finished, so the ratio it was missing diff --git a/crates/perry-runtime/src/gc/copying_pointer_set.rs b/crates/perry-runtime/src/gc/copying_pointer_set.rs new file mode 100644 index 0000000000..c690fa1d34 --- /dev/null +++ b/crates/perry-runtime/src/gc/copying_pointer_set.rs @@ -0,0 +1,248 @@ +//! The copied minor's pointer classifier: what kind of heap location a +//! candidate address names, and the header plausibility test that backs it. +//! +//! A SIBLING of `copying.rs` rather than a child module so that the `super::` +//! paths in these bodies keep resolving to `gc` — this is pure code motion out +//! of a file at the 2000-line cap, not a re-scoping. + +use super::*; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum CopyingPointerKind { + Eden, + FromSurvivor, + ToSurvivor, + Longlived, + Old, + Malloc, + /// On a block this cycle is promoting whole, in place (#7742). Generation + /// is already `Old` — so every barrier predicate reads old-gen semantics — + /// but the object was young when the cycle began and therefore still owes + /// the collector exactly one field scan. + PromotedYoung, +} + +#[derive(Clone, Copy)] +pub(crate) struct CopyingPointer { + pub(crate) header: *mut GcHeader, + pub(super) kind: CopyingPointerKind, +} + +pub(crate) struct CopyingPointerSet { + pub(super) malloc_registry_available: Cell, + pub(super) malloc_registry_empty_at_start: bool, + pub(super) malloc_validation_lookups: Cell, + pub(super) malloc_registry_rebuild_count_start: u64, +} + +impl CopyingPointerSet { + pub(super) fn new() -> Self { + let (malloc_registry_available, malloc_registry_empty_at_start) = MALLOC_STATE.with(|s| { + let mut s = s.borrow_mut(); + // Moving-nursery mode (`PERRY_GC_MOVING_LOOP_POLLS`): eagerly build the + // malloc registry so this copying minor can CLASSIFY malloc-tracked + // objects and evacuate, instead of hitting + // `MallocRegistryUnavailable` and falling back to a non-moving minor + // (which reclaims ~nothing on reallocation-heavy async/Map/generator + // code — measured: broad3 192 MiB / 100 fallbacks). The + // O(malloc-objects) rebuild is paid back by the RSS win. Default + // (non-moving) copied minors keep the lazy behavior — see + // `ensure_set_built`'s "keep copied-minor from rebuilding" note. + if super::gc_moving_loop_polls_enabled() && !s.objects.is_empty() { + super::malloc::ensure_set_built(&mut s); + } + (s.malloc_registry_available(), s.objects.is_empty()) + }); + let malloc_registry_rebuild_count_start = MALLOC_REGISTRY_REBUILD_COUNT.with(|c| c.get()); + Self { + malloc_registry_available: Cell::new(malloc_registry_available), + malloc_registry_empty_at_start, + malloc_validation_lookups: Cell::new(0), + malloc_registry_rebuild_count_start, + } + } + + #[inline] + pub(crate) fn classify(&self, addr: usize) -> Option { + self.classify_arena(addr) + .or_else(|| self.classify_malloc(addr)) + } + + #[inline] + pub(super) fn classify_for_preflight( + &self, + addr: usize, + possible_malloc: bool, + ) -> Result, CopiedMinorFallbackReason> { + if let Some(ptr) = self.classify_arena(addr) { + return Ok(Some(ptr)); + } + if possible_malloc && !self.malloc_registry_available.get() { + // With no malloc-tracked objects, every non-arena candidate is + // exactly rejectable without activating the lazy header registry. + if self.malloc_registry_empty_at_start { + return Ok(None); + } + return Err(CopiedMinorFallbackReason::MallocRegistryUnavailable); + } + Ok(self.classify_malloc(addr)) + } + + #[inline] + pub(super) fn classify_arena(&self, addr: usize) -> Option { + if addr < GC_HEADER_SIZE { + return None; + } + // ONE range lookup answers both classifications this needs. The header + // sits `GC_HEADER_SIZE` below the user pointer and a block always + // begins with a header, so a real object's header is on the same + // registered range as its user pointer; `range_base` is the guard that + // keeps a garbage candidate sitting at the very start of a range from + // becoming a read of the unmapped page below it. Before #7742 this was + // two `classify_heap_space` calls for addresses 8 bytes apart, on + // EVERY visited slot. + let Some((space, range_base)) = crate::arena::classify_heap_space_in_range(addr) else { + return None; + }; + let header_addr = addr - GC_HEADER_SIZE; + if header_addr < range_base { + return None; + } + debug_assert_eq!( + crate::arena::classify_heap_space(header_addr), + space, + "an object's header and user pointer must classify identically" + ); + if !matches!( + space, + crate::arena::HeapSpace::NurseryEden + | crate::arena::HeapSpace::Survivor0 + | crate::arena::HeapSpace::Survivor1 + | crate::arena::HeapSpace::Longlived + | crate::arena::HeapSpace::Old + | crate::arena::HeapSpace::PromotedYoung + ) { + return None; + } + let header = header_addr as *mut GcHeader; + if unsafe { !plausible_gc_header(header, true) } { + return None; + } + // The two survivor-space readings are TLS loads, and Darwin has no + // local-exec TLS — each is a real `_tlv_get_addr` call. Reading them + // eagerly cost two per classified pointer on workloads that never touch + // a survivor at all (`retain.ts` classifies Eden / PromotedYoung / Old + // and nothing else). They can only ever answer `Survivor0`, `Survivor1` + // or `Unknown`, and `space` is already narrowed to the six accepted + // spaces, so hoisting the non-survivor arms above them changes no + // verdict — it just stops paying for an answer the arm does not use. + let kind = match space { + crate::arena::HeapSpace::NurseryEden => CopyingPointerKind::Eden, + crate::arena::HeapSpace::PromotedYoung => CopyingPointerKind::PromotedYoung, + crate::arena::HeapSpace::Longlived => CopyingPointerKind::Longlived, + crate::arena::HeapSpace::Old => CopyingPointerKind::Old, + s if s == crate::arena::active_survivor_space() => CopyingPointerKind::FromSurvivor, + s if s == crate::arena::inactive_survivor_space() => CopyingPointerKind::ToSurvivor, + _ => return None, + }; + Some(CopyingPointer { header, kind }) + } + + #[inline] + pub(super) fn classify_malloc(&self, addr: usize) -> Option { + if addr < GC_HEADER_SIZE || !self.malloc_registry_available.get() { + return None; + } + let header = unsafe { header_from_user_ptr(addr as *const u8) }; + self.malloc_validation_lookups + .set(self.malloc_validation_lookups.get().saturating_add(1)); + MALLOC_STATE.with(|s| { + let mut s = s.borrow_mut(); + if !s.set.contains(&(header as usize)) { + s.record_copied_minor_validation_lookup(None); + return None; + } + let obj_type = + unsafe { plausible_gc_header(header, false).then_some((*header).obj_type) }; + s.record_copied_minor_validation_lookup(obj_type); + obj_type.map(|_| CopyingPointer { + header, + kind: CopyingPointerKind::Malloc, + }) + }) + } + + #[inline] + pub(super) fn raw_pointer_candidate(bits: u64) -> bool { + (0x1000..=POINTER_MASK).contains(&bits) && bits & 0x7 == 0 + } + + #[inline] + pub(super) fn decode_bits(&self, bits: u64) -> Option<(usize, bool, u64)> { + let tag = bits & TAG_MASK; + if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { + let addr = (bits & POINTER_MASK) as usize; + return (addr != 0).then_some((addr, true, tag)); + } + if tag >= 0x7FF8_0000_0000_0000 { + return None; + } + if !Self::raw_pointer_candidate(bits) { + return None; + } + let addr = bits as usize; + self.classify(addr).map(|_| (addr, false, 0)) + } + + #[inline] + pub(super) fn decode_bits_for_preflight( + &self, + bits: u64, + ) -> Result, CopiedMinorFallbackReason> { + let tag = bits & TAG_MASK; + if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { + let addr = (bits & POINTER_MASK) as usize; + if addr == 0 { + return Ok(None); + } + return self + .classify_for_preflight(addr, true) + .map(|ptr| ptr.map(|ptr| (addr, ptr))); + } + if tag >= 0x7FF8_0000_0000_0000 || !Self::raw_pointer_candidate(bits) { + return Ok(None); + } + let addr = bits as usize; + self.classify_for_preflight(addr, true) + .map(|ptr| ptr.map(|ptr| (addr, ptr))) + } + + #[inline] + pub(super) fn malloc_validation_lookups(&self) -> usize { + self.malloc_validation_lookups.get() + } + + #[inline] + pub(super) fn malloc_registry_rebuilds(&self) -> u64 { + MALLOC_REGISTRY_REBUILD_COUNT.with(|c| { + c.get() + .saturating_sub(self.malloc_registry_rebuild_count_start) + }) + } +} + +pub(super) unsafe fn plausible_gc_header(header: *mut GcHeader, arena: bool) -> bool { + if header.is_null() { + return false; + } + let obj_type = (*header).obj_type; + if gc_type_info(obj_type).is_none() { + return false; + } + let size = (*header).size as usize; + if size < GC_HEADER_SIZE || size as u64 > (1u64 << 34) { + return false; + } + let is_arena = (*header).gc_flags & GC_FLAG_ARENA != 0; + is_arena == arena +} diff --git a/crates/perry-runtime/src/gc/fromspace_scan.rs b/crates/perry-runtime/src/gc/fromspace_scan.rs index 9c02b97245..348e07c694 100644 --- a/crates/perry-runtime/src/gc/fromspace_scan.rs +++ b/crates/perry-runtime/src/gc/fromspace_scan.rs @@ -84,6 +84,11 @@ pub(crate) struct FromSpaceRef { pub(crate) struct FromSpaceScanReport { pub(crate) objects_scanned: usize, pub(crate) words_scanned: usize, + /// Words inside an array's unused capacity, excluded from the scan: no + /// collector walk can ever rewrite them, so they can only manufacture + /// false MISSING-REWRITEs (see the bound in `scan_object`). Counted so + /// the exclusion is visible in the report, not a silent shrink. + pub(crate) array_slack_words_skipped: usize, /// Owners skipped because they are themselves FORWARDED (dead relocation /// stubs). Reported so the filter can never be mistaken for a fix. pub(crate) forwarded_owners_skipped: usize, @@ -163,7 +168,7 @@ fn fromspace_scan_abort() -> bool { /// so is whichever survivor semispace was active going INTO the cycle (the /// flip happens later, inside `copying_reset_from_spaces_and_flip`). #[inline] -fn is_from_space(space: crate::arena::HeapSpace) -> bool { +pub(super) fn is_from_space(space: crate::arena::HeapSpace) -> bool { space == crate::arena::HeapSpace::NurseryEden || space == crate::arena::active_survivor_space() } @@ -204,7 +209,29 @@ unsafe fn scan_object(header: *mut GcHeader, report: &mut FromSpaceScanReport) { } report.objects_scanned += 1; - let payload_words = (total - GC_HEADER_SIZE) / 8; + let mut payload_words = (total - GC_HEADER_SIZE) / 8; + // #7803 identification postmortem: an ARRAY's payload past + // `ArrayHeader + length*8` is unused capacity, and on an old-gen HOLE + // REUSE it still holds the previous occupant's bytes — the dump that + // settled this showed a dead StringHeader ("StringDecoder") and a stale + // survivor pointer sitting in the slack of a live 8-element array. + // Marking, rewriting and the dirty scan all stop at `length` (the + // element range is length-keyed), so a word past it is invisible to + // every collector walk BY DESIGN and can never be rewritten. Scanning + // it manufactures a deterministic MISSING-REWRITE that reads exactly + // like the defect this instrument hunts — it cost this hunt a full + // false root cause before the owner dump exposed it. Bound the scan by + // the same length the collector uses; the bound is counted so a + // shrinking scan cannot silently read as a cleaner heap. + if (*header).obj_type == crate::gc::GC_TYPE_ARRAY { + let arr = user as *const crate::array::ArrayHeader; + let live_words = + std::mem::size_of::() / 8 + (*arr).length as usize; + if live_words < payload_words { + report.array_slack_words_skipped += payload_words - live_words; + payload_words = live_words; + } + } let words = user as *const u64; for i in 0..payload_words { let bits = *words.add(i); @@ -336,8 +363,58 @@ fn describe(r: &FromSpaceRef) -> String { ) } +/// #7803 identification dump: the offender line names the owner's GC type +/// and slot offset, which for an array does not say WHICH array. Dump its +/// header words and the first payload words with a per-word classification +/// so the abort identifies the structure semantically — one run instead of +/// a watchpoint hunt (mmap ASLR defeats address-pinned watchpoints here). +unsafe fn dump_owner(r: &FromSpaceRef) { + let header = r.owner_header as *const GcHeader; + let user = (r.owner_header as *const u8).add(GC_HEADER_SIZE); + let total = (*header).size as usize; + let payload_words = ((total - GC_HEADER_SIZE) / 8).min(24); + eprintln!( + "[gc-fromspace-scan abort] owner dump: obj_type={} size={} first_u32={} second_u32={}", + (*header).obj_type, + total, + *(user as *const u32), + *(user.add(4) as *const u32), + ); + let words = user as *const u64; + for i in 0..payload_words { + let bits = *words.add(i); + let decoded = super::root_words::decode_root_word(bits); + let class = match decoded { + Some(w) => format!( + "heap({:?}{})", + crate::arena::classify_heap_space(w.addr()), + match w { + super::root_words::RootWord::Nanboxed { .. } => ", boxed", + super::root_words::RootWord::Bare { .. } => ", bare", + } + ), + None => "-".to_string(), + }; + eprintln!( + "[gc-fromspace-scan abort] +{:<4} {:#018x} f64={:<24} {}{}", + i * 8, + bits, + format!("{:e}", f64::from_bits(bits)), + class, + if i * 8 == r.slot_offset { + " <-- OFFENDER" + } else { + "" + }, + ); + } +} + fn report_and_abort(report: &FromSpaceScanReport) -> ! { emit_report(report, "abort"); + for sample in &report.samples { + unsafe { dump_owner(sample) }; + } // The scan runs inside the collector, so this backtrace names the // COLLECTION, not the mutator store that created the stale slot — which is // exactly the limitation `PERRY_GC_PROTECT_FROMSPACE` exists to remove @@ -356,10 +433,11 @@ fn report_and_abort(report: &FromSpaceScanReport) -> ! { pub(super) fn emit_report(report: &FromSpaceScanReport, phase: &str) { eprintln!( - "[gc-fromspace-scan {}] objects={} words={} fwd_owners_skipped={} missing_rewrites={} dangling={} owners={} | never_dirty={} lost_dirty={} dirty_but_missed={}", + "[gc-fromspace-scan {}] objects={} words={} array_slack_skipped={} fwd_owners_skipped={} missing_rewrites={} dangling={} owners={} | never_dirty={} lost_dirty={} dirty_but_missed={}", phase, report.objects_scanned, report.words_scanned, + report.array_slack_words_skipped, report.forwarded_owners_skipped, report.missing_rewrites, report.dangling, diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 38efa8489e..2b8b87f444 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -116,6 +116,17 @@ use barrier_arming::*; /// eligibility preflight is skipped on. Every write of the bit goes through /// `pin::pin_object`; `scripts/gc_pin_sites.py` enforces that in `lint`. mod pin; + +/// #7803 diagnostics: expose the pin-latch's header-coherence verdict to +/// runtime-side producer traps (e.g. `object::this_binding::this_set_check`) +/// so every instrument grades headers with the same rules. +pub(crate) fn header_incoherence_for_diagnostics( + obj_type: u8, + size: u32, + flags: u8, +) -> Option { + pin::header_incoherence(obj_type, size, flags) +} #[cfg(test)] pub(crate) use pin::test_reset_young_pin_latch; pub use pin::{ @@ -129,15 +140,19 @@ mod prefetch; mod copying; mod copying_first_cycle; +mod copying_pointer_set; /// Per-scanner root attribution for the copied-minor root scan (#7915). mod scanner_profile; mod sticky_remembered; use copying::*; use copying_first_cycle::*; +// Named rather than glob-imported: a glob does not propagate through the +// transitive re-exports the gc submodules reach these through. +use copying_pointer_set::{plausible_gc_header, CopyingPointer, CopyingPointerKind}; use sticky_remembered::*; // The copied-minor pointer classifier is consumed by the weak-holder registry // pass in `crate::weakref` (#6182), which lives outside the gc module. -pub(crate) use copying::CopyingPointerSet; +pub(crate) use copying_pointer_set::CopyingPointerSet; // The hard ceiling every birth-generation threshold in `gc::types` must stay // under; asserted by `arena::tests::pointer_bearing_large_object_threshold_is_movable`. #[cfg(test)] diff --git a/crates/perry-runtime/src/gc/pin.rs b/crates/perry-runtime/src/gc/pin.rs index 84750199d2..2ba5a4e85b 100644 --- a/crates/perry-runtime/src/gc/pin.rs +++ b/crates/perry-runtime/src/gc/pin.rs @@ -72,10 +72,104 @@ //! `Atomics.waitAsync`, and the AppKit text reads. Programs that use them get //! today's behaviour; compute- and JSON-shaped programs get the walk removed. +use std::cell::Cell; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use super::types::{GcHeader, GC_FLAG_ARENA, GC_FLAG_PINNED}; +crate::perry_thread_local! { + static COPYING_WALK_PHASE: Cell> = + const { Cell::new(None) }; +} + +/// RAII label for the walk that is about to call into `move_young`. +/// +/// The pin-latch abort used to print only the garbage header. On #7803/#7990 +/// that header is *incoherent* (INTERNED on a Map, a 2 GiB nursery size), so +/// the interesting fact is which walk followed the stale slot. Set around +/// each mark/rewrite walk in `copying.rs`; read by +/// [`pinned_young_move_report`]. +pub(super) struct CopyingWalkPhaseGuard { + prev: Option<&'static str>, +} + +impl CopyingWalkPhaseGuard { + pub(super) fn enter(name: &'static str) -> Self { + let prev = COPYING_WALK_PHASE.with(|c| c.replace(Some(name))); + Self { prev } + } +} + +impl Drop for CopyingWalkPhaseGuard { + fn drop(&mut self) { + COPYING_WALK_PHASE.with(|c| c.set(self.prev)); + } +} + +fn copying_walk_phase() -> Option<&'static str> { + COPYING_WALK_PHASE.with(|c| c.get()) +} + +/// The native stack-map slot the walker is currently visiting, so the +/// pin-latch abort can name the OWNING FRAME — the compiled function, its +/// statepoint record and the slot address — instead of only the walk phase. +/// +/// §35's cut left exactly this gap: `mutable_root_slots/native_stack` says a +/// statepoint live bundle held the stale pointer, and the mutator backtrace +/// lists every candidate frame without saying which one. The walker resolves +/// all of it (`ResolvedRoot` in roots/stack_maps.rs) and then threw it away +/// one call before the latch. +#[derive(Clone, Copy, Debug)] +pub(crate) struct NativeRootSlotContext { + /// The frame's return address the record was matched on. + pub(crate) ip: usize, + /// Start of the compiled function owning the matched record. + pub(crate) function_address: usize, + /// The record's base register (29 = FP, 31 = SP on aarch64). + pub(crate) dwarf_reg: u16, + /// The record's frame offset from that base. + pub(crate) offset: i32, + /// Resolved slot address (base register + offset). + pub(crate) slot_addr: usize, +} + +crate::perry_thread_local! { + static NATIVE_ROOT_SLOT: Cell> = + const { Cell::new(None) }; +} + +/// Set around each native stack-map slot visit; cleared after. Two `Cell` +/// stores per slot, no allocation — the walk body already does strictly more +/// per slot than this. +#[inline] +pub(crate) fn set_native_root_slot_context(context: Option) { + NATIVE_ROOT_SLOT.with(|c| c.set(context)); +} + +pub(crate) fn native_root_slot_context() -> Option { + NATIVE_ROOT_SLOT.with(|c| c.get()) +} + +/// Best-effort symbol name for an address, via `dladdr` — same approach as +/// `eh_walker.rs`. `PERRY_KEEP_SYMBOLS=1` binaries resolve their own +/// `perry_closure_*` symbols; stripped ones print only the address. +#[cfg(unix)] +fn symbol_near(addr: usize) -> Option { + let mut info: libc::Dl_info = unsafe { std::mem::zeroed() }; + if unsafe { libc::dladdr(addr as *const libc::c_void, &mut info) } == 0 + || info.dli_sname.is_null() + { + return None; + } + let name = unsafe { std::ffi::CStr::from_ptr(info.dli_sname) }; + Some(name.to_string_lossy().into_owned()) +} + +#[cfg(not(unix))] +fn symbol_near(_addr: usize) -> Option { + None +} + /// Has any object in a space the copying minor relocates ever been pinned? /// /// Monotone: set by [`pin_object`], never cleared outside tests. Cleared only @@ -335,6 +429,121 @@ pub(super) fn pinned_young_move_report( ); } } + out.push_str(&format!( + " copying walk phase: {}\n", + copying_walk_phase().unwrap_or("(unset — not inside a named walk)") + )); + // #7803: name the owning frame. Only the native stack-map walk sets this; + // for every other phase it prints as absent rather than as a stale value. + match native_root_slot_context() { + Some(context) => { + let function = symbol_near(context.function_address) + .unwrap_or_else(|| "".to_string()); + out.push_str(&format!( + " native root slot: owner={function} fn={:#x} ip={:#x} \ + reg={} offset={} slot_addr={:#x} raw_bits={:#018x}\n", + context.function_address, + context.ip, + context.dwarf_reg, + context.offset, + context.slot_addr, + // Re-read the slot: the raw word says whether the value was + // NaN-boxed (and with which tag) or bare — the deref above + // only saw the masked address. + // Same hazard as the neighborhood dump below: a native + // root-slot context can name an unmapped address, and this + // report is printed on the way to an abort. + if matches!( + crate::arena::classify_heap_space(context.slot_addr), + crate::arena::HeapSpace::Unknown + ) { + 0 + } else { + unsafe { *(context.slot_addr as *const u64) } + }, + )); + } + None => out.push_str(" native root slot: (not visiting a native stack-map slot)\n"), + } + // #7803 target identification: the garbage "header" values this abort + // has printed were NaN-boxed VALUE words, which is what the memory looks + // like when the followed address points INTO live data rather than at an + // object start. Dump the neighborhood and, decisively, the live object + // that ENCLOSES the target (census + floor lookup — expensive, but this + // path is about to abort the process). + out.push_str(" target neighborhood (target-64 .. target+88):\n"); + let target_user = header_addr + super::types::GC_HEADER_SIZE; + for delta in (-64i64..=88).step_by(8) { + let addr = (header_addr as i64 + delta) as usize; + // This path is about to abort the process, so a diagnostic that + // SIGSEGVs destroys the very report it exists to print. `header_addr` + // is a SUSPECT address by construction — that is why we are here — and + // the neighborhood walks 64 bytes below it, so neither the address nor + // its neighborhood is known to be mapped. Classify against the arena's + // page metadata (a real mapping check, not a magnitude guess) and print + // a placeholder rather than dereferencing. A stale from-space address — + // the #7803 case this dump is FOR — still classifies into a live space, + // so the diagnostic keeps working where it matters. + let readable = !matches!( + crate::arena::classify_heap_space(addr), + crate::arena::HeapSpace::Unknown + ); + if !readable { + out.push_str(&format!( + " {}{:<4} (unmapped — not in any arena space){}\n", + if delta < 0 { "-" } else { "+" }, + delta.abs(), + if delta == 0 { + " <-- reported header" + } else { + "" + } + )); + continue; + } + let bits = unsafe { *(addr as *const u64) }; + out.push_str(&format!( + " {}{:<4} {:#018x}{}\n", + if delta < 0 { "-" } else { "+" }, + delta.abs(), + bits, + if delta == 0 { + " <-- reported header" + } else { + "" + }, + )); + } + let valid = super::trace::build_valid_pointer_set(); + match valid.enclosing_object(target_user) { + Some(enclosing) if enclosing != target_user => { + let eh = (enclosing - super::types::GC_HEADER_SIZE) as *const super::types::GcHeader; + out.push_str(&format!( + " ENCLOSING live object: user={enclosing:#x} obj_type={} ({}) size={} — the \ + followed address is +{} INTO it (an interior pointer, not a stale one)\n", + unsafe { (*eh).obj_type }, + gc_type_label(unsafe { (*eh).obj_type }), + unsafe { (*eh).size }, + target_user - enclosing, + )); + } + Some(_) => out.push_str( + " enclosing-object check: target IS an object start (interior-pointer \ + hypothesis rejected for this abort)\n", + ), + None => out.push_str( + " enclosing-object check: target is inside no censused live object \ + (dead/recycled memory — consistent with a genuinely stale slot)\n", + ), + } + // The collection is at a safepoint in the mutator. The frames below the + // copier name the compiled function whose statepoint live bundle (or + // shadow slot) held the stale pointer — #7803's missing owner. + out.push_str(" --- mutator backtrace at the latch ---\n"); + out.push_str(&format!( + "{}\n --- end mutator backtrace ---\n", + std::backtrace::Backtrace::force_capture() + )); if flags & super::types::GC_FLAG_TENURED != 0 { out.push_str( " note: GC_FLAG_TENURED next to a young space is NOT an anomaly. The \ diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 5382516714..c51e3e4ca0 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -10,7 +10,9 @@ mod stack_maps; mod temp_roots; pub(super) use stack_maps::initialize as initialize_stack_maps; pub(super) use stack_maps::native_maps_active as native_stack_maps_active; +pub(super) use stack_maps::publish_rewrite_walk_stats as stack_maps_publish_rewrite_walk_stats; pub(super) use stack_maps::record_native_stack_walk_source; +pub(super) use stack_maps::verify_native_slots_post_walk as stack_maps_native_slot_verify; pub use rooted_values::RootedValues; pub(super) use runtime_handles::{ @@ -1407,6 +1409,17 @@ pub(super) enum MutableRootSlotKind { GlobalRoot, } +impl MutableRootSlotKind { + /// Label for the pin-latch abort's `copying walk phase` line. + pub(super) fn walk_phase_name(self) -> &'static str { + match self { + Self::ShadowStack => "mutable_root_slots/shadow_stack", + Self::NativeStack => "mutable_root_slots/native_stack", + Self::GlobalRoot => "mutable_root_slots/global_root", + } + } +} + #[derive(Clone, Copy)] pub(super) struct MutableRootSlot { pub(super) kind: MutableRootSlotKind, diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index 87e3c19f7a..add295816d 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -36,7 +36,14 @@ use std::sync::{OnceLock, RwLock, RwLockReadGuard}; /// statepoint constant preamble and base/derived duplicates that this parser /// discarded anyway, and shipping it cost 3.9 MB on a real application. const GC_MAP_MAGIC: &[u8; 4] = b"PGCM"; -const GC_MAP_VERSION: u8 = 3; +/// v4 (#7803): records carry DERIVED (interior) pointer slots paired with +/// their base roots — the for-of element cursors the RS4GC prelude hoists +/// across polls. v3 collapsed those pairs, so this walker chased +/// `&elements[i]` as an object start and never rewrote it as `base' + delta` +/// after a move. Version mismatch still fails closed (the parser returns +/// None and `stack_maps()` panics), so a v3 binary cannot run on this +/// runtime half-understood. +const GC_MAP_VERSION: u8 = 4; const MAX_SAFEPOINT_RETURN_DELTA: usize = 16; #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct StackMapLocation { @@ -44,6 +51,14 @@ struct StackMapLocation { offset: i32, } +/// One derived (interior) pointer slot: `slot` holds `base + delta` for the +/// base root at `base_index` within the same record's roots range. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct StackMapDerived { + base_index: u32, + slot: StackMapLocation, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct StackMapRecord { pc: usize, @@ -69,6 +84,9 @@ struct StackMapRecord { /// repeats at one copy instead of duplicating 154k entries. roots_start: u32, roots_len: u32, + /// Half-open range into `StackMapIndex::derived`, same sharing scheme. + derived_start: u32, + derived_len: u32, } /// Parsed section plus the facts the fast walker's preconditions need. @@ -84,6 +102,8 @@ struct StackMapIndex { /// Every root slot, referenced by `StackMapRecord`'s range. Shared between /// records whose live sets are identical. roots: Vec, + /// Every derived slot, referenced by `StackMapRecord`'s derived range. + derived: Vec, /// Sorted, deduplicated start address of every function that has records. /// Used to confirm a matched record belongs to the function `ip` is in. function_starts: Vec, @@ -100,6 +120,12 @@ impl StackMapIndex { let end = start + record.roots_len as usize; self.roots.get(start..end).unwrap_or(&[]) } + + fn derived_locations(&self, record: &StackMapRecord) -> &[StackMapDerived] { + let start = record.derived_start as usize; + let end = start + record.derived_len as usize; + self.derived.get(start..end).unwrap_or(&[]) + } } /// All maps visible to this runtime provider. @@ -180,6 +206,234 @@ impl StackMapIndexStore { static STACK_MAPS: StackMapIndexStore = StackMapIndexStore::new(); +/// #7803 creation-cycle verifier (diagnostic, `PERRY_GC_NATIVE_SLOT_VERIFY=1`). +/// +/// Runs a SECOND, non-rewriting native-slot walk after the rewrite passes of +/// a copying minor, while from-space is still classifiable, and aborts on the +/// FIRST slot still naming a from-space address. A stale native slot whose +/// target later becomes unclassifiable is skipped silently by every ordinary +/// walk (`mark_addr` returns `None`), so the cycle that CREATED the staleness +/// never printed anything — this names it, with the owning frame from the +/// pin-latch context. +crate::perry_thread_local! { + /// The rewrite walk's stats for the CURRENT cycle, published so the + /// #7803 native-slot verifier can compare its own traversal against the + /// one that was supposed to rewrite (a rewrite walk that stopped early + /// and a verify walk that did not is the difference between "slot + /// skipped" and "slot unrewritable"). + static LAST_REWRITE_WALK: std::cell::Cell<(usize, usize, usize)> = + const { std::cell::Cell::new((0, 0, 0)) }; +} + +pub(in crate::gc) fn publish_rewrite_walk_stats(stats: &NativeStackWalkStats) { + LAST_REWRITE_WALK.with(|c| { + c.set(( + stats.frames_visited, + stats.records_matched, + stats.locations_visited, + )) + }); +} + +pub(in crate::gc) fn verify_native_slots_post_walk( + untraced: bool, + classify: &dyn Fn(usize) -> String, +) { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + if !*ON.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_NATIVE_SLOT_VERIFY").ok().as_deref(), + Some("1") | Some("on") | Some("true") + ) + }) { + return; + } + let _phase = super::super::pin::CopyingWalkPhaseGuard::enter("native_slot_verify"); + let rewrite_stats = LAST_REWRITE_WALK.with(|c| c.get()); + let mut verify_frames = 0usize; + let verify_stats = visit_stack_map_root_slots(&mut |slot| unsafe { + verify_frames += 1; + let bits = *slot.ptr; + let Some(word) = super::super::root_words::decode_root_word(bits) else { + return; + }; + let target = word.addr(); + if !super::super::fromspace_scan::is_from_space(crate::arena::classify_heap_space(target)) { + return; + } + let context = super::super::pin::native_root_slot_context(); + // Break the §40 contradiction: dump EVERY record `match_records` + // returns for this ip (the ±16 window can match several) and every + // slot value of each, resolving SP-relative addresses from the known + // victim slot (base = slot_addr - offset). A double-matched frame or + // a mis-attributed neighbor slot is visible here in one abort. + if let Some(ctx) = context { + // main's #8081 made the published index a guard; the walk reads + // through its `index` field. + let published = stack_maps(); + let index = &published.index; + let base = ctx.slot_addr.wrapping_sub(ctx.offset as usize); + for record in index.match_records(ctx.ip) { + eprintln!( + "[gc-native-slot-verify] record pc={:#x} (fn+{:#x}):", + record.pc, + record.pc.wrapping_sub(record.function_address), + ); + for location in index.locations(record) { + let addr = if location.offset < 0 { + base.wrapping_sub(location.offset.unsigned_abs() as usize) + } else { + base.wrapping_add(location.offset as usize) + }; + let word = if location.dwarf_reg == ctx.dwarf_reg && addr % 8 == 0 { + format!("{:#018x}", *(addr as *const u64)) + } else { + "".to_string() + }; + eprintln!( + "[gc-native-slot-verify] reg={} offset={} addr={addr:#x} word={word}", + location.dwarf_reg, location.offset, + ); + } + for entry in index.derived_locations(record) { + eprintln!( + "[gc-native-slot-verify] DERIVED base_index={} reg={} offset={}", + entry.base_index, entry.slot.dwarf_reg, entry.slot.offset, + ); + } + } + } + panic!( + "[gc-native-slot-verify] a native stack-map slot still names a from-space \ + address AFTER this cycle's rewrite passes: slot={:#x} word={bits:#018x} \ + target={target:#x} target_space={:?} untraced_cycle={untraced} \ + rewrite_walk(frames,records,locations)={rewrite_stats:?} \ + collector_classify={} raw_header={:#018x} payload0={:#018x} \ + context={context:?} — this is the CREATION cycle of the stale slot the \ + pin-latch only catches many cycles later (#7803)", + slot.ptr as usize, + crate::arena::classify_heap_space(target), + classify(target), + *((target - 8) as *const u64), + *(target as *const u64), + ); + }); + let _ = (verify_frames, verify_stats); +} + +/// Upper bound on how far a derived pointer may sit from its base before the +/// rewrite refuses to touch it. LLVM only pairs a derived pointer with the +/// base it was actually derived from, so a delta beyond any plausible object +/// means the map and this frame disagree — leave the slot alone rather than +/// manufacture an address. 64 MiB is far above the largest movable object +/// (`MAX_YOUNG_MOVE_BYTES` is 1 MiB) without being "any bits at all". +const MAX_DERIVED_DELTA: usize = 64 << 20; + +/// Visit one record's base roots, then rewrite its DERIVED (interior) slots +/// as `new_base + (old_derived - old_base)` (#7803). +/// +/// The order inside is the contract: old base words are captured BEFORE the +/// visitor runs (the visitor rewrites base slots in place), and the derived +/// slots are never handed to the visitor at all — a derived pointer is not an +/// object start, and treating it as one is exactly the defect the v4 map +/// exists to end (the collector chased `&elements[i]`, latched on element +/// bytes as a "header", and left the cursor pointing into from-space after a +/// move). +/// +/// `resolve` maps a location to `(slot_address, base_register_value)` for +/// THIS frame; both walkers pass their own base math in. A visitor that does +/// not rewrite (the verify walker's collection passes) leaves base words +/// unchanged, which makes every derived rewrite a no-op by construction. +unsafe fn visit_record_slots( + index: &StackMapIndex, + record: &StackMapRecord, + ip: usize, + resolve: &mut dyn FnMut(&StackMapLocation) -> Option<(usize, usize)>, + stats: &mut NativeStackWalkStats, + visit: &mut dyn FnMut(ResolvedRoot), +) { + let locations = index.locations(record); + let deriveds = index.derived_locations(record); + + let slot_ok = |address: usize| address != 0 && address & (align_of::() - 1) == 0; + + // Old base words, captured before the visitor rewrites anything. Only + // needed when the record has derived slots — the common record pays + // nothing. + let mut old_base: Vec> = Vec::new(); + if !deriveds.is_empty() { + old_base.reserve(locations.len()); + for location in locations { + old_base.push(resolve(location).and_then(|(address, _)| { + slot_ok(address).then(|| (address, *(address as *const u64))) + })); + } + } + + for location in locations { + stats.locations_visited = stats.locations_visited.saturating_add(1); + let Some((address, base)) = resolve(location) else { + continue; + }; + if !slot_ok(address) { + continue; + } + visit(ResolvedRoot { + address, + ip, + function_address: record.function_address, + dwarf_reg: location.dwarf_reg, + offset: location.offset, + base, + }); + } + + for entry in deriveds { + stats.locations_visited = stats.locations_visited.saturating_add(1); + let Some((derived_addr, _)) = resolve(&entry.slot) else { + continue; + }; + if !slot_ok(derived_addr) { + continue; + } + let Some(Some((base_addr, old_base_word))) = + old_base.get(entry.base_index as usize).copied() + else { + continue; + }; + rewrite_derived_slot(derived_addr, base_addr, old_base_word); + } +} + +/// The derived-slot rewrite itself. Decodes through `root_words` so a slot +/// keeps its stored form (NaN-boxed tag or bare) across the rewrite, exactly +/// like a base root does. +unsafe fn rewrite_derived_slot(derived_addr: usize, base_addr: usize, old_base_word: u64) { + use super::super::root_words::decode_root_word; + let new_base_word = *(base_addr as *const u64); + if new_base_word == old_base_word { + // The base did not move this cycle, so the derived offset from it is + // still current. + return; + } + let Some(old_base) = decode_root_word(old_base_word) else { + return; + }; + let Some(new_base) = decode_root_word(new_base_word) else { + return; + }; + let old_derived_word = *(derived_addr as *const u64); + let Some(old_derived) = decode_root_word(old_derived_word) else { + return; + }; + let delta = old_derived.addr().wrapping_sub(old_base.addr()); + if delta > MAX_DERIVED_DELTA { + return; + } + *(derived_addr as *mut u64) = old_derived.encode(new_base.addr().wrapping_add(delta)); +} + // The two register numbers the compact format's short base tags stand for. // These are aarch64's by definition of the FORMAT, on every architecture — see // `gc_map.rs`, which deliberately keeps them literal so the compiler's idea of @@ -285,6 +539,25 @@ pub(super) struct ResolvedRoot { } impl ResolvedRoot { + /// Visit this slot with its provenance published for the pin-latch abort: + /// the walker resolved the owning function, record and address, and until + /// #7803 threw all of it away one call before the latch printed + /// `mutable_root_slots/native_stack` with no owner. Two `Cell` stores per + /// slot; the clear keeps a later phase from being blamed on this frame. + fn visit_with_context(self, visit: &mut impl FnMut(MutableRootSlot)) { + super::super::pin::set_native_root_slot_context(Some( + super::super::pin::NativeRootSlotContext { + ip: self.ip, + function_address: self.function_address, + dwarf_reg: self.dwarf_reg, + offset: self.offset, + slot_addr: self.address, + }, + )); + visit(self.slot()); + super::super::pin::set_native_root_slot_context(None); + } + fn slot(self) -> MutableRootSlot { MutableRootSlot { kind: MutableRootSlotKind::NativeStack, @@ -358,8 +631,9 @@ fn build_stack_map_index() -> StackMapIndex { // compiler and runtime disagree about the map format. let mut records = Vec::new(); let mut roots = Vec::new(); + let mut derived = Vec::new(); for section in sections { - if append_gc_map_section(&mut records, &mut roots, section).is_none() { + if append_gc_map_section(&mut records, &mut roots, &mut derived, section).is_none() { panic!( "perry: a GC map section (__perry_gcmap / .perry_gcmap, {} bytes) is \ present but could not be decoded — expected format {:?} v{}. This binary's \ @@ -372,25 +646,33 @@ fn build_stack_map_index() -> StackMapIndex { } } records.sort_unstable_by_key(|record| record.pc); - index_records(records, roots) + index_records(records, roots, derived) } fn append_gc_map_section( records: &mut Vec, roots: &mut Vec, + derived: &mut Vec, section: &[u8], ) -> Option<()> { - let (mut section_records, section_roots) = parse_gc_map(section)?; + let (mut section_records, section_roots, section_derived) = parse_gc_map(section)?; let root_base = u32::try_from(roots.len()).ok()?; + let derived_base = u32::try_from(derived.len()).ok()?; for record in &mut section_records { record.roots_start = record.roots_start.checked_add(root_base)?; + record.derived_start = record.derived_start.checked_add(derived_base)?; } records.append(&mut section_records); roots.extend(section_roots); + derived.extend(section_derived); Some(()) } -fn index_records(records: Vec, roots: Vec) -> StackMapIndex { +fn index_records( + records: Vec, + roots: Vec, + derived: Vec, +) -> StackMapIndex { // SP-relative locations are admitted here and resolved per FRAME in the // walker, which decodes the owning function's `add x29, sp, #imm` // prologue to get the body SP (#7173). Deciding it here would mean @@ -401,12 +683,15 @@ fn index_records(records: Vec, roots: Vec) -> // is what decides the fast walker is usable at all, and a format change // that introduced a third base must disable the chain walk, not be // trusted by it. - let chain_walkable = roots.iter().all(|location| { - matches!( - location.dwarf_reg, - DWARF_REG_FP_AARCH64 | DWARF_REG_SP_AARCH64 - ) - }); + let chain_walkable = roots + .iter() + .chain(derived.iter().map(|entry| &entry.slot)) + .all(|location| { + matches!( + location.dwarf_reg, + DWARF_REG_FP_AARCH64 | DWARF_REG_SP_AARCH64 + ) + }); #[cfg(any(target_arch = "aarch64", test))] let min_pc = records.first().map_or(usize::MAX, |record| record.pc); #[cfg(any(target_arch = "aarch64", test))] @@ -420,6 +705,7 @@ fn index_records(records: Vec, roots: Vec) -> StackMapIndex { records, roots, + derived, function_starts, chain_walkable, #[cfg(any(target_arch = "aarch64", test))] @@ -756,16 +1042,20 @@ pub(super) fn visit_stack_map_root_slots( return NativeStackWalkStats::default(); } match walker_mode() { - WalkerMode::Unwind => unwind::visit(index, &mut |root: ResolvedRoot| visit(root.slot())), + WalkerMode::Unwind => unwind::visit(index, &mut |root: ResolvedRoot| { + root.visit_with_context(visit) + }), WalkerMode::Fast => { if index.chain_walkable { - if let Some(stats) = - fp_chain::visit(index, &mut |root: ResolvedRoot| visit(root.slot())) - { + if let Some(stats) = fp_chain::visit(index, &mut |root: ResolvedRoot| { + root.visit_with_context(visit) + }) { return stats; } } - let mut stats = unwind::visit(index, &mut |root: ResolvedRoot| visit(root.slot())); + let mut stats = unwind::visit(index, &mut |root: ResolvedRoot| { + root.visit_with_context(visit) + }); stats.fallback_walks = 1; stats } @@ -773,568 +1063,8 @@ pub(super) fn visit_stack_map_root_slots( } } -/// Decode every concatenated compact map in the section. -/// -/// The linker concatenates one blob per object file, so this walks blob by -/// blob using each header's `total_len` rather than assuming a single map — -/// a decoder that reads only the first header silently drops every other -/// object's roots, which is invisible until a collection frees a live object. -fn parse_gc_map(bytes: &[u8]) -> Option<(Vec, Vec)> { - let mut records = Vec::new(); - let mut roots: Vec = Vec::new(); - let mut base = 0usize; - - while base + 16 <= bytes.len() { - if bytes.get(base..base + 4)? != GC_MAP_MAGIC { - // Linkers pad between input sections; a zero tail is the end. - if bytes[base..].iter().all(|byte| *byte == 0) { - break; - } - base += 1; - continue; - } - if read_u8(bytes, base + 4)? != GC_MAP_VERSION { - return None; - } - let function_count = read_u32(bytes, base + 8)? as usize; - let total_len = read_u32(bytes, base + 12)? as usize; - // Header flags, bit 0: the function-address field is 8 bytes wide. The - // emitter writes the TARGET's pointer width (watchOS `arm64_32` is - // ILP32), and compile target and run target are the same machine — so - // a mismatch here means the binary's map was produced for a different - // width and every function address would be misread. Fail closed. - let flags = read_u16(bytes, base + 6)?; - if (flags & 1 == 1) != (std::mem::size_of::() == 8) { - return None; - } - let entry = if flags & 1 == 1 { 16 } else { 12 }; - // A blob must at least cover its header and function table. Without - // this, a `total_len` of 0 leaves `base` unchanged — and because the - // magic still matches at that offset the resynchronisation path below - // is never reached, so the loop spins forever. This runs inside - // `OnceLock::get_or_init`, so that is a process hang at the first - // collection rather than the fail-closed panic in `stack_maps`. - if total_len < 16 + function_count.checked_mul(entry)? { - return None; - } - let table = base.checked_add(16)?; - let stream_start = table.checked_add(function_count.checked_mul(entry)?)?; - let blob_end = base.checked_add(total_len)?; - if blob_end > bytes.len() || stream_start > blob_end { - return None; - } - - // Instruction offsets are a fixed-width array ahead of the varint - // stream: at -O3 the compiler emits them as label differences the - // assembler evaluates, so their values cannot be varint-encoded at - // rewrite time. - // Not `unwrap_or(0)`: a failed read here means the function table is - // truncated, and treating that function as having zero records starts - // `cursor` at the wrong offset so every later varint decodes from - // misaligned bytes. A wrong live set is worse than no map. - let mut record_total: usize = 0; - for index in 0..function_count { - record_total = - record_total.checked_add(read_u32(bytes, table + index * 16 + 12)? as usize)?; - } - let offsets = stream_start; - let mut cursor = offsets.checked_add(record_total.checked_mul(4)?)?; - if cursor > blob_end { - return None; - } - let mut record_index = 0usize; - - for index in 0..function_count { - // Address width follows the header flag checked above, so the - // stack-size and record-count offsets move with it. - let base_off = table + index * entry; - let addr_bytes = entry - 8; - let function_address = if addr_bytes == 8 { - read_u64(bytes, base_off)? as usize - } else { - read_u32(bytes, base_off)? as usize - }; - let stack_size = u64::from(read_u32(bytes, base_off + addr_bytes)?); - let record_count = read_u32(bytes, base_off + addr_bytes + 4)? as usize; - - let mut previous: Option<(u32, u32)> = None; - for _ in 0..record_count { - let instruction_offset = read_u32(bytes, offsets + record_index * 4)?; - record_index += 1; - - let (header, next) = read_varint(bytes, cursor, blob_end)?; - cursor = next; - let range = if header & 1 == 1 { - // Repeat: this safepoint's live set is the previous one's. - previous? - } else { - let count = (header >> 1) as usize; - let start = u32::try_from(roots.len()).ok()?; - let mut last: Option = None; - for _ in 0..count { - let (value, next) = read_varint(bytes, cursor, blob_end)?; - cursor = next; - // 2-bit base tag: 0 = FP, 1 = SP, 2 = explicit DWARF - // register in a following varint (LLVM uses x19 as a - // frame base in functions with dynamic allocation). - let dwarf_reg = match value & 3 { - 0 => DWARF_REG_FP_AARCH64, - 1 => DWARF_REG_SP_AARCH64, - 2 => { - let (reg, next) = read_varint(bytes, cursor, blob_end)?; - cursor = next; - u16::try_from(reg).ok()? - } - _ => return None, - }; - let delta = unzigzag((value >> 2) as u32); - let offset = match last { - None => delta, - Some(previous_offset) => previous_offset.wrapping_add(delta), - }; - last = Some(offset); - roots.push(StackMapLocation { dwarf_reg, offset }); - } - (start, u32::try_from(count).ok()?) - }; - previous = Some(range); - - records.push(StackMapRecord { - pc: function_address.checked_add(instruction_offset as usize)?, - function_address, - stack_size, - roots_start: range.0, - roots_len: range.1, - }); - } - } - - let next = align_up(blob_end, 8)?; - if next <= base { - return None; - } - base = next; - } - - Some((records, roots)) -} - -/// LEB128 read bounded by the blob it belongs to, so a corrupt length cannot -/// walk into the next blob or off the section. -fn read_varint(bytes: &[u8], mut at: usize, end: usize) -> Option<(u64, usize)> { - let mut value = 0u64; - let mut shift = 0u32; - loop { - if at >= end || shift > 63 { - return None; - } - let byte = *bytes.get(at)?; - at += 1; - value |= u64::from(byte & 0x7F) << shift; - if byte & 0x80 == 0 { - return Some((value, at)); - } - shift += 7; - } -} - -fn unzigzag(value: u32) -> i32 { - ((value >> 1) as i32) ^ -((value & 1) as i32) -} - -fn align_up(value: usize, alignment: usize) -> Option { - value - .checked_add(alignment.checked_sub(1)?) - .map(|value| value & !(alignment - 1)) -} - -fn read_u8(bytes: &[u8], offset: usize) -> Option { - bytes.get(offset).copied() -} - -/// Used by the map header's flags field and by ELF section headers. It was -/// briefly Linux-gated, which broke the Linux build the moment the map itself -/// needed a 16-bit read — keep it unconditional. -fn read_u16(bytes: &[u8], offset: usize) -> Option { - Some(u16::from_le_bytes( - bytes.get(offset..offset + 2)?.try_into().ok()?, - )) -} - -fn read_u32(bytes: &[u8], offset: usize) -> Option { - Some(u32::from_le_bytes( - bytes.get(offset..offset + 4)?.try_into().ok()?, - )) -} - -fn read_u64(bytes: &[u8], offset: usize) -> Option { - Some(u64::from_le_bytes( - bytes.get(offset..offset + 8)?.try_into().ok()?, - )) -} - -/// Every 64-bit Apple platform, not only macOS. iOS, iPadOS (which reports as -/// iOS), tvOS and visionOS are all aarch64 + Mach-O and share this loader -/// verbatim; gating it to `target_os = "macos"` sent them to the stub below, -/// where the section is never found and the index is empty — a collector with -/// no native roots, silently, on exactly the platforms that cannot be debugged -/// easily. -/// -/// 64-bit only: watchOS's `arm64_32` has 32-bit pointers, while the map stores -/// function addresses as `u64` and this code does `usize` arithmetic on them. -/// The compiler refuses that target for the same reason. -#[cfg(target_vendor = "apple")] -fn loaded_stack_map_sections() -> Result, String> { - use mach2::dyld::{_dyld_get_image_header, _dyld_get_image_vmaddr_slide, _dyld_image_count}; - - const LC_SEGMENT_64: u32 = 0x19; - - #[repr(C)] - #[derive(Clone, Copy)] - struct MachHeader64 { - magic: u32, - cpu_type: i32, - cpu_subtype: i32, - file_type: u32, - command_count: u32, - commands_size: u32, - flags: u32, - reserved: u32, - } - - #[repr(C)] - #[derive(Clone, Copy)] - struct LoadCommand { - command: u32, - size: u32, - } - - #[repr(C)] - #[derive(Clone, Copy)] - struct SegmentCommand64 { - command: u32, - size: u32, - segment_name: [u8; 16], - vm_address: u64, - vm_size: u64, - file_offset: u64, - file_size: u64, - max_protection: i32, - initial_protection: i32, - section_count: u32, - flags: u32, - } - - #[repr(C)] - #[derive(Clone, Copy)] - struct Section64 { - section_name: [u8; 16], - segment_name: [u8; 16], - address: u64, - size: u64, - offset: u32, - alignment: u32, - relocation_offset: u32, - relocation_count: u32, - flags: u32, - reserved1: u32, - reserved2: u32, - reserved3: u32, - } - - fn fixed_name_matches(actual: &[u8; 16], expected: &[u8]) -> bool { - actual.get(..expected.len()) == Some(expected) - && actual.get(expected.len()).copied().unwrap_or(0) == 0 - } - - let mut sections = Vec::new(); - unsafe { - for image_index in 0.._dyld_image_count() { - let raw_header = _dyld_get_image_header(image_index); - if raw_header.is_null() { - continue; - } - let header = &*(raw_header.cast::()); - let slide = _dyld_get_image_vmaddr_slide(image_index); - let mut command_ptr = raw_header - .cast::() - .add(std::mem::size_of::()); - for _ in 0..header.command_count { - let load = std::ptr::read_unaligned(command_ptr.cast::()); - if load.size < std::mem::size_of::() as u32 { - break; - } - if load.command == LC_SEGMENT_64 { - let segment = std::ptr::read_unaligned(command_ptr.cast::()); - let mut section_ptr = command_ptr.add(std::mem::size_of::()); - for _ in 0..segment.section_count { - let section = std::ptr::read_unaligned(section_ptr.cast::()); - if fixed_name_matches(§ion.segment_name, b"__PERRY_GCMAP") - && fixed_name_matches(§ion.section_name, b"__perry_gcmap") - { - if let (Some(address), Ok(size)) = ( - (section.address as isize).checked_add(slide), - usize::try_from(section.size), - ) { - if address > 0 && size != 0 { - sections.push(std::slice::from_raw_parts( - address as usize as *const u8, - size, - )); - } - } - break; - } - section_ptr = section_ptr.add(std::mem::size_of::()); - } - } - command_ptr = command_ptr.add(load.size as usize); - } - } - } - Ok(sections) -} - -#[cfg(not(any(target_vendor = "apple", target_os = "linux")))] -fn loaded_stack_map_sections() -> Result, String> { - Ok(loaded_stack_map_section().into_iter().collect()) -} - -/// ELF (#7173, #8075): the `.perry_gcmap` sections of every loaded image. -/// -/// Linker-provided `__start_`/`__stop_` symbols would need weak linkage -/// (unstable in Rust) or `-rdynamic` (not guaranteed), so instead: read -/// each `dl_iterate_phdr` image's ELF section headers for `.perry_gcmap` -/// (`sh_addr`, `sh_size`) and add that image's `dlpi_addr` load bias. The -/// executable has an empty `dlpi_name`, for which `/proc/self/exe` is the -/// stable path. Reading only that first image is unsound when the runtime is -/// a provider and generated code lives in an app dylib: its live native roots -/// disappear from the collector exactly when a full collection evacuates. -#[cfg(target_os = "linux")] -fn loaded_stack_map_sections() -> Result, String> { - use std::ffi::CStr; - use std::os::unix::ffi::OsStrExt; - use std::path::Path; - - #[repr(C)] - struct DlPhdrInfo { - dlpi_addr: usize, - dlpi_name: *const std::os::raw::c_char, - dlpi_phdr: *const ElfProgramHeader, - dlpi_phnum: u16, - } - #[repr(C)] - struct ElfProgramHeader { - p_type: u32, - _p_flags: u32, - _p_offset: u64, - p_vaddr: u64, - _p_paddr: u64, - _p_filesz: u64, - p_memsz: u64, - _p_align: u64, - } - struct SectionScan { - sections: Vec<&'static [u8]>, - unreadable_images: Vec, - } - #[allow(clashing_extern_declarations)] - unsafe extern "C" { - fn dl_iterate_phdr( - callback: unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut c_void) -> i32, - data: *mut c_void, - ) -> i32; - } - unsafe extern "C" fn collect(info: *mut DlPhdrInfo, _size: usize, data: *mut c_void) -> i32 { - let Some(info) = info.as_ref() else { - return 0; - }; - let image_name = if info.dlpi_name.is_null() { - &[][..] - } else { - CStr::from_ptr(info.dlpi_name).to_bytes() - }; - // The kernel-provided vDSO has no backing file. It cannot contain - // Perry-generated code, so it is the sole unreadable-image exception. - if image_name == b"linux-vdso.so.1" || image_name == b"linux-gate.so.1" { - return 0; - } - let path = if image_name.is_empty() { - Path::new("/proc/self/exe") - } else { - Path::new(std::ffi::OsStr::from_bytes(image_name)) - }; - let bytes = match std::fs::read(path) { - Ok(bytes) => bytes, - Err(error) => { - let scan = &mut *data.cast::(); - scan.unreadable_images - .push(format!("{} ({error})", path.display())); - return 0; - } - }; - let Some((addr, size)) = elf_section_vaddr(&bytes, b".perry_gcmap") else { - return 0; - }; - let Some(start) = info.dlpi_addr.checked_add(addr) else { - return 0; - }; - let Some(section_end) = addr.checked_add(size) else { - return 0; - }; - const PT_LOAD: u32 = 1; - let mapped = !info.dlpi_phdr.is_null() - && std::slice::from_raw_parts(info.dlpi_phdr, usize::from(info.dlpi_phnum)) - .iter() - .filter(|header| header.p_type == PT_LOAD) - .any(|header| { - let Ok(segment_start) = usize::try_from(header.p_vaddr) else { - return false; - }; - let Some(segment_end) = usize::try_from(header.p_memsz) - .ok() - .and_then(|size| segment_start.checked_add(size)) - else { - return false; - }; - addr >= segment_start && section_end <= segment_end - }); - // The on-disk path can be replaced after dlopen. Validate its claimed - // address against the loader's actual PT_LOAD ranges before turning - // it into a slice, so a stale or hostile section table cannot make GC - // initialization read outside the mapped image. - if mapped && start != 0 && size != 0 { - let scan = &mut *data.cast::(); - scan.sections - .push(std::slice::from_raw_parts(start as *const u8, size)); - } - 0 - } - - let mut scan = SectionScan { - sections: Vec::new(), - unreadable_images: Vec::new(), - }; - unsafe { - dl_iterate_phdr(collect, (&mut scan as *mut SectionScan).cast::()); - } - if scan.unreadable_images.is_empty() { - Ok(scan.sections) - } else { - Err(format!( - "unreadable loaded ELF image(s): {}", - scan.unreadable_images.join(", ") - )) - } -} - -/// Minimal ELF64 section-header walk: returns (sh_addr, sh_size) for the -/// named section. Same defensive read style as the stack-map parser. -#[cfg(target_os = "linux")] -fn elf_section_vaddr(bytes: &[u8], name: &[u8]) -> Option<(usize, usize)> { - if bytes.get(..4)? != b"\x7fELF" || *bytes.get(4)? != 2 { - return None; // not ELF64 - } - let shoff = read_u64(bytes, 0x28)? as usize; - let shentsize = read_u16(bytes, 0x3A)? as usize; - let shnum = read_u16(bytes, 0x3C)? as usize; - let shstrndx = read_u16(bytes, 0x3E)? as usize; - let strtab_hdr = shoff.checked_add(shstrndx.checked_mul(shentsize)?)?; - let strtab_off = read_u64(bytes, strtab_hdr.checked_add(0x18)?)? as usize; - for i in 0..shnum { - let hdr = shoff.checked_add(i.checked_mul(shentsize)?)?; - let name_off = read_u32(bytes, hdr)? as usize; - let name_pos = strtab_off.checked_add(name_off)?; - let candidate = bytes.get(name_pos..name_pos.checked_add(name.len())?)?; - let terminator = bytes.get(name_pos + name.len()).copied().unwrap_or(1); - if candidate == name && terminator == 0 { - // Only an SHF_ALLOC section has a runtime virtual address. Refuse - // a file-only namesake before constructing a slice from sh_addr. - const SHF_ALLOC: u64 = 0x2; - if read_u64(bytes, hdr.checked_add(0x08)?)? & SHF_ALLOC == 0 { - return None; - } - let addr = read_u64(bytes, hdr.checked_add(0x10)?)? as usize; - let size = read_u64(bytes, hdr.checked_add(0x20)?)? as usize; - return Some((addr, size)); - } - } - None -} - -/// Windows/PE: the `.pgcmap` section of the running image. -/// -/// The name is seven bytes because a PE image section header has an 8-byte name -/// field — `.perry_gcmap` would be truncated on the way into the image and the -/// lookup below could never match it. `gc_map::COFF_SECTION_NAME` is the -/// compiler-side half of that agreement. -/// -/// `GetModuleHandleW(NULL)` returns the image base, which is also a valid -/// `IMAGE_DOS_HEADER`; the section table follows the optional header, whose -/// size the file header records rather than being fixed. -#[cfg(target_os = "windows")] -fn loaded_stack_map_section() -> Option<&'static [u8]> { - const IMAGE_DOS_SIGNATURE: u16 = 0x5A4D; // "MZ" - const IMAGE_NT_SIGNATURE: u32 = 0x0000_4550; // "PE\0\0" - const SECTION_HEADER_SIZE: usize = 40; - const SECTION_NAME: &[u8] = b".pgcmap"; - - unsafe extern "system" { - fn GetModuleHandleW(name: *const u16) -> *mut core::ffi::c_void; - } - - unsafe { - let base = GetModuleHandleW(std::ptr::null()) as *const u8; - if base.is_null() { - return None; - } - if std::ptr::read_unaligned(base as *const u16) != IMAGE_DOS_SIGNATURE { - return None; - } - // e_lfanew sits at offset 0x3C of the DOS header. - let nt_offset = std::ptr::read_unaligned(base.add(0x3C) as *const u32) as usize; - let nt = base.add(nt_offset); - if std::ptr::read_unaligned(nt as *const u32) != IMAGE_NT_SIGNATURE { - return None; - } - // IMAGE_FILE_HEADER follows the 4-byte signature: NumberOfSections at - // +2, SizeOfOptionalHeader at +16. - let file_header = nt.add(4); - let section_count = std::ptr::read_unaligned(file_header.add(2) as *const u16) as usize; - let optional_size = std::ptr::read_unaligned(file_header.add(16) as *const u16) as usize; - let sections = file_header.add(20).add(optional_size); - - for index in 0..section_count { - let header = sections.add(index * SECTION_HEADER_SIZE); - let name = std::slice::from_raw_parts(header, 8); - // Names shorter than eight bytes are NUL-padded. - let trimmed = match name.iter().position(|b| *b == 0) { - Some(end) => &name[..end], - None => name, - }; - if trimmed != SECTION_NAME { - continue; - } - let virtual_size = std::ptr::read_unaligned(header.add(8) as *const u32) as usize; - let virtual_address = std::ptr::read_unaligned(header.add(12) as *const u32) as usize; - if virtual_size == 0 || virtual_address == 0 { - return None; - } - return Some(std::slice::from_raw_parts( - base.add(virtual_address), - virtual_size, - )); - } - } - None -} - -#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "windows")))] -fn loaded_stack_map_section() -> Option<&'static [u8]> { - None -} - -// Same platform set as the loader above: the Itanium unwinder personality and +// Same platform set as the section loader (`stack_maps_sections.rs`): the +// Itanium unwinder personality and // `_Unwind_*` API are present on every Apple platform, not just macOS. #[cfg(any(target_vendor = "apple", target_os = "linux"))] mod unwind { @@ -1395,8 +1125,7 @@ mod unwind { } state.stats.records_matched = state.stats.records_matched.saturating_add(matched.len()); for record in matched { - for location in state.index.locations(record) { - state.stats.locations_visited = state.stats.locations_visited.saturating_add(1); + let mut resolve = |location: &StackMapLocation| { // SP-relative roots take the CFA as their base VERBATIM. // // Not `CFA - stack_size`, which is what the DWARF definition of @@ -1430,21 +1159,16 @@ mod unwind { } else { base.checked_add(location.offset as usize) }; - let Some(address) = address else { - continue; - }; - if address == 0 || address & (std::mem::align_of::() - 1) != 0 { - continue; - } - (state.visit)(ResolvedRoot { - address, - ip, - function_address: record.function_address, - dwarf_reg: location.dwarf_reg, - offset: location.offset, - base, - }); - } + address.map(|address| (address, base)) + }; + visit_record_slots( + state.index, + record, + ip, + &mut resolve, + &mut state.stats, + &mut state.visit, + ); } 0 } @@ -1594,8 +1318,18 @@ mod unwind { if !matched.is_empty() { stats.records_matched = stats.records_matched.saturating_add(matched.len()); for record in matched { - for location in index.locations(record) { - stats.locations_visited = stats.locations_visited.saturating_add(1); + // This walker's contract is abandon-on-anomaly — return + // before visiting ANY slot the moment one location cannot + // be resolved and bounds-checked. Pre-validate every base + // and derived location, then hand the record to the + // shared visitor with a resolve that can no longer fail. + let all_locations = || { + index + .locations(record) + .iter() + .chain(index.derived_locations(record).iter().map(|d| &d.slot)) + }; + for location in all_locations() { let Some(base) = frame_base(&context, location.dwarf_reg) else { return stats; }; @@ -1613,14 +1347,25 @@ mod unwind { { return stats; } - visit(ResolvedRoot { - address, - ip: context.rip as usize, - function_address: record.function_address, - dwarf_reg: location.dwarf_reg, - offset: location.offset, - base, - }); + } + let mut resolve = |location: &StackMapLocation| { + let base = frame_base(&context, location.dwarf_reg)?; + let address = if location.offset < 0 { + base.checked_sub(location.offset.unsigned_abs() as usize) + } else { + base.checked_add(location.offset as usize) + }; + address.map(|address| (address, base)) + }; + unsafe { + visit_record_slots( + index, + record, + context.rip as usize, + &mut resolve, + &mut stats, + visit, + ); } } } @@ -1821,36 +1566,41 @@ mod fp_chain { // SP-relative record in the image (#7173). let sp = fp_to_sp_offset(record.function_address) .and_then(|off| caller_fp.checked_sub(off)); - for location in index.locations(record) { - stats.locations_visited = stats.locations_visited.saturating_add(1); + // An SP-relative location with no decodable + // prologue used to abandon the walk from inside + // the location loop; keep that fail-closed + // answer, decided before any slot is visited. + if sp.is_none() + && index + .locations(record) + .iter() + .chain(index.derived_locations(record).iter().map(|d| &d.slot)) + .any(|l| l.dwarf_reg != DWARF_REG_FP_AARCH64) + { + return None; + } + let mut resolve = |location: &StackMapLocation| { let base = if location.dwarf_reg == DWARF_REG_FP_AARCH64 { - Some(caller_fp) + caller_fp } else { - sp - }; - let Some(base) = base else { - return None; + sp? }; let address = if location.offset < 0 { base.checked_sub(location.offset.unsigned_abs() as usize) } else { base.checked_add(location.offset as usize) }; - let Some(address) = address else { - continue; - }; - if address == 0 || address & (std::mem::align_of::() - 1) != 0 - { - continue; - } - visit(ResolvedRoot { - address, - ip: return_address, - function_address: record.function_address, - dwarf_reg: location.dwarf_reg, - offset: location.offset, - base, - }); + address.map(|address| (address, base)) + }; + unsafe { + visit_record_slots( + index, + record, + return_address, + &mut resolve, + &mut stats, + visit, + ); } } } @@ -1880,6 +1630,19 @@ mod fp_chain { } } +// The compact-map decoder. Its own file because this one is close to the +// 2000-line cap; the re-export is named rather than a glob because a glob does +// not propagate through the transitive re-exports this module sits behind. +#[path = "stack_maps_decode.rs"] +mod decode; +use decode::parse_gc_map; + +// Finding the map section in the running image, per object file format. Its own +// file for the same reason, and re-exported by name for the same reason. +#[path = "stack_maps_sections.rs"] +mod sections; +use sections::loaded_stack_map_sections; + // `verify` mode, and the report it prints when the two walkers disagree. Its // own file because this one is close to the 2000-line cap. #[path = "stack_maps_verify.rs"] diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_decode.rs b/crates/perry-runtime/src/gc/roots/stack_maps_decode.rs new file mode 100644 index 0000000000..bc5d4394d5 --- /dev/null +++ b/crates/perry-runtime/src/gc/roots/stack_maps_decode.rs @@ -0,0 +1,271 @@ +//! Compact GC-map decoding: the concatenated-section parser and the +//! byte-level primitives it reads through. +//! +//! Its own file for the same reason `stack_maps_verify.rs` is: the parent is at +//! the 2000-line cap, and pure code motion is the cheapest way to stay under it. + +use super::{ + StackMapDerived, StackMapLocation, StackMapRecord, DWARF_REG_FP_AARCH64, DWARF_REG_SP_AARCH64, + GC_MAP_MAGIC, GC_MAP_VERSION, +}; + +/// Decode every concatenated compact map in the section. +/// +/// The linker concatenates one blob per object file, so this walks blob by +/// blob using each header's `total_len` rather than assuming a single map — +/// a decoder that reads only the first header silently drops every other +/// object's roots, which is invisible until a collection frees a live object. +pub(super) fn parse_gc_map( + bytes: &[u8], +) -> Option<( + Vec, + Vec, + Vec, +)> { + let mut records = Vec::new(); + let mut roots: Vec = Vec::new(); + let mut derived: Vec = Vec::new(); + let mut base = 0usize; + + while base + 16 <= bytes.len() { + if bytes.get(base..base + 4)? != GC_MAP_MAGIC { + // Linkers pad between input sections; a zero tail is the end. + if bytes[base..].iter().all(|byte| *byte == 0) { + break; + } + base += 1; + continue; + } + if read_u8(bytes, base + 4)? != GC_MAP_VERSION { + return None; + } + let function_count = read_u32(bytes, base + 8)? as usize; + let total_len = read_u32(bytes, base + 12)? as usize; + // Header flags, bit 0: the function-address field is 8 bytes wide. The + // emitter writes the TARGET's pointer width (watchOS `arm64_32` is + // ILP32), and compile target and run target are the same machine — so + // a mismatch here means the binary's map was produced for a different + // width and every function address would be misread. Fail closed. + let flags = read_u16(bytes, base + 6)?; + if (flags & 1 == 1) != (std::mem::size_of::() == 8) { + return None; + } + let entry = if flags & 1 == 1 { 16 } else { 12 }; + // A blob must at least cover its header and function table. Without + // this, a `total_len` of 0 leaves `base` unchanged — and because the + // magic still matches at that offset the resynchronisation path below + // is never reached, so the loop spins forever. This runs inside + // `OnceLock::get_or_init`, so that is a process hang at the first + // collection rather than the fail-closed panic in `stack_maps`. + if total_len < 16 + function_count.checked_mul(entry)? { + return None; + } + let table = base.checked_add(16)?; + let stream_start = table.checked_add(function_count.checked_mul(entry)?)?; + let blob_end = base.checked_add(total_len)?; + if blob_end > bytes.len() || stream_start > blob_end { + return None; + } + + // Instruction offsets are a fixed-width array ahead of the varint + // stream: at -O3 the compiler emits them as label differences the + // assembler evaluates, so their values cannot be varint-encoded at + // rewrite time. + // Not `unwrap_or(0)`: a failed read here means the function table is + // truncated, and treating that function as having zero records starts + // `cursor` at the wrong offset so every later varint decodes from + // misaligned bytes. A wrong live set is worse than no map. + let mut record_total: usize = 0; + for index in 0..function_count { + record_total = + record_total.checked_add(read_u32(bytes, table + index * 16 + 12)? as usize)?; + } + let offsets = stream_start; + let mut cursor = offsets.checked_add(record_total.checked_mul(4)?)?; + if cursor > blob_end { + return None; + } + let mut record_index = 0usize; + + for index in 0..function_count { + // Address width follows the header flag checked above, so the + // stack-size and record-count offsets move with it. + let base_off = table + index * entry; + let addr_bytes = entry - 8; + let function_address = if addr_bytes == 8 { + read_u64(bytes, base_off)? as usize + } else { + read_u32(bytes, base_off)? as usize + }; + let stack_size = u64::from(read_u32(bytes, base_off + addr_bytes)?); + let record_count = read_u32(bytes, base_off + addr_bytes + 4)? as usize; + + // The shared tag/delta slot decoding (see gc_map.rs's + // `encode_slots`): 2-bit base tag — 0 = FP, 1 = SP, 2 = explicit + // DWARF register in a following varint (LLVM uses x19 as a frame + // base in functions with dynamic allocation) — then a zigzagged + // offset delta, chained per list. + fn decode_slot_list( + bytes: &[u8], + mut cursor: usize, + blob_end: usize, + count: usize, + out: &mut Vec, + ) -> Option { + let mut last: Option = None; + for _ in 0..count { + let (value, next) = read_varint(bytes, cursor, blob_end)?; + cursor = next; + let dwarf_reg = match value & 3 { + 0 => DWARF_REG_FP_AARCH64, + 1 => DWARF_REG_SP_AARCH64, + 2 => { + let (reg, next) = read_varint(bytes, cursor, blob_end)?; + cursor = next; + u16::try_from(reg).ok()? + } + _ => return None, + }; + let delta = unzigzag((value >> 2) as u32); + let offset = match last { + None => delta, + Some(previous_offset) => previous_offset.wrapping_add(delta), + }; + last = Some(offset); + out.push(StackMapLocation { dwarf_reg, offset }); + } + Some(cursor) + } + + let mut previous: Option<(u32, u32, u32, u32)> = None; + for _ in 0..record_count { + let instruction_offset = read_u32(bytes, offsets + record_index * 4)?; + record_index += 1; + + let (header, next) = read_varint(bytes, cursor, blob_end)?; + cursor = next; + let range = if header & 1 == 1 { + // Repeat: this safepoint's live set is the previous one's + // — bases and deriveds both. + previous? + } else { + // v4 header word: (root_count << 2) | (has_derived << 1). + let count = (header >> 2) as usize; + let has_derived = header & 2 != 0; + let start = u32::try_from(roots.len()).ok()?; + cursor = decode_slot_list(bytes, cursor, blob_end, count, &mut roots)?; + let derived_start = u32::try_from(derived.len()).ok()?; + let mut derived_count = 0u32; + if has_derived { + let (entries, next) = read_varint(bytes, cursor, blob_end)?; + cursor = next; + derived_count = u32::try_from(entries).ok()?; + let mut bases = Vec::with_capacity(derived_count as usize); + for _ in 0..derived_count { + let (base_index, next) = read_varint(bytes, cursor, blob_end)?; + cursor = next; + // The base index addresses THIS record's roots + // list; out of range means the stream and this + // decoder disagree — fail closed like any other + // malformed map. + if base_index >= count as u64 { + return None; + } + bases.push(u32::try_from(base_index).ok()?); + } + let mut slots = Vec::with_capacity(derived_count as usize); + cursor = decode_slot_list( + bytes, + cursor, + blob_end, + derived_count as usize, + &mut slots, + )?; + for (base_index, slot) in bases.into_iter().zip(slots) { + derived.push(StackMapDerived { base_index, slot }); + } + } + ( + start, + u32::try_from(count).ok()?, + derived_start, + derived_count, + ) + }; + previous = Some(range); + + records.push(StackMapRecord { + pc: function_address.checked_add(instruction_offset as usize)?, + function_address, + stack_size, + roots_start: range.0, + roots_len: range.1, + derived_start: range.2, + derived_len: range.3, + }); + } + } + + let next = align_up(blob_end, 8)?; + if next <= base { + return None; + } + base = next; + } + + Some((records, roots, derived)) +} + +/// LEB128 read bounded by the blob it belongs to, so a corrupt length cannot +/// walk into the next blob or off the section. +fn read_varint(bytes: &[u8], mut at: usize, end: usize) -> Option<(u64, usize)> { + let mut value = 0u64; + let mut shift = 0u32; + loop { + if at >= end || shift > 63 { + return None; + } + let byte = *bytes.get(at)?; + at += 1; + value |= u64::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Some((value, at)); + } + shift += 7; + } +} + +fn unzigzag(value: u32) -> i32 { + ((value >> 1) as i32) ^ -((value & 1) as i32) +} + +fn align_up(value: usize, alignment: usize) -> Option { + value + .checked_add(alignment.checked_sub(1)?) + .map(|value| value & !(alignment - 1)) +} + +fn read_u8(bytes: &[u8], offset: usize) -> Option { + bytes.get(offset).copied() +} + +/// Used by the map header's flags field and by ELF section headers. It was +/// briefly Linux-gated, which broke the Linux build the moment the map itself +/// needed a 16-bit read — keep it unconditional. +pub(super) fn read_u16(bytes: &[u8], offset: usize) -> Option { + Some(u16::from_le_bytes( + bytes.get(offset..offset + 2)?.try_into().ok()?, + )) +} + +pub(super) fn read_u32(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +pub(super) fn read_u64(bytes: &[u8], offset: usize) -> Option { + Some(u64::from_le_bytes( + bytes.get(offset..offset + 8)?.try_into().ok()?, + )) +} diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs index 9256225427..d34013e969 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_decode_tests.rs @@ -20,35 +20,57 @@ mod tests { ((value << 1) ^ (value >> 31)) as u32 as u64 } - /// Build one compact blob, mirroring `perry-codegen/src/gc_map.rs`. - /// `records` is `(instruction_offset, roots)`, roots as `(dwarf_reg, offset)`; - /// an empty root slice with `repeat` set encodes the repeat flag. - fn one_map(function: u64, records: &[(u32, Vec<(u16, i32)>, bool)]) -> Vec { + fn push_slots(stream: &mut Vec, slots: &[(u16, i32)]) { + let mut last: Option = None; + for (reg, offset) in slots { + let tag = match *reg { + DWARF_REG_FP_AARCH64 => 0u64, + DWARF_REG_SP_AARCH64 => 1, + _ => 2, + }; + let delta = match last { + None => *offset, + Some(previous) => offset.wrapping_sub(previous), + }; + push_varint(stream, (zigzag(delta) << 2) | tag); + if tag == 2 { + push_varint(stream, u64::from(*reg)); + } + last = Some(*offset); + } + } + + /// Build one compact blob, mirroring `perry-codegen/src/gc_map.rs` (v4). + /// `records` is `(instruction_offset, roots, derived, repeat)` — roots as + /// `(dwarf_reg, offset)`, derived as `(base_index, dwarf_reg, offset)`; + /// empty lists with `repeat` set encode the repeat flag. + #[allow(clippy::type_complexity)] + fn one_map_with_derived( + function: u64, + records: &[(u32, Vec<(u16, i32)>, Vec<(u32, u16, i32)>, bool)], + ) -> Vec { let mut offsets = Vec::new(); let mut stream = Vec::new(); - for (instruction_offset, roots, repeat) in records { + for (instruction_offset, roots, derived, repeat) in records { offsets.extend_from_slice(&instruction_offset.to_le_bytes()); if *repeat { push_varint(&mut stream, 1); continue; } - push_varint(&mut stream, (roots.len() as u64) << 1); - let mut last: Option = None; - for (reg, offset) in roots { - let tag = match *reg { - DWARF_REG_FP_AARCH64 => 0u64, - DWARF_REG_SP_AARCH64 => 1, - _ => 2, - }; - let delta = match last { - None => *offset, - Some(previous) => offset.wrapping_sub(previous), - }; - push_varint(&mut stream, (zigzag(delta) << 2) | tag); - if tag == 2 { - push_varint(&mut stream, u64::from(*reg)); + let has_derived = u64::from(!derived.is_empty()); + push_varint( + &mut stream, + ((roots.len() as u64) << 2) | (has_derived << 1), + ); + push_slots(&mut stream, roots); + if !derived.is_empty() { + push_varint(&mut stream, derived.len() as u64); + for &(base_index, _, _) in derived { + push_varint(&mut stream, u64::from(base_index)); } - last = Some(*offset); + let slots: Vec<(u16, i32)> = + derived.iter().map(|&(_, reg, off)| (reg, off)).collect(); + push_slots(&mut stream, &slots); } } @@ -79,6 +101,15 @@ mod tests { bytes } + /// The v3-shaped builder every existing test uses: roots only. + fn one_map(function: u64, records: &[(u32, Vec<(u16, i32)>, bool)]) -> Vec { + let with_derived: Vec<(u32, Vec<(u16, i32)>, Vec<(u32, u16, i32)>, bool)> = records + .iter() + .map(|(off, roots, repeat)| (*off, roots.clone(), Vec::new(), *repeat)) + .collect(); + one_map_with_derived(function, &with_derived) + } + fn simple(function: u64, offset: u32, frame_offset: i32) -> Vec { one_map(function, &[(offset, vec![(29, frame_offset)], false)]) } @@ -86,7 +117,7 @@ mod tests { #[test] fn decodes_frame_location() { let bytes = simple(0x1000, 0x10, -8); - let (records, roots) = parse_gc_map(&bytes).expect("valid map"); + let (records, roots, _) = parse_gc_map(&bytes).expect("valid map"); assert_eq!(records.len(), 1); assert_eq!(records[0].pc, 0x1010); assert_eq!(records[0].function_address, 0x1000); @@ -104,7 +135,7 @@ mod tests { fn decodes_linker_concatenated_input_sections() { let mut bytes = simple(0x1000, 0x10, -8); bytes.extend_from_slice(&simple(0x2000, 0x20, -16)); - let (records, _) = parse_gc_map(&bytes).expect("concatenated maps"); + let (records, _, _) = parse_gc_map(&bytes).expect("concatenated maps"); assert_eq!(records.len(), 2); assert_eq!(records[0].pc, 0x1010); assert_eq!(records[1].pc, 0x2020); @@ -116,8 +147,11 @@ mod tests { let second = simple(0x2000, 0x20, -16); let mut records = Vec::new(); let mut roots = Vec::new(); - append_gc_map_section(&mut records, &mut roots, &first).expect("first image map"); - append_gc_map_section(&mut records, &mut roots, &second).expect("second image map"); + let mut derived = Vec::new(); + append_gc_map_section(&mut records, &mut roots, &mut derived, &first) + .expect("first image map"); + append_gc_map_section(&mut records, &mut roots, &mut derived, &second) + .expect("second image map"); assert_eq!(records.len(), 2); assert_eq!(roots.len(), 2); @@ -134,10 +168,16 @@ mod tests { fn index_for(function: u64) -> StackMapIndex { let mut records = Vec::new(); let mut roots = Vec::new(); - append_gc_map_section(&mut records, &mut roots, &simple(function, 0x20, -8)) - .expect("valid test map"); + let mut derived = Vec::new(); + append_gc_map_section( + &mut records, + &mut roots, + &mut derived, + &simple(function, 0x20, -8), + ) + .expect("valid test map"); records.sort_unstable_by_key(|record| record.pc); - index_records(records, roots) + index_records(records, roots, derived) } fn force_reversed_publication(store: Arc, expected_generation: u64) { @@ -340,7 +380,7 @@ mod tests { (0x30, vec![], true), ], ); - let (records, roots) = parse_gc_map(&bytes).expect("valid map"); + let (records, roots, _) = parse_gc_map(&bytes).expect("valid map"); assert_eq!(records.len(), 3); assert_eq!(roots.len(), 2, "the repeats must not append new roots"); for record in &records { @@ -349,10 +389,59 @@ mod tests { } } + #[test] + fn decodes_derived_interior_slots_with_their_bases() { + // #7803: a for-of element cursor is a DERIVED pointer — the v3 format + // collapsed the (base, derived) pair, so the walker chased the + // interior address as an object start and never rewrote the cursor + // as base'+delta after a move. v4 keeps the pairing; the repeat flag + // must carry it too. + let bytes = one_map_with_derived( + 0x1000, + &[ + (0x10, vec![(29, -16), (29, -8)], vec![(1, 31, 24)], false), + (0x20, vec![], vec![], true), + ], + ); + let (records, roots, derived) = parse_gc_map(&bytes).expect("valid map"); + assert_eq!(records.len(), 2); + assert_eq!(roots.len(), 2); + assert_eq!( + derived, + vec![StackMapDerived { + base_index: 1, + slot: StackMapLocation { + dwarf_reg: 31, + offset: 24, + }, + }] + ); + for record in &records { + assert_eq!(record.derived_start, 0); + assert_eq!( + record.derived_len, 1, + "the repeat must carry the derived set" + ); + } + } + + #[test] + fn rejects_a_derived_base_index_out_of_range() { + // Fail closed, like every other malformed map: a base index past the + // record's roots would make the walker read a base word from another + // record's slot. + let bytes = + one_map_with_derived(0x1000, &[(0x10, vec![(29, -8)], vec![(1, 31, 24)], false)]); + assert!( + parse_gc_map(&bytes).is_none(), + "base index 1 of 1 roots must not decode" + ); + } + #[test] fn decodes_negative_and_ascending_root_offsets() { let bytes = one_map(0x1000, &[(0, vec![(29, -64), (29, -8), (31, 24)], false)]); - let (_, roots) = parse_gc_map(&bytes).expect("valid map"); + let (_, roots, _) = parse_gc_map(&bytes).expect("valid map"); assert_eq!( roots, vec![ @@ -378,7 +467,7 @@ mod tests { // stack allocation — 66 root slots in one real module. A single FP/SP // bit cannot express that, which is what forced the 2-bit base tag. let bytes = one_map(0x1000, &[(0x10, vec![(19, -40), (29, -8)], false)]); - let (_, roots) = parse_gc_map(&bytes).expect("valid map"); + let (_, roots, _) = parse_gc_map(&bytes).expect("valid map"); assert_eq!( roots, vec![ @@ -405,11 +494,14 @@ mod tests { stack_size: 64, roots_start: 0, roots_len: 1, + derived_start: 0, + derived_len: 0, }], vec![StackMapLocation { dwarf_reg: 19, offset: -40, }], + Vec::new(), ); assert!(!index.chain_walkable); } @@ -483,6 +575,8 @@ mod tests { stack_size: 160, roots_start: 0, roots_len: 1, + derived_start: 0, + derived_len: 0, }; // FP and SP are both walkable: SP resolves per frame by decoding the // owning function's prologue (#7173). @@ -498,6 +592,7 @@ mod tests { offset: -8, }, ], + Vec::new(), ); assert!(walkable.chain_walkable); assert_eq!(walkable.min_pc, 0x1000); @@ -510,6 +605,7 @@ mod tests { dwarf_reg: 1, offset: -8 }], + Vec::new() ) .chain_walkable, "a non-FP/SP register must disable the fast walk" @@ -529,6 +625,8 @@ mod tests { stack_size: 32, roots_start: 0, roots_len: 1, + derived_start: 0, + derived_len: 0, }, StackMapRecord { pc: 0x2040, @@ -536,12 +634,15 @@ mod tests { stack_size: 32, roots_start: 0, roots_len: 1, + derived_start: 0, + derived_len: 0, }, ], vec![StackMapLocation { dwarf_reg: 29, offset: -8, }], + Vec::new(), ); // 0x2004 is 8 bytes past A's last safepoint but lives in B. assert!( @@ -561,6 +662,8 @@ mod tests { stack_size: 32, roots_start: 0, roots_len: 0, + derived_start: 0, + derived_len: 0, }; let maps = vec![rec(0x1000), rec(0x1020)]; assert_eq!(closest_record_pc(&maps, 0x1004), Some(0x1000)); diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_sections.rs b/crates/perry-runtime/src/gc/roots/stack_maps_sections.rs new file mode 100644 index 0000000000..9bfb7efc5c --- /dev/null +++ b/crates/perry-runtime/src/gc/roots/stack_maps_sections.rs @@ -0,0 +1,372 @@ +//! Locating the compact GC-map section in the running image, per object file +//! format: Mach-O on Apple platforms, ELF on Linux, PE on Windows, and a stub +//! everywhere else. +//! +//! Its own file for the same reason `stack_maps_verify.rs` is: the parent is at +//! the 2000-line cap, and pure code motion is the cheapest way to stay under it. + +#[cfg(target_os = "linux")] +use super::decode::{read_u16, read_u32, read_u64}; +#[cfg(target_os = "linux")] +use std::ffi::c_void; + +/// Every 64-bit Apple platform, not only macOS. iOS, iPadOS (which reports as +/// iOS), tvOS and visionOS are all aarch64 + Mach-O and share this loader +/// verbatim; gating it to `target_os = "macos"` sent them to the stub below, +/// where the section is never found and the index is empty — a collector with +/// no native roots, silently, on exactly the platforms that cannot be debugged +/// easily. +/// +/// 64-bit only: watchOS's `arm64_32` has 32-bit pointers, while the map stores +/// function addresses as `u64` and this code does `usize` arithmetic on them. +/// The compiler refuses that target for the same reason. +#[cfg(target_vendor = "apple")] +pub(super) fn loaded_stack_map_sections() -> Result, String> { + use mach2::dyld::{_dyld_get_image_header, _dyld_get_image_vmaddr_slide, _dyld_image_count}; + + const LC_SEGMENT_64: u32 = 0x19; + + #[repr(C)] + #[derive(Clone, Copy)] + struct MachHeader64 { + magic: u32, + cpu_type: i32, + cpu_subtype: i32, + file_type: u32, + command_count: u32, + commands_size: u32, + flags: u32, + reserved: u32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct LoadCommand { + command: u32, + size: u32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct SegmentCommand64 { + command: u32, + size: u32, + segment_name: [u8; 16], + vm_address: u64, + vm_size: u64, + file_offset: u64, + file_size: u64, + max_protection: i32, + initial_protection: i32, + section_count: u32, + flags: u32, + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct Section64 { + section_name: [u8; 16], + segment_name: [u8; 16], + address: u64, + size: u64, + offset: u32, + alignment: u32, + relocation_offset: u32, + relocation_count: u32, + flags: u32, + reserved1: u32, + reserved2: u32, + reserved3: u32, + } + + fn fixed_name_matches(actual: &[u8; 16], expected: &[u8]) -> bool { + actual.get(..expected.len()) == Some(expected) + && actual.get(expected.len()).copied().unwrap_or(0) == 0 + } + + let mut sections = Vec::new(); + unsafe { + for image_index in 0.._dyld_image_count() { + let raw_header = _dyld_get_image_header(image_index); + if raw_header.is_null() { + continue; + } + let header = &*(raw_header.cast::()); + let slide = _dyld_get_image_vmaddr_slide(image_index); + let mut command_ptr = raw_header + .cast::() + .add(std::mem::size_of::()); + for _ in 0..header.command_count { + let load = std::ptr::read_unaligned(command_ptr.cast::()); + if load.size < std::mem::size_of::() as u32 { + break; + } + if load.command == LC_SEGMENT_64 { + let segment = std::ptr::read_unaligned(command_ptr.cast::()); + let mut section_ptr = command_ptr.add(std::mem::size_of::()); + for _ in 0..segment.section_count { + let section = std::ptr::read_unaligned(section_ptr.cast::()); + if fixed_name_matches(§ion.segment_name, b"__PERRY_GCMAP") + && fixed_name_matches(§ion.section_name, b"__perry_gcmap") + { + if let (Some(address), Ok(size)) = ( + (section.address as isize).checked_add(slide), + usize::try_from(section.size), + ) { + if address > 0 && size != 0 { + sections.push(std::slice::from_raw_parts( + address as usize as *const u8, + size, + )); + } + } + break; + } + section_ptr = section_ptr.add(std::mem::size_of::()); + } + } + command_ptr = command_ptr.add(load.size as usize); + } + } + } + Ok(sections) +} + +#[cfg(not(any(target_vendor = "apple", target_os = "linux")))] +pub(super) fn loaded_stack_map_sections() -> Result, String> { + Ok(loaded_stack_map_section().into_iter().collect()) +} + +/// ELF (#7173, #8075): the `.perry_gcmap` sections of every loaded image. +/// +/// Linker-provided `__start_`/`__stop_` symbols would need weak linkage +/// (unstable in Rust) or `-rdynamic` (not guaranteed), so instead: read +/// each `dl_iterate_phdr` image's ELF section headers for `.perry_gcmap` +/// (`sh_addr`, `sh_size`) and add that image's `dlpi_addr` load bias. The +/// executable has an empty `dlpi_name`, for which `/proc/self/exe` is the +/// stable path. Reading only that first image is unsound when the runtime is +/// a provider and generated code lives in an app dylib: its live native roots +/// disappear from the collector exactly when a full collection evacuates. +#[cfg(target_os = "linux")] +pub(super) fn loaded_stack_map_sections() -> Result, String> { + use std::ffi::CStr; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + #[repr(C)] + struct DlPhdrInfo { + dlpi_addr: usize, + dlpi_name: *const std::os::raw::c_char, + dlpi_phdr: *const ElfProgramHeader, + dlpi_phnum: u16, + } + #[repr(C)] + struct ElfProgramHeader { + p_type: u32, + _p_flags: u32, + _p_offset: u64, + p_vaddr: u64, + _p_paddr: u64, + _p_filesz: u64, + p_memsz: u64, + _p_align: u64, + } + struct SectionScan { + sections: Vec<&'static [u8]>, + unreadable_images: Vec, + } + #[allow(clashing_extern_declarations)] + unsafe extern "C" { + fn dl_iterate_phdr( + callback: unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut c_void) -> i32, + data: *mut c_void, + ) -> i32; + } + unsafe extern "C" fn collect(info: *mut DlPhdrInfo, _size: usize, data: *mut c_void) -> i32 { + let Some(info) = info.as_ref() else { + return 0; + }; + let image_name = if info.dlpi_name.is_null() { + &[][..] + } else { + CStr::from_ptr(info.dlpi_name).to_bytes() + }; + // The kernel-provided vDSO has no backing file. It cannot contain + // Perry-generated code, so it is the sole unreadable-image exception. + if image_name == b"linux-vdso.so.1" || image_name == b"linux-gate.so.1" { + return 0; + } + let path = if image_name.is_empty() { + Path::new("/proc/self/exe") + } else { + Path::new(std::ffi::OsStr::from_bytes(image_name)) + }; + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) => { + let scan = &mut *data.cast::(); + scan.unreadable_images + .push(format!("{} ({error})", path.display())); + return 0; + } + }; + let Some((addr, size)) = elf_section_vaddr(&bytes, b".perry_gcmap") else { + return 0; + }; + let Some(start) = info.dlpi_addr.checked_add(addr) else { + return 0; + }; + let Some(section_end) = addr.checked_add(size) else { + return 0; + }; + const PT_LOAD: u32 = 1; + let mapped = !info.dlpi_phdr.is_null() + && std::slice::from_raw_parts(info.dlpi_phdr, usize::from(info.dlpi_phnum)) + .iter() + .filter(|header| header.p_type == PT_LOAD) + .any(|header| { + let Ok(segment_start) = usize::try_from(header.p_vaddr) else { + return false; + }; + let Some(segment_end) = usize::try_from(header.p_memsz) + .ok() + .and_then(|size| segment_start.checked_add(size)) + else { + return false; + }; + addr >= segment_start && section_end <= segment_end + }); + // The on-disk path can be replaced after dlopen. Validate its claimed + // address against the loader's actual PT_LOAD ranges before turning + // it into a slice, so a stale or hostile section table cannot make GC + // initialization read outside the mapped image. + if mapped && start != 0 && size != 0 { + let scan = &mut *data.cast::(); + scan.sections + .push(std::slice::from_raw_parts(start as *const u8, size)); + } + 0 + } + + let mut scan = SectionScan { + sections: Vec::new(), + unreadable_images: Vec::new(), + }; + unsafe { + dl_iterate_phdr(collect, (&mut scan as *mut SectionScan).cast::()); + } + if scan.unreadable_images.is_empty() { + Ok(scan.sections) + } else { + Err(format!( + "unreadable loaded ELF image(s): {}", + scan.unreadable_images.join(", ") + )) + } +} + +/// Minimal ELF64 section-header walk: returns (sh_addr, sh_size) for the +/// named section. Same defensive read style as the stack-map parser. +#[cfg(target_os = "linux")] +fn elf_section_vaddr(bytes: &[u8], name: &[u8]) -> Option<(usize, usize)> { + if bytes.get(..4)? != b"\x7fELF" || *bytes.get(4)? != 2 { + return None; // not ELF64 + } + let shoff = read_u64(bytes, 0x28)? as usize; + let shentsize = read_u16(bytes, 0x3A)? as usize; + let shnum = read_u16(bytes, 0x3C)? as usize; + let shstrndx = read_u16(bytes, 0x3E)? as usize; + let strtab_hdr = shoff.checked_add(shstrndx.checked_mul(shentsize)?)?; + let strtab_off = read_u64(bytes, strtab_hdr.checked_add(0x18)?)? as usize; + for i in 0..shnum { + let hdr = shoff.checked_add(i.checked_mul(shentsize)?)?; + let name_off = read_u32(bytes, hdr)? as usize; + let name_pos = strtab_off.checked_add(name_off)?; + let candidate = bytes.get(name_pos..name_pos.checked_add(name.len())?)?; + let terminator = bytes.get(name_pos + name.len()).copied().unwrap_or(1); + if candidate == name && terminator == 0 { + // Only an SHF_ALLOC section has a runtime virtual address. Refuse + // a file-only namesake before constructing a slice from sh_addr. + const SHF_ALLOC: u64 = 0x2; + if read_u64(bytes, hdr.checked_add(0x08)?)? & SHF_ALLOC == 0 { + return None; + } + let addr = read_u64(bytes, hdr.checked_add(0x10)?)? as usize; + let size = read_u64(bytes, hdr.checked_add(0x20)?)? as usize; + return Some((addr, size)); + } + } + None +} + +/// Windows/PE: the `.pgcmap` section of the running image. +/// +/// The name is seven bytes because a PE image section header has an 8-byte name +/// field — `.perry_gcmap` would be truncated on the way into the image and the +/// lookup below could never match it. `gc_map::COFF_SECTION_NAME` is the +/// compiler-side half of that agreement. +/// +/// `GetModuleHandleW(NULL)` returns the image base, which is also a valid +/// `IMAGE_DOS_HEADER`; the section table follows the optional header, whose +/// size the file header records rather than being fixed. +#[cfg(target_os = "windows")] +fn loaded_stack_map_section() -> Option<&'static [u8]> { + const IMAGE_DOS_SIGNATURE: u16 = 0x5A4D; // "MZ" + const IMAGE_NT_SIGNATURE: u32 = 0x0000_4550; // "PE\0\0" + const SECTION_HEADER_SIZE: usize = 40; + const SECTION_NAME: &[u8] = b".pgcmap"; + + unsafe extern "system" { + fn GetModuleHandleW(name: *const u16) -> *mut core::ffi::c_void; + } + + unsafe { + let base = GetModuleHandleW(std::ptr::null()) as *const u8; + if base.is_null() { + return None; + } + if std::ptr::read_unaligned(base as *const u16) != IMAGE_DOS_SIGNATURE { + return None; + } + // e_lfanew sits at offset 0x3C of the DOS header. + let nt_offset = std::ptr::read_unaligned(base.add(0x3C) as *const u32) as usize; + let nt = base.add(nt_offset); + if std::ptr::read_unaligned(nt as *const u32) != IMAGE_NT_SIGNATURE { + return None; + } + // IMAGE_FILE_HEADER follows the 4-byte signature: NumberOfSections at + // +2, SizeOfOptionalHeader at +16. + let file_header = nt.add(4); + let section_count = std::ptr::read_unaligned(file_header.add(2) as *const u16) as usize; + let optional_size = std::ptr::read_unaligned(file_header.add(16) as *const u16) as usize; + let sections = file_header.add(20).add(optional_size); + + for index in 0..section_count { + let header = sections.add(index * SECTION_HEADER_SIZE); + let name = std::slice::from_raw_parts(header, 8); + // Names shorter than eight bytes are NUL-padded. + let trimmed = match name.iter().position(|b| *b == 0) { + Some(end) => &name[..end], + None => name, + }; + if trimmed != SECTION_NAME { + continue; + } + let virtual_size = std::ptr::read_unaligned(header.add(8) as *const u32) as usize; + let virtual_address = std::ptr::read_unaligned(header.add(12) as *const u32) as usize; + if virtual_size == 0 || virtual_address == 0 { + return None; + } + return Some(std::slice::from_raw_parts( + base.add(virtual_address), + virtual_size, + )); + } + } + None +} + +#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "windows")))] +fn loaded_stack_map_section() -> Option<&'static [u8]> { + None +} diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_verify.rs b/crates/perry-runtime/src/gc/roots/stack_maps_verify.rs index 55a595ffe9..7acb444c3f 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_verify.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_verify.rs @@ -54,10 +54,9 @@ pub(super) fn visit( let mut slow: Vec = Vec::new(); let mut stats = unwind::visit(index, &mut |root: ResolvedRoot| { slow.push(root); - visit(MutableRootSlot { - kind: super::MutableRootSlotKind::NativeStack, - ptr: root.address as *mut u64, - }); + // Same provenance publication as the non-verify walks, so a latch + // fired under PERRY_STACKMAP_WALKER=verify names its frame too. + root.visit_with_context(visit); }); if !addresses_agree(&fast, &slow) { @@ -241,7 +240,7 @@ mod tests { /// An index that vouches for NO function address, so the report never /// dereferences the synthetic addresses above. fn empty_index() -> StackMapIndex { - super::super::index_records(Vec::new(), Vec::new()) + super::super::index_records(Vec::new(), Vec::new(), Vec::new()) } #[test] diff --git a/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs b/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs index 3a4f5fb774..2ea0abdd06 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps_walker_agreement.rs @@ -150,11 +150,14 @@ fn index_for(frame: Frame, return_address: usize, offset: i32) -> StackMapIndex stack_size: frame.stack_size, roots_start: 0, roots_len: 1, + derived_start: 0, + derived_len: 0, }], vec![StackMapLocation { dwarf_reg: DWARF_REG_SP_AARCH64, offset, }], + Vec::new(), ) } diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index b9435641d9..83d098cda3 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -178,9 +178,50 @@ thread_local! { /// original fixed policy, 1 promotes every live nursery object on first /// copy. pub(super) fn tenuring_survivals() -> u8 { + if let Some(forced) = tenuring_survivals_override() { + return forced; + } TENURING_SURVIVALS.with(Cell::get) } +/// `PERRY_GC_TENURING_SURVIVALS=` pins the promotion age, overriding the +/// adaptive threshold (#7432). Diagnostic only; unset means adaptive. +/// +/// # Why (#7803) +/// +/// Three independent measurements say this bug gets LESS likely as collections +/// get denser — paced ~30-50%, interpreter safepoints on 2/8 vs 6/8, unpaced +/// (9.4x the cycles) passing. That is backwards for a value held unrooted +/// across a collection point, and it is what four separate rooting fixes +/// failing to move the rate looks like. +/// +/// `moved_objects` explains it: 892k unpaced against 862k paced, despite 9.4x +/// the cycles. The extra collections are not relocating MORE, they are +/// promoting the same objects SOONER — and an old-gen object is not moved by a +/// minor. So denser collections mean fewer relocations per object. +/// +/// If the defect needs an object RELOCATED while a stale reference to it +/// exists, this knob tests it directly instead of through the schedule: +/// +/// `=1` promote on the first minor -> fewest relocations -> predicts the +/// failure disappears; +/// `=255` never promote by age -> every survivor re-evacuated every +/// cycle -> predicts the failure becomes reliable, which would be the +/// deterministic reproducer this bug has never had. +/// +/// Pair it with `PERRY_GC_SCHEDULE_ALLOC_KB=0`, which pins the schedule +/// exactly (63,941 safepoints, reproduced to the digit), so a change in +/// outcome at a fixed seed is attributable to this knob and nothing else. +fn tenuring_survivals_override() -> Option { + use std::sync::OnceLock; + static OVERRIDE: OnceLock> = OnceLock::new(); + *OVERRIDE.get_or_init(|| { + std::env::var("PERRY_GC_TENURING_SURVIVALS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + }) +} + /// The effective scavenge nursery cap: the configured base /// (`PERRY_GC_SCAVENGE_NURSERY_MB`, default 16 MB) times the influx-driven /// scale. A fixed cap sets collection frequency independently of how much diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index 78b2ee2a07..dc057f5cc4 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -4,6 +4,7 @@ mod deferred_finalize_7635; mod latch; mod pointer_publish_7154; mod promise_side_tables; +mod promoted_remembered_7803; mod survival_and_malloc; mod weak_holder_registry; mod weak_semantics; diff --git a/crates/perry-runtime/src/gc/tests/copying/promoted_remembered_7803.rs b/crates/perry-runtime/src/gc/tests/copying/promoted_remembered_7803.rs new file mode 100644 index 0000000000..45c6c25f82 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/promoted_remembered_7803.rs @@ -0,0 +1,164 @@ +//! #7803: an object promoted to Old DURING THE DRAIN must still get its +//! old->young edges into the remembered set. +//! +//! `rebuild_evacuated_old_to_young_remembered_set` used to run above +//! `collector.drain()`, so it only covered objects the ROOT walks moved. +//! Everything the drain promoted — every transitively-reachable object — +//! was appended to `moved_headers` after the rebuild had already run. A +//! parent promoted mid-drain whose child stayed young then had no +//! remembered-set entry (the collector's own rewrite fires no mutator +//! barrier, so its page was never dirty), and the NEXT minor moved the +//! child without rewriting the parent's slot. +//! +//! The shape below is zod's: schema metadata built at module init, promoted +//! after aging out, never written again, read on every parse. The victim +//! surfaces arbitrarily later as a TypeError or an incoherent-header +//! pin-latch abort, which is why #7803 burned five refuted hypotheses +//! before the whole-heap from-space scan named this slot. +//! +//! # Why this cannot pass vacuously +//! +//! The test asserts its subject ran at every step: the parent must actually +//! be IN old-gen after the promoting minor (a tenuring-threshold change +//! fails loudly here instead of silently degrading the test), the child +//! must still be young at that point (otherwise there is no old->young edge +//! and nothing is being tested), and the final read asserts the slot points +//! at the child's LIVE bytes, not merely at a different address. + +use super::*; + +/// Read closure `user`'s single capture slot. +unsafe fn capture_bits_of(user: usize) -> u64 { + *((user + std::mem::size_of::()) as *const u64) +} + +unsafe fn capture_slot_of(user: usize) -> *mut u64 { + (user + std::mem::size_of::()) as *mut u64 +} + +fn young_closure_capturing(bits: u64) -> usize { + let size = std::mem::size_of::() + std::mem::size_of::(); + let user = crate::arena::arena_alloc_gc(size, 8, GC_TYPE_CLOSURE); + unsafe { + init_test_closure_with_one_capture(user, bits); + } + user as usize +} + +/// A one-capture closure padded past several generation pages, so whatever +/// the drain copies to old-gen AFTER it lands on a different page than +/// whatever was copied BEFORE it. +fn young_padded_closure_capturing(bits: u64) -> usize { + let size = std::mem::size_of::() + + std::mem::size_of::() + + 3 * crate::arena::GENERATION_PAGE_SIZE; + let user = crate::arena::arena_alloc_gc(size, 8, GC_TYPE_CLOSURE); + unsafe { + init_test_closure_with_one_capture(user, bits); + } + user as usize +} + +#[test] +fn drain_promoted_parent_keeps_its_young_child_edge_remembered() { + let _guard = CopyingNurseryTestGuard::new(1); + + // parent captures a young leaf; intermediate captures parent. Only the + // INTERMEDIATE is rooted, so the parent is reached — and, on the + // promoting cycle, moved to Old — by the worklist DRAIN, never by a + // root walk. That drain-phase promotion is the population the pre-fix + // rebuild missed. + // The spacer between intermediate and parent is load-bearing: the + // intermediate is moved by the ROOT walk, so even the pre-fix + // (pre-drain) rebuild covers IT, and its from-space-looking slot value + // keeps its old PAGE in the sticky dirty set. The dirty-page scan is + // PAGE-granular — it walks every object on a dirty page — so a parent + // promoted onto the SAME fresh old page as the intermediate is repaired + // by its neighbor's entry and the pre-fix ordering passes this test by + // accident (measured twice while writing it: without the spacer the + // parent lands 32 bytes after the intermediate). The drain copies the + // spacer's 3 pages of padding between them, so the parent's page has no + // remembered neighbor — zod's shape, where the victim array's page had + // no such benefactor. + let first_child = young_leaf(); + let parent = young_closure_capturing(ptr_bits(first_child)); + let spacer = young_padded_closure_capturing(ptr_bits(parent)); + let intermediate = young_closure_capturing(ptr_bits(spacer)); + js_shadow_slot_set(0, ptr_bits(intermediate)); + + let deref = |slot: u64| (slot & POINTER_MASK) as usize; + // slot0 -> intermediate -> spacer -> parent, re-derived through the + // rooted chain after every collection. + let parent_now = || unsafe { + let intermediate = deref(js_shadow_slot_get(0)); + let spacer = deref(capture_bits_of(intermediate)); + deref(capture_bits_of(spacer)) + }; + + // Age everyone to the brink of promotion (power-on threshold: promote on + // the fourth survival — pinned by + // `test_copying_minor_promotes_survivor_on_fourth_survival`). + for _ in 0..3 { + let _ = gc_collect_minor(); + } + assert!( + crate::arena::pointer_in_nursery(parent_now()), + "parent must still be young after three survivals; the tenuring \ + threshold moved and this test no longer stages a drain promotion" + ); + + // Give the parent a FRESH young child while the parent itself is still + // young: a store to a young parent creates no remembered-set entry, so + // the only thing that can carry this edge across the parent's promotion + // is the promoted-object rebuild under test. + let second_child = young_leaf(); + let expected_bytes = unsafe { + let s = second_child as *const crate::StringHeader; + let data = (s as *const u8).add(std::mem::size_of::()); + std::slice::from_raw_parts(data, (*s).byte_len as usize).to_vec() + }; + // A RAW slot write, no barrier — mirroring the real-world shape: the + // zod slot was filled at allocation time while its owner was young + // (where no barrier is required), and never stored to again. Calling + // the barrier here would hand the edge to the dirty-page machinery and + // let the pre-fix ordering pass this test by accident. + unsafe { + let p = parent_now(); + *capture_slot_of(p) = ptr_bits(second_child); + } + + // The promoting minor: intermediate (rooted) moves in the root walk; + // parent moves — to Old — in the drain; the child (age 1) is copied to + // survivor space. Subject-liveness asserts, not assumptions: + let _ = gc_collect_minor(); + let parent_old = parent_now(); + assert!( + crate::arena::pointer_in_old_gen(parent_old), + "the fourth survival must promote the parent to old-gen; without \ + that there is no drain-promoted old parent and this test covers \ + nothing" + ); + let child_after_promotion = unsafe { deref(capture_bits_of(parent_old)) }; + assert!( + crate::arena::pointer_in_nursery(child_after_promotion), + "the freshly-stored child must still be young after the parent's \ + promotion; an old->old edge tests nothing" + ); + + // The exposing minor: the child moves again (survivor -> survivor). + // Only a remembered-set entry for the drain-promoted parent lets this + // cycle rewrite the parent's capture slot. Pre-fix, the slot keeps + // `child_after_promotion` — from-space about to be recycled. + let _ = gc_collect_minor(); + let parent_old = parent_now(); + let child_final = unsafe { deref(capture_bits_of(parent_old)) }; + assert_ne!( + child_final, child_after_promotion, + "the parent's capture slot was not rewritten when its young child \ + moved: the drain-promoted parent never made it into the remembered \ + set (#7803's root cause — the rebuild ran before the drain)" + ); + unsafe { + assert_string_bytes(child_final as *const crate::StringHeader, &expected_bytes); + } +} diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 540187b395..1a3ab82587 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1433,8 +1433,18 @@ pub unsafe extern "C" fn js_native_call_method( f64::from_bits(object().to_bits()), ); let prev_this = IMPLICIT_THIS.with(|c| c.replace(object().to_bits())); - let result = - crate::closure::js_native_call_value(f64::from_bits(bound), args_ptr, args_len); + // #7803: `clone_closure_rebind_this` above ALLOCATES, so the + // caller's raw `args_ptr` buffer holds pre-move addresses from + // here on. `arg_handles` is what the collector rewrites; the + // buffer is not. Same reasoning as #7528's receiver fix, which + // introduced `refreshed_args` and reached ten sites but not + // this one. + let call_args = refreshed_args(); + let result = crate::closure::js_native_call_value( + f64::from_bits(bound), + call_args.as_ptr(), + call_args.len(), + ); IMPLICIT_THIS.with(|c| c.set(prev_this)); return result; } @@ -1479,10 +1489,15 @@ pub unsafe extern "C" fn js_native_call_method( object(), ); IMPLICIT_THIS.with(|c| c.set(object().to_bits())); + // #7803: two collection points above this line — the + // getter is USER CODE (`js_closure_call0`) and the + // rebind allocates — so the caller's raw buffer is + // stale. Re-read the rooted arguments. + let call_args = refreshed_args(); let result = crate::closure::js_native_call_value( f64::from_bits(bound), - args_ptr, - args_len, + call_args.as_ptr(), + call_args.len(), ); IMPLICIT_THIS.with(|c| c.set(prev_getter_this)); return result; diff --git a/crates/perry-runtime/src/object/this_binding.rs b/crates/perry-runtime/src/object/this_binding.rs index 6f68a0a882..91d77a4d4e 100644 --- a/crates/perry-runtime/src/object/this_binding.rs +++ b/crates/perry-runtime/src/object/this_binding.rs @@ -153,12 +153,82 @@ pub extern "C" fn js_implicit_this_get_sloppy() -> f64 { value } +/// #7803 producer trap (diagnostic, default-off, parsed by value): +/// `PERRY_GC_THIS_SET_CHECK=1` prints — `=abort` aborts with — a backtrace +/// the moment a POINTER_TAG value whose header-at-minus-8 is incoherent +/// enters the implicit-this cell. The seed-3 latch shows the cell (and its +/// frame saves) holding `boxed(array + interior_offset)`; every walk +/// downstream then misreads array element bytes as a GcHeader. The abort at +/// the STORE names the producer, which the collector-side latch cannot. +#[inline] +fn this_set_check(value: f64, side: &str) { + use std::sync::OnceLock; + static MODE: OnceLock = OnceLock::new(); + let mode = + *MODE.get_or_init( + || match std::env::var("PERRY_GC_THIS_SET_CHECK").ok().as_deref() { + Some("abort") => 2, + Some("1") | Some("on") | Some("true") => 1, + _ => 0, + }, + ); + if mode == 0 { + return; + } + let bits = value.to_bits(); + if bits & crate::value::TAG_MASK != crate::value::POINTER_TAG { + return; + } + let addr = (bits & crate::value::POINTER_MASK) as usize; + if addr < crate::gc::GC_HEADER_SIZE + || !crate::value::addr_class::is_plausible_heap_addr(addr) + || !crate::arena::pointer_in_nursery(addr) + { + // Only young-arena candidates: the observed interiors are nursery + // arrays, and old/malloc objects have differently-managed headers. + return; + } + // Read the header through the centralized probe rather than re-typing the + // `addr - GC_HEADER_SIZE` cast: it repeats the band guard and additionally + // rejects the headerless small-buffer slab range, so no reachable input can + // turn this diagnostic into a wild read (`scripts/addr_class_inventory.py`). + let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(addr) }) else { + return; + }; + let (obj_type, size, flags) = (header.obj_type, header.size, header.gc_flags); + if crate::gc::header_incoherence_for_diagnostics(obj_type, size, flags).is_some() { + eprintln!( + "[gc-this-set-check] {side} value {bits:#018x} — target header \ + obj_type={obj_type} size={size} flags={flags:#04x} is INCOHERENT \ + (an interior or stale pointer entering the this cell).\n\ + --- setter backtrace ---\n{}", + std::backtrace::Backtrace::force_capture() + ); + if mode == 2 { + std::process::abort(); + } + } +} + /// Set the implicit `this` and return the previous value. /// Callers must restore the previous value to scope the binding to the /// duration of a single method-style call. #[no_mangle] pub extern "C" fn js_implicit_this_set(value: f64) -> f64 { - IMPLICIT_THIS.with(|c| f64::from_bits(c.replace(value.to_bits()))) + this_set_check( + value, + "INCOMING (read from a frame save slot — the WALKER corrupted the slot)", + ); + let previous = IMPLICIT_THIS.with(|c| f64::from_bits(c.replace(value.to_bits()))); + // Under the trap, grade the OUTGOING value too: an incoherent incoming + // value was read from a frame save slot (the walker corrupted the SLOT), + // an incoherent outgoing one was sitting in the cell (the scanner + // corrupted the CELL). Which side fires first is the decisive bit. + this_set_check( + previous, + "OUTGOING (was sitting in the cell — the SCANNER corrupted the cell)", + ); + previous } /// Read the current `new.target` value for ordinary function bodies. diff --git a/crates/perry/src/commands/compile/post_link.rs b/crates/perry/src/commands/compile/post_link.rs index 47dc3c1f83..8894ba27b8 100644 --- a/crates/perry/src/commands/compile/post_link.rs +++ b/crates/perry/src/commands/compile/post_link.rs @@ -45,6 +45,17 @@ pub(super) fn strip_final_binary( // no_mangle JNI/FFI symbols PerryActivity resolves at load. || is_android_target(target) || std::env::var("PERRY_DEBUG_SYMBOLS").is_ok() + // #7803 tooling: keep the symbol table WITHOUT asking for DWARF. + // + // `PERRY_DEBUG_SYMBOLS` does both — every consumer reads it with + // `is_some()`, so there is no value that skips the strip and leaves + // `-g` off. That coupling is a problem for an intermittent bug: the + // symbolized build of the #7803 corpus passed 7 seeds that the plain + // build fails at 44%, so asking for symbols changed the subject. This + // knob skips ONLY the strip, leaving codegen byte-identical to the + // build that reproduces, which is what makes the backtrace it yields + // evidence about the same program. + || std::env::var("PERRY_KEEP_SYMBOLS").is_ok() { return; } diff --git a/gc-handoff/7803-NEXT-PROMPT.md b/gc-handoff/7803-NEXT-PROMPT.md new file mode 100644 index 0000000000..b609c8d3a4 --- /dev/null +++ b/gc-handoff/7803-NEXT-PROMPT.md @@ -0,0 +1,88 @@ +# Task: close the LAST #7803 window (seed 3) + +Sessions 1–3 background: `gc-handoff/ZOD-NOTES.md` §1–§36. Session 4 +(2026-08-15) is §37–§40 and CHANGED THE PICTURE COMPLETELY — read §39 and +§40 before anything else. This file replaces its previous self. + +## State in five lines + +The main #7803 root cause is FOUND and FIXED: the compact GC map collapsed +RS4GC (base, derived) pairs on the false premise "Perry has no interior +pointers"; for-of element cursors were walked as object starts and never +rewritten as base+delta (gc_map v4 + derived-aware walkers, on the branch). +Seeds 1, 2, 5 flip to clean (pre-fix ~2/3 aborting, fixed ordinals). ONE +window remains: seed 3, 3/3 abort, characterized in §40 to the exact slot. + +## The residual, precisely (all one-run reproducible) + +``` +cd /Users/amlug/projects/perry/wt-7803 +cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static +PERRY_RUNTIME_DIR=$PWD/target/release PERRY_NO_AUTO_OPTIMIZE=1 \ +PERRY_DISABLE_BUILD_CACHE=1 PERRY_KEEP_SYMBOLS=1 \ + ./target/release/perry test-files/gc-dep-corpus/main.ts -o /tmp/zod + +# CREATION-cycle abort in ~seconds-to-minutes (collection #186): +PERRY_GC_SCHEDULE_SEED=3 PERRY_GC_SCHEDULE_RATE=0.1 \ +PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=0 \ +PERRY_GC_NATIVE_SLOT_VERIFY=1 /tmp/zod +``` + +Facts the instruments already pinned (§40): victim slot = SP+40 of +`schemas_ts__138` at its `+0xEA0` js_closure_call1 statepoint (the this-save +around that call; record exists, lists the slot, has no derived entries); +creation = an ORDINARY traced copy-minor whose rewrite walk traversed the +frame (frames=20/records=7/locations=36); the slot value at creation = a +boxed POINTER_TAG interior into a strings array (target-8 reads as boxed +strings); `collector_classify=None` (plausible_gc_header rejects interiors) +vs global Survivor1=from — so the rewrite silently skips, forever. The +two-sided this-set trap proves the interior appears in the slot between the +save and restore WITHOUT passing through js_implicit_this_set. + +## The open contradiction to break (start here) + +A value saved coherent, in a walked slot, reads as a boxed interior at the +first in-suspension collection. Either (a) the SAVE stores a different +register/slot than the map attributes at +0xEA0 (slot/liveness attribution), +or (b) an earlier-suspension record's walk rewrote this stack address under +another interpretation. Next instrument (one edit in +`gc/roots/stack_maps.rs::verify_native_slots_post_walk`): on the failing +cycle dump ALL slot values of the matched record(s) (16/24/32/40/48) AND the +full list of records `match_records` returned — adjacent records within the +±16 window exist in this function (+0xfd4/+0xfd8 pairs) and a double-match +walking one frame under two records is unaudited. + +## Instruments on the branch (all default-off, all one-run) + +| knob | what it does | +|---|---| +| `PERRY_GC_NATIVE_SLOT_VERIFY=1` | abort at the CREATION cycle of a stale native slot, with cycle kind, rewrite-walk stats, collector classification, raw target header | +| `PERRY_GC_THIS_SET_CHECK=1|abort` | trap incoherent implicit-this values, both directions (incoming = frame slot corrupted, outgoing = cell corrupted) | +| pin-latch (always-on) | names owning frame/reg/offset/slot, raw slot word, target neighborhood, census-backed ENCLOSING object | +| `PERRY_GC_FROMSPACE_SCAN(_ABORT)` | now bounded at array length (§38 false positive fixed) | + +## Traps that cost this session time — do not repeat + +* Seeds do NOT port across binaries, and detection is an address lottery on + top of a deterministic schedule window. Compare rates and windows, never + single runs; the CREATION-cycle verifier removes the lottery entirely. +* The scan/latch "garbage headers" are just NaN-boxed words at `addr-8`: + they do NOT discriminate stale-into-recycled from interior-into-live. + §37's confident spray story died on that; so did the §38 slack false + positive. The enclosing-object dump is the discriminator. +* `chain_walkable=false` on these binaries (x19-based roots) — every walk is + the Itanium unwinder; fp-chain hypotheses are dead on arrival. +* Wrapper exit codes lie: `| head` SIGPIPEs long runs, `| tail` hides script + failures. This session hit both. Grep to files. + +## Chores before undrafting PR #8084 + +* Gap suite on THIS tree (session 4 ran it — check the result landed in + §40/§41; rerun quiet if not): `PERRY_SKIP_BUILD=1 ./scripts/run_gap_tests.sh`. +* `scripts/gc_root_dominance_*.sh` reader fix (5f76bf5c7) also fixes MAIN's + red nightly — consider cherry-picking it out as its own fast PR. +* The corpus budget in `gc-root-dominance.yml` is `--max-unrooted 3`; after + the spread-new fix the residuals are 185 (`rel_ge`) + util 121 (read-only + sinks) — tighten to 2 once re-measured. +* §33's 36-site `js_native_call_method` args_ptr population is still open + (unrelated to the residual; separate issue recommended). diff --git a/gc-handoff/ZOD-NOTES.md b/gc-handoff/ZOD-NOTES.md index f6504f4154..2de4ade560 100644 --- a/gc-handoff/ZOD-NOTES.md +++ b/gc-handoff/ZOD-NOTES.md @@ -293,3 +293,2076 @@ RATE=1 TIMEOUT=1800 KEEP=1 PERRY_GC_PROTECT_FROMSPACE=0 PERRY_GC_DIAG=1 \ `PERRY_NO_AUTO_OPTIMIZE=1` is not optional: without it the auto-optimizer relinks the runtime without `diagnostics`, which removes the very `[gc-fromspace-protect]` evidence §3 depends on. + +--- + +# Session 2 (2026-08-13, `wt-7803` @ `410dadd45`, v0.5.150x) + +Picks up §7 "left open". Worktree `/Users/amlug/projects/perry/wt-7803`, branch +`fix/7803-zod-gc-rooting`, in-tree `target/` (not a separate `CARGO_TARGET_DIR` +this time). Everything below was measured on a **contended box** — three other +worktrees (`wt-1849`, `wt-5497`, `wt-7170`) were building throughout, and the +corpus compile ran at 22% CPU — so wall times here are not comparable with +§8's, and are quoted only to budget a repeat. + +## 9. The corpus/lowering matrix has an empty cell, and it is the cell #7803 lives in + +`gc-root-dominance.yml` emits three corpora, not four: + +| | shadow (`PERRY_RS4GC=0`) | native (statepoints — **what ships**) | +|------------------------|-------------------------------|---------------------------------------| +| curated (~124 files) | gated (dominance, allocas, `--max-stale 39`) | gated (`--statepoints --max-stale 0`) | +| dependency-scale (zod) | gated (dominance, allocas, `--max-stale 118`) | **never emitted** | + +Two separate corrections landed in this file and neither reached the other's +cell: + +* **#7280** — the curated corpus is the wrong POPULATION. "25 curated files + pass while 20 lines of stock zod fail." That added `ir-corpus-dep`. +* **#7452** — the shadow lowering is the wrong LOWERING. Statepoints became the + default in #7370, so a `PERRY_RS4GC=0` corpus contains zero of the root form + that ships; the curated corpus "was still emitting 81 modules with 0 bind + call sites". That added `ir-corpus-native`, curated only. + +The intersection — the zod corpus compiled the way the failing binary is +compiled — has never been generated, so its stale/unrooted population is +unmeasured. That is not a small residual either: the curated corpus's own +unfiltered native census reads **1123 unrooted + 321 stale** (the diagnostic +step in the same job), against a gated arm of 21. + +`scratchpad/zod/dep_native_corpus.sh` emits it (compile `PERRY_RS4GC=1`, then +the production `STATEPOINT_REWRITE_PASSES` rewrite through `opt`, single-sourced +out of `crates/perry-codegen/src/inprocess.rs` exactly as the curated script +does, plus the same generation-time subject-liveness assertion so an empty +corpus cannot read as a clean one). + +**Status: script written, measurement not yet taken.** Do not quote a number +here until it has run. + +### 9a. Measured: the empty cell reads 66, where its sibling is gated at 0 + +`scripts/…/dep_native_corpus.sh` → 81 modules, **52,198 statepoints, 39,073 +non-empty live bundles**, 0 rewrite failures (the curated native corpus, for +scale, is 30,033 / 17,478). Then the same mode the curated arm gates on: + +``` +python3 scripts/gc_root_dominance_check.py ir-corpus-dep-native \ + --statepoints --moving-only \ + --min-files 60 --min-funcs 1200 \ + --min-statepoints 15000 --min-live-bundles 8000 --min-relocates 20000 +``` + +``` +=== statepoint hazards: 66 (unrooted: 66, stale: 0) + 28 unrooted/global 19 unrooted/rootread + 18 unrooted/alloc 1 unrooted/capture + 24 sink=js_new_function_construct + 17 sink=js_closure_call1 + 16 sink=js_closure_call_apply_with_spread + 6 sink=js_closure_call2 + 1 sink=js_array_concat 1 sink=js_rel_ge + 1 sink=js_get_string_pointer_unified + (277 more suppressed by the #7210 IMMOVABLE_SOURCES box exemption) +``` + +**The curated corpus in the identical mode is gated at ZERO.** #7725 deleted its +`--max-unrooted` budget precisely because "`--max-unrooted` already defaults to +0, and a budget nobody re-measures is exactly the silently-absorbing kind", and +`gc-root-dominance-statepoints` is green on `main` as of `81a88de40` (verified +2026-08-13). So this is not "the instrument is noisy": it is calibrated to zero +on the curated population and reads 66 on the dependency one. + +`unrooted` is the serious class — the checker's own definition is "no +`ptr addrspace(1)` value in the register's cast chain is in the window +statepoint's live bundle. The OBJECT is unprotected: nothing marks it, nothing +rewrites it." That is #7207's shape, and it is the one that produces #7803's +two observed messages: + +* 39 of the 66 sink into `js_closure_call1` / `js_closure_call2` / + `js_closure_call_apply_with_spread` → **`TypeError: value is not a function`** + (seed 4, both sessions); +* 24 sink into `js_new_function_construct` → a receiver that is not the object + it was, which is what `Cannot read properties of undefined (reading '…')` + looks like downstream (seeds 1/16, and the filed `'toString'`). + +Zero `stale`, so `root_reload.rs` is doing its job on this corpus; the residual +is the *unrooted* class, which a reload cannot fix — those need a root. + +**This does not yet prove any of the 66 is the one that kills the run.** It +says the shipping lowering of this workload carries 66 hazards of exactly the +right shape that no gate has ever looked at. Itemisation and cross-referencing +against a captured backtrace is the next step, not a conclusion to skip to. + +## 10. The rate at HEAD: 7 of 16, not 3 of 16 + +Same command as §4, same corpus, `zod@4.3.5` unchanged, on `410dadd45`: + +``` +OUTDIR=… RATE=1 TIMEOUT=2400 KEEP=1 PERRY_GC_PROTECT_FROMSPACE=0 PERRY_GC_DIAG=1 \ + ./scripts/gc_schedule_fuzz.sh /tmp/zod-head 16 +``` + +| seed | verdict | safepoints | moved | +|---|---|---|---| +| 1 | **FAIL** `…undefined (reading 'issues')` | 1930 | 271,716 | +| 4 | **FAIL** `value is not a function` | 2602 | 353,678 | +| 10 | **FAIL** `…(reading 'issues')` | 2592 | 352,878 | +| 11 | **FAIL** `value is not a function` | 1581 | 230,109 | +| 12 | **FAIL** `…(reading 'issues')` | 2520 | 345,143 | +| 14 | **FAIL** `…(reading 'issues')` | 1234 | 187,843 | +| 15 | **FAIL** `…(reading 'issues')` | 1641 | 237,617 | +| 2,3,5,6,7,8,9,13,16 | pass | 6804–6931 | ~862k | + +**7/16 (44%)** against §4's 3/16 (19%). Fisher exact on the two sweeps is +p≈0.06 — suggestive, not established, and the honest reading is that ONE of +these is true and I have not separated them: + +* the class got worse between v0.5.1499 and `410dadd45` (20+ commits, several + GC-adjacent: #8014, #8023, #8024, #8026), or +* 16 runs is simply too thin to distinguish 19% from 44%. + +Deciding it needs the v0.5.1499 binary rebuilt and swept on the same box in the +same session, which is the correct A/B and was not done here. Do NOT quote +"the rate doubled" from this table alone. + +Two things it DOES establish, both load-bearing: + +* the subject is live on `main` today, so a fix has something to close; +* a failing run dies at safepoint 1234–2602 of the ~6850 a passing run + completes, i.e. **early** — inside module init / `describeAll()` / + `parseLoop(96)`, before the first `console.log`. §7's phase probe could not + localize it because the markers perturbed the schedule; the safepoint counts + say it without needing a probe. + +Note the passing runs are extremely uniform (6804–6931 safepoints, ~862k moved, +`loop_polls` a constant 63,936). The §1 "4% drift" is the same phenomenon seen +at a smaller sample: the schedule is stable to about ±1%, and what varies is +whether the run survives to finish it. + +## 11. The debugger is not a usable instrument here — so the runtime got one + +The plan was: break on the two throw helpers, read the native stack, name the +compiled JS function that read the lost value. The mechanics all work — + +* `js_throw_type_error_property_access` and `js_throw_type_error_not_a_function` + are `#[no_mangle]` globals and each resolves to exactly ONE location; +* a healthy unscheduled run hits NEITHER (verified), so any hit is the failure + and nothing else, no filtering needed; +* `--debug-symbols` keeps 1726 `_perry_fn_*` symbols in the corpus binary, so + the frames have names. + +**But the failure does not reproduce under `lldb`.** 4 seeds, all of which fail +natively at 44%, all passed to completion under the debugger (`bt` reported +"requires a process which is currently stopped"). 4 samples is 0.56⁴ ≈ 10% by +chance, so this is *suspicion, not proof* — I stopped rather than spend an hour +proving it, because the fix is the same either way. Disabling lldb's default +ASLR-off (`settings set target.disable-aslr false`) did not bring it back, so +address randomisation is not the discriminator. + +> Recorded as a mistake rather than quietly fixed: the sweep piped logs through +> `grep -vE '^\[gc-'`, which also removed the `[gc-schedule] done:` summary — +> the line that proves the run collected anything at all. Those four "passes" +> therefore carry no liveness evidence of their own. (An earlier run of the same +> harness, before the filter, did print `safepoints=6349 copying_minors=6349 +> moved_objects=847052`, so the env does reach the target under lldb.) A sweep +> whose logs cannot show its subject ran is the vacuous-green shape, and it took +> a second look to notice. + +So the instrument moved into the runtime, where it observes the run that +actually fails: **`PERRY_UNCAUGHT_BACKTRACE=1`** (`exception.rs`) emits a +symbolicated native backtrace on the uncaught-throw path, reusing the +`libc::backtrace` + `backtrace_symbols_fd` pair `arena::quarantine` already +uses. Off by default, parsed BY VALUE (`1`/`on`/`true`) — the `PERRY_GC_DIAG=0` +footgun in §3 is one release old and does not get repeated. It fires at most +once per process, on a path already headed for `exit(1)`. + +## 12. What the 66 are: two shapes, 37 of them in the functions this workload calls constantly + +Grouped by fingerprint (`scratchpad/zod/dep-native-verbose.txt` has all 66): + +| n | module | shape | +|---|---|---| +| 21 | `v4/classic/schemas.ts` | `unrooted:global -> js_closure_alloc` | +| 16 | `v4/classic/schemas.ts` | `unrooted:alloc -> js_array_like_to_array` | +| 10 | `v4/core/errors.ts` | `unrooted:rootread -> js_ctor_return_override` / `js_closure_get_capture_bits` | +| 7 | `v4/locales/he.ts` | `unrooted:rootread -> js_object_get_field_by_name_f64` | +| 5 | `v4/locales/lt.ts` | `unrooted:global -> js_object_get_field_by_name_f64` | +| 2+1+1+1+1 | `core/schemas.ts`, `core/parse.ts`, `core/util.ts`, `core/doc.ts`, `classic/schemas.ts` | tail | + +**The 12 locale hits are almost certainly not this bug.** They are inside +`he`/`lt` message-map closures; the corpus never selects a non-`en` locale, so +those bodies never run and a hazard in an uncalled function cannot fire. Worth +fixing, not worth chasing here. That leaves ~54, and 37 of them are two shapes +in one file. + +### Shape A — 21× `unrooted:global`, e.g. `strictObject` / `looseObject` + +```llvm +%r25 = load double, ptr @perry_global_…_classic_schemas_ts__39 ; module-level var +; across safepoint: js_closure_alloc, js_closure_call1, ; ← user code runs +; js_closure_set_capture_bits, js_object_alloc +call @llvm.experimental.gc.statepoint.p0(… @js_new_function_construct, %r25-derived, …) +``` + +A module-level variable holding a constructor is loaded into a register; a +closure is allocated and **called** (`js_closure_call1` — arbitrary user code, +so an evacuating minor is entirely plausible); the pre-move register is then +handed to `js_new_function_construct`. `@perry_global_*` IS a registered root, +so the object survives *at a new address* — property (2) without property (3), +#7240's shape exactly. Constructing from a recycled address yields an object +whose fields read `undefined`, which is what `Cannot read properties of +undefined (reading '…')` looks like one frame later. + +**This population is knowingly unhandled, and `root_reload.rs` says so:** + +> `is_string_handle_global`: "Narrow on purpose: `@perry_global_*` is a +> module-level variable the PROGRAM assigns, so re-reading it could observe a +> later assignment instead of the value the call was given — **that population +> needs rooting, not reloading, and is deliberately not matched here.**" + +That judgement is right — a reload is unsound here — and the rooting it defers +to was never done. What is new is the *count on a real library*: the reason to +prioritise it could not be seen, because the corpus that exhibits it was never +emitted under the lowering that ships. + +### Shape B — 16× `unrooted:alloc`, closures 146/147/… + +```llvm +%r123 = +; across safepoint: js_array_like_to_array ; allocates +call @llvm…statepoint(… @js_closure_call_apply_with_spread, … %r123 …) +``` + +A fresh object held in a bare register across the array-like→array conversion +of a spread/`apply` call, then passed to the call. Nothing roots it at all +(#7207's shape, the one `--unrooted-allocas` was built for). Unlike Shape A +there is no soundness objection to fixing it — the value has no other home, so +a temp root is simply the missing code. + +### Why this is a hypothesis and not yet a cause + +Every hazard here is a *possibility* of a stale/lost value, and the checker is +one-sided by design. Three things would settle it, in increasing cost: + +1. a `PERRY_UNCAUGHT_BACKTRACE` stack from a failing run that names one of + these functions (in flight); +2. fixing Shape A + Shape B and re-sweeping: 7/16 must go to 0/40 for the fix + to be distinguishable from luck at this rate; +3. sabotage: re-introduce the hazard and show the rate returns. + +Note the two shapes have the same fix and it is NOT a reload: root the value +(the `rooting/temp_root.rs` pool already exists and is the mechanism `#7719` +used for the 30 `lower_call/builtin.rs` ctor arms). + +## 13. `--debug-symbols` SUPPRESSES the failure — the symbolized build is a different program + +This is the finding that explains §11's dead end, and it was found by a control +rather than by reasoning. + +Three binaries, same compiler (`410dadd45`), same corpus, same `zod@4.3.5`, +same runtime archives, swept identically +(`RATE=1 PERRY_GC_PROTECT_FROMSPACE=0`, seeds 1..n): + +| binary | built with | result | +|---|---|---| +| `/tmp/zod-head` | plain, pre-patch runtime | **7/16 FAIL** | +| `/tmp/zod-bt` | `--debug-symbols`, patched runtime | 0/10 fail (seeds 1–8, 14, 15) | +| `/tmp/zod-plain2` | plain, **patched runtime** | **FAIL on seed 1 and seed 2** | + +The third row is the control that makes the second interpretable. Adding the +`PERRY_UNCAUGHT_BACKTRACE` hook to `exception.rs` does NOT suppress the bug — +the plain build carrying that exact runtime still dies at seeds 1 and 2. The +variable that suppresses is **`--debug-symbols`**: at the base rate of 44%, ten +consecutive passes is p ≈ 0.56¹⁰ ≈ 0.3%. + +So the debugger was never the problem in §11. I had changed two things at once +(symbols AND lldb) and blamed the wrong one: `/tmp/zod-dbg` was a +`--debug-symbols` build, so those four lldb "passes" were passes of a program +that does not have the bug. Recorded rather than quietly corrected, because the +mistake is instructive — **the instrument that makes a bug observable is also a +change to the program, and it needs its own control arm.** + +Why `-g` should move an intermittent GC bug is not established here. It is not +"debug info is inert": `PERRY_DEBUG_SYMBOLS` feeds the object-cache key, the +clang invocation (`-g`), and the final `strip`, and every consumer reads it with +`is_some()`, so there is no spelling that separates them. Two candidates, both +unproven: DWARF changes LLVM's inlining/scheduling enough to move allocation +sites; or the larger image shifts the layout the failure's timing depends on. + +### The fix for the instrument: `PERRY_KEEP_SYMBOLS` + +Skips ONLY the final `strip` (`post_link.rs`), leaving `-g` off and codegen +byte-identical to the build that reproduces. That is what makes a backtrace +from it evidence about the same program rather than about its symbolized twin. + +The stripped instrument already proves itself — `PERRY_UNCAUGHT_BACKTRACE=1` on +`/tmp/zod-plain2` seed 1 emits 11 frames at the fatal throw, 7 of them the +JS/runtime chain, just without names: + +``` +--- native backtrace at the uncaught throw --- +0 zod-plain2 + 9300712 +1 zod-plain2 + 14326752 +… +10 dyld start + 6992 +--- end native backtrace --- +[gc-schedule] done: seed=1 safepoints=1162 … copying_minors=1162 moved_objects=179572 +``` + +## 14. LOCALIZED: the fatal frame, at last + +`PERRY_KEEP_SYMBOLS=1` (no `-g`) reproduces — seed 3 of 8 — and symbolicates +itself. The first native backtrace of this failure in either session: + +``` +0 perry_runtime::exception::emit_uncaught_backtrace +1 js_throw + 992 +2 js_timer_has_pending + 0 ← nearest global; really the + stripped throw helper +3 perry_closure_…zod_src_v4_core_parse_ts__9 + 4132 +4 perry_closure_…zod_src_v4_classic_schemas_ts__108 + 76 +5 js_native_call_value + 3136 +6 js_native_call_method + 24208 +7 js_typed_feedback_native_call_method_by_id + 112 +8 perry_fn_…gc_dep_corpus_main_ts__parseLoop$spec_i32 + 2272 +9 main + 456 +``` + +seed 3: `safepoints=2177 copying_minors=2177 moved_objects=303880`. + +### Reading it + +`parseLoop`'s `S.safeParse([...])` → the `safeParse` method +(`classic/schemas.ts` closure 108) → `core/parse.ts` closure 9. + +**Closure 9 is `_safeParse`'s inner arrow.** Identified from the IR rather than +guessed: it references `$ZodAsyncError`'s class keys (parse.ts:61) and allocates +`perry_closure_…parse_ts__10`, which is the `(iss) => util.finalizeIssue(…)` +callback on parse.ts:68. Only `_safeParse`'s body has both. + +```ts +export const _safeParse = (_Err) => (schema, value, _ctx) => { + const ctx = _ctx ? { ..._ctx, async: false } : { async: false }; + const result = schema._zod.run({ value, issues: [] }, ctx); // ← line 60 + if (result instanceof Promise) throw new core.$ZodAsyncError(); + return result.issues.length // ← THROWS HERE +``` + +So the failing read is `result.issues` with `result` **undefined**: +`schema._zod.run({ value, issues: [] }, ctx)` returned undefined. + +That reframes the search. This frame is the VICTIM, not the site — it is +`--moving-only` clean, and nothing in §12's list names closure 9. The loss is +upstream, in the `run` chain, and `run` is built in `core/schemas.ts` — which +holds two of the 66 (closures 138 and 185, both `unrooted:rootread`, one +sinking into `js_closure_call1`). + +Note both observed messages are the SAME loss seen one call apart: if the +receiver `schema._zod` is a recycled object, `.run` misses and the call throws +`value is not a function`; if the call happens but its result is lost, the +caller reads `.issues` on undefined. That is why the two symptoms alternate +seed to seed and why chasing them as separate bugs would have been wrong. + +### Sequencing note, and a correction to §10 + +§10 read the low safepoint counts as "dies in module init". The backtrace says +otherwise: it dies inside `parseLoop`, which is the SECOND phase. Nothing had +printed because the corpus prints only after all three phases finish — absence +of output was never evidence about phase. §7's marker probe was answering a +question the stack answers directly. + +## 15. A hazard from §12's list is ON the stack of a failing run + +The other symptom, `value is not a function`, on the same binary (seed 7): + +``` + 3 throw_not_callable + 4 closure::dispatch::validate::dispatch_proxy_callee_or_throw + 5 js_closure_call2 + 6 js_native_call_value + 7 js_native_call_method + 8 dyn_eval::expr::eval_expr ← part of zod runs INTERPRETED, not native + 9 dyn_eval::interp::exec_stmt +10 dyn_eval::interp::interp_thunk +11 closure::registry::dispatch_with_arity +12 js_closure_call3 +13 perry_closure_…core_schemas_ts__137 + 172 +14 perry_closure_…core_schemas_ts__138 + 3904 ★ +15 js_native_call_value +16 js_native_call_method +17 js_typed_feedback_native_call_method_by_id +18 perry_closure_…core_schemas_ts__115 + 4060 +``` + +**`core/schemas.ts` closure 138 is one of the 66.** Its entry, quoted from +§12's run, predates any of this dynamic evidence: + +``` +core_schemas_ts.ll::perry_closure_…core_schemas_ts__138 [unrooted] + source (rootread): %r801333 = gc.result(%statepoint_token332) + stale use : statepoint … @js_closure_call1 … + across safepoint : js_closure_get_capture_bits, js_object_get_field_by_name_f64, + js_object_get_field_ic_miss, js_typed_feedback_object_get_field_by_name_f64 + MOVING : YES +``` + +A value read out of a root, held across property-get helpers that can run an +evacuating minor, then used as the callee of `js_closure_call1`. The stack +shows 138 calling 137 which calls a closure through `js_closure_call3`, and the +throw is `not callable` on a callee. Same function, same shape, same sink +family. + +**This is corroboration, not proof.** Closure 138 is 1,939 lines of IR and the +frame is `+3904` — being on the stack does not establish that the reported +hazard is the instruction that failed. What it does establish is that the two +independent methods now point at the same function, which neither did before +today. + +Worth noting separately: frames 8–11 show part of `zod` executing through +`dyn_eval` (the V8-fallback interpreter) rather than natively. Whether that +path's roots are complete is a question this stack raises and does not answer. + +## 16. The quarantine still suppresses — second confirmation, and a false alarm + +Retried at depth 800 on `/tmp/zod-ks`, whose UNPROTECTED rate is 3/8: + +| seed | unprotected | protected (depth 800) | +|---|---|---| +| 3 | FAIL | **pass**, 6822 safepoints, `sets_held=800/800` | +| 4 | pass | pass, 6834 safepoints, `sets_held=800/800` | + +Seed 3 is the discriminating cell: it fails unprotected and passes protected on +the same binary in the same session. §3 found this at v0.5.1499 and it holds at +`410dadd45` on a build with a 4× higher base rate. The instrument saturates +(800/800 sets, ~7 GB held), so this is suppression, not absence of instrument. + +> **False alarm, recorded because it nearly went in the other direction.** +> Seeds 5 and 6 exited 134 (`Abort trap: 6`) under the quarantine and my first +> reading was "the protector caught it". It did not: the tail says +> `panicked at … failed printing to stderr: No space left on device (os error +> 28)`. The depth-800 arm holds ~7 GB and `PERRY_GC_DIAG=1` writes tens of MB +> per run; the disk filled and the runs aborted on the write, not on a fault. +> An abort under a fault-detecting instrument is exactly the result you want to +> believe, which is why it needed the tail read before it was quoted. + +## 17. Where this leaves #7803, and what to do next + +### Established this session + +1. The class is live on `main` (`410dadd45`) and easy to hit: 7/16 on + `/tmp/zod-head`, 8/10 on `/tmp/zod-plain2`, 3/8 on `/tmp/zod-ks`. The rate + is strongly binary-dependent, so **A/B a fix on ONE binary pair, never + across builds.** +2. The corpus × lowering matrix had an empty cell — the dependency corpus under + the shipping (statepoint) lowering. It reads **66 unrooted hazards** where + the curated corpus in the identical mode is gated at **0**. +3. `--debug-symbols` suppresses the failure (0/13). Any instrument that needs + symbols must use `PERRY_KEEP_SYMBOLS` instead. +4. The fatal frame is `_safeParse`'s inner arrow (`core/parse.ts:65`), + `result.issues` on an undefined `result` returned by `schema._zod.run(…)`. + Both observed messages are the same loss one call apart. +5. `core/schemas.ts` closure 138 is both a §12 hazard and a stack frame of a + failing run. +6. The from-space quarantine suppresses on this workload — confirmed twice now. + It cannot be the localizing instrument here; `PERRY_UNCAUGHT_BACKTRACE` can. + +### Next, in order + +1. **Pin closure 138's hazard to a source construct.** It is 1,939 lines of IR; + the entry names the exact `%r80` and the `js_closure_call1` statepoint. + Identify which of `core/schemas.ts`'s `run`/`parse`/`runChecks` bodies it is + and what the unrooted value holds. +2. **Fix the two dominant shapes** (§12): 21× `unrooted:global` (needs a temp + root — `root_reload.rs` declines these deliberately and correctly) and 16× + `unrooted:alloc` across `js_array_like_to_array` (no soundness objection, + just missing). `rooting/temp_root.rs`'s alloca pool is the mechanism; #7719 + is the precedent. +3. **A/B honestly.** At 3/8 on `zod-ks`, a fix needs ~40 clean runs on the SAME + binary pair to be distinguishable from luck, plus the static count going + 66 → lower. Both, not either. +4. **Gate the cell.** Add `ir-corpus-dep-native` to `gc-root-dominance.yml` + with a budget that can only go down. Note the corollary CLAUDE.md gives: + a new gate has never been green, so run it before making it required. +5. **The 12 locale hits** are in never-executed bodies on this workload. Fix + with the rest; do not use them to judge the fix. + +### Loose ends + +* `PERRY_GC_SCHEDULE_ALLOC_KB=0` (every poll a candidate, no allocation pacing) + was identified as a way to make the schedule replayable and was **never + run** — the backtrace route landed first. It is still the cheapest route to a + deterministic reproducer if one is wanted. +* Why `-g` suppresses is unexplained (§13). +* The `dyn_eval` frames in §15 mean part of this workload is interpreted. +* Two diagnostics are uncommitted in `wt-7803`: + `PERRY_UNCAUGHT_BACKTRACE` (`exception.rs`) and `PERRY_KEEP_SYMBOLS` + (`post_link.rs`). Both are off by default and value-parsed. + +## 18. The fix: the CALLEE has to outlive the arguments + +Reading the three shapes against the lowering turned them into one defect. + +`rooting/temp_root.rs` already answers "root, re-derive, or reuse?" correctly +and in one place (`operand_protection`), and it already says module globals and +locals must be ROOTED rather than reloaded, for the reason a reload gets wrong: + +> `new C(g, bump())` where `bump()` sets `g` must capture `g`'s value at call +> time; re-lowering produced the post-`bump()` value, a miscompile rather than +> a rooting bug. + +The gap is not the decision, it is the POSITION it is asked about. That +machinery protects call **operands**. Three call-lowering arms lower the +**callee** into a bare register first, lower the arguments after it — each of +which can allocate — and then pass the original register: + +```rust +// expr/new_dynamic.rs, both js_new_function_construct arms +let func_double = lower_expr(ctx, callee)?; // ← callee +let lowered_args = args.iter().map(|a| lower_expr(ctx, a))…?; // ← these collect +let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); // ← allocates +ctx.block().call(DOUBLE, "js_new_function_construct", + &[(DOUBLE, &func_double), …]); // ← pre-move address + +// expr/call_spread.rs — same shape, `cb_box` at :458 consumed at :538 across +// the register-buffer stores, `js_array_like_to_array` and the concat. +``` + +Under the shipping lowering that register is in no statepoint live bundle, so +nothing marks it and nothing relocates it. JS resolves the callee BEFORE it +evaluates the arguments, so the fix has to preserve the call-time value: a temp +root, never a reload. `rooting::RootedGroup` is exactly that and needed no +extension — `adopt` for the hand-emitted callee, `lower` for the arguments, +`reread` below the allocations, one `release` after the consuming call. + +### Measured: 66 → 26 + +Same corpus, same command, `410dadd45` + these two files: + +| | before | after | +|---|---|---| +| **total hazards** | **66** | **26** | +| `unrooted/global` | 28 | **5** | +| `unrooted/alloc` | 18 | **2** | +| `unrooted/rootread` | 19 | 19 | +| `unrooted/capture` | 1 | 0 | +| `sink=js_new_function_construct` | 24 | **0** | +| `sink=js_closure_call_apply_with_spread` | 16 | **0** | +| non-empty live bundles | 39,073 | **39,140** | + +The two sinks the change targeted are at zero, the `rootread` population it did +NOT target is unchanged at 19, and the corpus grew 67 live bundles — the newly +rooted values entering statepoint bundles, which is what the fix looks like from +the collector's side rather than from the checker's. The unscheduled control run +is byte-identical, so the rooting did not change the answer. + +**The residual 19 `unrooted/rootread` are the `js_box_get_bits` shape** — a +mutable-capture box read held across property-get helpers and used as the callee +of `js_closure_call1/2`. That is the shape on §15's failing stack +(`core/schemas.ts` closure 138), and the same callee-outlives-arguments defect +in a third family of arms. + +**Fixed too, in `lower_call/early_branches.rs:384`** — and the final number is +better than this section's 26. See §22. + +## 19. The fix does NOT close #7803 — the dynamic half says so + +Static and dynamic disagree, and the dynamic half is the one that decides. + +| binary | codegen | static hazards | sweep | +|---|---|---|---| +| `/tmp/zod-ks` | `410dadd45` | 66 | 3/8 fail | +| `/tmp/zod-fix` | + shapes A, B | **26** | **5/16 fail** (5, 7, 9, 11, 15) | +| `/tmp/zod-fix3` | + shape C | (not measured) | **8/16 fail** (1,3,5,6,8,11,14,16) | + +40 hazards closed, two whole sinks to zero, 67 more live bundles — and the +failure rate did not move (3/8 → 5/16 is noise at this sample size). The +messages are unchanged, at the same early safepoints — and `zod-fix3`'s seed 6 +adds a THIRD surfacing form of the same loss, `TypeError: is not iterable`. + +`zod-fix3`'s 8/16 (50%) against `zod-fix`'s 5/16 (31%) is NOT evidence that +shape C made things worse: Fisher exact gives p≈0.47, i.e. nothing. The rate is +also strongly binary-dependent on this workload with no semantic difference +between builds — 44% / 80% / 38% / 31% / 50% across five binaries of the same +source — and the shape-C edit only moves a *pure* unmask below the argument +lowering. Quoted here so the next reader does not rediscover the 31→50 step and +read it as a regression. + +Seed 11 is worth one line on its own: it failed at safepoint **6137** of ~6840, +far later than every other failure (968–2453). Whatever is lost is not confined +to one early window. + +**So the three call arms are a real defect that is not this bug.** They are +worth landing on their own terms — the invariant they violate is the one +`docs/src/internals/gc-rooting-invariant.md` states, the fix is the mechanism +the codebase already sanctions, and the corpus count is a ratchet — but #7803 +stays open and the cause is elsewhere. + +Recording the negative result at full strength because the temptation here is +real: a 66 → 26 table looks like progress on the issue, and quoting it without +the sweep beside it would have been the exact "gate that cannot fail" shape +CLAUDE.md warns about, one level up — a *measurement* that cannot fail, because +its subject was never the failure. + +### Where the evidence now points: the `dyn_eval` interpreter + +§15's stack has frames the static checker structurally cannot see: + +``` + 8 dyn_eval::expr::eval_expr + 9 dyn_eval::interp::exec_stmt +10 dyn_eval::interp::interp_thunk +11 closure::registry::dispatch_with_arity +``` + +Part of `zod` executes through the V8-fallback INTERPRETER, not as native code. +`scripts/gc_root_dominance_check.py` reads emitted LLVM IR, so an interpreter +written in Rust is invisible to it — every hazard it can report is in a +population that, on this workload, may not contain the bug at all. That is +CLAUDE.md's own warning, and it fits every observation: + +* fixing 40 IR-level hazards changed nothing; +* the quarantine suppresses (§3, §16) — recycling decides what a stale read + finds, whoever holds the stale pointer; +* `--debug-symbols` suppresses (§13) — a layout/inlining sensitivity, not + something a rooting fix in emitted IR would move. + +`dyn_eval` DOES have a root scanner (`scan_dyn_eval_roots_mut`, `ROOTS` +thread-local, plus the env/member key caches), so the question is not "are +there roots" but **"does every interpreter-held JSValue reach `ROOTS` before a +call that can collect"** — an `f64` local in `eval_expr` held across a call into +user code is exactly the shape, and it is the intermittent-register kind rather +than the reproducible-table kind. + +**Next investigator: audit `dyn_eval/expr.rs` and `dyn_eval/interp.rs` for +`f64`/JSValue locals live across calls that can collect, before spending more +on the IR corpus.** And find out WHY part of this workload is interpreted at +all — a natively compiled `zod` would not use that path (#678 is the tracker +for native callsites into V8-fallback modules). + +> **Not yet done for the three call arms**: the gap suite has NOT been run +> against them. They change the lowering of every `new (…)`, every spread +> call and every closure-typed-local call in the language, so `./scripts/ +> run_gap_tests.sh` plus `cargo test -p perry-codegen` gate any PR — the +> unscheduled dep-corpus control run being byte-identical is nowhere near +> sufficient evidence for a change with that blast radius. + +## 20. THE PATH IS THE `new Function` INTERPRETER — one experiment, not an audit + +Instead of auditing `dyn_eval`, take the path out of the workload and see if the +bug leaves with it. + +### Why the workload interprets at all + +`zod/src/v4/core/schemas.ts:2028`: for **every object schema**, zod builds a +"fastpass" parser by generating source and compiling it with `new Function` +(`doc.compile()`), then routes `parse` through it. On Perry `new Function` lands +in the `dyn_eval` interpreter. The corpus's `parseLoop(96)` parses object +schemas 96 times, so the failing path runs generated code every iteration — +which is why §15's stack has `interp_thunk` two frames under the throw. + +zod ships the switch: `core.globalConfig.jitless` makes `parse` fall through to +`superParse`, all natively compiled. + +### Two things that had to be got right first + +**The config has to run before any schema is built.** `const jit = +!core.globalConfig.jitless` is captured when the `$ZodObject` is CONSTRUCTED +(schemas.ts:2007), and `alerts.ts` / `orgs.ts` / `scans.ts` build schemas at +import time — before `main.ts`'s body. A `z.config(...)` at the top of `main`'s +body is already too late. It moved into `jitless-first.ts`, imported ahead of +the schema modules. + +**The subject has to be asserted absent, not assumed absent.** The first +attempt *looked* right and was not: `/tmp/zod-jitless` still entered +`interp_thunk` through the identical `core/schemas.ts` 138 → 137 → +`js_closure_call3` stack as the failing run. Had it been swept as-is, a clean +result would have been quoted as "jitless is clean" while the interpreter was +still running the parse. + +The check that settles it, on the corrected build — armed breakpoint, whole +program, no hit: + +``` +lldb -b -o 'breakpoint set -r interp_thunk' -o run -- /tmp/zod-jitless2 +→ endpoints=9 … Process exited with status = 0 (never stopped) +``` + +(`dyn_function_from_strings` IS still reached in both builds — zod's +`util.allowsEval` probe compiles `new Function("return true")` regardless. So +"does `new Function` appear" is the wrong question; "does the parse path +INTERPRET" is the right one, and `interp_thunk` is what answers it.) + +### The result + +`RATE=1 PERRY_GC_PROTECT_FROMSPACE=0`, seeds 1..16, same compiler, same runtime +archives, same `zod@4.3.5`: + +| binary | interpreter on the parse path | sweep | +|---|---|---| +| `/tmp/zod-ks` | yes | 3/8 fail | +| `/tmp/zod-fix3` | yes | 8/16 fail | +| **`/tmp/zod-jitless2`** | **no** | **0/16** | + +At the jit builds' rate (31–50%), sixteen consecutive passes is p ≈ 0.001 at +37.5%, and every one of them ran the instrument hot: 5,054–5,434 forced +collections, ~765k objects moved per run. The answer is byte-identical +(`endpoints=9 parsed=96 registered=9`) — the schemas still parse, they just +parse natively. + +### What this is, and what it is not + +It is strong evidence that **the lost value lives on the generated-code / +`dyn_eval` path**, and it explains every earlier result at once: why 40 IR-level +hazards closed with no effect (the checker reads emitted LLVM IR and the +interpreter is Rust), why the quarantine suppresses, and why a build-layout +change like `-g` moves it. + +It is NOT a clean single-variable A/B and must not be quoted as one. `jitless` +changes the workload: 5,056 safepoints against 6,840, ~26% fewer collections and +a different allocation profile. A workload that collects less can fail less for +reasons that have nothing to do with who holds the pointer. What makes it +persuasive is the CONJUNCTION with §15's stack, not the sweep alone. + +The way to close that gap is not another sweep — it is to fix the interpreter's +rooting and show the *jit* build go green, which is the same evidence with the +confound removed. + +### Next + +Audit `dyn_eval` for JSValues held across calls that can collect — +`interp::exec_stmt`, `expr::eval_expr`, and `closure::registry:: +dispatch_with_arity` (all three on the failing stack). `scan_dyn_eval_roots_mut` +already scans a `ROOTS` thread-local plus the env/member key caches, so the +question is not whether roots exist but whether every intermediate reaches them +before a call — an `f64` local in `eval_expr` across a user call is exactly the +shape, and it is the intermittent-register kind, not the reproducible-table +kind. + +Second question, worth its own issue: **why does a compile-as-package build +interpret zod's hot parse path at all?** `Doc.compile`'s generated source is +known at build time for a static schema; #678 tracks native callsites into +V8-fallback modules. That is a performance finding independent of this bug. + +## 21. The architectural finding: the interpreter had no GC safepoints at all + +Auditing `dyn_eval` by hand first, because a fix needs a defect and I had a +hypothesis rather than one. The interpreter's rooting discipline is **better +than expected** — the hazardous shapes are all handled: + +* `eval_binary` roots the LHS before evaluating the RHS and re-reads both from + `roots` afterwards; +* `eval_call` roots the receiver before `eval_args`, re-reads it for the + dispatch, and `eval_args` roots every argument as it is produced; +* `set_prop_by_name` roots the VALUE before evaluating a computed key; +* `js_native_call_method`, the bridge's dispatch target, opens a + `RuntimeHandleScope`, roots receiver and args on entry, and (#7528) re-reads + them per use rather than once at the top. + +A mechanical scan for "value produced, used below an intervening call" over all +of `expr.rs` / `interp.rs` / `bridge.rs` / `env.rs` returned ~40 candidates and +every one I checked was a non-allocating probe (`truthy`, `to_number` on a +number) or already rooted. **I did not find the hole by reading.** + +### What I found instead + +The interpreter offers the collector **no cooperative safepoints whatsoever**. +Compiled code polls at loop back-edges (default on since #7721). Interpreted +code polls nowhere, so a collection can only reach it at an *allocation* point +— and the alloc-point arm forces a conservative stack scan, which finds Rust +locals and makes the copying minor ineligible. + +The consequence is not that the interpreter is safe. It is that the +interpreter is **untestable**: + +| instrument | reaches compiled code | reaches `dyn_eval` | +|---|---|---| +| `gc_root_dominance_check.py` (3 modes) | yes | no — there is no IR | +| `PERRY_GC_ZEAL` | yes, at back-edge polls | **no — no safepoints** | +| `PERRY_GC_SCHEDULE_SEED` | yes | **no — no safepoints** | +| `PERRY_GC_PROTECT_FROMSPACE` | yes | only via compiled frames | + +So the one rooting domain with no static checker also had no dynamic one, and +`mod.rs`'s claim — "interpreter frames hold **every** live JSValue in a rooted +thread-local value stack" — was unfalsifiable by anything in the tree. That is +the architectural defect, independent of what #7803's own root cause turns out +to be. + +### `PERRY_GC_INTERP_SAFEPOINTS=1` + +`dyn_eval::interp_safepoint()`, called at every `eval_expr` node and every +`exec_stmt`. It routes through `js_gc_loop_safepoint` deliberately rather than +collecting directly, so every entry guard (in-alloc, root-lock, unsafe-FFI +zone, budgeted cycle) and the seeded-schedule ordinal apply exactly as they do +to a compiled back-edge: an interpreter safepoint is the *same* safepoint, not +a second kind. Both existing instruments now reach the interpreter for free. + +**Subject asserted live** — seed 2, rate 1, same binary: + +| | `loop_polls` | safepoints | moved | +|---|---|---|---| +| off | 24,029 | 2,725 | 369,076 | +| **on** | **93,210** | **6,973** | **866,480** | + +69,181 additional polls, ~4× the compiled ones. That number IS the size of the +blind spot: on this workload the interpreter was where most of the potential +safepoints were, and none of them existed. + +Output is byte-identical in both modes. + +### Why it is opt-in and not on + +If the interpreter's rooting is complete, default-on is strictly better — the +copying minor becomes eligible where only a conservative sweep could run. If it +is not, flipping it turns a latent hole into a live crash for exactly the +workloads `dyn_eval` exists to serve (ajv, fast-json-stringify, find-my-way, +every fastify app). Shipping that before the rooting is verified trades a quiet +bug for a loud one in someone else's server. So it lands as an instrument, and +the flip is a separate evidence-gated decision — the same sequencing +`PERRY_GC_MOVING_LOOP_POLLS` had between #7161 and #7721. + +### 21a. The sweeper took the build mid-session — commits survived + +§8 recorded that "whatever sweeps `/Users/amlug/projects/perry/wt-*` on this +box" deleted the previous session's worktree AND its `CARGO_TARGET_DIR` while +an experiment was running. It happened again here: `wt-7803/target/` vanished +between two commands (free space 8 GB → 60 GB), taking `perry`, +`libperry_runtime.a` and `libperry_stdlib.a` with it. + +Nothing was lost, because the work had been committed as it was produced — +five commits, all intact, plus the uncommitted working-tree edits. The +in-flight A/B kept running because its binary lives in `/tmp`, not the +worktree. + +**Operational rule for this box, stated because it has now cost two sessions:** +commit before any long-running step, and never treat a worktree `target/` as +durable for longer than a single command. The 25 minutes to rebuild is the +whole cost when you have commits; it is the whole session when you don't. + +## 22. Final static number: 66 → 3, and a lesson about which build you measured + +The third arm (`lower_call/early_branches.rs`: `recv_box` lowered, arguments +lowered, then unmasked into `closure_handle`) was fixed but never measured +statically — the 26 in §18 was taken from a corpus emitted before that fix +existed. On a CLEAN rebuild with all three arms: + +``` +=== statepoint hazards: 3 (unrooted: 3, stale: 0) + 2 unrooted/alloc 1 unrooted/rootread + 1 sink=js_array_concat + 1 sink=js_rel_ge + 1 sink=js_get_string_pointer_unified +``` + +| sink | before | after | +|---|---|---| +| `js_new_function_construct` | 24 | **0** | +| `js_closure_call_apply_with_spread` | 16 | **0** | +| `js_closure_call1` / `js_closure_call2` | 23 | **0** | +| everything else | 3 | 3 | +| **total** | **66** | **3** | + +Live bundles 39,073 → 39,186; relocations 444,472. The dependency corpus under +the shipping lowering is now within a hair of the zero its curated sibling is +gated at. + +> **The lesson is about the 26, not the 3.** That number came from an +> incremental build whose corpus predated one of the three fixes, and it went +> into a committed gate budget. A ratchet's number has to come from a tree +> someone else can reproduce — a clean build — or the ratchet encodes whatever +> the build directory happened to contain that afternoon. Caught only because +> the worktree was swept and the rebuild was from scratch; a friendlier box +> would have shipped `--max-unrooted 26` and never known. + +The gate now carries `--max-unrooted 3 --max-stale 0`. + +**None of this closes #7803** (§19): the failure rate is unmoved. Two separate +true statements, and the second one is the one the issue is about. + +## 23. The interpreter-safepoint A/B, which points AWAY from the obvious reading + +One binary, one variable, quarantine off, seeds 1–8: + +| `PERRY_GC_INTERP_SAFEPOINTS` | failures | +|---|---| +| off | **6/8** | +| on | **2/8** | + +Collecting *more* often inside the interpreter made the workload fail *less*. +That is the opposite of what "the interpreter holds the unrooted value" +predicts — if interpreted frames were the hazard, adding ~69,000 collection +opportunities inside them should have raised the rate, not halved it. + +n=8 and p≈0.13, so it settles nothing on its own. But taken with §20 it means +the honest position is narrower than "the interpreter is the culprit": + +* §20 shows the failure needs the `new Function` PATH (0/16 without it); +* §23 shows that collecting inside the interpreter does not make it worse. + +Both can hold if the lost value is not held by the interpreter at all but by +something the interpreted path *reaches* — the bridge between the two worlds, +or a compiled callee invoked from interpreted code, or a runtime cache keyed on +a value the interpreter passed. Note §15's stack crosses that boundary twice +(`js_native_call_method` → `dispatch_with_arity` → `interp_thunk` → back out +through `js_native_call_method`), and CLAUDE.md's own warning applies to the +runtime side of it: a thread-local or side table holding a `*mut` into the heap +is invisible to the static checker. + +**So the next investigator should not start by auditing `dyn_eval`'s own +locals** — §21 already did that and found the discipline sound. Start at the +BOUNDARY: `dyn_eval/bridge.rs`, `closure::registry::dispatch_with_arity`, and +whatever caches the interpreted-closure dispatch path populates. + +## 24. What is verified, what is not, and the one thing blocking the codegen PR + +### Verified in this session + +| claim | how | +|---|---| +| #7803 live on `410dadd45` | 7/16, 8/16, 6/8 across builds | +| fatal frame is `parse.ts:65` | symbolicated native backtrace, §14 | +| both messages are one loss | same stack, two surfacing points, §14/§15 | +| the failure needs the `new Function` path | 0/16 jitless vs 8/16, instrument hot, §20 | +| the dep corpus × native lowering was ungated | four-cell matrix, §9 | +| that cell read 66 unrooted, curated reads 0 | §9a, gate green on main | +| three call arms lose the callee | source read + 66→3 after the fix, §18/§22 | +| the interpreter had no safepoints | `loop_polls` 24,029 → 93,210, §21 | +| more interpreter collection does NOT worsen it | 6/8 → 2/8, §23 | +| `--debug-symbols` suppresses it | 0/13 vs 44%, §13 | +| the quarantine suppresses it | seed 3 fails unprotected, passes protected, §16 | + +### NOT verified — and the codegen change must not land until it is + +**The gap suite has not run against the three call arms.** They change the +lowering of every `new (…)`, every spread call and every closure-typed +local call in the language. This box could not give a trustworthy run: load +average **60** with 47 sibling worktrees building, and the suite went from 25 +tests in 3 minutes to 30 in 19. A timeout-flake red under that load is worse +than no run, so it was stopped rather than finished badly. + +**Partial: 30/554, 0 failures** (`scratchpad/zod/gap-partial.log`). That is +evidence of nothing except that the first 30 do not crash. + +Before the codegen commit (`95d9fbb9d` + the `early_branches.rs` arm) goes into +a PR: `./scripts/run_gap_tests.sh` and `cargo test -p perry-codegen`, on a +quiet host. + +### Still open, and where to look next + +The cause. §23 narrows it: the failure needs the interpreted path, but the +interpreter's own frames are neither obviously the holder (§21's audit) nor +made worse by collecting in them (§23). The remaining surface is the +**boundary** — `dyn_eval/bridge.rs`, `closure::registry::dispatch_with_arity`, +and any runtime cache the interpreted-dispatch path populates. A runtime-side +cache of a raw heap pointer is invisible to the static checker by construction, +and CLAUDE.md's rule of thumb applies in reverse here: this bug is intermittent, +which argues for a register rather than a table — but a table reached only from +the interpreted path would also present intermittently, because the path itself +is only taken 96 times. + +## 25. At the boundary: `js_native_call_method` hands some callees a STALE argument buffer + +§23 said to look at the boundary rather than at `dyn_eval`'s own locals. Doing +that found a defect of exactly the right shape, in the frame that is literally +on the failing stack (`js_native_call_method`, frame 7 of §15). + +#7528 established the rule for this function and stated it well: + +> `object_handle` roots the receiver, but a value READ OUT of a root and held +> in a local is not rooted — the collector rewrites the SLOT, not the copy. +> This function then runs ~1160 more lines across a dozen probes that allocate. + +Its fix was `refreshed_args()` — re-read the rooted arguments at the point of +use. **It reaches ten sites. The function has many more dispatch arms, and +several of them pass the caller's raw `args_ptr` instead.** That buffer is the +CALLER's memory; `arg_handles` is what the collector rewrites. Nobody rewrites +the buffer. + +Two arms verified to have a collection point between entry and the dispatch: + +```rust +// ~1424, dynamic prop on a closure receiver +let bound = clone_closure_rebind_this(dyn_val.to_bits(), object()); // ALLOCATES +js_native_call_value(f64::from_bits(bound), args_ptr, args_len); // ← stale buffer + +// ~1476, accessor getter +let method_fn = js_closure_call0(getter); // runs USER CODE +let bound = clone_closure_rebind_this(method_fn.to_bits(), object()); // ALLOCATES +js_native_call_value(f64::from_bits(bound), args_ptr, args_len); // ← stale buffer +``` + +Both now use `refreshed_args()`. + +**Why this fits #7803's symptom exactly.** zod's generated fastpass calls +`shape[k]._zod.run({ value: input[k], issues: [] }, ctx)` — the first argument +is a freshly allocated object literal, the youngest possible object, the one +most likely to be moved by the next minor. If the tower collects between entry +and dispatch, the callee is handed the pre-move address of that literal: +`result` comes back wrong, and the caller reads `.issues` on it. That is the +message, on the argument that literally contains `issues: []`. + +And `_zod` is an ACCESSOR on zod's schema objects, which is the second arm. + +**MEASURED, and it does NOT close #7803.** `/tmp/zod-argfix`, same conditions: +**6/16 fail** (seeds 4, 9, 10, 14, 15, 16), against baselines of 3/8, 5/16 and +8/16 on comparable binaries — squarely in the middle, i.e. no effect at all. +One failure would have been enough to refute "fixed"; the full sweep says it +did not even move the rate. A real defect found and fixed, and a cause +REFUTED. The remaining raw-`args_ptr` arms +(the JS-handle dispatcher at ~1496 and several others) were left alone: they +need the same per-arm "can anything above me collect?" argument, and guessing +uniformly would be the audit-by-eye that §21 already showed is unreliable. + +## 26. Where this session ends, and the two things blocked on a quiet host + +Everything below is committed on `fix/7803-zod-gc-rooting` (10 commits). + +### Landed + +| | what | evidence | +|---|---|---| +| diagnostics | `PERRY_UNCAUGHT_BACKTRACE`, `PERRY_KEEP_SYMBOLS` | §11, §13 — the pair that made §14's localization possible at all | +| codegen | callee rooted across argument evaluation, 3 arms | **66 → 3** hazards, §18/§22 | +| runtime | `dyn_eval` cooperative safepoints | `loop_polls` 24,029 → 93,210, §21 | +| runtime | argument buffer refreshed in 2 dispatch arms | §25 — fits the symptom precisely, and does NOT close the bug (seed 4 still fails) | +| CI | the fourth corpus × lowering cell, gated at 3 | §9, §22 — verified end to end | + +### Blocked on host load, not on work + +The box sat at load **40–74** with 47–49 sibling worktrees building for the +last several hours. Two verifications need a quiet host and are the only thing +between this branch and a PR: + +1. **`./scripts/run_gap_tests.sh` + `cargo test -p perry-codegen`** for the + three call arms, which change the lowering of every `new`, spread call and + closure-typed-local call in the language. Partial run: 30/554, 0 failures, + stopped when the suite slowed from 25-tests-in-3-minutes to 30-in-19. +2. ~~The rate A/B for §25's fix~~ — **answered**: seed 4 fails, so it does not + close the bug. The remaining seeds only refine the rate. + +Neither is a judgement call. Both are "run this on a machine that isn't at +load 60". + +### The §25 follow-up stands regardless of it not being the cause + +The follow-up is not "fix the other arms one at a time". `js_native_call_method` +has one rule — *no value read out of a root may be used below a collection +point* — and enforces it by two different means in the same function: ten sites +call `refreshed_args()`, the rest pass the caller's raw buffer, and nothing +distinguishes them but an author's per-arm judgement. That is the same +"invariant maintained by audit" shape as `dyn_eval`'s `root_push` discipline +and as the pre-`RootedGroup` codegen. The architectural fix is to make the raw +buffer unreachable from the dispatch arms — hand them a type that can only +yield refreshed values — so the losing spelling stops compiling. + +### Where the cause still hides + +The remaining surface, in order: the other raw-`args_ptr` arms (~1496 and +below), `dyn_eval/bridge.rs`, and any runtime cache the interpreted-dispatch +path populates. §23's A/B says the interpreter's own frames are not obviously +the holder, and §21's audit says its `root_push` discipline is sound, so the +boundary remains the place to look. + +## 27. Scorecard: four fixes, four times the bug survived + +| # | fix | static effect | effect on #7803 | +|---|---|---|---| +| §18 | callee rooted, `new_dynamic.rs` ×2 + `call_spread.rs` | 66 → 26 | none (5/16) | +| §22 | callee rooted, `early_branches.rs` | 26 → 3 | none (8/16) | +| §21 | `dyn_eval` cooperative safepoints | — | rate *fell* 6/8 → 2/8, bug survives | +| §25 | argument buffer refreshed, 2 dispatch arms | — | none (**6/16**, mid-baseline) | + +Four separate rooting defects, all real, all in the right family, none of them +this bug. That is worth stating as its own finding: **the zod corpus under a +rate-1 unprotected schedule is not a one-defect workload.** Each fix was +justified on its own evidence and each left the failure standing. + +The discipline that made this readable is the one to keep: every fix got its +own A/B against the SAME binary pair, and every null result was written down at +full strength instead of being folded into the next attempt's baseline. The +alternative — landing four fixes and re-measuring once at the end — would have +produced a single ambiguous number and no way to attribute it. + +What is now known about the cause, positively: + +* it needs the `new Function` / interpreted path (§20, 0/16 without it); +* it is not in `dyn_eval`'s own `root_push` discipline (§21 audit) and not made + worse by collecting there (§23); +* it survives every callee- and argument-rooting fix in the compiled tower and + in the three call lowering arms (§27, this table); +* it is suppressed by `--debug-symbols` (§13) and by the from-space quarantine + (§16), which are both *layout* interventions rather than rooting ones. + +That last line is the one I would pull on next. Two independent interventions +that change memory LAYOUT (not rooting) both make it vanish, while four +interventions that change ROOTING leave it untouched. That pattern fits a stale +raw pointer held somewhere the collector never rewrites — a runtime-side cache +keyed on an address, rather than a value on anyone's stack. CLAUDE.md names +that class and says the static checker cannot see it; the registry to audit is +`gc_register_mutable_root_scanner`'s ~123 entries, and the ones reached only +from the interpreted-dispatch path are the short list. + +## 28. Gap suite (partial, quiet host): no regressions, plus one pre-existing suite defect + +Re-run once the box dropped to load ~13. Through test 68/554, three failures, +**none of them a regression**: + +| test | verdict | +|---|---| +| `test_gap_2159_defineproperty_class_prototype` | in `known_failures.json` | +| `test_gap_2514_settracesigint` | in `known_failures.json` | +| `test_gap_4510_enum_forward_ref` | **NOT a regression — see below** | + +`test_gap_4510_enum_forward_ref` fails with `Node exit: 1, Perry exit: 0`: +Perry prints the correct `fwd: B` and **Node cannot run the file at all** — +`--experimental-strip-types` rejects `enum`, which is not erasable syntax. +Verified by hand. + +It is not in the skip list and it is not classified `node_fail`, because +`run_parity_tests.sh:1341` records `node_fail` only for an ABNORMAL exit +(`perry_abnormal_exit`, i.e. a signal). A clean `exit 1` falls through to the +output comparison, and with no expected-output file the test is compared +against Node's crash text. **So this test can never pass under the pinned +Node**, regardless of what Perry does. + +That is the mirror image of the hazard CLAUDE.md describes for this suite. The +documented failure mode is a node-unrunnable test being silently DROPPED from +the gate; this one is silently RED instead, for the same underlying reason (the +oracle can't run it). Either it needs an expected-output file, or the +`node_fail` predicate needs to cover a clean non-zero exit. Worth its own +issue; unrelated to #7803. + +**Bearing on the codegen change:** through 68 tests the three call arms +introduce no new failure. That is not yet the clean run the PR needs — the +suite was still running when this note was written — but it is the first +evidence in either direction, and it is the right direction. + +## 29. Gap suite, complete: no regressions from this branch + +554/554 on a quiet host (load ~10-20, 1h25m). The harness's own verdict, with +attribution: + +**"REGRESSIONS — these were expected to pass"** (2): + +| test | verdict | +|---|---| +| `test_gap_specabi_reassign` | **NOT this branch.** Reverted the three codegen files to `410dadd45`, rebuilt `perry`, ran it: byte-identical failure (`plain: 0 0 2`, `captured: 0:2` where node gives `99 101 2` / `77:2`). Pre-existing on main. | +| `test_gap_zlib_4917_level` (`compile_fail`) | **Spurious — my fault.** I started `cargo build -p perry` WHILE the suite was running, which swapped `target/release/perry` mid-run. Recompiled by hand afterwards: compiles clean and matches node byte-for-byte. | + +**"STATUS CHANGES: node_fail -> parity_fail"** (10) — all oracle-side, all +`Node exit: 1, Perry exit: 0`, Perry printing the right answer in every case: + +* 6 need npm packages this worktree does not have (`backoff`, `cron`, `dayjs`, + `moment`, `slugify`, `ratelimiter` — `npm ci` was never run here); +* 4 are TypeScript Node cannot strip — `enum` and parameter properties are not + erasable syntax (`4510_enum_forward_ref`, `enum_in_function_body`, + `derived_param_props`, `prop_plan_cache_invalidation`). + +They flipped from `node_fail` to `parity_fail` because `node_fail` is recorded +only for an ABNORMAL exit (`run_parity_tests.sh:1341`); a clean `exit 1` falls +through to the output comparison. **1 improvement**: `iterator_helpers_2874` +now passes. + +### Verdict + +**The three call arms introduce no gap regression.** That was the one thing +blocking the codegen PR, and it is now cleared — with two caveats stated rather +than buried: the run had an incomplete `node_modules`, and I polluted it with a +concurrent rebuild (the one test that touched is individually verified above). +A clean-environment CI run remains the real gate. + +### Two findings for other people + +1. **`test_gap_specabi_reassign` is failing on `main`** and is not in + `known_failures.json`. It is #6906/#7052's own regression test — a + reassigned binding proving `TaPtr` and reading a plain array through + typed-array lowering, which is exactly the unsoundness those issues closed. + The gap suite is tag-gated, so nothing per-PR would have caught it. +2. **A gap test the oracle cannot run reads as RED, not as skipped.** See §28. + Ten tests are in that state right now. Either they need expected-output + files or `node_fail` must cover a clean non-zero exit. + +## 30. The poison result is INCONCLUSIVE, and that is the session's real blocker + +`PERRY_GC_POISON_FROMSPACE` (§ commit `ed543fb5e`), one binary, seeds 1–6: + +| | failures | +|---|---| +| poison off | 3/6 | +| poison on | **0/6** | + +The tempting read is "a fifth suppression". It is not supportable. Checking +what else moved, which is the discipline this whole session has run on: + +| seed | off (safepoints) | on (safepoints) | +|---|---|---| +| 1 | 6834 | 6889 | +| 3 | 6828 | 6874 | +| 4 | 6871 | 6836 | + +The schedules differ by ±0.7% between arms — the same magnitude as the +ordinary run-to-run drift §1 measured at a FIXED seed (6627→6909, and passing +runs spanning 6804–6931). So the two arms did not run the same schedule, the +difference is indistinguishable from noise, and Fisher on 0/6 vs 3/6 is +p ≈ 0.09 anyway. **It neither confirms nor refutes the mechanism.** + +### The design problem, stated plainly + +This workload cannot support the experiments being asked of it: + +* the failure rate is ~30–50%; +* run-to-run schedule drift at a fixed seed is ~1–4%; +* **every** intervention — a rooting fix, an extra safepoint, a memset — + perturbs the schedule by about that much; +* so no 6-to-16-run sweep can attribute anything, and each run costs 3–20 + minutes. + +Attributing a 50%→30% shift at p<0.05 needs ~40 runs per arm; that is 2–13 +hours per arm on this box. Four of this session's conclusions (§19, §23, §27, +§30) are rate comparisons that are individually under-powered, and only §20 +(0/16 vs 8/16) clears that bar comfortably. + +**The fix is not more runs, it is a deterministic reproducer**, and the lever +for one has been sitting unused since §3 of the task list: +`PERRY_GC_SCHEDULE_ALLOC_KB=0` makes EVERY loop poll a schedule candidate, +which removes the allocation-pacing feedback (`schedule_poll_collection_due` +compares against a from-space high-water mark, so a byte of drift moves which +polls become candidates, and the effect compounds). Unpaced, the candidate set +is `loop_polls`, which §1 already measured as *stable at 63,936 across runs*. + +That is the one number on this workload that does not drift, and it has been in +the notes since the first session without anyone building the experiment on +top of it. A run is now in flight (~10× the collections, so budget an hour). + +If it makes the failure deterministic, every A/B above becomes a single run +instead of forty, and the four under-powered conclusions can be settled +properly rather than hedged. + +## 31. The unpaced schedule works, and it inverts the picture + +`PERRY_GC_SCHEDULE_ALLOC_KB=0` (task-list item #3, unused until now), seed 1, +rate 1, quarantine off: + +``` +[gc-schedule] done: seed=1 safepoints=63941 scheduled_collections=63941 + polls_paced=0 copying_minors=63941 moved_objects=892662 + loop_polls=63936 +``` + +* `polls_paced=0` — the allocation pacing is gone, which was the point; +* `safepoints=63941` = `loop_polls` (63,936) + 5 event-loop boundaries, so the + candidate set is now the ONE quantity §1 measured as stable across runs; +* **63,941 collections** against ~6,840 paced: 9.4× the collection pressure. + +**And it passed.** That is the opposite of what more collection pressure is +supposed to do to a rooting bug, and it is now the third independent +observation of the same shape: + +| configuration | collections | failure rate | +|---|---|---| +| paced (default 4 KB) | ~6,840 | ~30–50% | +| interpreter safepoints on (§23) | ~2× more candidates | 2/8 vs 6/8 | +| **unpaced (`ALLOC_KB=0`)** | **63,941 (9.4×)** | passed seed 1 | + +**More collections make this bug LESS likely, consistently.** A value held +unrooted across a collection point should get *more* dangerous as collections +get denser; this gets safer. Four rooting fixes changing nothing fits the same +story. + +### The hypothesis that predicts all of it + +`moved_objects` barely moved: 892,662 unpaced against ~862,000 paced, despite +9.4× the cycles. So the extra collections are not relocating extra objects — +they are relocating the same population *earlier*. Perry promotes a nursery +survivor after **2 minor cycles** (two-bit aging, `HAS_SURVIVED` / `TENURED`), +and old-gen objects are not moved by a minor. + +Dense collections therefore **promote objects out of the evacuating nursery +sooner**, so any given object is evacuated FEWER times. If the defect needs an +object to be relocated while some stale reference to it exists, then: + +* denser collections → earlier promotion → fewer relocations → safer ✓ +* the quarantine → retired pages held → the stale read finds the intact + original → safer ✓ (§16) +* `--debug-symbols` → different layout → different reuse → safer ✓ (§13) +* rooting fixes → do not change WHEN an object is promoted → no effect ✓ + +That is the first hypothesis in this session that accounts for every +observation rather than most of them. It points at **promotion / tenuring and +the evacuation policy** (`gc/copying.rs`, the C4b policy, `HAS_SURVIVED` / +`TENURED` transitions) rather than at anyone's root set. + +### The concrete next experiment + +Test the promotion boundary directly rather than the schedule: + +1. force immediate promotion (promote on the FIRST minor rather than the + second) — the hypothesis predicts the failure disappears; +2. suppress promotion (never tenure) so everything is evacuated every cycle — + the hypothesis predicts the failure gets much worse, ideally deterministic; +3. if (2) makes it reliable, that IS the reproducer this session lacked, and + the bug is then a stale reference to an object across an EVACUATION, which + `PERRY_GC_VERIFY_EVACUATION=1` and `PERRY_GC_FROMSPACE_SCAN=1` are both + built to catch — and both have been unusable so far only because the + failure was too rare to catch in the act. + +### The unpaced config REPLAYS — this is the experimental control the session lacked + +Two seed-1 runs, same binary: + +| counter | run A | run B | +|---|---|---| +| `safepoints` | 63941 | **63941** | +| `scheduled_collections` | 63941 | **63941** | +| `copying_minors` | 63941 | **63941** | +| `polls_paced` | 0 | **0** | +| `moved_objects` | 892,662 | 892,062 (0.07% apart) | + +The schedule — *which* safepoints collect — is now **exactly** reproducible, +against ~4% drift in the paced config (§1). Only `moved_objects` still wobbles, +by 0.07%, which is a couple of objects' survival differing rather than a +different schedule. + +That changes the economics of every experiment in this note. §30's arithmetic +said attributing a rate shift needed ~40 runs per arm because the schedule +itself moved between arms; with the schedule pinned, an intervention that +changes the outcome at a fixed seed has changed something real, and one run per +arm can say so. **Use `PERRY_GC_SCHEDULE_ALLOC_KB=0` for every A/B from here +on**, and treat the paced config as a rate-survey tool only. + +Cost: ~9.4× the collections, so budget 30–60 minutes per run on a quiet box. +Worth it — the paced config's cheapness was false economy, since its results +needed forty runs to mean anything. + +## 32. The promotion hypothesis is NOT supported either — and why I stopped here + +`PERRY_GC_TENURING_SURVIVALS` (commit `b7dbe5c3d`) pins the promotion age, +overriding the adaptive threshold. Paced schedule, seeds 1–5, same binary: + +| promotion age | relocations per object | failures | +|---|---|---| +| `=255` (never promote by age) | **most** — every survivor re-evacuated every cycle | **0/5** | +| `=1` (promote on first minor) | **fewest** | **1/5** | +| adaptive (#7432, default) | in between | ~40% (3/6 on the sibling binary) | + +§31 predicted `=255` becomes RELIABLE and `=1` disappears. Neither happened. + +And the shape kills the follow-on story too. When `=255` and `=1` both looked +clean I reached for "it is the adaptive TRANSITIONS, not the value" — two +opposite interventions sharing only a fixed threshold. Then `=1` failed a seed. +A pinned threshold has no transitions, so that explanation is gone as well. + +What is left is non-monotonic: most relocations is safest, fewest is middling, +and the adaptive middle is worst. No story about relocation count fits that, +and at n=5 (0.6⁵ ≈ 8% by chance for `=255`) none of these cells is individually +significant anyway. + +### Tally of hypotheses tested against this bug + +| # | hypothesis | verdict | +|---|---|---| +| 1 | `Object.defineProperty` rooting (#7962/#7978) | refuted (session 1, §2) | +| 2 | callee unrooted across arguments, compiled code | real defect, **not this bug** (§19) | +| 3 | `dyn_eval`'s own `root_push` discipline | audited sound (§21); more collection there made it *better* (§23) | +| 4 | stale argument buffer in the dispatch tower | real defect, **not this bug** (§25, 6/16) | +| 5 | promotion / tenuring age | **not supported** (this section) | + +Five hypotheses, two real defects fixed, bug still standing. + +### Why I am stopping rather than trying a sixth + +Not because the leads are exhausted — because the *measurement* cannot support +another one. §30 laid out the arithmetic and this section is another instance +of it: a ~40% base rate, ~1–4% schedule drift, and five-run arms. Every cell in +the table above is under-powered, and I would be pattern-matching on noise. + +The honest state is: **the next person should not run another 5-seed sweep.** +They should either + +* build a deterministic FAILING reproducer — §31 pinned the schedule exactly + (`ALLOC_KB=0`, 63,941 safepoints reproduced to the digit) but seed 1 passes + there, so the remaining work is a seed search under that config until one + fails, after which every A/B is one run per arm; or +* attack it statically instead — the remaining unaudited surface is the + interpreted/compiled BOUNDARY (`dyn_eval/bridge.rs`, the raw-`args_ptr` arms + below `native_call_method.rs:1496`, and whatever caches the interpreted + dispatch path populates), where a hazard can be found by reading rather than + by sampling. + +Everything needed for either route is committed: five diagnostics, a pinned +schedule, a symbolicating build mode, and this note. + +## 33. Measured: the stale-argument population in the dispatch tower is 36 sites, not 10 + +Route 2 from §32, done rather than handed off. The question §25 left open was how +many dispatch arms in `js_native_call_method` pass the caller's raw `args_ptr` +below the handle scope. Counting by eye is exactly the audit-by-judgement that +created the problem, so I let the compiler count. + +**The enforcement experiment.** Immediately after `arg_handles` is built, +shadow the raws so no arm below can name them: + +```rust +#[allow(unused_variables)] +let args_ptr = (); +#[allow(unused_variables)] +let args_len = (); +``` + +`cargo check` then reports **36 errors** — 36 places that reach past the rooted +handles for the caller's memory. #7528 converted ten of them. The other 26 were +never distinguished from the ten by anything except an author's per-arm +judgement at the time. + +The file's own justification is what makes this a defect rather than a style +question. #7528 re-reads the RECEIVER at every use, and says why: + +> a value READ OUT of a root and held in a local is not rooted — the collector +> rewrites the SLOT, not the copy. This function then runs ~1160 more lines +> across a dozen probes that allocate. + +`arg_handles` is the slot; `args_ptr` is the copy. The argument that forces the +receiver to be re-read forces the arguments to be re-read, at every one of the +36. + +**Reverted, not landed.** Fixing them correctly needs a per-site +`let ra = refreshed_args();` — a single refresh at the top is precisely the +mistake #7528 documents — which is 36 individually-checked edits. That is a +focused change someone should make with a clean host and the gap suite, not +something to bolt on at the end of a session. The shadowing trick above is the +enforcement mechanism to land WITH it, so the population cannot regrow: the +losing spelling stops compiling, the same move `RootedGroup` made on the +codegen side. + +**Cost note, since it is the obvious objection:** the genuinely hot path does +not pay. `try_class_vtable_fast_dispatch` returns above the handle scope +entirely, so all 36 sites are already slow paths. + +**Is one of the 26 this bug?** Unknown, and I am not going to guess after five +refuted hypotheses. What can be said: two of the arms in this family were +verified to have a collection point before the dispatch (§25) and fixing them +did not close #7803 (6/16). The remaining 26 are a real, enumerated, +compiler-checkable defect population on the exact frame in the failing stack — +which is worth fixing whether or not it is this bug. + +## 34. The prescribed RATE=1 unpaced seed search cannot distinguish seeds + +`schedule.rs:79-81` states it, and `schedule_hit` implements it: + +``` +`1` means every handled safepoint — the maximum-pressure endpoint, where +the seed stops mattering because every ordinal is selected whatever it +hashes to. +``` + +```rust +if threshold == THRESHOLD_ALWAYS { return true; } // rate >= 1 +``` + +`PERRY_GC_SCHEDULE_ALLOC_KB=0` makes every loop poll a handled safepoint +(`polls_paced=0`, 63,941 candidates). Combined with `RATE=1`, **every seed +runs the identical schedule**: collect at all 63,941. Seed 1 already passed +that schedule twice (§31). A 1–40 sweep under those two knobs is four to +forty copies of the same experiment. + +This session started that sweep (seeds 1–4 in parallel) before reading the +decision function. Seeds 2–4 were killed at T+35 min; seed 1 was left +running as a check that `/tmp/zod` still matches §31. Those 35 minutes are +not a rate measurement. + +### The experiment that actually uses the seed + +The seed is the hash input. It only selects a *subset* of candidates when +`RATE < 1`. Pair that with `ALLOC_KB=0` so the candidate set stays the one +quantity that does not drift: + +``` +PERRY_GC_SCHEDULE_SEED=$s +PERRY_GC_SCHEDULE_RATE=0.1 # NOT 1 — seed must select +PERRY_GC_SCHEDULE_ALLOC_KB=0 # candidate set = loop_polls +PERRY_GC_PROTECT_FROMSPACE=0 +PERRY_UNCAUGHT_BACKTRACE=1 +``` + +Rate 0.1 against 63,941 candidates is ~6,400 forced collections — the same +*count* as the paced RATE=1 config that fails ~40% of the time (~6,840), +but a *stable, seed-determined subset* rather than an allocation-feedback +subset that drifts 1–4%. Cost should be much closer to a paced run than to +the RATE=1 unpaced hour, because the expensive part is the collection, not +the poll entry. + +If some seed under this config fails twice, that is the deterministic +reproducer §31 was aiming at. If none of 1–40 fail, the failure needs the +paced clustering (collect soon after an allocation) rather than a random +10% of polls — which is the brief's own fallback, now reached for a +stated reason rather than after a day of identical runs. + +### Also noted, not pursued yet + +`#7803` was closed on 2026-08-13 citing #8011's 26/26 quarantine-off +passes. This branch is based on `410dadd45` (#8021, which is that close's +own follow-up) and still fails at ~30–50% under paced RATE=1. The close +and the later measurements cannot both be describing the same binary +under the same knobs; one of them used a suppressor (`--debug-symbols`, +quarantine, auto-optimizer stripping diagnostics) or a stale archive. +Not re-litigated here — the subject is still live on this tree. + +## 35. RATE=0.1 + ALLOC_KB=0: seed 1 passes, seeds 2 and 3 abort on a stale header + +The corrected experiment from §34, same `/tmp/zod` (KEEP_SYMBOLS, no `-g`), +quarantine off: + +| seed | result | safepoints | scheduled_collections | note | +|---|---|---|---|---| +| 1 | **pass** | 63941 | 6335 | `polls_paced=0`, `moved_objects=852795` — candidate set is the pinned one | +| 2 | **abort 134** | 58281 | 5637 | pin-latch, incoherent Map header | +| 3 | **abort 134** | 21547 | 2159 | pin-latch, incoherent native_pod_view header | + +Seed 2 header: `obj_type=8 (map) size=2147418795 flags=0x1e (ARENA|PINNED|SHAPE_SHARED|INTERNED)`. +INTERNED is written in exactly one place and only on strings. `size` is 2 GiB. + +Seed 3 header: `obj_type=16 (native_pod_view) size=2147419055 flags=0x47 (MARKED|ARENA|PINNED|HAS_SURVIVED)`. +`size` is outside `8..=1048576`. + +The latch's own coherence verdict names this: **the copier followed a slot +that was not rooted across a collection** — not a real pin, not #7990's +original "pin site outside pin_object". Two seeds, two different garbage +types, same class. This is #7803 and #7990 as one defect, caught in the +act rather than as a late TypeError. + +Seed 1 passing on the same binary and knobs is the other half: the seed +now actually selects, and at least one selected subset does not hit the +window. + +Seed 3 confirmation (same binary, same knobs): **abort 134 again**. +Different garbage (now a Map, size 2147419459) and a different safepoint +(52836 / 5319 collections, against 21547 / 2159 on the first hit). So +seed 3 is a **reliable fail (2/2)** but not a fixed-ordinal replay — the +schedule is pinned, the moment a stale slot lands on bytes that look +pinned is not. That is still the A/B this session lacked: seed 1 passes, +seed 3 fails, same binary. + +Final confirmation table, one binary, RATE=0.1 ALLOC_KB=0, quarantine off: + +| run | seed | result | safepoints | collections | +|---|---|---|---|---| +| first | 1 | pass | 63941 | 6335 | +| first | 2 | abort | 58281 | 5637 | +| confirm | 2 | **pass** | 63941 | 6238 | +| first | 3 | abort | **21547** | **2159** | +| confirm A | 3 | abort | 52836 | 5319 | +| confirm B | 3 | abort | **21547** | **2159** | + +**Seed 3 is the reproducer (3/3 fail).** Two of the three land on the same +ordinal. Seed 2 is 1/2 — a bias, not a replay. Seed 1 remains the passing +control. + +The abort is a *layout lottery on top of a pinned schedule*: the stale slot +is visited on the selected collections, and the latch only fires when the +bytes there happen to look PINNED. That is why paced RATE=1 is ~40% and +why `--debug-symbols` / the quarantine suppress — they change reuse, not +rooting. + +`CopyingWalkPhaseGuard` is committed so the next abort (after a rebuild) +prints `copying walk phase: `. +The latch still does not name the slot; the phase is the next cut. + +### Seed 3 on the walk-phase binary: `mutable_root_slots` + +`/tmp/zod-phase` (archives 11:19, KEEP_SYMBOLS, no `-g`), same knobs: + +``` +copying walk phase: mutable_root_slots +safepoints=52836 scheduled_collections=5319 +obj_type=8 (map) size=2147418931 (incoherent) +``` + +That is the precise-root walk — shadow stack + RS4GC native stack maps + +module globals — not a named runtime scanner, not the remembered set, not +the worklist drain. The stale pointer is in a slot the collector already +believes is a root. Next cut: which of the three kinds +(`shadow_stack` / `native_stack` / `global_root`). + +### Seed 3 on the kind-split binary: `mutable_root_slots/native_stack` + +`/tmp/zod-kind`, same knobs, abort 134: + +``` +copying walk phase: mutable_root_slots/native_stack +safepoints=6795 scheduled_collections=701 +INCONSISTENT — INTERNED on a map +``` + +The stale pointer is in an **RS4GC statepoint live bundle** — a compiled +frame the collector already treats as a root. That is why `--debug-symbols` +suppresses (different register allocation / stack maps) and why four +runtime-side rooting fixes did not. The latch now also dumps a mutator +backtrace so the next abort names the function. + +## 36. Named: the slot is a native stack map, the safepoint is `Doc.write`, the caller is `generateFastpass` + +`/tmp/zod-bt` (KEEP_SYMBOLS, walk-phase + backtrace), seed 3, RATE=0.1, +ALLOC_KB=0, quarantine off. Abort 134, `safepoints=52836`. + +``` +copying walk phase: mutable_root_slots/native_stack + 12 js_gc_loop_safepoint_armed + 13 perry_method_…core_doc_ts__Doc__write + 18 perry_closure_…core_schemas_ts__135 generateFastpass + 19 perry_closure_…core_schemas_ts__138 $ZodObjectJIT inst._zod.parse + 23 perry_closure_…core_schemas_ts__115 + 27 perry_closure_…core_parse_ts__9 _safeParse (the §14 victim) + 32 parseLoop$spec_i32 +``` + +Source, from `js_register_function_source` in the IR: + +* **138** is `$ZodObjectJIT`'s `inst._zod.parse` (`schemas.ts` ~2015) — + `if (!fastpass) fastpass = generateFastpass(def.shape); payload = fastpass(…)`. +* **135** is `generateFastpass` itself — `new Doc(…)`, then a loop of + `doc.write(...)` to assemble the fastpass source. +* **Doc.write** is the loop that `split`s the line, `map`s indent, and + `push`es onto `this.content`. The loop poll is here. + +The failure is **during schema JIT compilation**, not during +`result.issues`. parse.ts:65 is still the victim: a later `_safeParse` +reads `.issues` on whatever the broken construction left behind. That is +why jitless (no `generateFastpass`, no `Doc.write`) is 0/16. + +Static twin, same IR that named 138: the checker still reports + +``` +schemas_ts__138 [unrooted] + source (rootread) → sink js_closure_call1 + across js_closure_get_capture_bits, js_object_get_field_by_name_f64, + js_object_get_field_ic_miss, js_typed_feedback_object_get_field_by_name_f64 +``` + +That is the *same* 138 hazard §15 put on a failing stack and that the +callee-across-arguments fix never touched (different arm: a value read +out of a root, held across allocating property-gets, then called). +`Doc.compile` still has the third residual (`js_array_concat` across +`js_array_like_to_array`). IR dated 2026-08-13; re-emit before treating +the static list as current. + +### What "fixed" means from here + +1. Emit fresh IR (`gc_root_dominance_dep_native_corpus.sh`) and confirm + 138's `unrooted:rootread→js_closure_call1` is still there. +2. Root that value (and anything 135 holds across `doc.write`) with + `RootedGroup`, same as the three call arms. +3. Seed 3 RATE=0.1 ALLOC_KB=0 flips abort→pass on the same binary pair. +4. Sabotage the root, abort returns. Land a checker budget that can + only go down, plus a seed-3 schedule cell that asserts + `copying_minors > 0`. + +## 37. NAMED: the spread-new bundle wrote through a moved accumulator — `Doc.compile`, `Expr::NewDynamicSpread` + +Session 4 (2026-08-14, fresh binary at `6ae8e5016`+fix). §36's prescription +("root 138's rootread→js_closure_call1") turned out to be STALE EVIDENCE — on +freshly emitted IR that finding is GONE, closed by main's native-root alloca +lowering (#8062/#8071): the `generateFastpass` callee is stored to and +re-read from a tracked `addrspace(1)` alloca below the `def.shape` pget +diamond. The bug still reproduced. What was actually left, per the re-run +checker (`--statepoints --moving-only` on the re-emitted corpus, 81 modules, +40/40 planted violations caught): + +``` +Doc.compile [unrooted] alloc → across js_array_like_to_array → js_array_concat +schemas 185 [unrooted] rootread → across pget diamond → js_rel_ge (read-only sink) +util 121 [unrooted] alloc → across number_coerce/pad_fill → get_string_pointer (read-only sink) +``` + +### The mechanism, with the forensics that pin it + +`Expr::NewDynamicSpread` (`new F(...args, src)` — `Doc.compile`'s closing +expression, run at the end of EVERY `generateFastpass`) bundled its arguments +with the accumulator in a bare i64 register: + +``` +acc = js_array_alloc(0) // raw register +for arg: lower_expr(arg) // can collect + js_array_like_to_array(part) // allocates, can run a MOVING minor + acc = js_array_concat(acc, …) // ← writes through acc's PRE-MOVE address + acc = js_array_push_f64(acc, v) // ← same +js_new_function_construct_apply(func_double, acc) // callee ALSO unrooted +``` + +A scheduled minor inside the window moves the accumulator, retires its pages +to from-space and recycles them into Eden **within the same cycle**; the next +push/concat then writes a NaN-boxed element through the stale pointer — over +whatever live young object now occupies those bytes. The element is a string +(`lines.join("\n")`, `Doc` content lines), and **every garbage header this +bug ever produced is the high half of a NaN-boxed string**: + +| size at latch | hex | run's heap base | +|---|---|---| +| 2147418795 | 0x7FFF_02AB | (§35 seed 2) | +| 2147419055 | 0x7FFF_03AF | (§35 seed 3) | +| 2147419135 | 0x7FFF_03FF | header 0x3ff2… ✓ | +| 2147418856 | 0x7FFF_02E8 | header 0x2e85… ✓ | +| 2147419192 | 0x7FFF_0438 | header 0x438e… ✓ | + +0x7FFF = STRING_TAG; the low bits are the top of the 48-bit pointer and +track each run's ASLR heap base exactly. The "PINNED map/native_pod_view" +objects the latch reported were never objects at all. + +This explains every property the bug ever showed: needs the `new Function` +path (jitless 0/16 — no `generateFastpass`, no `Doc.compile`); victim frame +varies (the latch names whoever holds a pointer INTO the sprayed +neighborhood, not the culprit); `--debug-symbols`/quarantine suppress +(different reuse timing/none); four runtime-side rooting fixes changed +nothing (the write is emitted by codegen); intermittent per run at a FIXED +schedule ordinal (the window is schedule-determined — seed 2 aborts at +safepoints=58281 scheduled_collections=5637 on two different binaries and +every failing run of this one — only the reuse layout varies). + +### The frame-namer (landed `6ae8e5016`) corroborates + +The pin-latch abort now prints the owning frame of the visited native slot. +Seed 2 abort on the pre-fix binary: + +``` +native root slot: owner=perry_closure_…core_schemas_ts__138 + reg=31 offset=40 (SP+40) +ip = fn+0xF64 → the instruction after `bl _js_closure_call2` +``` + +138 suspended at `fastpass(payload, ctx)`; a tracked slot in its bundle +points at an object whose header was sprayed while the JIT corridor ran +beneath that call. The victim, exactly where the mechanism predicts. + +### The fix (`af4a26762`) + +`NewDynamicSpread` and the dynamic `super.m(...spread)` arm (an identical +private copy) now route through `call_spread::bundle_args_rooted` — the +rooted-accumulator bundling the CallSpread arms have used since #7664 — with +the callee in a `RootedGroup`, re-read below the bundle. `bundle_args_rooted` +went `pub(crate)` so no private copies of that loop can exist. + +Tests (`expr/call_spread_rooting_tests.rs`): IR-ordering assertions — the +accumulator each fold reads and the callee the dispatch reads must be defined +BELOW the last collection point of the bundle, with liveness asserted by +callee name. **Sabotage-verified**: with the two lowering files reverted the +two new tests FAIL; with the fix they pass. + +### Scoreboard for the acceptance bar + +1. Named cause: the spread-new accumulator (and callee) in + `Expr::NewDynamicSpread`, lost across the bundle's collection points; + the value written through it is what corrupted headers. ✓ +2. Deterministic-window reproducer: seed 2, RATE=0.1, ALLOC_KB=0 — + abort always at ordinal 58281 (2/3 detection per run, layout lottery); + flip measured on the fix binary (see below). — +3. Sabotage: compile-time flip demonstrated (tests red on reverted arm); + dynamic sabotage arm = the pre-fix binary itself (aborting). ✓ + +## 38. §37 WAS WRONG about the writer — the root cause is the remembered-set rebuild running BEFORE the drain + +The spread-new fix flipped nothing: on the fixed binary seed 2 aborted 2/6, +seed 3 1/1, seed 5 1/1 — the same rate. §37's forensics (0x7FFF string +high-halves tracking the heap base) never discriminated "stale WRITE sprayed +a header" from "stale SLOT resurrects recycled bytes"; any NaN-boxed word in +recycled memory produces the same picture. Both the mechanism sentence and +the scoreboard in §37 over-claimed. The spread-new + super-spread rooting fix +is REAL (checker fingerprint gone, sabotage-tested IR-ordering tests) and +stays — hypothesis #6, sixth real defect, not the cause. + +What found the truth: the instruments finally became cheap together. With +the frame-namer every abort named the SAME victim slot (138's saved implicit +`this`, SP+40, at its `js_closure_call1` statepoint), and +`PERRY_GC_FROMSPACE_SCAN_ABORT=1` under the pinned seed-3 schedule aborts at +**scheduled collection #2, safepoints=12, in seconds**: + +``` +owner=0x… type=1(array) space=Old +120 bare -> 0x… (type=2 object, Survivor1) +MISSING-REWRITE (target moved) [dirty_now=false ever_dirty=false] never_dirty=1 not_in_snapshot=1 +``` + +An Old parent, never dirty, whose young child moved without the slot being +rewritten. `owner_flags=0x23` = MARKED|ARENA|TENURED. The stack: zod +`$constructor` machinery during corpus module init. + +**The defect** (`gc/copying.rs`, `run_copied_minor_attempt`): +`rebuild_evacuated_old_to_young_remembered_set(&collector.moved_headers)` ran +ABOVE `collector.drain()`. `moved_headers` at that point holds only what the +ROOT walks moved; everything the DRAIN promotes — every transitively-reachable +object — is appended after the rebuild already ran. A parent promoted to Old +mid-drain with a still-young child therefore had NO remembered-set entry: the +collector's own drain rewrote its slots (no mutator barrier fires for +collector writes, so the page was never dirty), and the next minor moved the +child again without tracing the parent. Stale slot; recycled bytes read back +as objects; every downstream symptom follows. Under production pacing the +child promotes ~2 cycles later so the window is short (the original 1-in-60 +rarity); the seeded schedule multiplies exposed edges (30–50%). + +Why five hypotheses and six fixes missed it: the failure is created by the +COLLECTOR, not the mutator — no codegen window, no runtime cache, no rooting +discipline touches it. The static checker cannot see it by construction. And +every prior from-space scan ran on binaries where an earlier collection had +already recycled the evidence; the pinned schedule finally made collection #2 +observable. + +**The fix** (`ab558bf5e` + follow-up): move the rebuild (and the old-young +edge verifier) BELOW the post-drain runtime-scanner walks — the last phase +that can move an object — where `moved_headers` is complete and every slot +holds its final address. + +**Regression test**: `gc/tests/copying/promoted_remembered_7803.rs` — stages +exactly the drain-promotion shape (rooted intermediate → parent → fresh young +child; parent promoted on the 4th survival VIA THE DRAIN; next minor moves +the child) and asserts the parent's capture slot tracks the child, with +subject-liveness asserts at each stage (parent actually in old-gen, child +actually still young). Sabotage = revert the reorder; the slot keeps the +from-space address and the test fails. + +### §38 amendment — the reorder is NECESSARY but did not close the scan finding + +On the rebuilt binary (reorder fix in): the seed-3 FROMSPACE_SCAN_ABORT run +still aborts at scheduled collection #2 with the IDENTICAL offender (same +page offsets `…c3d0`/`…8148`, Old array +120 bare -> Survivor1 object, +never_dirty), and seed 2 still aborts the plain run. `not_in_snapshot` even +though the owner was promoted a full cycle earlier means the CYCLE-1 +(post-drain, fixed) rebuild also produced no entry for this slot — i.e. +**`visit_gc_rewrite_slots` does not enumerate it**. A slot no enumerated walk +can see cannot be remembered by any rebuild ordering. The reorder stays (it +is a real gap for enumerable slots: unit-test probe shows the post-drain +rebuild classifying exactly — parent's page dirty via its own entry, +intermediate's correctly clean — where pre-fix the intermediate was +remembered on a from-space over-approximation), but the live defect is the +UNENUMERATED BARE SLOT. + +Candidates ruled out by reading: object spill (barriers its own slot address, +stores boxed), growth stubs (carry GC_FLAG_FORWARDED, scan skips them). +Next instrument (building now): the scan abort dumps the OWNER ARRAY — +header words + first 24 payload words with per-word classification — to +identify the structure semantically. lldb watchpoints are defeated by mmap +ASLR (heap addresses differ run-to-run even under lldb). + +### §38 second amendment — the cycle-2 scan finding was a FALSE POSITIVE + +The owner dump (one 5-second run) settled it: the "Old array +120 bare" +offender is a live length-8/capacity-16 array whose UNUSED CAPACITY — a +hole-reused old block — still holds the previous occupant's bytes: a dead +StringHeader (`byte_len=13`, ASCII `Stri|ngDecode|r` = "StringDecoder") and +the flagged survivor word at element 14, PAST the array's length. No +collector walk can ever rewrite capacity slack (the element range is +length-keyed by design), so the scan manufactured a deterministic +MISSING-REWRITE out of dead bytes. The scan now stops at +ArrayHeader+length (`array_slack_skipped=` counts the exclusion). + +Standing evidence after the retraction: +* the pin-latch aborts (seeds 2/3/5, fixed per-seed ordinals) remain REAL + and unexplained — the victim slot is 138's saved implicit `this` at a + call statepoint (SP+40), and the "garbage headers" at its target are + NaN-boxed VALUE words; +* that value-word signature fits an address pointing INTO live data (an + interior pointer / non-header address) as well as it fits recycled + memory. The pin-latch now prints the victim slot's raw word, a + neighborhood dump, and the census-backed ENCLOSING live object of the + followed address, which separates those two futures in one abort. +* the remembered-set reorder (ab558bf5e) keeps its soundness rationale + (drain promotions genuinely postdate the old rebuild point) but has no + dynamic evidence attached anymore. + +## 39. NAMED AND FIXED: the compact GC map collapsed RS4GC (base, derived) pairs — for-of cursors were unrewritable + +The latch identification dump (one seed-3 run) ended the hunt: + +``` +native root slot: owner=…schemas_ts__138 reg=31 offset=40 + raw_bits=0x7ffd_0529_988c_0508 ← boxed POINTER_TAG +ENCLOSING live object: user=0x529988c0458 obj_type=1 (array) size=424 + — the followed address is +176 INTO it +``` + +The slot held a **boxed interior pointer**: the address of ELEMENT 21's slot +of a live 52-element array of strings (the schema keys array). The seed-5 +abort was the same species from another observation point — the +implicit-this CELL holding a one-past-end cursor (`&elements[len]`), landing +in the bytes of the generated fastpass source string. Every "garbage header" +this bug ever produced (INTERNED-on-map, 0x7FFF/0x7FFD sizes tracking the +heap base) was the walker reading ARRAY ELEMENT WORDS at `interior - 8` as a +GcHeader. + +**Root cause**: `perry-codegen/src/gc_map.rs`'s compact format was built on +the stated premise "Perry has no interior pointers" and collapsed every +statepoint (base, derived) pair to one slot. The premise is false: the RS4GC +prelude (`mem2reg,sccp`) hoists for-of element GEPs into values live across +the poll, recorded by LLVM as DERIVED pointers. With the pairing gone: +1. the walker chased `&elements[i]` as an object start — the pin-latch + aborts (a DIAGNOSTIC misfire, the heap was fine at that instant); +2. on a cycle that moved the array, the cursor slot was never rewritten as + `base' + delta` — the dangling cursor whose deref is `parse.ts:65`. + +Why every prior signature fits: shadow-stack era re-derived cursors per +iteration (class born at #7370's statepoint default); `--debug-symbols` +changes regalloc (cursor lives in a register, not a slot); the quarantine +changes which bytes sit at the misread address (detection lottery, fixed +schedule ordinal per seed); jitless never runs the fastpass corridor's key +loops; and all six earlier fixes were runtime/codegen-side while the defect +sits between the emitter and the walker. + +**Fix (gc_map v4 + walker)**: records keep `(base_index, reg, offset)` +derived entries; the walkers exclude derived slots from the visited-root set +and rewrite each as `new_base + (old_derived - old_base)` after its base, +preserving the slot's stored form. All three walkers (Itanium, fp-chain, +Windows). Version-gated both sides, fail-closed. + +Validation pending at the time of writing: seed 2/3/5 flip on the v4 binary +(pre-fix arms: seed 3 = 2/2 abort, seed 2 = 2/3, seed 5 = 1/1 on the same +tree minus the fix), full gap suite (the map change touches every compiled +binary), perry-codegen + perry-runtime suites. + +## 40. v4 flips seeds 1/2/5; the seed-3 residual, characterized to the slot + +### The flip (same tree, pre-fix arms recorded in §35–§39) + +| seed | pre-v4 | v4 binary | +|---|---|---| +| 1 | 1/3 abort | 0/1 | +| 2 | 2/3 abort (fixed ordinal 58281) | **0/3** | +| 3 | 2/2–3/3 abort (ordinals 21547/52836) | **3/3 abort — residual** | +| 5 | 1/1 abort | 0/2 | + +All passing runs assert `copying_minors>0 moved_objects>0 loop_polls=63936`. +perry-runtime and perry-codegen lib suites green; the emitter round-trip +(always-on, per module) passed over all 81 corpus modules. + +### The residual window, pinned by the instrument chain + +Chain (each step one run): the two-sided this-set trap → the per-cycle +native-slot verifier (`PERRY_GC_NATIVE_SLOT_VERIFY=1`, abort at the CREATION +cycle) → cycle-kind/space enrichment → rewrite-walk stats → collector- +classification → raw-header dump. Established, all on seed 3, always the +same site: + +* Victim slot: SP+40 of `schemas_ts__138` at its `+0xEA0` `js_closure_call1` + statepoint (the this-save around that call). Map record exists and lists + the slot; NO derived entries in 138 (the v4 rewrite is exonerated — the + identical failure predates v4, seed 3 was 2/2 pre-v4). +* Creation: scheduled collection #186 (safepoints=1698), an ORDINARY traced, + preflight-skipped copy-minor (`untraced_cycle=false`). +* The rewrite walk DID traverse (frames=20, records=7, locations=36) in the + same pause where the verifier then finds the slot bad. +* The slot's value at creation: a boxed POINTER_TAG word whose target's + "header" reads as TWO BOXED STRINGS (`raw_header=0x7fff…`, + `payload0=0x7fff…`) — an interior pointer into a strings array (the + schema-keys array shape from §39), NOT a stale-recycled pattern. +* `collector_classify=None` (plausible_gc_header fails on the interior) vs + global `target_space=Survivor1` (= the cycle's FROM-survivor): the rewrite + closure silently skips (`decode→classify→None`), the value never changes, + and the pin-latch trips whole minutes later when the bytes happen to look + pinned. +* The two-sided trap proves the interior value enters the slot BETWEEN the + save and the restore WITHOUT passing through `js_implicit_this_set` + (incoming fires at the restore; outgoing never fires at the save). In that + window the only writers are the collector's walks — yet the interior is + present already at the FIRST verifier-visible cycle of the suspension. + +Open contradiction for the next session: a value the mutator saved coherent, +in a slot the walk visits, reads as a boxed interior at the first +in-suspension collection — either the SAVE path stores a different register +than the map's slot claims at that pc (slot/liveness attribution at +0xEA0), +or a pre-#186 walk of an EARLIER suspension record rewrote this stack +address under a different (base? derived? other-frame?) interpretation. +Next instrument: on the creation cycle, dump ALL 5 slot values of the ++0xEA0 record (16/24/32/40/48) plus the raw record list for the pc actually +matched (match_records can return several records within the ±16 window — ++0xfd4/+0xfd8-style adjacent pairs exist in this function). + +### Kept fixes (all real, all sabotage- or checker-backed) + +1. gc_map v4 derived pairs + walker rewrite (`ed1b9bb27` lineage) — the + seeds 1/2/5 flip. +2. Spread-new/super-spread bundle rooting (`af4a26762`) — IR-ordering tests. +3. Remembered-set rebuild after the drain (`ab558bf5e`). +4. From-space scan array-slack bound; latch owner/target identification; + this-set trap; native-slot verifier — the instrument shelf that made each + step one run instead of a sweep. + +## 41. Gap suite on the v4 tree: gate green; status chatter is host noise + +`PERRY_SKIP_BUILD=1 ./scripts/run_gap_tests.sh` on this tree (Node 26.5.1, +load ~30): **exit 0 — the gate passed.** The report listed +`test_gap_zlib_4917_level: pass -> compile_fail` and seven +`node_fail -> parity_fail` status changes; both classes spot-checked as +environment artifacts, not branch regressions: + +* `test_gap_zlib_4917_level` compiles AND runs correctly by hand on the same + binaries (all-true output; only the known-benign ext-zlib duplicate-alloc + ld warnings). The suite's compile_fail was a load-30 timeout/artifact. +* `test_gap_enum_in_function_body`'s oracle still fails by design (`node + --experimental-strip-types` cannot strip enums — triggerUncaughtException, + §28's class); the node_fail→parity_fail flips are oracle-environment + classification drift, the same local-flake family as + `flaky_gap_oracle_threadpool`. +* Improvement recorded by the harness: `test_gap_iterator_helpers_2874: + parity_fail -> pass`. + +Re-run on a QUIET host before undrafting, per the standing rule. + +## 42. The seed-3 residual: the victim's target sits at a CONSTANT arena offset the snapshot cannot classify + +The record-dump verifier (one run) printed all five slots of 138's +0xEA0 +record at the creation cycle: + +``` ++16 0x7ffd_02af46e2_9bd0 ← healthy: current to-space, rewritten ++24 0x7ffd_02af46e2_50c0 ← healthy ++32 0x7ffd_02af46e2_9c30 ← healthy ++40 0x7ffd_02af46_8004c0 ← VICTIM ++48 0x7ffd_02af46e2_9c68 ← healthy +``` + +Across EVERY failing run tonight — different ASLR bases 0x247bb…, +0x2de97…, 0x3513e…, 0x2af46… — the victim's low bits are the constant +**`…8004C0`**: a FIXED offset from the arena base, in the survivor region +(global classify says Survivor1 = the from-survivor). Its page is in no +cycle's `CopyingPointers` snapshot (`collector_classify=None` — the +`plausible_gc_header`/page filter rejects it), so the rewrite walk skips it +silently every cycle while the surrounding slots track to-space normally. +The bytes at target-8 read as boxed strings = whatever currently occupies +that fixed survivor offset. + +**Working hypothesis, one code question away**: a survivor-side allocation +path (mid-cycle overflow block? bootstrap-era survivor block?) produces +blocks whose pages are missing from — or mis-tagged for — the classifier +snapshot the copying minor builds, so any root pointing into them is +unmaintainable. Check: `arena_alloc_gc_survivor` → `arena_cell_alloc`'s +NEW-BLOCK path (arena/allocators.rs:370–) — are the block's pages entered +into the page-generation map the snapshot reads, and is the snapshot taken +before mid-cycle blocks can appear? Compare against how +`copying_prepare_to_space` registers the prepared to-space blocks. + +Instrument ready for the confirmation: `PERRY_GC_NATIVE_SLOT_VERIFY=1` +aborts at the creation cycle in ~2 minutes; add a page-provenance print +(when was the target's page registered, by whom) to close it in one run. + +## 43. The widened poll-capable set does not cost the dep-native arm its budget + +#8134 (merged to main) lists five buffer/typed-array constructors in +`POLL_CAPABLE_RUNTIME`. Widening that set is one-sided — it can only make +windows VISIBLE that `--moving-only` previously dropped — so the open +question was whether a gated arm newly exceeds its budget. Measured here +after the fact, on this branch's dep-native corpus with the merged checker: + +``` +=== checked 12909 functions / 81 modules +=== safepoints: 52322 with a live bundle: 39355 relocates: 445204 +=== statepoint hazards: 2 (unrooted: 2, stale: 0) [budget --max-unrooted 3] +=== seeded statepoint violations: 40 planted, 40 caught, 0 MISSED +exit 0 +``` + +Subject-liveness is asserted by the seeded arm (40/40), so this is a real +pass, not an empty one. The two residuals are the read-only sinks named in +§40 (`schemas_ts__185` rel_ge, `util_ts__121` pad/coerce) — i.e. the +post-spread-fix floor, and the basis for tightening the budget 3 → 2. That +tightening is deliberately NOT taken here: the curated arm's number has not +been re-measured on this tree, and a budget lowered on one arm's evidence is +how a gate goes red for the wrong reason. + +Note on method: the first attempt at this measurement reported `gate-exit=124` +— my own 40-minute `timeout`, not a verdict. Re-run with a real budget it is +exit 0. A wrapper's exit code is not the subject's; this file now has three +instances of that. diff --git a/gc-handoff/sweep-unpaced-subrate.sh b/gc-handoff/sweep-unpaced-subrate.sh new file mode 100755 index 0000000000..7919ce0373 --- /dev/null +++ b/gc-handoff/sweep-unpaced-subrate.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# #7803 Step 1, corrected (ZOD-NOTES §34). +# +# RATE=1 + ALLOC_KB=0 makes the seed inert — every ordinal collects. +# RATE=0.1 + ALLOC_KB=0 keeps the pinned candidate set (loop_polls) and +# lets the seed select ~10% of them (~6,400 collections, same count as +# the paced RATE=1 config that fails ~40%). +# +# usage: +# sweep-unpaced-subrate.sh +# sweep-unpaced-subrate.sh +set -u +BIN="${ZOD_BIN:-/tmp/zod}" +OUT="${ZOD_SWEEP_DIR:-/tmp/zod-sweep-r01}" +RATE="${ZOD_SWEEP_RATE:-0.1}" +TIMEOUT_SECS="${ZOD_SWEEP_TIMEOUT:-5400}" + +run_one() { + local s="$1" + mkdir -p "$OUT" + PERRY_GC_SCHEDULE_SEED=$s PERRY_GC_SCHEDULE_RATE=$RATE \ + PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=0 \ + PERRY_UNCAUGHT_BACKTRACE=1 \ + timeout "$TIMEOUT_SECS" "$BIN" >"$OUT/o.$s" 2>"$OUT/e.$s" + local rc=$? + local sched + sched=$(grep -h 'gc-schedule.*done' "$OUT/o.$s" "$OUT/e.$s" 2>/dev/null | tail -1) + local err + err=$(grep -m1 -h 'TypeError\|Error:' "$OUT/e.$s" 2>/dev/null | head -c 160) + echo "seed $s exit=$rc | $sched | $err" +} + +if [ $# -eq 1 ]; then + run_one "$1" +elif [ $# -eq 3 ]; then + start="$1"; end="$2"; par="$3" + mkdir -p "$OUT" + seq "$start" "$end" | xargs -P "$par" -I{} "$0" {} >>"$OUT/summary.log" 2>&1 + echo "DRIVER DONE seeds $start..$end rate=$RATE" >>"$OUT/summary.log" +else + echo "usage: $0 | $0 " >&2 + exit 2 +fi diff --git a/scripts/gc_root_dominance_dep_native_corpus.sh b/scripts/gc_root_dominance_dep_native_corpus.sh new file mode 100755 index 0000000000..8d988358fd --- /dev/null +++ b/scripts/gc_root_dominance_dep_native_corpus.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Emit the DEPENDENCY-SCALE corpus under the NATIVE (statepoint) lowering. +# +# WHY THIS EXISTS +# +# gc-root-dominance.yml has four corpus/lowering combinations, and one of the +# four is missing: +# +# shadow (PERRY_RS4GC=0) native (statepoints, SHIPS) +# curated ~124 files gated gated, --max-unrooted 0 +# dependency (zod) gated, --max-stale 118 THIS SCRIPT (was missing) +# +# #7280's own finding was that the curated corpus is the wrong POPULATION -- +# 25 curated files pass while 20 lines of stock zod fault. #7452's finding was +# that the shadow lowering is the wrong LOWERING -- statepoints became the +# default in #7370 and the shadow corpus contains zero of the shipping root +# form. The cell where both corrections meet is the one nobody has ever run, +# and it is exactly the configuration #7803 fails in: the zod corpus, compiled +# the way the failing binary was compiled. First measurement, at #7803: 66 +# `unrooted` hazards where the curated corpus in the identical mode is gated at +# ZERO. 40 of them were the callee-outlives-arguments defect in three call +# lowering arms; the residual is a budget that can only go down. +set -euo pipefail +OUTDIR="${1:-ir-corpus-dep-native}" +PERRY_BIN="${PERRY_BIN:-target/release/perry}" +# `env` execs the binary directly, so a relative path is resolved against the +# CWD at exec time rather than against the repo root. Pin it now. +case "$PERRY_BIN" in /*) ;; *) PERRY_BIN="$PWD/$PERRY_BIN" ;; esac +[ -x "$PERRY_BIN" ] || { echo "::error::$PERRY_BIN not found or not executable" >&2; exit 2; } +ENTRY="${ENTRY:-test-files/gc-dep-corpus/main.ts}" + +# Single-sourced from the Rust const, never retyped (same reason the curated +# script gives: a fourth copy is how the pass string drifts from production). +# Join continuation lines up to the `;` first: rustfmt wraps the initializer +# when the line grows (#8068 did), and a single-line match then reads nothing. +PASSES="$(awk '/const STATEPOINT_REWRITE_PASSES: &str/ { + buf = $0 + while (buf !~ /;[[:space:]]*$/ && (getline line) > 0) buf = buf " " line + if (match(buf, /"[^"]*"/)) print substr(buf, RSTART + 1, RLENGTH - 2) + exit + }' crates/perry-codegen/src/inprocess.rs)" +[ -n "$PASSES" ] || { echo "could not read STATEPOINT_REWRITE_PASSES" >&2; exit 2; } +OPT_BIN="${PERRY_LLVM_OPT:-/opt/homebrew/opt/llvm/bin/opt}" +if [ ! -x "$OPT_BIN" ]; then + for c in "${LLVM_SYS_221_PREFIX:-}/bin/opt" /opt/homebrew/opt/llvm/bin/opt /usr/local/opt/llvm/bin/opt; do + [ -n "$c" ] && [ -x "$c" ] && OPT_BIN="$c" && break + done +fi +[ -x "$OPT_BIN" ] || { echo "::error::no LLVM \`opt\` found; set PERRY_LLVM_OPT" >&2; exit 2; } +if [ ! -f node_modules/zod/src/index.ts ]; then + echo "::error::node_modules/zod/src/index.ts is missing; run npm ci --ignore-scripts" >&2 + exit 2 +fi +echo "passes: $PASSES" +echo "opt: $OPT_BIN" + +rm -rf "$OUTDIR" .perry-trace/llvm +mkdir -p "$OUTDIR" +scratch="$(mktemp -d)" + +env PERRY_RS4GC=1 \ + PERRY_GC_MOVING_LOOP_POLLS=1 \ + PERRY_INLINE_SHADOW_SLOT=0 \ + PERRY_NO_AUTO_OPTIMIZE=1 \ + "$PERRY_BIN" compile "$ENTRY" -o "$scratch/dep-native" --trace llvm \ + >"$scratch/compile.log" 2>&1 || { tail -40 "$scratch/compile.log" >&2; exit 1; } + +mods=0; failed=0 +for ll in .perry-trace/llvm/*.ll; do + [ -e "$ll" ] || break + out="$OUTDIR/dep__$(basename "$ll")" + if "$OPT_BIN" -passes="$PASSES" -S "$ll" -o "$out" 2>"$scratch/opt.err"; then + mods=$((mods + 1)) + else + rm -f "$out"; failed=$((failed + 1)) + echo " rewrite failed: $(basename "$ll") -- $(head -1 "$scratch/opt.err")" + fi +done + +# Subject liveness, asserted at generation time: `opt` exits 0 on a module with +# nothing to rewrite, so "no statepoints" and "clean" are indistinguishable +# downstream. +sp="$(grep -ho 'gc\.statepoint\.p0(' "$OUTDIR"/*.ll 2>/dev/null | wc -l | tr -d ' ')" +live="$(grep -ho '"gc-live"(' "$OUTDIR"/*.ll 2>/dev/null | wc -l | tr -d ' ')" +echo "dep-native corpus: $mods modules ($failed rewrite failures)" +echo " statepoints: $sp non-empty live bundles: $live" +[ "$sp" -gt 0 ] && [ "$live" -gt 0 ] || { echo "::error::corpus has nothing to check" >&2; exit 1; } +rm -rf "$scratch" diff --git a/test-files/gc-dep-corpus-jitless/README.md b/test-files/gc-dep-corpus-jitless/README.md new file mode 100644 index 0000000000..a6a16b71c9 --- /dev/null +++ b/test-files/gc-dep-corpus-jitless/README.md @@ -0,0 +1,50 @@ +# The dependency-scale GC-rooting corpus + +`scripts/gc_root_dominance_corpus.sh` compiles ~99 hand-written `test-files/` +sources. Each is a few dozen lines, written to exercise one lowering. That +corpus reads **zero** violations in the two modes `gc-root-dominance.yml` gates +on, and it has read zero while a twenty-line program that imports a stock npm +package faulted deterministically (#7280). + +That is not a paradox, it is a distribution problem. The two corpora do not +contain the same code: + +| corpus | what dominates its `--stale-registers --moving-only` report | +|---|---| +| curated (`gc_root_dominance_corpus.sh`) | property-GET helper windows, `js_number_coerce`, `js_closure_callN` | +| dependency-scale (this directory) | `js_array_alloc → js_array_spread_append`, `js_box_get_bits → js_closure_callN`, `js_object_alloc → js_object_set_field_by_name` | + +A hand-written test allocates a couple of objects and calls a couple of +helpers. A real library allocates in loops, spreads arrays into arrays, boxes +every mutable capture because its closures outlive their frames, and builds +objects field by field from data. Those are different *shapes*, and the rooting +hazards live in the shapes. + +So this corpus is generated from a real npm dependency — +`zod`, the repo's own `package.json` devDependency, pinned by +`package-lock.json` — rather than from anything written for the occasion. It is +emitted by `scripts/gc_root_dominance_dep_corpus.sh`. + +## Layout + +`main.ts` is the only entry point. It pulls in three shapes at once so that one +compile produces the whole corpus: + +* **the library itself** — `zod`'s own modules, imported by source path + (`node_modules/zod/src/index.js`), which is what makes them *native* modules + rather than a V8-fallback bundle. This is where the module count comes from + and it is what the distribution above is about. +* **an app over the library** — `shared.ts` plus the three endpoint modules, + which build a registry at MODULE INIT time out of cross-module calls whose + arguments are string literals, object literals, closures and schemas. That is + the frame shape #7154's disassembly named. +* **a parse loop** — the twenty-line library-only control from #7280, which is + the program that failed 5/40 while the curated corpus passed 25/25. + +## These files are not gap tests + +They are corpus inputs: they are compiled for their **IR**, and the compile is +the assertion. `run_parity_tests.sh` globs `test-files` at `-maxdepth 1`, so a +subdirectory is out of its scope by construction — which is deliberate, because +these need `node_modules/` and would otherwise be a parity failure on any +checkout that has not run `npm ci`. diff --git a/test-files/gc-dep-corpus-jitless/alerts.ts b/test-files/gc-dep-corpus-jitless/alerts.ts new file mode 100644 index 0000000000..a7a31db24b --- /dev/null +++ b/test-files/gc-dep-corpus-jitless/alerts.ts @@ -0,0 +1,37 @@ +import * as z from "../../node_modules/zod/src/index.js"; +import { defineApiCall, baseFields } from "./shared.js"; + +const Alert = z.object({ + ...baseFields(), + severity: z.string(), + meta: z.object({ note: z.string(), rank: z.number() }), +}); + +defineApiCall( + "https://registry.example.com/v1/alerts", + "GET", + { paginated: true, cache: "no-store", retries: 3 }, + ["alerts", "read"], + Alert.array(), + (body) => "alerts:" + String((body as { id?: string }).id ?? "-"), +); + +defineApiCall( + "https://registry.example.com/v1/alerts/{alertId}", + "GET", + { paginated: false, cache: "no-store", retries: 1 }, + ["alerts", "read", "one"], + Alert, + (body) => "alert:" + String((body as { id?: string }).id ?? "-"), +); + +defineApiCall( + "https://registry.example.com/v1/alerts/{alertId}/ack", + "POST", + { paginated: false, cache: "no-store", retries: 0 }, + ["alerts", "write"], + Alert, + (body) => "ack:" + String((body as { id?: string }).id ?? "-"), +); + +export { Alert }; diff --git a/test-files/gc-dep-corpus-jitless/jitless-first.ts b/test-files/gc-dep-corpus-jitless/jitless-first.ts new file mode 100644 index 0000000000..e93831b0bb --- /dev/null +++ b/test-files/gc-dep-corpus-jitless/jitless-first.ts @@ -0,0 +1,6 @@ +// #7803 EXPERIMENT. Must run BEFORE any module that builds a schema: zod +// captures `const jit = !core.globalConfig.jitless` when the $ZodObject is +// constructed (core/schemas.ts:2007), so a `config()` call in main.ts's body +// is already too late for the schemas alerts/orgs/scans build at import time. +import * as z from "../../node_modules/zod/src/index.js"; +z.config({ jitless: true }); diff --git a/test-files/gc-dep-corpus-jitless/main.ts b/test-files/gc-dep-corpus-jitless/main.ts new file mode 100644 index 0000000000..733b84bb8f --- /dev/null +++ b/test-files/gc-dep-corpus-jitless/main.ts @@ -0,0 +1,113 @@ +// Entry point for the dependency-scale GC-rooting IR corpus. +// +// Compiled by scripts/gc_root_dominance_dep_corpus.sh for its IR, not for its +// stdout — see README.md in this directory. It is also runnable, and is run as +// the acceptance workload for the moving collector, so the printed line is +// deterministic on purpose. +import * as z from "../../node_modules/zod/src/index.js"; +import "./jitless-first.js"; +import "./alerts.js"; +import "./orgs.js"; +import "./scans.js"; +import { allApiCalls, SCHEMAS, CALLBACKS } from "./shared.js"; + +// #7803 EXPERIMENT (not a corpus, not a gate — a copy of gc-dep-corpus with +// one line added). zod builds a `new Function`-generated "fastpass" parser for +// every OBJECT schema (`core/schemas.ts:2028`, `doc.compile()`), which on +// Perry executes through the dyn_eval interpreter — and dyn_eval frames are on +// #7803's failing stack. `jitless` makes `parse` fall through to `superParse` +// instead, so the identical workload runs entirely as native code. +// +// Failures vanish -> the loss is on the generated-code path. +// Failures persist -> dyn_eval is exonerated and the hypothesis is dead. +// (set in ./jitless-first.js, which must execute before the schema modules) + +// The #7280 library-only control: twenty lines of stock zod, no scaffolding. +// This is the program that failed 5/40 under the moving arm while the curated +// 25-file corpus passed 25/25. +function parseLoop(iterations: number): number { + let ok = 0; + for (let i = 0; i < iterations; i++) { + const S = z + .object({ + id: z.string().min(1).max(64), + kind: z.string(), + count: z.number().int(), + ratio: z.number(), + active: z.boolean(), + labels: z.array(z.string()), + meta: z.object({ note: z.string(), rank: z.number() }), + }) + .array(); + const r = (S as unknown as { safeParse: (v: unknown) => { success: boolean } }) + .safeParse([ + { + id: "id-" + i, + kind: "k", + count: i, + ratio: i + 0.5, + active: i % 2 === 0, + labels: ["a", "b", "c"], + meta: { note: "n" + i, rank: i }, + }, + ]); + if (r.success) ok++; + } + return ok; +} + +// The registry walk: cross-module closures created at module init, called back +// long after the frames that built them are gone. +function describeAll(): string[] { + const calls = allApiCalls(); + const out: string[] = []; + for (let i = 0; i < calls.length; i++) { + out.push(calls[i].describe()); + } + return out; +} + +// Schemas built at module init, parsed later — the shape that keeps a library +// object live across many collections before it is finally dereferenced. +function parseRegistered(): number { + let ok = 0; + const keys: string[] = []; + SCHEMAS.forEach((_v, k) => keys.push(k)); + keys.sort(); + for (let i = 0; i < keys.length; i++) { + const schema = SCHEMAS.get(keys[i]) as { + safeParse: (v: unknown) => { success: boolean }; + }; + const cb = CALLBACKS.get(keys[i]); + const sample = { + id: "row-" + i, + kind: "k", + count: i, + ratio: 1.5, + active: true, + labels: ["x", "y"], + severity: "high", + meta: { note: "n", rank: i }, + slug: "org-" + i, + seats: 4, + owners: [{ login: "a", role: "admin" }], + digest: "deadbeef", + findings: [{ rule: "r", level: "warn", line: i }], + summary: "s", + }; + const r = schema.safeParse(Array.isArray(sample) ? sample : [sample]); + const r2 = schema.safeParse(sample); + if (r.success || r2.success) ok++; + if (cb) cb(sample); + } + return ok; +} + +const described = describeAll(); +const parsed = parseLoop(96); +const registered = parseRegistered(); + +console.log("endpoints=" + described.length); +console.log("parsed=" + parsed); +console.log("registered=" + registered); +console.log(described[0]); diff --git a/test-files/gc-dep-corpus-jitless/orgs.ts b/test-files/gc-dep-corpus-jitless/orgs.ts new file mode 100644 index 0000000000..3c35106522 --- /dev/null +++ b/test-files/gc-dep-corpus-jitless/orgs.ts @@ -0,0 +1,38 @@ +import * as z from "../../node_modules/zod/src/index.js"; +import { defineApiCall, baseFields } from "./shared.js"; + +const Org = z.object({ + ...baseFields(), + slug: z.string().min(2), + seats: z.number().int(), + owners: z.array(z.object({ login: z.string(), role: z.string() })), +}); + +defineApiCall( + "https://registry.example.com/v1/orgs", + "GET", + { paginated: true, cache: "default", retries: 2 }, + ["orgs", "read"], + Org.array(), + (body) => "orgs:" + String((body as { slug?: string }).slug ?? "-"), +); + +defineApiCall( + "https://registry.example.com/v1/orgs/{orgId}/members", + "GET", + { paginated: true, cache: "default", retries: 2 }, + ["orgs", "read", "members"], + Org, + (body) => "members:" + String((body as { slug?: string }).slug ?? "-"), +); + +defineApiCall( + "https://registry.example.com/v1/orgs/{orgId}/seats", + "PUT", + { paginated: false, cache: "no-store", retries: 0 }, + ["orgs", "write"], + Org, + (body) => "seats:" + String((body as { slug?: string }).slug ?? "-"), +); + +export { Org }; diff --git a/test-files/gc-dep-corpus-jitless/scans.ts b/test-files/gc-dep-corpus-jitless/scans.ts new file mode 100644 index 0000000000..f208527986 --- /dev/null +++ b/test-files/gc-dep-corpus-jitless/scans.ts @@ -0,0 +1,40 @@ +import * as z from "../../node_modules/zod/src/index.js"; +import { defineApiCall, baseFields } from "./shared.js"; + +const Scan = z.object({ + ...baseFields(), + digest: z.string().regex(/^[a-f0-9]{8,}$/), + findings: z.array( + z.object({ rule: z.string(), level: z.string(), line: z.number().int() }), + ), + summary: z.union([z.string(), z.number()]), +}); + +defineApiCall( + "https://registry.example.com/v1/scans", + "POST", + { paginated: false, cache: "no-store", retries: 0, timeoutMs: 30000 }, + ["scans", "write"], + Scan, + (body) => "scan:" + String((body as { digest?: string }).digest ?? "-"), +); + +defineApiCall( + "https://registry.example.com/v1/scans/{scanId}", + "GET", + { paginated: false, cache: "default", retries: 2 }, + ["scans", "read"], + Scan, + (body) => "scanone:" + String((body as { digest?: string }).digest ?? "-"), +); + +defineApiCall( + "https://registry.example.com/v1/scans/{scanId}/findings", + "GET", + { paginated: true, cache: "default", retries: 2 }, + ["scans", "read", "findings"], + Scan.array(), + (body) => "findings:" + String((body as { digest?: string }).digest ?? "-"), +); + +export { Scan }; diff --git a/test-files/gc-dep-corpus-jitless/shared.ts b/test-files/gc-dep-corpus-jitless/shared.ts new file mode 100644 index 0000000000..c8b29f950b --- /dev/null +++ b/test-files/gc-dep-corpus-jitless/shared.ts @@ -0,0 +1,83 @@ +// Cross-module API registry built at MODULE INIT time. +// +// The shape is taken from #7154's disassembly: a cross-module call whose first +// arguments are string literals parked in registers across an object +// allocation, a schema build (library code with its own allocations) and a +// closure allocation. Every argument here is live across the evaluation of the +// ones that follow it, which is the "evaluate-then-allocate" hazard in +// docs/src/internals/gc-rooting-invariant.md. +import * as z from "../../node_modules/zod/src/index.js"; + +export interface ApiCall { + readonly url: string; + readonly method: string; + readonly opts: Record; + readonly tags: string[]; + readonly describe: () => string; +} + +const REGISTRY: ApiCall[] = []; + +const ABSOLUTE_RE = /^https?:\/\/[a-z0-9.-]+\//; +const PATH_SEGMENT_RE = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g; + +export function defineApiCall( + url: string, + method: string, + opts: Record, + tags: string[], + schema: unknown, + cb: (body: unknown) => string, +): ApiCall { + // js_regexp_new allocates, then the subject string is coerced (another + // allocation) before js_regexp_test consumes both. + const absolute = ABSOLUTE_RE.test(String(url)); + const params: string[] = []; + let m: RegExpExecArray | null; + PATH_SEGMENT_RE.lastIndex = 0; + while ((m = PATH_SEGMENT_RE.exec(url)) !== null) { + params.push(m[1]); + } + // A spread into a fresh array: js_array_alloc followed by + // js_array_spread_append, which is the single largest population in the + // dependency-scale report. + const allTags = [...tags, method.toLowerCase(), absolute ? "abs" : "rel"]; + const call: ApiCall = { + url, + method, + opts, + tags: allTags, + describe(): string { + // A boxed mutable capture read back across a closure call. + let n = 0; + const bump = (): number => ++n; + bump(); + bump(); + const shown = params.length > 0 ? params.join(",") : "-"; + return `${method} ${url} [${allTags.join("|")}] {${shown}} #${n}`; + }, + }; + REGISTRY.push(call); + // Keep the schema reachable so the parse loop below has something to run. + SCHEMAS.set(url + " " + method, schema); + CALLBACKS.set(url + " " + method, cb); + return call; +} + +export const SCHEMAS = new Map(); +export const CALLBACKS = new Map string>(); + +export function allApiCalls(): ApiCall[] { + return REGISTRY.slice(); +} + +export function baseFields() { + return { + id: z.string().min(1).max(64), + kind: z.string(), + count: z.number().int(), + ratio: z.number(), + active: z.boolean(), + labels: z.array(z.string()), + }; +}