Skip to content

parser: bound recursion depth so deeply nested source can't crash #592

parser: bound recursion depth so deeply nested source can't crash

parser: bound recursion depth so deeply nested source can't crash #592

Workflow file for this run

name: CI
on:
push:
branches: [main]
# Skip CI when only docs / scoreboard / git plumbing changed.
# `paths-ignore` is a union — a commit that touches *both* a
# `.md` and a `.zig` still triggers (only suppressed when
# *every* changed path matches). README / docs / scoreboard /
# pass-cache touch nothing the engine builds against.
paths-ignore:
- '**/*.md'
- 'docs/**'
- 'LICENSE'
- 'test262-results.md'
- '.test262-pass-cache.txt'
- '.gitignore'
- 'gh-pages/**'
pull_request:
paths-ignore:
- '**/*.md'
- 'docs/**'
- 'LICENSE'
- 'test262-results.md'
- '.test262-pass-cache.txt'
- '.gitignore'
- 'gh-pages/**'
permissions:
contents: read
# Cancel an older in-progress run when a newer push lands. Keeps
# the queue from piling up when the user pushes a flurry of fixes.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Opt the JavaScript actions (github/codeql-action, step-security/
# harden-runner, …) into Node 24 now. GitHub forces this on
# 2026-06-16; until upstream actions ship `using: node24` we set the
# env var here to clear the deprecation warning. xyzzylabs/setup-zig
# is already node24-native and doesn't need this — but the var is
# harmless on actions that have migrated. Drop the env block once
# every action used here advertises node24 natively.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
# `xyzzylabs/setup-zig` (Cynic-org fork of mlugg/setup-zig, rewritten
# in TypeScript on Node 24) resolves the Zig version from
# `build.zig.zon`'s `minimum_zig_version` when `version:` is omitted
# — so `build.zig.zon` is the single source of truth. It also caches
# `.zig-cache/` by default; for matrix jobs we set `cache-key:` per
# matrix entry so each entry gets its own cache (the action can't
# auto-distinguish jobs in a matrix). SHA-pinned to v1.0.0;
# dependabot rotates the SHA on bump.
jobs:
build-and-test:
name: Build & unit tests (${{ matrix.os }})
timeout-minutes: 45
strategy:
# Surface every OS's failure, don't short-circuit on first.
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Audit egress (advisory)
# `step-security/harden-runner` records every outbound
# network call from the runner. In `audit` mode it just
# logs to the run; flip to `block` after a few weeks of
# baseline data, with the observed hosts in `allowed-endpoints`.
# Defense in depth against a compromised transitive
# dependency exfiltrating source or credentials.
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
# No submodules: `zig build` / `zig build test` never read
# the test262 corpus (the only submodule). The inline
# `tools/test262/*` tests are pure skip-rule / frontmatter
# logic. Only the `test262` job below needs the submodule.
uses: actions/checkout@v6
- name: Set up Zig
uses: xyzzylabs/setup-zig@58626e899b126520afe59a875fb2848cf0774c59 # v1.0.0
with:
cache-key: ${{ matrix.os }}
- name: Show Zig version
run: zig version
- name: Build
run: zig build
- name: Unit tests
# `test-fast` builds the unit-test binaries ReleaseSafe rather
# than Debug. Most tests eval JS through the engine, where Debug
# is 5-10× slower, so the full Debug suite is run-bound (can
# exceed ten minutes); ReleaseSafe finishes in ~3 with the same
# safety checks, GC verifiers, 0xaa free-poison, and
# testing.allocator leak detection — a faithful gate. See
# build.zig + AGENTS.md "test vs test-fast". `zig build test`
# (Debug) stays the local stack-trace-on-panic path.
#
# Linux additionally runs the exhaustive Unicode invariants
# (whole-range case conversion in `src/unicode/case_conv.zig`,
# NormalizationTest.txt UAX #15 conformance in
# `src/unicode/normalization.zig`). macOS skips them — same code
# paths are POSIX-clean across both, and they dominate wall-time
# under testing.allocator. Gated via `-Dexhaustive-tests=true`;
# see the `b.option(...)` block in `build.zig`.
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
zig build test-fast --summary all -Dexhaustive-tests=true
else
zig build test-fast --summary all
fi
- name: SES positive-coverage tests
# Runs the hand-written tests under `tests/ses/` against the
# installed cynic CLI (hardened by default). Each fixture
# asserts a piece of SES behaviour the engine is supposed
# to enable (override-mistake shadowing, `harden()`
# traversal, frozen-globalThis carve-outs). Gating: any
# failure exits 1.
run: zig build test-ses
fmt-check:
name: zig fmt
runs-on: ubuntu-latest
timeout-minutes: 5
# No `needs:` — `zig build fmt-check` compiles nothing, it just
# runs the formatter. Letting it start immediately surfaces a
# formatting drift without waiting on a full engine build.
# Gating: any drift fails the build. Local fix: `zig build fmt`
# rewrites in place. The generators under `tools/gen_unicode_*`
# emit fmt-clean output, so a regen via `zig build gen-unicode`
# never reintroduces drift.
steps:
- name: Audit egress (advisory)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v6
- name: Set up Zig
uses: xyzzylabs/setup-zig@58626e899b126520afe59a875fb2848cf0774c59 # v1.0.0
- name: zig build fmt-check
run: zig build fmt-check
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 5
# Static linter for `.github/workflows/*.yml`. Catches the
# things YAML parsing misses: typoed `runs-on`, wrong
# shell, undeclared matrix variables, expressions that
# reference non-existent contexts. ~5s; gating, so a workflow
# regression can't ship.
steps:
- name: Audit egress (advisory)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v6
- name: Run actionlint
# The official docker image pins a release; safer than
# `curl | bash` and faster than checking out the action's
# source on every run.
run: |
docker run --rm \
-v "$PWD:/repo" \
-w /repo \
rhysd/actionlint:latest -color
wasm-build:
name: WASM build smoke
runs-on: ubuntu-latest
timeout-minutes: 15
# Catches freestanding-incompatible regressions before they
# hit `gh-pages` on merge. Builds without `wasm-opt` (no
# Binaryen download, ~6 min cold → ~3 min); the post-merge
# `playground.yml` re-builds with `wasm-opt -Oz` for the
# deploy. We just want the compile signal.
steps:
- name: Audit egress (advisory)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v6
- name: Set up Zig
uses: xyzzylabs/setup-zig@58626e899b126520afe59a875fb2848cf0774c59 # v1.0.0
with:
cache-key: wasm
- name: zig build wasm
run: zig build wasm
cross-build:
name: Cross-build (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build-and-test
# Build-only sanity check for targets we don't natively execute.
# Catches compile breaks on platforms Cynic deploys to but the
# GitHub runner pool doesn't cover, plus catches macOS-vs-Linux
# libc divergence (the recent `std.c.clock_gettime` libc-link
# break would have shown up here pre-push if a contributor on
# darwin ran `zig build -Dtarget=x86_64-linux-gnu`).
#
# aarch64-linux-gnu — Workers / Deno on ARM64 Linux.
# x86_64-linux-musl — Catches static-link / libc-symbol-set
# regressions. Workers' v8-isolate edge
# runners run under musl.
# aarch64-macos — Apple Silicon cross-from-Linux; lets a
# Linux contributor catch macOS-only
# compile breaks before pushing.
#
# `x86_64-linux-gnu` deliberately NOT in the matrix — it's the
# host triple of `ubuntu-latest`, so `build-and-test` already
# exercises it natively (and at higher coverage). Adding a
# cross-from-self entry was a 36s no-op.
#
# Not added: aarch64-linux-android (needs the Android NDK for
# Bionic libc headers — Zig doesn't bundle them) and
# aarch64-ios (needs the iOS SDK + `build.zig` plumbing to
# thread SDKROOT into the libc link step on the native modules).
# Both are real porting efforts, tracked separately.
strategy:
fail-fast: false
matrix:
target:
- aarch64-linux-gnu
- x86_64-linux-musl
- aarch64-macos
steps:
- name: Audit egress (advisory)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v6
- name: Set up Zig
uses: xyzzylabs/setup-zig@58626e899b126520afe59a875fb2848cf0774c59 # v1.0.0
with:
cache-key: cross-${{ matrix.target }}
- name: Cross-compile
run: zig build -Dtarget=${{ matrix.target }}
- name: Confirm target architecture
# `file zig-out/bin/cynic` should report the cross target —
# surfaces a silent fallthrough to the host triple if the
# cross-compile didn't actually take.
run: file zig-out/bin/cynic
test262:
name: test262 conformance + RSS smoke (gating)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build-and-test
# One job builds the ReleaseFast harness once, then runs both
# the full runtime sweep and the filtered RSS smoke against it —
# the second `zig build test262` reuses the first's `.zig-cache`,
# so the harness compiles a single time.
#
# The runtime sweep is gating via `--min-pass-pct`: a score
# regression below the floor exits 2 and fails the job. The
# filtered RSS smoke stays advisory because it surfaces leak
# signals as numbers in the log, not pass/fail.
#
# Runtime mode is the meaningful signal — parser is uniformly
# ≥95% across areas. ReleaseFast + 4 workers (matching the
# GitHub-hosted runner's vCPUs) targets ~1 min wall-time.
#
# Linux-only. macOS would double CI time for marginal signal
# — the engine is POSIX-clean across both, and the user runs
# test262 locally on darwin constantly.
#
# We deliberately do NOT auto-commit a refreshed scoreboard —
# that loop is finicky to get right with concurrency policy
# (the auto-commit triggers a new run that cancels itself,
# plus paths-ignore on the bot push doesn't always interact
# cleanly with concurrency: cancel-in-progress). Refresh
# `test262-results.md` locally with `zig build test262 --
# --write-results` and commit it like any other source change.
steps:
- name: Audit egress (advisory)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Zig
uses: xyzzylabs/setup-zig@58626e899b126520afe59a875fb2848cf0774c59 # v1.0.0
- name: Run test262 (runtime, parallel)
# Drop `--quiet` so the per-bucket tally lands in the CI
# log. Step timeout caps it in case the harness wedges.
#
# One floor gates the job. The harness scores every fixture
# binary pass/fail under a single posture
# (`--unhardened --allow=eval`); `pass%` = `passing /
# (passing + failing)`. See the legend in
# `test262-results.md`.
#
# --min-pass-pct=88.5 Headline pass% floor. Today
# ~89.2 %; ~0.7pp headroom. A score
# regression below it exits 2 — see
# the diagnostic in `tools/test262.zig`.
#
# `--top-rss=20` surfaces the memory-heaviest fixtures
# after the tally; pair with the filtered RSS smoke step
# below to catch leaks at PR time before they reach `main`.
timeout-minutes: 8
run: |
zig build test262 -- \
--threads=4 --top-rss=20 \
--min-pass-pct=88.5
- name: Filtered sweep with top-rss
# Advisory leak guard: a filtered runtime sweep reports the
# heaviest per-fixture RSS deltas via `--top-rss`. Healthy
# deltas on `language/expressions` are ≤ ~20 MiB; a jump
# almost always means a new allocation path stopped freeing.
# We deliberately do NOT wrap in `/usr/bin/time -v` — that
# reports the parent `zig build` RSS (≈1 GB during link),
# not the harness, and would always look "leaking."
#
# Reuses the harness binary built by the step above (same
# runner, same `.zig-cache`) — no second compile.
timeout-minutes: 5
continue-on-error: true
run: |
zig build test262 -- --quiet --threads=4 \
--filter=language/expressions --top-rss=10
test262-gc-stress:
name: test262 GC stress / ${{ matrix.bucket }} (advisory)
# Advisory, runs on PRs and main: gc-stress is a per-bucket
# ReleaseSafe build plus a slow `--gc-threshold=1` sweep. It stays
# `continue-on-error` (does not gate merges) because a gc1 sweep is
# slow and, at `--threads=4`, prone to the occasional flake under
# libc-malloc contention — surfacing a GC regression as a pre-merge
# annotation a reviewer can act on is the goal, not blocking. The
# default `test262` job (gating, ReleaseFast, every PR) is the
# front-line check; gc-stress is the deeper net. It runs on PRs now
# — not just post-merge — so a rooting hole is caught before it
# lands, not bisected afterward.
runs-on: ubuntu-latest
timeout-minutes: 25
needs: build-and-test
continue-on-error: true
# Catches GC rooting / write-barrier regressions that the
# default `test262` job above CANNOT see: that job builds
# ReleaseFast, where `Heap.verifyRememberedSet` and the
# freed-memory poison are compiled out, so a native holding an
# unrooted pointer across a JS re-entry passes silently.
#
# Here the harness is built `-Doptimize=ReleaseSafe`, which arms
# the remembered-set verifier, and `--gc-threshold=1` collects
# on every allocation — so any rooting hole is a deterministic
# crash and any un-barriered mature→young store trips the
# verifier with the offending `(container, field, target)`.
#
# NOT a full sweep: under `--gc-threshold=1` the ~600
# `built-ins/RegExp/property-escapes` fixtures each exceed the
# per-fixture watchdog (huge generated patterns × per-alloc GC),
# so a whole-corpus gc1 run is timeout-bound. Instead it probes
# the GC-mutation-heavy buckets — the areas where the rooting
# contract and the iterative-marker contract are most often
# exercised. See docs/handbook/gc.md ("Finding these bugs").
#
# `Iterator` and `generators` were added after the shadow-shape
# removal class (9c4b70c) and the recursive-marker overflow
# (9ac49ff, f97155a) — both surfaced in those buckets and
# would slip past Array / String / TypedArray. `Map` / `Set` /
# `WeakMap` / `WeakSet` / `Promise` / `Function` / `Object` were
# added after this net caught three real use-after-free clusters
# in them: the `Object.create` / `defineProperties` / `assign`
# accessor path (a hard segfault — unrooted receiver / key / getter
# result across the descriptor re-entry), the `Set` set-like ops
# (raw `*JSFunction` has/keys held across the iteration), and the
# `Promise` aggregators (the once-read `resolve` held across every
# element). Set covers the `collections.zig` family; WeakMap /
# WeakSet additionally exercise the ephemeron-marking path; Object
# is the property-bag / shape mutation surface.
#
# Matrix-parallelized: each bucket runs as its own job, so a
# single regression points at the bucket directly and wall-time
# is the slowest bucket, not the sum.
strategy:
fail-fast: false
matrix:
bucket:
- built-ins/Array
- built-ins/String
- built-ins/TypedArray
- built-ins/Iterator
- language/expressions/generators
- built-ins/Map
- built-ins/Set
- built-ins/WeakMap
- built-ins/WeakSet
- built-ins/Promise
- built-ins/Function
- built-ins/Object
steps:
- name: Audit egress (advisory)
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Zig
uses: xyzzylabs/setup-zig@58626e899b126520afe59a875fb2848cf0774c59 # v1.0.0
with:
# Every gc-stress job builds the same ReleaseSafe harness,
# so share one cache namespace across the matrix.
cache-key: gc-stress
- name: GC stress sweep (ReleaseSafe, --gc-threshold=1)
timeout-minutes: 20
run: |
zig build test262 -Doptimize=ReleaseSafe -- \
--gc-threshold=1 --threads=4 --filter=${{ matrix.bucket }}