Skip to content

fix(ci): bound the mcp-conformance-fixture beforeAll with hookTimeout - #2239

Closed
willgriffin wants to merge 1 commit into
mainfrom
fix/issue-2238-conformance-hook-timeout
Closed

fix(ci): bound the mcp-conformance-fixture beforeAll with hookTimeout#2239
willgriffin wants to merge 1 commit into
mainfrom
fix/issue-2238-conformance-hook-timeout

Conversation

@willgriffin

Copy link
Copy Markdown
Contributor

Summary

packages/mcp-conformance-fixture/vitest.config.ts set testTimeout: 240_000
but no hookTimeout, so its beforeAll ran against vitest's 10s default
hooks do not inherit testTimeout. On a loaded runner that hook measured
10,022ms and ejected PR #2230 from the merge queue.

The fix is hookTimeout: 240_000 alongside the existing testTimeout, matching
the repo's prevailing "match testTimeout" convention (smrt-svelte, ads, chat,
cli, content, …; core goes higher at 60s/120s; bundle-gate is the one package
that deliberately diverges).

The diagnosis was verified, not assumed

Everything that could have been supplying a bound was ruled out:

  • The hook takes no per-hook timeout argument (beforeAll(async () => {…})).
  • vitest.workspace.ts is a re-export shim, not a vitest workspace config
    it exports smrtVitestPlugin and a setup path. There is no root vitest config.
  • smrtVitestPlugin sets no timeouts (the only Timeout hits in
    packages/vitest/src are two setTimeout sleeps in test-db.ts).
  • CI passes no timeout flag or env: the package's test script is a bare
    vitest run, invoked through turbo.

Two positive reproductions, both against the unmodified hook:

probe result
vitest run --hookTimeout=1 Error: Hook timed out in 1ms. — 1 file failed, 2 tests skipped
probe config with hookTimeout: 1 same failure

That is the CI signature exactly (Hook timed out in 10000ms, 1 failed, 2
skipped), and the second probe confirms the config-level option is the
operative one, not just the CLI flag.

Why the hook, and not the tests, is the expensive part

beforeAll does essentially all of the suite's work — slightly more than the
issue described, since it spawns two tsx children, not one:

  1. rm -rf + mkdir the generated dir
  2. MCPGenerator.generateServer()
  3. spawn tsx over stdio → connect Client → negotiate 2026-07-28listTools
  4. startHttpAdapter() → spawn a second tsx for the HTTP adapter

The two test bodies only issue requests against what the hook already built:
25ms and 192ms locally, against a 240s budget. Locally the hook is ~1.94s of
the 2.16s total; CI's >10s means roughly 5x amplification under load, which is
ordinary for a shard runner spawning child processes.

Blast radius

The fixture is private: true but has a test script, and
scripts/test-packages-shard.mjs excludes only smrt-core — so it runs in the
shared test-packages shard. The 22ms margin was never local to #2230: any PR
in the merge queue could hit it, after a full merge-group validation cycle had
already been spent.

Sibling sweep — none affected

33 of 58 vitest.config.ts files set testTimeout without hookTimeout, but
the omission only matters where a hook does heavy work. Only three packages
have process-spawning or generation code anywhere in their test files, and in
all three it sits in test bodies rather than hooks:

package hook does measured budget
smrt-app-mcp in-process createHttpServer + listen(0) conformance test 189ms 15s
smrt-dev-mcp in-process createHttpServer + listen(0) conformance test 622ms 30s
tenancy in-memory sqlite seed (generateServer is inside it() bodies) 30s

Their hooks are tens of milliseconds; even 100x CI amplification stays far under
the 10s default. No sibling needs this fix, so the change stays at one package.

Documentation

The issue notes this trap is "worth a line in the testing conventions if it bites
a third time" — it has (smrt-svelte teardown in #1426, then #2220, now this), so
it is recorded in TESTING_STANDARD.md and .claude/rules/testing.md. The
signature is worth naming because it is misleading: the file fails while every
test body reports as skipped, so it does not read as a timeout in the test
you were budgeting for.

Validation

  • @happyvertical/smrt-mcp-conformance-fixture full suite: 2 files/2 tests
    passed
    , 3.26s (transform 376ms, import 845ms, tests 2.33s)
  • tsc --noEmit green for the fixture package
  • pnpm smrt dev:knowledge-check: fresh, 0 errors, 0 warnings
  • pnpm check:agents-chain: 65 chains under the 32,768-byte cap (the two

    80% warnings, packages/users and packages/chat, are pre-existing and
    untouched here)

  • biome check on the three touched paths: all three are ignored by
    biome.json (a vitest config and two markdown files), so there is nothing for
    it to lint
  • git status clean apart from the three intended files; git diff-tree on the
    commit confirms exactly those three

Closes #2238

{"schema":"hv-agent-run:v1","runtime":"claude","session":"53e2c43c-41e2-4f9f-a63a-996baed10afd","issue":"2238","policy_revision":"1.0.0","status":"complete","validation":["mcp-conformance-fixture full suite: 2 files / 2 tests passed in 3.26s","tsc --noEmit green for the fixture package","diagnosis reproduced twice on the unmodified hook: --hookTimeout=1 and a probe config with hookTimeout:1 both yield the CI signature (1 failed, 2 skipped)","ruled out per-hook argument, root/workspace config, smrtVitestPlugin, and CI flags as alternative sources of the bound","sibling sweep of 33 testTimeout-without-hookTimeout configs: only smrt-app-mcp (189ms/15s), smrt-dev-mcp (622ms/30s), tenancy spawn or generate, and all do so in test bodies, not hooks","pnpm smrt dev:knowledge-check fresh (0 errors, 0 warnings)","pnpm check:agents-chain: 65 chains under cap"]}

`packages/mcp-conformance-fixture/vitest.config.ts` set `testTimeout: 240_000`
but no `hookTimeout`, so its `beforeAll` ran against vitest's 10s default.
Hooks do not inherit `testTimeout`.

Nearly all of that suite's work is in the hook, not the test bodies: it
regenerates the MCP server with `MCPGenerator`, spawns a `tsx` child over
stdio to negotiate the protocol and list tools, then spawns a second `tsx`
child for the HTTP adapter. The two test bodies only issue requests against
what the hook already built (25ms + 192ms locally, against 240s).

On a loaded runner the hook measured 10,022ms against the 10,000ms default
and ejected PR #2230 from the merge queue. The package sits in the shared
`test-packages` shard, so the miss was never local to that PR: any PR in the
queue could hit it, after a full merge-group validation cycle had already
been spent.

Verified rather than assumed. The hook takes no per-hook timeout argument;
`vitest.workspace.ts` is a re-export shim, not a vitest workspace config;
`smrtVitestPlugin` sets no timeouts; and CI passes no timeout flag or env.
Nothing else was ever supplying a bound. Running the unmodified suite with
`--hookTimeout=1` reproduces the CI signature exactly -- "Hook timed out in
1ms", 1 file failed, 2 tests skipped -- and a probe config carrying
`hookTimeout: 1` fails the same way, confirming the config-level option is
the operative one.

Also documents the trap in TESTING_STANDARD.md and .claude/rules/testing.md.
This is its third occurrence (smrt-svelte teardown in #1426, then #2220), and
its signature is misleading: the file fails while every test body reports as
skipped, so it does not read as a timeout in the test you budgeted for.

Swept the other 32 `vitest.config.ts` files that set `testTimeout` without
`hookTimeout`. Only three packages have process-spawning or generation code
anywhere in their test files -- smrt-app-mcp, smrt-dev-mcp, tenancy -- and in
all three that work sits in test bodies, not hooks. Their hooks are
in-process HTTP listens or in-memory sqlite seeds. Measured: smrt-app-mcp's
conformance test 189ms against 15s, smrt-dev-mcp's 622ms against 30s. No
sibling needs the same fix.

Closes #2238
Copilot AI review requested due to automatic review settings August 5, 2026 20:35
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents nondeterministic CI failures in @happyvertical/smrt-mcp-conformance-fixture by explicitly bounding Vitest hook execution time (beforeAll/etc.) with hookTimeout, and documents the repo convention that hook timeouts must be set alongside testTimeout when hooks do meaningful work.

Changes:

  • Add hookTimeout: 240_000 to packages/mcp-conformance-fixture/vitest.config.ts to match the existing testTimeout.
  • Document the “testTimeout does not apply to hooks” pitfall and its CI failure signature in TESTING_STANDARD.md.
  • Add the same timeout guidance to .claude/rules/testing.md for agent/dev workflow consistency.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
packages/mcp-conformance-fixture/vitest.config.ts Sets hookTimeout to match testTimeout, preventing beforeAll from hitting Vitest’s default hook timeout in CI.
TESTING_STANDARD.md Adds a testing convention note explaining independent testTimeout vs hookTimeout and the “tests skipped” failure mode.
.claude/rules/testing.md Captures the timeout rule in the Claude testing rules so it’s less likely to regress.

@willgriffin

Copy link
Copy Markdown
Contributor Author

Superseded by #2242, which carries this branch's commits cherry-picked verbatim (same authors, same messages) alongside the other two CI-lane fixes.

Rationale: these three PRs each needed a full validation pass on the very lane they were unclogging, and #2217/#2237 both edit .github/workflows/test-suite.yml. Landing them as one PR at the front of the queue means one validation cycle and one merge, after which the rest of the board can drain.

The merged result is the union of both workflow changes, verified job by job — the three light jobs are hosted per #2236, every heavy job keeps the CI_HOSTED_FALLBACK_ENABLED selector, and coverage-gate keeps #2217's narrowed if: inputs.mode == 'full' (this branch predated that change where applicable). actionlint is clean on all three workflows.

The branch is preserved. Reopen if #2242 needs to be unwound.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ci): mcp-conformance-fixture beforeAll runs on the 10s default hookTimeout and ejects PRs from the merge queue

2 participants