From d5a4d88ff9f4ff4d6586f801d07facd5360f8fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 04:15:32 +0200 Subject: [PATCH 1/2] fix(codegen): reading `.buffer` revokes a typed array's inline-storage proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit const words = new Uint32Array(1); const bytes = new Uint8Array(words.buffer); words[0] = 0x01020304; bytes[0] + bytes[1] + bytes[2] + bytes[3]; // 0, not 10 Reading `.buffer` on a typed array that owns its bytes HANDS OUT ITS STORAGE: `js_typed_array_backing_buffer` materializes a backing `ArrayBuffer` and rebinds the array to alias it, so element 0 stops following the header. Codegen's proven-view tiers read `header + 16 + idx*width` directly, on a proof taken at CONSTRUCTION — `new Uint32Array(1)` has a literal length, so `storage_inline_proven` is true and rightly so at that point. Nothing revoked that proof when `.buffer` created a second view, so the store above landed in the orphaned pre-materialization bytes while the byte view read the buffer. NEITHER DIRECTION ALIASED, and the buffer's IDENTITY was already correct (`words.buffer === bytes.buffer` is true, `byteLength` is 4), which is what made this read as a lost alias rather than a modelling gap. Writing BEFORE the second view was always right, because materialization copies the current bytes — so the bug looked like a timing quirk. The runtime guards its own inline reader with `PERRY_TA_VIEW_GUARD`, which `register_view_meta` bumps. These tiers are the compile-time proof that skips that check entirely, so the hazard has to be recorded where the alias is created, not where it is used. `downgrade_buffer_alias(..., MutableAlias)` is the existing mechanism for exactly this, already used for reassignment, closure capture and unknown-call escapes; `.buffer` was simply not one of its triggers. Scope is one property name on a local that already has a tracked view slot, so a typed array whose `.buffer` is never read keeps its fast path. The new test asserts that direction for VALUE — a too-broad revocation would break it silently, by staying correct and getting slower. NOT fixed here: #7220, which is that `--verify-native-regions` accepted this state. After this change the repro compiles and runs correctly under that flag, but that is the invalid state no longer arising rather than the verifier learning to reject it. Fixes #7219 --- crates/perry-codegen/src/expr/property_get.rs | 38 +++++++ ...est_gap_typedarray_buffer_aliasing_7219.ts | 105 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 test-files/test_gap_typedarray_buffer_aliasing_7219.ts diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index babaa38d46..49bfb83701 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -66,6 +66,44 @@ use super::{ }; pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { + // #7219: reading `.buffer` on a tracked typed-array view HANDS OUT ITS + // STORAGE, so the local's inline-storage proof stops holding from here on. + // + // `js_typed_array_backing_buffer` materializes a backing `ArrayBuffer` for + // a typed array that owned its bytes and rebinds the array to alias it — + // element 0 no longer follows the header. The proven-view tiers + // (`proven_view_access`, `buffer_access`, `range_facts`, `i32_fast_path`) + // all read `header + 16 + idx*width` directly, so after + // + // const words = new Uint32Array(1); // storage_inline_proven + // const bytes = new Uint8Array(words.buffer); + // words[0] = 0x01020304; // <- wrote the ORPHANED bytes + // + // the write landed in the pre-materialization storage while `bytes` read + // the buffer, and neither direction aliased: the repro summed 0 instead of + // 10, and writing through `bytes` was equally invisible to `words`. + // + // The runtime side already guards its own inline reader with + // `PERRY_TA_VIEW_GUARD`, which `register_view_meta` bumps. These tiers are + // the compile-time proof that skips that check entirely, so the hazard has + // to be recorded where the alias is created rather than where it is used. + // `MutableAlias` is exactly what this is. + if let Expr::PropertyGet { + object, property, .. + } = expr + { + if property == "buffer" { + if let Expr::LocalGet(id) = object.as_ref() { + if ctx.buffer_view_slots.contains_key(id) { + super::downgrade_buffer_alias( + ctx, + *id, + crate::native_value::MaterializationReason::MutableAlias, + ); + } + } + } + } // `split("literal")[constant].length` on a scalar-replaced split can // read the precomputed numeric length directly. The split part itself was // never observable as a string, so materializing a StringHeader would only diff --git a/test-files/test_gap_typedarray_buffer_aliasing_7219.ts b/test-files/test_gap_typedarray_buffer_aliasing_7219.ts new file mode 100644 index 0000000000..2fc7ee9461 --- /dev/null +++ b/test-files/test_gap_typedarray_buffer_aliasing_7219.ts @@ -0,0 +1,105 @@ +// A typed array that owns its bytes hands that storage out when `.buffer` is +// read: `js_typed_array_backing_buffer` materializes a backing `ArrayBuffer` +// and rebinds the array to alias it, so element 0 stops following the header. +// +// Codegen's proven-view tiers read `header + 16 + idx*width` directly, on a +// proof taken at CONSTRUCTION (`new Uint32Array(1)` — a literal length, so +// storage is inline). Nothing revoked that proof when `.buffer` created a +// second view, so the write below landed in the orphaned pre-materialization +// bytes while the byte view read the buffer — and neither direction aliased. +// +// The runtime's own inline reader is guarded by `PERRY_TA_VIEW_GUARD`, which +// `register_view_meta` bumps. These tiers are the compile-time proof that skips +// that check, so the hazard has to be recorded where the alias is created. + +// The issue's reproduction. The sum is endian-independent: the four bytes are +// 1, 2, 3 and 4 in either order. +function writeAfterAliasing(): number { + const words = new Uint32Array(1); + const bytes = new Uint8Array(words.buffer); + words[0] = 0x01020304; + return bytes[0] + bytes[1] + bytes[2] + bytes[3]; +} +console.log("write after aliasing:", writeAfterAliasing()); + +// Writing BEFORE the second view was always correct — the materialization +// copies the current bytes — which is what made this look like a timing quirk +// rather than a lost alias. +function writeBeforeAliasing(): number { + const words = new Uint32Array(1); + words[0] = 0x01020304; + const bytes = new Uint8Array(words.buffer); + return bytes[0] + bytes[1] + bytes[2] + bytes[3]; +} +console.log("write before aliasing:", writeBeforeAliasing()); + +// The reverse direction is the same bug and was equally broken: bytes written +// through the view must be visible through the original typed array. +function writeThroughTheByteView(): number { + const words = new Uint32Array(1); + const bytes = new Uint8Array(words.buffer); + bytes[0] = 1; + bytes[1] = 2; + bytes[2] = 3; + bytes[3] = 4; + return words[0]; +} +console.log("write through the byte view:", writeThroughTheByteView()); + +// Buffer identity was already right — only the bytes were not shared, which is +// why this reads as a correctness bug rather than a modelling one. +function bufferIdentity(): string { + const words = new Uint32Array(2); + const bytes = new Uint8Array(words.buffer); + return `${words.buffer === bytes.buffer} ${words.buffer.byteLength} ${bytes.length}`; +} +console.log("buffer identity:", bufferIdentity()); + +// The ArrayBuffer-FIRST shape (issue #579) was fixed earlier and must stay +// fixed: this one never had an inline-storage proof to lose. +function arrayBufferFirst(): number { + const buf = new ArrayBuffer(4); + const words = new Uint32Array(buf); + const bytes = new Uint8Array(buf); + words[0] = 0x01020304; + return bytes[0] + bytes[1] + bytes[2] + bytes[3]; +} +console.log("ArrayBuffer first:", arrayBufferFirst()); + +// An offset view over the materialized buffer sees the same bytes. +function offsetView(): string { + const words = new Uint32Array(2); + const tail = new Uint8Array(words.buffer, 4, 4); + words[1] = 0x01020304; + return `${tail[0] + tail[1] + tail[2] + tail[3]} ${tail.byteOffset}`; +} +console.log("offset view:", offsetView()); + +// A typed array whose `.buffer` is NEVER read keeps its inline storage, so the +// fast path it is meant to serve must still produce the right answer. This is +// the direction a too-broad revocation would break silently — by being correct +// but slow — so it is asserted for VALUE here and left to the benchmarks for +// speed. +function neverAliased(): number { + const values = new Uint32Array(4); + let total = 0; + for (let i = 0; i < 4; i++) { + values[i] = i * 1000 + 7; + } + for (let i = 0; i < 4; i++) { + total += values[i]; + } + return total; +} +console.log("never aliased:", neverAliased()); + +// Two independent arrays: reading one's `.buffer` must not disturb the other. +function onlyTheAliasedOne(): string { + const aliased = new Uint32Array(1); + const plain = new Uint32Array(1); + const view = new Uint8Array(aliased.buffer); + aliased[0] = 0x01020304; + plain[0] = 42; + return `${view[0] + view[1] + view[2] + view[3]} ${plain[0]}`; +} +console.log("only the aliased one:", onlyTheAliasedOne()); From 7a00021c3eacb74aeae556b08f9141dab9786844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 04:16:20 +0200 Subject: [PATCH 2/2] docs(changelog): add fragment for the #7219 buffer-alias hazard fix --- .../7827-typedarray-buffer-alias-hazard.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 changelog.d/7827-typedarray-buffer-alias-hazard.md diff --git a/changelog.d/7827-typedarray-buffer-alias-hazard.md b/changelog.d/7827-typedarray-buffer-alias-hazard.md new file mode 100644 index 0000000000..83f92f395d --- /dev/null +++ b/changelog.d/7827-typedarray-buffer-alias-hazard.md @@ -0,0 +1,29 @@ +**Reading `.buffer` on a typed array now revokes its inline-storage proof.** +`js_typed_array_backing_buffer` materializes a backing `ArrayBuffer` for a typed +array that owned its bytes and rebinds the array to alias it, so element 0 stops +following the header. Codegen's proven-view tiers read +`header + 16 + idx*width` directly, on a proof taken at *construction* — a +literal length proves inline storage, and rightly so at that point — but nothing +revoked it when `.buffer` handed the storage out. So + +```ts +const words = new Uint32Array(1); +const bytes = new Uint8Array(words.buffer); +words[0] = 0x01020304; // wrote the ORPHANED pre-materialization bytes +bytes[0] + bytes[1] + bytes[2] + bytes[3]; // 0, not 10 +``` + +and the reverse direction was equally invisible, while the buffer's *identity* +was already correct (`words.buffer === bytes.buffer`) — a lost alias rather than +a modelling gap. Writing *before* the second view was always right, because +materialization copies the current bytes, which made it look like a timing +quirk. The runtime guards its own inline reader with `PERRY_TA_VIEW_GUARD`; +these tiers are the compile-time proof that skips that check, so the hazard is +now recorded where the alias is created, via the existing +`downgrade_buffer_alias(..., MutableAlias)` path that reassignment, closure +capture and unknown-call escapes already use. A typed array whose `.buffer` is +never read keeps its fast path — the new +`test-files/test_gap_typedarray_buffer_aliasing_7219.ts` asserts that direction +for value, since a too-broad revocation would break it silently by staying +correct and getting slower. #7276 had closed only the ArrayBuffer-first shape +(#579), which never had an inline-storage proof to lose. (#7219)