From 1769ff86346dc6f44c0a8a809dac4a6b5a144001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 12:31:44 +0200 Subject: [PATCH] fix(runtime): recognize captured ArrayBuffer/SharedArrayBuffer/DataView constructors (#8724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructing `ArrayBuffer`, `SharedArrayBuffer`, or `DataView` through a captured value — `const D = DataView; new D(buf)`, `Reflect.construct(DataView, …)`, `class X extends DataView {}`, `new globalThis.ArrayBuffer(n)` — threw `TypeError: Constructor requires 'new'`. Direct `new DataView(buf)` was fine. `06e1ab349` moved these three builtins' global [[Call]] behavior from `global_this_builtin_noop_thunk` to the shared `construct_only_builtin_call_thunk` (populate.rs) but did not add that thunk to `identify_global_builtin_constructor`'s func_ptr allow-list — the recognition step the dynamic-`new` path uses to recover a captured builtin's name and route it to construct.rs's real factory. So a captured AB/SAB/DataView closure was no longer recognized (returned `None`), never reached the existing `"ArrayBuffer" | "SharedArrayBuffer"` / `"DataView"` construct arms, and fell through to invoke the bare-call thunk. The previous noop thunk WAS in the allow-list, so this worked before `06e1ab349`; it is the same bug class the WeakMap/RegExp comments in that function already document. Minified bundles capture these globals into locals pervasively, so this broke module init broadly — the natively-compiled Claude Code cli.js 2.1.112 bundle threw at startup on nearly every command (only `--version` escaped, via its early exit); node runs the same bundle fine. Fix: add `construct_only_builtin_call_thunk` to the allow-list. The name-dispatch construct arms already exist. Regression fixture `tests/issue_8724_captured_arraybuffer_construct.ts` covers captured / Reflect / subclass / globalThis forms plus a functional read-back; output matches Node v26. Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF --- .../8724-captured-arraybuffer-construct.md | 1 + .../src/object/class_registry/class_meta.rs | 13 ++++++++ ...sue_8724_captured_arraybuffer_construct.ts | 33 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 changelog.d/8724-captured-arraybuffer-construct.md create mode 100644 tests/issue_8724_captured_arraybuffer_construct.ts diff --git a/changelog.d/8724-captured-arraybuffer-construct.md b/changelog.d/8724-captured-arraybuffer-construct.md new file mode 100644 index 0000000000..5ed9e22e9a --- /dev/null +++ b/changelog.d/8724-captured-arraybuffer-construct.md @@ -0,0 +1 @@ +Fix a regression where constructing `ArrayBuffer`, `SharedArrayBuffer`, or `DataView` through a captured value — `const D = DataView; new D(buf)`, `Reflect.construct(DataView, …)`, `class X extends DataView {}`, `new globalThis.ArrayBuffer(n)` — threw `TypeError: Constructor requires 'new'`. Their global [[Call]] behavior had moved to the shared construct-only thunk without adding that thunk to `identify_global_builtin_constructor`'s allow-list, so the dynamic-`new` path no longer recognized a captured constructor and fell through to the bare-call thunk. Minified bundles capture these globals into locals pervasively; this broke module init (e.g. the natively-compiled Claude Code cli.js bundle failed at startup on nearly every command). Direct `new DataView(buf)` was unaffected. diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index 745de82517..e41cae59eb 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -186,6 +186,19 @@ pub(crate) fn identify_global_builtin_constructor(func_value: f64) -> Option<&'s let is_global_builtin_func = func_ptr == global_this_builtin_noop_thunk as *const u8 as usize || func_ptr == typed_array_constructor_call_thunk as *const u8 as usize + // ArrayBuffer / SharedArrayBuffer / DataView carry the shared + // construct-only call thunk (populate.rs). When one is captured into + // a variable and constructed — `const D = DataView; new D(buf)`, + // `Reflect.construct(DataView, …)`, `class X extends DataView` — the + // dynamic-`new` path lands here and must recognize the thunk so the + // singleton walk recovers the name and construct.rs's + // "ArrayBuffer"/"SharedArrayBuffer"/"DataView" arms build it, instead + // of falling through to invoke the bare-call thunk (which throws + // "Constructor requires 'new'"). Minified bundles capture these + // globals into locals pervasively (the Claude Code cli.js bundle + // fails at module init without this). Regression from the thunk swap + // in 06e1ab349 — before it these carried the recognized noop thunk. + || func_ptr == construct_only_builtin_call_thunk as *const u8 as usize // #4102: `Array`/`Object`/`Date` constructor *values* carry their own // coercion thunks (not the shared noop thunk), so the dynamic // `instanceof` / reflective `@@hasInstance` path could not recover diff --git a/tests/issue_8724_captured_arraybuffer_construct.ts b/tests/issue_8724_captured_arraybuffer_construct.ts new file mode 100644 index 0000000000..e0e522199a --- /dev/null +++ b/tests/issue_8724_captured_arraybuffer_construct.ts @@ -0,0 +1,33 @@ +// #8724: constructing ArrayBuffer / SharedArrayBuffer / DataView through a +// CAPTURED value (a local holding the builtin, Reflect.construct, or a +// subclass) must build the object, not throw "Constructor requires 'new'". +// Regression from routing their [[Call]] to the shared construct-only thunk +// without teaching `identify_global_builtin_constructor` about it, so the +// dynamic-`new` path stopped recognizing a captured constructor. Minified +// bundles capture these globals into locals everywhere. + +function run(label: string, fn: () => void): void { + try { + fn(); + console.log(label, "ok"); + } catch (e: any) { + console.log(label, "THREW:", e.message); + } +} + +const ab = () => new ArrayBuffer(8); + +run("direct DataView", () => { const dv = new DataView(ab()); if (dv.byteLength !== 8) throw new Error("len"); }); +run("captured DataView", () => { const D = DataView; const dv = new D(ab()); if (dv.byteLength !== 8) throw new Error("len"); }); +run("Reflect.construct DataView", () => { const dv = Reflect.construct(DataView, [ab()]); if ((dv as DataView).byteLength !== 8) throw new Error("len"); }); +run("subclass DataView", () => { class MV extends DataView {} const dv = new MV(ab()); if (dv.byteLength !== 8) throw new Error("len"); }); +run("captured ArrayBuffer", () => { const AB = ArrayBuffer; const b = new AB(8); if (b.byteLength !== 8) throw new Error("len"); }); +run("captured ArrayBuffer resizable", () => { const AB = ArrayBuffer; const b = new AB(8, { maxByteLength: 16 }); if (b.byteLength !== 8) throw new Error("len"); }); +run("captured SharedArrayBuffer", () => { const SAB = SharedArrayBuffer; const s = new SAB(8); if (s.byteLength !== 8) throw new Error("len"); }); +run("globalThis ArrayBuffer", () => { const b = new (globalThis as any).ArrayBuffer(8); if (b.byteLength !== 8) throw new Error("len"); }); + +// The captured DataView must be fully functional, not a branded-but-empty stub. +const D2 = DataView; +const dv2 = new D2(ab()); +dv2.setInt32(0, 0x41424344); +console.log("readback", dv2.getInt32(0).toString(16));