Status: phases A-C ship. Each test262 agent runs on its own OS thread,
realm, and heap; agents share only refcounted backing bytes and futex state.
$262.agent supplies the harness channels, and cross-thread Atomics.wait /
notify are live. Exact-count FIFO wake behavior and cross-agent waitAsync
timer/microtask refinements remain the main follow-ups.
This document records the accepted real-threads design and its original phasing. The single-agent precursor is sab-atomics.md.
It is a deliberate departure from Cynic's "single-agent-per-isolate" default (AGENTS.md), greenlit as a separate initiative. The design's guiding constraint: introduce real OS threads without making the whole engine thread-safe.
Agents share only raw bytes (a SharedArrayBuffer's backing store) and a futex table. They do NOT share JS objects, heaps, or GC. So:
- Each agent runs on its own OS thread with its own Realm + heap + allocators — all of which stay single-threaded, unchanged.
- The only cross-thread mutable state is (a) a refcounted shared byte block and (b) a process-global futex table. Both are small, explicit, lock-guarded surfaces.
This mirrors V8 (one Isolate per agent, a shared BackingStore) and is
what makes the project tractable rather than a full engine
thread-safety rewrite.
Before this work, a SAB's bytes used a realm-owned array-buffer slice, which could not cross threads. The shipped design introduces a process-global, refcounted block:
const SharedDataBlock = struct {
bytes: []u8, // page-allocated, outside any realm heap
byte_length: usize, // current length (≤ cap; grows in place)
max_byte_length: usize, // cap (growable SAB pre-allocates this)
refcount: std.atomic.Value(usize),
// futex state for this block (see §2)
mutex: std.Thread.Mutex,
cond: std.Thread.Condition,
};
- Allocated from a process-global allocator (page allocator / a dedicated shared arena), never from a realm's GC heap.
- A
SharedArrayBufferJSObjectholds a*SharedDataBlock(a new slot besidearray_buffer) and bumps the refcount on construction. - When a SAB object is swept /
deinit'd, it decrements the refcount; the block frees at zero. Growable SABs pre-allocatemax_byte_lengthsogrowonly bumpsbyte_length(the data block never moves — so other agents' views stay valid; this also fixes the single-agent realloc-moves-the-store shortcut). - Broadcast (
$262.agent.broadcast(sab)) hands the block pointer to another agent, which constructs its own SABJSObjectin its own realm pointing at the same block (refcount++). Same bytes, two isolated views.
Non-shared ArrayBuffer is unchanged (keeps the per-realm slice).
Per-block mutex + cond (above) back a wait queue:
Atomics.wait(ta, i, v, t): lock the block mutex; re-read the element under the lock; if it ≠v→"not-equal"; elsecond.timedWait(t)in a loop until woken or the deadline →"ok"/"timed-out". The current single-agent stub (always"timed-out") becomes a real blocking wait.Atomics.notify(ta, i, count): lock; wake up tocountwaiters parked on(block, i); return the number woken. Today's0becomes the real count.- Waiters carry their byte-index so a
notifyon indexjdoesn't wake a waiter on indexi(theno-spurious-wakeupfixtures).
A single mutex+cond per block (broadcast-wake then re-filter by index) is simplest and correct; a per-index queue is the optimization.
waitAsync resolves its pending Promise from notify — which means the
notifying thread must enqueue a microtask on the waiter's realm. That
cross-thread microtask hand-off is the fiddliest part; defer waitAsync
cross-agent resolution to last.
$262.agent is a test262 host hook, not a JS builtin — it lives in
the harness's install262, keeping the engine free of test262
specifics. Surface (by fixture usage): start, receiveBroadcast,
safeBroadcast / broadcast, report / getReport / getReportAsync,
waitUntil, monotonicNow, timeouts, tryYield, leaving, sleep.
start(src): spawn astd.Threadthat builds a fresh Realm (own heap), installs builtins + a child$262.agent(receiveBroadcast,report,leaving,sleep,monotonicNow), and evaluatessrc.- Broadcast channel parent→agent: a thread-safe slot holding the
*SharedDataBlock(+ optional int); the agent'sreceiveBroadcastcallback fires when set. - Report channel agent→parent: a mutex-guarded queue of strings;
report(s)pushes,getReport()pops (blocking until available). monotonicNow/timeouts: a shared monotonic clock;timeoutscomes fromatomicsHelper.js(anincludes:the fixtures pull in).waitUntil/tryYield/safeBroadcastare harness JS helpers (atomicsHelper.js) built on the primitives — no new host hooks.
Lifetime: agents must be joined (or detached + drained) at fixture end;
a wait-forever agent is bounded by the harness's existing per-fixture
timeout watchdog (--timeout), which must signal agent threads to
unpark and exit.
- A — SharedDataBlock substrate (shipped). Refcounted non-GC block; SAB points at it; growable grows in place; a SAB can be handed to a second Realm on the same thread and both views see one block. Verifiable with a unit test; single-agent corpus must stay flat.
- B — real futex
wait/notify(shipped). Block mutex/cond; blockingwaitwith timeout;notifywakes by index. Cynic unit test spawning two threads sharing a block. - C —
$262.agentharness hooks (shipped). Thread spawn + broadcast/report channels + child-agent$262; wireatomicsHelper.js. Land the ~112 fixtures.waitAsynccross-agent resolution last (cross-thread microtask enqueue).
- Thread lifetime / hangs. A stuck agent must be unparked + joined; rely on the per-fixture watchdog and a shutdown flag the futex wait checks.
- CI flakiness. Real-thread timing tests can be flaky; the
monotonicNow-based duration asserts have slack, but watch the--threadsinteraction (the harness's own worker pool vs. agent threads — agents are per-fixture and must not be confused with harness workers). - GC vs shared block. The block is refcounted and outside GC; the
SAB object's sweep must decrement, never free directly. Audit under
test262-safe --gc-threshold=1. - Scope creep. This is the only part of Cynic that uses real
threads; keep the shared surface to exactly
SharedDataBlock+ futex table, nothing else.
Future changes should preserve the isolation boundary: realms and GC heaps are
thread-confined; only SharedDataBlock and futex/channel state are shared.