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) 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());