Unit lane scaling: where the time goes¶
This is the measurement half of issue #10583. It answers three questions with
data: where the Node unit lane’s wall time goes, which test files need
--isolate, and whether duration-based shard assignment is worth adopting.
Method¶
- Host: Apple M3 Max (16 cores), Bun 1.3.14,
origin/mainat2769b96a5(2026-10-08). - File list: the canonical unit list. That is every tracked
test/**/*.test.tsexcept*.integration.test.ts,test/integration/,test/stress/andtest/daemon/manager.test.ts, which leaves 1,919 files and 40,468 tests. - Runs: sequential batches of 60 files, one
bun testprocess per batch, never two at once. Each run used the canonical flags (--timeout 20000 --no-orphans, thebunfig.tomlpreloads andtest/setup/fileTimingProbe.ts), with and without--isolate, and produced JUnit and per-file timing logs. - Sample: every 19th file of the sorted list, 101 files in all. It was used for the one-process-per-file and cold-transpile-cache runs.
- CI data: Read-only
ghreads of ubuntu-latestNode Unit Testsjob logs, for example the green run 37710748903, and the nightlyNode Randomized Unit Testsjobs.
The timing probe logs start when its preload runs, which is after the
bunfig.toml preloads and before the test file is imported. It logs end in a
global afterAll. Under --isolate this splits each file’s cost into:
- gap: from the previous file’s
endto this file’sstart. This covers isolate teardown and setup and the threebunfig.tomlpreloads. - elapsed: from
starttoend. This covers the module import of the test file and its graph,beforeAll/afterAllhooks, and the tests themselves. - test time: the sum of JUnit
<testcase time>. This excludes hooks and imports.
1. Where the wall time goes¶
Whole suite, local¶
| Mode (32 batches of 60) | Wall | Failures |
|---|---|---|
--isolate (canonical) |
205 s | 0 |
no --isolate (shared realm) |
48 s | 0 |
The --isolate run breaks down as follows:
| Component | Time | Share | Per file (median / p90) |
|---|---|---|---|
| Process start + exit (32 processes) | 2.4 s | 1% | 75 ms per process |
| Inter-file gap (isolate reset + preloads) | 59.1 s | 29% | 26 ms / 57 ms |
| Import + hooks (elapsed − test time) | 109.6 s | 53% | 43 ms |
| Test time (JUnit sum) | 34.0 s | 17% | |
| Total per-file cost | 202.6 s | 86 ms / 206 ms |
- The known “~30 s of test time against ~186 s of wall” checks out. Test bodies are 34 s, about 17% of the wall. The other 83% is re-creating a realm and re-importing each file’s module graph once per file.
- Isolation also inflates the test bodies themselves. Without
--isolatethe JUnit test-time sum falls from 34.0 s to 18.5 s. Cold JIT state and lazy imports inside the first test of each file get paid again in every fresh realm. - The isolate overhead is about 82 ms per file across the suite ((205 s − 48 s) / 1,919). On the 101-file sample it is about 122 ms per file (15.3 s against 3.0 s).
- The slowest files by wall are
test/lint/*suites at 0.8–1.6 s each, with 1–33 ms of test time. Their cost is inbeforeAll, which scanssrc/. The JUnit-based 100 ms gate does not see it.
Sample of 101 files: process, isolate and transpile cache¶
| Mode (101 files) | Wall | Per file |
|---|---|---|
One bun test process per file (--isolate) |
20.8 s | 206 ms |
One process, --isolate, warm transpiler cache |
15.3 s | 152 ms |
One process, --isolate, cold (BUN_RUNTIME_TRANSPILER_CACHE_PATH=0) |
17.5 s | 173 ms |
| One process, shared realm, warm | 3.0 s | 30 ms |
| One process, shared realm, cold | 3.1 s | 31 ms |
- A fresh process costs about 60 ms at minimum. That is the fastest one-file run: Bun startup plus the three preloads. The one-process-per-file median is 180 ms.
- The transpiler cache is not the lever. Cold against warm moves the isolated run by about 14% and the shared run by about 4%. Bun only caches large files, and nearly all of the cost is evaluating the module graph again per realm, not transpiling it. This matches #10429: our own module graph is the cost.
CI (ubuntu-latest, 4 vCPU, 3 shards)¶
The green run 37710748903 has these shards. “Ran” is Bun’s own per-shard duration, and “Completed test-file time” is the probe’s elapsed sum.
| Shard | Files | Tests | Ran (Bun) | Probe elapsed sum | Gap (Ran − elapsed) |
|---|---|---|---|---|---|
| 1/3 | 642 | 13,703 | 520.5 s | 345.4 s | 175 s |
| 2/3 | 641 | 13,836 | 521.7 s | 348.1 s | 174 s |
| 3/3 | 641 | 13,106 | 428.5 s | 283.0 s | 146 s |
- CI pays about 7.6 times the local per-file cost. That is 1,471 shard-seconds against 205 s locally. Three concurrent realms on 4 vCPU, plus the runner’s own load, slow every import.
- The gap share matches local. About 33% of CI shard time is inter-file isolate overhead, against 29% locally.
- Timeouts and runner shutdowns dominate the red runs. From 2026-10-07 to
2026-10-08, 11 of the 14 most recent ubuntu unit jobs in the PR workflow (and nearly every merge-workflow run) died with
exit 143(runner shutdown) or124. On every124run, all three shards hit the budget together (758–785 s). That points to a slow runner, not an unbalanced one. - The nightly randomized lane runs the whole suite in one non-isolated
process in 76–95 s on ubuntu. That is the canonical sharded lane’s
~520 s shard wall cut by about 5.5 times. It surfaces about 6 failures in 3
files per night (
navigation resource session resolution,structural SQLite characterization,navigation recorder caller arguments, and the WebRTC publish teardown suite). Those are order-dependent leaks.
2. Which files need isolation¶
Static signals were matched per file over the 1,919 unit files:
| Signal | Files |
|---|---|
spyOn( |
554 |
singleton access (getInstance(, resetInstance, .instance =, __reset) |
187 |
process.env write or delete |
134 |
DB (getDatabase, createTestDatabase, the DB test harnesses) |
60 |
assignment to childProcess.*, Bun.*, fs.* or process.* |
21 |
globalThis/global write or Object.defineProperty(globalThis…) |
11 |
testOverrides |
5 |
mock.module( |
2 |
process.chdir |
2 |
The proposed classification:
| Tier | Rule | Files | Isolated cost |
|---|---|---|---|
| A: must isolate | mock.module, native/global patching, chdir. These change the module registry or process-wide natives, and afterEach cannot reliably undo them. |
34 | 3.6 s |
| B: restorable state | Any other signal: spies, env writes, singletons, DB, testOverrides, system time. These are safe only if every test restores. |
707 | 101.7 s |
| C: no signal | Pure tests over injected fakes. | 1,178 | 97.3 s |
Measured outcomes for running subsets in a shared realm:
| Shared set (batches) | Files | Shared wall | Failures |
|---|---|---|---|
| Tier C (60-file batches) | 1,178 | 24.3 s | 0 |
Tier C (400-file batches, --randomize --seed=10583) |
1,178 | 17.8 s | 0 |
Tier C + B minus {DB, singleton, env, testOverrides} (60) |
1,562 | 33.4 s | 4 tests in BaseVisualChange.uiStability.test.ts (leaked spy state) |
| Everything (60) | 1,919 | 48.2 s | 0 in this order; nightly random order fails ~3 files |
The estimated local saving if Tier C shares processes and A+B stay isolated is 97.3 s of isolated cost replaced by 17.8–24.3 s shared. That takes the suite from 205 s to about 125–130 s (−37 to −39%). The same ratio on the CI shard would take about 520 s to about 330 s.
The study did not adopt this, because it changes the lane’s hermeticity contract. The owner decided on 2026-10-08 to adopt it for tier C; section 5 describes the change.
- A Tier C file that passes only because an earlier file in its batch left state behind would no longer fail on its own. The nightly randomized lane is the backstop for that.
- The tiering is a static heuristic. The run that also shared Tier B files without DB, singleton or env signals found a real leak at once, so the heuristic needs a curated allow-list or deny-list and a guard test before it gates CI.
- That is an owner decision. The pieces are: a checked-in list of shared-safe
files or directories,
bun testwithout--isolateover them,--isolatefor the rest, a lint that moves a file to the isolated set when it gains a Tier A signal, and the randomized nightly run as the leak detector.
3. Shard balance¶
scripts/test-ts.sh assigns files round-robin. It walks the sorted
find test -name '*.test.ts' list and gives file i to shard i mod N. Each
shard is one bun test --isolate process.
Simulated with the measured local per-file costs (gap + elapsed):
| Shards | Round-robin (current) max / mean | LPT, exact manifest | LPT, ±50% per-file drift | LPT, 10% unknown files (round-robin fallback) |
|---|---|---|---|---|
| 2 | +5.9% / −5.9% | ±0.0% | ±0.1% | ±1.0% |
| 3 | +2.0% / −2.7% | ±0.0% | +1.6% / −1.6% | ±0.2% |
| 4 | +14.7% / −7.0% | ±0.0% | +0.8% / −1.6% | +1.2% / −1.7% |
| 6 | +11.5% / −10.2% | ±0.0% | +1.9% / −2.2% | +2.0% / −1.6% |
On CI at 3 shards, the observed spread on the green run above is +6.4% / −12.6%: 521.7 s and 520.5 s against 428.5 s, with a mean of 490 s. LPT would cut the critical path by about 6% (about 31 s), and by less when per-file costs on the runner drift from the manifest.
Recommendation: do not adopt duration-based assignment now.
- Balance is already within the issue’s ±20% target. It gains 2–6% at 3 shards. It only pays at 4–6 shards, which the ubuntu runner, at 3 workers on 4 vCPU, does not use.
- It would not have prevented the observed failures. Every
124timeout had all three shards at the budget together, and143is a runner shutdown. - A manifest is ongoing cost. It needs a refresh job and churns as files
are added, and CI per-file costs (
TargetDisplayActionat 14 s on CI) are not proportional to local ones.
Revisit it if the lane moves to more shards or runners. Build the manifest from
CI JUnit and probe data (scratch/test-ts-unit-shards/timing-shard-*.ndjson),
not local runs, and fall back to round-robin for unknown files.
4. Change made: accurate per-shard wall time¶
The study found that test-ts: unit shard N/M wall= was wrong. The parent
reaps shards in index order and computed each shard’s wall at reap time. Every
shard reaped after the slowest earlier one therefore reported that shard’s
time. That is why CI showed wall=523s for all three shards when Bun’s own
times were 520.5, 521.7 and 428.5 s. Any balance or p50/p95 tracking needs
correct per-shard numbers, as the issue’s “track the lane’s health” option and
its ±20% acceptance do.
Each unit shard subshell now writes its own elapsed seconds to
scratch/test-ts-<mode>-shards/shard-N.wall before the timing summary runs. The
parent reports that value. It keeps the reap-time measurement only when a
killed shard wrote no record. The bats test unit shard wall time is each
shard's own duration, not its reap time (#10583) covers it: a slow shard 0 and
a fast shard 1 must report different walls.
Since #10644 the parent reaps attempts in completion order. It polls the live
shard pids (AUTOMOBILE_UNIT_SHARD_POLL_SECONDS, default 0.2 s, because macOS
Bash 3.2 has no wait -n). A shard that infra-exits early is retried at once,
and its retry window is judged against the lane cap at its own end time. The
per-shard .wall record still excludes the end probe and the poll latency.
5. Change made: tier C files share processes¶
The owner decided on 2026-10-08 to run tier C files in shared processes and keep everything else isolated.
- The list.
test/shared-process-allowlist.txtholds the shared files.bun scripts/test/classify-shared-safe.tsgenerates it from the section 2 signals plus one more,moduleState, which is described below. Fast Validation runs the script with--check(shared-process-allowlist). The check fails when a listed file gained a tier A or B signal, no longer exists, is excluded, or when the list is unsorted or has duplicates. A new tier C file that is not listed only prints a hint: files that are not listed run isolated. - The runner.
scripts/test-ts.sh unitputs the listed files first and then assigns files round-robin, so each shard gets an even share of both groups. Each shard then runs its shared files in onebun testprocess without--isolate(scripts/lib/bun-unit-groups.sh), followed by its other files in one--isolateprocess. Both use the canonical flags and preloads and run under the shard’s single watchdog. JUnit goes toshard-N-shared.xmlandshard-N.xml, and the timing gate already globs*.xml. Chunked shards (AUTOMOBILE_UNIT_TEST_CHUNK_FILES, nightly macOS) chunk each group separately and never mix them. The changed lane, coverage, explicit file targets and Windows are unchanged.AUTOMOBILE_UNIT_SHARED_PROCESS=0restores the all-isolated lane. - Timing probe. A shared process runs the preloads once. The probe
therefore records one entry per shared process, labelled through
AUTOMOBILE_TEST_TIMING_GROUP_LABEL, for exampleunit shard 0 shared process (360 files). The watchdog names that label, and the per-file headers in Bun’s log name the file. - Leak detection. The nightly
Node Randomized Unit Testsjob already runs every unit file, including the shared group, in one non-isolated process with--randomize --seed=<run number>. It covers the shared group in a new random order every night.
Verification found leaks that the study’s signals miss¶
The study ran tier C once in a random order. Running the whole group in one process under further seeds found three order-dependent failures, each bisected to a single earlier file:
| Victim | Polluter | State |
|---|---|---|
androidInventoryMixedWorkload.test.ts |
AndroidEmulatorClient-killIdentity.test.ts |
AdbClient’s module-level device-list cache. The victim resets it only in afterEach. |
androidInventoryReviewFix.test.ts |
not bisected | Same cache, also reset only in afterEach. |
ObserveCacheRegistry.test.ts |
AndroidMultiDisplayActivity.test.ts |
ObserveScreen installs its cacheStore with the module-level setObserveCacheStore. |
BaseVisualChange.uiStability.test.ts |
ClearAppData.test.ts |
The default window-cache invalidator marks a pending window resolution for device-123 in a module map. |
The classifier therefore adds a tier B signal, moduleState. It matches
free-function set…(/reset…( calls, such as setDeviceToolsDependencies,
setObserveCacheStore and resetAdbClientCaches, and X.resetForTests(). It
does not match methods on fakes or the timer globals. It moved 93 files out of
the shared group, which catches the first three victims. In the last pair, the
state is written through a production default collaborator, which a static check of the test file cannot
see, so both files are in the script’s SHARED_PROCESS_EXCLUSIONS with the
reason. That leaves 1,079 shared files, down from 1,174.
With those changes, nine further seeds (3, 7, 8, 11, 12, 13, 14, 15 and 20261008, which include every seed that had failed) ran the 1,079 shared files in one process with 0 failures, in 16–28 s each.
The residual risk is that the shard lists are round-robin over the sorted file list. Adding or removing a test file therefore shifts which files share a process and in what order. A leak that no seed has hit yet could surface on an unrelated PR. The nightly random lane is the detector. A failure there in a listed file is fixed by adding the victim or the polluter to the exclusions (or fixing its reset), not by re-running.
Measured result¶
The run used the same host, the shard contents test-ts.sh builds for 3
shards, and every process run sequentially. test/daemon/manager.test.ts was
skipped (1,923 files).
| Layout | Shard 1 | Shard 2 | Shard 3 | Sum of shard time | Failures |
|---|---|---|---|---|---|
| Before: all files isolated | 231.3 s | 203.7 s | 224.6 s | 659.6 s | 0 |
| After: 1,079 shared, 844 iso | 112.1 s | 122.0 s | 109.7 s | 343.9 s | 0 |
Each shard’s critical path falls by about 47%. Each shared process took 5–7 s for its 359–360 files. The isolated per-file cost in a 281–641-file process is about 0.36 s locally. That is much higher than the 86 ms of the 60-file batches in section 1, which suggests long isolated processes slow down as they grow (compare #10213).
Chunking the isolated group¶
The isolated group of a shard now runs in sequential bun test --isolate
processes of AUTOMOBILE_UNIT_ISOLATED_CHUNK_SIZE files (0 = one process),
with JUnit reports shard-N-iso-K.xml (scripts/lib/bun-unit-groups.sh).
Measured locally on shard 0’s 211 isolated files (4 shards), run
sequentially, each size twice in opposite order on a loaded machine (load
35–83), wall seconds:
| Chunk size | Run 1 | Run 2 |
|---|---|---|
| 0 (one process) | 78.3 | 97.0 |
| 120 | 51.2 | 49.0 |
| 60 | 35.6 | 45.2 |
| 30 | 28.0 | 34.3 |
Smaller chunks were faster at every size measured, so the default is 30. The absolute numbers are noisy because other lanes shared the host, but the order held in both passes.
Summary of the decisions this informs¶
| Question | Finding |
|---|---|
| Where does wall time go? | 83% is per-file realm setup and module re-import under --isolate. Test bodies are 17%. Process startup and the transpiler cache are minor. |
| Biggest lever | Fewer isolates. Sharing processes for the 1,178 no-signal files cuts the suite by about 37%. A fully shared realm cuts it by 77% but leaks across files. |
| Shard balancing | Round-robin is within ±13% on CI at 3 shards. LPT gains about 6% and does not address the timeouts. |
| Timeout and shutdown failures | Runner-wide slowness, which hits all shards together. Fewer isolates shorten the window, and a retry or infra classification handles the remainder. |