From 564dbbf390138848ee932c7a698438d8c95460b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 17:16:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(codegen):=20Layer=201=20prototype=20?= =?UTF-8?q?=E2=80=94=20rooting=20by=20construction,=20with=20the=20RFC's?= =?UTF-8?q?=20API=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RFC (docs/src/internals/rfc-rooting-by-construction.md) proposes three types and one rule, and rests on a claim it cannot check on paper: that the borrow checker rejects the #7341 bug shape. This prototypes the types and turns that claim into two doctests that cargo test executes. It also found a defect in the RFC's proposed API. It spells the constructor let obj = e.emit_call(...).root(&mut e, &mut frame); which does not compile: the returned handle already borrows the emitter, so asking for a second, mutable borrow is E0499. Corrected here by having Raw carry the shared reborrow and root(self) consume it. Anyone starting the migration from the RFC as written would have hit this on line one. Validation, and each part is checked rather than asserted: * the bug shape is rejected -- compile_fail doctest * pinned to E0499, so it cannot pass on an unrelated typo * sabotage-tested: rewriting the bad example into the good one makes that doctest FAIL, so it is not vacuously green * the correct form compiles, and emits root_store BEFORE the window and root_load AFTER it -- the ordering that every #7341 fix turned on Prototype only: not wired into any lowering path. The migration cost is the RFC's open question and is unchanged by this. --- crates/perry-codegen/src/lib.rs | 1 + crates/perry-codegen/src/rooting.rs | 240 ++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 crates/perry-codegen/src/rooting.rs diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index b509f926c4..9e38656578 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -33,6 +33,7 @@ pub(crate) mod native_value; pub(crate) mod nm_install; pub mod opt_report; pub(crate) mod root_reload; +pub mod rooting; pub mod runtime_decls; pub mod statepoint_report; pub(crate) mod stmt; diff --git a/crates/perry-codegen/src/rooting.rs b/crates/perry-codegen/src/rooting.rs new file mode 100644 index 0000000000..a35cedc68d --- /dev/null +++ b/crates/perry-codegen/src/rooting.rs @@ -0,0 +1,240 @@ +//! Layer 1 prototype: rooting by construction (`docs/src/internals/rfc-rooting-by-construction.md`). +//! +//! **Status: prototype, not yet on any lowering path.** It exists to settle the +//! one question the RFC could not answer on paper — *does the borrow checker +//! actually reject the bug shape?* — before anyone pays the migration cost of +//! threading these types through `perry-codegen`. The `compile_fail` doctests +//! below are the answer, and they are executed by `cargo test`, so this claim +//! cannot rot into prose the way the RFC's example could. +//! +//! # The shape it has to reject +//! +//! Every bug in the #7341 family is one sentence: *a GC-managed pointer is held +//! in a register across a point where the collector can run.* #7453 is the most +//! recent — `js_url_coerce_string` returns a raw `StringHeader`, `base` lowers +//! (arbitrary user code), a second coercion allocates, and only then is the +//! first pointer used. +//! +//! Today `LlBlock::call` takes `&mut self` but returns an owned `String`, so the +//! borrow ends at the semicolon and nothing stops that register from being used +//! ten collection points later. The entire fix is to return a value that *keeps* +//! the borrow. +//! +//! # Three types and one rule +//! +//! [`Plain`] is anything the collector does not manage — an `i32`, a length, a +//! slot index. Freely cloneable, no borrow. +//! +//! [`Raw`] is a register holding a GC-managed value that is **not** rooted. It +//! borrows the emitter immutably, and is neither `Clone` nor `Copy`. +//! +//! [`Rooted`] is a slot the collector knows about. It survives collection +//! points, and cannot be read except through [`Rooted::get`], which hands back a +//! fresh [`Raw`] — so "re-read after every collection point", which +//! `expr/temp_root.rs` can only state in prose today, becomes the only thing +//! that type-checks. +//! +//! The rule needs no new machinery. Emitting something that can collect takes +//! `&mut`, which ends every outstanding [`Raw`] borrow: +//! +//! ```compile_fail,E0499 +//! # use perry_codegen::rooting::{RootingEmitter, Raw}; +//! # fn demo(e: &mut RootingEmitter) { +//! // #7453's shape: coerce, then lower `base` (which can collect), then use +//! // the first pointer. +//! let url_ptr = e.emit_collecting("js_url_coerce_string"); +//! let base_ptr = e.emit_collecting("js_url_coerce_string"); +//! // ERROR[E0499]: `url_ptr` still borrows `e`, which is mutably borrowed above. +//! e.emit_use(&url_ptr, &base_ptr); +//! # } +//! ``` +//! +//! The correct code is also the shortest way out of that error — root it, then +//! re-read after the window: +//! +//! ``` +//! # use perry_codegen::rooting::RootingEmitter; +//! # fn demo(e: &mut RootingEmitter) { +//! let url = e.emit_collecting("js_url_coerce_string").root(); +//! let base = e.emit_collecting("js_url_coerce_string").root(); +//! e.emit_use(&url.get(e), &base.get(e)); +//! # } +//! ``` +//! +//! # What it cannot catch +//! +//! Anything not expressed through this emitter: runtime-side Rust (layer 3), a +//! raw pointer cached in a side table, or a value the collector moves that never +//! passes through a `Raw`. The RFC's "What it cannot catch" section is the +//! authority; this prototype does not widen it. + +/// A register holding something the collector does not manage — an `i32`, a +/// length, a slot index. No borrow, freely cloneable. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Plain(pub String); + +/// A register holding a GC-managed value that is **not** rooted. +/// +/// Borrows the emitter immutably, so it cannot outlive the next emission that +/// can collect. Deliberately neither `Clone` nor `Copy`: cloning one would let a +/// copy escape the borrow that makes it safe. +#[derive(Debug)] +pub struct Raw<'e> { + reg: String, + /// The emitter this register was produced by. Carrying the reference here + /// rather than taking a fresh one in [`Raw::root`] is load-bearing: the RFC + /// spells that method `root(self, e: &mut Emitter)`, which **cannot + /// compile** — `self` already holds a borrow of the emitter, so asking for + /// a second one is `E0499`. Storing the shared reborrow lets `root` consume + /// the handle without re-borrowing. + emitter: &'e RootingEmitter, +} + +impl<'e> Raw<'e> { + /// The SSA name. Safe to read *now* — the borrow proves no collection point + /// has intervened since it was produced. + pub fn reg(&self) -> &str { + &self.reg + } + + /// Consume this register into a root. The only way to obtain a [`Rooted`], + /// which is what forces the root to be taken *before* the window rather + /// than after — the ordering error in #7184, #7192 and #7453 alike. + pub fn root(self) -> Rooted { + Rooted { + slot: self.emitter.emit_root_store(&self.reg), + } + } +} + +/// A slot the collector knows about. Survives collection points. +#[derive(Debug, Clone)] +pub struct Rooted { + slot: String, +} + +impl Rooted { + /// Re-read the slot, yielding a [`Raw`] valid until the next collecting + /// emission. There is deliberately no way to keep the result across one: + /// a cached read is the second half of the bug, and it does not type-check. + pub fn get<'e>(&self, e: &'e RootingEmitter) -> Raw<'e> { + Raw { + reg: e.emit_root_load(&self.slot), + emitter: e, + } + } + + /// The slot index, for diagnostics. + pub fn slot(&self) -> &str { + &self.slot + } +} + +/// Prototype emitter. Records emissions instead of writing IR — the point here +/// is the *signatures*, which is what the borrow checker reads. +#[derive(Debug, Default)] +pub struct RootingEmitter { + ops: std::cell::RefCell>, + next: std::cell::Cell, +} + +impl RootingEmitter { + pub fn new() -> Self { + Self::default() + } + + fn fresh(&self) -> String { + let n = self.next.get(); + self.next.set(n + 1); + format!("%r{n}") + } + + /// Emit something that **cannot** collect. Takes `&self`, so outstanding + /// [`Raw`] handles stay valid across it. + pub fn emit_pure(&self, op: &str) -> Plain { + let r = self.fresh(); + self.ops.borrow_mut().push(format!("{r} = pure {op}")); + Plain(r) + } + + /// Emit something that **can** collect. Takes `&mut self`, which ends every + /// outstanding [`Raw`] borrow — that single signature is the whole rule. + pub fn emit_collecting(&mut self, callee: &str) -> Raw<'_> { + let r = self.fresh(); + self.ops.borrow_mut().push(format!("{r} = call {callee}")); + Raw { + reg: r, + emitter: self, + } + } + + /// Consume two live registers. Takes `&self`: using values is not a + /// collection point, so this must not invalidate anything. + pub fn emit_use(&self, a: &Raw<'_>, b: &Raw<'_>) -> Plain { + let r = self.fresh(); + self.ops + .borrow_mut() + .push(format!("{r} = use {} {}", a.reg(), b.reg())); + Plain(r) + } + + fn emit_root_store(&self, reg: &str) -> String { + let s = self.fresh(); + self.ops + .borrow_mut() + .push(format!("{s} = root_store {reg}")); + s + } + + fn emit_root_load(&self, slot: &str) -> String { + let r = self.fresh(); + self.ops + .borrow_mut() + .push(format!("{r} = root_load {slot}")); + r + } + + /// The emitted sequence, for tests. + pub fn ops(&self) -> Vec { + self.ops.borrow().clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rooted form emits root-store, the collecting call, then root-load + /// — root BEFORE the window, re-read AFTER it. That ordering is the fix in + /// every #7341 bug; getting it backwards roots an already-stale pointer. + #[test] + fn rooting_emits_store_before_the_window_and_load_after() { + let mut e = RootingEmitter::new(); + let url = e.emit_collecting("js_url_coerce_string").root(); + let base = e.emit_collecting("js_url_coerce_string").root(); + e.emit_use(&url.get(&e), &base.get(&e)); + + let ops = e.ops(); + let store = ops.iter().position(|o| o.contains("root_store")).unwrap(); + let second_call = ops + .iter() + .enumerate() + .filter(|(_, o)| o.contains("call js_url_coerce_string")) + .nth(1) + .unwrap() + .0; + let load = ops.iter().position(|o| o.contains("root_load")).unwrap(); + assert!(store < second_call, "root store must precede the window"); + assert!(load > second_call, "re-read must follow the window"); + } + + /// A `Plain` is not GC-managed, so it may cross a collection point. If this + /// stopped compiling the types would be too strict to migrate to. + #[test] + fn plain_values_survive_collection_points() { + let mut e = RootingEmitter::new(); + let len = e.emit_pure("array_length"); + let _ = e.emit_collecting("js_array_grow"); + assert_eq!(len.0, "%r0"); + } +}