Uh oh!
There was an error while loading. Please reload this page.
fix(devx): cap pnpm test concurrency at 50% of the host's own cores - #11954
Conversation
`pnpm test` (`turbo run test`, no `--concurrency`) fell through to turbo's flat default of 10 parallel tasks. On a 4-CPU/~15GB box (confirmed via `nproc`/`free -h`; cgroup carries no lower limit than host RAM), that fan-out -- each `test` task additionally forking its own vitest worker pool, since only 1 of 41 vitest.config.ts files in the repo declares poolOptions/maxWorkers -- gets the process OOM-killed (exit 137) before all tasks run (#11938). Root `test` script now runs `turbo run test --concurrency=100%`. `100%` is turbo's own percentage syntax, resolved against the host's detected core count AT INVOCATION TIME -- environment-derived, not a hard low constant: scales with whatever box actually runs it. Zero CI behaviour change: every `turbo run test` invocation in .github/workflows/*.yml already passes its own explicit --concurrency=4 (a CLI flag always wins over the script default), and none of them call the root `pnpm test` script at all. The only invocation this touches is the bare `pnpm test` with no flag -- the local/agent-container path #11938 is about. Part of #11938
…ver that scales with host cores Live evidence while measuring the 100% (=4 on this box) value: 4 concurrent turbo test tasks were running 10 total vitest worker OS processes (~2.5 workers/task average) -- vitest's own default pool sizing ALSO scales with the host's own core count, independently of turbo's --concurrency. That means turbo's outer fan-out and vitest's inner fan-out compound: on a hypothetical bigger box, 100% is not a proportional, safe scale-up -- it is BOTH factors growing with core count at once, which grows memory pressure worse than linearly. And since turbo's OLD flat default was 10 regardless of box size, "100%" on any box with more than 10 cores is a real INCREASE in outer fan-out over today's behaviour, in the opposite direction from the defect this fixes. 50% avoids that: on THIS box (4 cores) it resolves to 2, which is the exact value the original card itself measured directly (135/136 tasks, 30m39s, peak ~7.6GB, real headroom) -- so the small-box side of this change has direct proof, not extrapolation. And 50% never exceeds turbo's old flat default of 10 until a box has 20+ cores, so on every runner shape this repo or a typical contributor machine actually has today, this change is a decrease or a wash relative to current behaviour, never an increase -- closing the gap the 100% value left open. Part of #11938
pnpm test concurrency at 100% of the host's own corespnpm test concurrency at 50% of the host's own coresos-steve
commented
Aug 25, 2026
ACCEPT, and option A on the open question. Flipping out of draft; arming once green. ⭐ You found the mechanism behind the risk I flagged, and it changed the answerI sent you a warning that
That is the compounding I suspected but had not measured, and it makes
|
Uh oh!
There was an error while loading. Please reload this page.
turbo's outer `--concurrency=50%` (#11954) bounds how many package `test` tasks run at once, but not vitest's own pool inside each of them. 40 of the 41 `vitest.config.ts` files say nothing about pool sizing — the single mention, in `packages/cli`, is a comment recording a REJECTED lever — so every package takes vitest's default of `max(cores - 1, 1)`, which scales with the host rather than with the shard it was given. Peak workers is the product of the two, and both terms grow with core count. Measured on a 4-CPU/15GB container, the product law holds exactly: 2x3=6, 4x3=12, 4x2=8, 4x1=4 concurrent workers observed. The bound goes at the invocation layer, per #10149's recorded reasoning that worker allocation is a property of the shard rather than of any one package's config. No `vitest.config.ts` is touched. Two traps this shape exists to avoid, both measured rather than assumed: - turbo filters task environments, so `VITEST_MAX_WORKERS` alone does NOTHING. Through turbo it spawned 3 workers (the unbounded default) while the same variable on a direct `vitest run` spawned 1. The `globalPassThroughEnv` entry is what makes the lever real. - vitest's `maxWorkers` is a PIN, not a ceiling — `resolveMaxWorkers()` returns the configured value outright. A flat `4` produced 8 workers at outer=2 where the default produces 6, i.e. a flat number RAISES the count on small boxes. So the cap is computed against the host's own cores and only ever lowers. A no-op on any host with <= 5 cores, today's CI runners included. In the regime where it binds (outer=2, inner 8 -> 4 on the 7-package fleet) it cut worker RSS 5700MB -> 2475MB for 93s -> 95s of wall, inside this box's noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
Fixes#11938
What
pnpm test(turbo run test, no--concurrency) falls through to turbo'sbuilt-in default of 10 parallel tasks. On this container (4 CPU / ~15 GB,
confirmed via
nproc/free -h; the cgroup carries no lower limit than thehost's own memory), that fan-out — each
testtask additionally forking itsown vitest worker pool, since only 1 of 41
vitest.config.tsfiles in therepo declares
poolOptions/maxWorkers— gets the process killed by thekernel OOM killer (exit 137) before 16 of 91 tasks ever run (the card's own
measurement, reproduced twice with
dmesgconfirmation).Fix: the root
testscript now runsturbo run test --concurrency=50%.50%is turbo's own percentage syntax (--concurrency=<N>%, resolvedagainst
available_parallelism()at invocation time, confirmed byreading the turbo binary's own validation strings) — environment-derived,
not a hard low constant: it scales with whatever box actually runs it.
This changes zero CI behaviour. Every
turbo run testinvocation in.github/workflows/*.ymlalready passes an explicit--concurrency=4(aCLI flag always wins over the script default) and nothing calls the root
pnpm testscript directly (grepped every workflow). The only invocationthis changes is the bare
pnpm test/turbo run testwith no flag — thelocal / agent-container path #11938 is about.
Why
50%, not100%(revised mid-PR — see commit history)The first commit on this branch shipped
--concurrency=100%(→ 4 on thisbox), reasoned from real production evidence:
ci.yml's Test Core job andrerun-safety-nightly.yml's full-suite double pass both already run at aliteral
--concurrency=4on this repo's only CI shape (ubuntu-latest,confirmed 4 vCPU/16GB via every
runs-on:in every workflow — there is no"larger CI shape" in this repo today, so the two-runtime ask collapses to
"this container" + "the identical shape CI already proves" for the repo as
it stands). Sampled several
rerun-safety-nightlyruns via the GitHub API:2026-08-21 both passes green (26m44s + 26m30s, 136/136 twice); 2026-08-23/24
"failed" but not from OOM — 135/136, 45-47min, same pre-existing unrelated
envelope-unwrap.test.tshook-timeout flake, no exit 137 anywhere.That evidence is real but incomplete on one axis: it only covers boxes
that are exactly this shape.
--concurrency=100%is turbo's OUTERfan-out only. While the fix was being measured live,
psshowed the actualprocess count: 4 concurrent turbo test tasks were running 10 total
vitest worker processes (~2.5 workers/task) — vitest's own default pool
sizing also scales with the host's detected core count, independently of
turbo's
--concurrency. The two multiply. On a hypothetical bigger box thatcompounds rather than staying proportional (outer fan-out grows and each
task's own inner fan-out grows), so
100%is not a safe linear scale-up —and since turbo's OLD flat default was 10 regardless of box size,
100%onany box with more than 10 cores is a real increase in outer fan-out over
today's behaviour, in the opposite direction from the defect this PR fixes.
No such box exists in this repo's CI to measure directly, so rather than
ship an unverified increase, the value was revised down. Filed the inner
lever itself as its own follow-up: #11958.
50%closes that gap without losing the "environment-derived, not a hardlow constant" property:
original card itself already measured directly: 135/136 tasks,
30m39s, peak ~7.6 GB, real headroom. Not extrapolated — the small-box side
of this change has direct prior proof.
50%never exceeds turbo's old flat default of 10 until a box has 20+cores — so on every runner shape this repo (or a typical contributor
machine) actually has today, this change is a decrease or a wash relative
to current behaviour, never an increase. That directly answers the
compounding-fan-out risk above without needing a bigger box to prove it on.
Measured (this container)
pnpm buildunder the shared verify lock — 71/71 tasks, 5m14s,clean.
--concurrency=50%(shipped value) on this container: not freshlyre-run by this session as a full suite — resting on the card's own prior
direct measurement above (135/136, 30m39s, peak ~7.6GB), which is real
evidence but pre-dates this PR. Disclosing this rather than implying a
fresh run happened.
--concurrency=100%(the value this PR shipped, then walked back) onthis container: a fresh, real
pnpm testrun was executed live duringthis PR. As of this writing it has been running ~12 minutes, no failing
test, no OOM signature (no
Killed process, no exit 137), peak memorysampled every 5s topping out at ~8.25 GB so far (~55% of the ~15GB
ceiling) — still in progress, not yet at a terminal state. Included as
supplementary, monotonic evidence:
50%does strictly less concurrentwork than
100%on the same box (2 tasks in flight vs 4), so if100%clears this box without OOM,
50%clearing it is the expected,lower-resource case, not a coincidence needing separate proof — the
card's own direct measurement of
50%already confirms it independentlyanyway.
session. The card's own reproduction (twice,
dmesg-confirmed cgroup OOMkill) already stands as evidence, and deliberately re-inducing an OOM on a
container shared with other parallel agents risks killing an unrelated
process — the kernel OOM killer doesn't limit itself to the invoking
process. Flagging this decision rather than silently only running the safe
side.
session (confirmed via
os-verify-lock.sh --status— queued behindanother agent's
sweep.shfor part of the build step). All wall-clocknumbers above are reported as observed on a shared box, not as clean,
isolated benchmarks.
Note: the inner lever (vitest's own worker pool) — filed as #11958
Only
packages/cli/vitest.config.tseven mentionspoolOptions/maxWorkers(in a comment about a rejected lever) — the other 40 configsrun vitest's own default pool sizing. This is the mechanism behind the
100%→50%revision above, and it's the reason full-suite--concurrency=4runs in the nightly CI logs (45-47 min) are slower thanthe card's
--concurrency=2run (30m39s) despite using more of the box:outer fan-out × inner fan-out is oversubscribing a 4-core box, thrashing
rather than parallelizing cleanly. Bounding vitest's own pool directly would
close this more completely, but it's a 41-file change with its own
measurement, and out of the scope this card set ("the root test-concurrency
policy") — filed as #11958 rather than done here.
Tests
pnpm build— 71/71 tasks, 5m14s, under the shared verify lock.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack—0 check families matched
package.json; nothing else to run for this diff.pnpm testat the shipped value (50%/2 on this box): see "Measured"above — resting on the card's own prior direct measurement, not a fresh
run in this session.
pnpm testat100%/4 on this box: fresh run executed live in thissession, in progress as of this writing (~12 min elapsed, no OOM
signature, peak ~8.25GB) — supplementary evidence, not the shipped value.
Generated by Claude Code