diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 77975d3e4..a0747bc55 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -400,6 +400,10 @@ pub fn gc_init() { gc_register_mutable_root_scanner(crate::v8::scan_v8_promise_hook_roots_mut); gc_register_mutable_root_scanner(crate::typed_feedback::scan_typed_feedback_roots_mut); gc_register_mutable_root_scanner(crate::typedarray_props::scan_typed_array_own_props_roots_mut); + // A typed array's materialized backing ArrayBuffer lives only as a raw + // address in TYPED_ARRAY_VIEW_META — collectable/stale under a live typed + // array, which made `subarray` hand back a garbage-length view. + gc_register_mutable_root_scanner(crate::typedarray_view::scan_typed_array_view_meta_roots_mut); gc_register_mutable_root_scanner(transition_cache_mutable_root_scanner); gc_register_mutable_root_scanner(crate::object::scan_object_cache_roots_mut); gc_register_mutable_root_scanner(crate::object::scan_arguments_object_roots_mut); diff --git a/crates/perry-runtime/src/gc/tests/alloc.rs b/crates/perry-runtime/src/gc/tests/alloc.rs index d786729ee..fa5b1c134 100644 --- a/crates/perry-runtime/src/gc/tests/alloc.rs +++ b/crates/perry-runtime/src/gc/tests/alloc.rs @@ -517,7 +517,7 @@ fn test_gc_type_metadata_covers_all_declared_types() { pointer_free: true, move_hook_kind: GcMoveHookKind::None, rewrite_hook_kind: GcRewriteHookKind::None, - finalize_hook_kind: GcFinalizeHookKind::None, + finalize_hook_kind: GcFinalizeHookKind::TypedArrayViewMeta, }, GcTypeInfo { type_id: GC_TYPE_SET, diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 93087635b..af8da4052 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -160,6 +160,12 @@ pub(crate) enum GcFinalizeHookKind { NativeTypedView, NativeHandle, NativePodView, + /// Drop a dead typed array's `TYPED_ARRAY_VIEW_META` entry. The table is + /// keyed by the header address and records the array's materialized backing + /// ArrayBuffer; leaving the entry behind both keeps that buffer rooted + /// forever and lets whatever is allocated at the reused address inherit a + /// backing that is not its own. + TypedArrayViewMeta, /// Drop the embedded `temporal_rs` value in a `GC_TYPE_TEMPORAL` cell so a /// heap-owning variant (e.g. a `ZonedDateTime` IANA timezone string) is /// released when the cell is swept. POD variants drop to a no-op. @@ -388,7 +394,7 @@ pub(super) static GC_TYPE_INFO_BY_ID: [Option; MALLOC_KIND_BUCKET_CO true, GcMoveHookKind::None, GcRewriteHookKind::None, - GcFinalizeHookKind::None, + GcFinalizeHookKind::TypedArrayViewMeta, )), Some(gc_type_info_entry( GC_TYPE_SET, @@ -699,6 +705,9 @@ pub(crate) unsafe fn gc_type_finalize_unmarked_payload(obj_type: u8, user_ptr: * GcFinalizeHookKind::ErrorSideTables => { crate::node_submodules::diagnostics_gc::error_side_tables_clear_dead(user_ptr as usize); } + GcFinalizeHookKind::TypedArrayViewMeta => { + crate::typedarray_view::clear_view_meta(user_ptr as usize); + } } } diff --git a/crates/perry-runtime/src/typedarray_view.rs b/crates/perry-runtime/src/typedarray_view.rs index b3270d229..a4f7cc10f 100644 --- a/crates/perry-runtime/src/typedarray_view.rs +++ b/crates/perry-runtime/src/typedarray_view.rs @@ -259,6 +259,33 @@ pub(crate) fn view_backing_data_ptr(addr: usize) -> Option<*mut u8> { }) } +/// A typed array's backing `ArrayBuffer` is reachable only through +/// `TYPED_ARRAY_VIEW_META`, as a raw address the collector cannot see. Without +/// this scanner the buffer is swept (or left stale after evacuation) while the +/// typed array still points at it, and `js_typed_array_backing_buffer` then +/// hands `js_typed_array_view` a dead pointer. That path does not fail loudly: +/// it falls back to `js_typed_array_new`, which reinterprets the dead buffer's +/// *byte* length as an element count — so `subarray(0, 11)` on an +/// `Int32Array(17)` returned a 68-element array (17 × 4 bytes) over a fresh +/// store, and the `set` that followed threw "offset is out of bounds". +/// +/// Root the backing and let the visitor rewrite it, the way every other +/// object-keyed side table already does. +pub(crate) fn scan_typed_array_view_meta_roots_mut( + visitor: &mut crate::gc::RuntimeRootVisitor<'_>, +) { + if !any_view_meta() { + return; + } + TYPED_ARRAY_VIEW_META.with(|r| { + for meta in r.borrow_mut().values_mut() { + let mut backing = meta.backing as *mut crate::buffer::BufferHeader; + visitor.visit_raw_mut_ptr_slot(&mut backing); + meta.backing = backing as usize; + } + }); +} + /// Drop any recorded view metadata for `addr` (called from /// `unregister_typed_array` when the typed array is collected). pub(crate) fn clear_view_meta(addr: usize) { diff --git a/tests/issue_typed_array_backing_gc.js b/tests/issue_typed_array_backing_gc.js new file mode 100644 index 000000000..2eb41b1cd --- /dev/null +++ b/tests/issue_typed_array_backing_gc.js @@ -0,0 +1,46 @@ +// A typed array's backing ArrayBuffer is materialized lazily and recorded only +// in `TYPED_ARRAY_VIEW_META`, a side table the collector did not trace. The +// buffer was therefore swept out from under a live typed array, and `subarray` +// — which builds its view from that backing — silently fell back to +// `js_typed_array_new`, reinterpreting the dead buffer's *byte* length as an +// element count: `subarray(0, 11)` on an `Int32Array(17)` returned a 68-element +// array (17 x 4), and a subsequent `set` threw "offset is out of bounds". + +const a = new Int32Array(17); + +if (a.subarray(0, 11).length !== 11) { + throw new Error("subarray is broken before any collection"); +} + +for (let round = 0; round < 5; round++) { + if (typeof gc === "function") { + gc(); + gc(); + } + for (let i = 0; i < 5000; i++) { + const junk = { x: i, y: [i] }; + } + if (typeof gc === "function") gc(); + + const len = a.subarray(0, 11).length; + if (len !== 11) { + throw new Error(`round ${round}: subarray(0, 11).length = ${len}, expected 11`); + } + + const buffer = a.buffer; + if (!buffer || buffer.byteLength !== 68) { + throw new Error( + `round ${round}: a.buffer.byteLength = ${buffer && buffer.byteLength}, expected 68`, + ); + } + + // The view must still alias the array, not a fresh copy. + const view = a.subarray(0, 4); + view[0] = 4242; + if (a[0] !== 4242) { + throw new Error(`round ${round}: subarray stopped aliasing (a[0] = ${a[0]})`); + } + a[0] = 0; +} + +console.log("typed-array backing survived GC"); diff --git a/tests/test_typed_array_backing_gc.sh b/tests/test_typed_array_backing_gc.sh new file mode 100755 index 000000000..77653bb47 --- /dev/null +++ b/tests/test_typed_array_backing_gc.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$ROOT/target/release/perry}}" +RUNTIME_DIR="${PERRY_RUNTIME_DIR:-$ROOT/target/release}" +FIXTURE="$ROOT/tests/issue_typed_array_backing_gc.js" +WORKDIR="${TMPDIR:-/tmp}/perry-ta-backing-gc-$$" +BIN="$WORKDIR/perry-ta-backing-gc" +COMPILE_LOG="$WORKDIR/compile.log" +OUT_LOG="$WORKDIR/out.log" + +mkdir -p "$WORKDIR" +trap 'rm -rf "$WORKDIR"' EXIT + +if [[ ! -x "$PERRY" ]]; then + PERRY="$ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build --release -p perry)" + exit 0 +fi + +env PERRY_ALLOW_UNIMPLEMENTED=1 PERRY_RUNTIME_DIR="$RUNTIME_DIR" "$PERRY" compile --no-cache --no-auto-optimize "$FIXTURE" -o "$BIN" \ + >"$COMPILE_LOG" 2>&1 || { + cat "$COMPILE_LOG" >&2 + exit 1 + } + +# Run under the default collector and again with evacuation forced, so both the +# sweep and the relocation path are covered. +for mode in default evacuate; do + set +e + if [[ "$mode" == "evacuate" ]]; then + PERRY_GC_FORCE_EVACUATE=1 "$BIN" >"$OUT_LOG" 2>&1 + else + "$BIN" >"$OUT_LOG" 2>&1 + fi + rc=$? + set -e + if [[ "$rc" -ne 0 ]]; then + echo "typed-array backing fixture failed ($mode GC, exit $rc):" >&2 + cat "$OUT_LOG" >&2 + exit 1 + fi + grep -qx "typed-array backing survived GC" "$OUT_LOG" || { + echo "unexpected output ($mode GC):" >&2 + cat "$OUT_LOG" >&2 + exit 1 + } +done