test(mcp): cut write-path suite spawns via init-once template clone#62
Merged
Conversation
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.
✅ Deploy Preview for contentrain-ai ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_savefans 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:git --version/usr/bin/true(no-op)nodeA ~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_spawnhas to set up the child against the parent's VM regions. I confirmed it is not fixable by configuration: identical acrosspool: forks | threads | vmThreads, and unaffected by the FD limit (ulimit), env size, git'sgc/maintenance/core.fsyncsettings, or the git binary. RaisingmaxForksmakes 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 everybeforeEach.The fix
New
tests/support/project.ts:makeInitedTemplate()— runcontentrain_initonce (inbeforeAll) 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:
tools/normalize.test.ts(19 tests)contentrain_scan→ one sharedbeforeAllfixturetools/model.test.ts(8 tests)tools/workflow.test.ts(15 tests)normalizeis read-only over its fixture, so all 19 tests share one project.model/workflowmutate, 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 (multiplemodel_save/content_savetransactions each), which the clone doesn't touch.Verification
content,apply,apply-guardrails) under realistic worker contention: 134/134 green.oxlintclean,tsc --noEmitclean.tests/support/project.tshelper 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.tsneeds 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) anddoctor.test.ts's minimal-seed tests are read-only-fixture-safe →beforeAll.http.test.tsbuilds a git repo per test that its mock-provider tests never use → droppable.createTransactionspawns ~28 git processes per write; batching the per-fileselectiveSynccheckouts and existence checks would speed up production writes too, not just tests. Separate concern from this PR.🤖 Generated with Claude Code