zig-js

Runtime Thread Inventory

Every OS thread created by production zig-js code crosses the typed boundary in src/runtime_threads.zig. The checked runtime-thread-inventory-v1.json records each resource's owner, admission and multiplicity, stack policy, blocking behavior, memory, and shutdown contract.

The current inventory contains six creation sites:

ResourceOwnerMultiplicityPriorityCurrent admission
test262 agentProcess agent groupOne per live agentForegroundPer-resource thread/stack limits
concurrent GC markerContextAt most one per contextSafetyCollection policy plus per-resource limits
shared-realm JavaScript ThreadContext thread recordOne per live ThreadForegroundContext and per-resource limits
script WorkerCaller-owned WorkerOne per WorkerBackgroundPer-resource thread/stack limits
module WorkerCaller-owned WorkerOne per WorkerBackgroundPer-resource thread/stack limits
private execution watchdogC API ContextGroupAt most one per groupSafetyDeadline plus per-resource limits

JIT and WebAssembly compilation remain synchronous and own no production helper thread or artifact queue. Their CPU-heavy boundaries now enter the same runtime coordinator as typed threads. Compilation performed by a typed engine thread reuses that thread's runnable slot. An untyped embedder thread uses the reserved host lane when available; additional host compilers enter the background queue for general runnable capacity.

The public Zig module exposes a coherent process-wide telemetry snapshot:

const resources = js.runtimeThreadSnapshot();
const workers = resources.resource(.script_worker);

const previous = js.setRuntimeThreadLimits(.script_worker, .{
    .max_threads = 8,
    .max_configured_stack_bytes = 128 * 1024 * 1024,
});

const previous_scheduler = js.setRuntimeThreadSchedulerLimits(.{
    .max_runnable_threads = 4,
});

// Restore automatic host sizing. An explicit maxInt(u64) means unlimited.
_ = js.setRuntimeThreadSchedulerLimits(.{});

Each resource row reports attempts, successful starts, completions, spawn failures, policy rejections, in-flight spawn calls, admitted reservations, live and peak threads, current/peak runnable and blocked threads, block/resume transition totals, current/peak configured stack bytes, and the active limits. The schema-v7 thread invariants are attempts = starts + spawn_failures + admission_rejections + in_flight_attempts, live = starts - completions, and live = runnable + blocked. The scheduler row reports its runnable limit, policy mode, detected logical CPU count, automatic host reservation, configured override, active and peak slots, current and peak queued waiters, and cumulative slot waits. It also reports current/peak internal work in general slots and in the reserved host lane. Each priority row reports its weight, current/peak typed thread waiters, cumulative grants, and last granted ticket. Each internal-work row reports requests, starts, completions, current/peak active work, typed and nested slot reuse, reserved/general host admissions, current/peak waiters, cumulative waits and wait time, and last granted ticket. The coherent invariants are requests = starts + waiters, active = starts - completions, active_slots = sum(resource.runnable) + internal_work_general_active, and scheduled_cpu_total = active_slots + host_work_reserved_active. Configured stack bytes describe the std.Thread.SpawnConfig reservation, not resident or committed process memory. Counters mutate only at OS-thread lifecycle, blocking transitions, and synchronous internal-work boundaries; snapshot readers retry across those short mutations so they cannot combine fields from different completed states.

Limits are process-wide per resource class. A spawn reserves one thread and its configured stack before calling the OS; concurrent callers cannot cross either limit. OS failure and thread exit release both reservations. Lowering a limit below current use leaves existing threads alone and refuses new ones until use falls below the limit. The defaults are unlimited, preserving existing admission behavior.

Each typed thread starts runnable. Engine waits use nested blocking scopes: the outermost scope moves the thread from runnable to blocked, and its matching end moves it back. This covers agent broadcast/sleep/Atomics waits, Worker inbox and inspector queues, shared-realm Lock/Condition/Atomics/join/GIL waits, collector joins, and watchdog sleeps. Nested waits do not double-count, and calls made by host or test threads outside the typed boundary are inert. Thread completion requires the blocking depth to be zero, so a missing scope end fails in debug and test builds instead of silently corrupting the state totals.

The process-wide general runnable limit is shared by all six resource classes and excess untyped internal work. A typed thread acquires one slot before its entry function, releases it at its outermost blocking transition, reacquires one before returning from that wait, and releases it at completion. Excess entries and resumes join allocation-free intrusive queues and park on stack-owned conditions, without a spin or sleep admission loop. Safety, foreground, and background grants follow a repeating 4:2:1 weighted cycle. Empty classes are skipped, FIFO order is preserved inside each class, and a continuously queued class therefore cannot starve. New arrivals queue behind existing eligible waiters instead of bypassing them. Synchronous native and Wasm compilation use the background class without an allocation: the queue node and condition live in the waiting host stack. One untyped host compiler may use the automatic host reservation; further host compilers consume available general slots or wait. Reserved admission can wake only internal work, while general background grants preserve ticket order between internal work and typed Worker waiters.

The coordinator reserves a slot, publishes the selected resource as runnable, and removes its waiter in one coherent mutation before signaling it. A thread woken from an engine condition never queues for that slot while holding its reacquired wait mutex: it tries immediate admission first, and if capacity or older waiters prevent it, releases the mutex, parks in the scheduler queue, then reacquires the mutex before returning to its caller.

The runnable limit defaults to max(1, logical CPUs - 1). The reserved lane keeps capacity available for the embedder or foreground host mutator, which runs outside the typed engine-thread boundary. If host detection fails, the coordinator fails safe to one runnable slot. Passing null reapplies automatic sizing and refreshes host detection; a numeric override is exact, including zero to pause every new entry, resume, and untyped internal-work boundary, or maxInt(u64) for intentional unlimited capacity. Lowering effective capacity below current use does not interrupt existing runnable threads. Increasing it grants exactly the newly available capacity through the weighted queues.

Run the fail-closed audit after adding or removing any runtime or test thread:

zig build runtime-thread-audit

The audit reconciles every typed resource call site and every remaining direct std.Thread.spawn token under src/. A count change, unknown resource kind, missing inventory row, or direct spawn in a new source file fails. Update the JSON only after reviewing whether the new work belongs to production, test scaffolding, or an existing resource class.

The boundary controls live-thread/configured-stack admission, records runnable/blocked and synchronous internal-work state, and enforces automatically sized, priority-scheduled shared CPU slots. Issue #985 owns synchronous compiler admission and its performance evidence. Issue #502 owns the remaining cross-subsystem memory-pressure work.