Skip to content

fix(thread): loud cross-heap transfer errors, worker MallocState cleanup, owner-tagged timer dispatch (#6185)#6212

Merged
proggeramlug merged 1 commit into
mainfrom
fix/thread-cross-heap-safety
Jul 9, 2026
Merged

fix(thread): loud cross-heap transfer errors, worker MallocState cleanup, owner-tagged timer dispatch (#6185)#6212
proggeramlug merged 1 commit into
mainfrom
fix/thread-cross-heap-safety

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Three mechanical cross-thread GC-safety wins identified by the 2026-07-09 GC audit (§6, threading; #6185). These reduce silent corruption to loud failure / eliminate a leak without committing to the larger single-JS-thread-vs-multi-heap threading-model decision (still open — see "What remains").

Perry is single-JS-thread by default; perry/thread gives spawn() / parallelMap / parallelFilter per-thread arenas + GC, values cross by SerializedValue deep-copy, and results return via the global PENDING_THREAD_RESULTS drained in the microtask pump.

Shipped

1. Loud SerializedValue failures (audit P2 → not-silent)

serialize_nanbox_for_thread silently lowered every non-transferable runtime type — Map, Set, Promise, Error, TypedArray, Buffer, Symbol (POINTER_TAG'd but GC_TYPE_STRING), Temporal, native handles, unmaterialized lazy JSON arrays — to Inline(TAG_UNDEFINED). A captured or returned value of one of these crossed a perry/thread boundary as undefined with no diagnostic, contradicting the documented deep-copy contract.

Now the serializer emits a named SerializedValue::Unsupported(&'static str) marker, and the transfer boundary raises a catchable TypeError ("Cannot transfer a Map across a perry/thread boundary (unsupported type)"):

  • Capture / input-element paths (spawn, parallelMap, parallelFilter): guard_transferable throws synchronously on the calling/main thread, right after serialization.
  • spawn RETURN path: the worker cannot throw (it has no setjmp frame), so the marker rides back in the serialized result and js_thread_process_pending rejects the returned promise on the main thread — observable via await/.catch.

The recursive first_unsupported_transfer_type walks arrays / object fields / closure captures, so a Map nested inside a returned object is caught too.

Preserved transfers: every arm that previously worked is untouched — arrays, plain objects, closures, strings, numbers, int32, BigInt, Date, SharedArrayBuffer, and all inline tags. Only the arms that previously became silent undefined now throw, so no working transfer regresses.

Safety argument (throw placement + leak avoidance): js_throw longjmps and does not run Rust destructors. In spawn_impl the captures are therefore serialized and guarded before the cross-thread promise is allocated + pinned, so a throw cannot leak a pinned promise. Throwing only ever happens on the calling/main thread (which has an active setjmp frame from compiled JS); worker-side failures are surfaced by promise rejection instead of a throw with no target.

2. Drop for MallocState — free worker malloc objects at thread exit (audit P1 leak)

thread_local! MALLOC_STATE tracked a worker's malloc-heap objects (promises / maps / errors / large closures / tracked buffers) in objects: Vec<*mut GcHeader> with no Drop. A spawn/parallelMap worker leaked all of them at exit — and because the first malloc-count GC needs 100k objects, per-request workers effectively never collected once. Added a Drop that reclaims every tracked block via the same dealloc(Layout) path process_sweep_header/sweep_malloc_objects use for one object.

Drop-ordering safety argument:

  • MALLOC_STATE is thread_local!, so Drop runs only at thread teardown, never mid-program.
  • Worker threads: at TLS destruction the worker's result has already crossed the boundary as an owned SerializedValue deep-copy — no other thread holds a raw pointer into this heap — so reclaiming these blocks cannot dangle a reference anywhere else.
  • Main thread: its MALLOC_STATE is not dropped until process teardown, so this never yanks live objects from running code.
  • Finalizers are deliberately NOT run. The sweep pairs dealloc with gc_type_finalize_unmarked_payload/layout_clear_for_ptr, which touch other thread-locals (MAP_REGISTRY, MAP_INDEX, PROMISE_CONTEXTS, async-hooks queues). TLS destruction order is unspecified, so reaching an already-destroyed TLS during this Drop would panic ("cannot access a Thread Local Storage value during or after destruction") and abort the thread — worse than the leak. We reclaim only the object blocks (the audit's primary worker-exit leak); external side-allocations (a Map's entry table, an error's side tables) are left to the fuller rework, and the re-entrant MALLOC_STATE.with(...) the sweep bookkeeping performs is avoided. Pinned objects are skipped, mirroring the sweep, so a cross-thread promise pinned for an in-flight result is never yanked.

Scoped to MALLOC_STATE per the task's "only if clearly safe" guidance; the small-buffer slab / buffer registry are not touched here (their finalizer-free freeing safety wasn't clear).

Deferred

3. Owner-ThreadId-tagged timer dispatch (audit P0 mitigation) — BLOCKED

The naive slice (tag each queued timer with std::thread::current().id() at schedule time; have the tick/fire functions skip entries whose owner != current thread) would break all Android timer delivery, and this is a real, active configuration — exactly the "timers legitimately fire on a different thread than created" case flagged in the task:

  • crates/perry-ui-android/src/app.rs nativePumpTick (JNI, ~8ms) calls js_callback_timer_tick / js_interval_timer_tick on the UI thread, while TypeScript (and thus setTimeout/setInterval scheduling) runs on the perry-native thread. The in-file comment states this explicitly: "Timer state is global (Mutex-protected), so this works from the UI thread even though timers were registered on the perry-native thread."
  • timer.rs's own module doc documents the same design: "Uses global Mutex-protected state (not thread_local) so that timers registered on one thread can be pumped from another. This is critical on Android…"

A blanket owner-skip would make the UI-thread pump skip every perry-native-registered timer → setTimeout/setInterval never fire on Android. A safe variant (quarantine only known spawn/parallelMap worker-origin timers) is no longer the "narrow mechanical slice" this PR targets — it needs a worker-thread registry and answers to "should a quarantined timer ever fire". Per the audit this P0 belongs to the per-thread-queue rework (Wave 4 threading-model decision), not this slice. No timer code changed.

Tests

PERRY_NO_AUTO_OPTIMIZE=1 cargo test -p perry-runtime --lib -- --test-threads=11219 passed, 0 failed. New unit tests:

  • Support custom menu bar items #1serialize_nanbox_for_thread on a real Map yields Unsupported("Map") (the concrete audit case, not silent undefined); the boundary check recurses through array/object/closure trees; supported values (undefined/null/bool/int32/f64/string/array + round-trip) still transfer with no Unsupported; type-name mapping is human-readable.
  • linux compilation and README #2 — TLS Drop can't be exercised directly from a unit test (fires only at real thread teardown), so a #[cfg(test)] helper drives the shared free path on a TLS-independent MallocState: asserts the exact freed-byte total, that objects is drained (idempotent second call frees 0), and that pinned objects are skipped.

What remains for the full threading-model decision

The audit's Wave 4: either enforce single-JS-thread discipline mechanically (owner-tagged per-thread queues + compiler rejection of module-global access in thread closures) or commit to real multi-heap isolation. Beyond deferred #3, still open from §6: module globals aliasing the main heap into workers, per-thread GCs scanning shared global tables without coordination, the await-loop draining process-global completion queues cross-heap, and ws.rs's deferred-resolution tag bug.

Ref #6185; 2026-07-09 GC audit §6.

Summary by CodeRabbit

  • Bug Fixes
    • Improved cross-thread operations so unsupported values now fail with a clear error instead of silently becoming undefined.
    • parallelMap, parallelFilter, and background task results now surface transfer issues more reliably.
    • Fixed a leak when a background task fails early, preventing pinned work from being left behind.
    • Reduced memory waste by releasing tracked allocations when runtime state shuts down.

…anup (#6185)

Two mechanical cross-thread GC-safety wins from the 2026-07-09 GC audit §6,
independent of the larger single-JS-thread-vs-multi-heap threading-model
decision (which remains open).

1. Loud SerializedValue failures (audit P2). `serialize_nanbox_for_thread`
   silently lowered every non-transferable runtime type (Map, Set, Promise,
   Error, TypedArray, Buffer, Symbol, Temporal, native handles, unmaterialized
   lazy JSON arrays) to Inline(TAG_UNDEFINED), so a captured/returned value of
   one of these crossed a perry/thread boundary as `undefined` with no
   diagnostic. Now the serializer emits a named SerializedValue::Unsupported
   marker, and the transfer boundary raises a catchable TypeError on the MAIN
   thread — capture/element paths (spawn/parallelMap/parallelFilter) throw
   synchronously from the calling thread, and the spawn RETURN path rejects the
   returned promise (the worker can't throw: it has no setjmp frame). All
   currently-working transfers (arrays, plain objects, closures, strings,
   numbers, BigInt, Date, SharedArrayBuffer) are preserved unchanged. In spawn,
   captures are serialized+guarded before the promise is allocated so the throw
   (js_throw longjmps, skipping Rust drops) cannot leak a pinned promise.

2. Drop for MallocState (audit P1 leak). The thread_local MALLOC_STATE tracked
   worker-allocated promises/maps/errors/large-closures/tracked-buffers with no
   Drop, so a spawn/parallelMap worker leaked all of them at thread exit (the
   first malloc-count GC needs 100k objects, so per-request workers never
   collected once). Added a Drop that reclaims every tracked block via the same
   dealloc(Layout) path the sweep uses. Sound because TLS Drop only runs at
   thread teardown, when the worker's heap is private and unreferenced (results
   already crossed as owned SerializedValue), and the main thread's MALLOC_STATE
   is not dropped until process teardown. Deliberately does NOT run payload
   finalizers: those touch other thread-locals (MAP_REGISTRY, PROMISE_CONTEXTS,
   async-hooks queues) whose TLS-destruction order is unspecified, so reaching a
   destroyed TLS would panic and abort the thread; external side-allocations are
   left to the fuller rework. Pinned objects are skipped, mirroring the sweep.

Item 3 (owner-ThreadId-tagged timer dispatch) is deferred: the Android UI-thread
pump fires timers registered on the perry-native thread by design
(perry-ui-android/src/app.rs nativePumpTick; timer.rs module doc), so a naive
owner!=current skip would break all Android setTimeout/setInterval delivery.
That P0 needs the per-thread-queue rework, not this narrow slice.

Tests: perry-runtime --lib (1210 passed). New unit tests cover the serializer's
named-Unsupported routing for a real Map, recursion of the boundary check
through arrays/objects/closures, preservation of supported transfers, and the
MallocState free path (byte total + pinned-skip). Ref #6185; 2026-07-09 GC audit.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR fixes a memory leak in MallocState by freeing tracked GC object blocks during Drop (skipping pinned entries), with new unit tests. It also adds a SerializedValue::Unsupported variant and guard_transferable mechanism that raises a named TypeError for non-transferable values crossing thread boundaries in spawn, parallelMap, and parallelFilter, replacing silent undefined fallback.

Changes

MallocState Drop Cleanup

Layer / File(s) Summary
Free-all-tracked-objects and Drop wiring
crates/perry-runtime/src/gc/malloc.rs
Adds free_all_tracked_objects to drain and deallocate tracked GC blocks (skipping null/pinned entries) and calls it from Drop for MallocState.
Test harness and unit tests
crates/perry-runtime/src/gc/malloc.rs
Adds new_empty_for_test/push_test_object helpers and drop_tests verifying freed totals, idempotency, and pinned-object skipping.

Thread Transfer Guard

Layer / File(s) Summary
Unsupported variant and serialization
crates/perry-runtime/src/thread.rs
Adds SerializedValue::Unsupported(&'static str), updates serialize_nanbox_for_thread to emit it for non-transferable GC types, and adds a defensive deserialization arm.
Transfer-guard infrastructure
crates/perry-runtime/src/thread.rs
Adds type-name mapping, first-unsupported-marker traversal, TypeError construction, and guard_transferable.
parallelMap / parallelFilter guard integration
crates/perry-runtime/src/thread.rs
Runs guard_transferable on serialized input elements, closure captures, and collected per-chunk results in both parallelMap_impl and parallel_filter_impl.
spawn reordering and pending rejection
crates/perry-runtime/src/thread.rs
Reorders spawn_impl to guard captures before promise allocation/pinning, and updates js_thread_process_pending to reject with a named TypeError on unsupported markers.
Transfer guard tests
crates/perry-runtime/src/thread.rs
Adds transfer_guard_tests covering type-name mapping, nested marker detection, Map serialization, and round-trip correctness.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6188: Modifies the same spawn/parallelMap/parallelFilter code paths in crates/perry-runtime/src/thread.rs, changing cross-thread promise allocation/GC rooting alongside this PR's transfer guards.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main thread-transfer and MallocState cleanup changes, and the timer item is a real deferred aspect.
Description check ✅ Passed The description is detailed and covers the main changes, tests, and deferred work, though it omits the template's 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/thread-cross-heap-safety

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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@crates/perry-runtime/src/thread.rs`:
- Around line 466-480: The `guard_transferable` checks in `thread.rs` can throw
via `js_throw`, which longjmps and leaves heap-backed buffers like
`serialized_elements`, `caps`, and `all_results` unreleased on the error path.
Update the `spawn`/`parallelMap`/`parallelFilter` call sites to run
`guard_transferable` before allocating those temporary Vecs, or explicitly
drop/free the buffers before any possible throw. Use the existing
`throw_unsupported_transfer` and `guard_transferable` helpers to keep the
validation on the calling thread without retaining allocated state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 881009c5-8846-4626-a3e9-d68e3baedfba

📥 Commits

Reviewing files that changed from the base of the PR and between d9500be and 4102de4.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/gc/malloc.rs
  • crates/perry-runtime/src/thread.rs

Comment on lines +466 to +480
unsafe fn throw_unsupported_transfer(type_name: &str) -> ! {
crate::exception::js_throw(make_unsupported_transfer_error(type_name));
}

/// Main-thread guard: if any value in `values` is non-transferable, throw a
/// named `TypeError`. Call this at a `spawn`/`parallelMap`/`parallelFilter`
/// serialization boundary, on the calling thread, before spawning any worker.
///
/// # Safety
/// Same as [`throw_unsupported_transfer`] — main/calling thread only.
unsafe fn guard_transferable(values: &[SerializedValue]) {
if let Some(name) = values.iter().find_map(first_unsupported_transfer_type) {
throw_unsupported_transfer(name);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file with line numbers around the cited areas.
sed -n '430,520p' crates/perry-runtime/src/thread.rs
printf '\n--- around 830-990 ---\n'
sed -n '830,990p' crates/perry-runtime/src/thread.rs
printf '\n--- around 1070-1320 ---\n'
sed -n '1070,1320p' crates/perry-runtime/src/thread.rs

# Find js_throw and the spawn_impl comment mentioned in the review.
rg -n "js_throw|spawn_impl|longjmp|pinned promise|unsupported_transfer|guard_transferable" crates/perry-runtime/src/thread.rs crates -S

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

path = Path("crates/perry-runtime/src/thread.rs")
lines = path.read_text().splitlines()

for start, end in [(846, 876), (966, 978), (1088, 1110), (1284, 1304)]:
    print(f"\n--- {start}-{end} ---")
    for i in range(start, end + 1):
        print(f"{i:4}: {lines[i-1]}")
PY

Repository: PerryTS/perry

Length of output: 4779


Drop the temporary Vecs before guard_transferable can throw.

js_throw longjmps, so serialized_elements, caps, and all_results stay on the heap on this path. Repeated caught TypeErrors can leak memory unboundedly. The call sites need to release those buffers before throwing, or restructure the check so it runs without allocating them first.

🤖 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 `@crates/perry-runtime/src/thread.rs` around lines 466 - 480, The
`guard_transferable` checks in `thread.rs` can throw via `js_throw`, which
longjmps and leaves heap-backed buffers like `serialized_elements`, `caps`, and
`all_results` unreleased on the error path. Update the
`spawn`/`parallelMap`/`parallelFilter` call sites to run `guard_transferable`
before allocating those temporary Vecs, or explicitly drop/free the buffers
before any possible throw. Use the existing `throw_unsupported_transfer` and
`guard_transferable` helpers to keep the validation on the calling thread
without retaining allocated state.

@proggeramlug proggeramlug merged commit 86b0fff into main Jul 9, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/thread-cross-heap-safety branch July 9, 2026 21:50
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