Problem
perry/thread's cross-thread machinery has several unsound edges (all code-verified in the 2026-07-09 GC audit, threading dimension):
- Await loop drains global queues on whatever thread runs it (
perry-codegen/src/expr/fs_await.rs:194-207 → promise/microtasks.rs:110-122 → thread.rs:1283-1307): a worker's await can steal another thread's completion, deserialize it into the worker's arena, and resolve a main-heap promise with a foreign pointer; the settle task then runs main-heap closures on the worker. Worker exit unmaps the arena → main thread reads freed memory.
- Global timer queues carry raw closure/promise pointers across heaps (
timer.rs:25-40, 323, 367-380): any thread's await loop fires foreign-heap timer callbacks (the "only accessed from the pump thread" SAFETY comment is not enforced).
- Module-level globals are process-wide but rooted/rewritten only by the thread that ran module init (
gc/roots.rs:63,425-434): worker closures calling top-level functions alias main-heap objects with no synchronization and no rewrite when a main-thread moving cycle relocates them.
- Serializer silently converts Map/Set/Promise/Error/TypedArray/ArrayBuffer captures to
undefined (thread.rs:349-374) — contradicts the documented "views deep-copy" claim; should fail loudly.
- Worker death leaks every malloc-tracked object — no
Drop for MallocState (gc/malloc.rs:112-128), so per-request spawn workers accrete promises/maps/closures forever.
Proposed fix (decision needed)
Either enforce the single-JS-thread discipline mechanically — owner-ThreadId tags on every queued completion/timer with drains skipping foreign entries, compiler rejection (or serialize-snapshot) of module-global access in thread closures, loud serializer errors, Drop for MallocState — or commit to real multi-heap isolation. The current halfway point is the least safe option.
Quick wins already shipping separately (Wave-1 PR3): ensure_gc_initialized() at worker entry, rooting the rebuilt worker closure, a scanner/pinned-gate for PENDING_THREAD_RESULTS, and the ws.rs deferred-resolution fix.
Context
2026-07-09 GC audit. Items 1-3 are P0-severity UAF classes when perry/thread is combined with await/timers/module state.
Problem
perry/thread's cross-thread machinery has several unsound edges (all code-verified in the 2026-07-09 GC audit, threading dimension):perry-codegen/src/expr/fs_await.rs:194-207→promise/microtasks.rs:110-122→thread.rs:1283-1307): a worker'sawaitcan steal another thread's completion, deserialize it into the worker's arena, and resolve a main-heap promise with a foreign pointer; the settle task then runs main-heap closures on the worker. Worker exit unmaps the arena → main thread reads freed memory.timer.rs:25-40, 323, 367-380): any thread's await loop fires foreign-heap timer callbacks (the "only accessed from the pump thread" SAFETY comment is not enforced).gc/roots.rs:63,425-434): worker closures calling top-level functions alias main-heap objects with no synchronization and no rewrite when a main-thread moving cycle relocates them.undefined(thread.rs:349-374) — contradicts the documented "views deep-copy" claim; should fail loudly.Drop for MallocState(gc/malloc.rs:112-128), so per-request spawn workers accrete promises/maps/closures forever.Proposed fix (decision needed)
Either enforce the single-JS-thread discipline mechanically — owner-
ThreadIdtags on every queued completion/timer with drains skipping foreign entries, compiler rejection (or serialize-snapshot) of module-global access in thread closures, loud serializer errors,Drop for MallocState— or commit to real multi-heap isolation. The current halfway point is the least safe option.Quick wins already shipping separately (Wave-1 PR3):
ensure_gc_initialized()at worker entry, rooting the rebuilt worker closure, a scanner/pinned-gate forPENDING_THREAD_RESULTS, and the ws.rs deferred-resolution fix.Context
2026-07-09 GC audit. Items 1-3 are P0-severity UAF classes when
perry/threadis combined withawait/timers/module state.