From a20c9674fb6c28be01ee3a77e6eb4e6cd4155244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 04:22:22 +0200 Subject: [PATCH 1/2] fix(codegen): keep RS4GC off the inline-asm loop barrier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue-#74 loop-preservation barrier is `call void asm sideeffect "", ""()`. rewrite-statepoints-for-gc rewrites every non-leaf call in a `gc "statepoint"` function into a gc.statepoint, and for inline asm that means using the InlineAsm itself as the statepoint's callee operand — invalid IR, rejected by the verifier with "Cannot take the address of an inline asm!". optimize_and_emit verified the module before the rewrite but not after, so the broken module reached SelectionDAG and killed the compiler with SIGBUS inside AArch64TargetLowering::LowerCall. next@16.3.0's bundled jsonwebtoken died this way 100 modules into a 104-module production App Route. Mark the barrier "gc-leaf-function" on both emission paths, and verify after RS4GC so this class is a clean error naming the pass rather than a crash. Fixes #8121. Refs #8040. --- .../8121-rs4gc-inline-asm-statepoint.md | 9 +++ crates/perry-codegen/src/dialect/mod.rs | 33 +++++++- crates/perry-codegen/src/inprocess.rs | 14 ++++ crates/perry-codegen/src/inst.rs | 5 +- crates/perry-codegen/src/module.rs | 6 ++ crates/perry-codegen/src/native_emit.rs | 77 +++++++++++++++++++ 6 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 changelog.d/8121-rs4gc-inline-asm-statepoint.md diff --git a/changelog.d/8121-rs4gc-inline-asm-statepoint.md b/changelog.d/8121-rs4gc-inline-asm-statepoint.md new file mode 100644 index 0000000000..9946882fed --- /dev/null +++ b/changelog.d/8121-rs4gc-inline-asm-statepoint.md @@ -0,0 +1,9 @@ +`perry compile` no longer SIGBUSes on production Next.js bundles that contain a preserved loop. + +Perry emits `call void asm sideeffect "", ""()` as the issue-#74 loop-preservation barrier. `rewrite-statepoints-for-gc` rewrites every non-leaf call in a `gc "statepoint"` function into a `gc.statepoint`, and for inline asm that means using the `InlineAsm` itself as the statepoint's callee operand — invalid IR, rejected by the verifier with `Cannot take the address of an inline asm!`. + +`optimize_and_emit` verified the module *before* the rewrite but not after, so the broken module went straight to SelectionDAG, which dereferenced the bogus callee and killed the compiler with `SIGBUS` inside `AArch64TargetLowering::LowerCall`. Compiling next@16.3.0's bundled `jsonwebtoken` died this way 100 modules into a 104-module production App Route, blocking #8040. + +The barrier now carries `"gc-leaf-function"` on both the native and text emission paths, which is the documented way to tell RS4GC a call cannot trigger a collection — true by construction for a barrier that emits no instructions. `optimize_and_emit` additionally verifies *after* RS4GC, so this class of bug is a clean compile error naming the pass instead of a crash that takes the compiler process down. + +Fixes #8121. Refs #8040. diff --git a/crates/perry-codegen/src/dialect/mod.rs b/crates/perry-codegen/src/dialect/mod.rs index 2082a56410..17e9ee28ba 100644 --- a/crates/perry-codegen/src/dialect/mod.rs +++ b/crates/perry-codegen/src/dialect/mod.rs @@ -861,9 +861,11 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { let ptr = self.ctx .create_inline_asm(void_fn, asm, constraints, sideeffect, false, None, false); - self.builder + let site = self + .builder .build_indirect_call(void_fn, ptr, &[], "") .map_err(be)?; + mark_gc_leaf(self.ctx, site); Ok(()) } @@ -1542,9 +1544,11 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { None, false, ); - self.builder + let site = self + .builder .build_indirect_call(void_fn, ptr, &[], "") .map_err(be)?; + mark_gc_leaf(self.ctx, site); Ok(()) } I::Br { label } => { @@ -1818,3 +1822,28 @@ fn apply_flags(inst: Option>, flags: &[&str]) { unsafe { llvm_sys::core::LLVMSetFastMathFlags(inst.as_value_ref(), fmf) }; } } + +/// #8121: tell `rewrite-statepoints-for-gc` to leave an inline-asm call alone. +/// +/// Perry emits `call void asm sideeffect "", ""()` as the issue-#74 loop +/// preservation barrier. RS4GC rewrites every non-leaf call in a `gc +/// "statepoint"` function into a `gc.statepoint` whose callee operand is the +/// original callee — and for inline asm that means taking the address of an +/// `InlineAsm`, which is not a value. The result fails the verifier with +/// "Cannot take the address of an inline asm!", and because production only +/// verifies BEFORE the rewrite, the broken module reached SelectionDAG and +/// SIGBUS'd in `AArch64TargetLowering::LowerCall` while lowering the bogus +/// statepoint callee. +/// +/// `"gc-leaf-function"` is the documented way to say "this call cannot trigger +/// a collection", which is exactly true of an empty barrier: it emits no +/// instructions and cannot call back into the runtime. +fn mark_gc_leaf<'ctx>( + ctx: &'ctx inkwell::context::Context, + site: inkwell::values::CallSiteValue<'ctx>, +) { + site.add_attribute( + inkwell::attributes::AttributeLoc::Function, + ctx.create_string_attribute("gc-leaf-function", ""), + ); +} diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index 7d726a630a..c005facfd9 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -412,6 +412,20 @@ fn optimize_and_emit( e.to_string() ) })?; + // #8121: verify AFTER the rewrite, not only before it. RS4GC can turn + // a module the verifier accepted into one it rejects (it rewrote an + // inline-asm barrier into a statepoint whose callee is an InlineAsm). + // Production previously verified only the input, so the broken module + // went straight to SelectionDAG and took the whole compiler down with + // a SIGBUS instead of reporting anything. A crash inside this process + // is exactly what the funclet refusal above exists to avoid. + module.verify().map_err(|e| { + anyhow!( + "rewrite-statepoints-for-gc produced a module the LLVM verifier \ + rejects; refusing to hand it to codegen (#8121):\n{}", + e.to_string() + ) + })?; } let pipeline = match opt { diff --git a/crates/perry-codegen/src/inst.rs b/crates/perry-codegen/src/inst.rs index 89b9ce0e21..e377c5e292 100644 --- a/crates/perry-codegen/src/inst.rs +++ b/crates/perry-codegen/src/inst.rs @@ -277,7 +277,10 @@ impl LlInst { out.push(')'); } LlInst::AsmBarrier => { - out.push_str(" call void asm sideeffect \"\", \"\"()"); + // #8121: `#5` is `{ "gc-leaf-function" }`, which stops + // rewrite-statepoints-for-gc from rewriting this barrier into + // a statepoint whose callee is an InlineAsm (invalid IR). + out.push_str(" call void asm sideeffect \"\", \"\"() #5"); } LlInst::Br { label } => { let _ = write!(out, " br label %{label}"); diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 4f05e3ad0a..a8426a0295 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -799,6 +799,12 @@ impl LlModule { if used_nounwind_willreturn { ir.push_str("\nattributes #4 = { nounwind willreturn }\n"); } + // #8121: the issue-#74 loop barrier carries `#5` so RS4GC treats it as + // a GC leaf. Emitted only when a barrier actually rendered, so modules + // without one keep byte-identical IR. + if ir.contains("call void asm sideeffect \"\", \"\"() #5") { + ir.push_str("\nattributes #5 = { \"gc-leaf-function\" }\n"); + } } fn push_attrs_and_metadata(&self, ir: &mut String) { diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index fe41b97e27..6453509a63 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -679,6 +679,83 @@ mod tests { } } + /// #8121: the issue-#74 loop-preservation barrier is inline asm, and RS4GC + /// rewrites every non-leaf call in a `gc "statepoint"` function into a + /// `gc.statepoint`. For inline asm that means taking the address of an + /// `InlineAsm`, which is not a value: the verifier rejects it with "Cannot + /// take the address of an inline asm!". Production verified only BEFORE the + /// rewrite, so the broken module reached SelectionDAG and took the whole + /// compiler down with a SIGBUS in `AArch64TargetLowering::LowerCall` while + /// compiling next@16.3.0's bundled `jsonwebtoken` (100 of 104 modules in). + /// + /// `statepoint_rewritten_ir` verifies AFTER the rewrite, which is exactly + /// the check that was missing, so this fails without the `gc-leaf-function` + /// attribute on the barrier. + fn asm_barrier_rs4gc_fixture() -> LlModule { + let mut module = LlModule::new(crate::codegen::default_target_triple()); + module.declare_function_with_ret_attrs("js_shadow_frame_enter", PTR, &[I32], "nonnull"); + module.declare_function("js_shadow_frame_pop", VOID, &[I64]); + module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]); + module.declare_function("js_map_alloc", I64, &[I32]); + module.declare_function("may_collect", I64, &[]); + + let function = module.define_function("asm_barrier_gc_fixture", I64, vec![]); + function.enable_shadow_frame(0); + let root_index = function + .reserve_shadow_slot() + .expect("asm-barrier fixture reserves a precise-root slot"); + let root = function.alloca_entry(I64); + function.entry_allocas_push_store(I64, "0", &root); + function.entry_setup_call_void( + "js_shadow_slot_bind", + &[(I32, &root_index.to_string()), (PTR, &root)], + ); + + let entry = function.create_block("entry"); + let dynamic = entry.call(I64, "js_map_alloc", &[(I32, "0")]); + entry.store(I64, &dynamic, &root); + // The barrier under test, in a function RS4GC will rewrite. + entry.asm_sideeffect_barrier(); + let _safepoint = entry.call(I64, "may_collect", &[]); + let observed = entry.load(I64, &root); + entry.ret(I64, &observed); + module + } + + #[test] + fn asm_barrier_is_a_gc_leaf_so_rs4gc_leaves_it_alone() { + let module = asm_barrier_rs4gc_fixture(); + let target = crate::codegen::default_target_triple(); + let text_ir = module.to_ir(); + let context = Context::create(); + let native_ir = build_native_module(&context, &module) + .expect("asm-barrier fixture constructs") + .print_to_string() + .to_string(); + for (arm, ir) in [("text", text_ir), ("native", native_ir)] { + assert!( + ir.contains("asm sideeffect"), + "{arm} arm never emitted the barrier, so this proves nothing" + ); + let rewritten = crate::inprocess::statepoint_rewritten_ir( + &ir, + &target, + &format!("asm_barrier_{arm}"), + ) + .unwrap_or_else(|e| { + panic!("{arm} arm: RS4GC must not turn the asm barrier into invalid IR: {e:#}") + }); + assert!( + !rewritten.contains("elementtype(void ()) asm"), + "{arm} arm: RS4GC used the inline-asm barrier as a statepoint callee:\n{rewritten}" + ); + assert!( + rewritten.contains("asm sideeffect"), + "{arm} arm: the barrier vanished instead of being left alone" + ); + } + } + fn compact_gc_map_section_name() -> &'static [u8] { if cfg!(target_os = "macos") { b"__perry_gcmap" From a116e58f737003f97a89439c4f75514c3dd945db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:10:41 +0200 Subject: [PATCH 2/2] test(codegen): pin the RS4GC inline-asm barrier bug and its emission Three tests, split so no half can pass hollowly: - `rs4gc_breaks_an_unmarked_inline_asm_barrier` pins the defect itself: without "gc-leaf-function", RS4GC produces IR the verifier rejects with "Cannot take the address of an inline asm!". If this ever stops failing, the marking has become unnecessary and its sibling is vacuous. - `a_gc_leaf_inline_asm_barrier_survives_rs4gc` covers the cure, and asserts `gc.statepoint` is present first so a fixture RS4GC never touched cannot pass by doing nothing. (An earlier attempt did exactly that: it used enable_shadow_frame, so the function carried no gc strategy, RS4GC rewrote nothing, and the test passed with the fix reverted.) - `perry_emits_the_loop_barrier_as_a_gc_leaf` covers the emission on both the text and native paths. The two above use hand-written IR and would stay green if Perry stopped emitting the attribute, so this is the half that catches a revert of the fix. Refs #8121. --- crates/perry-codegen/src/inprocess.rs | 64 ++++++++++++++++ crates/perry-codegen/src/native_emit.rs | 97 ++++++++----------------- 2 files changed, 95 insertions(+), 66 deletions(-) diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index c005facfd9..0ef993d923 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -455,6 +455,70 @@ fn optimize_and_emit( mod tests { use super::*; + /// #8121: Perry emits `call void asm sideeffect "", ""()` as the issue-#74 + /// loop-preservation barrier. RS4GC rewrites every non-leaf call in a + /// `gc "statepoint-example"` function into a `gc.statepoint`, and for inline + /// asm that means using the `InlineAsm` itself as the callee operand — + /// invalid IR ("Cannot take the address of an inline asm!"). Production + /// verified only BEFORE the rewrite, so the broken module reached + /// SelectionDAG and killed the compiler with a SIGBUS in + /// `AArch64TargetLowering::LowerCall`. + /// + /// `%p` stays live across `@may_collect`, so RS4GC has real work to do and + /// a fixture that rewrote nothing cannot pass either arm silently. + fn asm_barrier_fixture(gc_leaf: bool) -> String { + let barrier_attr = if gc_leaf { " #5" } else { "" }; + format!( + "declare i64 @may_collect()\n\ + \n\ + define ptr addrspace(1) @barrier_fn(ptr addrspace(1) %p) gc \"statepoint-example\" {{\n\ + entry:\n\ + \x20 call void asm sideeffect \"\", \"\"(){barrier_attr}\n\ + \x20 %r = call i64 @may_collect()\n\ + \x20 ret ptr addrspace(1) %p\n\ + }}\n\ + \n\ + attributes #5 = {{ \"gc-leaf-function\" }}\n" + ) + } + + /// The bug itself, pinned. If this ever stops failing, the `gc-leaf-function` + /// marking has become unnecessary and the sibling test below is vacuous. + #[test] + fn rs4gc_breaks_an_unmarked_inline_asm_barrier() { + let target = crate::codegen::default_target_triple(); + let err = + statepoint_rewritten_ir(&asm_barrier_fixture(false), &target, "asm_barrier_unmarked") + .expect_err("RS4GC must reject an unmarked inline-asm barrier (#8121)"); + let text = format!("{err:#}"); + assert!( + text.contains("inline asm"), + "expected the inline-asm verifier rejection, got: {text}" + ); + } + + /// The fix: marked `gc-leaf-function`, the barrier is left alone and the + /// rewrite still happens for the genuinely collecting call. + #[test] + fn a_gc_leaf_inline_asm_barrier_survives_rs4gc() { + let target = crate::codegen::default_target_triple(); + let rewritten = + statepoint_rewritten_ir(&asm_barrier_fixture(true), &target, "asm_barrier_gc_leaf") + .expect("a gc-leaf inline-asm barrier must survive RS4GC (#8121)"); + assert!( + rewritten.contains("gc.statepoint"), + "RS4GC rewrote nothing, so this fixture proves nothing:\n{rewritten}" + ); + assert!( + !rewritten.contains("elementtype(void ()) asm"), + "the inline-asm barrier was used as a statepoint callee:\n{rewritten}" + ); + assert!( + rewritten.contains("asm sideeffect"), + "the barrier vanished instead of being left alone:\n{rewritten}" + ); + } + fn constant_fold_order_fixture(folded: bool) -> String { let mut ir = String::from( "declare i64 @may_collect()\n\ndefine i64 @f(i64 %d0, i64 %d1, i64 %d2, i64 %d3, i64 %d4, i64 %d5, i64 %d6, i64 %d7) gc \"statepoint-example\" {\nentry:\n", diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index 6453509a63..cdc51ca42e 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -679,81 +679,46 @@ mod tests { } } - /// #8121: the issue-#74 loop-preservation barrier is inline asm, and RS4GC - /// rewrites every non-leaf call in a `gc "statepoint"` function into a - /// `gc.statepoint`. For inline asm that means taking the address of an - /// `InlineAsm`, which is not a value: the verifier rejects it with "Cannot - /// take the address of an inline asm!". Production verified only BEFORE the - /// rewrite, so the broken module reached SelectionDAG and took the whole - /// compiler down with a SIGBUS in `AArch64TargetLowering::LowerCall` while - /// compiling next@16.3.0's bundled `jsonwebtoken` (100 of 104 modules in). - /// - /// `statepoint_rewritten_ir` verifies AFTER the rewrite, which is exactly - /// the check that was missing, so this fails without the `gc-leaf-function` - /// attribute on the barrier. - fn asm_barrier_rs4gc_fixture() -> LlModule { + /// #8121, emission half. The sibling pair in `inprocess::tests` proves the + /// LLVM mechanism (RS4GC breaks an unmarked inline-asm barrier, and + /// `gc-leaf-function` stops it) using hand-written IR, so it would still + /// pass if Perry stopped emitting the attribute. This asserts the emission + /// itself, on both paths. + #[test] + fn perry_emits_the_loop_barrier_as_a_gc_leaf() { let mut module = LlModule::new(crate::codegen::default_target_triple()); - module.declare_function_with_ret_attrs("js_shadow_frame_enter", PTR, &[I32], "nonnull"); - module.declare_function("js_shadow_frame_pop", VOID, &[I64]); - module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]); - module.declare_function("js_map_alloc", I64, &[I32]); - module.declare_function("may_collect", I64, &[]); - - let function = module.define_function("asm_barrier_gc_fixture", I64, vec![]); - function.enable_shadow_frame(0); - let root_index = function - .reserve_shadow_slot() - .expect("asm-barrier fixture reserves a precise-root slot"); - let root = function.alloca_entry(I64); - function.entry_allocas_push_store(I64, "0", &root); - function.entry_setup_call_void( - "js_shadow_slot_bind", - &[(I32, &root_index.to_string()), (PTR, &root)], - ); - + let function = module.define_function("barrier_emission_fixture", VOID, vec![]); let entry = function.create_block("entry"); - let dynamic = entry.call(I64, "js_map_alloc", &[(I32, "0")]); - entry.store(I64, &dynamic, &root); - // The barrier under test, in a function RS4GC will rewrite. entry.asm_sideeffect_barrier(); - let _safepoint = entry.call(I64, "may_collect", &[]); - let observed = entry.load(I64, &root); - entry.ret(I64, &observed); - module - } + entry.ret_void(); - #[test] - fn asm_barrier_is_a_gc_leaf_so_rs4gc_leaves_it_alone() { - let module = asm_barrier_rs4gc_fixture(); - let target = crate::codegen::default_target_triple(); let text_ir = module.to_ir(); + assert!( + text_ir.contains("asm sideeffect"), + "fixture emitted no barrier, so this proves nothing:\n{text_ir}" + ); + assert!( + text_ir.contains(r#"attributes #5 = { "gc-leaf-function" }"#), + "text path lost the gc-leaf attribute group (#8121):\n{text_ir}" + ); + assert!( + text_ir.contains(r#"call void asm sideeffect "", ""() #5"#), + "text path barrier is not marked #5 (#8121):\n{text_ir}" + ); + let context = Context::create(); let native_ir = build_native_module(&context, &module) - .expect("asm-barrier fixture constructs") + .expect("barrier emission fixture constructs") .print_to_string() .to_string(); - for (arm, ir) in [("text", text_ir), ("native", native_ir)] { - assert!( - ir.contains("asm sideeffect"), - "{arm} arm never emitted the barrier, so this proves nothing" - ); - let rewritten = crate::inprocess::statepoint_rewritten_ir( - &ir, - &target, - &format!("asm_barrier_{arm}"), - ) - .unwrap_or_else(|e| { - panic!("{arm} arm: RS4GC must not turn the asm barrier into invalid IR: {e:#}") - }); - assert!( - !rewritten.contains("elementtype(void ()) asm"), - "{arm} arm: RS4GC used the inline-asm barrier as a statepoint callee:\n{rewritten}" - ); - assert!( - rewritten.contains("asm sideeffect"), - "{arm} arm: the barrier vanished instead of being left alone" - ); - } + assert!( + native_ir.contains("asm sideeffect"), + "native arm emitted no barrier, so this proves nothing:\n{native_ir}" + ); + assert!( + native_ir.contains("gc-leaf-function"), + "native path lost the gc-leaf attribute on the barrier (#8121):\n{native_ir}" + ); } fn compact_gc_map_section_name() -> &'static [u8] {