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