ReviewOS

zig-utils/zig-js

Runtime: define heap cap and per-thread OOM contract

#24
Closed chris opened this 9 days ago ·
9 days ago

Child tracker from #11/#16/#1.

Goal

Define and maintain the public heap-cap and per-thread OOM contract for zig-js contexts, including how allocation pressure is surfaced to hosts and to shared-realm Thread joiners.

Current status

The original PR-249 blocker semantics/oom-one-thread.js is now promoted. GC-backed capped contexts can collect and retry at safe GC cell allocation failures and ArrayBuffer byte allocation failures, and the reference recovery witness passes.

Implemented contract:

  • Context.Options.heap_limit_bytes applies a thread-safe outstanding-byte budget to Context-owned allocator traffic.
  • Context.heapBudgetStats() reports limit, used, peak, and remaining bytes for capped contexts.
  • Top-level host evaluation reports unrecovered cap pressure as Zig error.OutOfMemory.
  • A capped context prebuilds an immutable OutOfMemoryError object so shared-realm Thread completion can publish a deterministic JS error value even when ordinary heap allocation is exhausted.
  • join() rethrows that reserved object, and already-created asyncJoin() reactions reject with it when they can be delivered.
  • Allocation-site OOM can become catchable by active JS handlers in the covered interpreter/tree-walker paths, including catch-binding pressure that has pre-reserved environment storage.
  • Arena-backed caps remain fail-closed and non-reclaimable unless callers opt into GC-backed recovery.
  • GC-backed capped contexts can recover after unreachable pressure is collected at safe allocation-recovery points.

Remaining linked work

  • #30 tracks the remaining emergency-recovery perfection work: no-GIL root-publication recovery, side-store pressure, and avoiding lock-order deadlocks during collect/retry.
  • Keep documenting any new capped allocation classes as either fail-closed, catchable, or recoverable.
  • Keep the PR-249 audit/probe evidence current if a future behavior change affects heap-cap cases.

Acceptance criteria

  • Heap-cap behavior is documented for arena-backed and GC-backed contexts.
  • Allocation failure remains observable as ordinary JS/host errors without corrupting GC state.
  • One capped shared-realm Thread can hit OOM while siblings and cleanup/finalization remain well-defined.
  • Promoted reference coverage and focused unit tests prove the intended supported contract.
  • Remaining uncovered allocation classes are tracked by narrower follow-up issues rather than hidden behind this parent tracker.
9 days ago

Landed first resource-control primitive in a510e76 (feat(context): add heap allocation limit).

What changed:

  • Added Context.Options.heap_limit_bytes plus matching Context.TestingOptions.heap_limit_bytes.
  • Added a small atomic BudgetAllocator wrapper that enforces a per-Context outstanding-byte cap and fails closed with error.OutOfMemory.
  • Routed Context-owned allocations through the capped allocator: arena chunks, GC lifecycle state/cell backing, SAB retain tables, thread/GIL/park state, and other Context.gpa users.
  • Tightened partial-create cleanup so quota failures during global installation release the partially-created Context allocation before arena/budget unwind.
  • Documented the boundary in architecture/testing/limits docs and called out that this is an allocator cap, not yet the full per-thread OOM survivor contract.

Focused validation run locally:

  • zig build test -Dtest-filter='BudgetAllocator enforces outstanding byte limit' --summary none
  • zig build test -Dtest-filter='Context heap_limit_bytes fails closed on allocation pressure' --summary none
  • zig build test -Dtest-filter='Context public Options expose only stable thread controls' --summary none
  • bun run docs:build
  • git diff --check

Remaining for this issue:

  • Define the deterministic observable per-thread OOM survivor semantics.
  • Promote/adjust the PR-249 OOM witness once the JS-facing contract is exact.
  • Decide whether we want additional public diagnostics for current/peak budget usage, or keep the first version intentionally minimal.
9 days ago

Landed another small #24 slice in 5df46a7 (feat(context): expose heap budget stats).

What changed:

  • Added Context.heapBudgetStats() for capped contexts, returning limit_bytes, current used_bytes, lifetime successful-allocation peak_bytes, and remaining_bytes.
  • Uncapped contexts return null, keeping the diagnostic tied to the explicit fail-closed allocator boundary.
  • Tightened BudgetAllocator tests so peak accounting stays monotonic and only reflects successful outstanding allocation growth.
  • Documented the diagnostic in the thread limits/testing docs.

Validation run locally:

  • zig build test -Dtest-filter='BudgetAllocator enforces outstanding byte limit' --summary none
  • zig build test -Dtest-filter='Context heap' --summary none
  • bun run docs:build
  • git diff --check

Remaining #24 work is still the harder semantics piece: define deterministic per-thread OOM survivor behavior under pressure and then promote the matching WebKit PR-249 witness once that observable contract is nailed down.

9 days ago

Landed the first deterministic per-thread OOM survivor contract slice in ecce653 (fix(threads): publish heap-limit oom sentinel).

What changed:

  • Added allocation-free Value.staticStr(comptime) for sentinel values that must remain constructible under heap pressure.
  • Spawned shared-realm Threads now publish a static "OutOfMemoryError" completion value when thread startup or body execution hits native error.OutOfMemory, instead of allowing join() to rethrow an ambiguous undefined.
  • Added a focused capped-context witness: one Thread exhausts Context.Options.heap_limit_bytes, join() catches the sentinel, and an already-running sibling Thread still joins successfully afterward.
  • Documented the split contract: top-level host Context.evaluate() reports Zig error.OutOfMemory; spawned Thread completion publishes the allocation-free sentinel for JS-visible joining.

Validation run locally:

  • zig fmt src/value.zig src/jsthread.zig src/context.zig
  • zig build test -Dtest-filter='Context heap_limit_bytes lets sibling Threads survive one Thread OOM' --summary none
  • zig build test -Dtest-filter='Context heap' --summary none
  • bun run docs:build
  • git diff --check

Remaining #24 work: stress this survivor contract under more interleavings and then promote or reclassify the PR-249 OOM reference case based on the audited observable behavior.

9 days ago

Opened follow-up #26 for the final polish beyond the current robust sentinel contract: use an emergency reserve/prebuilt value so heap-limit Thread OOM can throw a real OutOfMemoryError object instead of the current allocation-free string sentinel, without depending on ordinary heap headroom.

9 days ago

Follow-up #26 landed in 78c5a4f: the #24 OOM survivor contract now uses a reserved immutable OutOfMemoryError object instead of the temporary primitive sentinel.

That means the documented spawned-Thread path is now:

  • top-level host Context.evaluate() still reports allocator pressure as Zig error.OutOfMemory;
  • a capped shared-realm Thread that exhausts the context budget publishes a prebuilt OutOfMemoryError object;
  • join() rethrows that object without allocating on the failed path;
  • sibling Threads remain joinable after the exhausted peer.

Focused validation is listed on #26.

9 days ago

Landed another small #24 slice in f3cb0cc: https://github.com/zig-utils/zig-js/commit/f3cb0cc01d039502dbf325303e4c0ada86f28f2e

What changed:

  • The PR-249 semantics/oom-one-thread.js reference probe now maps the inert vendored JSC RAM-cap directive to zig-js's real Context.Options.heap_limit_bytes cap (192MiB).
  • tools/threads-reference-audit.py now classifies that probe under allocation-site catchable OOM semantics and expects the current blocker evidence.
  • Thread docs now distinguish the already-landed reserved thread OOM completion contract from the remaining allocation-site catchability work.

Validation:

  • zig fmt conformance/threads_test.zig
  • PATH="/Users/chris/.local/bin:$PATH" python3 tools/threads-reference-audit.py --fail-on-uncategorized --run-probes --expect-current-blockers --skip-timeout-probes --probe-timeout 20
  • /Users/chris/.bun/bin/bun run docs:build
  • git diff --check

Current evidence is now sharper: the probe fails with OutOfMemoryError: Context heap limit exceeded, so the cap is firing. The remaining runtime gap is that JS allocation pressure escapes as whole-Thread completion instead of being catchable by the allocating frame's local try/catch.

Opened follow-up #27 for that allocation-site catchability work.

9 days ago

Follow-up landed in 75a7e49: https://github.com/zig-utils/zig-js/commit/75a7e4980b5ad435dedeb7f646945950e0a81c31

This improves the heap-cap contract from #24/#27: capped allocation failures can now become catchable JS OutOfMemoryError throws when an active JS handler exists, using the prebuilt OOM object instead of trying to allocate a fresh error while the heap is full.

Validation:

  • focused heap-limit allocation-pressure unit: pass
  • existing sibling Thread OOM survivor unit: pass
  • PR-249 semantics/oom-one-thread.js: still fails with OutOfMemoryError: Context heap limit exceeded, so #27 remains open for catch-binding/small-increment pressure.

Also opened #28 for the separate string-cell OOM panic found during this work.

9 days ago

#27 follow-up from 4a83575: https://github.com/zig-utils/zig-js/commit/4a8357525e283a2cbba2bcff2090fa37319e0869

The simple allocation-site catch-binding blocker improved: capped catch (e) now pre-reserves its catch environment/binding and has a focused green regression.

The PR-249 semantics/oom-one-thread.js witness still fails with a whole-thread OutOfMemoryError. The remaining shape appears to belong here in the heap-cap contract: the witness expects recovery after dropping JS references, but the current arena-backed heap_limit_bytes budget is outstanding-byte based and does not reclaim individual arena allocations when the hog clears its hoard. We need to decide/implement the intended contract for capped threaded contexts: arena fail-closed only, GC-backed reclaimable caps for PR-249-style recovery, emergency headroom, or a hybrid.

9 days ago

Opened #29 to track the concrete heap-cap recovery policy now blocking PR-249 semantics/oom-one-thread.js after the #27 catch-binding improvement.

The decision needed here is the contract split: arena-backed caps are fail-closed and non-reclaimable, while GC-backed capped contexts need safe collect/retry or emergency-headroom behavior if we want PR-249-style post-OOM recovery.

9 days ago

Heap-cap contract update: zig-gc b99de49 plus zig-js 78b553a promote semantics/oom-one-thread.js. GC-backed capped contexts now have a GIL-safe GC cell allocation failure collect/retry path, and the PR-249 recovery witness passes.

The remaining cap-policy surface is narrower now: #30 tracks no-GIL emergency recovery and side-store/ArrayBuffer-byte retry policy. Arena-backed caps remain fail-closed unless callers opt into GC-backed recovery.

9 days ago

Heap-cap contract docs caught up in 34134b7.

Current status for #24:

  • the public cap knob and stats API are documented;
  • spawned shared-realm Thread OOM uses the reserved OutOfMemoryError object and keeps siblings joinable;
  • allocation-site catchability and catch-binding recovery landed in prior #27/#29 chunks;
  • GC-backed capped contexts now recover after safe GC cell and ArrayBuffer-byte allocation failures, which promoted semantics/oom-one-thread.js.

I am leaving #24 open while #30 tracks the remaining perfection work: no-GIL emergency recovery and lock-aware side-store pressure. Arena-backed caps are intentionally documented as fail-closed.

9 days ago

CI follow-up: e179650 tightens the heap-cap recovery contract after TSan caught parked-stack conservative scanning during allocation-failure recovery. The practical contract is now clearer: GIL/single-thread recovery may collect and retry when it can scan only safe roots; live no-GIL peers fail closed until #30 grows an abort-safe recovery collector. Focused TSan checks for semantics/oom-one-thread.js and the sibling-thread OOM unit witness pass locally.

9 days ago

Completed. The heap-cap and per-thread OOM contract is now covered by promoted/reference evidence plus focused unit coverage, including the bounded sibling-Thread OOM witness.

Fresh full CI is green for b2e7dbe5e9bc2a27a87ff1bad8be8b0be777e46e: https://github.com/zig-utils/zig-js/actions/runs/29170980761

Remaining narrower heap-cap perfection work stays open in #30.

Sign in to comment on this issue.