Class: typescript-vitest-hook-timeout-fts-index-under-load
I wrote the solution to ~/solution.md. Here it is in full:
beforeAll hook timeouts on real DuckDB FTS index buildsClass: typescript-vitest-hook-timeout-fts-index-under-load
Stack: Vitest 4.1.10 (also 3.x), Node 22, DuckDB native fts extension, 132 test files, maxWorkers: 4
When the full suite runs in parallel on a loaded host, suites that build a real DuckDB FTS sidecar fail in beforeAll:
Error: Hook timed out in 10000ms.
at src/search/search-retr007.test.ts:74:1 (beforeAll)
at src/search/search-retr008.test.ts:75:1 (beforeAll)
Every affected suite is green in isolation. The failure then cascades: one hook timeout is followed by an ENOENT on the atomic sidecar rename (.search/fts.duckdb.tmp -> .search/fts.duckdb) inside a different suite, so 4+ unrelated suites report red while the code is correct.
Setting only testTimeout in vitest.config.ts changes nothing — the hook budget is a separate key with its own default.
Vitest has two independent budgets. testTimeout (default 5000 ms) and hookTimeout (default 10000 ms) are separate config keys. Raising testTimeout does not raise hookTimeout. A beforeAll that does native work is governed only by hookTimeout.
The work is genuinely slower than the default budget under load. Building the FTS index is native DuckDB work measured at 9–22 s per suite. In isolation the host is idle enough to finish under 10 s; with maxWorkers: 4 on a host at load average 5–8 / 16 cores, the same build crosses 10 s. The code is correct; the budget is what fails.
The ENOENT is a cascade, not a second bug. A killed/timed-out hook leaves the on-disk sidecar lifecycle inconsistent (partial .search/fts.duckdb.tmp, or a rebuild retriggered by the next suite). With several workers sharing the .search/fts.duckdb path, a concurrent rebuild can observe the .tmp file already consumed/removed, producing ENOENT on rename(.tmp -> fts.duckdb). Fixing the hook budget removes the window that triggers the rename race.
Global raises are the anti-pattern. Raising hookTimeout globally hides this class everywhere and removes the signal that a hook is slow. The fix must be file-scoped to the suites that actually build an index.
vi.setConfigmust be imported. Writingvi.setConfig(...)withoutviin the import list does not compile (tscnoUnusedLocals / undefined symbol) and fails 3 files at once.
In each suite that builds an FTS sidecar, add a file-scoped budget at module scope, next to the imports, before any beforeAll/it is registered:
// src/search/search-retr007.test.ts
import { beforeAll, describe, expect, it, vi } from 'vitest';
// File-scoped budget: this suite builds a real DuckDB FTS index (~9-22s of
// native work). Keep it bounded to this file — do NOT raise hookTimeout globally.
vi.setConfig({ hookTimeout: 60_000, testTimeout: 60_000 });
describe('search-retr007', () => {
beforeAll(async () => {
// ...build .search/fts.duckdb...
}, /* optional per-hook timeout, see below */);
});
Apply the same two lines to every suite that builds an index (search-retr007.test.ts, search-retr008.test.ts, …).
ts
beforeAll(async () => { /* build index */ }, 60_000);
Prefer vi.setConfig when several hooks/fixtures in the file need the larger budget; prefer the per-hook arg when exactly one hook needs it.
Do not add hookTimeout to vitest.config.ts. That is the global raise this class warns against; the budget must stay bounded to the files that need it.
vi.setConfig applies to the current test file only and must run at module scope (not inside beforeAll) so it takes effect before the hook runs.
Run from a clean checkout. Scratch files must be removed (step 7) because the suite count is asserted against README/docs.
npx vitest run src/search/search-retr007.test.ts
npx vitest run src/search/search-retr008.test.ts
Both green.
git stash # ensure pre-fix budgets
npm test # full suite, maxWorkers: 4
Expect Hook timed out in 10000ms and the cascade ENOENT ... .search/fts.duckdb.tmp.
git stash # if step 2 applied
git checkout HEAD~1 -- src/
npm test
Fails the same way — proves the flake predates the change.
# re-apply the file-scoped vi.setConfig changes
npm test # expect green at the same host load
cp src/search/search-retr007.test.ts src/search/__scratch_hook.test.ts
# edit the scratch copy:
# vi.setConfig({ hookTimeout: 1, testTimeout: 60_000 });
npx vitest run src/search/__scratch_hook.test.ts
Must report:
Error: Hook timed out in 1ms.
This proves the file-scoped config is honored (if ignored, the hook would pass).
cp src/search/search-retr007.test.ts src/search/__scratch_assert.test.ts
# edit scratch: expect(true).toBe(false) (or invert one real expectation)
npx vitest run src/search/__scratch_assert.test.ts
Must report an AssertionError with a summary like N-1 passed | 1 failed — a larger hook budget does not hide a real defect.
rm -f src/search/__scratch_hook.test.ts src/search/__scratch_assert.test.ts
git status --porcelain
git status must show no untracked *.test.ts file — a leftover scratch file changes the suite count that CI asserts against README/docs.
hookTimeout, so a genuinely hung hook elsewhere still fails fast.ENOENT rename in unrelated suites.hookTimeout globally in vitest.config.ts to hide the class.ENOENT as an independent bug and patching the rename.vi.setConfig without importing vi.*.test.ts files behind (changes the asserted suite count).# Evidence - Problem class: typescript-vitest-hook-timeout-fts-index-under-load - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T01:59:55.170Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Vitest beforeAll hooks that build real DuckDB FTS sidecars (9-22s of native work) blow vitest's DEFAULT 10s hookTimeout whenever the full suite runs in parallel (maxWorkers 4) on a loaded host, even though every affected suite passes in isolation. The red is a false positive that then cascades: one hook timeout produces an ENOENT on the atomic `.search/fts.duckdb.tmp -> fts.duckdb` rename inside the NEXT suite's rebuild, so 4+ unrelated suites report failures and the whole gate reads red while the code is fine. Setting only `testTimeout` in vitest.config.ts does NOT help \u2014 the hook budget is a separate key with its own default.", "environment": "vitest 4.1.10 (also seen on 3.x), node 22, DuckDB native fts extension, 132 test files, maxWorkers: 4, host load average 5-8 on 16 cores", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "typescript-vitest-hook-timeout-fts-index-under-load", "provider": "openrouter", "solved_at": "2026-09-17T01:59:55.170Z", "version": ""}I wrote the solution to ~/solution.md. Here it is in full:
beforeAll hook timeouts on real DuckDB FTS index buildsClass: typescript-vitest-hook-timeout-fts-index-under-load
Stack: Vitest 4.1.10 (also 3.x), Node 22, DuckDB native fts extension, 132 test files, maxWorkers: 4
When the full suite runs in parallel on a loaded host, suites that build a real DuckDB FTS sidecar fail in beforeAll:
Error: Hook timed out in 10000ms.
at src/search/search-retr007.test.ts:74:1 (beforeAll)
at src/search/search-retr008.test.ts:75:1 (beforeAll)
Every affected suite is green in isolation. The failure then cascades: one hook timeout is followed by an ENOENT on the atomic sidecar rename (.search/fts.duckdb.tmp -> .search/fts.duckdb) inside a different suite, so 4+ unrelated suites report red while the code is correct.
Setting only testTimeout in vitest.config.ts changes nothing — the hook budget is a separate key with its own default.
Vitest has two independent budgets. testTimeout (default 5000 ms) and hookTimeout (default 10000 ms) are separate config keys. Raising testTimeout does not raise hookTimeout. A beforeAll that does native work is governed only by hookTimeout.
The work is genuinely slower than the default budget under load. Building the FTS index is native DuckDB work measured at 9–22 s per suite. In isolation the host is idle enough to finish under 10 s; with maxWorkers: 4 on a host at load average 5–8 / 16 cores, the same build crosses 10 s. The code is correct; the budget is what fails.
The ENOENT is a cascade, not a second bug. A killed/timed-out hook leaves the on-disk sidecar lifecycle inconsistent (partial .search/fts.duckdb.tmp, or a rebuild retriggered by the next suite). With several workers sharing the .search/fts.duckdb path, a concurrent rebuild can observe the .tmp file already consumed/removed, producing ENOENT on rename(.tmp -> fts.duckdb). Fixing the hook budget removes the window that triggers the rename race.
Global raises are the anti-pattern. Raising hookTimeout globally hides this class everywhere and removes the signal that a hook is slow. The fix must be file-scoped to the suites that actually build an index.
vi.setConfigmust be imported. Writingvi.setConfig(...)withoutviin the import list does not compile (tscnoUnusedLocals / undefined symbol) and fails 3 files at once.
In each suite that builds an FTS sidecar, add a file-scoped budget at module scope, next to the imports, before any beforeAll/it is registered:
// src/search/search-retr007.test.ts
import { beforeAll, describe, expect, it, vi } from 'vitest';
// File-scoped budget: this suite builds a real DuckDB FTS index (~9-22s of
// native work). Keep it bounded to this file — do NOT raise hookTimeout globally.
vi.setConfig({ hookTimeout: 60_000, testTimeout: 60_000 });
describe('search-retr007', () => {
beforeAll(async () => {
// ...build .search/fts.duckdb...
}, /* optional per-hook timeout, see below */);
});
Apply the same two lines to every suite that builds an index (search-retr007.test.ts, search-retr008.test.ts, …).
ts
beforeAll(async () => { /* build index */ }, 60_000);
Prefer vi.setConfig when several hooks/fixtures in the file need the larger budget; prefer the per-hook arg when exactly one hook needs it.
Do not add hookTimeout to vitest.config.ts. That is the global raise this class warns against; the budget must stay bounded to the files that need it.
vi.setConfig applies to the current test file only and must run at module scope (not inside beforeAll) so it takes effect before the hook runs.
Run from a clean checkout. Scratch files must be removed (step 7) because the suite count is asserted against README/docs.
npx vitest run src/search/search-retr007.test.ts
npx vitest run src/search/search-retr008.test.ts
Both green.
git stash # ensure pre-fix budgets
npm test # full suite, maxWorkers: 4
Expect Hook timed out in 10000ms and the cascade ENOENT ... .search/fts.duckdb.tmp.
git stash # if step 2 applied
git checkout HEAD~1 -- src/
npm test
Fails the same way — proves the flake predates the change.
# re-apply the file-scoped vi.setConfig changes
npm test # expect green at the same host load
cp src/search/search-retr007.test.ts src/search/__scratch_hook.test.ts
# edit the scratch copy:
# vi.setConfig({ hookTimeout: 1, testTimeout: 60_000 });
npx vitest run src/search/__scratch_hook.test.ts
Must report:
Error: Hook timed out in 1ms.
This proves the file-scoped config is honored (if ignored, the hook would pass).
cp src/search/search-retr007.test.ts src/search/__scratch_assert.test.ts
# edit scratch: expect(true).toBe(false) (or invert one real expectation)
npx vitest run src/search/__scratch_assert.test.ts
Must report an AssertionError with a summary like N-1 passed | 1 failed — a larger hook budget does not hide a real defect.
rm -f src/search/__scratch_hook.test.ts src/search/__scratch_assert.test.ts
git status --porcelain
git status must show no untracked *.test.ts file — a leftover scratch file changes the suite count that CI asserts against README/docs.
hookTimeout, so a genuinely hung hook elsewhere still fails fast.ENOENT rename in unrelated suites.hookTimeout globally in vitest.config.ts to hide the class.ENOENT as an independent bug and patching the rename.vi.setConfig without importing vi.*.test.ts files behind (changes the asserted suite count).# Evidence - Problem class: typescript-vitest-hook-timeout-fts-index-under-load - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T01:59:55.170Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Vitest beforeAll hooks that build real DuckDB FTS sidecars (9-22s of native work) blow vitest's DEFAULT 10s hookTimeout whenever the full suite runs in parallel (maxWorkers 4) on a loaded host, even though every affected suite passes in isolation. The red is a false positive that then cascades: one hook timeout produces an ENOENT on the atomic `.search/fts.duckdb.tmp -> fts.duckdb` rename inside the NEXT suite's rebuild, so 4+ unrelated suites report failures and the whole gate reads red while the code is fine. Setting only `testTimeout` in vitest.config.ts does NOT help \u2014 the hook budget is a separate key with its own default.", "environment": "vitest 4.1.10 (also seen on 3.x), node 22, DuckDB native fts extension, 132 test files, maxWorkers: 4, host load average 5-8 on 16 cores", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "typescript-vitest-hook-timeout-fts-index-under-load", "provider": "openrouter", "solved_at": "2026-09-17T01:59:55.170Z", "version": ""}