Skip to content

fix(runtime): unknown method on a Buffer receiver must throw, not return undefined - #2

Closed
proggeramlug wants to merge 3256 commits into
andrewtdiz:mainfrom
proggeramlug:fix/buffer-unknown-method-throws
Closed

fix(runtime): unknown method on a Buffer receiver must throw, not return undefined#2
proggeramlug wants to merge 3256 commits into
andrewtdiz:mainfrom
proggeramlug:fix/buffer-unknown-method-throws

Conversation

@proggeramlug

Copy link
Copy Markdown

Problem

dispatch_buffer_method's catch-all returns undefined for any method that neither the Buffer API arms nor the delegated %TypedArray%.prototype tower implement. Node throws:

const buf = readFileSync(path);   // Buffer since #1420 (no encoding → Buffer, Node parity)
buf.charCodeAt(0);                // Node: TypeError: buf.charCodeAt is not a function
                                  // Perry: undefined — and execution continues

Plain objects, strings, and number receivers already throw through js_throw_type_error_not_a_function; Buffer was the one receiver that swallowed the call.

Why it matters

The silence has real teeth precisely because PerryTS#1420 correctly aligned readFileSync with Node: any caller still treating the no-encoding result as a string now gets undefined from every string-method call and keeps running on garbage instead of failing at the call site. Real-world case (how we found it): a code editor's NUL-byte binary-detection scan — content.charCodeAt(i) === 0 over the first 8000 chars — misclassified every file it opened as binary. No crash, no error message, nothing to grep for; the app just quietly stopped displaying files. A thrown TypeError would have named the exact line on the first file opened.

This also matches the direction of PerryTS#6453 ("String method on a nullish receiver must throw, not coerce") — loud beats lenient when the alternative is silent corruption.

Fix

In the catch-all, after Buffer-API arms and typed-array delegation both miss, route through js_throw_type_error_not_a_function — the same thrower the string/number primitive catch-alls use, so message shape ((Buffer).charCodeAt is not a function) and catchability match those paths. Internal __perry_* duck-type probes (using-disposal) keep the non-throwing undefined.

Caller audit: both dynamic-dispatch entry points (handle_methods.rs:138, collection_methods.rs:313) return Some(dispatch_buffer_method(...)) unconditionally — nothing relied on the undefined to fall through to another dispatcher. The named-method callers (set/export/slice) never reach the catch-all.

Validation

  • New gap test test_gap_buffer_unknown_method_throws.ts: byte-identical to the Node oracle locally (assertions avoid raw message text since V8 renders the callee source expression; the test checks instanceof TypeError + method-name/is not a function substrings, and that execution continues after a caught throw).
  • Buffer sweep unchanged: test_buffer_prototype_methods, test_buffer_numeric_read_intrinsic, test_buffer_small_alloc, test_edge_buffer_from_encoding all still match Node. test_compat_buffers_typed diffs on toSorted/toReversed (u8 element widening) — pre-existing, byte-identical before/after this change.

Per CONTRIBUTING, no version/CHANGELOG edits — happy to adjust anything.

proggeramlug and others added 30 commits July 6, 2026 13:43
… receiver-typed throws in harness closures (PerryTS#6096)

PerryTS#6039 (PerryTS#5982 fix) stripped every MODULE-GLOBAL captured local out of
`module_local_types` so the typed-ABI closure specialization would not
read an unset capture slot for a global read through `@perry_global_*`.
That part was right, but `module_local_types` is used for TWO purposes in
`compile_module`:

  1. the typed-ABI closure-clone DECISION
     (`typed_{f64,i1,i32,string}_closure_rejection_reason_with_types`), and
  2. the per-function-body RECEIVER-TYPE oracle — it is handed to
     `emit_module_artifacts` -> `FnCtx.local_types`, which drives
     `static_type_of` / `is_array_expr`.

Removing a module-global's declared type from (2) mis-classified a
captured array receiver as untyped INSIDE a closure. `arr.every()` with an
undefined callbackfn then lowered to the generic dynamic method dispatch
(`js_arraylike_*`) instead of the array-typed path that emits
`js_validate_array_callback`, so the mandatory TypeError was never thrown.
The test262 harness runs every negative case as
`assert.throws(TypeError, function () { ... })` — a module-level closure
that captures the module-global under test — so the swallowed throw
surfaced as 24 conformance regressions:

  - 8 Array HOF callbackfn-not-callable cases
    (every/filter/forEach/map/reduce/reduceRight/some 15.4.4.*-4-1)
  - 3 symbol-strict [[Set]] cases (Object.defineProperty/freeze/seal
    *-strict: obj[sym]=2 on a non-writable prop must throw)
  - the private-async-method / Proxy / Temporal cases that reach the same
    harness-closure path.

Fix: keep the module-globals filter scoped to the typed-ABI specialization
only. Build a dedicated `typed_abi_local_types` (module-locals minus
module-globals) and feed it to the four closure-clone decisions and the
capture-rep probe; leave `module_local_types` (the receiver oracle passed
to `emit_module_artifacts`) module-global-INCLUSIVE. The emission side
(`compile_typed_*_closure`) is only reached for closures the decision
accepted, and a closure capturing a module-global is always rejected by
the decision (its capture type is absent from `typed_abi_local_types`), so
decision and emission never disagree — PerryTS#5982's
`for(let i...){const c=i; fns.push(()=>c)}` still returns 0,1,2,3,4, not
0,0,0,0,0.

Bisected to 09f2f24 (PerryTS#6039) on an internal Linux sweep host; witness
slice (8 Array HOF + 3 symbol-strict) restored to 10/10, PerryTS#5982 guard held.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
….values() returned empty (PerryTS#6095)

An any-typed `.keys()`/`.entries()`/`.values()` call folds to the array-only
`Expr::Array{Keys,Entries,Values}` (perry-hir PerryTS#597), whose runtime helpers read
the receiver as a heap `ArrayHeader`. A Web `Headers` (and `FormData` /
`URLSearchParams`) instance is a fetch-band registry *handle*, not an
`ArrayHeader`, so `js_array_{keys,entries,values}_iter_obj` read the handle id
as an array length and yielded an empty iterator — while `.has()`/`.get()`
(dynamic dispatch) and the computed `headers["keys"]()` form worked.

Impact: a large esbuild-bundled client SDK builds request headers, wraps them
as `{ values: Headers }`, and merges via `yield* wrapper.values.entries()`.
Because `wrapper.values` is any-typed, `.entries()` folded to `ArrayEntries` →
empty → every header was silently dropped and the request failed its
`validateHeaders` auth check.

`collection_iter_obj_for_receiver` already routes Map/Set receivers to their
iterators; route fetch-band handles through `js_native_call_method` →
`js_headers_{keys,entries,values}` too. Non-collection fetch handles
(Response/Request/Blob) and genuine plain objects still fall through to the
empty-array path. Adds an e2e regression test.

Co-authored-by: Ralph Küpper <ralph2@skelpo.com>
PerryTS#6100)

RUSTSEC-2026-0204 (published 2026-07-06) flags crossbeam-epoch < 0.9.20:
invalid pointer dereference in the fmt::Pointer impl for Atomic/Shared.
Transitive dep via rayon -> crossbeam-deque; Cargo.lock-only bump, no
manifest changes. Unblocks the required security-audit check that is
currently failing on every open PR (first seen on PerryTS#6097/PerryTS#6098).

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ng properties (PerryTS#6097)

Perry's regex engine expands `\p{...}` / `\P{...}` through `regex-syntax`
0.8.11, which bundles the Unicode 16.0 UCD. Test262's
`built-ins/RegExp/property-escapes/generated/*` cases were regenerated against
Unicode 17.0, so every property whose code-point set changed between UCD 16 and
UCD 17 fails: the regex matches the UCD-16 set, the test asserts the UCD-17 set.

PerryTS#6068 handled the four brand-new self-contained U17 scripts (Beria_Erfe,
Sidetic, Tai_Yo, Tolong_Siki). This covers the remaining 69 failing cases —
U17 changes to *pre-existing* scripts (Han, Latin, Arabic, ...), general
categories (L, M, N, Lo, ...), binary properties (Alphabetic, ID_Start,
XID_Continue, Grapheme_Base, ...) and the Unknown/Zzzz catch-alls.

New `regex/unicode17_data.rs` carries, per affected property, either:
  * Delta  — UCD 17 only *added* code points; the added ranges are unioned into
    the crate's own class (`\p{Prop}` -> `[\p{Prop}<delta>]`). 58 properties.
  * Full   — the crate can't represent the property (Script=Unknown/Zzzz,
    Changes_When_NFKC_Casefolded) or UCD 17 *removed* points (e.g. U+0295 left
    Cased), so a union would over-match; the full UCD-17 range set replaces the
    crate's expansion. 10 properties.

`regex/unicode17.rs` gains `u17_replacement()` (wraps the data into the correct
positive/negated/in-class form, mirroring `script_replacement`), and
`grammar.rs` gains one dispatch arm before the legacy never-match fallback so
the U17 overrides for Script=Unknown / Changes_When_NFKC_Casefolded win.

Every range set is derived offline by diffing each generated test's own
embedded UCD-17 `matchSymbols` against the UCD-16 tables `regex-syntax` ships;
`delta = UCD17 \ UCD16`, promoted to Full automatically when `UCD16 \ UCD17` is
non-empty. This is bundled data, not a dependency bump — `regex-syntax` 0.8.11
is already newest and ships no U17 data.

built-ins/RegExp/property-escapes: 69 runtime-fails -> 0 (585/585, 100%).
built-ins/RegExp regression gate (fix vs clean origin/main, native sweep on an
internal Linux host): 0 new failures, 69 fixed.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
… minor (PerryTS#6099)

In a compiled program, codegen registers synchronous-only root scanners at
startup, so the budgeted GC stepper can never start a cycle and nursery
pressure is handled by a direct synchronous minor in gc_check_trigger. Unlike
every other collection-completion path (gc_finish_budgeted_cycle, the full-GC
path), that arm never re-baselined its arming trigger: it just emitted the
outcome, leaving GC_NEXT_TRIGGER_BYTES / GC_NEXT_MALLOC_TRIGGER at the value
that armed the collection.

The non-moving minor reclaims dead objects into per-block free lists without
lowering arena_total (committed blocks). So a workload holding a large live set
above the trigger -- e.g. building an object graph that stays reachable while
churning transient allocations -- keeps gc_budgeted_due_trigger reporting the
trigger as due, and every fresh block re-arms a whole-arena mark/sweep. That is
one O(arena) collection per block allocated: O(n^2) in the graph size, a ~100%
CPU stall with a bounded live set that never makes progress.

Re-baseline the arming trigger after the direct minor via the same helpers the
budgeted finisher uses (gc_finish_arena_trigger_collection for ArenaBytes,
gc_finish_malloc_trigger_collection for MallocCount). These raise the trigger
past the retained set (adapting the step), so collections drop to O(n / step)
and the workload completes. RSS stays bounded (the arena re-baseline caps at
max(min(new_total + step, ceiling), new_total + 16 MB)), preserving the
nursery-GC guarantee for compiled programs.

Adds two regression tests (gc/tests/debt_pacer.rs), one per arm: each registers
a synchronous-only scanner (forcing the direct arm), arms its trigger, and
asserts the collection completes synchronously and the trigger is re-baselined
past the retained set (arena trigger above arena_total; malloc trigger to
survivors + step). The MallocCount case also exercises the malloc_swept
debug_assert on that path.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…erryTS#6098)

* fix(hir): pre-register forward-captured lets in NESTED block scopes

A closure created earlier in a nested block (try / catch / finally, plain
`{}`, loop body, switch case) that forward-references a `let`/`const`
declared LATER in that SAME block was globalized instead of captured: the
reference lowered to `js_global_get_or_throw_unresolved` and threw
`ReferenceError: <name> is not defined` at runtime.

`pre_register_forward_captured_lets` only scanned the FUNCTION-BODY top
level, so a forward-captured lexical binding nested inside any block was
never pre-registered and the earlier closure literal captured a globalThis
read of the not-yet-declared name.

Fix: process a worklist of block statement-lists — the function body PLUS
every nested block scope (new `push_nested_block_stmt_lists` queues
try/catch/finally, `{}`, for / for-in / for-of / while / do-while, labeled
body, and switch-case bodies), each with its own closure-ref set.
Forward-captured boxes from any depth still preallocate at function entry
and each declaration reuses its id by span, so sibling same-name lets in
different scopes don't collide.

Minimal repro (`function f(){ try { let cb = () => q; let q = 5; return
cb() } finally {} }`) returned 5 under node but threw `q is not defined`
natively; the async-generator variant (closures in a `try` that
forward-capture and mutate later-declared locals) is covered too. Adds an
e2e regression test; the perry-hir suite stays green.

* fix(hir): scope nested-block forward-capture pre-registrations to their block (review feedback)

Addresses both CodeRabbit findings on PerryTS#6098 plus a scope-leak found while
verifying them:

1. else-if chains (and any non-block single-statement body) never reached
   the forward-capture worklist: push_nested_block_stmt_lists only enqueued
   DIRECT Block bodies. It now recurses through If cons/alt, loop bodies,
   and labeled bodies to find blocks behind any wrapper chain.
   Repro (threw 'q is not defined'):
     if (n === 0) { ... } else if (n === 1) { let cb = () => q; let q = 5; return cb(); }

2. Nested-scope pre-registrations were defined as name-visible function-level
   locals for the WHOLE body, which broke same-name cases:
   - a same-named forward-captured let in a later sibling block was deduped
     by name against the earlier block's registration, so both closures
     shared one box (printed '1,1' where Node prints '1,2');
   - the name leaked outside its block: reads after the block resolved the
     block's box instead of the outer/module binding, and reads before it
     hit the TDZ sentinel ('Cannot access undefined before initialization'
     where Node reads the module binding).
   Nested scopes now allocate a HIDDEN id (fresh_local, tracked in the new
   nested_forward_scope_ids set); rebind_nested_forward_scope_lets makes it
   name-visible exactly while its own block lowers (hooked in
   lower_block_stmt / lower_block_stmt_scoped and the two switch-case
   lowering arms, whose case stmt-lists share the switch scope without being
   a BlockStmt), and pop_block_scope drops it at block exit instead of
   retaining it as var-hoisted.

3. The forward-captured 'var' prealloc branch now checks a function-WIDE
   closure-ref set instead of the per-scope ordered one: vars hoist, so
   'let cb = () => n; { var n = 5; } cb()' must preallocate n's box even
   though the capturing closure lives in the enclosing scope (returned
   'undefined' instead of 5).

Validation: 11-case node-parity battery (else-if, siblings, leak
before/after, try control, switch, loop, deep nesting, var hoist, labeled,
top+nested same-name) all match node; cargo test -p perry-hir green (221
lib + suites); forward-capture e2e suites green; new e2e regression tests
added to issue_nested_block_forward_capture.rs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…s (review feedback) (PerryTS#6102)

push_nested_block_stmt_lists handled if/loop/labeled wrappers but not
With, so a block-scoped forward-captured let inside a sloppy-mode
'with (o) { ... }' body never pre-registered (mirrors the existing With
arm in cic_stmt). Battery of 11 nested-forward-capture parity cases
still matches node; with-shape returns the block-lexical binding per
spec.

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…built-in (PerryTS#6105)

The field-initializer chain (`apply_field_initializers_recursive`) contains
only USER classes, so a built-in parent (`Error`, `Map`, `Array`, …) is not in
it. For a no-own-constructor `class A extends Error {}` the chain is just
`["A"]` (length 1). The no-own-ctor construction path applied
`FieldInitMode::AfterRoot`, which keeps `chain[1..]` — empty for a length-1
chain — and the up-front `AncestorsOnly` pass also returns empty for a
length-1 chain, so A's OWN field initializers never ran. Its fields read the
raw-0 slot; a later `this.arr.includes(x)` on an unset array/string field then
threw `TypeError: Cannot read properties of undefined (reading 'includes')`.
(A two-level `class B extends A extends Error` was unaffected: its chain is
`["A","B"]`, so `AncestorsOnly` applies A and `AfterRoot` applies B.)

Fix: in `AfterRoot`, when the chain has no user root ancestor to skip
(length ≤ 1 — i.e. the leaf directly extends a built-in or imported parent),
apply the leaf's own initializers instead of nothing. Plain user-class parents
(chain length ≥ 2) are unchanged.

Adds tests/test_subclass_builtin_field_init.sh: `class A extends Error` with
public/private/array field initializers, asserting they are all set after
`new A()`.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…age (PerryTS#6106)

`class A extends Base` where `Base` is an in-scope lexical local (a
let/const/param), not a class, is heritage-shadowed: the parent is a
runtime value resolved dynamically via `extends_expr`. Lowering already
captured `extends_expr` and set `extends = None`, but still left
`extends_name = Some("Base")`. The many STATIC parent-chain walks in
codegen (packed-keys field layout, `js_register_class_parent` edge,
inherited-method / vtable install, type-facts) re-resolve that bare name
through the module-wide name->class map, binding to an UNRELATED same-named
class elsewhere in the module -- e.g. a function-local `class Base` that
leaked into the global map.

In a large minified program this mis-bound
`let Y = _?.Parent ?? Object; class A extends Y {}` to a captured
function-local iterator class also named `Y` (declaring a private `#q`), so
`A` instances inherited that class's layout/methods and a `this.#q` access
threw "Cannot access private member from an object whose class did not
declare it" on a legal receiver.

Fix: for a lexically-shadowed heritage, leave both `extends` and
`extends_name` `None` (matching the fully-dynamic
`class X extends <runtimeValue>` shape). The parent edge is wired at runtime
via `js_register_class_parent_dynamic` and `super()` runs through
`extends_expr` + `heritage_lexically_shadowed`. The super-call codegen gate
is updated to proceed via `extends_expr` when `extends_name` is absent, so a
shadowed subclass's `super()` still runs its (dynamic) parent constructor.

Adds HIR lowering regression tests: a lexically-shadowed heritage lowers to
`extends_name = None` + `extends_expr = Some`, while a plain
class-to-class heritage keeps its static `extends_name`.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…io works (PerryTS#6109)

Two bugs kept the watchOS microphone path silent on device:

1. AVFAudio / AVFoundation were never linked for the watchOS target (the iOS
   branch already links them). Without them AVAudioSession / AVAudioEngine /
   AVAudioApplication aren't registered in the objc runtime, so
   `AnyClass::get` returns nil and audio silently no-ops — a watchOS dB meter
   shows no levels and never prompts for mic access.

2. The record-permission check compared against 0/1/2, but
   AVAudio{Session,Application}RecordPermission is a FourCC-coded NSInteger
   ('undt' / 'deny' / 'grnt'), so `== 1` never matched, the request was never
   issued (no system prompt), and the engine started without mic access. Use
   the correct FourCC constants and prefer the modern AVAudioApplication
   permission API (AVAudioSession.requestRecordPermission is deprecated on
   watchOS 10+/26), falling back to AVAudioSession where it's unavailable.

`start()` now returns 1 on success and 0 on any failure/retry.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…eader size (PerryTS#6107)

The inline class-field get/set fast paths hardcoded a 24-byte ObjectHeader
skip when computing a field's address (`obj + 24 + slot*8`). On arm64_32
(ILP32: 32-bit pointers) the runtime `ObjectHeader` is 20 bytes (4×u32 + a
4-byte `keys_array` pointer), so these paths read and wrote every class
field 4 bytes off — while the generic-PIC load, the runtime setters, and
the GC all correctly derive the size from the target.

Number fields survived because their read AND write both used the hardcoded
24, staying self-consistent. But typed-object *string* fields came back as a
32-bit word-swapped NaN-box: the write went through the class-field path
(24) while the read fell through to the generic PIC (target-aware 20) — a
4-byte skew that straddles two slots. `interface LevelInfo { label: string;
… }` read `levels[i].label` back as a garbage float (~1.5e-202) on Apple
Watch Series 4–8 / SE.

Derive the skip from `object_header_size_bytes(ctx.target_triple)` (20 on
ILP32, 24 on LP64 — a no-op on 64-bit), matching every other field-access
path. Verified on the arm64_32 IR: class-field get/set now emit
`getelementptr i8, ptr, i64 20`, identical to the generic PIC's base.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…64_32) (PerryTS#6108)

`ExceptionState` stored three `[_; MAX_TRY_DEPTH]` arrays inline in
thread-local storage — at MAX_TRY_DEPTH=1024 that's ~280KB of initialized
TLS (`jump_buffers` alone is 1024 * 256B = 256KB). On arm64_32 (ILP32
watchOS) ld64 caps `__thread_data` at 64KB, so the binary fails to link.

Move the three arrays to the heap (`Box<[JmpBuf]>`, `Box<[ShadowSavepoint]>`,
`Box<[u32]>`), leaving only three fat pointers + scalars inline in TLS.
`new()` drops `const` and builds each array straight onto the heap with
`vec![…].into_boxed_slice()` (no large stack temporary); `[T]` indexing on a
`Box<[T]>` is identical, so the accessors are unchanged. This mirrors the
existing TRANSITION_CACHE / VTABLE_IC / INTERN_TABLE TLS boxing. First
exception use per thread now lazily allocates ~280KB off the heap instead of
reserving it in TLS — negligible, and unblocks the arm64_32 watchOS link.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…lks (PerryTS#6101)

The wide-key field-get index (get_field_by_name) and Object.assign's source
enumeration both size their work by the object's keys array length
(js_array_length). A dense keys array's logical length can never exceed its
capacity, but a malformed keys array can report a bogus, pointer-sized length
(observed roughly equal to the keys pointer's own low bits -- hundreds of
millions). Fed straight into wide_key_index_lookup's
HashMap::with_capacity(key_count) or a 0..key_count copy loop, that turns a
single missing-property read or an Object.assign into a multi-GB allocation /
minutes-long spin walking a phantom tail through the slow sparse-array path.

Add keys_array_len_capped_to_capacity (array/indexing.rs) and use it in both
walks. length <= capacity holds for well-formed dense keys arrays, so this is a
no-op on the common path and a hard bound otherwise. Regression test forges an
oversized length and asserts the cap returns capacity.

This is defensive hardening for the property-walk consumers; the upstream cause
of the oversized length (js_array_length reporting a pointer-sized value for a
particular object's keys array) is a separate issue.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…erryTS#6103)

`js_get_global_this` caches this thread's `globalThis` in a thread-local
raw-pointer cell (`THREAD_GLOBAL_THIS`) but, unlike its sibling
`js_module_top_this`, never registers that cache slot as a GC root. The
authoritative `GLOBAL_THIS_PTR` static IS scanned and relocated by
`scan_object_cache_roots_mut`, so when a copying minor evacuates
`globalThis` the static and the object's overflow side-table are rekeyed
to the moved address — while the thread-local cache keeps pointing at the
stale from-space copy.

Subsequent `js_get_global_this()` calls then hand back that dead pointer.
Reading an overflow-stored builtin such as `globalThis.Error` returns
`undefined` (its overflow entry was rekeyed onto the moved object), which
surfaces downstream as `Class extends value is not a constructor` in
programs that construct many classes during startup and trigger a
collection between two global reads.

Register the cache slot as a mutable global root (exactly as
`js_module_top_this` already does) so the collector rewrites the raw
pointer to the forwarding address on every move. Raw-pointer global-root
slots are handled by `mark_global_root_bits` / `rewrite_value_bits` and
covered by `test_rewrite_mutable_root_slots_updates_shadow_and_global_roots`.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…t name (PerryTS#6104)

`Expr::PrivateGuard` carried only the declaring class's NAME, and codegen
resolved it to a class id via `ctx.class_ids` — a name→id map built by
`hir.classes.map(|c| (c.name, c.id)).collect()`, i.e. last-writer-wins.
Minified programs routinely reuse class names (and anonymous/duplicate
classes share a source name), so two distinct classes with the same name
collapse to one `class_ids` entry. A `this.#field` access inside the class
that LOSES the collision then resolves its declaring-class id to the OTHER
same-named class, and the runtime brand check (`js_private_guard`) rejects a
perfectly legal access with `TypeError: Cannot access private member from an
object whose class did not declare it` — the instance's class-id chain never
reaches the wrong declaring id.

Every HIR class already has a unique id, so thread it instead of the name:
the private-name scope records the declaring class's id, `resolve_private`
returns it, `Expr::PrivateGuard` carries it, and codegen uses it directly as
the brand's `declaring_class_id`. The `class_ids` name lookup remains only as
a fallback when the id is 0 (unresolved → the guard degrades to a no-op, as
before).

Adds a regression test: two distinct classes both named `Box` (one extending
a built-in), each with a `#v` field accessed in a method — the losing class's
access threw before the fix.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…pointer classifier (PerryTS#6118)

* fix(runtime): arm64_32 ILP32 — box oversized thread_locals, guard GC pointer classifier

On arm64_32 (Apple Watch Series 4–8 / SE), oversized `#[thread_local]` statics
overflow the ILP32 TLS layout and their writes silently corrupt adjacent
thread-locals. Heap-allocate (Box) the three large per-thread caches so only a
pointer lives in TLS:

  - INTERN_TABLE            (~128KB, string/intern.rs)
  - TRANSITION_CACHE_GLOBAL (~320KB, object/mod.rs)
  - VTABLE_IC               (~160KB, object/class_registry/dispatch.rs)

Confirmed on a real Series 7: shrinking OR boxing each table removes the
corruption; boxing keeps full cache capacity.

Also on ILP32 only:
  - gc/barrier.rs: reject heap-word candidates whose tagged payload exceeds 32
    bits, so a mistagged/immediate value can't truncate to a garbage 32-bit
    address the GC then marks or dereferences.
  - lib.rs: use the system allocator (libsystem_malloc, solid on watchOS) on
    32-bit targets — mimalloc on ILP32 is unproven and a corruption suspect.
    64-bit keeps mimalloc.

No behavior change on 64-bit targets (all new code is `cfg`-gated to 32-bit,
except the Box indirection which is target-agnostic and free in the hot path).

* chore(runtime): declare mimalloc dependency 64-bit-only

Addresses CodeRabbit: with lib.rs selecting std::alloc::System on 32-bit,
mimalloc was still pulled in and compiled (dead C) on arm64_32. Gate the
dependency on cfg(target_pointer_width = "64") so the ILP32 build neither
uses nor compiles it — shrinking the arm64_32 build surface of an allocator
that is itself a corruption suspect on that tier.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…yTS#6121)

js_array_length (array/indexing.rs) was `pub extern "C"` but missing
#[no_mangle], so libperry_runtime.a carried it only under its mangled Rust
name. Codegen emits a bare `js_array_length` call in native-region wrappers
(__perry_wrap_*) and other array paths, so any program that hits that path
failed to link with `Undefined symbols: _js_array_length` — including the
native-region-proof / native-ABI-proof compiler-output gates.

Add #[no_mangle] (matching the neighbouring js_array_push) plus a #[used]
KEEP_ARRAY_LENGTH anchor so the export survives auto-opt dead-stripping.

Verified: `nm libperry_runtime.a` now shows `T _js_array_length`, and the
native-region-proof gate goes from 3 failed workloads to 0.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…yTS#6112)

Bumps the cargo-minor-and-patch group with 2 updates: [x448](https://github.com/RustCrypto/elliptic-curves) and [cc](https://github.com/rust-lang/cc-rs).


Updates `x448` from 0.14.0-pre.11 to 0.14.0-pre.12
- [Commits](RustCrypto/elliptic-curves@x448/v0.14.0-pre.11...x448/v0.14.0-pre.12)

Updates `cc` from 1.2.65 to 1.2.66
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](rust-lang/cc-rs@cc-v1.2.65...cc-v1.2.66)

---
updated-dependencies:
- dependency-name: x448
  dependency-version: 0.14.0-pre.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: cc
  dependency-version: 1.2.66
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [cron](https://github.com/zslayton/cron) from 0.16.0 to 0.17.0.
- [Release notes](https://github.com/zslayton/cron/releases)
- [Commits](https://github.com/zslayton/cron/commits)

---
updated-dependencies:
- dependency-name: cron
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [wasmi](https://github.com/wasmi-labs/wasmi) from 0.51.5 to 1.1.0.
- [Release notes](https://github.com/wasmi-labs/wasmi/releases)
- [Changelog](https://github.com/wasmi-labs/wasmi/blob/v1.1.0/CHANGELOG.md)
- [Commits](wasmi-labs/wasmi@v0.51.5...v1.1.0)

---
updated-dependencies:
- dependency-name: wasmi
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](actions/cache@v4...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ce no longer type-confuses its header (PerryTS#6119)

js_object_assign_one fell through to `(*src).keys_array` for any pointer source
that wasn't a string/proxy/closure/array. For a Map/Set/Date/RegExp (each a
non-ObjectHeader layout) that reads a garbage pointer past the exotic header,
which the key-copy loop then walks as an array — a memory-layout-dependent
SIGBUS, reliably reproduced by sourcing through an `any`-typed binding. Gate the
keys_array walk on GC_TYPE_OBJECT so exotics contribute nothing, mirroring the
already-hardened js_object_copy_own_fields.

Also harden the js_object_clone_with_extra footgun: its structuredClone caller
pre-checks GC_TYPE_OBJECT, but the pre-audit `top16 >= 0x7FF8` extraction still
admitted non-object payloads before deref'ing field_count.

Adds test_gap_object_assign_collection (Map/Set/Date/RegExp via assign + spread,
incl. the any-typed loop; genuine object/array sources still copy their props).

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…th.clz32 (PerryTS#6122)

The six dynamic bitwise/shift helpers (js_dynamic_{shr,shl,bitand,bitor,bitxor,
ushr}) and Math.clz32 converted their f64 operand with `x as i64 as i32/u32`,
which SATURATES for |x| >= 2^63 instead of ECMAScript ToInt32/ToUint32
(truncate toward zero, reduce modulo 2^32). So on the fully-dynamic (any-typed
operand) path, e.g. `(1e20 as any) | 0`, `(1e20 as any) >>> 0`, and
Math.clz32(1e20) produced wrong results (0/-1 instead of 1661992960 / 1).

Route all seven through shared dyn_to_int32/dyn_to_uint32 helpers using the
same rem_euclid form already proven correct in js_dynamic_bitnot and
js_math_to_int32. Adds test_gap_toint32_dynamic_bitops (byte-matched vs node).

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
… of NaN (PerryTS#6123)

The non-decimal literal path used `u64::from_str_radix`, which overflows (→
NaN) once the value exceeds u64::MAX — but ECMAScript's NonDecimalIntegerLiteral
has no width limit, so `Number("0xFFFFFFFFFFFFFFFFF")` must round to the nearest
double (node: 295147905179352830000), not NaN.

Convert with a correctly-rounded (round-to-nearest, ties-to-even) bit
accumulation: collect significant bits into a u128, fold anything that overflows
into a sticky bit, then round — a naive `value*radix + digit` in f64 rounds at
every step and mis-rounds past 2^53 (e.g. 0x3fffffffffffff8). Verified the exact
doubles match node via `===` for 2^53 tie boundaries, 2^58, and >f64::MAX →
Infinity. Adds test_gap_number_nondecimal_overflow.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
… O(n²)) (PerryTS#6126)

QUEUED_MICROTASKS was a Vec drained with `remove(0)` — O(n) per job, so a burst
of n queued nextTick jobs (common under Node stream / 'readable' scheduling)
memmoved ~n²/2 entries. Switch to VecDeque + push_back/pop_front for O(1) drain.
FIFO order and same-drain visibility of jobs enqueued during the drain are
unchanged (verified vs node: ordering + a 1000-job burst). PerryTS#6084.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…ox AVX-512 into published binaries (PerryTS#6128)

* fix(publish/compile): controllable CPU baseline — stop baking build-box AVX-512 into published binaries (PerryTS#6125)

perry publish linux binaries SIGILL'd at startup on non-AVX-512 x86-64
CPUs (Zen2/EPYC-Rome): the hub worker compiles linux-on-linux natively,
and compile_ll_to_object unconditionally passed -march=native for host
builds, so every app object inherited the build box's full ISA. The
[build] native_tuning / march keys users reached for were parsed by
nothing, and the publish manifest had no CPU-baseline field at all.

- perry-codegen/linker.rs: cpu_tuning_arg_for() resolves PERRY_TARGET_CPU
  (native | generic/off | explicit LLVM CPU name) into -march/-mcpu by
  the EFFECTIVE target arch; default behavior unchanged (native for host
  builds, generic for cross builds).
- compile: new --march flag; promote_cpu_baseline_env() folds
  --march > PERRY_TARGET_CPU > perry.toml [build] march >
  [build] native_tuning into the canonical env var before rayon workers
  spawn (same pattern as --debug-symbols).
- auto-optimize rebuild: maps the knob to -C target-cpu for the
  runtime/stdlib/ext staticlib cargo build (and the bitcode-LTO path),
  and pins RUSTFLAGS explicitly whenever a baseline is requested so
  ambient env tuning can't leak into shipped libs.
- caches: PERRY_TARGET_CPU joins the object-cache key env set and
  BUILD_CACHE_ENV_VARS, so flipping the baseline can't serve stale
  differently-tuned objects.
- publish: parses [build] march / native_tuning, adds --march, sends
  build_march in the BuildManifest (drives the worker's
  perry compile --march), and defaults linux to the portable x86-64-v2.

Verified end-to-end on the compiled binary via compile-plan metadata:
default host build keeps -mcpu=native; --march generic drops the flag;
--march <cpu> lands in the actual clang args; [build] keys and the env
var resolve with documented precedence; changing the baseline misses the
object cache (an invalid CPU reaches clang instead of a cached hit).

Closes PerryTS#6125

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(publish): move build_march resolution into config_types — publish/mod.rs crossed the 2000-line CI gate

The PerryTS#6125 baseline-resolution block pushed publish/mod.rs to 2002 lines,
tripping scripts/check_file_size.sh in the lint job. Extract it as
config_types::resolve_build_march() (mod.rs back to 1982) and add a unit
test covering the precedence chain (--march > [build] march >
native_tuning shorthand > linux x86-64-v2 default > none).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…d, precise-root (Phases 1-4) (PerryTS#6134)

* feat(gc): Phase 1 — moving (copying) minor at the event-loop safepoint

First step of the "one great GC" project: make Perry's already-built
precise/generational/MOVING minor actually run, by triggering it at a
precise-root safepoint instead of at arbitrary allocation points.

Background: the copying minor (gc_collect_minor_copying_fast_path) is fully
built but never runs today. The nursery-churn arm forces a conservative
native-stack scan at the alloc point (a mid-construction value may live only
in a register there), which makes the copying minor ineligible
(CopiedMinorEligibility::evaluate -> ConservativeStack) so the non-moving
minor runs. Everything else the moving minor needs is already satisfied:
barriers on, zero copy-only scanners in production, a shadow stack that binds
each live local to its real alloca so copied-minor GC can rewrite roots.

This commit adds gc_safepoint_moving_minor(), called from the outermost
microtask-pump boundary (js_promise_run_microtasks, depth==1). At that point
the JS stack has fully unwound: no live register temporaries, every live heap
value is a named local on the shadow stack or a registered root. So the
copying minor is eligible with PRECISE, rewritable roots and no
force_full_scan — it MOVES (compacting, O(survivors), no sweep). Trigger
detection (ArenaBytes/MallocCount) and re-baseline mirror the nursery-churn
arm; OldReclaim stays on its existing full-sweep path.

Purely additive and gated behind PERRY_GC_MOVING_SAFEPOINT (default off) while
the moving path is validated; the alloc-point fallback is untouched. Programs
that yield to the event loop (servers especially — the RSS-sensitive case)
get compacting young collection at safepoints.

* feat(gc): [gc-copy-minor] PERRY_GC_DIAG line for the copying minor

Adds observability for the moving (copying) young-gen minor: under
PERRY_GC_DIAG, each attempt logs whether it was eligible or the fallback
reason (barriers_inactive / conservative_stack / copy_only_roots /
pinned_young_* etc.), and each successful run logs copied_objects,
copied_bytes, promoted_objects, freed_bytes.

Without this the copying minor was invisible — PERRY_GC_DIAG only showed the
sweep/evac-policy paths, so there was no way to tell whether a nursery
collection actually moved survivors or fell back to the non-moving minor.
Needed to validate the safepoint-triggered moving minor (it revealed that
alloc-point minors fall back with conservative_stack by design while the
event-loop-safepoint minor runs eligible and copies survivors).

* fix(gc): defensive guard — never memmove a young object with an out-of-range size

Hardening for the copying minor (not a full fix; see the copying-minor
relocation issue). A genuine young/survivor object is always small — large
objects are allocated old-gen/malloc, never in the copying nursery — so a
"young" object classified with a size below the header size or above a nursery
block is a corrupt / mis-classified header (e.g. an off-heap typed-array
pointer whose preceding bytes coincidentally pass plausible_gc_header).

move_young previously trusted (*header).size unconditionally and could drive a
wild out-of-bounds std::ptr::copy_nonoverlapping -> SIGSEGV. This refuses to
relocate such an object (leaves it in place, which is correct for a real
off-heap object kept live by its own side-table) and surfaces it under
PERRY_GC_DIAG as [gc-move-guard]. It turns undefined behavior from a corrupt
header into a no-op; it does NOT catch a plausible-but-wrong *small* size, so
the root classification fix is still required.

* docs(gc): mark PERRY_GC_MOVING_SAFEPOINT experimental (exposes pre-existing relocation bugs)

* feat(gc): Phase 2/3 — defer alloc-point minor to loop back-edge safepoints (moving primary)

Extends the moving-GC project so the copying minor can be the PRIMARY collector
for tight synchronous loops, not just at the event-loop boundary. All gated
behind the experimental PERRY_GC_MOVING_SAFEPOINT opt-in (compile-time for the
codegen polls, runtime for the collection); default binaries are unchanged and
carry zero loop overhead.

Phase 3 (deferral, runtime): when moving mode is on, the alloc-point nursery-
churn arm no longer runs the conservative non-moving minor mid-expression — it
sets GC_SAFEPOINT_PENDING and returns, deferring the collection to the next
precise-root safepoint. A hard cap (256 MB committed) is the safety valve: a
pathological single mega-expression that reaches no safepoint falls back to the
non-moving minor so growth stays bounded.

Phase 2 (codegen polls): emit js_gc_loop_safepoint() at loop back-edges (after
clear_loop_body_shadow_slots, where the body expression has completed so roots
are precise). The runtime poll drains a pending deferral by running the moving
minor. Gated at compile time (moving_safepoint_polls_enabled) so default builds
emit nothing.

Status: validated end-to-end for the generic while/do-while/for back-edges
(poll fires; output byte-identical to the non-moving GC). KNOWN GAP: the
specialized/versioned for-loop lowering paths (i32-bound, packed-f64/i32/u32,
bulk-fill) and for-of/for-in don't emit the poll yet, so a hot loop on one of
those paths defers to the event-loop safepoint instead — the remaining Phase 2
codegen-coverage work, documented at emit_gc_loop_safepoint.

* feat(gc): make the moving (copying) GC the DEFAULT + opt-in incremental old-gen

Flips the moving GC on by default — it is now "the" GC, not an experiment. The
copying minor runs at the event-loop safepoint and at loop back-edge polls,
moving survivors (compacting, O(survivors), no sweep); the alloc-point path
defers to those safepoints. `PERRY_GC_MOVING_SAFEPOINT=0` is a single kill
switch that reverts to the non-moving path for regression bisection — not a
parallel fallback maze.

Changes:
- gc_moving_safepoint_enabled + moving_safepoint_polls_enabled default to ON
  (kill switch is an explicit =0/off/false), so both the runtime collection and
  the codegen loop polls are on by default and stay coherent.
- Lower the deferral hard cap 256->128 MB so a synchronous loop on a
  specialized lowering path that doesn't yet emit the poll can't balloon RSS
  before the alloc-point valve fires.
- Phase 4 (opt-in, PERRY_GC_INCREMENTAL, default off): unblock the incremental
  old-gen budgeted stepper without converting all 88 mutable root scanners —
  when on, registered_root_scanners_block_budgeted_gc() stops blocking on
  unbudgeted mutable scanners and the stepper runs them synchronously in its
  initial root-scan step (bounded initial-mark pause), then marks/sweeps the
  old gen incrementally.

Validated: default output byte-identical to the kill switch across a spread of
programs (classes/closures/Map/Set/WeakMap/async/recursion/JSON, retained
graphs, object-keyed maps), moving fires by default (copied 171 / promoted 6722
on the stress test), zero crashes. Known hardening items (test + harden in
place): PerryTS#6132 (codegen Array+TypedArray bug — corrupts the heap, so moving can
crash on those programs; highest priority), PerryTS#6133 (old-page force-evac), and
loop poll coverage on the specialized/for-of lowering paths.

* fix(codegen): PerryTS#6132 — typed-array element loop reads garbage when receiver is a member

`for (let i = 0; i < n.buf.length; i++) ... n.buf[i]` where `n.buf` is a
Uint32Array (or any typed array) accessed as a MEMBER expression returned
garbage / nondeterministic junk, corrupting the heap (which then crashed the
moving GC). Root cause: a member receiver with a loop-variable integer index and
no proven numeric layout was lowered via lower_legacy_array_index_get, which
inline-reads the value as a plain ArrayHeader — gc_type at `handle-8`, raw f64
slot at `handle+8+i*8`. A small typed array is allocated OFF the GC heap with no
GcHeader, so both reads land on unrelated bytes: the dispatch routes
nondeterministically and the "fast" path returns raw garbage.

Fix: route that case through lower_guarded_array_index_get instead. Its runtime
typed-feedback guard rejects non-plain arrays and takes the boxed fallback
(which dispatches typed arrays correctly), while plain arrays keep the inline
fast path — so regular-array member loops are unaffected and typed-array member
loops are correct. lower_legacy_array_index_get is now unused (retired).

Validated: the case matrix (member typed-array loop, first/only, after another
loop, in a helper, local-alias, direct) matches Node exactly; the moving-GC
stress test that used to crash ~4/5 runs now crashes 0/6; gc_valid / object-keyed
Map/WeakMap / high-volume loop / class+closure smoke unchanged. (A separate,
smaller non-GC nondeterminism remains in the heaviest mixed repro; filed apart.)

* chore(gc): include promoted_bytes in the [gc-copy-minor] diagnostic (CodeRabbit)

* fix(gc): loop back-edge polls are opt-in (they defeat vectorization) — keep event-loop moving default

compiler-output-regression caught it: emitting js_gc_loop_safepoint() at every
loop back-edge (default-on in the prior commit) inserts a CALL into hot numeric
loops, which defeats LLVM auto-vectorization and violates the native-region
"no runtime calls in hot loop" proofs (image_convolution, packed_f64 versioning,
h1_* buffer regions). That's a real, broad perf regression.

Split the concern:
- Phase 1 (moving minor at the EVENT-LOOP safepoint) stays the DEFAULT — it's
  pure runtime, no codegen change, no per-loop cost. Moving still fires by
  default (validated: gc_valid eligible=true, byte-identical).
- Phase 2/3 (loop back-edge polls + alloc-point deferral, making moving PRIMARY
  inside loops) is now opt-in behind PERRY_GC_MOVING_LOOP_POLLS (compile-time
  poll emission + runtime deferral/poll gate, kept coherent). Off by default
  until the poll is emitted only for loops that actually ALLOCATE, so
  numeric/vectorizable loops stay call-free.

Verified: default numeric-loop IR has 0 js_gc_loop_safepoint (was the
regression); PERRY_GC_MOVING_LOOP_POLLS=1 emits it; PerryTS#6132 matrix still matches
Node; gc_valid unchanged.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…g, not the + default hint (PerryTS#6139)

`` `${x}` `` desugared to a `+` chain, and `+` with a string operand coerces the
other operand ToPrimitive(default) — valueOf-first. So `` `${obj}` `` for an
object with both valueOf and toString took valueOf (e.g. "42") while the spec
(and `String(obj)`) use ToString → toString ("str"). Template substitutions,
Symbol.toPrimitive hint, and `String(obj)` all disagreed.

Fix: wrap each substitution in `Expr::StringCoerce` — the same `js_string_coerce`
/ ToString that `String(x)` uses — so the substitution is toString-first and the
surrounding concat sees a plain string. No-op for string/number/bool/null/
undefined/array/bigint substitutions (they already stringify identically);
corrects the object + Symbol.toPrimitive("string") cases.

Validated against Node: `` `${obj}` === String(obj)`` now true; the
Symbol.toPrimitive hint is "string"; nested templates, calls, arrays, bigint,
class toString, and multi-substitution templates all match byte-for-byte.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…onnects + real ws.readyState (PerryTS#6130)

Three defects behind "ws doesn't seem to work":

1. wss:// connects panicked the tokio worker ("Could not automatically
   determine the process-level CryptoProvider") — the final link
   feature-unifies rustls with BOTH ring (perry-ext-http-server) and
   aws-lc-rs (net/tls), so rustls 0.23 refuses to auto-pick and every
   TLS entry point must install a provider explicitly (PerryTS#4971 pattern).
   Both ws client-connect paths now do the idempotent install:
   perry-stdlib::ws (bundled-ws enables dep:rustls) and perry-ext-ws
   (direct rustls dep, already in the graph via tokio-tungstenite).

2. Instance ws.readyState always read undefined (plain dynamic
   PropertyGet against the NaN-boxed ws id), so the canonical
   `while (ws.readyState !== WebSocket.OPEN)` wait loop never exits
   even after 'open' fires. Wired as a native data getter:
   is_native_dispatch_member reroutes the bare read on ws client
   instances to a 0-arg NativeMethodCall, a new NATIVE_MODULE_TABLE
   row dispatches to js_ws_ready_state, and both runtimes track the
   npm-ws lifecycle (CONNECTING=0 / OPEN=1 / CLOSING=2 / CLOSED=3)
   via new is_closing/is_closed flags on WsConnection.

3. WebSocket.OPEN reading as undefined was already fixed by the
   ready-state constant fold (v0.5.1102, PerryTS#4070) — regression test
   added for the exact reported shape (plain named import compared
   inside an async function body).

Verified live against wss://echo.websocket.org: no panic, open fires,
readyState reaches OPEN, echo round-trip, post-close CLOSING/CLOSED.

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
proggeramlug and others added 27 commits July 14, 2026 23:39
…rew past 8 properties (PerryTS#6416)

`field_count` is the number of properties resident in an object's INLINE slots —
every reader treats `field_index >= field_count` as living in the overflow map. It
is NOT the property count: an object with 9 properties and 8 inline slots carries
`field_count == 8`, with the 9th spilled to overflow.

`js_object_delete_field` decremented it by one regardless. Deleting one property
from that 9-property object leaves 8 survivors — all of which now FIT inline — but
`field_count` became 7, so the last survivor's slot (index 7) sat at the boundary
and was read from the overflow map, which held nothing:

    const o = {};
    for (let i = 0; i < 8; i++) o["k" + i] = "v" + i;
    o.keep = "KEEP";
    delete o.k0;

    o.keep              // node: "KEEP"   perry: undefined
    Object.keys(o)      // still lists "keep"   <-- key survives
    JSON.stringify(o)   // drops it entirely    <-- value is gone

The key stays enumerable while its value vanishes, so the failure surfaces far from
the delete. It bites any object that ever grew past 8 properties, and the damage
scales: the corruption appears exactly when the property count drops from above 8
back to 8 or below.

After the shift the survivors occupy slots `0..new_count`, inline up to the
allocation's capacity — so the correct count is `min(new_count, alloc_limit)`.

Note the shift itself must keep reading by SLOT INDEX. Reading by name to move the
values would invoke a getter and store its result as a data property, silently
collapsing accessors — Next's module exports are `Object.defineProperty(…, {get})`,
and flattening them breaks every route. The test pins that down.

Next.js deletes a batch of `x-middleware-request-*` keys from the middleware's
header object; the surviving `x-middleware-rewrite` then read back `undefined`, so
Next concluded there was no rewrite, treated the middleware response as final, and
served `NextResponse.next()`'s null body — a 200 with the right headers and zero
bytes, on every page.

Found while compiling a real Next.js 16 app. Covered by
`test_gap_delete_field_count_boundary`: the 9-property case, 766 exhaustive
build/delete/compare cases against a `Map` model (keys, values, `JSON.stringify`,
`Object.entries`), re-adding after a delete, `for-in`/spread agreement, and an
accessor surviving a delete on the same object. Byte-identical to node.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…oke the capture (PerryTS#6414)

* fix(transform): inlining a function that closes over its own local broke the capture

    function mk() { let k = 0; return () => { for (const x of [1]) { k = 7; } return k; }; }
    const g = mk();
    g();   // undefined, expected 7

`is_inlinable` refused to inline a function whose body builds a closure over one
of its PARAMETERS ("the parameter IDs won't exist in the outer context") but said
nothing about its LOCALS — and a captured-and-mutated local is *boxed*.

The closure body is compiled once, from the original function, and reads its
capture slot as a box pointer (`js_closure_get_capture_bits` ->
`js_box_get_bits`). Cloning the callee's body into a call site re-derives the
local there as a plain slot, so the call site stores the local's *value* into the
capture slot instead of a box:

    ; the out-of-line copy — correct
    %box = call i64 @js_box_alloc_bits(...)
    call void @js_box_set_bits(i64 %box, i64 0)
    store i64 %box, ptr %captures            ; capture 0 = the box
    call i64 @js_closure_alloc_with_captures_singleton(ptr @closure, i32 1, ptr %captures)

    ; the inlined copy — the same closure, a different capture layout
    store i32 0, ptr %k                      ; k as a plain i32 local, no box
    %v = ... bitcast double 0.0 to i64
    store i64 %v, ptr %captures              ; capture 0 = the VALUE
    call i64 @js_closure_alloc_with_captures_singleton(ptr @closure, i32 2, ptr %captures)

The closure then dereferences that value as a box pointer: every read comes back
`undefined` and every write from inside the closure is lost. It only bites when
the enclosing function is actually inlined — `mk()()` in place happened to work,
which is what made it so hard to see.

Widen the guard to the callee's own bindings: parameters *and* locals (`let`/
`const`, a `for` init, a `catch` param, and ids already marked for boxing).

* style: cargo fmt

---------

Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…eader (PerryTS#6425)

`Headers.get(name)` returns a NULL `*mut StringHeader` when the header is absent,
but the codegen unconditionally NaN-boxed that pointer as a STRING. So a missing
header read back as a value whose `typeof` was "string" and which was not `===
null`:

    const h = new Headers();
    typeof h.get("x-forwarded-host");        // node: "object"   perry: "string"
    h.get("x-forwarded-host") === null;      // node: true       perry: false
    h.get("x-forwarded-host") ?? "fallback"; // node: "fallback" perry: the boxed null

The nullish-coalescing and `||` fallbacks that WHATWG code relies on therefore
never fired. Mirror `UrlSearchParamsGet`: when the returned pointer is null, box
`TAG_NULL` instead of a string.

Auth.js v5 (next-auth) builds its base URL under `trustHost` with
`h.get("x-forwarded-host") ?? h.get("host")`. With the left operand a non-nullish
boxed-null, the `??` kept it, so the builder did `new URL("://")` and threw
`TypeError: Invalid URL` — a 500 on every authenticated page render.

Found while compiling a real Next.js 16 + Auth.js app. Covered by
`test_gap_headers_get_null_missing` — a present header (a real string), an absent
header (`typeof` object, `=== null`, `String()` "null"), `??` / `||` fallthrough,
the exact Auth.js trustHost URL-builder pattern producing a valid URL, and present
reads still working after a missing one. Byte-identical to node.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
PerryTS#6426)

The `Request` constructor runs `ToString(input)` when `input` isn't already a
`Request`, so a `URL` object must stringify to its href. The codegen extracted a
raw string pointer from the first arg (`js_get_string_pointer_unified`), which
only unwraps an actual string value — handed a URL object (a heap `ObjectHeader`)
it read the object pointer as a string and produced `""`:

    const u = new URL("http://localhost/api/auth/session");
    new Request(u).url;   // node: "http://localhost/api/auth/session"   perry: ""

New `js_request_input_to_url` coerces the input: a `Request` handle returns its
cloned url (a Request has no custom `toString` — `String(request)` is `"[object
Request]"` — so ToString would store that literal), and anything else goes
through `js_jsvalue_to_string`, which invokes `toString` on an object (URL → href)
and passes a string straight through.

Auth.js v5 builds its session request as `new Request(makeSessionUrl(...))` where
the helper returns a URL object. With an empty url the session lookup returned 400
"Bad request.", so `auth()` yielded that string instead of `null`, and the
authenticated-page guard `if (!session?.user?.id) redirect(...)` mis-decided —
falling through to code that then threw. With this fix `auth()` correctly returns
`null` for an unauthenticated request and the guard redirects.

Found while compiling a real Next.js 16 + Auth.js app. Covered by
`test_gap_request_url_object` — a string input, a URL object, a URL object plus
init, a URL with a path and query, a runtime init still applying its method
(PerryTS#5458 guard), and `String()`/url consistency. Byte-identical to node.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…d on non-object" (PerryTS#6418)

Perry represents native web objects — Headers, Request, Response, sockets, streams
— as handle ids: a POINTER_TAG'd small integer, not a heap `ObjectHeader`.
`reflect_value_is_object` rejected every POINTER_TAG value whose address was below
4 GB (`lower48 < 0x1_0000_0000`), which is exactly the handle band, so the whole
`Reflect.*` family refused them:

    const h = new Headers();
    Reflect.get(h, "get");   // node: [Function]   perry: TypeError: Reflect.get called on non-object

A handle-backed object IS an object to JS. `reflect_value_is_object` now admits a
value whose masked address is in the handle band or the stream-id band (non-zero),
before the sub-4GB cutoff that screens out tagged non-pointers.

Next.js's app-route runtime wraps the request in a Proxy whose `get` trap forwards
through `Reflect.get(target, prop, receiver)`, so every route that read the request
— i.e. every authenticated API route — 500'd with this TypeError instead of running.

Found while compiling a real Next.js 16 + Auth.js app: `/api/sites` and
`/api/dimensions` went from 500 to their correct JSON bodies. Covered by
`test_gap_reflect_handle_band_objects` — `Reflect.get`/`has` on Headers / Request /
Response, a method read through Reflect and invoked against its receiver, a Proxy
`get` trap forwarding to `Reflect.get` (Next's shape), a plain heap object still
working, and a primitive still being refused. Byte-identical to node.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…erryTS#6427)

The `in` operator runs ToPropertyKey on its left operand. Object property names
are strings, so a NUMBER key must be coerced to its string form before the
lookup: `307 in {307: …}` is `"307" in {…}` and must be true. `js_object_has_property`
only matched a key that was already a string, so a numeric key never matched a
numeric-string property:

    const o = { 307: "a" };
    307 in o;     // node: true    perry: false
    "307" in o;   // node: true    perry: true

A number key is now run through `js_to_property_key` (ToPropertyKey) at the top of
the operator, before the proxy / handle / heap-object paths, so every receiver
sees the coerced string. Strings and symbols pass through unchanged; arrays were
unaffected because they resolve a numeric index through a separate path.

Next.js's `isRedirectError` classifies a thrown redirect with
`Number(digest.at(-2)) in RedirectStatusCode`, where `RedirectStatusCode` is a
`{307: …, 308: …, 303: …}` map. With `307 in map` returning false, a `redirect()`
thrown from a Server Component was not recognized as a redirect: Next treated it
as a genuine error, so a concurrently-rendered sibling's `session.user` read —
guarded by that same redirect on the happy path — surfaced as a fatal 500 instead
of the intended 307. Every authenticated page was affected.

Found while compiling a real Next.js 16 + Auth.js app: `/dashboard`, `/settings`
and their locale-prefixed forms went from 500 to their correct 307 redirects.
Covered by `test_gap_in_operator_numeric_key` — numeric keys (int and float),
`Number()` results, computed keys, a non-integer number, array index membership,
string keys (present and absent), a symbol key, and the exact `isRedirectError`
shape. Byte-identical to node.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](actions/setup-node@v4...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ryTS#6419)

Bumps the cargo-minor-and-patch group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [toml](https://github.com/toml-rs/toml) | `1.1.2+spec-1.1.0` | `1.1.3+spec-1.1.0` |
| [console](https://github.com/console-rs/console) | `0.16.3` | `0.16.4` |
| [regex](https://github.com/rust-lang/regex) | `1.12.4` | `1.13.0` |
| [socket2](https://github.com/rust-lang/socket2) | `0.6.4` | `0.6.5` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.23.4` | `1.23.5` |
| [lru](https://github.com/jeromefroe/lru-rs) | `0.18.0` | `0.18.1` |
| [redis](https://github.com/redis-rs/redis-rs) | `1.3.0` | `1.4.0` |
| [bytes](https://github.com/tokio-rs/bytes) | `1.12.0` | `1.12.1` |
| [mongodb](https://github.com/mongodb/mongo-rust-driver) | `3.7.0` | `3.8.0` |
| [rustls](https://github.com/rustls/rustls) | `0.23.41` | `0.23.42` |
| [http-body-util](https://github.com/hyperium/http-body) | `0.1.3` | `0.1.4` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.66` | `1.2.67` |


Updates `toml` from 1.1.2+spec-1.1.0 to 1.1.3+spec-1.1.0
- [Commits](toml-rs/toml@toml-v1.1.2...toml-v1.1.3)

Updates `console` from 0.16.3 to 0.16.4
- [Release notes](https://github.com/console-rs/console/releases)
- [Changelog](https://github.com/console-rs/console/blob/main/CHANGELOG.md)
- [Commits](console-rs/console@0.16.3...0.16.4)

Updates `regex` from 1.12.4 to 1.13.0
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](rust-lang/regex@1.12.4...1.13.0)

Updates `socket2` from 0.6.4 to 0.6.5
- [Release notes](https://github.com/rust-lang/socket2/releases)
- [Changelog](https://github.com/rust-lang/socket2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/socket2/commits/v0.6.5)

Updates `uuid` from 1.23.4 to 1.23.5
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](uuid-rs/uuid@v1.23.4...v1.23.5)

Updates `lru` from 0.18.0 to 0.18.1
- [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md)
- [Commits](jeromefroe/lru-rs@0.18.0...0.18.1)

Updates `redis` from 1.3.0 to 1.4.0
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](redis-rs/redis-rs@redis-1.3.0...redis-1.4.0)

Updates `bytes` from 1.12.0 to 1.12.1
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](tokio-rs/bytes@v1.12.0...v1.12.1)

Updates `mongodb` from 3.7.0 to 3.8.0
- [Release notes](https://github.com/mongodb/mongo-rust-driver/releases)
- [Commits](mongodb/mongo-rust-driver@v3.7.0...v3.8.0)

Updates `rustls` from 0.23.41 to 0.23.42
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](rustls/rustls@v/0.23.41...v/0.23.42)

Updates `http-body-util` from 0.1.3 to 0.1.4
- [Release notes](https://github.com/hyperium/http-body/releases)
- [Commits](hyperium/http-body@http-body-util-v0.1.3...http-body-util-v0.1.4)

Updates `cc` from 1.2.66 to 1.2.67
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](rust-lang/cc-rs@cc-v1.2.66...cc-v1.2.67)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.3+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: console
  dependency-version: 0.16.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: regex
  dependency-version: 1.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-minor-and-patch
- dependency-name: socket2
  dependency-version: 0.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: uuid
  dependency-version: 1.23.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: lru
  dependency-version: 0.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: redis
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-minor-and-patch
- dependency-name: bytes
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: mongodb
  dependency-version: 3.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-minor-and-patch
- dependency-name: rustls
  dependency-version: 0.23.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: http-body-util
  dependency-version: 0.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
- dependency-name: cc
  dependency-version: 1.2.67
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [wasm-encoder](https://github.com/bytecodealliance/wasm-tools) from 0.252.0 to 0.253.0.
- [Release notes](https://github.com/bytecodealliance/wasm-tools/releases)
- [Commits](https://github.com/bytecodealliance/wasm-tools/commits)

---
updated-dependencies:
- dependency-name: wasm-encoder
  dependency-version: 0.253.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(fs): propagate write errors

* fix(fs): map EBADF and the write-side errnos to their Node codes

The propagation this PR adds worked, but the CODE was wrong: a write to a
closed descriptor reported

    write string sync: EIO write      (node: EBADF write)

`io_error_code` matches the raw errno first, but its table had no EBADF arm —
and Rust has no `ErrorKind` for it either, so it fell through to the catch-all
`_ => "EIO"`. `io_error_errno` already returned the raw errno, so only the code
string was lost.

Add EBADF plus the other descriptor/write-side errnos that hit the same hole
(EPIPE, EROFS, EFBIG, ESPIPE, EBUSY, EMFILE, ENFILE, EXDEV). Each only replaces
a wrong "EIO" with the correct code.

test_gap_fs_write_error_propagation is now byte-identical to node, and
test_gap_fs_fd_2749 / test_gap_fs_errprop_2735plus / test_gap_fs_errprop2_2745plus
/ test_gap_node_fs still pass.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
…n Response.json and super(body, init) (PerryTS#6424)

* fix(fetch): honor a runtime init object's status/statusText/headers in Response.json and super(body, init)

`Response.json(data, init)` and `new Response(body, init)` extracted the init's
`status` / `statusText` / `headers` only when `init` was an object LITERAL the
codegen could read at compile time. A runtime init — a bound variable, a function
parameter, or another `Response` used as init — was dropped, so the response
silently defaulted to status 200. Two paths were affected:

1. `Response.json`'s codegen (`extract_options_fields`) returned `None` for a
   non-literal init and skipped the whole init. It now falls back to reading the
   fields at runtime via `js_object_get_field_by_name_f64`, mirroring the runtime
   path `new Response(body, init)` already had.

2. `super(body, init)` in a subclass of the native `Response` routes through
   `global_this_fetch_option`, which bailed to `undefined` for a handle-backed
   init (`is_valid_obj_ptr` rejects a fetch-band handle id). A Response used as
   init is such a handle, so it now hands the handle to
   `js_object_get_field_by_name_f64`, which resolves `.status` etc. through the
   handle property dispatch.

`Response.json(x, {status: 401})` therefore returned 401 at module scope (literal
init) but 200 the instant the init flowed through a variable — which is exactly
what `NextResponse.json` does:

    class NextResponse extends Response { /* ... */ }
    NextResponse.json = (body, init) => {
      const r = Response.json(body, init);        // init is a runtime var here
      return new NextResponse(r.body, r);         // r (a Response) used AS init, via super()
    };

So under Next.js every authenticated route's intended 401 became a 200.

Found while compiling a real Next.js 16 + Auth.js app: `/api/sites` and
`/api/dimensions` went from 200 to their correct 401. Covered by
`test_gap_response_json_runtime_init` — literal init, a module-scope variable, a
function parameter, static and instance methods, a Response-as-init forwarded
through a subclass `super(body, init)` (NextResponse.json's shape), statusText, the
default (no init), and `new Response(body, runtimeInit)`. Byte-identical to node.

* fix(codegen): don't deref a non-object Response.json init

The runtime-init read this PR adds unboxed `init` to a raw pointer and handed it
straight to js_object_get_field_by_name_f64. `init` is a runtime value that need
not be an object — `Response.json(x, 3.14)` is legal TS — and a non-integer
double's bits land inside the heap-pointer magnitude window, so the read
dereferenced a forged pointer and SIGSEGV'd (exit 139 on `3.14`; `123`/`true`/
strings happened not to, which is worse: intermittent).

Add js_object_get_field_by_name_boxed, which takes the BOXED receiver and
applies the same handle-band / is_valid_obj_ptr guard the runtime fetch-option
reader already uses, returning `undefined` fields for a non-object. Codegen calls
it with the boxed value instead of re-implementing the pointer checks in IR
(which is the anti-pattern that produced the segfault). A real init object still
has its `status`/`statusText`/`headers` honored — verified byte-identical to node,
and the literal-init path is unchanged.

Note: node THROWS a TypeError for a non-nullish non-object init; perry defaults
to 200 (which is the pre-PR behavior for a dropped init too). Matching that throw
is a separate, pre-existing parity gap, not this segfault fix.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
…ies (PerryTS#6428)

A `var` is function-scoped and hoisted: a declaration that appears
textually AFTER a statement that reads or writes it is still bound (as
`undefined`) from function entry. The function-declaration / arrow path
(`predefine_var_bindings_in_function_body`) emits an undefined-initialised
entry slot for every such `var`, but the function-EXPRESSION path did not:
its top-level `var` pre-pass (issue PerryTS#838) pre-registered the hoisted local
and added it to `hoisted_id_set` — which only makes the forward-CAPTURE
case work (a closure created before the declaration reads the box). A
`var` merely read/written before its own textual declaration but NOT
captured by any closure got no prealloc box and no entry slot, so its
storage first materialised at the late `var x = …` statement and every
earlier read folded to a constant `undefined`.

React 19's `cloneElement`/`createElement` are exactly this shape: one
hoisted `propName` drives a `for (propName in config)` loop and is then
redeclared `var propName = arguments.length - 2`. Compiled as a function
expression (`exports.cloneElement = function (…) {…}`), the loop body's
`hasOwnProperty.call(config, propName)` saw `propName === undefined`, so
the `!hasOwn(...) || …` chain short-circuited and `props[propName] =
config[propName]` never ran — cloneElement dropped every config prop.
Downstream, Radix `Slot` (shadcn `<Button asChild>`) merges its props onto
its child via cloneElement, so `<Button asChild><Link/></Button>` rendered
a bare `<a href>` with no `data-slot`/`data-variant`/`className`.

Fix: the top-level `var` pre-pass now also pushes the undefined-initialised
entry `Let` into `nested_var_prologue`, exactly like the nested-`var`
pre-pass a few lines below (and the fn-decl/arrow path). HIR was already
correct (one shared LocalId for both the loop use and the late redecl);
only the entry slot was missing.

Repros at O0, so it is a lowering bug, not an LLVM-optimisation artifact.
Added test-files/test_gap_var_hoist_forin_fn_expr.ts covering the
cloneElement shape plus forward-var reads in function expressions, methods,
arrows, and if-branch `var`s.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Bumps [bcrypt](https://github.com/Keats/rust-bcrypt) from 0.17.1 to 0.19.2.
- [Commits](Keats/rust-bcrypt@v0.17.1...v0.19.2)

---
updated-dependencies:
- dependency-name: bcrypt
  dependency-version: 0.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…y memory (PerryTS#6429)

`js_uint8array_alloc` set the header and relied on the backing block already
being zero. It isn't: `buffer_alloc` allocates through `arena_alloc_gc_old`,
and the old generation reclaims and re-hands dirty blocks — only pristine mmap
pages read as zero. So `new Uint8Array(n)` (and the bool / string /
numeric-length sources that funnel through it) could observe stale bytes, which
is a spec violation: a length-constructed typed array is zero-initialized.

The sibling paths already know this. `js_buffer_alloc` (Buffer.alloc) does
`write_bytes(data, fill, size)`, and `zeroed_array_buffer_storage` carries the
exact comment — "buffer_alloc does not zero, but ArrayBuffer per ECMAScript
spec must observe zero-initialized bytes." `js_uint8array_alloc` was the lone
sibling that forgot; this adds the same explicit zero-fill.

Latent on macOS (length-constructed arrays land on fresh, zero mmap pages) and
on current main even on Linux (they happen to land on clean blocks). It surfaced
under an unrelated codegen change (PerryTS#6407 expands BUFFER_PROTOTYPE_METHODS 11->93,
shifting -O3/LTO layout) which moved these allocations onto reclaimed dirty
blocks: test_gap_uint8array_source_dispatch then printed garbage, non-
deterministic bytes for the 'boolean' / 'string' length cases. Reproduced on a
Linux box: 20/20 runs correct with this fix, byte-identical to Node; reverting
it fails.

No new gap test: the defect needs the arena to re-hand a specific dirtied block,
which neither macOS nor main-on-Linux reproduces on demand, so any portable test
would pass with or without the fix (it would not prove it can fail). The fix is
unconditionally correct — zero-filling a spec-zero-initialized buffer can never
regress a caller.

Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
…itable (PerryTS#6431)

Large static files served through `fs.createReadStream(path).pipe(res)` on an
http `ServerResponse` were truncated at the first 64 KB (high-water-mark) chunk:
the server sent a correct `Content-Length` header but only 65536 bytes of body
and then hung the connection. In a Next.js standalone app this left every page
whose `_next/static/chunks/*.js` exceed 64 KB stuck on its loading fallback,
because the chunk `<script>` never finished downloading.

Two independent server-side bugs combine to cause it; both are fixed here.

1. `res.once(event, cb)` was silently dropped. The `ServerResponse` handle
   dispatcher had an `"on" | "addListener"` arm but no `"once"`, and `"once"`
   was absent from the ServerResponse method vocabulary, so the call fell
   through to a no-op. Perry's own pipe pump re-arms `res.once('drain')` after
   every backpressure pause, so the drain callback never fired and the pump
   stalled. Fixed with real one-shot listener support: a `once_listeners` map
   alongside the persistent `listeners` map, `js_node_http_res_once`, and a
   `take_event_listeners` helper that fires `on` listeners (retained) then
   `once` listeners (removed, so each runs exactly once) at every event
   consumption site (drain / finish / close), plus the `"once"` /
   `"prependOnceListener"` dispatch arm and vocabulary entries.

2. `res.socket.writable` was `undefined`. Node's `res.socket` is the Duplex TCP
   socket whose `writable` is `true` during an active exchange; Perry aliases
   `res.socket` to the request handle (there is no separate socket object), so a
   read of `res.socket.writable` resolves on the IncomingMessage and was
   `undefined`. That broke the `on-finished` package's readiness probe —
   `isFinished(res) = Boolean(res.finished || (socket && !socket.writable))` —
   which returned `true` the instant a stream was piped. The `send` package
   (Next.js `serve-static`) treats that as "response already finished" and
   destroys the piped read stream one tick later, truncating the body. Fixed by
   exposing `writable` on the IncomingMessage property dispatch, returning
   `true` while the connection is alive (`!destroyed && !aborted`). This is
   symmetric to the existing `readable` accessor, which was added for the
   request-body side of the same `on-finished` / socket-alias interaction.

Coverage: unit tests for the one-shot listener semantics (fire-once,
persistent-refire, on+once ordering) in `response.rs`, and a gap test
(`test_gap_http_res_socket_writable_onfinished.ts`) asserting
`isFinished(res) === false` at pipe-setup, byte-identical to Node.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…ody: req) (PerryTS#6433)

Two related gaps in reading a Node http request's body on the Web-platform side,
both hit by frameworks that bridge a Node `IncomingMessage` into `fetch`
primitives (Next.js's App Router / Auth.js).

1. `http.IncomingMessage` was not async-iterable — `for await (const chunk of req)`
   threw `is not iterable` and yielded 0 bytes (Node yields the body). Perry
   represents the request as a native handle that never exposed
   `[Symbol.asyncIterator]`. Next.js's `requestToBodyStream` reads the body with
   exactly `for await (const chunk of stream)`, so an empty body reached the
   consumer. Fixed by:
   - `js_make_single_value_async_iterator` (perry-runtime) — a one-shot async
     iterator; perry buffers the whole request body before the handler runs (its
     `.on('data')` emits `body_bytes` in a single shot), so yielding `req.rawBody`
     once reproduces Node's observable `for await` result without wiring the
     handle into node:stream's event registry (which keys on a JS-object identity
     the handle lacks);
   - a generic small-handle `Symbol.asyncIterator` resolver in
     `js_object_get_symbol_property` that delegates to the handle property
     dispatch (mirrors the existing `Symbol.dispose` / `Symbol.asyncDispose`
     handling), plus the `@@asyncIterator` method + vocabulary entries on the
     `IncomingMessage` dispatch.

2. `new Request(url, { body: req })` where `req` is a Node `IncomingMessage`
   dropped the body. The `Response` constructor already reads such a body via
   `rawBody` (PerryTS#5437), but the `Request` constructor only handled Buffer /
   typed-array / Blob / string bodies. Factored the `IncomingMessage` reader out
   of `js_response_body_init_ptr` into a shared
   `incoming_message_raw_body_bytes` and used it in the `Request` constructor —
   probed in both the handle-band and pointer arms (the IM handle is in the
   handle band, so a plain `if handle_band { blob } else` routed it to the Blob
   reader and lost the body).

Gap tests: `for await…of req` byte count, and
`new Request(url, { body: req }).text()` — both byte-identical to
`node --experimental-strip-types`.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…rryTS#6435)

Three independent defects broke `pool.query`/`pool.execute` against a real
MySQL 8 server (surfaced as an Auth.js credentials `authorize` DB lookup
failing with CallbackRouteError):

1. RSA auth disabled. MySQL 8 defaults to `caching_sha2_password`, whose
   full-auth handshake over a non-TLS connection needs an RSA public-key
   exchange. sqlx gates that on its `mysql-rsa` feature, which perry-ext-mysql2
   did not enable — so every connect failed with "RSA auth backend disabled".
   `rsa` is already in Cargo.lock, so enabling the feature adds no new deps.

2. Double-encoded credentials. Node's mysql2 percent-DECODES the user/password
   it takes out of a connection URL; perry used the raw substring and then
   re-encoded it for sqlx, double-encoding every reserved character. A `%`,
   `@`, or `:` in the password produced a wrong password and `1045 Access
   denied`. Added `percent_decode` and applied it in `parse_mysql_uri`.

3. f64 param ABI mismatch. The `js_mysql2_*_query`/`_execute` runtime entry
   points take the params array as `params_f: f64` (NaN-boxed, passed in a
   float register), but the codegen native table declared that argument as
   NA_PTR — an i64 in an integer register. `params_f` then read uninitialized
   float-register bits (~0x8000000000000000), so parameterized queries saw
   garbage instead of the bound array. Changed the 12 mysql2 query/execute
   rows to NA_F64 so the argument is passed in the register the callee reads.

Adds percent_decode unit tests and a percent-encoded-URI parse test.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…nothing (PerryTS#6436)

The idiomatic server layout

    // server.ts
    export function buildServer(): FastifyInstance { return Fastify(); }
    // main.ts
    async function main() {
      const app = buildServer();
      await app.listen({ port });
    }

compiled clean, ran, exited 0 — and served nothing. `listen` lowered to
`dynamic_boundary:runtime_api` and hit codegen's unknown-native-method arm,
which returns 0.0, so the await resolved on a no-op and the process looked
like a healthy server that had simply finished. No diagnostic at any stage:
`perry check` and `--type-check` were both clean, and the same code inside a
single module works, which makes this very expensive to localize.

Two independent gaps had to line up:

1. `lower_decl/fn_decl.rs` matched the return-type annotation against its own
   hand-rolled allowlist, which had drifted behind both the parameter paths
   and the shared `native_instance_from_return_type` table — it knew Redis,
   Pool and WebSocket but none of the Fastify types, so `buildServer` never
   landed in `exported_func_return_native_instances`. The Fastify types were
   taught to the *parameter* lists (fn_decl, expr_function) and never to the
   *return* lists. Fixed by routing fn_decl and the exported-arrow path in
   `module_decl.rs` through the shared table and adding the Fastify entries
   there, so there is one list to teach instead of five.

2. `js_transform/cross_module_natives.rs` only recognised the consumer shape
   `Stmt::Let { init: Some(Call) }` and never traversed `Labeled`/`DoWhile`.
   Async lowering turns `const app = buildServer()` inside an async function
   into a hoisted box plus `Expr(LocalSet(0, Call(buildServer)))` nested in
   the generator state machine (`Try > Labeled > DoWhile > If`), so the scan
   neither reached the statement nor matched its shape — even though
   `fix_native_instance_stmt` already walked both statement kinds. That
   asymmetry is why only *async* consumers were affected.

Regression test compiles the two-module factory shape, runs the binary and
curls the port; it fails on the pre-fix compiler with "process exited —
listen() no-opped" and passes after. It must keep `await app.listen(...)`
inside an async function: a non-async main passes even with the bug present.

Verified: repro binds and serves; 889 perry-hir/codegen/types tests green
(x3 runs); test_cross_module_getter, test_chained_cross_module_getter and
test_namespace_const_cross_module still pass; fmt clean, no new clippy.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…ve ext (PerryTS#6437)

A Next.js/webpack/turbopack build INLINES `mysql2` into the server bundle
under a numeric module id, so perry never sees a bare `import "mysql2"` and
the import-keyed native-extension redirect (perry-ext-mysql2) doesn't fire.
The inlined JS mysql2 then runs, and its row-parser JIT (`generate-function`
→ `Function.apply(null, [...params, bodyString])`) builds a function from a
RUNTIME string — which an ahead-of-time-compiled binary cannot execute. Every
query throws (surfaced on a real app as an Auth.js credentials `authorize`
lookup dying with `CallbackRouteError`; the underlying error is a misleading
"Function.prototype.apply was called on a value that is not a function").

Recognize the bundled call at HIR lowering by the mysql2 config-object
SIGNATURE and route it to perry-ext-mysql2 regardless of how mysql2 was
imported or bundled:

- perry-hir `native_module.rs`: a `createPool`/`createConnection` whose sole
  config argument is an object carrying BOTH a mysql connection key
  (`uri`/`host`/`socketPath`) AND a mysql2-specific driver/pool option
  (`connectionLimit`, `waitForConnections`, `queueLimit`, `namedPlaceholders`,
  …) lowers to `NativeMethodCall{module:"mysql2/promise", …}`, dropping the
  opaque JS receiver. Downstream typing tags the result `Pool`, so
  `pool.execute`/`pool.query` dispatch natively too. The signature is tight
  enough to be mysql/mysql2-exclusive (generic-pool passes a factory object;
  pg uses `new Pool()`); a non-literal config falls through unchanged.

- Closed object literals lower to `New{class_name:"__AnonShape_*"}` with the
  keys stripped into the shape class, so a `anon_shape_fields` reverse map
  (class name -> field names) is added to the lowering context to recover the
  config keys.

- perry-codegen `ext_registry.rs`: tag the `js_mysql2_*` FFIs `WellKnown("mysql2")`
  so emitting them off codegen provenance (no import) flips the `[bindings.mysql2]`
  well-known and links the staticlib — same mechanism as the http/net/events rows.

Verified end-to-end against a live MySQL 8 server: an opaque
`bundle.i(id).default.createPool({uri, waitForConnections, connectionLimit, …})`
(and the same inside a factory arrow, capturing the pool in nested query/execute
functions — the turbopack shape) now runs on native mysql2 instead of the JS
receiver. Adds unit tests for the signature matcher.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…like Node (PerryTS#6430)

* fix(crypto): run WebCrypto digest + async randomBytes on a macrotask like Node

Node runs `crypto.subtle.digest` and the callback form of
`crypto.randomBytes(size, cb)` on the libuv threadpool, so `await`ing them
observably yields a macrotask — the promise/callback settles on a later
event-loop iteration, never synchronously. Perry computed the bytes eagerly and
resolved synchronously (+0 hops), so an `await`ing caller continued a full
event-loop iteration ahead of Node.

That timing is observable. Auth.js v5 hashes its CSRF token with
`subtle.digest`, so a Next.js Server Component's `await auth()` finished a
macrotask early under Perry, which collapsed React's Flight (RSC) streaming
waves and renumbered the serialized rows of the response versus Node.

Fix: both now schedule their settlement through `setImmediate` (the timer/
callback queue) instead of resolving inline. The bytes are still produced
eagerly; only the callback/promise dispatch is deferred, so values are
unchanged. `subtle.digest` re-arms once (Node's threadpool digest yields ~2
setImmediate ticks); `randomBytes` defers one tick. The deferred Promise/Buffer
survive GC via the timer root scanner (`scan_timer_roots`).

Verified: digest and randomBytes values are byte-identical to Node
(`sha256("hello")` etc.); a synchronous `createHash(...).digest()` still crosses
zero macrotasks; the promise/stream microtask-hop harnesses are unaffected.
Added test-files/test_gap_webcrypto_async_threadpool.ts pinning the observable
contract (async crypto crosses a macrotask, sync hash does not, values unchanged).

* style(crypto): rustfmt digest/random deferral

* fix(crypto): avoid handle-floor pattern in randomBytes deferral

Use JSValue::is_pointer() + js_nanbox_get_pointer for the callback closure
instead of a raw `bits & mask + >= 0x1000` floor check, so the addr-class
ratchet doesn't gain a handle-floor site. Behavior unchanged (randomBytes
callback still deferred one setImmediate tick; verified +1 hop, values correct).

* harden(crypto): gate randomBytes async callback on is_closure_ptr

The comment said "only a genuine closure is a schedulable callback" but the
guard tested `is_pointer()`, which also accepts a non-function object/array
(`randomBytes(n, {})`). Match the guard to its stated intent and to every other
node-style-callback site (`is_closure_ptr` / `is_callable_value`): validate the
CLOSURE_MAGIC at the source instead of relying on the timer's downstream check.

Not a crash fix — `js_closure_call2` already routes through
`get_valid_func_ptr`, which range-checks the address and rejects a non-magic
tag, so a mis-typed callback was already a safe no-op (verified: the pre-change
`is_pointer` build survives `randomBytes(16, {})` / `[..]` / `42`). This just
rejects it one layer earlier and keeps the guard honest. Node throws
ERR_INVALID_ARG_TYPE for these; matching that (vs the current silent no-op,
which predates this PR) is a separate parity item across all crypto async
callbacks.

* style(crypto): drop the redundant handle-band literal from the callback guard

The address-classification ratchet flagged `cb_ptr >= 0x10000` as a bare band
literal. It is redundant anyway: `is_closure_ptr` opens with
`if is_handle_band(ptr) return false` and bounds the address before the
CLOSURE_MAGIC probe, so it already rejects the whole handle band and any
non-heap value. Call it directly.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
…lue, and stop ext-lib handle ids colliding (PerryTS#6407)

Three defects on the path a bundled `mysql2` takes to open its socket.

**1. `net.connect` had no arm in the native-module dispatch.** `const net =
require('net'); net.connect(port, host)` — how turbopack's CJS externals wrapper
calls it — arrives at the runtime's native-module dispatch rather than the static
codegen table. `connect` / `createConnection` were not registered as callable
exports there, so they read as non-callable and the connection was never opened.
Both names now route to the same event-driven socket factory the static path uses.

**2. The stdlib bound to the wrong twin symbol.** `js_net_socket_connect` and the
listener entry points have twins in both the bundled stdlib and the ext-net
staticlib (PerryTS#5010/PerryTS#5021's twin-symbol disease). Binding to the wrong one splits the
socket registry from the `.on('data')` listener registry, and the handshake then
hangs with the bytes silently dropped. perry-ext-net now exports distinct
`js_ext_net_socket_connect` / `js_ext_net_socket_on` / `_once` /
`_remove_listener` / `_remove_all_listeners` symbols, and the stdlib's dispatch is
cfg-split to call the implementation that OWNS the registries in that build.

**3. Every ext lib minted handle ids privately from 1.** They all draw from the
shared `[1, 0x40000)` band, so ext-net socket PerryTS#1 and ext-http-server's server PerryTS#1
were the same number — and the composite handle-method dispatch routed the call to
whichever extension *thought* it owned it. That is how `socket.on('data', …)` got
claimed by the HTTP server: the listener registered on the wrong object and the
socket's bytes reached nobody. New `perry_ffi::reserve_handle_id()` mints a
globally-unique id without storing a value, for subsystems that keep their own
object map; ext-net now uses it.

Found while compiling a real Next.js + MySQL app. Covered by
`test_gap_net_connect_bound_value` — `typeof net.connect`, a full socket round
trip through `connect()` as a bound value with a connect-listener callback, and
the same through `createConnection()`. Byte-identical to node.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…cks Auth.js login body parse) (PerryTS#6434)

* fix(fetch): `"prop" in Request/Response` reports real properties, not false

The `in` operator on a Web Fetch handle reported `false` for every key. Perry
represents `Request` / `Response` / `Headers` as native handle-band registry ids
(not heap objects), and `js_object_has_property` took a blanket shortcut —
returning `false` for all handle-band values to avoid dereferencing the id as a
pointer. But a `Request` genuinely has `body` / `method` / `url` / `headers`, so
`"body" in request` was wrongly `false`.

That broke request-body reading in real frameworks: Auth.js's body parser gates
on `if (!("body" in e) || !e.body || …) return`, so with `"body" in request`
false it skipped parsing the credentials POST body entirely — the `csrfToken`
form field never reached the CSRF check and every login failed with
`MissingCSRF` (redirecting to `/auth/error?error=Configuration` instead of
running the `authorize` callback).

Two arms of `js_object_has_property` now forward a STRING key to the same handle
property dispatcher that property *reads* use (safe for these ids — no heap
deref); the property exists iff it resolves to a non-undefined value, and a miss
falls through so real expandos still resolve:

- the Web-Fetch / zlib handle-band arm (a bare `Request`/`Response`/`Headers`);
- the `class X extends Request/Response` arm — a heap object whose native
  members live on an underlying handle stashed in `__perry_fetch_handle__`
  (`fetch_subclass_handle_id`). Next.js's `NextRequest` extends `Request`, so
  the credentials request Auth.js inspects is a subclass instance.

A symbol key still reports `false` (no own-property meaning on these handles).

Gap test covers `"body"/"method"/"url"/"headers"/"bodyUsed" in request`,
`Response`, a `Request` subclass, and a non-existent key — byte-identical to
`node --experimental-strip-types`.

* fix(runtime): restore Buffer own-prop / method `in` after the handle-in rewrite

Rewriting the pointer `in` arm dropped the PerryTS#6406 buffer branch that answered a
user own-property (`buf.foo = v`) and a `Buffer.prototype` method
(`writeInt8`/`readUInt8`/…). The surviving typed-array arm covers indices, the
view slots, and the %TypedArray% prototype chain, but NOT those two — Perry
keeps buffers outside the object model, so both live in the buffer side tables.
`"writeInt8" in buf` and `"foo" in buf` regressed to false
(test_gap_buffer_own_props). Re-add the two checks in the typed-array arm,
after the prototype-chain scan.

Verified byte-identical to node; the PR's own test_gap_fetch_handle_in_operator
still passes.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
…erryTS#6445)

`new C()` where `C`'s constructor lives in another module calls the imported
`<C>_constructor` symbol, which is compiled in its source module and reads
`new.target` from the runtime cell — NOT this module's codegen `new_target_stack`
slot. The imported-ctor call sites never set that cell, so an ancestor
constructor reading `new.target` (e.g. `this.type = new.target.type`) saw a
stale/undefined value; with an unguarded read it threw `Cannot read properties
of undefined`.

Bind the runtime new.target cell to the leaf class ref around the imported-ctor
call and restore it after, mirroring the local standalone-symbol path.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…#6443)

* fix(runtime): inherit static DATA fields from a parent class

A subclass inherits its parent's static data properties via the class-object
prototype chain (`Sub.__proto__ === Base`) — both in-body `static x = …` fields
and runtime `Base.x = …` assignments. Perry inherited static METHODS (they walk
the class_id chain) but the static-DATA-field read only consulted the receiver
class's own `CLASS_DYNAMIC_PROPS`, so `Sub.x` returned `undefined` even though
`Base` defined `x`.

`get_field_by_name` now walks the `get_parent_class_id` chain after an own-field
miss, reading each ancestor's `CLASS_DYNAMIC_PROPS` (and honoring a deleted key
on an ancestor). This mirrors the existing static-method chain walk.

Surfaced by Auth.js v5: `SignInError.kind = "signIn"` is read off a
`CredentialsSignin` subclass (`this.constructor.kind`) to choose the sign-in vs
error redirect page; the missing inheritance sent every credentials error to
`/auth/error` instead of `/auth/login`.

* fix(runtime): continue past a deleted intermediate static in the parent-chain walk

The static-data-field inheritance walk broke out of the loop when a key was
marked deleted on an intermediate ancestor. But `delete Mid.foo` only removes
Mid's own static — a higher ancestor may still define it, so `Sub.foo` must
inherit `Base.foo`, not resolve to undefined. `break` aborted the whole
traversal; `continue` skips the deleted level and keeps walking up. Safe: `cid`
and `depth` both advance at the top of every iteration, so it can't loop.

Verified against node: `delete Mid.tag; Sub.tag` -> "base" with the fix,
"undefined" without it. Extended test_gap_static_field_inheritance.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
…rce (PerryTS#6453)

The inline lowering for `String.prototype` char-access/case/split methods
(`charAt`/`charCodeAt`/`codePointAt`/`toUpperCase`/`toLowerCase`/`split`/…)
applied `ToString(this)` to a non-string receiver via `js_string_coerce`
without a `RequireObjectCoercible` guard. `ToString` maps `undefined`→
`"undefined"` and `null`→`"null"`, so:

    (undefined as any).codePointAt(0)  // => 117  ("undefined".codePointAt(0))
    (undefined as any).toUpperCase()   // => "UNDEFINED"
    (null as any).charAt(0)            // => "n"

where V8/Node throw `TypeError: Cannot read properties of undefined
(reading 'codePointAt')` — the member access `x.codePointAt` reads the
method off `x` first (ECMA-262 §13.3), before the call. The general
property-get path (used for e.g. `slice`, `indexOf`) already threw
correctly; only the optimistically-inlined char-access/case/split path
routed through `lower_string_method` skipped the guard.

Fix: the non-string-receiver coercion branch now calls a new
`js_string_coerce_method_this(value, prop_name, prop_len)` runtime helper,
which performs `RequireObjectCoercible(this)` — throwing the V8-shaped
`Cannot read properties of {undefined|null} (reading '<method>')` via
`js_throw_type_error_property_access` — before `ToString`. A statically
string-typed receiver still skips the guard (fast path, unchanged).

Regression test compiles + runs the nullish-receiver shapes and asserts the
member-access TypeError message (and that a real string receiver still
works).

Claude-Session: https://claude.ai/code/session_01XRHKpxgDnVB3GJdsV9ud7g

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
When two classes share a name in one compilation (e.g. a bundler flattens two
module factories that each declare `class l`), the second is registered under a
suffixed name (`l$0`) and `class_renames` maps the raw name to it. `extends`,
`new`, and static reads already resolve through that map, but the `instanceof`
right-hand side used the RAW identifier — so `x instanceof l` resolved to the
OTHER factory's same-named class, mismatched the class_id, and returned `false`
even though the prototype chain was correct.

Resolve the `instanceof` class-name operand through `resolve_class_name`.

Surfaced by Auth.js v5 bundled into one chunk: `AuthError` (renamed on a name
collision) failed `error instanceof AuthError`, so a `CredentialsSignin` was
mis-wrapped in a `CallbackRouteError` and the login redirected to
`?error=Configuration` instead of `?error=CredentialsSignin`.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…urn undefined

dispatch_buffer_method's catch-all returned undefined for any method
neither the Buffer API nor the delegated %TypedArray%.prototype tower
implements. Node throws: `buf.charCodeAt(0)` is
`TypeError: buf.charCodeAt is not a function`.

The silent fallback has real teeth since PerryTS#1420 made readFileSync return
a Buffer when called without an encoding (Node parity): a caller still
treating the result as a string gets undefined from every string-method
call and keeps running on garbage instead of failing at the call site.
Real-world case: an editor's NUL-byte binary-file scan
(content.charCodeAt(i) === 0) misclassified every text file it opened —
no crash, no error, just an app that quietly stopped displaying files.

Fix: after Buffer-API arms and typed-array delegation both miss, route
through js_throw_type_error_not_a_function — the same thrower the
string and number primitive catch-alls already use, so the message
shape ((Buffer).charCodeAt is not a function) and catchability match
those paths. Internal __perry_* duck-type probes (using-disposal) keep
the non-throwing undefined.

Callers are unaffected: both dynamic-dispatch entry points
(handle_methods, collection_methods) return Some(...) unconditionally,
so nothing relied on the undefined to fall through to another
dispatcher; the named-method callers (set/export/slice) never reach the
catch-all.

Test: test_gap_buffer_unknown_method_throws.ts — byte-identical to the
Node oracle locally; buffer sweep (prototype_methods,
numeric_read_intrinsic, small_alloc, edge_from_encoding) unchanged.
compat_buffers_typed's toSorted/toReversed diff is pre-existing and
identical before/after.
@proggeramlug

Copy link
Copy Markdown
Author

Misfiled — this repo is a fork; the canonical repo is PerryTS/perry. Re-submitted there as PerryTS#6464 (rebased onto current main).

@proggeramlug
proggeramlug deleted the fix/buffer-unknown-method-throws branch July 16, 2026 08:37
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.

4 participants