diff --git a/changelog.d/8652-diverged-block-guards.md b/changelog.d/8652-diverged-block-guards.md new file mode 100644 index 0000000000..bbfbe00c2e --- /dev/null +++ b/changelog.d/8652-diverged-block-guards.md @@ -0,0 +1 @@ +fix(codegen): block-creating lowerings (`lower_index_set_fast`, `emit_persistent_shadow_root_barrier`) now emit nothing once the current block is terminated. When a sub-expression provably diverges — a throwing operand (a captured TDZ access / const-reassignment) emits a throw + `unreachable` — the block is terminated and its trailing setup registers are silently dropped, but the guarded fast path / root barrier still created fresh blocks referencing those dropped registers, which the dialect builder rejected as "register %rN used but never defined". Guarding on `ctx.block().is_terminated()` fixes the (unreachable) dead code. Also adds an env-gated `PERRY_DIALECT_DUMP=` diagnostic that, on a dialect construction failure, names the offending function and dumps its full IR (the failing unit never parses, so `PERRY_SAVE_LL` cannot capture it). Together with #8633 this lets the Claude Code 2.1.112 bundle codegen all 84 units cleanly (previously failed at unit 25). diff --git a/crates/perry-codegen/src/expr/index.rs b/crates/perry-codegen/src/expr/index.rs index 4bfa6cdfee..51f9613929 100644 --- a/crates/perry-codegen/src/expr/index.rs +++ b/crates/perry-codegen/src/expr/index.rs @@ -93,6 +93,19 @@ pub(crate) fn lower_index_set_fast( value_is_canonical_raw_f64: bool, feedback_site_id: &str, ) -> Result<()> { + // #8583-followup: if evaluating an operand diverged — a throwing + // sub-expression (e.g. a TDZ access on a captured `let`) emitted a + // `js_throw_error_with_code` + `unreachable` — the current block is + // terminated. `LlBlock` silently drops any instruction emitted after a + // terminator, so the element setup below (`arr_bits`/`arr_handle`/`idx_i32`) + // is dropped, but the guarded fast path still creates fresh blocks that + // reference those dropped registers, which the dialect builder rejects as + // "register %rN used but never defined". The index-set is unreachable on + // this path, so emit nothing. + if ctx.block().is_terminated() { + return Ok(()); + } + // Capture the local slot for the realloc path. let slot = ctx .locals diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index afa1525900..6b89e15e78 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -315,6 +315,16 @@ pub(crate) fn emit_shadow_slot_bind_ptr(ctx: &mut FnCtx<'_>, slot_idx: u32, slot /// `monotonic`, matching the runtime's Rust `Relaxed` readers: the counter is /// only a gate and does not publish accompanying memory. pub(crate) fn emit_persistent_shadow_root_barrier(ctx: &mut FnCtx<'_>, value_bits: &str) { + // #8583-followup: if computing the value diverged (a throwing sub-expression + // — e.g. a TDZ access on a captured `let` — emitted `unreachable`), the + // current block is terminated. `LlBlock` drops instructions emitted after a + // terminator, so `value_bits`' defining instruction was silently discarded; + // the barrier block created below would then reference an undefined register + // ("register %rN used but never defined"). The root store is unreachable on + // this path, so emit no barrier. + if ctx.block().is_terminated() { + return; + } let active = ctx.block() .load_atomic_monotonic(I32, "@PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT", 4); diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index b21d815308..d5edc5d6db 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -255,20 +255,60 @@ fn stream_frozen_functions<'ctx>( .map_err(|e| anyhow!("native IR construction failed in @{}: {e:#}", f.name))?; for item in &f.items { use crate::function::FinalItem as FI; - match item { - FrozenItem::Label(s) => stream.item(&FI::Label(s))?, - FrozenItem::Blank => stream.item(&FI::Blank)?, - FrozenItem::Text(s) => stream.item(&FI::Text(s))?, - FrozenItem::Inst(i) => stream.item(&FI::Inst(i))?, - } + let res = match item { + FrozenItem::Label(s) => stream.item(&FI::Label(s)), + FrozenItem::Blank => stream.item(&FI::Blank), + FrozenItem::Text(s) => stream.item(&FI::Text(s)), + FrozenItem::Inst(i) => stream.item(&FI::Inst(i)), + }; + res.map_err(|e| dump_dialect_failure(f, e))?; } - let (t, r) = stream.finish()?; + let (t, r) = stream.finish().map_err(|e| dump_dialect_failure(f, e))?; typed += t; raw += r; } Ok((typed, raw)) } +/// Diagnostic for a dialect construction failure (e.g. "register %rN was used +/// but never defined"): name the offending function and, when +/// `PERRY_DIALECT_DUMP=` is set, write the function's full constructed IR +/// text (typed insts rendered via `render_into`) to `/.ll` so the +/// malformed use site is visible. The failing unit never parses, so the normal +/// `PERRY_SAVE_LL` post-parse dump cannot capture it. +fn dump_dialect_failure(f: &FrozenFunction, e: anyhow::Error) -> anyhow::Error { + if let Ok(dir) = std::env::var("PERRY_DIALECT_DUMP") { + let _ = std::fs::create_dir_all(&dir); + let mut buf = String::new(); + buf.push_str(&f.header); + buf.push('\n'); + for item in &f.items { + match item { + FrozenItem::Label(s) => { + buf.push_str(s); + buf.push('\n'); + } + FrozenItem::Blank => buf.push('\n'), + FrozenItem::Text(s) => { + buf.push_str(s); + buf.push('\n'); + } + FrozenItem::Inst(i) => { + i.render_into(&mut buf); + buf.push('\n'); + } + } + } + let safe: String = f + .name + .chars() + .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .collect(); + let _ = std::fs::write(format!("{dir}/{safe}.ll"), &buf); + } + anyhow!("native IR construction failed in @{}: {e:#}", f.name) +} + /// Native construction for a module large enough to split into codegen /// units (#5391): each unit is its own context+module (peak RSS stays /// ~whole/n, same bound as the per-unit clang model), functions stream with