parser: bound recursion depth so deeply nested source can't crash #592
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |