diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index 90da00a26f..990f31b646 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -1221,7 +1221,26 @@ fn rewrite_site(list: &mut Vec, i: usize, site: &SegmentForOfSite, fresh: pick(cur, Expr::Undefined, decline_iter), ), ); - 3 + // Clear the cursor at loop exit. The cursor local is declared in the + // ENCLOSING statement list, not inside the loop, so without this its slot + // stays a live GC root until the function returns. A cursor that spans a + // minor while the loop runs is promoted, and because it holds the input + // string in a traced slot it drags that string into the old generation + // with it — one per `open`, and `string-width` is entered thousands of + // times per reply. That is a candidate mechanism for I4 settling 45-65 MB + // ABOVE I3 after idle despite winning 20-50 MB of peak. + // + // One unconditional clear covers both paths: on the declined path the + // local holds `0.0`, a number, so clearing it is a no-op. `break` reaches + // this statement; `return` inside the body pops the frame, which is + // equally fine. It does not prevent promotion DURING the loop — nothing + // in the compiler can, since the cursor is genuinely live there — it stops + // the slot from keeping a dead cursor rooted for the rest of the function. + list.insert( + i + 5, + Stmt::Expr(Expr::LocalSet(cur, Box::new(Expr::Undefined))), + ); + 4 } fn unwrap_for_mut(s: &mut Stmt) -> &mut Stmt { diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 51b7ae71d5..9783faf640 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -509,3 +509,46 @@ fn a_site_with_an_unanswerable_use_stays_on_v1() { "v1 does not rewrite uses: {out}" ); } + +/// The cursor local is declared in the enclosing statement list, so its slot is +/// a live GC root until the function returns unless the lowering clears it. A +/// cursor promoted during the loop holds the input string in a traced slot and +/// drags it into the old generation; leaving the slot rooted afterwards keeps a +/// DEAD cursor doing that for the rest of the function. +#[test] +fn the_cursor_is_cleared_at_loop_exit() { + let mut m = module_with(region(cc_body())); + assert_eq!(segview_rewrite_module(&mut m), 1); + + // Structural, not string-matched: the statement AFTER the `For` must be a + // `LocalSet(, Undefined)`, and the cursor is the local the `For`'s + // condition tests against zero. + let for_idx = m + .init + .iter() + .position(|s| matches!(s, Stmt::For { .. })) + .expect("the rewritten loop"); + let cursor_id = match &m.init[for_idx] { + Stmt::For { + condition: Some(Expr::Conditional { condition, .. }), + .. + } => match condition.as_ref() { + Expr::Compare { left, .. } => match left.as_ref() { + Expr::LocalGet(id) => *id, + other => panic!("expected the cursor guard, got {other:?}"), + }, + other => panic!("expected a compare, got {other:?}"), + }, + _ => unreachable!(), + }; + match m.init.get(for_idx + 1) { + Some(Stmt::Expr(Expr::LocalSet(id, v))) => { + assert_eq!(*id, cursor_id, "the cleared local must be the cursor"); + assert!( + matches!(v.as_ref(), Expr::Undefined), + "the cursor slot must be cleared to undefined, got {v:?}" + ); + } + other => panic!("no cursor clear after the loop: {other:?}"), + } +} diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 2e50446cf8..43d9730a4f 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -866,6 +866,19 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if std::env::var("PERRY_SEGVIEW_DIAG").is_ok() { return Err("segview-diag".to_string()); } + // #9843: `PERRY_SEGVIEW` is NOT a diagnostic — it changes the emitted + // code. It is not part of the build-cache fingerprint or any object-cache + // key, so without this a cached build can hand back a binary compiled with + // the OTHER setting: compile a file with the tier on, compile it again + // with the tier off, and the second can be served from the first. The + // A/B rig's whole shape is "one compiler binary, two compiles of one + // source differing only in this variable", which is exactly the collision. + // Excluded rather than keyed because the tier is experimental and default + // OFF; a cache key is the right fix when it ships on, and then a stale + // entry cannot silently become the measurement. + if std::env::var("PERRY_SEGVIEW").is_ok() { + return Err("segview-lowering".to_string()); + } if args.verify_native_regions || args.emit_attest || args.emit_sandbox { return Err("sidecar-or-verify".to_string()); }