ReviewOS

zig-utils/zig-js

Conformance: publish terminal PR-249 execution inventory

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

Parent: #143 Roadmap: #134, #147, #246

Goal

Publish the terminal execution artifact for the exact PR-249 pin after every executable is either promoted or carries a verifier-enforced terminal incompatibility disposition.

Acceptance

  • Record every one of the 259 executable files with exact mode, result, duration, checksum/source identity, and implementation or disposition owner.
  • Distinguish passing execution, intentional incompatibility, private terminal premises, and non-executable helpers; permit no generic skip state.
  • Fail on new/disappearing files, changed hooks/options, stale dispositions, unexpected passes, hidden exclusions, missing modes, or missing evidence.
  • Run the complete required serialized and no-GIL corpus with bounded per-case handling and publish exact totals.
  • Add a bounded CI witness plus a deliberate complete reproduction command.
  • Refresh generated README/release claims and provide #147/#246 with machine-readable release evidence.

Completion

Completed by 709f34b9 (versioned emitter, two-mode disposition scan, combiner/verifier) and f6f8ef1b (terminal evidence and CI gate).

  • pinned source: 709f34b9d9ee6cf9fffb6ad736236fd35a9ba5d4
  • vendored PR-249 head: 3a14f2a821ac56fcb01d1c765200be7e9dfdb458
  • serialized ReleaseSafe: 246/246 pass, 0 fail, 0 skip, 133,440 ms summed case time
  • no-GIL ReleaseSafe: 253/253 pass, 0 fail, 0 skip, 168,166 ms summed case time
  • terminal dispositions: 6/6 confirmed in both modes
  • complete classification: 259 executable = 253 promoted + 6 terminal, with 5 helpers/preloads outside the denominator

The release matrix now marks shell_and_reference_hooks green and its bounded CI witness verifies both fresh inventory emission and the complete checked artifact.

Evidence: terminal execution artifact, reference inventory, terminal scan.

9 days ago

Inventory refresh for the terminal artifact this issue owns: 247 promoted, 6 blocked, 6 terminal of 259 executables (242 in the default allowlist plus 5 parallel_js-only), after 6b7754b0. Regenerated from --print-inventory; check-inventory clean and 7/7 audit self-tests pass.

One method correction worth recording here, because it affects how the remaining tail gets measured. The earlier probe of the blocked cases ran every one of them through parallel-js one, but only two of them declare --useThreadGILOffUnsafe=1. The rest — checktraps-invalidation.js, cve/mc-jit-stale-base-grow-oob.js, cve/mc-val-fire-vs-link.js, jit/ic-publish-reset-loops.js, cve/mc-code-calllink-writer-writer.js — declare only --useJSThreads=1 and are written for the cooperative-GIL interleaving the default mode provides (several of them yield with sleepMs(0) explicitly for that reason). Probing them in no-GIL is the wrong mode, so the recorded "no result in 150 s" outcomes do not characterise them. They are being re-probed in their declared modes and the results will be recorded against #429.

checktraps-invalidation.js is the first re-measured case and still gives no result in 300 s serialized. Its RangeError: evaluation step budget exceeded is gone — that was the per-interpreter budget fix in 01acfc07 — but the case is now dominated by GIL handoff: three worker threads spin while (!Atomics.load(control, 0)) calling a 5,000-iteration kernel while the main thread has to complete 2,050 calls of the same kernel before it can set the stop flag. Every main-thread step checkpoint hands the lock to three hot spinners. That is a throughput characterisation, not a hang diagnosis yet, and it is being tracked rather than asserted.

9 days ago

A third instance of the no-GIL binding-resolution failure this issue tracks, and by far the cheapest reproduction.

cve/mc-aint-poll-resume-stale-elided.js, run no-GIL in a ReleaseSafe build, fails in 6,009 ms with ReferenceError: b. It was previously recorded as "no result in 200 s", which was a Debug-build artifact — Debug's allocator captures a stack trace per allocation and costs roughly 10× here.

That signature matches the family already documented on this issue: a binding that exists stops resolving under concurrent no-GIL execution. dw2-marklistset-storm.js fails the same way on threads, and the baseline note for it records that instrumenting the four ReferenceError sites identified the tree-walker path with the binding absent rather than in TDZ, after main-thread GC pressure between the declaration and the use. cve/mc-tear-typedarray-detach-grow-shrink.js was linked to the same root cause because its Thread global resolved at startup and stopped resolving later.

The value here is the cost. The existing reproductions take ~1.4e6 ms and ~894,000 ms; this one takes 6 s. A defect that previously needed a 20-minute run to observe once is now observable in the time it takes to build. If the reduction that "does not reproduce" is still the blocker for diagnosing this, that reduction can now be iterated against a 6-second witness instead.

Also relevant to the inventory this issue owns: the recorded outcomes for four of the five remaining #429-blocked cases were Debug measurements and are wrong. Re-measured in ReleaseSafe, three of them pass outright — checktraps-invalidation.js 44,317 ms, cve/mc-jit-stale-base-grow-oob.js 26,808 ms, jit/ic-publish-reset-loops.js 2,703 ms — where all three were previously "no result". They remain unpromoted on tier evidence, not on liveness; details on #429. The execution inventories in docs/.data/ were produced in Debug and their durations should be read as such.

9 days ago

Confirmed and landed in f838364a. The ReferenceError: b is not a coincidental name — it is this defect.

const [a, b] = readPair(o);
const aOwner = inSet(a, ALPHA_BASE), aForeign = inSet(a, FOREIGN_ALPHA);
const bOwner = inSet(b, BETA_BASE),  bForeign = inSet(b, FOREIGN_BETA);   // <- throws here

A block-scoped const, destructured and initialised, then absent two statements later inside a reader loop under concurrent no-GIL execution. That is the shape recorded for dw2-marklistset-storm.js — the binding absent, not in TDZ, which the earlier four-site instrumentation identified on the tree-walker path.

cve/mc-aint-poll-resume-stale-elided.js now carries a nogil-binding-resolution dependency owned by this issue, listed alongside its existing JIT dependencies rather than replacing them: trap polling and the shell hooks still gate promotion once the binding defect is fixed and the case can reach its poll/resume assertions. The inventory attributes it to #430 as well as #429, so the tail is owned where the work is.

The reason this matters is iteration cost. The two existing reproductions need ~1.4e6 ms and ~894,000 ms; this one fails in 6,009 ms. The standing blocker on diagnosing this has been that the reduction does not reproduce and the full witnesses take twenty minutes to show the fault once. There is now a witness that shows it in the time it takes to build:

zig build threads-test-bin -Doptimize=ReleaseSafe
./zig-out/bin/threads-test parallel-js one cve/mc-aint-poll-resume-stale-elided.js

Three signatures now agree on one mechanism — threads in dw2-marklistset-storm.js, Thread in cve/mc-tear-typedarray-detach-grow-shrink.js, and b here — and two of the three are destructured or block-scoped bindings observed absent after GC pressure. Worth pointing the next instrumentation pass at the tree-walker's block-scope binding publication rather than at TDZ handling.

9 days ago

Two structural leads from reading the root path with the 6-second witness in hand. Neither is confirmed as the defect — I am recording them because they are precise and cheap to check, not asserting them.

1. traceEnv does not recurse into the parent scope.

pub fn traceEnv(e: *Environment, v: anytype) void {
    ...                                  // traces THIS scope's vars/disposables/aliases
    if (e.parent) |p| markManaged(v, p); // marks the parent CELL only
}

traceInterpreterRoots calls traceEnv(machine.env, v) once, on the innermost scope. Every outer scope is reached only through markManaged, which for an Environment is:

if (Cell == interp.Environment) {
    if (cell.gc_managed) v.mark(cell);
    return;
}

So an outer scope is traced only if the collector later scans the marked cell, and only if gc_managed is true. For a non-gc_managed Environment this call is a silent no-op, and nothing in that scope is reached from the interpreter-root path at all.

2. gc_managed is decided by a global at creation time.

.gc_managed = gc_mod.allocationsAreManaged(),

It is a snapshot of process/thread-wide allocation state at initEnvironment, not a property of the scope. A scope created on a worker at a moment when that predicate reads false is permanently invisible to markManaged, regardless of what the heap is doing later. That is exactly the shape needed to explain a binding being absent rather than in TDZ, only at scale, only under concurrent no-GIL execution, and only with GC pressure between the declaration and the use — and it explains why a small reduction passes: the reduction never creates the scope in the losing window.

Why this is worth checking first. It is the same family as the already-fixed termination-keepalive defect (4d4dab9), where a thread simply failed to republish roots and live state was swept. The check is cheap: assert gc_managed on every Environment reachable from a registered interpreter at publication time, or log gc_managed for the scope that owns b in the witness.

Reproduce in 6 s:

zig build threads-test-bin -Doptimize=ReleaseSafe
./zig-out/bin/threads-test parallel-js one cve/mc-aint-poll-resume-stale-elided.js
# FAIL ... ReferenceError: b   (~6,009 ms)

I am stopping here rather than changing GC rooting on a hypothesis. A wrong change to which scopes are traced turns a visible ReferenceError into a silent use-after-free, which is strictly worse than the bug being fixed.

9 days ago

Tested the traceEnv hypothesis from my previous comment. It is refuted. Recording the negative result so nobody spends the same afternoon on it.

I changed traceEnv to walk the scope chain directly — tracing each ancestor's bindings in place and only handing a gc_managed ancestor back to the mark queue — instead of relying on markManaged(parent), which is a no-op for a scope whose gc_managed is false. Rebuilt ReleaseSafe and ran the witness:

before:  FAIL cve/mc-aint-poll-resume-stale-elided.js (6,009 ms) ReferenceError: b
after:   FAIL cve/mc-aint-poll-resume-stale-elided.js (8,754 ms) ReferenceError: a

Still fails. The change is reverted — I am not landing an unmotivated change to GC root computation, in the safe direction or not, without a test that fails without it.

Two things learned that are worth more than the hypothesis was:

1. The failing binding moved from b to a between runs. Both are elements of the same const [a, b] = readPair(o). Whatever drops the binding is nondeterministic within a single destructuring statement, which argues against a systematic scope-construction or scope-tracing fault and for a race on the binding write or on the map itself. A tracing gap would not pick a different element each run.

2. The markManaged chain break is real as code, but is not this defect. For the record, since it looked so promising: traceEnv reaches ancestors only via markManaged, which does if (cell.gc_managed) v.mark(cell), and gc_managed is a snapshot of the thread-local active_heap taken at initEnvironment. A scope created on a thread with no heap installed is unmarkable, and since the mark queue is what continues the walk, the chain stops there — that scope's bindings and every scope above it go untraced. Whether such a scope can actually exist in a GC-enabled context is unverified; if it can, it is a latent hazard deserving its own test rather than a drive-by fix. It is not what breaks this witness.

Next probe I would run, given the nondeterminism in (1): instrument the def_lex/destructuring binding write and the lookupIdent miss to log which scope object each side saw, and check whether the reader is walking a different Environment instance than the writer populated — i.e. an env pointer swap around the concurrent-GC safepoint — rather than a binding vanishing from one map.

9 days ago

Instrumented the failure. This is not a lost binding — it is a swept-and-recycled scope. That changes the diagnosis materially.

I added a temporary dump of the scope chain at the identifier-miss throw and ran the 6-second witness. Output (diagnostic reverted, not landed):

[#430] ident miss 'aOwner' interp=Interpreter@17506bd80 env=Environment@7090cac820
[#430]   [0] env=Environment@7090cac820 fn_scope=false gc_managed=true  vars=1   has=false
[#430]   [1] env=Environment@700c503c20 fn_scope=false gc_managed=true  vars=0   has=false
[#430]   [2] env=Environment@700c503020 fn_scope=false gc_managed=true  vars=0   has=false
[#430]   [3] env=Environment@1083c8020  fn_scope=false gc_managed=true  vars=0   has=false
[#430]   [4] env=Environment@1083c7820  fn_scope=true  gc_managed=true  vars=0   has=false
[#430]   [5] env=Environment@1083bf020  fn_scope=false gc_managed=true  vars=1   has=false
[#430]   [6] env=Environment@103d3f088  fn_scope=true  gc_managed=false vars=189 has=false
FAIL cve/mc-aint-poll-resume-stale-elided.js (8,193 ms) ReferenceError: aOwner

The innermost scope has vars=1. At that point in the reader loop —

const [a, b] = readPair(o);
const aOwner = inSet(a, ALPHA_BASE), aForeign = inSet(a, FOREIGN_ALPHA);
const bOwner = inSet(b, BETA_BASE),  bForeign = inSet(b, FOREIGN_BETA);
if (!(aOwner || aForeign) || ...)      // <- throws

— that scope must hold at least a, b, aOwner, aForeign. It holds one. The enclosing scopes hold zero. These are not scopes that lost a key; they are scopes that contain someone else's contents.

evalBlockScope allocates each block scope with gc_mod.allocEnv, a GC cell, and every scope in the failing chain reports gc_managed=true. A live pointer to a gc_managed cell whose contents are those of a newer, barely-populated scope is the signature of sweep and reuse: the reader's block scope was collected while still current, its cell was recycled for a fresh Environment, and the reader is now walking the recycled occupant.

That explains every property this defect has shown:

  • absent, never TDZ — the key was never in this map, because it is a different scope;
  • a different identifier each run (b, then a, then aOwner) — whichever read lands after the sweep;
  • needs GC pressure between declaration and use — the sweep must fall inside the inSet(...) calls;
  • needs scale — the collection has to hit that window;
  • reduction passes — it never opens the window.

So the fault is a root-publication gap: a worker thread's current scope chain is not kept alive across a concurrent collection, which is the same family as the termination-keepalive defect fixed in 4d4dab9 (a thread failing to republish roots and live state being swept).

This also retires my previous hypothesis for the right reason. I had suspected the markManaged/gc_managed chain break; every scope in the failing chain is gc_managed=true, so that break is not what is happening here. traceInterpreterRoots does mark and trace machine.env, so the question is now narrower and better posed: is this worker's interpreter in the set being traced for that collection generation at all — i.e. did it publish roots for the generation that swept it, or was it running unpublished?

Next step, for whoever picks this up: log machine.gc_published_gen against gc_par_request at the identifier miss, and confirm whether the reader thread had published for the generation that ran the sweep. If it had not, the fix is at the publication/park boundary rather than anywhere in traceEnv.

The witness remains 6 seconds:

zig build threads-test-bin -Doptimize=ReleaseSafe
./zig-out/bin/threads-test parallel-js one cve/mc-aint-poll-resume-stale-elided.js
9 days ago

Narrowing the swept-scope diagnosis one more step, and ruling out the obvious cause.

Allocate-grey is already correct. evalBlockScope → gc_mod.allocEnv → allocCell → Heap.create, and create ends with:

return self.publishCellLocked(T, kind, h, self.marking.load(.acquire), true);

A cell created while marking is in progress is born marked and queued for tracing. So "new scopes allocated during a concurrent mark are unprotected" is not the mechanism — that path is handled.

The window that remains is the ordering between root publication and marking. A mutator publishes once per request generation:

if (machine.gc_published_gen.load(.acquire) == req) return;
gc_mod.publishInterpreterRoots(machine);
machine.gc_published_gen.store(req, .release);

Publication records the scope chain as it exists at that instant. If the mutator then resumes and evalBlockScope allocates a fresh child scope, assigning machine.env = child, that child is:

  • not in the published root set, which was snapshotted before it existed, and
  • not grey, if marking was still false when it was created.

If marking is set only after the request is acknowledged, every scope a mutator creates in that gap is invisible to the collector while being the mutator's live env. It is then swept and its cell recycled — which is exactly what the instrumented dump shows: a live gc_managed=true pointer whose contents are a newer scope's (vars=1, ancestors vars=0).

That predicts every observed property, including why it needs both scale and GC pressure: the window is only open between a publication and the marker actually starting, and the reader loop has to allocate a block scope inside it.

The check is a one-line ordering question: does marking become true before or after gc_par_request is raised and peers publish? If after, the fix is to set marking first — so anything a mutator allocates from the moment it can observe the request is grey — rather than anything in traceEnv or the publication walk.

Two hypotheses now retired with reasons, for anyone picking this up:

  1. markManaged/gc_managed chain break — refuted: every scope in the failing chain is gc_managed=true, and patching traceEnv to walk the chain directly did not fix it (the failure just moved from b to a).
  2. New cells unprotected during marking — refuted: Heap.create allocates grey off self.marking.

Witness unchanged, still ~6 s:

zig build threads-test-bin -Doptimize=ReleaseSafe
./zig-out/bin/threads-test parallel-js one cve/mc-aint-poll-resume-stale-elided.js
9 days ago

Exact locations for the ordering check, so it is a five-minute read rather than a hunt.

The request is raised in context.zig at the end of the generation allocator:

self.gc_par_request.store(gen, .release);   // context.zig:6303

marking is armed inside zig-gc's collector (heap.zig:1551, :1779, :1912), reached from driveParallelCollection, whose own comment describes the sequence as:

BEGIN: whiten + arm the barrier under alloc_lock, then trace the collector-safe roots — realm roots (lock-guarded), this collector's own interpreter + native stack, and parked peers' frozen stacks. Running peers self-publish via the handshake.

So the question is precisely: is gc_par_request.store(gen) (6303) ordered before or after the whiten/arm step that sets marking?

If the request is visible to peers before marking is armed, a running peer can:

  1. observe the request at a safepoint and publish its scope chain,
  2. resume and have evalBlockScope allocate a child scope — not in the just-taken snapshot, and not grey because marking is still false,
  3. set machine.env = child,

and that child is the mutator's live scope while being invisible to the collector. Sweep, recycle, and the reader walks the recycled occupant — the vars=1 / ancestors-vars=0 dump.

If the ordering is already arm-then-request, this window is closed and the fault is elsewhere, and that result is worth just as much as a fix: it would eliminate the last hypothesis standing and point at the handshake's publish path instead.

9 days ago

Answered the ordering question from my last comment: the window is closed. Third hypothesis refuted.

In driveParallelCollection, h.beginConcurrentMarkParallel() (arming marking) runs at context.zig:6322, before the round loop that opens publication generations at :6342:

h.beginConcurrentMarkParallel();          // context.zig:6322  — marking armed
...
while (round < max_rounds) : (round += 1) {
    const gen = self.openParallelPublicationGeneration();   // :6342 — request raised

So marking is true before any peer can observe a request. Every scope a peer allocates from that moment is allocate-grey via Heap.create, and the publish-then-allocate window I described cannot open. Good news for the design; the hypothesis is dead.

Three hypotheses now eliminated with code evidence:

  1. markManaged/gc_managed chain break — every scope in the failing chain is gc_managed=true, and patching traceEnv to walk the chain directly only moved the failure from b to a.
  2. New cells unprotected during marking — Heap.create allocates grey off self.marking.
  3. Publish-before-arm window — refuted above by the call ordering.

Where the evidence points now: the abort path. In the same loop, when peers fail to publish inside the wait budget:

self.gc_scan_native_stack = true;
h.abortConcurrentMarkParallel();
self.gc_scan_native_stack = false;
_ = self.gc_par_aborts.fetchAdd(1, .monotonic);
_ = self.gc_par_publication_timeout_aborts.fetchAdd(1, .monotonic);

That fits the defect's profile better than anything eliminated above. Aborts are provoked by exactly the conditions this reproduces under — load, oversubscription, slow peers — which explains the scale dependence, the nondeterminism about which identifier dies, and why a small reduction never sees it. The question to answer is whether an aborted mark can leave grey/marked state that a later cycle's sweep does not honour, so a cell allocated grey during the aborted mark is reclaimed by the next sweep while still live.

Concrete next probe: correlate the failure against gc_par_aborts and gc_par_publication_timeout_aborts. Both counters already exist. If the miss only ever occurs in runs where an abort fired, that is the mechanism; if it happens with zero aborts, this one dies too and the remaining surface is the publish path itself (publishInterpreterRoots / RootPublishVisitor consumption).

Witness unchanged, ~6 s:

zig build threads-test-bin -Doptimize=ReleaseSafe
./zig-out/bin/threads-test parallel-js one cve/mc-aint-poll-resume-stale-elided.js
9 days ago

Update from main@0bd8e67: the cheap no-GIL binding-resolution attribution for cve/mc-aint-poll-resume-stale-elided.js is now removed from the checked-in PR-249 inventory.\n\nCurrent evidence:\n- The declared no-GIL mode is now verified by the unpromoted scan itself.\n- The focused ReleaseSafe no-GIL run passes in the current tree and reports cooperative GC activity, but no optimizing-tier publication evidence.\n- Therefore the case remains blocked for #429/#143 promotion, but no longer on #430's stale absent-binding diagnosis.\n\nThe release compatibility checker now requires the declared no-GIL probe in docs/.data/pr249-unpromoted-scan-2026-07-28.json, so the old ReferenceError-style blocker cannot silently return without failing the release evidence.\n\nVerification included full Test262: VALID 48,506/48,506; NEGATIVE 4,669/4,669; skipped 0.\n\nCases flipped: 0

9 days ago

bf590377 refreshes #430 machine-readable evidence after promoting two optimizer cases.

Current exact inventory: 259 executable = 250 promoted + 3 blocked + 6 terminal dispositions; 5 helper/preload files remain outside the executable denominator. The regenerated unpromoted scan covers all 9 blocked/terminal executables with unpromoted_scan_disposition_drift: 0, and the inventory self-tests remain 7/7.

The previously stale mc-aint declared no-GIL blocker is removed rather than re-characterized: current full ReleaseSafe and bounded TSan runs both pass with real optimizer publication/invalidation/reclamation evidence, so it is now enforced in the no-GIL allowlist. The terminal w16-c1-prevent-collection.js probe is bounded through a pinned runner-only 2 × 18 × 80 churn shape that preserves two mutators, the explicit-GC election lane, and deterministic reference rerun; the vendored source is unchanged and the unavailable snapshot premise is confirmed in 2,768 ms instead of spending over six minutes in Debug allocator tracing.

zig build release-compatibility-check threads-reference-audit threads-reference-probes, docs link check, and docs build all pass.

9 days ago

Pushed 279fbe6a (fix(atomics): grow arrays on indexed property stores) to main.

The indexed-property transaction added for the MC-PRIM Missing/define race correctly serialized descriptor publication, but its direct-own fallback wrote a missing Array index as a shape slot and left length unchanged. Property-mode Atomics.store now performs the Array [[DefineOwnProperty]] storage/length update under that same indexed transaction, without reintroducing inherited-setter interception.

Validation:

  • focused property-Atomics unit test: pass
  • atomics/property-load-store.js: serialized Debug pass
  • same case no-GIL: Debug, ReleaseSafe, and TSan pass
  • Test262 flips: 0; expectations unchanged
9 days ago

Terminal publication is on main in f6f8ef1b (docs(pr249): publish terminal execution evidence).

The checked artifact binds all 259 executables to the pinned source checksum, exact required mode, result, duration, measured commit, and owner. ReleaseSafe totals are 246/246 serialized and 253/253 no-GIL with zero failures/skips; all six terminal dispositions match in both modes. The CI witness emits a fresh schema-v2 inventory and verifies the complete artifact on every push.

The release matrix marks shell_and_reference_hooks green (9/12 overall), and the generated README no longer lists a PR-249 blocker.

9 days ago

e951c66c repairs the bounded execution-inventory witness against the current Zig 0.17-dev toolchain.

Newer snapshots lowercase OptimizeMode tag names, which changed emitted schema-v2 build_mode from the published Debug/ReleaseSafe vocabulary to debug. The runner now canonicalizes compiler spellings only at the versioned artifact boundary; the schema assertion remains strict. threads-test-bin compiles cleanly, and this flips 0 test262 cases.

9 days ago

Follow-up CI evidence for e951c66c: the actual threads-execution-inventory-witness Required Check completed successfully against the current Zig dev toolchain.

9 days ago

The ReleaseSafe no-GIL regression for cve/mc-life-wasm-grow-relocate.js is fixed by 235f6124. The checked-in baseline remains unchanged: this was a real intermittent detached-buffer failure, not a disposition/timing exception. Final evidence is exact ReleaseSafe 1/1, exact suppression-free TSan 1/1, and full units 1,479/1,479; an amplified ReleaseSafe loop passed 30/30 after the pre-fix build failed at iteration 16.

Sign in to comment on this issue.