Skip to content

test: run the unit suite across parallel processes (~3x faster) - #3860

Merged
karlseguin merged 14 commits into
mainfrom
test-parallel
Oct 9, 2026
Merged

karlseguin merged 14 commits into
mainfrom
test-parallel

Conversation

@arrufat

@arrufat arrufat commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

make test now splits the suite across up to 4 processes. Each one claims the next test nobody has taken yet, and the parent merges their results into the usual summary. On an 8-core machine a cached make test goes from 10.9s to 3.4s.

Isolation

  • Ports. The HTTP, CDP and WebSocket test servers now bind ephemeral ports instead of 9582/9583/9584.
    • Fixtures and tests still address 127.0.0.1:9582, localhost:9582 and :9584. In test builds, CURLOPT_CONNECT_TO (set once per handle in Connection.reset) routes those to the real ports. URLs, origins and Host headers are unchanged, so none of the ~350 hardcoded references needed touching.
    • The HTTP and WebSocket servers start before App.init, so the connection pool picks up the routes as it's built.
    • Only createTestClient and the two assertions on URLs the CDP server advertises use the real CDP port.
  • Files. Tests that wrote to fixed paths now use std.testing.tmpDir, through a new testing.tmpPath helper:
    • /tmp/lightpanda_test.sqlite
    • /tmp/iid
    • .zig-cache/tmp/upload.txt, a.txt and b.txt
    • the MCP save and screenshot files and the agent trace file in the repo root
  • Side effect: test runs in several worktrees no longer fight over port 9582.

Runner

  • TEST_JOBS sets the number of processes. The default is min(cpus, 4); TEST_JOBS=1 restores the single-process run.
  • The runner re-executes itself as N shards. They share a counter through a mapped file: each shard walks the tests in order and runs one only when it wins that index with an atomic add, taking the next index only once it's done. A slow test never holds the next one back, so the run ends when the work runs out rather than when the unluckiest shard does. A fixed every-Nth split was ~15% slower at 4 jobs (4.13s vs 3.50s median) and ~19% at 8.
  • Each shard writes a small JSON report (counts, failed names, its slowest tests), and the parent merges them.
  • Failure output still streams live from each shard; each status line goes out in one write, so shards' colors don't bleed into each other. A shard that dies without writing a report, or whose exit status disagrees with its report, is flagged and fails the run. If the parent itself errors out, it kills the shards it started.
  • TEST_FAIL_FIRST stops every shard: the failing one pushes the shared counter past the last test. The failing test still shows in the summary (it used to be dropped).
  • TEST_FILTER behaves as before; filtered runs give the same counts with 1 or 4 jobs.
  • METRICS=true stays in a single process, so the CI bench.json numbers remain comparable.
  • Which shard runs a given test varies between runs, so reproduce a failure with TEST_FILTER (or TEST_JOBS=1).

Timings on 8 cores (cached build, test binary only, performance power profile, median of 5):

jobs wall user
1 10.9s 7.2s
2 6.0s 8.2s
4 3.4s 9.3s
6 2.7s 11.2s
8 2.5s 12.7s

For order dependence, I ran the full suite 12 times with 2–8 jobs, 20 more times with 8 jobs (claiming gives each run a different split), and two full suites at the same time. That turned up one hidden dependence: server: HTTP session ended before its worker attached snapshotted the bidi worker gauge while the previous test's deleted session was still winding down (a delete answers 200 before its worker exits). It now waits for no bidi workers before and after, and the HTTP session tests that delete their session at the end now also delete it if they fail before getting there, so one failure can't spill into the next.

Also lists the slowest tests slowest first, shares the bound-port lookup as sys_net.boundAddress (it was copied in five places) and replaces the deprecated std.fmt.allocPrint / std.fs.path uses in the new code.

@arrufat
arrufat force-pushed the test-parallel branch 2 times, most recently from ca36903 to 26a484c Compare October 8, 2026 13:13
@arrufat arrufat changed the title test: run the unit suite across parallel processes (11.8s → 4.2s) test: run the unit suite across parallel processes (~3x faster) Oct 8, 2026
Base automatically changed from test-speedup to main October 8, 2026 15:06
The test servers bind ephemeral ports instead of 9582-9584. Fixtures and
tests keep addressing the fixed ports: in test builds, libcurl's
CONNECT_TO routes them to the real ones, so URLs and origins are
unchanged. Only the few CDP server tests that dial it, or assert a URL
it advertises, use the real port.

Tests that wrote to fixed paths (/tmp/lightpanda_test.sqlite, /tmp/iid,
.zig-cache/tmp/upload.txt, files in the repo root) now use tmpDir.
The runner re-executes itself TEST_JOBS times (default: up to 4), each
copy taking every Nth test, and merges the reports they write back into
the usual summary. METRICS=true stays in a single process so the numbers
remain comparable across runs.
arrufat added 11 commits October 8, 2026 17:18
Share the bound-address lookup as sys_net.boundAddress, set the test
connect-to routes once per handle in Connection.reset, add a
testing.tmpPath helper, and print the runner Report directly instead of
round-tripping it through SlowTracker. Replace deprecated
std.fmt.allocPrint and std.fs.path uses.
A fixed stride left one shard as the tail. Shards now take the next
unclaimed test index from a counter in a shared mapped file, so the
suite finishes when the work runs out rather than when the unluckiest
shard does.
A deleted session's worker exits after the 200, so the previous test's
can still be live when this one snapshots the gauge. Once tests are
claimed dynamically that predecessor varies, and the snapshot either
never comes back or matches while our own worker is still running.
Wait for no bidi workers before and after instead.
Shards share the terminal, and the color escape went out in a write of
its own, so another shard's reset could land between it and its dot
and leave the dot uncolored.
runShards ends in exit(), which skips defers, so its temp dir was never
removed; it now uses tmpDir and cleans up before exiting. With
TEST_FAIL_FIRST a failing shard pushes the shared counter past the last
test so its siblings stop too. Status lines are buffered so long ones
also go out in one write, the Trace test writes under tmpDir, and the
report printing and comments are trimmed.
Comment thread src/test_runner.zig Outdated
Someone filtering is usually debugging one case and wants as little
variability as possible. TEST_JOBS still overrides it.
@arrufat

arrufat commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

I ran the zig-test job 4 times on CI on the latest commit (047ed3e), and all runs passed. That includes the TSan debug job, which runs the suite across parallel processes.

@karlseguin
karlseguin merged commit 918f5ea into main Oct 9, 2026
38 checks passed
@karlseguin
karlseguin deleted the test-parallel branch October 9, 2026 06:53
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 9, 2026
@arrufat

arrufat commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Let's hope it doesn't break often 🤞

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants