Skip to content

test(mcp): cut write-path suite spawns via init-once template clone#62

Merged
ABB65 merged 1 commit into
mainfrom
perf/mcp-test-speed
Jul 9, 2026
Merged

test(mcp): cut write-path suite spawns via init-once template clone#62
ABB65 merged 1 commit into
mainfrom
perf/mcp-test-speed

Conversation

@ABB65

@ABB65 ABB65 commented Jul 9, 2026

Copy link
Copy Markdown
Member

Why the write-path suite is slow

Profiling the MCP write-path tests turned up a counterintuitive root cause: it is not git's work, it is the cost of spawning a subprocess from inside a loaded vitest worker on macOS.

A single contentrain_model_save fans out to 92 git subprocesses, yet their total git execution time is ~0.5s (slowest single command: 25ms). The test still takes ~17s. The gap is spawn latency:

Context git --version /usr/bin/true (no-op)
standalone node ~12ms ~3ms
inside a vitest worker ~170ms ~148ms

A ~14× penalty, on a binary that does nothing. The cost scales with the worker's address space (V8 JIT + mmapped module graph), not with git — fork/posix_spawn has to set up the child against the parent's VM regions. I confirmed it is not fixable by configuration: identical across pool: forks | threads | vmThreads, and unaffected by the FD limit (ulimit), env size, git's gc/maintenance/core.fsync settings, or the git binary. Raising maxForks makes it worse — concurrent spawns contend on kernel VM locks. (On Linux CI the penalty is typically far smaller; this is mostly a macOS-local-dev tax.)

The only lever is spawning git fewer times. The biggest offender was re-running contentrain_init (~28 spawns) in every beforeEach.

The fix

New tests/support/project.ts:

  • makeInitedTemplate() — run contentrain_init once (in beforeAll) into a template repo.
  • cloneTemplate() — hand each test an isolated copy via a recursive file copy (zero git spawns) instead of a fresh init (~28 spawns).
  • createClient / parseResult / initGitRepo — dedupe the helper bodies that were inlined identically across ~9 test files.

Refactored the three heaviest write-path files onto it:

File Pattern Standalone before → after
tools/normalize.test.ts (19 tests) read-only contentrain_scanone shared beforeAll fixture ~250s → 13s
tools/model.test.ts (8 tests) mutating → template-clone per test 130s → 55s
tools/workflow.test.ts (15 tests) mutating → template-clone per test ~270s → 209s

normalize is read-only over its fixture, so all 19 tests share one project. model/workflow mutate, so each test gets its own clone — full isolation preserved, verified by an explicit "two clones don't see each other's models" check during development. workflow's win is smaller because its tests are dominated by body mutations (multiple model_save/content_save transactions each), which the clone doesn't touch.

Verification

  • Each refactored file passes with the same test count as before — standalone: normalize 19/19, model 8/8, workflow 15/15.
  • A combined run of the three refactored files plus the three heaviest unrefactored write-path files (content, apply, apply-guardrails) under realistic worker contention: 134/134 green.
  • oxlint clean, tsc --noEmit clean.
  • The tests/support/project.ts helper is a plain module (not a *.test.ts), so vitest does not collect it as an empty suite.

(A full-suite run on this macOS box takes ~20+ min end-to-end. The only failures seen in a full run were self-inflicted — a leftover profiling probe from root-causing this issue was spawning subprocesses in a tight 16-way loop and starved the real workers' spawns into timeouts; every one vanished once it was deleted. No assertion failures, ever.)

Not done (follow-ups)

  • setup.test.ts needs an uninitialized repo (it tests the init transition) → could use a cheaper git-only template.
  • apply-guardrails.test.ts's Scope block (5 dry-run tests) and doctor.test.ts's minimal-seed tests are read-only-fixture-safe → beforeAll.
  • http.test.ts builds a git repo per test that its mock-provider tests never use → droppable.
  • Runtime-level: createTransaction spawns ~28 git processes per write; batching the per-file selectiveSync checkouts and existence checks would speed up production writes too, not just tests. Separate concern from this PR.

🤖 Generated with Claude Code

Root cause of the slow write-path suite is not git's work but the cost of
spawning a subprocess from inside a loaded vitest worker on macOS: a full
model_save fans out to 92 git processes whose total execution is ~0.5s, yet
the test takes ~17s. Standalone, git --version spawns in ~12ms; inside a
vitest worker it is ~170ms (even /usr/bin/true is ~148ms vs ~3ms) — the
fork/posix_spawn cost scales with the worker's address space, not with git.
Confirmed not fixable by config (identical across forks/threads/vmThreads;
unaffected by FD limit, env size, git gc/maintenance/fsync, or the binary),
and raising maxForks backfires on kernel VM-lock contention. The only lever
is spawning git fewer times.

- tests/support/project.ts: makeInitedTemplate (run contentrain_init ONCE in
  beforeAll) + cloneTemplate (cp -R per test = zero git spawns) + shared
  createClient/parseResult/initGitRepo (were inlined identically ~9x).
- normalize.test.ts: 19 read-only contentrain_scan tests now share ONE
  beforeAll fixture instead of re-initing per test (~250s -> 13s standalone).
- model.test.ts, workflow.test.ts: template-clone per test keeps full
  isolation while dropping the ~28-spawn init from every beforeEach
  (model 130s -> 55s; workflow ~270s -> 209s, body-mutation-bound).

Same test counts (normalize 19, model 8, workflow 15); combined run with the
heaviest unrefactored write-path files is 134/134 green.
@netlify

netlify Bot commented Jul 9, 2026

Copy link
Copy Markdown

Deploy Preview for contentrain-ai ready!

Name Link
🔨 Latest commit 4a87d73
🔍 Latest deploy log https://app.netlify.com/projects/contentrain-ai/deploys/6a4f592c25a2cb0008869722
😎 Deploy Preview https://deploy-preview-62--contentrain-ai.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@ABB65
ABB65 merged commit dc1e7e5 into main Jul 9, 2026
6 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 9, 2026
@ABB65
ABB65 deleted the perf/mcp-test-speed branch July 9, 2026 09:06
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.

1 participant