Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions changelog.d/7827-typedarray-buffer-alias-hazard.md
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 38 additions & 0 deletions crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@ use super::{
};

pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// #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
Expand Down
105 changes: 105 additions & 0 deletions test-files/test_gap_typedarray_buffer_aliasing_7219.ts
Original file line number Diff line number Diff line change
@@ -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());
Loading