From 9bc472fecfb1b48628383e3dcd6bd43435b16d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 07:32:05 +0200 Subject: [PATCH 1/2] test(gc): scope the #7114 IR assertions to the function under test (#7116 review) ir_for returns the WHOLE module, so a bare .find() over it answers with whichever function comes first in the file. Every ordering assertion in the #7114 section is now sliced to @main via function_ir/init_ir and paired with an exact count of the operand-specific opcode inside that slice, so 'this is the operand I meant' is a property of the test rather than of module layout. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9 --- .../tests/temp_root_operand_temporaries.rs | 136 ++++++++++++++---- 1 file changed, 105 insertions(+), 31 deletions(-) diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index c1fd8c1b3d..5e11306abb 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -460,6 +460,34 @@ fn allocating_numeric() -> Expr { } } +/// The IR of ONE generated function, sliced out of the module. +/// +/// `ir_for` / `ir_for_new` return the **whole module**, and an assertion about +/// instruction ORDER over a whole module is satisfiable by an unrelated +/// function's IR: `ir.find("call i64 @js_object_alloc(")` answers with whichever +/// function happens to come first in the file, not with the operand under test. +/// A test that passes that way has proved nothing, which is exactly what it was +/// written to rule out (raised on #7116). +/// +/// Every ordering assertion in the #7114 section goes through here, and pairs +/// the ordering with an exact *count* of the operand-specific opcode inside the +/// slice — order plus count is what makes "this is the operand I meant" a +/// property of the test rather than of the module layout. +fn function_ir<'a>(ir: &'a str, define_prefix: &str) -> &'a str { + let start = ir + .find(define_prefix) + .unwrap_or_else(|| panic!("no `{define_prefix}` in module IR:\n{ir}")); + let body = &ir[start..]; + let end = body.find("\n}\n").map(|e| e + 2).unwrap_or(body.len()); + &body[..end] +} + +/// The function a `module_with_init` / `module_with_new` statement lowers into. +/// Module-level `init` statements are emitted into the entry module's `@main`. +fn init_ir(ir: &str) -> &str { + function_ir(ir, "define i32 @main(") +} + /// `"acc:" + ` — the reported shape. /// /// The invariant: **no operand register may outlive a collection point.** The @@ -477,15 +505,37 @@ fn string_literal_concat_operand_is_re_derived_below_the_allocating_sibling() { })], ); - let concat = ir - .find("call i64 @js_string_concat_value(") - .unwrap_or_else(|| panic!("expected the fused string+value concat:\n{ir}")); - let alloc = ir[..concat] + // Scoped to @main, so nothing below can be satisfied by another function's + // IR, and count-anchored, so "the concat" and "the sibling's allocation" + // are unambiguous rather than "whichever matched first". + let f = init_ir(&ir); + let handle = "load double, ptr @concat_reload_ts_.str."; + assert_eq!( + f.matches("call i64 @js_string_concat_value(").count(), + 1, + "exactly one fused string+value concat in @main:\n{f}" + ); + assert_eq!( + f.matches("call i64 @js_object_alloc(").count(), + 1, + "exactly one allocating sibling in @main:\n{f}" + ); + assert_eq!( + f.matches(handle).count(), + 2, + "the literal's handle must be loaded TWICE in @main: once where the \ + operand is lowered, and once re-derived below the collection point. \ + One load is the #7114 bug; three means something else is re-lowering \ + it:\n{f}" + ); + + let concat = f.find("call i64 @js_string_concat_value(").unwrap(); + let alloc = f[..concat] .rfind("call i64 @js_object_alloc(") - .unwrap_or_else(|| panic!("the sibling operand must allocate:\n{ir}")); - let handle_load = ir[..concat] - .rfind("load double, ptr @concat_reload_ts_.str.") - .unwrap_or_else(|| panic!("the literal must come from its handle global:\n{ir}")); + .unwrap_or_else(|| panic!("the sibling must allocate before the concat:\n{f}")); + let handle_load = f[..concat] + .rfind(handle) + .unwrap_or_else(|| panic!("the literal must come from its handle global:\n{f}")); assert!( handle_load > alloc, @@ -494,11 +544,11 @@ fn string_literal_concat_operand_is_re_derived_below_the_allocating_sibling() { below is what made `console.log(\"acc:\" + run(1e7))` print an empty \ line — the handle global is a registered root that an evacuating \ cycle REWRITES, and the pre-call register keeps the pre-move \ - address:\n{ir}" + address:\n{f}" ); assert_eq!( - ir.matches("call i32 @js_gc_temp_root_push").count(), + f.matches("call i32 @js_gc_temp_root_push").count(), 0, "and it must cost no runtime call. A registered root already has \ liveness; all it was missing is the re-derivation, which is the load \ @@ -527,16 +577,17 @@ fn string_literal_concat_operand_is_not_re_derived_when_nothing_collects() { })], ); + let f = init_ir(&ir); assert_eq!( - ir.matches("load double, ptr @concat_noreload_ts_.str.") + f.matches("load double, ptr @concat_noreload_ts_.str.") .count(), 1, "a comparison over two immediates runs no user code and allocates \ - nothing, so the literal must be loaded exactly once:\n{ir}" + nothing, so the literal must be loaded exactly once:\n{f}" ); assert!( - !ir.contains("call i32 @js_gc_temp_root_push"), - "and no rooting at all:\n{ir}" + !f.contains("call i32 @js_gc_temp_root_push"), + "and no rooting at all:\n{f}" ); } @@ -553,16 +604,26 @@ fn string_literal_array_element_is_re_derived_below_an_allocating_element() { ]))], ); + let f = init_ir(&ir); let handle_pat = "load double, ptr @array_reload_ts_.str."; - let loads: Vec = ir.match_indices(handle_pat).map(|(i, _)| i).collect(); - let element_alloc = ir - .find("call i64 @js_object_alloc(") - .unwrap_or_else(|| panic!("element 1 must allocate:\n{ir}")); + assert_eq!( + f.matches("call i64 @js_object_alloc(").count(), + 1, + "exactly one allocating element in @main:\n{f}" + ); + assert_eq!( + f.matches(handle_pat).count(), + 2, + "element 0's handle must be loaded twice in @main: the original \ + lowering and the re-derivation below element 1's allocation:\n{f}" + ); + let loads: Vec = f.match_indices(handle_pat).map(|(i, _)| i).collect(); + let element_alloc = f.find("call i64 @js_object_alloc(").unwrap(); assert!( - loads.iter().any(|&l| l > element_alloc), - "#7114: element 0's handle must be re-derived below element 1's \ - allocation before it is stored into the array:\n{ir}" + loads[0] < element_alloc && loads[1] > element_alloc, + "#7114: one load above element 1's allocation and one below it, so the \ + value stored into the array is the post-relocation address:\n{f}" ); } @@ -593,20 +654,33 @@ fn wtf8_literal_operand_is_rooted_not_merely_reused() { })], ); - let push = ir - .find("call i32 @js_gc_temp_root_push") - .unwrap_or_else(|| panic!("a WTF-8 literal operand must be ROOTED:\n{ir}")); - let alloc = ir - .find("call i64 @js_object_alloc(") - .unwrap_or_else(|| panic!("the sibling operand must allocate:\n{ir}")); - let get = ir - .find("call i64 @js_gc_temp_root_get") - .unwrap_or_else(|| panic!("and re-read after it:\n{ir}")); + let f = init_ir(&ir); + assert_eq!( + f.matches("call i32 @js_gc_temp_root_push").count(), + 1, + "exactly one temp root in @main — the WTF-8 operand's. Zero means it \ + was suppressed without a compensating re-derivation (#7114 for \ + lone-surrogate literals); more than one means this assertion is no \ + longer about the operand it names:\n{f}" + ); + assert_eq!( + f.matches("call i64 @js_gc_temp_root_get").count(), + 1, + "and exactly one re-read of it:\n{f}" + ); + assert_eq!( + f.matches("call i64 @js_object_alloc(").count(), + 1, + "exactly one allocating sibling in @main:\n{f}" + ); + let push = f.find("call i32 @js_gc_temp_root_push").unwrap(); + let alloc = f.find("call i64 @js_object_alloc(").unwrap(); + let get = f.find("call i64 @js_gc_temp_root_get").unwrap(); assert!( push < alloc && alloc < get, "order must be push -> allocating sibling -> re-read. Anything else \ means the WTF-8 literal is being carried across the collection point \ - in a register, which is #7114 for lone-surrogate literals:\n{ir}" + in a register, which is #7114 for lone-surrogate literals:\n{f}" ); } From 3b606797f2f43dbe7bac53aaa1a6b99cc8e86281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 07:35:34 +0200 Subject: [PATCH 2/2] test(gc): make the IR-slice narrowing itself falsifiable Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9 --- .../tests/temp_root_operand_temporaries.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index 5e11306abb..41310a2e9a 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -488,6 +488,51 @@ fn init_ir(ir: &str) -> &str { function_ir(ir, "define i32 @main(") } +/// The scoping itself, made falsifiable. +/// +/// Everything below rests on `init_ir` genuinely narrowing the search. If it +/// silently returned the whole module the ordering assertions would go back to +/// being satisfiable by an unrelated function's IR and nothing else in this file +/// would notice — so the narrowing is asserted directly: the slice is one +/// function, strictly smaller than the module, and it does NOT reach +/// `__perry_init_strings_*`, which is the other function in these modules that +/// touches the same handle globals. +#[test] +fn init_ir_slices_only_the_function_under_test() { + let ir = ir_for( + "scope_check.ts", + vec![Stmt::Expr(Expr::Binary { + op: perry_hir::BinaryOp::Add, + left: Box::new(Expr::String("acc:".to_string())), + right: Box::new(allocating_numeric()), + })], + ); + let f = init_ir(&ir); + + assert!( + f.starts_with("define i32 @main("), + "the slice must begin at the function it names:\n{f}" + ); + assert_eq!( + f.matches("\ndefine ").count(), + 0, + "and end before the next one — exactly one function in the slice:\n{f}" + ); + assert!( + f.len() < ir.len(), + "a slice the size of the module is not a slice" + ); + assert!( + ir.contains("define void @__perry_init_strings_"), + "precondition: the module really does contain another function that \ + touches the same handle globals, otherwise this test proves nothing" + ); + assert!( + !f.contains("__perry_init_strings_scope_check_ts_chunk"), + "and the slice must not reach it (#7116):\n{f}" + ); +} + /// `"acc:" + ` — the reported shape. /// /// The invariant: **no operand register may outlive a collection point.** The