From febd3c852fbfdd65089346733b7d0bb7ce7a3615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 20:32:12 +0200 Subject: [PATCH] fix(gc): the statepoint bridge must refuse an invoke it cannot root (#7327) --- changelog.d/7330-bridge-refuses-invoke.md | 24 +++++++++ .../src/function/precise_roots.rs | 54 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 changelog.d/7330-bridge-refuses-invoke.md diff --git a/changelog.d/7330-bridge-refuses-invoke.md b/changelog.d/7330-bridge-refuses-invoke.md new file mode 100644 index 0000000000..3865c9800a --- /dev/null +++ b/changelog.d/7330-bridge-refuses-invoke.md @@ -0,0 +1,24 @@ +The explicit statepoint bridge emitted no statepoint on `invoke`, so every call +inside a `try` carried no GC roots. + +`is_call` matched only `call`/`tail call`, so an `invoke` skipped both the +statepoint conversion *and* the fail-closed panic below it, and passed through as +an ordinary line. Since #7302 moved exception lowering to `invoke`/`landingpad`, +that is every call inside a `try`. Measured on one program: 58 invokes, 0 +carrying `gc.statepoint`, with allocating callees among them +(`js_object_alloc_class_inline_keys`, `js_array_push_f64`, +`js_native_call_method_by_id`). + +`--statepoint-report` was silent about it too, because it counts only the lines +it recognises — "0 parser fallbacks" said nothing about any call inside a `try`, +including in #7314's headline census. + +Forming a statepoint from an invoke is real work: the statepoint must itself +become an invoke, with `gc.result` and the relocates in the normal successor. +Until the bridge does that, it now refuses — the same fail-closed rule the plain +stack-map fallback was deleted for (#7314). Invokes are also counted by the +report, so the census stops overstating its coverage. + +Note this leaves no working statepoint path for try-carrying code on a default +toolchain: RS4GC handles invokes but requires `PERRY_LLVM_CLANG` pointing at a +version-matched LLVM 22, since Apple clang rejects the IR it emits. diff --git a/crates/perry-codegen/src/function/precise_roots.rs b/crates/perry-codegen/src/function/precise_roots.rs index 6531cc17c5..401155b1cf 100644 --- a/crates/perry-codegen/src/function/precise_roots.rs +++ b/crates/perry-codegen/src/function/precise_roots.rs @@ -679,6 +679,60 @@ pub(super) fn lower_precise_roots_to_native_stack( || trimmed.contains(" = call ") || trimmed.starts_with("tail call ") || trimmed.contains(" = tail call "); + // #7327: an `invoke` is a call with two successors. Since #7302 moved + // exception lowering to `invoke`/`landingpad`, EVERY call inside a + // `try` is one — and none matched `is_call`, so they skipped both the + // statepoint conversion and the fail-closed panic below, passing + // through as ordinary lines. Measured on one program: 58 invokes, 0 + // carrying `gc.statepoint`, with allocating callees among them + // (`js_object_alloc_class_inline_keys`, `js_array_push_f64`, + // `js_native_call_method_by_id`). Those frames had no roots at all, + // and `--statepoint-report` was silent because it only counts lines it + // recognises — "0 parser fallbacks" said nothing about any call inside + // a `try`. + // + // Forming a statepoint FROM an invoke is real work: the statepoint must + // itself become an invoke, with `gc.result` and the relocates in the + // normal successor. RS4GC already does it correctly. Until the bridge + // does, refuse — same fail-closed rule the plain-stackmap fallback was + // deleted for (#7314). + let is_invoke = trimmed.starts_with("invoke ") || trimmed.contains(" = invoke "); + if is_invoke && backend == PreciseRootBackend::Statepoint { + let active = active_slots.get(line_idx).and_then(Option::as_ref); + let live_here: Vec<&String> = slot_roots + .iter() + .enumerate() + .filter(|(idx, _)| active.is_some_and(|slots| slots.contains(idx))) + .filter_map(|(_, ptr)| ptr.as_ref()) + .filter(|ptr| available.contains(*ptr) && initialized.contains(*ptr)) + .collect(); + let callee = direct_callee_name(line); + let compiler_only = callee.is_some_and(|c| c.starts_with("llvm.")); + let cannot_collect = callee.is_some_and(|c| { + matches!( + crate::gc_call_effects::classify_direct_callee(c), + crate::gc_call_effects::GcCallEffect::CannotCollect + | crate::gc_call_effects::GcCallEffect::NeverReturns + ) + }); + if let Some(report) = report.as_mut() { + report.note_call(live_here.len()); + } + if !live_here.is_empty() && !compiler_only && !cannot_collect { + panic!( + "perry: native-root lowering cannot yet express a safepoint on an \ + `invoke` — `{}` in @{} has {} live root(s) across it. Since #7302 \ + every call inside a `try` is an invoke, so emitting it unchanged \ + would leave those roots invisible to the collector (#7327). \ + PERRY_RS4GC=1 handles invokes, but needs PERRY_LLVM_CLANG pointing \ + at a version-matched LLVM 22 (Apple clang rejects the IR it emits). \ + Otherwise compile this module without PERRY_STATEPOINTS.", + callee.unwrap_or(""), + function_name, + live_here.len(), + ); + } + } if !is_call || trimmed.contains("@llvm.experimental.stackmap") { continue; }