diff --git a/changelog.d/9846-iterator-next-override-probe-allocation.md b/changelog.d/9846-iterator-next-override-probe-allocation.md new file mode 100644 index 0000000000..b127d8fd7c --- /dev/null +++ b/changelog.d/9846-iterator-next-override-probe-allocation.md @@ -0,0 +1,55 @@ +### Fixed + +- **Every built-in iterator step allocated a `"next"` key string to learn that + nothing was patched.** `call_overridden_iterator_next` — the per-step probe + that lets a user replacement of `%ArrayIteratorPrototype%.next` (and the Map + / Set / String family prototypes) drive `for…of`, spread, `Array.from` and + manual `.next()` — ended in a by-name prototype lookup that minted a fresh + 4-byte `"next"` string on every call. One 32-byte allocation per iteration + step of every array, Map, Set and string iterator in the program. + + The existing early-out could not prevent it. `ITERATOR_PROTOTYPE_PTR == 0` + ("the tower was never materialized, so no override can exist") is **dead on + any program that has allocated one iterator**: every iterator allocator calls + `attach_iterator_prototype`, which calls `ensure_iterator_prototypes`, which + builds the tower. The guard is true exactly once and false forever after. + + Replaced by an allocation-free proof that runs on the path every real program + takes: the prototype's OWN `next` slot still holds a closure whose native + entry is the canonical thunk (the certified non-allocating own-field read, + #9480), AND no accessor descriptor is recorded for `"next"` on it (the + per-key Bloom bit `set_accessor_descriptor` sets before inserting, #6759 C2 — + needed because `defineProperty(proto, "next", {get})` leaves the old closure + in the data slot and puts the accessor in the side table). Anything else — + replaced, deleted, an accessor, a bound copy — takes the by-name path + unchanged. + + Affected files: + + - `crates/perry-runtime/src/object/iterator_prototypes.rs` — the + `prototype_next_is_canonical` probe, ahead of the by-name lookup. + + Measured: the 2026-09-06 claude-code allocation census ranked this site third + by count — ~122,880 allocations of 32 bytes per 400-character reply, 17.1 % + of the top-30 allocation count — and misattributed it to `Intl.Segmenter` + substring copying. Resolved by an explicit caller walk in the shipped binary: + `js_for_of_next+0xd0` → `dispatch_array_iterator_method_inner+0x218` (a `bl` + to `call_overridden_iterator_next`) → `+0x67c` (a `bl` to + `js_string_from_bytes_with_capacity`) → `string_storage_alloc`. + + Validation: `test-files/test_gap_iterator_prototype_next_patch.ts` drives a + replaced `next` through `for…of`, spread, `Array.from` and manual `.next()` + on all four families, and covers restore-by-identity, a second replace after + a restore, a bound copy of the original (which must NOT be mistaken for the + builtin), an accessor `next`, and a deleted `next`. The unit counter asserts + that 1,000 probes on an unpatched iterator with the tower materialized move + the arena by ZERO bytes, with the minor-cycle count pinned so a collection + inside the window cannot manufacture a zero delta. + + Counter on a relinked claude-code binary (this fix plus a measurement-only + hit/miss counter; before the fix every probe allocated, so `hits + byname` is + the pre-fix count and `byname` is what survives): a 400-character reply runs + **144,189 / 144,303** probes and a 3300-character reply **887,076**, with + **`byname = 0` on every one of the 173 per-minor reports across three runs** + — the proof answers 100 % of probes on a real program. At 32 B a string that + is 4.6 MB and 28.4 MB of allocation removed per process respectively. diff --git a/crates/perry-runtime/src/object/iterator_prototypes.rs b/crates/perry-runtime/src/object/iterator_prototypes.rs index 98e53d1fbe..1865aa9fed 100644 --- a/crates/perry-runtime/src/object/iterator_prototypes.rs +++ b/crates/perry-runtime/src/object/iterator_prototypes.rs @@ -494,6 +494,31 @@ pub(crate) unsafe fn call_overridden_iterator_next( if proto.with_const_ptr::(|proto| proto.is_null()) { return None; } + // The null-tower proof above is dead on any program that has allocated + // one iterator: `attach_iterator_prototype` materializes the tower at the + // FIRST iterator allocation, so every builtin advance after that reached + // the by-name lookup below and minted a fresh "next" key string just to + // learn nothing was patched — one 24-byte string per `for…of` step, on + // every array / Map / Set / string iterator in the program (~137,000 per + // 400-character claude-code reply; the second 32-byte site of the + // 2026-09-06 allocation census). + // + // Allocation-free proof of "not overridden": the prototype's OWN `next` + // slot still holds a closure whose native entry is the canonical thunk, + // AND no accessor descriptor is recorded for "next" on it. The own read + // is the certified non-allocating leaf (#9480); the accessor check is + // the per-key Bloom bit `set_accessor_descriptor` sets BEFORE inserting + // (#6759 C2), needed because `defineProperty(proto, "next", {get})` on + // an existing data property leaves the old closure in the slot and puts + // the accessor in the side table. Anything else — replaced, deleted, + // accessor, a bound copy — takes the by-name path, unchanged. + // The closure body is NOT covered by the enclosing `unsafe fn`'s implicit + // unsafe block, so the call is spelled out. + if proto.with_const_ptr::(|proto| unsafe { + prototype_next_is_canonical(proto, canonical) + }) { + return None; + } let key = scope.root_raw_const_ptr(crate::string::js_string_from_bytes(b"next".as_ptr(), 4)); let method = proto.with_const_ptr::(|proto| { key.with_const_ptr::(|key| { @@ -517,3 +542,221 @@ pub(crate) unsafe fn call_overridden_iterator_next( Err(error) => crate::exception::js_throw(error), } } + +/// Does `proto`'s OWN `next` data slot hold a closure whose native entry is +/// `canonical`, with no accessor descriptor recorded for `"next"`? A `true` +/// proves the prototype's `next` is the builtin (a user restoring the +/// original closure object after a patch matches too, by entry rather than +/// by object identity); a `false` proves nothing and the caller must run the +/// full by-name lookup. Reads only: no allocation, no collection point. +#[inline] +unsafe fn prototype_next_is_canonical(proto: *const ObjectHeader, canonical: *const u8) -> bool { + let own = super::js_object_get_own_field_or_undef( + crate::value::js_nanbox_pointer(proto as i64), + b"next".as_ptr(), + 4, + ); + if !JSValue::from_bits(own.to_bits()).is_pointer() { + return false; + } + let own_ptr = crate::value::js_nanbox_get_pointer(own) as *const crate::closure::ClosureHeader; + if own_ptr.is_null() || crate::closure::get_valid_func_ptr(own_ptr) != canonical { + return false; + } + !super::descriptor_state::may_have_descriptor_entry(proto as usize, "next", true) +} + +/// The prototype-override probe must be free on the path every real program +/// takes: tower materialized (any iterator allocation does that), nothing +/// patched. Before this module's `prototype_next_is_canonical`, that path +/// allocated a "next" key string per call — the second-largest 32-byte +/// allocation site of a claude-code reply (2026-09-06 census, ~137,000 per +/// 400 characters), mislabelled there as a substring copy. +#[cfg(test)] +mod override_probe_allocation_tests { + use super::*; + use crate::closure::ClosureHeader; + use crate::value::{js_nanbox_get_pointer, js_nanbox_pointer, TAG_UNDEFINED}; + + const PATCHED_SENTINEL: f64 = 4242.0; + + extern "C" fn patched_next_thunk(_closure: *const ClosureHeader) -> f64 { + PATCHED_SENTINEL + } + + extern "C" fn accessor_getter_thunk(_closure: *const ClosureHeader) -> f64 { + f64::from_bits(TAG_UNDEFINED) + } + + /// One array iterator, rooted; materializes the tower as a side effect. + unsafe fn rooted_array_iterator( + scope: &crate::gc::RuntimeHandleScope, + ) -> crate::gc::RuntimeHandle<'_> { + let arr = crate::array::js_array_alloc(1); + crate::array::js_array_push_f64(arr, 1.0); + let iter = crate::array::array_values_iter(js_nanbox_pointer(arr as i64)); + assert!( + iterator_prototypes_materialized(), + "premise: allocating an iterator materializes the tower" + ); + scope.root_nanbox_f64(iter) + } + + unsafe fn array_proto() -> *mut ObjectHeader { + ARRAY_ITERATOR_PROTOTYPE_PTR.load(Ordering::Acquire) as *mut ObjectHeader + } + + unsafe fn set_proto_next(value: f64) { + let key = crate::string::js_string_from_bytes(b"next".as_ptr(), 4); + super::super::js_object_set_field_by_name(array_proto(), key, value); + } + + unsafe fn own_next(proto: *const ObjectHeader) -> f64 { + super::super::js_object_get_own_field_or_undef( + js_nanbox_pointer(proto as i64), + b"next".as_ptr(), + 4, + ) + } + + /// The counter, and the falsifier for the fix: N probes on an unpatched + /// iterator with the tower up must bump the arena by ZERO bytes. Before + /// the fix every probe minted a 24-byte "next" string (32 B rounded), so + /// this read N × 32 — the number the census reported per grapheme. + #[test] + fn probe_on_an_unpatched_iterator_allocates_nothing() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let iter_h = rooted_array_iterator(&scope); + let iter_obj = || js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; + + // Warm once: a first call may lazily build anything it builds. + assert!( + call_overridden_iterator_next(iter_obj(), crate::array::ARRAY_ITERATOR_CLASS_ID) + .is_none() + ); + const N: usize = 1000; + let minors_before = crate::gc::instruments::copying_minor_cycles(); + let bytes_before = crate::arena::arena_in_use_bytes(); + for _ in 0..N { + assert!( + call_overridden_iterator_next( + iter_obj(), + crate::array::ARRAY_ITERATOR_CLASS_ID + ) + .is_none(), + "nothing is patched, so the probe must decline" + ); + } + let bytes_after = crate::arena::arena_in_use_bytes(); + assert_eq!( + crate::gc::instruments::copying_minor_cycles(), + minors_before, + "a collection inside the window would make a zero delta prove nothing" + ); + assert_eq!( + bytes_after.saturating_sub(bytes_before), + 0, + "the override probe allocated {} bytes over {N} calls on an unpatched \ + iterator with the tower materialized (it minted a \"next\" key string per call)", + bytes_after.saturating_sub(bytes_before) + ); + } + } + + /// The fast path must not be too eager: a replaced prototype `next` is + /// still honoured, and restoring the ORIGINAL closure object (what + /// `test_gap_array_iterator_manual_next.ts` (7) does) returns the probe + /// to its allocation-free decline — by native entry, not by identity. + #[test] + fn probe_honours_a_replaced_prototype_next_and_a_restored_one() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let iter_h = rooted_array_iterator(&scope); + let iter_obj = || js_nanbox_get_pointer(iter_h.get_nanbox_f64()) as *mut ObjectHeader; + + let original = scope.root_nanbox_f64(own_next(array_proto())); + assert!( + JSValue::from_bits(original.get_nanbox_f64().to_bits()).is_pointer(), + "premise: the prototype carries an own `next` closure" + ); + + let patched = crate::closure::js_closure_alloc(patched_next_thunk as *const u8, 0); + crate::closure::js_register_closure_arity(patched_next_thunk as *const u8, 0); + let patched_h = scope.root_nanbox_f64(js_nanbox_pointer(patched as i64)); + set_proto_next(patched_h.get_nanbox_f64()); + assert!( + !prototype_next_is_canonical(array_proto(), array_iterator_next_thunk as *const u8), + "a replaced prototype `next` must defeat the allocation-free proof" + ); + assert_eq!( + call_overridden_iterator_next(iter_obj(), crate::array::ARRAY_ITERATOR_CLASS_ID), + Some(PATCHED_SENTINEL), + "the replacement installed on the prototype must be the one called" + ); + + set_proto_next(original.get_nanbox_f64()); + assert!( + prototype_next_is_canonical(array_proto(), array_iterator_next_thunk as *const u8), + "restoring the original closure must re-enable the allocation-free proof" + ); + assert!( + call_overridden_iterator_next(iter_obj(), crate::array::ARRAY_ITERATOR_CLASS_ID) + .is_none(), + "after the restore the builtin advance is back" + ); + } + } + + /// `Object.defineProperty(proto, "next", { get })` records the accessor in + /// the descriptor side table and leaves the old data slot behind, so the + /// own-slot read alone would still see the canonical closure. The per-key + /// accessor bit is what makes the proof decline; without it the getter + /// would be silently bypassed. + #[test] + fn probe_declines_when_an_accessor_next_is_defined_on_the_prototype() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let _iter_h = rooted_array_iterator(&scope); + let original = scope.root_nanbox_f64(own_next(array_proto())); + assert!( + prototype_next_is_canonical(array_proto(), array_iterator_next_thunk as *const u8), + "premise: unpatched prototype passes the proof" + ); + + let getter = crate::closure::js_closure_alloc(accessor_getter_thunk as *const u8, 0); + crate::closure::js_register_closure_arity(accessor_getter_thunk as *const u8, 0); + let getter_h = scope.root_nanbox_f64(js_nanbox_pointer(getter as i64)); + let key = scope.root_string_ptr(crate::string::js_string_from_bytes(b"next".as_ptr(), 4)); + super::super::js_object_define_accessor( + js_nanbox_pointer(array_proto() as i64), + key.with_const_ptr::(|k| { + f64::from_bits(JSValue::string_ptr(k as *mut crate::StringHeader).bits()) + }), + getter_h.get_nanbox_f64(), + f64::from_bits(TAG_UNDEFINED), + ); + assert!( + !prototype_next_is_canonical(array_proto(), array_iterator_next_thunk as *const u8), + "an accessor `next` on the prototype must defeat the allocation-free proof \ + even though the data slot may still hold the canonical closure" + ); + + // Delete the accessor and put the data property back. The Bloom + // bit is sticky (zeroed only at meta creation), so the PROOF stays + // declined on this prototype for good — conservative: the by-name + // path runs, exactly as before the fix. Only the semantics are + // pinned here: the builtin advance is back. + key.with_const_ptr::(|k| { + super::super::js_object_delete_field(array_proto(), k); + }); + set_proto_next(original.get_nanbox_f64()); + let iter_obj = js_nanbox_get_pointer(_iter_h.get_nanbox_f64()) as *mut ObjectHeader; + assert!( + call_overridden_iterator_next(iter_obj, crate::array::ARRAY_ITERATOR_CLASS_ID) + .is_none(), + "after delete + restore the builtin advance must be back" + ); + } + } +} diff --git a/crates/perry/tests/issue_9846_iterator_prototype_next_patch.rs b/crates/perry/tests/issue_9846_iterator_prototype_next_patch.rs new file mode 100644 index 0000000000..18bcdae86e --- /dev/null +++ b/crates/perry/tests/issue_9846_iterator_prototype_next_patch.rs @@ -0,0 +1,111 @@ +//! Regression coverage for the allocation-free "is `next` still the builtin?" +//! proof in `object/iterator_prototypes.rs`. +//! +//! The probe that lets a user replacement of `%ArrayIteratorPrototype%.next` +//! (and the Map / Set / String family prototypes) drive `for…of`, spread, +//! `Array.from` and manual `.next()` used to end in a by-name prototype lookup +//! that minted a fresh `"next"` key string on EVERY built-in iterator step. +//! The proof that removed it reads the prototype's own `next` slot and the +//! per-key accessor Bloom bit instead — so every way of *defeating* that proof +//! has to keep working, and every way of *restoring* it has to hand iteration +//! back to the builtin. +//! +//! The expected output below is `node v26.5.1` running the same source +//! (`test-files/test_gap_iterator_prototype_next_patch.ts`), captured +//! 2026-09-06. The discriminating lines are: +//! +//! * `F-bound-copy 100,200` — `orig.bind(other)` has the SAME native entry as +//! the builtin thunk but a different `this`. A proof that compared by native +//! entry alone, without first reading the prototype's own slot, would call +//! the builtin and print `1,2`. +//! * `G-accessor … true` — an accessor `next` installed by `defineProperty` +//! leaves the old closure in the data slot, so the own-slot read alone still +//! sees the canonical closure. Only the accessor Bloom bit makes the proof +//! decline; without it the getter is silently bypassed and `gets` stays 0. +//! * `H true` — a deleted `next` must throw a TypeError, not fall through to +//! the builtin advance. +//! * `I true` x5 — a non-callable prototype `next` (a number, a string, +//! `undefined`, `null`, a plain object) must throw a TypeError. The proof +//! reads the own slot as a RAW value, so each of these has to defeat it: the +//! number and the string never reach `is_pointer`/`get_valid_func_ptr` as a +//! closure, and the plain object passes `is_pointer` but fails the +//! CLOSURE_MAGIC probe inside `get_valid_func_ptr`. + +use std::path::PathBuf; +use std::process::Command; + +const SOURCE: &str = include_str!("../../../test-files/test_gap_iterator_prototype_next_patch.ts"); + +const EXPECTED: &str = "A-forof 2,4,6\n\ +A-spread 8,10\n\ +A-from 12\n\ +A-manual 14 16 true\n\ +B-forof 1,2,3\n\ +B-spread 4,5\n\ +B-manual 7 8 true\n\ +C-forof-empty 0\n\ +C-restored 9\n\ +D-map a=101,b=102\n\ +D-map-restored a,1\n\ +D-set s1,s2\n\ +D-set-restored 3\n\ +E-string A,B\n\ +E-string-restored c,d\n\ +F-same-object 1,2\n\ +F-bound-copy 100,200\n\ +F-restored 3\n\ +G-accessor 1,2 true\n\ +G-restored 4\n\ +H true\n\ +H-restored 5,6\n\ +I number true\n\ +I string true\n\ +I undefined true\n\ +I object true\n\ +I object true\n\ +I-restored 7,8\n"; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn patched_iterator_prototype_next_drives_every_iteration_form() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("iterator_next_patch.ts"); + let output = dir.path().join("iterator_next_patch_bin"); + std::fs::write(&entry, SOURCE).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + EXPECTED, + "output must match node v26.5.1\nstderr:\n{}", + String::from_utf8_lossy(&run.stderr) + ); +} diff --git a/test-files/test_gap_iterator_prototype_next_patch.ts b/test-files/test_gap_iterator_prototype_next_patch.ts new file mode 100644 index 0000000000..7f66bdac27 --- /dev/null +++ b/test-files/test_gap_iterator_prototype_next_patch.ts @@ -0,0 +1,192 @@ +// A replaced `%ArrayIteratorPrototype%.next` (and the Map / Set / String +// family prototypes) must drive for-of, spread, Array.from and manual calls; +// restoring the ORIGINAL closure must hand iteration back to the builtin. +// The runtime proves "not patched" allocation-free by comparing the +// prototype's own `next` against the builtin thunk, so both directions of a +// replace / restore cycle are exercised on every family, plus an accessor +// `next` on the prototype, which that proof must decline. + +const arrayProto: any = Object.getPrototypeOf([][Symbol.iterator]()); +const mapProto: any = Object.getPrototypeOf(new Map().entries()); +const setProto: any = Object.getPrototypeOf(new Set().values()); +const stringProto: any = Object.getPrototypeOf(""[Symbol.iterator]()); + +function withPatched(proto: any, patch: (orig: any) => any, body: () => void) { + const orig = proto.next; + proto.next = patch(orig); + try { + body(); + } finally { + proto.next = orig; + } +} + +// A: array family, every driver, doubled values through the patch. +withPatched( + arrayProto, + (orig) => + function (this: any) { + const r = orig.call(this); + if (!r.done) r.value = (r.value as number) * 2; + return r; + }, + () => { + const got: number[] = []; + for (const v of [1, 2, 3]) got.push(v); + console.log("A-forof", got.join(",")); + console.log("A-spread", [...[4, 5]].join(",")); + console.log("A-from", Array.from([6].values()).join(",")); + const it = [7, 8].values(); + console.log("A-manual", it.next().value, it.next().value, it.next().done); + }, +); + +// B: after the restore the builtin is back, in every driver. +{ + const got: number[] = []; + for (const v of [1, 2, 3]) got.push(v); + console.log("B-forof", got.join(",")); + console.log("B-spread", [...[4, 5]].join(",")); + const it = [7, 8].values(); + console.log("B-manual", it.next().value, it.next().value, it.next().done); +} + +// C: a second replace after the restore is honoured again (the proof is a +// per-call read, not a one-shot latch). +withPatched( + arrayProto, + () => + function () { + return { done: true, value: undefined }; + }, + () => { + const got: number[] = []; + for (const v of [1, 2]) got.push(v); + console.log("C-forof-empty", got.length); + }, +); +console.log("C-restored", [...[9]].join(",")); + +// D: Map and Set family prototypes, patched and restored. +withPatched( + mapProto, + (orig) => + function (this: any) { + const r = orig.call(this); + if (!r.done) r.value = [r.value[0], (r.value[1] as number) + 100]; + return r; + }, + () => { + const got: string[] = []; + for (const [k, v] of new Map([["a", 1], ["b", 2]])) got.push(k + "=" + v); + console.log("D-map", got.join(",")); + }, +); +console.log("D-map-restored", [...new Map([["a", 1]])].join(",")); +withPatched( + setProto, + (orig) => + function (this: any) { + const r = orig.call(this); + if (!r.done) r.value = "s" + r.value; + return r; + }, + () => { + console.log("D-set", [...new Set([1, 2])].join(",")); + }, +); +console.log("D-set-restored", [...new Set([3])].join(",")); + +// E: String family prototype. +withPatched( + stringProto, + (orig) => + function (this: any) { + const r = orig.call(this); + if (!r.done) r.value = (r.value as string).toUpperCase(); + return r; + }, + () => { + console.log("E-string", [..."ab"].join(",")); + }, +); +console.log("E-string-restored", [..."cd"].join(",")); + +// F: restoring by assigning the very same closure object, then a patch that +// is a bound copy of the original (same algorithm, different function +// object) — the proof compares by builtin entry, so the bound copy must NOT +// be mistaken for the builtin: its `this` is fixed to a different iterator. +{ + const orig = arrayProto.next; + arrayProto.next = orig; + console.log("F-same-object", [...[1, 2]].join(",")); + const other = [100, 200].values(); + arrayProto.next = orig.bind(other); + try { + console.log("F-bound-copy", [...[1, 2]].join(",")); + } finally { + arrayProto.next = orig; + } + console.log("F-restored", [...[3]].join(",")); +} + +// G: an accessor `next` on the prototype is consulted on every step. +{ + const orig = arrayProto.next; + let gets = 0; + Object.defineProperty(arrayProto, "next", { + configurable: true, + get() { + gets++; + return orig; + }, + }); + try { + console.log("G-accessor", [...[1, 2]].join(","), gets > 0); + } finally { + Object.defineProperty(arrayProto, "next", { + value: orig, + writable: true, + enumerable: false, + configurable: true, + }); + } + console.log("G-restored", [...[4]].join(",")); +} + +// H: a deleted prototype `next` makes for-of throw a TypeError; restoring +// it by plain assignment brings the builtin back. +{ + const orig = arrayProto.next; + delete arrayProto.next; + try { + for (const _v of [1]) { + console.log("H-unexpected"); + } + console.log("H", "no-throw"); + } catch (e: any) { + console.log("H", e instanceof TypeError); + } finally { + arrayProto.next = orig; + } + console.log("H-restored", [...[5, 6]].join(",")); +} + +// I: a NON-CALLABLE prototype `next` must throw a TypeError, not be mistaken +// for a pointer. The allocation-free proof reads the own slot as a raw value +// first, so a number, a string and `undefined` each have to defeat it. +for (const bad of [42, "not a function", undefined, null, {}]) { + const orig = arrayProto.next; + arrayProto.next = bad; + try { + for (const _v of [1]) { + console.log("I-unexpected"); + } + console.log("I", typeof bad, "no-throw"); + } catch (e: any) { + console.log("I", typeof bad, e instanceof TypeError); + } finally { + arrayProto.next = orig; + } +} +console.log("I-restored", [...[7, 8]].join(","));