From 87df08b9f3419b76f099359f97651844d8570c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 18:58:53 +0200 Subject: [PATCH 1/2] fix(codegen): guard block-creating lowerings against diverged (terminated) blocks (#8583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a sub-expression provably diverges — a throwing operand (e.g. a captured TDZ access or const-reassignment) emits `js_throw_error_with_code` + `unreachable` — the current block is terminated. `LlBlock` silently drops any instruction emitted after a terminator (block.rs), so the setup instructions for the surrounding operation are discarded; but block-creating lowerings still emit fresh blocks that reference those dropped `%rN` registers, which the dialect builder rejects with "register %rN used but never defined" (dialect/mod.rs). The whole surrounding operation is unreachable on that path, so the fix is to emit nothing once the block is terminated. Two sites hit this in the Claude Code 2.1.112 bundle (both dead code after a proven-throwing operand): `lower_index_set_fast` (`a[i] = v`, closure `__44845`) and `emit_persistent_shadow_root_barrier` (a pointer root store, closure `__44449`). Each now returns early when `ctx.block().is_terminated()`. Also adds a `PERRY_DIALECT_DUMP=` diagnostic: on a dialect construction failure, `render_units_from_frozen` names the offending function and writes its full IR (typed insts rendered via `render_into`) — the failing unit never parses, so the normal `PERRY_SAVE_LL` post-parse dump cannot capture it. This is how the two sites above were located. Validated end-to-end: with these guards, the cli.js bundle codegens ALL 84 units with zero "used but never defined" errors (it previously failed at unit 25); the remaining blocker to a final binary is unrelated (host disk). Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF --- crates/perry-codegen/src/expr/index.rs | 13 +++++ crates/perry-codegen/src/expr/shadow_slot.rs | 10 ++++ crates/perry-codegen/src/native_emit.rs | 54 +++++++++++++++++--- 3 files changed, 70 insertions(+), 7 deletions(-) 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 From 1645fb9597629baf566fa8ce8c7f4b8815d8bd49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 23 Aug 2026 18:59:32 +0200 Subject: [PATCH 2/2] docs(changelog): fragment for #8652 Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF --- changelog.d/8652-diverged-block-guards.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/8652-diverged-block-guards.md 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).