|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Prints the value for `VITEST_MAX_WORKERS` — the bound on vitest's INNER |
| 5 | + * worker pool — for the root `test` script to export into `turbo run test`. |
| 6 | + * |
| 7 | + * ## Why this exists at all (#11958) |
| 8 | + * |
| 9 | + * Two fan-outs multiply and neither bounds the other: |
| 10 | + * |
| 11 | + * OUTER `turbo run test --concurrency=50%` — how many package `test` tasks |
| 12 | + * run at once. Bounded as a share of the host's cores (#11954/#11938). |
| 13 | + * INNER vitest's own pool inside EACH of those tasks. Unbounded: 40 of this |
| 14 | + * repo's 41 `vitest.config.ts` files say nothing about pool sizing |
| 15 | + * (the one mention, in `packages/cli`, is a COMMENT recording a |
| 16 | + * rejected lever), so every package takes vitest's default, which is |
| 17 | + * `max(availableParallelism() - 1, 1)` — i.e. it scales with the |
| 18 | + * HOST's core count, not with the shard it was given. |
| 19 | + * |
| 20 | + * Peak concurrent test-worker processes is therefore `outer × inner`, and both |
| 21 | + * terms grow with core count, so the product grows QUADRATICALLY. Measured on |
| 22 | + * a 4-CPU/15GB container, the product law holds exactly — 2×3=6, 4×3=12, |
| 23 | + * 4×2=8, 4×1=4 workers observed for those combinations. |
| 24 | + * |
| 25 | + * ## Why a CAP, computed here, and not a flat pinned number |
| 26 | + * |
| 27 | + * ⚠️ vitest's `maxWorkers` is a PIN, not a ceiling: `resolveMaxWorkers()` |
| 28 | + * returns the configured value outright rather than `min()`-ing it with the |
| 29 | + * default. Measured: `VITEST_MAX_WORKERS=4` on this 4-core box produced 8 |
| 30 | + * concurrent workers at outer=2, where the DEFAULT produces 6. A flat number |
| 31 | + * small enough to protect a 64-core box would tax every small box, and a flat |
| 32 | + * number chosen for comfort would RAISE the count on small boxes. So the cap is |
| 33 | + * applied here, against this host's own core count, and only ever lowers. |
| 34 | + * |
| 35 | + * The ceiling is 4 rather than 1-2 because oversubscription is what makes this |
| 36 | + * suite fast — its cost is dominated by module IMPORT, not CPU. Holding the |
| 37 | + * ceiling at 4 keeps today's oversubscription ratio roughly constant as core |
| 38 | + * count grows (total ≈ 2 × cores) instead of letting it grow with the box. |
| 39 | + * |
| 40 | + * ## What it buys, measured in the regime where it binds |
| 41 | + * |
| 42 | + * On the 7-package fleet at outer=2, emulating a larger box by setting the |
| 43 | + * inner pool explicitly (peak RSS is of the vitest processes only): |
| 44 | + * |
| 45 | + * inner=8 (a 9-core box's default) 16 workers 5700 MB workers 93s |
| 46 | + * inner=4 (this cap) 8 workers 2475 MB workers 95s |
| 47 | + * |
| 48 | + * -57% worker RSS for ~0 wall-clock (93s vs 95s is inside this box's run-to-run |
| 49 | + * noise; two same-config repeats differed by 17s). On a host with <= 5 cores |
| 50 | + * this file returns the default unchanged, so it is a NO-OP for every box the |
| 51 | + * project runs on today, CI runners included — which is the point: it bounds |
| 52 | + * growth without taxing anyone now. |
| 53 | + * |
| 54 | + * ## The silent no-op this is paired with |
| 55 | + * |
| 56 | + * ⚠️ Exporting this variable does NOTHING on its own. Turbo filters task |
| 57 | + * environments, so the variable must also be declared in `turbo.json` |
| 58 | + * (`globalPassThroughEnv`). Measured before that line existed: |
| 59 | + * `VITEST_MAX_WORKERS=1` through turbo spawned 3 workers — the unbounded |
| 60 | + * default — while the same variable on a direct `vitest run` spawned 1. If you |
| 61 | + * change either half, verify by OBSERVING the worker count (`ps` for |
| 62 | + * `--experimental-import-meta-resolve` children), never by the value being |
| 63 | + * accepted without error. |
| 64 | + * |
| 65 | + * ⚠️ The value must be a plain integer. vitest reads this variable with |
| 66 | + * `Number.parseInt`, so a percentage — the spelling turbo's `--concurrency` |
| 67 | + * accepts — is silently truncated: `VITEST_MAX_WORKERS=50%` means FIFTY |
| 68 | + * workers, not half the box. |
| 69 | + */ |
| 70 | + |
| 71 | +import os from 'node:os'; |
| 72 | +import { isEntrypoint } from './invoked-as.mjs'; |
| 73 | + |
| 74 | +/** vitest's own default: `max(availableParallelism() - 1, 1)` (non-watch). */ |
| 75 | +export function vitestDefaultWorkers(cores) { |
| 76 | + return Math.max(cores - 1, 1); |
| 77 | +} |
| 78 | + |
| 79 | +/** The ceiling. Only ever lowers vitest's default — never raises it. */ |
| 80 | +export const WORKER_CEILING = 4; |
| 81 | + |
| 82 | +export function workerCap(cores) { |
| 83 | + return Math.min(vitestDefaultWorkers(cores), WORKER_CEILING); |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Resolves the value to print. An explicit value from the environment wins: a |
| 88 | + * developer profiling one package, or a CI job that knows its own runner, is a |
| 89 | + * better judge of its shard than this file's host-relative guess. Only a |
| 90 | + * positive integer is honoured — anything else falls through to the computed |
| 91 | + * cap rather than reaching vitest as NaN. |
| 92 | + */ |
| 93 | +export function resolveValue(env = process.env) { |
| 94 | + const override = Number.parseInt(env.VITEST_MAX_WORKERS ?? '', 10); |
| 95 | + if (Number.isInteger(override) && override > 0) return override; |
| 96 | + const cores = |
| 97 | + typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length; |
| 98 | + return workerCap(cores); |
| 99 | +} |
| 100 | + |
| 101 | +// Guarded per `check:entry-guard`: this file exports bindings, so its top level |
| 102 | +// must not run inside an importer. |
| 103 | +if (isEntrypoint(import.meta.url)) { |
| 104 | + // A non-integer here would reach vitest as NaN and break the pool, so the |
| 105 | + // output is validated rather than trusted. An empty value is vitest's own |
| 106 | + // "use the default" signal, which is the safe way to fail. |
| 107 | + const value = resolveValue(); |
| 108 | + process.stdout.write(Number.isInteger(value) && value > 0 ? String(value) : ''); |
| 109 | +} |
0 commit comments