diff --git a/CLAUDE.md b/CLAUDE.md index 80f8eb98fd..931e728d02 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.1329 +**Current Version:** 0.5.1330 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 70de9999f3..5efddfb37f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1329" +version = "0.5.1330" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1329" +version = "0.5.1330" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1329" +version = "0.5.1330" [[package]] name = "perry-ui-tvos" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1329" +version = "0.5.1330" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 8c0ee4a942..01bb99f89f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1329" +version = "0.5.1330" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7583-iterator-helpers-class-id-collision.md b/changelog.d/7583-iterator-helpers-class-id-collision.md new file mode 100644 index 0000000000..22fa7de2eb --- /dev/null +++ b/changelog.d/7583-iterator-helpers-class-id-collision.md @@ -0,0 +1,48 @@ +### Fixed + +- **The entire TC39 iterator-helpers surface was dead (#7576).** `Iterator.from(x)` + returned an iterator that was exhausted before its first step, and `.map` / + `.filter` / `.take` / `.drop` / `.flatMap` all returned `undefined`, so any + chain threw `TypeError: Cannot read properties of undefined`. + + **Root cause: a class-id collision.** `ITERATOR_HELPER_CLASS_ID` + (`crates/perry-runtime/src/iterator_helpers.rs`) and `STRING_ITERATOR_CLASS_ID` + (`crates/perry-runtime/src/string/iter_object.rs`) were both `0xFFFF_0009`. + The two constants were introduced independently and each carries the comment + "sits just past the Set iterator id (0xFFFF0008)"; neither author checked + whether the slot was taken. Every dispatch tower matches these ids in a fixed + order with the String arm first, so **every** helper object was dispatched as + a String iterator: `.next()` read the helper's op-kind field as a cursor index + against a null backing array and answered `{ done: true }`, and every other + helper method fell into that dispatcher's `_ => undefined` arm. One cause, + both symptoms. The helper now takes `0xFFFF_000B` (`0xFFFF_000A` is the + RegExp-string iterator). + + **Second, independent defect on the same path.** `iterator_step` resolved the + source iterator's `next` with the *inheriting* getter. Since #321 every + built-in iterator inherits `.next` from its shared `%…IteratorPrototype%` + singleton, and that inherited `next` is a thunk that resolves its receiver + from `js_implicit_this_get()`. The lookup therefore found a callable closure + for an array / Map / Set / String iterator source, took the raw-closure-call + branch (which binds no `this`), and ran the thunk against a stale receiver — + `done` on the first step, or `Method %IteratorPrototype%.next called on + incompatible receiver`. `array/iterator.rs::js_iterator_to_array` already + carried the own-field version of this fix; `iterator_step` now matches it + (`js_object_get_own_field_or_undef`, which is also allocation-free) and binds + `this` to the iterator per `IteratorNext`'s `Call(next, iterator)`. Both + halves are load-bearing. + + **Why it survived.** `test-files/test_gap_iterator_helpers_2874.ts` caught this + and had been listed in `test-parity/known_failures.json` since 2026-07-04. It + passes byte-for-byte now and the skip is removed. + + New coverage, `cargo-test`-visible per #5960: + `crates/perry-runtime/src/iterator_helpers/tests.rs` (16 tests, all driving + `js_native_call_method` — the tower a compiled program reaches, since a test + calling the helper dispatcher directly would have been green throughout the + outage), including `iterator_class_ids_are_pairwise_distinct`, which fails on + any duplicate in the iterator family. Plus + `test-files/test_gap_iterator_helpers_7576.ts`, 28 lines byte-identical to + `node --experimental-strip-types`, covering the issue reproducer, hand-stepped + stored helpers, all six source kinds, every combinator and terminal, laziness + over an unbounded generator, and spread. diff --git a/crates/perry-runtime/src/iterator_helpers.rs b/crates/perry-runtime/src/iterator_helpers.rs index 257d8eeeb3..da5d468710 100644 --- a/crates/perry-runtime/src/iterator_helpers.rs +++ b/crates/perry-runtime/src/iterator_helpers.rs @@ -40,9 +40,31 @@ use crate::object::{ use crate::string::js_string_from_bytes; use crate::value::{js_nanbox_get_pointer, js_nanbox_pointer, JSValue, TAG_TRUE, TAG_UNDEFINED}; -/// Class id reserved for lazy iterator-helper objects. Sits just past the -/// Set iterator id (0xFFFF0008). -pub const ITERATOR_HELPER_CLASS_ID: u32 = 0xFFFF_0009; +#[cfg(test)] +mod tests; + +/// Class id reserved for lazy iterator-helper objects. +/// +/// #7576: this was `0xFFFF_0009`, which is [`STRING_ITERATOR_CLASS_ID`]. The +/// two constants were introduced independently, each with a comment reading +/// "sits just past the Set iterator id (0xFFFF0008)", and neither author +/// checked whether the slot was taken. Every dispatch tower tests the String +/// arm before the helper arm, so EVERY helper object was dispatched as a +/// String iterator: `.next()` read the helper's op-kind field as a cursor +/// index against a null backing array and answered `{ done: true }` on the +/// first step, and every other helper method (`map`/`filter`/`take`/`drop`/ +/// `flatMap`/`toArray`/`reduce`/…) fell into that dispatcher's `_ => +/// undefined` arm. The whole proposal surface was dead — silently for +/// `Iterator.from`, loudly (`Cannot read properties of undefined`) for the +/// combinators. +/// +/// `0xFFFF_000A` is the RegExp-string iterator, so this takes the next free +/// slot. [`iterator_class_ids_are_pairwise_distinct`] pins the whole family so +/// a third collision cannot be introduced silently. +/// +/// [`STRING_ITERATOR_CLASS_ID`]: crate::string::STRING_ITERATOR_CLASS_ID +/// [`iterator_class_ids_are_pairwise_distinct`]: tests::iterator_class_ids_are_pairwise_distinct +pub const ITERATOR_HELPER_CLASS_ID: u32 = 0xFFFF_000B; // Op kinds stored in field 1. const OP_IDENTITY: i32 = 0; // `Iterator.from(x)` — yields the source unchanged. @@ -68,18 +90,38 @@ pub fn is_iterator_helper_addr(addr: usize) -> bool { /// Drive one `.next()` step on ANY iterator object. Returns `(value, done)`. /// Mirrors the dual dispatch in `array/iterator.rs::js_iterator_to_array`: -/// prefer a stored `next` closure FIELD (generators / bare `{next}` objects), +/// prefer an OWN `next` closure FIELD (generators / bare `{next}` objects), /// else fall back to class-id method dispatch (array / Map / Set / helper /// iterators). /// +/// #7576: the own-vs-inherited distinction is the whole ballgame, and reading +/// it with the INHERITING getter is what killed the entire helper surface. +/// Every built-in iterator now inherits `.next` from its shared +/// `%…IteratorPrototype%` singleton (`object/iterator_prototypes.rs`), and that +/// inherited `next` is a THUNK that resolves its receiver from +/// `js_implicit_this_get()` and dispatches by class id. So +/// `js_object_get_field_by_name` found a perfectly callable closure for an +/// array / Map / Set / String iterator, this function took the closure-call +/// branch, and the thunk ran with whatever `this` happened to be left in the +/// thread-local — reporting `done` on the first step for every source kind, or +/// throwing `Method %IteratorPrototype%.next called on incompatible receiver`. +/// `js_iterator_to_array` already carries the own-field version of this fix +/// (#321); this is the same fix for the helper chain. +/// +/// The call itself binds `this` to the iterator per `IteratorNext`'s +/// `Call(next, iterator)`. That matters for the own-field case too: a `next()` +/// method written as `next() { return this.#impl.next(); }` gets the receiver +/// it is entitled to instead of `undefined`. +/// /// #7564: the READ side of the protocol had the same defect as the WRITE side. -/// It allocated the three constant key strings `"next"` / `"done"` / `"value"` -/// on EVERY step, and held `iter_obj`, `result_obj` and the earlier keys in -/// bare Rust locals across those allocations — plus, in the middle, a call -/// into the user's own `next`, which can run arbitrary JS and collect -/// anything. The keys are now interned (allocation-free after the first call -/// per thread) and every pointer is re-read from a handle after each call that -/// can collect, so no pre-collection address is nameable. +/// It allocated the constant key strings on EVERY step, and held `iter_obj`, +/// `result_obj` and the earlier keys in bare Rust locals across those +/// allocations — plus, in the middle, a call into the user's own `next`, which +/// can run arbitrary JS and collect anything. The keys are now interned +/// (allocation-free after the first call per thread) or read by byte slice +/// (`js_object_get_own_field_or_undef`, which allocates nothing at all), and +/// every pointer is re-read from a handle after each call that can collect, so +/// no pre-collection address is nameable. unsafe fn iterator_step(iter_f64: f64) -> (f64, bool) { if js_nanbox_get_pointer(iter_f64) == 0 { return (f64::from_bits(TAG_UNDEFINED), true); @@ -88,15 +130,15 @@ unsafe fn iterator_step(iter_f64: f64) -> (f64, bool) { let scope = crate::gc::RuntimeHandleScope::new(); let iter_h = scope.root_nanbox_f64(iter_f64); - // `intern_ascii_literal` allocates on a first-call-per-thread miss, so the - // receiver comes back out of its handle ACROSS that call — the pre-call - // address is never bound. The key itself is the call's own return value, - // so it is current by construction. - let (next_key, iter_now) = - iter_h.across_nanbox(|| crate::string::intern_ascii_literal(b"next")); - let next_val = js_object_get_field_by_name( - js_nanbox_get_pointer(iter_now) as *const ObjectHeader, - next_key, + // OWN field only — see the `#7576` note above. Allocation-free (it matches + // the key by byte slice), so no `across_*` is needed to reach it. + let next_val = JSValue::from_bits( + crate::object::js_object_get_own_field_or_undef( + iter_h.get_nanbox_f64(), + b"next".as_ptr(), + 4, + ) + .to_bits(), ); let next_ptr = if next_val.is_undefined() { std::ptr::null::() @@ -105,11 +147,16 @@ unsafe fn iterator_step(iter_f64: f64) -> (f64, bool) { }; let use_field = !next_ptr.is_null() && is_closure_ptr(next_ptr as usize); - // The user's `next` runs here and can collect anything. `next_ptr` is used - // immediately — nothing between its read and the call allocates — and the - // receiver for the fallback comes from its handle. + // The user's `next` runs here and can collect anything, so the receiver and + // the saved implicit-`this` both come out of handles rather than out of + // bare locals that predate the call. let result_f64 = if use_field { - js_closure_call1(next_ptr, f64::from_bits(TAG_UNDEFINED)) + let next_h = scope.root_nanbox_f64(f64::from_bits(next_val.bits())); + let prev_this_h = + scope.root_nanbox_f64(crate::object::js_implicit_this_set(iter_h.get_nanbox_f64())); + let r = crate::closure::js_native_call_value(next_h.get_nanbox_f64(), std::ptr::null(), 0); + crate::object::js_implicit_this_set(prev_this_h.get_nanbox_f64()); + r } else { crate::object::js_native_call_method( iter_h.get_nanbox_f64(), diff --git a/crates/perry-runtime/src/iterator_helpers/tests.rs b/crates/perry-runtime/src/iterator_helpers/tests.rs new file mode 100644 index 0000000000..e5b8beb6c6 --- /dev/null +++ b/crates/perry-runtime/src/iterator_helpers/tests.rs @@ -0,0 +1,448 @@ +//! #7576 acceptance: the TC39 iterator-helpers surface, exercised through the +//! same dispatch tower a compiled program reaches (`js_native_call_method`), +//! not by calling [`dispatch_iterator_helper_method`] directly. +//! +//! Going through the tower is the point. The bug was a class-id COLLISION — +//! `ITERATOR_HELPER_CLASS_ID` and `STRING_ITERATOR_CLASS_ID` were both +//! `0xFFFF_0009`, and the tower's String arm is tested first — so a test that +//! called the helper dispatcher directly passed while every real program got +//! an empty iterator. Any test here that stops short of the tower would have +//! been green throughout the outage. +//! +//! These live as `cargo test -p perry-runtime` unit tests rather than under +//! `crates/*/tests/` because the integration suites only run nightly/tag +//! (CLAUDE.md #5960). The user-visible byte-for-byte parity half is +//! `test-files/test_gap_iterator_helpers_{2874,7576}.ts` — the first was +//! already in `test-parity/known_failures.json` for the whole outage, which is +//! why the outage lasted; it is un-skipped by this change. + +use super::{ITERATOR_HELPER_CLASS_ID, OP_DROP, OP_FILTER, OP_FLATMAP, OP_MAP, OP_TAKE}; +use crate::array::ArrayHeader; +use crate::object::ObjectHeader; +use crate::value::JSValue; + +/// A NaN-boxed array of the given numbers. +unsafe fn number_array(values: &[f64]) -> f64 { + let mut a = crate::array::js_array_alloc(values.len().max(1) as u32); + for v in values { + a = crate::array::js_array_push_f64(a, *v); + } + crate::value::js_nanbox_pointer(a as i64) +} + +/// Call `recv.method(args)` through the production dispatch tower. +unsafe fn tower(recv: f64, method: &str, args: &[f64]) -> f64 { + crate::object::js_native_call_method( + recv, + method.as_ptr() as *const i8, + method.len(), + if args.is_empty() { + std::ptr::null() + } else { + args.as_ptr() + }, + args.len(), + ) +} + +/// The `(value, done)` pair of an iterator-result object. +unsafe fn step(result: f64) -> (JSValue, bool) { + let p = crate::value::js_nanbox_get_pointer(result); + assert_ne!( + p, + 0, + "`.next()` must return an iterator-result OBJECT, got bits {:#018x}", + result.to_bits() + ); + let obj = p as *mut ObjectHeader; + let value = crate::object::js_object_get_field(obj, 0); + let done = crate::object::js_object_get_field(obj, 1); + ( + value, + crate::value::js_is_truthy(f64::from_bits(done.bits())) != 0, + ) +} + +/// Drive `iter.next()` through the tower until done, collecting the numbers. +unsafe fn drain(iter: f64) -> Vec { + let mut out = Vec::new(); + for _ in 0..64 { + let (value, done) = step(tower(iter, "next", &[])); + if done { + return out; + } + out.push(f64::from_bits(value.bits())); + } + panic!("iterator did not finish in 64 steps"); +} + +/// The numbers in a NaN-boxed array value. +unsafe fn array_numbers(value: f64) -> Vec { + let p = crate::value::js_nanbox_get_pointer(value); + assert_ne!( + p, + 0, + "expected an ARRAY, got bits {:#018x}", + value.to_bits() + ); + let arr = p as *mut ArrayHeader; + (0..crate::array::js_array_length(arr)) + .map(|i| crate::array::js_array_get_f64(arr, i)) + .collect() +} + +/// `Iterator.from(iterable)` — the object every case below starts from. +unsafe fn helper_over(values: &[f64]) -> f64 { + let h = super::js_iterator_from(number_array(values)); + let p = crate::value::js_nanbox_get_pointer(h); + assert_ne!(p, 0, "`Iterator.from` must return an object"); + assert_eq!( + (*(p as *const ObjectHeader)).class_id, + ITERATOR_HELPER_CLASS_ID, + "`Iterator.from` must return an iterator-helper object" + ); + h +} + +/// The closure the combinator tests map with. Perry closures are invoked with +/// the argument in the first `f64` slot; `js_closure_alloc` + a registered +/// arity is the runtime-side equivalent of a compiled arrow function. +extern "C" fn double_it(_c: *const crate::closure::ClosureHeader, x: f64) -> f64 { + f64::from_bits(JSValue::number(f64::from_bits(x.to_bits()) * 2.0).bits()) +} + +extern "C" fn is_even(_c: *const crate::closure::ClosureHeader, x: f64) -> f64 { + let v = f64::from_bits(x.to_bits()); + f64::from_bits(if v as i64 % 2 == 0 { + crate::value::TAG_TRUE + } else { + crate::value::TAG_FALSE + }) +} + +/// `(x) => [x, x * 10]`, for `flatMap`. +extern "C" fn pair_with_ten_times(_c: *const crate::closure::ClosureHeader, x: f64) -> f64 { + let v = f64::from_bits(x.to_bits()); + unsafe { number_array(&[v, v * 10.0]) } +} + +/// `(acc, v) => acc + v`, for `reduce`. +extern "C" fn add(_c: *const crate::closure::ClosureHeader, a: f64, b: f64) -> f64 { + let sum = f64::from_bits(a.to_bits()) + f64::from_bits(b.to_bits()); + f64::from_bits(JSValue::number(sum).bits()) +} + +unsafe fn closure1(f: extern "C" fn(*const crate::closure::ClosureHeader, f64) -> f64) -> f64 { + let p = f as *const u8; + crate::closure::js_register_closure_arity(p, 1); + crate::value::js_nanbox_pointer(crate::closure::js_closure_alloc(p, 0) as i64) +} + +unsafe fn closure2(f: extern "C" fn(*const crate::closure::ClosureHeader, f64, f64) -> f64) -> f64 { + let p = f as *const u8; + crate::closure::js_register_closure_arity(p, 2); + crate::value::js_nanbox_pointer(crate::closure::js_closure_alloc(p, 0) as i64) +} + +// --------------------------------------------------------------------------- +// The collision itself. +// --------------------------------------------------------------------------- + +/// #7576: every runtime-defined iterator family must own a DISTINCT class id. +/// +/// This is the test that would have caught the outage at the moment it was +/// introduced. The helper id was copied from the comment above the String +/// iterator's ("sits just past the Set iterator id"), landing on the identical +/// value; because the towers test String first, the collision presented as a +/// silently empty iterator rather than as anything resembling a duplicate +/// constant. +/// +/// SABOTAGE CHECK: set `ITERATOR_HELPER_CLASS_ID` back to `0xFFFF_0009` and +/// this fails on the `iterator-helper` / `string` pair. +#[test] +fn iterator_class_ids_are_pairwise_distinct() { + let ids: [(&str, u32); 7] = [ + ("buffer", crate::buffer::BUFFER_ITERATOR_CLASS_ID), + ("array", crate::array::ARRAY_ITERATOR_CLASS_ID), + ("map", crate::collection_iter_object::MAP_ITERATOR_CLASS_ID), + ("set", crate::collection_iter_object::SET_ITERATOR_CLASS_ID), + ("string", crate::string::STRING_ITERATOR_CLASS_ID), + ( + "regexp-string", + crate::regex::REGEXP_STRING_ITERATOR_CLASS_ID, + ), + ("iterator-helper", ITERATOR_HELPER_CLASS_ID), + ]; + for (i, (a_name, a_id)) in ids.iter().enumerate() { + for (b_name, b_id) in &ids[i + 1..] { + assert_ne!( + a_id, b_id, + "class-id collision: `{a_name}` and `{b_name}` are both {a_id:#010x}. \ + Every dispatch tower matches these in a fixed order, so the LATER \ + arm becomes unreachable and its whole method surface dies silently." + ); + } + } +} + +/// The op-kind discriminants live in field 1 of the helper object, which is +/// exactly the slot the String iterator's dispatcher read as a cursor index. +/// Pin them so a future renumbering is a deliberate act. +#[test] +fn op_kinds_are_distinct() { + let ops = [ + super::OP_IDENTITY, + OP_MAP, + OP_FILTER, + OP_TAKE, + OP_DROP, + OP_FLATMAP, + ]; + for (i, a) in ops.iter().enumerate() { + for b in &ops[i + 1..] { + assert_ne!(a, b, "duplicate iterator-helper op kind {a}"); + } + } +} + +// --------------------------------------------------------------------------- +// The surface, all of it, through the tower. +// --------------------------------------------------------------------------- + +#[test] +fn iterator_from_yields_the_source_sequence() { + unsafe { + assert_eq!(drain(helper_over(&[1.0, 2.0, 3.0])), vec![1.0, 2.0, 3.0]); + } +} + +#[test] +fn iterator_from_on_an_existing_helper_returns_it_unchanged() { + unsafe { + let h = helper_over(&[1.0, 2.0]); + assert_eq!( + super::js_iterator_from(h).to_bits(), + h.to_bits(), + "`Iterator.from` on something already inheriting Iterator.prototype \ + must return it unchanged" + ); + } +} + +#[test] +fn map_returns_a_helper_and_transforms_lazily() { + unsafe { + let m = tower(helper_over(&[1.0, 2.0, 3.0]), "map", &[closure1(double_it)]); + let p = crate::value::js_nanbox_get_pointer(m); + assert_ne!(p, 0, "`.map()` must return a helper OBJECT, not undefined"); + assert_eq!( + (*(p as *const ObjectHeader)).class_id, + ITERATOR_HELPER_CLASS_ID + ); + assert_eq!(drain(m), vec![2.0, 4.0, 6.0]); + } +} + +#[test] +fn filter_returns_a_helper_and_keeps_matching_values() { + unsafe { + let f = tower( + helper_over(&[1.0, 2.0, 3.0, 4.0]), + "filter", + &[closure1(is_even)], + ); + assert_ne!(crate::value::js_nanbox_get_pointer(f), 0); + assert_eq!(drain(f), vec![2.0, 4.0]); + } +} + +#[test] +fn take_returns_a_helper_and_stops_after_n() { + unsafe { + let t = tower( + helper_over(&[1.0, 2.0, 3.0, 4.0, 5.0]), + "take", + &[f64::from_bits(JSValue::number(2.0).bits())], + ); + assert_ne!(crate::value::js_nanbox_get_pointer(t), 0); + assert_eq!(drain(t), vec![1.0, 2.0]); + } +} + +#[test] +fn drop_returns_a_helper_and_skips_the_first_n() { + unsafe { + let d = tower( + helper_over(&[1.0, 2.0, 3.0, 4.0]), + "drop", + &[f64::from_bits(JSValue::number(2.0).bits())], + ); + assert_ne!(crate::value::js_nanbox_get_pointer(d), 0); + assert_eq!(drain(d), vec![3.0, 4.0]); + } +} + +#[test] +fn flat_map_returns_a_helper_and_flattens_one_level() { + unsafe { + let f = tower( + helper_over(&[1.0, 2.0]), + "flatMap", + &[closure1(pair_with_ten_times)], + ); + assert_ne!(crate::value::js_nanbox_get_pointer(f), 0); + assert_eq!(drain(f), vec![1.0, 10.0, 2.0, 20.0]); + } +} + +#[test] +fn chained_helpers_compose() { + unsafe { + // Iterator.from([1..6]).map(x => x*2).filter(even).take(2) → [2, 4] + let m = tower( + helper_over(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]), + "map", + &[closure1(double_it)], + ); + let f = tower(m, "filter", &[closure1(is_even)]); + let t = tower(f, "take", &[f64::from_bits(JSValue::number(2.0).bits())]); + assert_eq!(drain(t), vec![2.0, 4.0]); + } +} + +#[test] +fn to_array_drains_the_chain() { + unsafe { + let a = tower(helper_over(&[1.0, 2.0, 3.0]), "toArray", &[]); + assert_eq!(array_numbers(a), vec![1.0, 2.0, 3.0]); + let mapped = tower(helper_over(&[1.0, 2.0]), "map", &[closure1(double_it)]); + assert_eq!(array_numbers(tower(mapped, "toArray", &[])), vec![2.0, 4.0]); + } +} + +#[test] +fn reduce_with_and_without_an_initial_value() { + unsafe { + let with_init = tower( + helper_over(&[1.0, 2.0, 3.0]), + "reduce", + &[closure2(add), f64::from_bits(JSValue::number(10.0).bits())], + ); + assert_eq!(f64::from_bits(with_init.to_bits()), 16.0); + + let no_init = tower( + helper_over(&[1.0, 2.0, 3.0, 4.0]), + "reduce", + &[closure2(add)], + ); + assert_eq!(f64::from_bits(no_init.to_bits()), 10.0); + } +} + +#[test] +fn some_every_find_short_circuit() { + unsafe { + assert_eq!( + tower(helper_over(&[1.0, 2.0, 3.0]), "some", &[closure1(is_even)]).to_bits(), + crate::value::TAG_TRUE + ); + assert_eq!( + tower(helper_over(&[1.0, 3.0, 5.0]), "some", &[closure1(is_even)]).to_bits(), + crate::value::TAG_FALSE + ); + assert_eq!( + tower(helper_over(&[2.0, 4.0]), "every", &[closure1(is_even)]).to_bits(), + crate::value::TAG_TRUE + ); + assert_eq!( + tower(helper_over(&[2.0, 3.0]), "every", &[closure1(is_even)]).to_bits(), + crate::value::TAG_FALSE + ); + let found = tower(helper_over(&[1.0, 2.0, 3.0]), "find", &[closure1(is_even)]); + assert_eq!(f64::from_bits(found.to_bits()), 2.0); + let missing = tower(helper_over(&[1.0, 3.0]), "find", &[closure1(is_even)]); + assert!(JSValue::from_bits(missing.to_bits()).is_undefined()); + } +} + +#[test] +fn symbol_iterator_returns_the_helper_itself() { + unsafe { + let h = helper_over(&[1.0]); + assert_eq!(tower(h, "@@iterator", &[]).to_bits(), h.to_bits()); + } +} + +/// `next()` written as an OWN method that reads `this` must receive the +/// iterator as its receiver — `IteratorNext`'s `Call(next, iterator)`. +/// +/// This pins the second half of the `iterator_step` fix independently of the +/// own-vs-inherited lookup: an own `next` takes the closure branch either way, +/// so only the `js_implicit_this_set` around the call makes this pass. It is +/// the shape a hand-written `next() { return this.#impl.next(); }` takes, and +/// pre-fix it saw `undefined`. +/// +/// SABOTAGE CHECK: drop the `js_implicit_this_set` pair in `iterator_step` and +/// this reports a receiver of `undefined`. +#[test] +fn an_own_next_method_is_called_with_the_iterator_as_this() { + use std::sync::atomic::{AtomicU64, Ordering}; + /// Bits of the `this` the last `next()` observed. + static OBSERVED_THIS: AtomicU64 = AtomicU64::new(0); + + extern "C" fn next_recording_this(_c: *const crate::closure::ClosureHeader, _arg: f64) -> f64 { + let this = crate::object::js_implicit_this_get(); + OBSERVED_THIS.store(this.to_bits(), Ordering::SeqCst); + unsafe { crate::iter_result::make_iter_result(JSValue::undefined(), true) } + } + + unsafe { + // A bare `{ next() {...} }` object: one own field named `next`. + let obj = crate::object::js_object_alloc(0, 1); + let key = crate::string::js_string_from_bytes(b"next".as_ptr(), 4); + let p = next_recording_this as *const u8; + crate::closure::js_register_closure_arity(p, 0); + let closure = crate::closure::js_closure_alloc(p, 0); + crate::object::js_object_set_field_by_name( + obj, + key, + crate::value::js_nanbox_pointer(closure as i64), + ); + let iterable = crate::value::js_nanbox_pointer(obj as i64); + + let h = super::js_iterator_from(iterable); + OBSERVED_THIS.store(0, Ordering::SeqCst); + let (_v, done) = step(tower(h, "next", &[])); + assert!(done, "the stub `next` reports done"); + + let observed = OBSERVED_THIS.load(Ordering::SeqCst); + assert_ne!(observed, 0, "`next` was never called"); + assert_eq!( + observed, + iterable.to_bits(), + "`next()` must run with `this` === the iterator (got bits {observed:#018x}, \ + expected {:#018x}); `undefined` here is the pre-#7576 behaviour", + iterable.to_bits() + ); + } +} + +/// A String iterator must still dispatch as a String iterator after the +/// renumbering — the other half of the collision. +#[test] +fn string_iterator_still_dispatches_to_its_own_family() { + unsafe { + let s = crate::string::js_string_from_bytes(b"ab".as_ptr(), 2); + let it = crate::string::string_values_iter(s); + assert_eq!( + (*(crate::value::js_nanbox_get_pointer(it) as *const ObjectHeader)).class_id, + crate::string::STRING_ITERATOR_CLASS_ID + ); + let (v, done) = step(tower(it, "next", &[])); + assert!(!done, "a fresh String iterator must not report done"); + assert!( + !JSValue::from_bits(v.bits()).is_undefined(), + "a String iterator's first step must carry a code point" + ); + } +} diff --git a/crates/perry-runtime/src/string/iter_object.rs b/crates/perry-runtime/src/string/iter_object.rs index 714e23b478..197321b6c2 100644 --- a/crates/perry-runtime/src/string/iter_object.rs +++ b/crates/perry-runtime/src/string/iter_object.rs @@ -20,8 +20,19 @@ use crate::object::{js_object_alloc, js_object_get_field, js_object_set_field, O use crate::value::{js_nanbox_get_pointer, js_nanbox_pointer, JSValue, TAG_UNDEFINED}; use crate::StringHeader; -/// Class id reserved for String iterators. Sits just past the Set iterator id -/// (0xFFFF0008) in the 0xFFFF prefix reserved for runtime-defined classes. +/// Class id reserved for String iterators, in the 0xFFFF prefix reserved for +/// runtime-defined classes. +/// +/// #7576: "sits just past the Set iterator id (0xFFFF0008)" was this comment's +/// original wording, and it was copied verbatim onto +/// [`ITERATOR_HELPER_CLASS_ID`], which then claimed the same value. Every +/// dispatch tower matches these ids in a fixed order, so the later arm went +/// unreachable and the whole iterator-helper surface died silently. **The next +/// free id is not "one past the id in the comment above" — check the family**: +/// `iterator_helpers::tests::iterator_class_ids_are_pairwise_distinct` +/// enumerates every one of them and fails on a duplicate. +/// +/// [`ITERATOR_HELPER_CLASS_ID`]: crate::iterator_helpers::ITERATOR_HELPER_CLASS_ID pub const STRING_ITERATOR_CLASS_ID: u32 = 0xFFFF_0009; unsafe fn alloc_iterator(cp_array: *mut ArrayHeader) -> f64 { diff --git a/test-files/test_gap_iterator_helpers_7576.ts b/test-files/test_gap_iterator_helpers_7576.ts new file mode 100644 index 0000000000..37b426ebf6 --- /dev/null +++ b/test-files/test_gap_iterator_helpers_7576.ts @@ -0,0 +1,109 @@ +// #7576: `ITERATOR_HELPER_CLASS_ID` collided with `STRING_ITERATOR_CLASS_ID` +// (both 0xFFFF_0009), so every helper object dispatched as a String iterator: +// `Iterator.from(x)` was exhausted before its first step and every combinator +// returned `undefined`. +// +// `test_gap_iterator_helpers_2874.ts` covers the inline-chain forms. This file +// covers the shapes that file does not: a helper STORED in a local and stepped +// with `.next()` by hand, and every source kind (bare `{ next() }`, array +// iterator, Map iterator, Set iterator, string iterator, generator). + +function makeCounter(n: number): Iterator { + let i = 0; + return { + next(): IteratorResult { + return i < n ? { value: i++, done: false } : { value: undefined, done: true }; + }, + }; +} + +function* gen(n: number): Generator { + for (let k = 0; k < n; k++) yield k; +} + +function drain(it: { next(): { value: unknown; done?: boolean } }): unknown[] { + const out: unknown[] = []; + let s = it.next(); + let guard = 0; + while (!s.done && guard++ < 50) { + out.push(s.value); + s = it.next(); + } + return out; +} + +// --- stored helper, hand-stepped: the shape that was silently empty --- +const a = Iterator.from(makeCounter(4)); +console.log("A", JSON.stringify(drain(a))); + +const b = Iterator.from([7, 8, 9][Symbol.iterator]()); +console.log("B", JSON.stringify(drain(b))); + +// the same object driven directly, WITHOUT the helper (the control case) +console.log("C", JSON.stringify(drain(makeCounter(3)))); + +const d = Iterator.from(makeCounter(2)); +console.log("D", JSON.stringify(d.next())); +console.log("D2", JSON.stringify(d.next())); +console.log("D3", JSON.stringify(d.next())); + +// --- every source kind reaches the same helper --- +console.log("E array", JSON.stringify(Iterator.from([1, 2, 3]).toArray())); +console.log("E gen", JSON.stringify(Iterator.from(gen(3)).toArray())); +console.log( + "E map", + JSON.stringify(Iterator.from(new Map([["k", 1], ["j", 2]]).values()).toArray()), +); +console.log("E set", JSON.stringify(Iterator.from(new Set([4, 5])).toArray())); +console.log("E string", JSON.stringify(Iterator.from("hi"[Symbol.iterator]()).toArray())); +console.log("E arrayiter", JSON.stringify(Iterator.from([6, 7].values()).toArray())); + +// --- combinators return helper objects, not undefined --- +const src = Iterator.from([1, 2, 3, 4, 5, 6]); +const mapped = src.map((x: number) => x * 2); +console.log("F typeof", typeof mapped); +console.log("F chain", JSON.stringify(mapped.filter((x: number) => x % 3 !== 0).toArray())); + +console.log("G take", JSON.stringify(Iterator.from(gen(9)).take(3).toArray())); +console.log("G drop", JSON.stringify(Iterator.from(gen(5)).drop(2).toArray())); +console.log( + "G flatMap", + JSON.stringify(Iterator.from([1, 2]).flatMap((x: number) => [x, -x]).toArray()), +); + +// --- terminal helpers on a stored receiver --- +const t1 = Iterator.from([1, 2, 3, 4]); +console.log("H reduce", t1.reduce((p: number, c: number) => p + c, 100)); +const t2 = Iterator.from([1, 2, 3]); +console.log("H reduce-noinit", t2.reduce((p: number, c: number) => p + c)); +const t3 = Iterator.from([1, 2, 3]); +console.log("H some", t3.some((x: number) => x === 2)); +const t4 = Iterator.from([1, 2, 3]); +console.log("H every", t4.every((x: number) => x > 0)); +const t5 = Iterator.from([1, 2, 3]); +console.log("H find", t5.find((x: number) => x > 1)); +const t6 = Iterator.from([1, 2, 3]); +let acc = 0; +t6.forEach((x: number) => { + acc += x; +}); +console.log("H forEach", acc); + +// --- laziness: `.take` must terminate over an unbounded generator --- +function* naturals(): Generator { + let i = 0; + while (true) yield i++; +} +console.log("I lazy", JSON.stringify(Iterator.from(naturals()).map((x: number) => x * 3).take(4).toArray())); + +// --- spread drives the helper through the iterator protocol --- +console.log("J spread", JSON.stringify([...Iterator.from([1, 2, 3]).map((x: number) => x + 1)])); + +// --- the String iterator, the other half of the collision, is unaffected --- +const si = "abc"[Symbol.iterator](); +console.log("K string-iter", JSON.stringify(si.next()), JSON.stringify(drain(si))); +console.log("K spread", JSON.stringify([..."héllo"])); + +// --- Iterator.from on something already a helper hands it back --- +const h = Iterator.from([1, 2]); +console.log("L identity", Iterator.from(h) === h); diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index 18088a03f0..dc802ad606 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -106,12 +106,6 @@ "category": "module-inventory", "reason": "global API surface inventory; standing per #5917." }, - "test_gap_iterator_helpers_2874": { - "issue": "2874", - "added": "2026-07-04", - "category": "bug-open", - "reason": "iterator helpers (#2874); standing per #5917." - }, "test_gap_module_const_local_shadow": { "issue": "5917", "added": "2026-07-04",