Skip to content

mcp-cli-*.test.ts real-subprocess spawns: 40% of the suite's summed test time #8587

Description

@JSONbored

Finding (2026-07-24 test-suite timing audit)

A full-suite vitest run --reporter=json timing pass found that test/unit/mcp-cli-*.test.ts (78
files) accounts for 423s of the suite's 1,052s summed test time — 40%. The 13 slowest files alone:

File Time (full-suite, contended) run()/runAsync() calls
mcp-cli-packets.test.ts 43.1s 37
mcp-cli-maintain.test.ts 38.2s 53
mcp-cli-doctor.test.ts 34.6s 27
mcp-cli-basics.test.ts 31.9s 37
mcp-cli-profiles.test.ts 30.3s 25
mcp-cli-review-pr.test.ts 18.5s
mcp-cli-telemetry.test.ts 13.4s
mcp-cli-notifications.test.ts 13.3s
mcp-cli-pr-outcomes.test.ts 12.9s
mcp-cli-maintain-tools.test.ts 12.8s
mcp-cli-monitor-open-prs.test.ts 12.3s
mcp-discovery.test.ts 12.0s
mcp-local-telemetry-chokepoint.test.ts 11.8s

Root cause

test/unit/support/mcp-cli-harness.ts's run() spawns the REAL CLI as a subprocess
(execFileSync(process.execPath, ["--experimental-strip-types", bin, ...args])) for every call —
deliberately, per its own header comment, because a subprocess is the only way to exercise the real
process entrypoint (argv parsing, exit codes, real stdout/stderr) and because v8 can't Codecov-instrument
a separate process. A parallel -inprocess/-stdio file family already exists specifically to get
coverage numbers via InMemoryTransport instead (those run in ~0.2s each) — this is deliberate
architecture, not an oversight.
Do not "fix" this by deleting/weakening the subprocess tests.

Isolated timing (single file, zero contention from other test files):

  • mcp-cli-packets.test.ts alone: 22 tests, 15.45s "tests" time (~0.4s/spawn) -- close to a single bare
    invocation's cost (node --experimental-strip-types bin/loopover-mcp.ts --help = ~0.3-0.4s real time,
    vs. ~0.05s for bare node -e "1" -- the ~250-350ms delta is module-graph resolution + type-stripping
    the CLI's own import graph, not V8 recompilation).
  • The SAME file in the full-suite run: 43.1s -- roughly 3x the isolated cost. This gap is CPU
    oversubscription: vitest's default worker pool runs many files concurrently (12 cores on the machine
    this was measured on), and dozens of subprocess-spawning files firing real OS process creation
    simultaneously overwhelms it.

Already ruled out as a free fix: NODE_COMPILE_CACHE (Node 22's built-in V8 compile cache) -- tested
directly, made no measurable difference (0.27-0.34s either way across 5 runs with/without). The cost is
module resolution, not bytecode recompilation. Also tried --pool=forks vs. the default threads pool
on a single file in isolation -- no meaningful difference (15.78s vs. 16.44s, within run-to-run noise).

Candidate fixes (neither attempted -- both need real design + validation)

  1. Constrain concurrency for subprocess-heavy files specifically, e.g. a vitest workspace/projects
    split so mcp-cli-*.test.ts (minus the -inprocess/-stdio files) runs with a lower maxWorkers/
    poolOptions than the rest of the suite, without reducing parallelism for the other ~1,084 files.
    Needs careful validation against how CI currently shards + merges coverage (test:coverage runs
    sharded in CI, merged after -- see .claude/skills/contributing-to-loopover/reference.md) so a
    projects split doesn't silently break shard balancing or coverage merging. Requires a real controlled
    A/B measurement of full-suite wall-clock before/after, not just this one file.
  2. Reduce subprocess-spawn COUNT per file by converting cases that don't actually need real-process
    boundary realism (e.g. argument-validation-only tests, or tests already covered by an -inprocess
    sibling) to the in-process harness, file by file. This needs per-file judgment -- risks silently
    losing genuine subprocess-boundary coverage (real argv parsing, real exit codes, real stdout framing)
    if done carelessly. Start with the 5 heaviest files (packets/maintain/doctor/basics/profiles, 179
    combined run() calls) since they dominate the total.

Requirements for whoever picks this up

  • Real before/after full-suite wall-clock measurement for any concurrency change (single-file timing
    is not representative -- see the 3x isolated-vs-contended gap above).
  • Never weaken or delete a subprocess-based test to make it faster; the subprocess boundary is the
    point of these specific tests.
  • Verify CI's existing shard balancing and coverage-merge behavior is unaffected by any workspace/
    projects restructuring, in a dedicated CI run before merging.

Metadata

Metadata

Assignees

Labels

maintainer-onlyOwner-only work — yields no Gittensor points.

Projects

Status
Todo
Status
Todo

Relationships

None yet

Development

No branches or pull requests

Issue actions