diff --git a/CLAUDE.md b/CLAUDE.md index 8a5bbe5fc7..b325429869 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1445 +**Current Version:** 0.5.1446 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 569a14fb48..e1c8d93611 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1445" +version = "0.5.1446" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1445" +version = "0.5.1446" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1445" +version = "0.5.1446" [[package]] name = "perry-ui-tvos" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1445" +version = "0.5.1446" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 744b4efbf0..03e6ea0abd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1445" +version = "0.5.1446" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7761-array-proto-iterator-own-property.md b/changelog.d/7761-array-proto-iterator-own-property.md new file mode 100644 index 0000000000..a15f0095e1 --- /dev/null +++ b/changelog.d/7761-array-proto-iterator-own-property.md @@ -0,0 +1,19 @@ +### Fixed + +- **`Array.prototype[Symbol.iterator]` is now a real own property, and `Array.prototype.values` actually iterates (#7760, items 2 and 3).** The symbol was not an own property of `Array.prototype` at all — `js_object_get_symbol_property` synthesized a receiver-bound method on every read — and `values` was installed by `install_noop_proto_methods`, so it existed and did nothing. Every other builtin prototype (Map, Set, String, `%TypedArray%`) already had a real descriptor; only Array did not. + + Two spec violations followed. `Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator)` returned `undefined`, `hasOwnProperty` was `false`, and the symbol was absent from `getOwnPropertySymbols`. And the ordinary patch-and-restore idiom was broken: `const o = p[Symbol.iterator]; …; p[Symbol.iterator] = o` left a closure bound to the PROTOTYPE, so the restored iterator threw `next is not a function`. + + One real `array_prototype_values_thunk` — reading `this` at CALL time — is now installed as `values` and as the own `[Symbol.iterator]`, with the spec descriptor `{ writable: true, enumerable: false, configurable: true }`, mirroring what `install_collection_iterator_symbol` already does for the collections. `Array.prototype.values.call(arr)`, restore-by-reference, and a `getOwnPropertyDescriptor` → `defineProperty` round-trip all now match node. + + **This unblocks testing the whole family.** #7542's fix shipped without a gap test because any test that patches `Array.prototype[Symbol.iterator]` takes the ORACLE down — node's own primordials build a `SafeMap` from an iterable and get the patched value, so `node` exits 1 and the comparison runs against a crashed reference. Restoring the slot first is the fix for that, and restoring is exactly what was broken. `test-files/test_gap_array_proto_iterator_replaced_7542.ts` now covers both issues end-to-end and passes through the harness. + +- **`for…of` over an array honours a replaced `Array.prototype[Symbol.iterator]` (#7760 item 1).** `for…of` desugars to an index loop (`__i < __arr.length` / `__arr[__i]`) that never consults the iteration protocol, so the patch was ignored there even after #7542 fixed every spread form. Two parallel lowerings had to be fixed — `lower::stmt_loops` for module-init loops and `lower_decl::body_stmt` for loops inside functions — which is why a `for…of` over an array *parameter* stayed wrong after the first half landed. + + The patch is a RUNTIME fact and the index-vs-lazy choice is a COMPILE-TIME one, so both forms are emitted and selected by a branch on the new `Expr::ArrayIterationPatched`, which lowers to a single volatile `i8` load of `PERRY_ARRAY_PROTO_ITERATOR_PATCHED` — the same shape `PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED` already uses. Three properties shaped it: + + * **Lazy.** Materializing the protocol once at loop entry — what spread does — is eager where node is lazy: an early `break` would over-pull, a side-effecting iterator would run extra steps, and an infinite patched iterator would hang. The test asserts `break` after 2 of 3 pulls exactly 2, matching node. + * **The index loop is emitted byte-identically**, inside the `else`. Threading the element through a shared `__item` temp (one loop, loop-invariant branch) would have been smaller but would have broken the HIR pattern matchers behind the element-shape clone (#7612), the dense spread path (#7533) and the packed-f64 / i32-counter loop specializations. + * **The check is at loop ENTRY**, not per iteration — which is what the spec wants anyway: `for…of` performs GetIterator exactly once, so a patch landing mid-loop must not change the iterator already in hand. + + Cost, measured interleaved on a loaded host (7 reps each, 20k-iteration loop fixture): 1.85 s vs 1.86 s median, 1.85 s vs 1.82 s best — no measurable difference. A first non-interleaved best-of-5 on the same box read 2.64 s vs 2.90 s, which was host noise; the numbers above are the interleaved ones and still want confirming on the pinned bench host before being quoted as a bound. diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index ac2c45f308..1633972486 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -51,7 +51,8 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | Expr::LocalGet(..) | Expr::LocalSet(..) | Expr::Update { .. } - | Expr::DateNow => super::literals_vars::lower(ctx, expr), + | Expr::DateNow + | Expr::ArrayIterationPatched => super::literals_vars::lower(ctx, expr), Expr::Binary { .. } => super::binary::lower(ctx, expr), Expr::Unary { .. } => super::unary::lower(ctx, expr), Expr::Compare { .. } => super::compare::lower(ctx, expr), diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index 2cf35f1d53..b48dc82348 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -851,6 +851,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // call returning a `double` (milliseconds since UNIX epoch as // produced by `js_date_now` in `perry-runtime/src/date.rs`). Expr::DateNow => Ok(ctx.block().call(DOUBLE, "js_date_now", &[])), + // #7760: one volatile `i8` load, the same shape the array index fast + // path uses for its own invalidation flag. Emitted once at the entry of + // a `for…of` over a proven array to choose between the index loop and + // the lazy iterator-protocol loop, so the fast arm pays a load and a + // predictable branch PER LOOP, never per iteration. + Expr::ArrayIterationPatched => { + let blk = ctx.block(); + let flag = blk.load_volatile(crate::types::I8, "@PERRY_ARRAY_PROTO_ITERATOR_PATCHED"); + let widened = blk.zext(crate::types::I8, &flag, crate::types::I32); + Ok(super::i32_bool_to_nanbox(blk, &widened)) + } // -------- Arithmetic -------- // String concatenation (Phase B): if Add receives operands where diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index d7cb0557af..ec897bd143 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -33,6 +33,8 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // Array [[Prototype]] installation. Normal compiled programs read this // byte directly in the inline plain-array index guard. module.add_external_global("PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED", I8); + // #7760: set when `Array.prototype[Symbol.iterator]` is replaced. + module.add_external_global("PERRY_ARRAY_PROTO_ITERATOR_PATCHED", I8); // Process-wide count of threads with an active incremental marking // barrier. Persistent shadow-slot updates use zero as an authoritative // fast skip before calling the TLS-backed root barrier. diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 4ee9fce3b0..5162266289 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -2331,6 +2331,19 @@ pub enum Expr { /// `operand[Symbol.iterator]()` when iterable, else the operand itself (a /// generator object already *is* its iterator). Lowers to `js_get_iterator`. GetIterator(Box), + /// #7760: is `Array.prototype[Symbol.iterator]` currently replaced? + /// + /// Reads the runtime's `PERRY_ARRAY_PROTO_ITERATOR_PATCHED` flag. Emitted + /// once at the ENTRY of a `for…of` over a statically-proven array, to pick + /// between the index loop (`__i < __arr.length`) and the lazy + /// iterator-protocol loop. Checking once is what the spec wants — `for…of` + /// performs GetIterator exactly once — and it keeps the cost off the + /// per-iteration path. + /// + /// A dedicated node rather than a call so codegen emits a single volatile + /// `i8` load (the `PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED` shape) instead + /// of an opaque FFI call that would block hoisting around the loop. + ArrayIterationPatched, /// Resolve the iterator for generic `for await...of`: use /// `operand[Symbol.asyncIterator]()` when present, otherwise wrap the /// synchronous iterator from `operand[Symbol.iterator]()` in Perry's diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index d31c61bba7..22176ea9f6 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -105,6 +105,7 @@ impl LoweringContext { scope_local_marks: Vec::new(), scope_module_shadow_marks: Vec::new(), inside_block_scope: 0, + for_of_force_lazy: false, namespace_vars: Vec::new(), current_namespace: None, module_native_instances: Vec::new(), diff --git a/crates/perry-hir/src/lower/for_of_guard.rs b/crates/perry-hir/src/lower/for_of_guard.rs new file mode 100644 index 0000000000..8f8cfa12b4 --- /dev/null +++ b/crates/perry-hir/src/lower/for_of_guard.rs @@ -0,0 +1,62 @@ +//! #7760 item 1: the runtime guard that lets a `for…of` over a proven array +//! honour a replaced `Array.prototype[Symbol.iterator]`. +//! +//! Split out of `stmt_loops.rs` to keep that file under the 2000-line CI cap. + +use anyhow::Result; +use swc_ecma_ast as ast; + +use super::stmt_loops::lower_stmt_for_of_inner; +use super::LoweringContext; +use crate::ir::{Expr, Stmt}; +use crate::Module; + +/// #7760 item 1: `for…of` over a statically-proven array desugars to an index +/// loop that never consults the iteration protocol, so a patched +/// `Array.prototype[Symbol.iterator]` was ignored there even after #7542 fixed +/// every spread form. +/// +/// The patch is a RUNTIME fact and the index/lazy choice is a COMPILE-TIME one, +/// so the only correct answer is to emit both and branch. Three properties made +/// the shape non-obvious: +/// +/// * It must stay LAZY. Materializing the protocol once at loop entry — what +/// spread now does — is eager where node is lazy: an early `break` would +/// over-pull, a side-effecting iterator would run extra steps, and an +/// infinite patched iterator would hang instead of terminating. +/// * The index loop must be emitted BYTE-IDENTICALLY. Threading the element +/// through a shared `__item` temp (one loop, loop-invariant branch) would +/// have cost nothing in LLVM but would have broken the HIR-level pattern +/// matchers behind the element-shape clone (#7612), the dense spread path +/// (#7533) and the packed-f64 / i32-counter loop specializations — a silent +/// regression on the hottest loop in the language. +/// * The check belongs at loop ENTRY, not per iteration — which is also what +/// the spec says: `for…of` performs GetIterator exactly once, so a patch +/// landing mid-loop must not change the iterator already in hand. +/// +/// So the fast arm pays one volatile `i8` load and a predictable branch per +/// LOOP, and the loop inside it is the same `Stmt::For` as before. +pub(crate) fn lower_stmt_for_of( + ctx: &mut LoweringContext, + module: &mut Module, + for_of_stmt: &ast::ForOfStmt, +) -> Result<()> { + // Lower the index form first and see whether it is a proven-array loop that + // needs the guard (the callee reports it). If so, lower the lazy form too + // and splice the two into an `if`. Lowering twice re-runs the setup rather + // than duplicating it here, so the two arms cannot drift apart. + let mark = module.init.len(); + let guarded = lower_stmt_for_of_inner(ctx, module, for_of_stmt, None)?; + if !guarded { + return Ok(()); + } + let index_arm: Vec = module.init.split_off(mark); + lower_stmt_for_of_inner(ctx, module, for_of_stmt, Some(true))?; + let lazy_arm: Vec = module.init.split_off(mark); + module.init.push(Stmt::If { + condition: Expr::ArrayIterationPatched, + then_branch: lazy_arm, + else_branch: Some(index_arm), + }); + Ok(()) +} diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 63c28f7e92..617f1e4a0e 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -398,6 +398,11 @@ pub struct LoweringContext { /// `inside_block_scope == 0`; `const captured = i` inside a top-level for /// loop must still be per-iteration box, not a shared global slot. pub(crate) inside_block_scope: usize, + /// #7760: while true, a `for…of` over a statically-proven array lowers to + /// the LAZY iterator-protocol form instead of the index loop. Set only by + /// the guard emission, which lowers the same statement twice — once each + /// way — and wraps them in a runtime branch on `Expr::ArrayIterationPatched`. + pub(crate) for_of_force_lazy: bool, /// Namespace exported variables: (namespace_name, member_name, local_id) /// Used to resolve Namespace.member access to module-level LocalGet pub(crate) namespace_vars: Vec<(String, String, LocalId)>, diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 25fe3d7701..7d002c868c 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -56,10 +56,12 @@ pub(crate) use for_head::{ map_set_delete_safe_for_of, predefine_for_head, resolve_for_of_iterable_type, rewrite_collection_view_for_of, }; +mod for_of_guard; +pub(crate) use for_of_guard::lower_stmt_for_of; mod stmt_loops; pub(crate) use stmt_loops::{ insert_iterator_close_on_abrupt, lazy_iter_for_stmt, lazy_or_index_elem, lower_stmt_for_in, - lower_stmt_for_of, wrap_lazy_for_of_body_close_on_throw, + wrap_lazy_for_of_body_close_on_throw, }; mod module_decl; pub(crate) use module_decl::*; diff --git a/crates/perry-hir/src/lower/stmt_loops.rs b/crates/perry-hir/src/lower/stmt_loops.rs index 9f04fa3caa..e36b0c5ee7 100644 --- a/crates/perry-hir/src/lower/stmt_loops.rs +++ b/crates/perry-hir/src/lower/stmt_loops.rs @@ -776,11 +776,15 @@ fn lower_runtime_for_await_iterator( Ok(()) } -pub(crate) fn lower_stmt_for_of( +/// Returns `true` when the caller should ALSO emit the lazy arm (#7760): this +/// was a proven-array index loop, which ignores a patched +/// `Array.prototype[Symbol.iterator]`. +pub(super) fn lower_stmt_for_of_inner( ctx: &mut LoweringContext, module: &mut Module, for_of_stmt: &ast::ForOfStmt, -) -> Result<()> { + force_lazy: Option, +) -> Result { // `for (… of m.values()/keys()/entries())` on a statically-proven Map/Set // is the direct-collection loop written another way; rewrite it to that // form so it reaches the delete-safe index fast path instead of building a @@ -1066,7 +1070,7 @@ pub(crate) fn lower_stmt_for_of( .push(iter_driver_while_stmt(result_id, next_call, body_stmts)); ctx.pop_block_scope(for_scope_mark); - return Ok(()); + return Ok(false); } // --- #1646: `for await (const c of )` --- @@ -1228,7 +1232,7 @@ pub(crate) fn lower_stmt_for_of( })); ctx.pop_block_scope(for_scope_mark); - return Ok(()); + return Ok(false); } } @@ -1410,7 +1414,8 @@ pub(crate) fn lower_stmt_for_of( // pre-registration slot (never written) and calling it throws // `value is not a function` (claude-code bundle e8/K8). ctx.pop_block_scope(for_scope_mark); - return lower_runtime_for_await_iterator(ctx, module, for_of_stmt, arr_expr); + return lower_runtime_for_await_iterator(ctx, module, for_of_stmt, arr_expr) + .map(|()| false); } // #for-of lazy iterator protocol: a generic/untyped iterable (custom // iterator, generator object, any-typed value) must be driven lazily — @@ -1421,7 +1426,22 @@ pub(crate) fn lower_stmt_for_of( // which (a) runs a generator past the point a `break` should have closed // it and (b) made IteratorClose impossible. `is_await` is already handled // by the early return above, so this is always the synchronous path. - let use_lazy_iter = needs_runtime_iterator; + // #7760: `force_lazy: Some(true)` builds the protocol arm of a guarded + // proven-array loop; `None` is the ordinary lowering. + let use_lazy_iter = needs_runtime_iterator || force_lazy == Some(true); + // The guard is needed exactly when this loop would otherwise be a plain + // array index loop: a proven array, no other fast path, not the await form + // (which returned above), and not the arm we are building for the guard. + let guard_with_lazy_arm = force_lazy.is_none() + && proven_array + && !needs_runtime_iterator + && !is_string_iter + && !is_headers_iter + && !is_urlsp_iter + && !is_iterable_map + && !is_iterable_set + && !is_iterable_typed_array + && !for_of_stmt.is_await; let arr_expr = if is_iterable_map { if let Some(args) = map_type_args.as_ref() { if args.len() >= 2 { @@ -1802,7 +1822,7 @@ pub(crate) fn lower_stmt_for_of( .init .push(lazy_iter_for_stmt(arr_id, result_id, full_body)); ctx.pop_block_scope(for_scope_mark); - return Ok(()); + return Ok(false); } // Prepend the binding statements to the loop body @@ -1856,7 +1876,7 @@ pub(crate) fn lower_stmt_for_of( body: loop_body, }); ctx.pop_block_scope(for_scope_mark); - Ok(()) + Ok(guard_with_lazy_arm) } pub(crate) fn lower_stmt_for_in( diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 01e7b20fe9..f861b5ea42 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -901,6 +901,9 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result { + // #7760: where this arm's own statements begin, so the guard below + // can splice them back out as the index arm. + let result_mark = result.len(); // `for (… of m.values()/keys()/entries())` on a statically-proven // Map/Set is the direct-collection loop written another way; see // `for_head::rewrite_collection_view_for_of`. @@ -1431,7 +1434,18 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result = result.split_off(result_mark); + ctx.for_of_force_lazy = true; + let lazy_arm = lower_body_stmt(ctx, stmt); + ctx.for_of_force_lazy = false; + result.push(Stmt::If { + condition: Expr::ArrayIterationPatched, + then_branch: lazy_arm?, + else_branch: Some(index_arm), + }); + } } ast::Stmt::ForIn(for_in_stmt) => { // Desugar for-in to a for-of over Object.keys(obj) (same as in lower_stmt). diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index 90f6641574..48bebfc38f 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -442,6 +442,7 @@ impl SH for Expr { Expr::SetValueAt { set, idx } => { tag(h, 308); set.as_ref().hash(h); idx.as_ref().hash(h); } Expr::Sequence(es) => { tag(h, 309); es.hash(h); } Expr::DateNow => tag(h, 310), + Expr::ArrayIterationPatched => tag(h, 7760), Expr::DateNew(es) => { tag(h, 311); es.hash(h); } Expr::BoxedPrimitiveNew { kind, diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index a44e3a4ea0..e4977b5de6 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -95,6 +95,7 @@ where | Expr::OsLoadavg | Expr::OsMachine | Expr::OsVersion + | Expr::ArrayIterationPatched | Expr::DateNow | Expr::MathRandom | Expr::MapNew diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index 733036c25c..cd652acba8 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -96,6 +96,7 @@ where | Expr::OsLoadavg | Expr::OsMachine | Expr::OsVersion + | Expr::ArrayIterationPatched | Expr::DateNow | Expr::MathRandom | Expr::MapNew diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 3885be789c..e4efb93dca 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -216,6 +216,25 @@ pub(crate) fn object_prototype_addr_matches(addr: usize) -> bool { /// hot-path shape as `ARRAY_PROTO_HAS_INDEX` above. static ARRAY_PROTO_ITERATOR_MODIFIED: AtomicBool = AtomicBool::new(false); +/// The same fact as [`ARRAY_PROTO_ITERATOR_MODIFIED`], exported so GENERATED +/// code can read it (#7760 item 1). +/// +/// `for…of` over a statically-proven array desugars to an index loop +/// (`__i < __arr.length` / `__arr[__i]`) in HIR lowering, which never consults +/// the iteration protocol — so a patched `Array.prototype[Symbol.iterator]` was +/// ignored there even after the spread paths were fixed (#7542). The loop now +/// branches on this flag ONCE at entry, which is also what the spec wants: +/// `for…of` performs GetIterator exactly once, so a patch landing mid-loop must +/// not change the iterator already in hand. +/// +/// A separate `u8` global rather than exposing the `AtomicBool`: codegen emits +/// a plain volatile `i8` load, the same shape as +/// `PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED`, so the fast arm pays one load and +/// a predictable branch per LOOP — not per iteration — and the index loop +/// itself is emitted byte-identically to before. +#[no_mangle] +pub static PERRY_ARRAY_PROTO_ITERATOR_PATCHED: AtomicU8 = AtomicU8::new(0); + /// Record (if `obj` is `Array.prototype` and `sym_key` is the well-known /// `Symbol.iterator`) that the array iteration protocol has been tampered /// with. Called from the symbol-property set/delete paths. @@ -227,6 +246,9 @@ pub(crate) fn note_array_proto_iterator_write(obj: usize, sym_key: usize) { && sym_key == crate::symbol::well_known_symbol("iterator") as usize { ARRAY_PROTO_ITERATOR_MODIFIED.store(true, Ordering::Relaxed); + // Publish to generated code. Release so a loop that observes the `1` + // also observes the prototype write that preceded it. + PERRY_ARRAY_PROTO_ITERATOR_PATCHED.store(1, Ordering::Release); } } diff --git a/crates/perry-runtime/src/object/global_this/array_error.rs b/crates/perry-runtime/src/object/global_this/array_error.rs index ac534b55aa..ea7afb7ea7 100644 --- a/crates/perry-runtime/src/object/global_this/array_error.rs +++ b/crates/perry-runtime/src/object/global_this/array_error.rs @@ -632,6 +632,34 @@ pub(crate) extern "C" fn array_prototype_slice_thunk( /// methods were noop-backed (`global_this_builtin_noop_thunk`), so a borrowed /// reference (`obj.pop = Array.prototype.pop; obj.pop()` or /// `Array.prototype.pop.call(obj)`) returned `undefined` / looped. +/// `Array.prototype.values` / `Array.prototype[Symbol.iterator]` (#7760). +/// +/// `values` used to be installed by `install_noop_proto_methods`, so it existed +/// and did nothing, and `Array.prototype[Symbol.iterator]` was not an own +/// property at all — `js_object_get_symbol_property` synthesized a +/// receiver-bound method on every read. Two consequences, both spec violations: +/// +/// * `Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator)` was +/// `undefined`, and the symbol was absent from `getOwnPropertySymbols`, +/// while every other builtin prototype (Map/Set/String/%TypedArray%) had a +/// real descriptor. +/// * saving and restoring the method — `const o = p[Symbol.iterator]; …; +/// p[Symbol.iterator] = o` — left a closure bound to the PROTOTYPE, so the +/// restored iterator threw `next is not a function`. That is the ordinary +/// patch-and-restore idiom, and it also made the whole family untestable +/// against the node oracle (#7542). +/// +/// Reading `this` at CALL time is the fix for the second: the value is one +/// function, and `Array.prototype.values.call(arr)` / a restored slot both +/// iterate the receiver they are given. +pub(crate) extern "C" fn array_prototype_values_thunk( + _c: *const crate::closure::ClosureHeader, + _a: f64, +) -> f64 { + let this = crate::object::js_implicit_this_get(); + crate::array::array_values_iter(this) +} + pub(crate) extern "C" fn array_prototype_pop_thunk( _c: *const crate::closure::ClosureHeader, _a: f64, diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index a8ac69877f..86701fd892 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -42,6 +42,31 @@ const OBJECT_PROTO_METHODS: &[(&str, u32)] = &[ /// The common forms — `arr.map(fn)` (codegen's NativeMethodCall) and /// `Array.prototype.map.call(arr, fn)` (HIR rewrite, see /// `try_builtin_prototype_method_apply_call`) — are unaffected. +/// #7760: install `value` as `proto_obj`'s OWN `[Symbol.iterator]`, with the +/// spec descriptor. Mirrors `collection_proto_thunks::install_collection_iterator_symbol`; +/// kept here because `Array.prototype` is populated in this module. +fn install_array_iterator_symbol(proto_obj: *mut ObjectHeader, value: f64) { + if proto_obj.is_null() || value.to_bits() == crate::value::TAG_UNDEFINED { + return; + } + let iter = crate::symbol::well_known_symbol("iterator"); + if iter.is_null() { + return; + } + unsafe { + crate::symbol::js_object_set_symbol_property( + crate::value::js_nanbox_pointer(proto_obj as i64), + f64::from_bits(crate::value::JSValue::pointer(iter as *const u8).bits()), + value, + ); + } + crate::symbol::set_symbol_property_attrs( + proto_obj as usize, + iter as usize, + crate::object::PropertyAttrs::new(true, false, true), + ); +} + pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: *mut ObjectHeader) { if proto_obj.is_null() { return; @@ -97,7 +122,6 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ("toSorted", 1), ("toSpliced", 2), ("toString", 0), - ("values", 0), ("with", 2), ], ); @@ -105,6 +129,20 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: // reference works: `obj.pop = Array.prototype.pop; obj.pop()` and // `Array.prototype.splice.call(obj, …)`. Each reads IMPLICIT_THIS and // runs the array algorithm on a real array or array-like object. + // #7760: `values` was a NOOP in the list above, and + // `Array.prototype[Symbol.iterator]` was not an own property at all + // — the symbol getter synthesized a receiver-bound method on every + // read. Install one real function and make it the own `@@iterator`, + // matching Map/Set/String/%TypedArray%, which already do this + // (`install_collection_iterator_symbol`). Attributes are the spec's + // `{ writable: true, enumerable: false, configurable: true }`. + let values_value = install_proto_method( + proto_obj, + "values", + array_prototype_values_thunk as *const u8, + 0, + ); + install_array_iterator_symbol(proto_obj, values_value); install_proto_method(proto_obj, "pop", array_prototype_pop_thunk as *const u8, 0); install_proto_method( proto_obj, diff --git a/test-files/test_gap_array_proto_iterator_replaced_7542.ts b/test-files/test_gap_array_proto_iterator_replaced_7542.ts new file mode 100644 index 0000000000..acf9f53a99 --- /dev/null +++ b/test-files/test_gap_array_proto_iterator_replaced_7542.ts @@ -0,0 +1,87 @@ +// #7542 + #7760: a replaced `Array.prototype[Symbol.iterator]` must drive every +// spread form, and the method must be a real own property of the prototype so +// the ordinary patch-and-restore idiom works. +// +// This test is only possible because of #7760: restoring the original by +// reference used to throw (`next is not a function` — the read returned a +// method bound to the PROTOTYPE), and leaving the slot patched takes the NODE +// oracle down after the script, since its own primordials build a `SafeMap` +// from an iterable and get the patched value. + +const arrProto: any = Array.prototype; + +// #7760: a real own property, like Map/Set/String/%TypedArray% already had. +const desc: any = Object.getOwnPropertyDescriptor(arrProto, Symbol.iterator); +console.log("descriptor:", typeof desc.value, desc.writable, desc.enumerable, desc.configurable); +console.log("hasOwn:", Object.prototype.hasOwnProperty.call(arrProto, Symbol.iterator)); +console.log("in ownSymbols:", Object.getOwnPropertySymbols(arrProto).indexOf(Symbol.iterator) >= 0); +console.log("name:", desc.value.name); + +const original = arrProto[Symbol.iterator]; +// #7760: the value reads `this` at CALL time, so a borrowed reference works. +console.log("values.call:", JSON.stringify(Array.from(original.call([7, 8]) as any))); + +arrProto[Symbol.iterator] = function* () { + yield "patched"; +}; + +const src = [1, 2, 3]; +function count(...xs: any[]) { + return xs.length; +} + +// #7542: every spread form drives the patched method. +console.log("spread literal:", JSON.stringify([...[1, 2, 3]])); +console.log("spread variable:", JSON.stringify([...src])); +console.log("spread into array:", JSON.stringify([0, ...src, 9])); +console.log("call spread:", count(...src)); +console.log("Array.from:", JSON.stringify(Array.from(src as any))); + +// An OWN `[Symbol.iterator]` still wins over the prototype. +const own: any = [1, 2, 3]; +own[Symbol.iterator] = function* () { + yield "own"; +}; +console.log("own wins:", JSON.stringify([...own])); + +// #7760 item 1: `for…of` honours the patch too — over a module const, a typed +// local, and an array PARAMETER (which lowers through a second, parallel +// for-of path in `lower_decl/body_stmt.rs`). And it stays LAZY: an early +// `break` must stop pulling, which is what rules out materializing the +// iterator eagerly at loop entry. +function viaParam(a: number[]): string[] { + const o: string[] = []; + for (const v of a) o.push(String(v)); + return o; +} +const fromConst: string[] = []; +for (const v of src) fromConst.push(String(v)); +const typedLocal: number[] = [1, 2, 3]; +const fromLocal: string[] = []; +for (const v of typedLocal) fromLocal.push(String(v)); +console.log("for-of const:", JSON.stringify(fromConst)); +console.log("for-of local:", JSON.stringify(fromLocal)); +console.log("for-of param:", JSON.stringify(viaParam(src))); + +let pulled = 0; +arrProto[Symbol.iterator] = function* () { + for (let i = 0; i < 100; i++) { + pulled++; + yield i; + } +}; +let seen = 0; +for (const _v of src) { + seen++; + if (seen === 2) break; +} +console.log("lazy break: seen", seen, "pulled", pulled); + +// #7760: restore by reference, and by descriptor round-trip. +arrProto[Symbol.iterator] = original; +console.log("restored spread:", JSON.stringify([...src])); +console.log("restored Array.from:", JSON.stringify(Array.from(src as any))); +console.log("restored call spread:", count(...src)); + +Object.defineProperty(arrProto, Symbol.iterator, desc); +console.log("after defineProperty:", JSON.stringify([...src]));