Skip to content

fix(gc): a typed array's backing ArrayBuffer was invisible to the collector#6408

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/typed-array-backing-gc-root
Jul 14, 2026
Merged

fix(gc): a typed array's backing ArrayBuffer was invisible to the collector#6408
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/typed-array-backing-gc-root

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The bug

const a = new Int32Array(17);
a.subarray(0, 11).length;   // 11  ✓
gc();
a.subarray(0, 11).length;   // 68  ✗   (17 × 4 — the *byte* length)
a.buffer;                   // undefined

A plain typed array materializes its backing ArrayBuffer lazily
(js_typed_array_backing_buffer), and the only reference to that buffer is the
raw address recorded in TYPED_ARRAY_VIEW_META. Nothing traced that table, so
the collector swept the buffer out from under a live typed array.

The table's own comment asserted the invariant that made this "safe":

The backing BufferHeader lives for the thread's lifetime (Perry never
deallocs individual buffers — see buffer::view), so the raw addr is stable
and aliasing through it is free of use-after-free.

That is no longer true.

Why it surfaced so far from the fault

Nothing failed loudly. js_typed_array_backing_buffer handed the dead pointer to
js_typed_array_view, which saw it was not a registered ArrayBuffer and quietly
took its fallback:

if !crate::buffer::is_registered_buffer(addr) || !crate::buffer::is_any_array_buffer(addr) {
    return js_typed_array_new(kind as i32, source);   // ← treats it as a length/array-like
}

js_typed_array_new then read the dead buffer's byte length as an element
count, so subarray(0, 11) on an Int32Array(17) returned a 68-element
array over a fresh store — no longer a view, and the wrong length. The
RangeError: offset is out of bounds only appeared later, in the set that
consumed it.

slice was unaffected: it copies out of the array's own storage and never
consults the backing, which is why the two disagreed.

The fix

Trace the table the way every other object-keyed side table already is:

  • scan_typed_array_view_meta_roots_mut — a mutable root scanner that visits
    each entry's backing and lets the collector rewrite the pointer.
  • GcFinalizeHookKind::TypedArrayViewMeta — drops a dead typed array's entry
    on sweep, so the backing is not rooted forever and a later allocation at the
    recycled address cannot inherit a backing that is not its own. (A finalize
    hook rather than a move hook: GC_TYPE_TYPED_ARRAY is movable: false.)

Test

tests/issue_typed_array_backing_gc.js + tests/test_typed_array_backing_gc.sh:
materialize the backing, force collections, then assert subarray still reports
11 elements, a.buffer.byteLength is still 68, and the view still aliases the
array. Run under the default collector and again with PERRY_GC_FORCE_EVACUATE=1.

Without the fix the fixture fails on the first round with the exact symptom:

round 0: subarray(0, 11).length = 68, expected 11

cargo test -p perry-runtime --release -- --test-threads=1: 1233 passed, 0 failed.

How it was found

Compiling a large esbuild-bundled CLI app. Its terminal UI blits the previous
frame with dst.set(src.subarray(y0, y1), y0) over the screen's Int32Array
cell buffers. Once the process had GC'd, every frame threw "offset is out of
bounds" inside the renderer's try/catch — so the app painted its first screen
and then silently stopped repainting, with no error anywhere.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed typed array subarray views so their backing ArrayBuffer stays alive and consistent after garbage collection.
    • Ensured typed array view length, buffer size, and aliasing behavior remain correct across repeated GC cycles (including evacuation mode).
  • Tests

    • Added a regression test covering typed array backing under standard GC and forced evacuation.
    • Added a shell integration script to compile and run the fixture, validating expected GC survival output.

…lector

`subarray` on a live typed array returned a garbage-length array after a
collection:

    const a = new Int32Array(17);
    a.subarray(0, 11).length;   // 11
    gc();
    a.subarray(0, 11).length;   // 68  (17 * 4 — the *byte* length)

A plain typed array materializes a backing `ArrayBuffer` the first time one is
needed, and the only reference to it is the raw address recorded in
`TYPED_ARRAY_VIEW_META`. Nothing traced that table, so the buffer was swept out
from under the still-live typed array. (The table's own comment asserted the
opposite — "the backing BufferHeader lives for the thread's lifetime […] free of
use-after-free" — which stopped being true once buffers became collectable.)

Nothing failed loudly. `js_typed_array_backing_buffer` handed the dead pointer to
`js_typed_array_view`, which found it was not a registered ArrayBuffer, quietly
fell back to `js_typed_array_new`, and reinterpreted the dead buffer's *byte*
length as an element count — so a 17-element Int32Array yielded a 68-element
view over a fresh store. The `set` that followed threw "offset is out of bounds",
far from the actual fault. `slice` was unaffected: it copies out of the array's
own storage and never consults the backing.

Trace the table like every other object-keyed side table:

  * a mutable root scanner visits each entry's backing and lets the collector
    rewrite it, and
  * a finalize hook drops a dead typed array's entry, so the buffer is not
    rooted forever and a later allocation at the recycled address cannot
    inherit a backing that is not its own.

Found compiling a large esbuild-bundled CLI app, whose terminal UI blits the
previous frame with `dst.set(src.subarray(y0, y1), y0)`. Once the process had
GC'd, every frame raised "offset is out of bounds" inside the renderer's
try/catch: the UI painted its first screen and then silently stopped repainting.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 48bf16e9-acc9-4916-8a45-bae9c80805bd

📥 Commits

Reviewing files that changed from the base of the PR and between 7783044 and 628c755.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/gc/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/gc/mod.rs

📝 Walkthrough

Walkthrough

Typed array view metadata is registered as a mutable GC root, typed arrays clear metadata during finalization, and regression tests validate backing-buffer and subarray behavior under default and evacuation GC modes.

Changes

Typed array backing GC

Layer / File(s) Summary
Typed array metadata finalization
crates/perry-runtime/src/gc/types.rs, crates/perry-runtime/src/gc/tests/alloc.rs
Adds the TypedArrayViewMeta finalize hook, assigns it to typed arrays, clears metadata during finalization, and updates metadata expectations.
Typed array view root scanning
crates/perry-runtime/src/typedarray_view.rs, crates/perry-runtime/src/gc/mod.rs
Scans and rewrites typed array backing pointers through the GC root visitor and registers the scanner during GC initialization.
Typed array GC regression coverage
tests/issue_typed_array_backing_gc.js, tests/test_typed_array_backing_gc.sh
Tests subarray lengths, backing-buffer size, aliasing, and successful execution under default and forced evacuation GC modes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GC
  participant gc_init
  participant scan_typed_array_view_meta_roots_mut
  participant RuntimeRootVisitor
  participant gc_type_finalize_unmarked_payload
  participant typedarray_view
  GC->>gc_init: run registered metadata root scanner
  gc_init->>scan_typed_array_view_meta_roots_mut: scan ViewMeta backing pointers
  scan_typed_array_view_meta_roots_mut->>RuntimeRootVisitor: visit and rewrite backing pointer
  RuntimeRootVisitor-->>scan_typed_array_view_meta_roots_mut: return updated pointer
  GC->>gc_type_finalize_unmarked_payload: finalize unmarked typed array
  gc_type_finalize_unmarked_payload->>typedarray_view: clear_view_meta(array buffer header)
Loading

Possibly related PRs

  • PerryTS/perry#5537: Modifies the typed-array view metadata bookkeeping and clearing behavior used by this GC change.
  • PerryTS/perry#6190: Adds related cleanup of typed-array backing and view metadata after GC processing.
  • PerryTS/perry#6273: Updates handling of the same TYPED_ARRAY_VIEW_META entries for detached-buffer invalidation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: typed array backings were hidden from GC and caused stale ArrayBuffer references.
Description check ✅ Passed The description covers the bug, fix, and test plan, but it doesn't follow the template headings and omits related issue and checklist sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_typed_array_backing_gc.sh (1)

33-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid disabling errexit to prevent masking failures.

Temporarily disabling set -e can mask unexpected failures if additional commands are ever inserted inside this block. You can keep errexit enabled and explicitly handle failures using || instead.

♻️ Proposed refactor
-  set +e
+  rc=0
   if [[ "$mode" == "evacuate" ]]; then
-    PERRY_GC_FORCE_EVACUATE=1 "$BIN" >"$OUT_LOG" 2>&1
+    PERRY_GC_FORCE_EVACUATE=1 "$BIN" >"$OUT_LOG" 2>&1 || rc=$?
   else
-    "$BIN" >"$OUT_LOG" 2>&1
+    "$BIN" >"$OUT_LOG" 2>&1 || rc=$?
   fi
-  rc=$?
-  set -e
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_typed_array_backing_gc.sh` around lines 33 - 40, Update the
command execution block in the mode-based test flow to keep errexit enabled, and
explicitly capture the binary’s exit status using an || handling pattern around
the evacuate and non-evacuate invocations. Preserve the existing
PERRY_GC_FORCE_EVACUATE behavior, output redirection, and rc assignment without
relying on set +e/set -e.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_typed_array_backing_gc.sh`:
- Around line 33-40: Update the command execution block in the mode-based test
flow to keep errexit enabled, and explicitly capture the binary’s exit status
using an || handling pattern around the evacuate and non-evacuate invocations.
Preserve the existing PERRY_GC_FORCE_EVACUATE behavior, output redirection, and
rc assignment without relying on set +e/set -e.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 63541fb9-e45b-4a27-a5e0-62ad89b94f33

📥 Commits

Reviewing files that changed from the base of the PR and between 51469b3 and 7783044.

📒 Files selected for processing (6)
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/tests/alloc.rs
  • crates/perry-runtime/src/gc/types.rs
  • crates/perry-runtime/src/typedarray_view.rs
  • tests/issue_typed_array_backing_gc.js
  • tests/test_typed_array_backing_gc.sh

@proggeramlug proggeramlug merged commit 2f51c0e into PerryTS:main Jul 14, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant