Skip to content

ci(threadfuzz): parameterize nightly depth #966

ci(threadfuzz): parameterize nightly depth

ci(threadfuzz): parameterize nightly depth #966

Workflow file for this run

name: CI
# Runs the docs/threads/testing.md "Required Checks" — crucially the
# ThreadSanitizer gates that surface the no-GIL races blocking the execution-path
# GIL drop. The toolchain comes from pantry's own action (`zig@0.17.0-dev`),
# which resolves the current 0.17.0-dev master (≥0.17.0-dev.956) from
# ziglang.org/download/index.json — so the pin tracks upstream automatically
# instead of going stale. At ≥dev.956 TSan now builds on macOS too; CI keeps
# running it on Linux as the shared gate. (The older dev.131 pin could not build
# TSan on darwin — bundled libcxx failed with `undeclared identifier 'INFINITY'`
# — which is why this was Linux-only before.)
#
# Triggers: pull_request + push-to-main + manual dispatch + nightly schedule. The
# no-GIL TSan corpus gate and test262-parallel leg now run on
# every PR and main push (production gating), not just nightly.
on:
pull_request:
push:
branches: [main] # this project lands directly on main, so gate main pushes too
workflow_dispatch:
inputs:
nightly_tsan_default_iters:
description: 'Manual nightly-threadfuzz-tsan default-profile iterations'
required: false
default: '120'
nightly_tsan_midgc_iters:
description: 'Manual nightly-threadfuzz-tsan mid-GC profile iterations'
required: false
default: '5'
nightly_tsan_lifecycle_iters:
description: 'Manual nightly-threadfuzz-tsan lifecycle profile iterations'
required: false
default: '5'
schedule:
- cron: '0 7 * * *' # nightly continuous validation (incl. the race-hunt probe)
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Each Required Check runs as its own matrix leg (fail-fast: false), so an
# unrelated failure in one (e.g. the full unit suite) does not mask the
# threading / ThreadSanitizer gates.
gate:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- name: unit (shard 0/4)
cmd: zig build test -Dunit-shard-index=0 -Dunit-shard-count=4
- name: unit (shard 1/4)
cmd: zig build test -Dunit-shard-index=1 -Dunit-shard-count=4
- name: unit (shard 2/4)
cmd: zig build test -Dunit-shard-index=2 -Dunit-shard-count=4
- name: unit (shard 3/4)
cmd: zig build test -Dunit-shard-index=3 -Dunit-shard-count=4
- name: threads-gil (shard 0/4)
cmd: zig build threads-test -Dthreads-shard-index=0 -Dthreads-shard-count=4
- name: threads-gil (shard 1/4)
cmd: zig build threads-test -Dthreads-shard-index=1 -Dthreads-shard-count=4
- name: threads-gil (shard 2/4)
cmd: zig build threads-test -Dthreads-shard-index=2 -Dthreads-shard-count=4
- name: threads-gil (shard 3/4)
cmd: zig build threads-test -Dthreads-shard-index=3 -Dthreads-shard-count=4
- name: threads-nogil-witness
cmd: zig build threads-test -Dthreads-parallel-js=true -Dthreads-case=sync/condition-wait-notify.js
# Reference-only corpus hygiene: fail if a non-promoted PR-249 file
# loses its blocker classification or if one of the nearest
# promotion probes starts passing or changes failure shape without the
# allowlist/docs being updated in the same change.
- name: threads-reference-audit
cmd: zig build threads-reference-audit && python3 tools/threads-reference-audit.py --run-probes --expect-current-blockers --probe-timeout 60
- name: tsan-unit (shard 0/4)
cmd: zig build test -Dtsan=true -Dunit-shard-index=0 -Dunit-shard-count=4
- name: tsan-unit (shard 1/4)
cmd: zig build test -Dtsan=true -Dunit-shard-index=1 -Dunit-shard-count=4
- name: tsan-unit (shard 2/4)
cmd: zig build test -Dtsan=true -Dunit-shard-index=2 -Dunit-shard-count=4
- name: tsan-unit (shard 3/4)
cmd: zig build test -Dtsan=true -Dunit-shard-index=3 -Dunit-shard-count=4
- name: tsan-parallel-js
cmd: zig build test -Dtsan=true -Dtest-filter=parallel_js
# Concurrent-JS fuzzer: random programs sharing objects/arrays/closures/
# typed-arrays across JS Threads in GIL-free parallel contexts. Seeded,
# so it is a deterministic gate (no flake): a throw/UAF fails the leg.
# It found the call-dispatch + frame-slot-rooting bugs (commit b462c90).
- name: threadfuzz
cmd: zig build threadfuzz -Dfuzz-iters=400
# A smaller batch under ThreadSanitizer surfaces any unsynchronized
# engine access the seeded programs exercise (TSan is ~10× slower).
- name: tsan-threadfuzz
cmd: zig build threadfuzz -Dtsan=true -Dfuzz-iters=60
# Specialized TSan smoke for the mid-script-GC profile. The larger
# non-TSan leg owns breadth; this leg proves the hidden root/parked
# waiter/microtask paths are also free of unsuppressed engine races.
# TSan makes this 45-case profile exceed the default 120s per-seed
# watchdog on loaded runners, so keep the watchdog local to this leg.
- name: tsan-threadfuzz-midgc
cmd: THREADFUZZ_SEED_TIMEOUT_MS=300000 zig build threadfuzz -Dtsan=true -Dfuzz-midgc=true -Dfuzz-iters=2
# Specialized TSan smoke for lifecycle teardown. It keeps the Worker,
# asyncJoin, waitAsync, cleanup, and termination combinations under
# sanitizer instead of relying only on the default-profile TSan leg.
- name: tsan-threadfuzz-lifecycle
cmd: zig build threadfuzz -Dtsan=true -Dfuzz-lifecycle=true -Dfuzz-iters=2
# Stress amplification: the high-contention profile (6–14 threads,
# 5000-iter loops) drives rare interleavings far harder — a throw, UAF,
# hang, or torn read fails the leg. Run without TSan (its ~10× slowdown
# makes amplified contention impractical; the default-profile
# `tsan-threadfuzz` above covers race detection).
- name: threadfuzz-amplified
cmd: zig build threadfuzz -Dfuzz-amplify=true -Dfuzz-iters=30
# Breadth profile: cheaper than amplified, but deliberately covers
# caught exceptions/finally, nested thread lifecycle, asyncJoin,
# property wait/waitAsync, Condition wakeups, Thread.restrict, and
# FinalizationRegistry cleanup under GC-backed parallel contexts.
- name: threadfuzz-broad
cmd: zig build threadfuzz -Dfuzz-broad=true -Dfuzz-iters=80
# Mid-script GC wait-pump profile: blocks peers in property
# Atomics.wait, Condition.wait, and contended Lock acquisition while
# allocation pressure drives the experimental parallel collector. It
# also keeps ThreadLocal-only roots and completed-but-unjoined Thread
# results live across that pressure window.
# Each seed must complete exactly and finish at least one parallel sweep.
- name: threadfuzz-midgc
cmd: zig build threadfuzz -Dfuzz-midgc=true -Dfuzz-iters=20
# Lifecycle profile: deterministic termination storms (main throws
# with parked/unjoined Threads), cross-realm Worker + shared-realm
# Thread overlap on one SharedArrayBuffer with an exact counter oracle,
# Worker/thread/finalization scheduling on one retained SAB,
# Worker handler exception recovery, Thread.restrict lifecycle
# isolation, deterministic asyncHold barging, exact Worker FIFO
# close/terminate drain/drop ordering, and cross-thread
# finalization cleanup count/sum oracles including parked-waiter
# delivery and unregister-token suppression.
- name: threadfuzz-lifecycle
cmd: zig build threadfuzz -Dfuzz-lifecycle=true -Dfuzz-iters=60
# Sanitizer combo: ReleaseSafe keeps Zig's safety checks (UB, bounds,
# overflow, unreachable) on but under the optimizer, catching codegen-
# dependent faults the Debug build hides — a different bug class than TSan.
- name: releasesafe-threadfuzz
cmd: zig build threadfuzz -Doptimize=ReleaseSafe -Dfuzz-iters=400
# Deterministic-correctness oracle: each program's exact result is
# predicted and checked, so a wrong VALUE (lost/torn atomic update) fails
# the leg — a bug class the no-throw/no-race gates above can't see.
- name: threadfuzz-verify
cmd: zig build threadfuzz -Dfuzz-verify=true -Dfuzz-iters=300
steps:
# build.zig.zon uses local-path deps (`../zig-regex`, `../zig-gc`), so the
# sibling repos are checked out next to this one.
- uses: actions/checkout@v6
with:
path: zig-js
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-regex
path: zig-regex
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-gc
path: zig-gc
- uses: pantry-pm/pantry/packages/action@main
with:
packages: zig@0.17.0-dev
- name: ${{ matrix.name }}
working-directory: zig-js
run: ${{ matrix.cmd }}
# Whole-corpus no-GIL ThreadSanitizer sweep — the issue #1 GIL-drop gate
# (drive the no-GIL corpus to zero unsuppressed races, then the GIL can flip
# off as the default). Runs EVERY allowlist case under `parallel_js` + TSan,
# each in its OWN process: per-case isolation sidesteps the cumulative-load OOM
# of a single all-in-one-process TSan run (TSan shadow memory grows across the
# whole allowlist). Sharded 4 ways so the ~5h serial sweep fits the
# runner time limit. Both *named* no-GIL frontier races are now closed — the
# api/blocking-gate settlement race (re-derived its GIL-specific assertion) and
# the semantics-batch StringHashMap-grow panic (accessor-snapshot in seal/freeze
# + locked getAccessor in property-mode Atomics) — so this sweep hunts any
# remaining *un-named* residual. continue-on-error while still being driven to
# zero: a TSan report names the next fix.
tsan-nogil-corpus:
name: tsan-nogil-corpus (shard ${{ matrix.shard }}/4)
# Gates every PR + main push (production no-GIL safety), plus nightly. Now at
# zero engine-state races, so it hard-blocks: a TSan report fails the run.
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
steps:
- uses: actions/checkout@v6
with:
path: zig-js
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-regex
path: zig-regex
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-gc
path: zig-gc
- uses: pantry-pm/pantry/packages/action@main
with:
packages: zig@0.17.0-dev
- name: Build the TSan-instrumented threads-test binary
working-directory: zig-js
run: zig build threads-test-bin -Dtsan=true
- name: Sweep shard under parallel_js + TSan
working-directory: zig-js
run: |
set -uo pipefail
# Halt each case at its first race so the report is the case-log tail.
# The engine currently keeps even JS program-byte paths TSan-clean via
# locks/atomics, so the production corpus runs with no suppressions.
export TSAN_OPTIONS="halt_on_error=1"
BIN=./zig-out/bin/threads-test
# This shard's slice of the allowlist (NR % 4 == shard), each case run
# in its own process. `timeout` caps a heavy stress case (the GC/CAS
# storms run minutes under TSan's ~10x slowdown) so a shard can't hang.
# `list` prints to stderr (the harness's only output stream), so 2>&1.
"$BIN" list 2>&1 | awk -v S=${{ matrix.shard }} 'NR % 4 == S' > shard.txt
echo "shard ${{ matrix.shard }}: $(wc -l < shard.txt) cases"
races=0; fails=0; passes=0; n=0
while IFS= read -r c; do
[ -z "$c" ] && continue
n=$((n+1))
if timeout 600 "$BIN" parallel-js one "$c" > case.log 2>&1; then
passes=$((passes+1))
elif grep -q "ThreadSanitizer:" case.log; then
races=$((races+1)); echo "::error::TSan race in $c"; echo "----- $c -----"; cat case.log
else
fails=$((fails+1)); echo "::warning::non-race failure/timeout in $c"; tail -8 case.log
fi
done < shard.txt
echo "=== shard ${{ matrix.shard }} done: pass=$passes fail=$fails race=$races (n=$n) ==="
# The gate is races==0; surface a race as a leg failure (non-race
# functional fails/timeouts under TSan slowness do not fail the gate).
[ "$races" -eq 0 ]
nightly-threadfuzz-tsan:
name: nightly-threadfuzz-tsan (${{ matrix.name }})
# Higher-iteration sanitizer expansion for issue #1's remaining roadmap.
# PRs/main pushes run the fast TSan smoke legs above; nightly/manual runs
# spend more time on the same default, mid-GC, and lifecycle profiles.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: default
profile: default
- name: midgc
profile: midgc
- name: lifecycle
profile: lifecycle
steps:
- uses: actions/checkout@v6
with:
path: zig-js
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-regex
path: zig-regex
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-gc
path: zig-gc
- uses: pantry-pm/pantry/packages/action@main
with:
packages: zig@0.17.0-dev
- name: ${{ matrix.name }}
working-directory: zig-js
env:
DEFAULT_ITERS: ${{ github.event.inputs.nightly_tsan_default_iters || '120' }}
MIDGC_ITERS: ${{ github.event.inputs.nightly_tsan_midgc_iters || '5' }}
LIFECYCLE_ITERS: ${{ github.event.inputs.nightly_tsan_lifecycle_iters || '5' }}
run: |
case "${{ matrix.profile }}" in
default)
zig build threadfuzz -Dtsan=true -Dfuzz-iters="${DEFAULT_ITERS}"
;;
midgc)
THREADFUZZ_SEED_TIMEOUT_MS=300000 zig build threadfuzz -Dtsan=true -Dfuzz-midgc=true -Dfuzz-iters="${MIDGC_ITERS}"
;;
lifecycle)
zig build threadfuzz -Dtsan=true -Dfuzz-lifecycle=true -Dfuzz-iters="${LIFECYCLE_ITERS}"
;;
esac
# Runs a curated test262 cross-section in GIL-free parallel contexts and asserts
# it introduces no NEW failures vs the baseline (arena) engine — i.e. the
# parallel-mode locked paths + GC-managed allocator are correct across a broad
# slice of the language, not just the threads corpus. (The full corpus is too
# slow per-context under GC for CI; this is the representative gate.)
test262-parallel:
name: test262-parallel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
path: zig-js
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-regex
path: zig-regex
- uses: actions/checkout@v6
with:
repository: zig-utils/zig-gc
path: zig-gc
- uses: pantry-pm/pantry/packages/action@main
with:
packages: zig@0.17.0-dev
- name: Parallel test262 introduces no new failures
working-directory: zig-js
run: |
set -uo pipefail
SUBS="test/language/types/boolean test/language/types/number test/language/statements/class/subclass-builtins test/built-ins/Object/keys test/built-ins/Map/prototype/set"
zig build test262-bin >/dev/null
BASE=./zig-out/bin/test262
fails_base=0
for s in $SUBS; do
f=$("$BASE" --diag "$s" 2>&1 | grep -oE '[0-9]+ fail' | grep -oE '[0-9]+' | head -1 || echo 0)
fails_base=$((fails_base + ${f:-0}))
done
echo "baseline (arena) total fails: $fails_base"
zig build test262-bin -Dtest262-parallel-js=true >/dev/null
PAR=./zig-out/bin/test262
fails_par=0
for s in $SUBS; do
line=$("$PAR" --diag "$s" 2>&1 | tail -1); echo "$line"
f=$(echo "$line" | grep -oE '[0-9]+ fail' | grep -oE '[0-9]+' | head -1 || echo 0)
fails_par=$((fails_par + ${f:-0}))
done
echo "parallel total fails: $fails_par (baseline: $fails_base)"
if [ "$fails_par" -gt "$fails_base" ]; then
echo "::error::parallel mode introduced $((fails_par - fails_base)) new test262 failures"; exit 1
fi
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
path: zig-js
- uses: oven-sh/setup-bun@v2
- name: docs:build
working-directory: zig-js
run: |
bun install
bun run docs:build