From a423a1b635bd19b5431bf4a302ac23766ca4b579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 19:37:19 +0200 Subject: [PATCH 1/2] fix(compile): exclude PERRY_SEGVIEW from the build cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PERRY_SEGVIEW` is not a diagnostic — it changes the emitted code — and it is in neither the build-cache fingerprint nor any object-cache key. So a cached build can return a binary compiled with the OTHER setting: compile a source with the tier on, compile it again with the tier off, and the second can be served from the first. That is precisely the shape every A/B in this campaign uses — one compiler binary, two compiles of one source differing only in this variable — so the failure mode is not a broken build, it is two arms that are secretly the same binary and a measured difference of zero, or two arms swapped. Silent, and it would look like a result. `PERRY_SEGVIEW_DIAG` was already excluded for the weaker reason that a cached build prints no report. The switch that changes codegen was not, which is the worse omission of the two and mine. Excluded rather than keyed because the tier is experimental and default OFF. A cache key is the right fix when it ships on by default; exclusion is correct now and cannot produce a stale entry that becomes the measurement. --- crates/perry/src/commands/compile/build_cache.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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()); } From 15ea8eb62679cfd667094d2f28c90763b7a66415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 17:34:42 +0200 Subject: [PATCH 2/2] fix(codegen): clear the segment-view cursor at loop exit The cursor local is declared in the ENCLOSING statement list, not inside the loop: let __segview_recv = let __segview_input = let __segview_cursor = js_segments_view_open(recv, inp) let __segview_iter = cur != 0 ? undefined : GetIterator(...) For { ... } <- last read of the cursor so without a clear its slot stays a live GC root until the function returns. The cursor holds the input string in a traced slot, so a cursor promoted during the loop drags that string into the old generation, and leaving the slot rooted afterwards keeps a DEAD cursor doing it for the rest of the function. `string-width` is entered thousands of times per reply. That is a candidate mechanism for the idle behaviour measured on cc: I4 settles 45-65 MB ABOVE I3 at 3300 and 15-20 MB at 400 after 120 s, despite winning 20-50 MB of PEAK RSS in 12/12 paired runs. Lower peak with a higher floor is not "less garbage"; it is something being retained. One unconditional `LocalSet(cursor, undefined)` after the loop covers both paths: on the declined path the local holds `0.0`, a number, so the clear is a no-op. `break` reaches it; `return` inside the body pops the frame, which is equally fine. WHAT THIS DOES NOT DO, stated so the commit is not read as a cure: it does not prevent promotion DURING the loop, and nothing in the compiler can, because the cursor is genuinely live there. It removes only the post-loop rooting of a dead cursor. If the idle delta comes from cursors promoted mid-loop, this will not move it. perrymaster's old-gen census after idle, counting class id 0xFFFF_000E on I5-spec / I5-view / I7-view, decides that independently. The test is structural rather than string-matched: it locates the rewritten `For`, reads the cursor's LocalId out of the loop's own guard, and requires the next statement to be `LocalSet(that id, Undefined)`. It fails if the clear is removed, clears the wrong local, or is emitted before the loop. 17/17 segview tests. --- .../perry-codegen/src/collectors/segview.rs | 21 ++++++++- .../src/collectors/segview_tests.rs | 43 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) 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:?}"), + } +}