fix(runtime): unknown method on a Buffer receiver must throw, not return undefined - #2
Closed
proggeramlug wants to merge 3256 commits into
Closed
fix(runtime): unknown method on a Buffer receiver must throw, not return undefined#2proggeramlug wants to merge 3256 commits into
proggeramlug wants to merge 3256 commits into
Conversation
… 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>
…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.
Author
|
Misfiled — this repo is a fork; the canonical repo is PerryTS/perry. Re-submitted there as PerryTS#6464 (rebased onto current main). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
dispatch_buffer_method's catch-all returnsundefinedfor any method that neither the Buffer API arms nor the delegated%TypedArray%.prototypetower implement. Node throws: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
readFileSyncwith Node: any caller still treating the no-encoding result as a string now getsundefinedfrom 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) === 0over 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-throwingundefined.Caller audit: both dynamic-dispatch entry points (
handle_methods.rs:138,collection_methods.rs:313) returnSome(dispatch_buffer_method(...))unconditionally — nothing relied on theundefinedto fall through to another dispatcher. The named-method callers (set/export/slice) never reach the catch-all.Validation
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 checksinstanceof TypeError+ method-name/is not a functionsubstrings, and that execution continues after a caught throw).test_buffer_prototype_methods,test_buffer_numeric_read_intrinsic,test_buffer_small_alloc,test_edge_buffer_from_encodingall still match Node.test_compat_buffers_typeddiffs ontoSorted/toReversed(u8 element widening) — pre-existing, byte-identical before/after this change.Per CONTRIBUTING, no version/CHANGELOG edits — happy to adjust anything.