Skip to content

fix(codegen): destructured-value copies no longer truncate to i32 (#4766 regression)#4785

Merged
proggeramlug merged 1 commit into
mainfrom
fix/destructure-i32-slot-regression
Jun 8, 2026
Merged

fix(codegen): destructured-value copies no longer truncate to i32 (#4766 regression)#4785
proggeramlug merged 1 commit into
mainfrom
fix/destructure-i32-slot-regression

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Fixes a regression introduced by #4766 (iterator-protocol array destructuring): a value pulled from an array binding pattern (const [k, v] = pair, or the parameter form ([k, v]) => …) and then copied into another local (const cb = v) read back as -2147483648 (i32::MIN) instead of the original object/string. Calling a method on it threw TypeError: (number).<method> is not a function.

This broke every drizzle-orm schema:

mysqlTable("t", { id: varchar("id", { length: 36 }) })
// drizzle does: Object.fromEntries(Object.entries(columns).map(([name, col]) => { col.setName(name); … }))

so any app using drizzle crashed at startup with (number).setName is not a function.

Regression window

Root cause

The new array-destructuring lowering emits a scaffolding local let __destruct_N = undefined (mutable, init Undefined) per binding element. That mutable+undefined shape is one of collect_integer_let_ids's seeds (it targets the clampIdx let xx = undefined; do { xx = clampIdx(…) } while(false) pattern), so the scaffolding local was optimistically seeded into integer_locals.

The forward closure (collect_extra_integer_let_ids) then propagated integer-ness down the init-only copy chain: cbBase = __destruct_Ncb = cbBase.

The disqualify fixed point only prunes locals via non-int LocalSet writes. It correctly removed the scaffolding local (its writes are undefined / step.value), but the forward-propagated copies have no LocalSet write — their defining Let-init is their sole definition — so they were never re-validated and stayed stale-integer. The first copy escaped (its source left the set, so it was no longer strictly_i32_bounded), but the second hop (const cb = cbBase) was both in integer_locals and strictly_i32_bounded_locals, qualifying it for an i32 shadow slot. Storing a NaN-boxed value there did fptosi(NaN) = i32::MIN.

This is why direct obj.id was always correct (read from the double slot) but the value mis-read as a number only when copied out of a destructure.

Fix

In the disqualify fixed point, also re-validate any candidate Let that has no LocalSet write (collect_non_int_init_only_let_ids) — every const, plus never-reassigned lets such as the mutable bindings the parameter-destructuring path emits — against its defining init over the current (shrinking) candidate set. When the init's source local is disqualified, the copy is pruned too, cascading through the chain.

Locals with real int-producing LocalSet writes (clampIdx's xx) remain governed by those writes, so the image_convolution i32 fast paths are preserved.

Validation

Minimal repro (drizzle-free) before → after:

entry id typeof cb = number   →   entry id typeof cb = object
ISOLATE THREW: (number).setName is not a function   →   ISOLATE OK: id,email
  • Real-drizzle repro prints DRIZZLE OK: columns = id,name.
  • Deployed Hono + Drizzle + mysql2 app (billing.skelpo.com) now boots and serves HTTP without the (number).setName is not a function startup crash (previously it could not start at all).
  • Full perry-codegen test suite green.

Regression tests in collectors/hir_facts.rs:

  • destructure_undefined_seed_does_not_leak_into_const_copy_chain (immutable body-let path)
  • destructure_mutable_param_bindings_do_not_leak_into_copy (mutable parameter path)
  • const_copy_of_live_integer_accumulator_stays_integer (over-pruning guard)

Refs #793.

@proggeramlug proggeramlug force-pushed the fix/destructure-i32-slot-regression branch from 46af5c0 to 111c686 Compare June 8, 2026 10:32
 regression)

A value pulled from an array binding pattern (const [k, v] = pair, or the
parameter form ([k, v]) => ...) and then copied into another local
(const cb = v) read back as -2147483648 (i32::MIN) instead of the original
object/string; calling a method on it threw
"TypeError: (number).<method> is not a function". This broke every drizzle-orm
schema (Object.fromEntries(Object.entries(cols).map(([name, col]) => { col.setName(name); ... }))),
crashing apps at startup with "(number).setName is not a function".

Regressing commit: #4766 (iterator-protocol array destructuring), which emits a
scaffolding local `let __destruct_N = undefined` per binding element. That
mutable+undefined shape is one of collect_integer_let_ids's seeds (it targets
the clampIdx do-while pattern), so the scaffolding local was optimistically
seeded into integer_locals. The forward closure then propagated integer-ness
down the init-only copy chain (cbBase = __destruct -> cb = cbBase). The
disqualify fixed point only prunes locals via non-int LocalSet writes, so it
removed the scaffolding local (writes are undefined/step.value) but never
re-validated the forward-propagated copies (their Let-init is their sole
definition, no LocalSet). The second hop (const cb = cbBase) ended up both in
integer_locals and strictly_i32_bounded_locals, qualifying for an i32 shadow
slot; storing the NaN-boxed value did fptosi(NaN) = i32::MIN.

Fix: in the disqualify fixed point, also re-validate any candidate Let with no
LocalSet write (collect_non_int_init_only_let_ids) -- every const, plus
never-reassigned lets such as the mutable bindings the parameter-destructuring
path emits -- against its defining init over the current candidate set. When the
source local is disqualified the copy is pruned too, cascading through the
chain. Locals with real int-producing LocalSet writes (clampIdx's xx) stay
governed by those writes, preserving the image_convolution i32 fast paths.

Regression tests in collectors/hir_facts.rs cover the immutable and mutable
(parameter) destructure-copy chains plus an over-pruning guard.

Refs #793.
@proggeramlug proggeramlug force-pushed the fix/destructure-i32-slot-regression branch from 111c686 to e3da116 Compare June 8, 2026 11:37
@proggeramlug proggeramlug merged commit e75409d into main Jun 8, 2026
13 checks passed
@proggeramlug proggeramlug deleted the fix/destructure-i32-slot-regression branch June 8, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant