Phase 2 design: real concurrent agents + blocking Atomics
On this page 6
Status: implemented (src/agent.zig, typed-array Atomics hooks in
src/interpreter.zig). Scope: Phase 2 of
https://github.com/zig-utils/zig-js/issues/1 — replace the cooperative
$262.agent model with real OS-thread agents, and make Atomics.wait /
Atomics.notify genuinely block and wake. Builds on Phase 1
(src/shared_buffer.zig: refcounted process-wide SAB storage) and the
bindings audit (bindings.md).
Primitives
This zig-0.17-dev's std.Thread has spawn/join/Id only; Mutex/Condition
moved to std.Io and require an Io instance (vtable with
futexWait/futexWake). std.Io.Threaded is the blocking implementation
with real futex waits and timeouts. Decision: one engine-global
std.Io.Threaded instance (lazily initialized, page_allocator-backed) in a
new src/agent.zig, giving the engine Io.Mutex, Io.Condition.waitTimeout,
and real sleep. The conformance runner already constructs a std.Io for file
I/O, so the dependency is precedented.
AgentGroup (replaces g_agent)
One AgentGroup per main Context (process-global registry keyed by nothing
— v1 keeps exactly one live group; the runner runs one test per process-worker
already). All fields mutex-guarded unless noted:
reports: FIFO of []u8— copied OUT of agent arenas (an agent's arena dies with the agent; the group allocator owns report strings).agents: list of *AgentRecord.bcast: ?*SharedBufferStorage(holds a ref) + a broadcast generation counter for the rendezvous.
AgentRecord: std.Thread handle, state word (starting / parked_for_bcast / running / done, atomic), can_block: bool = true, stop flag (atomic, for
teardown), its own arena/realm (exactly today's agentRunSync realm setup —
per-realm shapes/microtasks/RetainList stay, which is why Phase 1 needs no
shape locks).
threadlocal var t_agent: ?*AgentRecord replaces t_is_agent (the bindings
audit's per-thread ruling).
Protocol (test262 INTERPRETING.md semantics)
$262.agent.start(src): spawn the OS thread NOW. The agent thread runssrcin its fresh realm.receiveBroadcast(cb)parks the agent (state=parked_for_bcast, waits on the group condition) until a broadcast generation arrives, then callscb(sab)with a wrapper over the retained storage. This ordering — agent code runs immediately, broadcast blocks — is precisely what the cooperative model could not express and what the blocking-wait tests require.broadcast(sab): publish storage + bump generation, wake all parked receivers, then block the caller until every started agent has acked receipt (counted under the group mutex). Agents started but never reachingreceiveBroadcastwould deadlock the parent — guard with the runner's process-level timeout plus a generous internal cap (60s) that reports and proceeds (matches engine262/V8 shell behavior closely enough for the corpus).report(msg): mutex push (dupe into group allocator).getReport(): mutex pop or null.sleep(ms): real sleep.monotonicNow():std.time.Timerbased, ms resolution, one timer per process (monotonic across agents — several timeout tests measure elapsed spans across agents).leaving(): marks done; the thread exits after draining microtasks. Group teardown (agentResetStatesuccessor, called per test): set every stop flag, notify all waiter lists (parked waiters must poll their stop flag on wake — PR-249's "stop the world waited for a world that couldn't hear it" lesson), join each thread with a hard cap, release the broadcast ref, free reports.- Lifetime rule (assert in debug): no pointer into an agent arena survives the agent. Only SAB storage refs and group-owned report copies cross.
Waiter table (blocking wait/notify)
In src/shared_buffer.zig (or src/agent.zig): a global table
(storage: *SharedBufferStorage, byte_offset: usize) → WaiterList, guarded
by one global Io.Mutex (contention is bounded by the corpus's scale; shard
later if it ever matters). WaiterList: FIFO of tickets; each ticket has its
own Io.Condition + woken/stop bits.
Atomics.wait(ta, i, expected, timeout): validate (i32/i64, shared,[[CanBlock]]else TypeError) → under the list lock re-load the element (SeqCst, via the Phase-1 atomic accessors) →"not-equal"early-out → enqueue ticket →Condition.waitTimeoutloop until woken / timed out / stopped → dequeue →"ok"/"timed-out".Atomics.notify(ta, i, count): under the list lock, mark+signal up tocounttickets FIFO; return the number actually woken. Non-shared buffers: return 0 without touching the table.waitAsync(Phase 3): same list, ticket carries a promise capability + owning agent; notify marks it and pokes the owner's inbox; the owner's drain loop settles it. Designed so Phase 2's table needs no rework.[[CanBlock]]: field on AgentRecord (agents true; main agent host-set). Runner gains a mode to runCanBlockIsFalsetests with main-agent can_block=false instead of skipping them.
Runner interaction
The test262 runner's per-worker process isolation is the crash/hang backstop; it needs a wall-clock timeout per test (currently none — a wedged agent test would stall a worker subprocess forever). Add a watchdog: the parent already respawns workers past a crash; extend the protocol with "no progress in N seconds → kill + respawn past the test" BEFORE enabling real agents, so a deadlock costs one test, not the run. This lands first, as its own commit.
Order of work
- Runner watchdog (independent, also useful today).
src/agent.zig: Io.Threaded bootstrap, AgentGroup/AgentRecord, rewire$262.agent.*(cooperative path deleted),monotonicNow/sleepreal.- Waiter table + blocking
wait/notify+[[CanBlock]]plumbing. - Bindings-audit execution: per-thread
math_prng, atomicsymbol_counter(both flagged in bindings.md), re-verifyre_legacy/depth are per-realm. - Stress: 1000× loop of the heaviest wait/notify tests; TSan build of the agent unit tests.