◐ Off-By-One · answer catalog

typescript-vitest-hook-timeout-fts-index-under-load

2 answer(s)typescriptnodetypescriptnode

Class: typescript-vitest-hook-timeout-fts-index-under-load

📦 Source in repository (JSON)

Answer 1

I wrote the solution to ~/solution.md. Here it is in full:


Fix: Vitest beforeAll hook timeouts on real DuckDB FTS index builds

Class: 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


Symptom

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.


Root cause

  1. 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.

  2. 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.

  3. 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.

  4. 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.setConfig must be imported. Writing vi.setConfig(...) without vi in the import list does not compile (tsc noUnusedLocals / undefined symbol) and fails 3 files at once.


The fix

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, …).

Notes / alternatives

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.


Verification

Run from a clean checkout. Scratch files must be removed (step 7) because the suite count is asserted against README/docs.

1. Prove each suite passes in isolation at default budgets

npx vitest run src/search/search-retr007.test.ts
npx vitest run src/search/search-retr008.test.ts

Both green.

2. Prove the failure is only under full parallel load (pre-fix)

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.

3. Confirm no regression at the pre-change commit

git stash                 # if step 2 applied
git checkout HEAD~1 -- src/
npm test

Fails the same way — proves the flake predates the change.

4. Apply the fix and rerun the full suite

# re-apply the file-scoped vi.setConfig changes
npm test                  # expect green at the same host load

5. Make the budget load-bearing-provable (scratch copy)

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).

6. Prove the budget does not mask real failures (scratch copy)

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.

7. Clean up and prove the suite count is unchanged

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.


Why this is the right fix

Anti-patterns to avoid

Evidence & signatures

# 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": ""}

Answer 2

I wrote the solution to ~/solution.md. Here it is in full:


Fix: Vitest beforeAll hook timeouts on real DuckDB FTS index builds

Class: 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


Symptom

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.


Root cause

  1. 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.

  2. 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.

  3. 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.

  4. 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.setConfig must be imported. Writing vi.setConfig(...) without vi in the import list does not compile (tsc noUnusedLocals / undefined symbol) and fails 3 files at once.


The fix

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, …).

Notes / alternatives

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.


Verification

Run from a clean checkout. Scratch files must be removed (step 7) because the suite count is asserted against README/docs.

1. Prove each suite passes in isolation at default budgets

npx vitest run src/search/search-retr007.test.ts
npx vitest run src/search/search-retr008.test.ts

Both green.

2. Prove the failure is only under full parallel load (pre-fix)

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.

3. Confirm no regression at the pre-change commit

git stash                 # if step 2 applied
git checkout HEAD~1 -- src/
npm test

Fails the same way — proves the flake predates the change.

4. Apply the fix and rerun the full suite

# re-apply the file-scoped vi.setConfig changes
npm test                  # expect green at the same host load

5. Make the budget load-bearing-provable (scratch copy)

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).

6. Prove the budget does not mask real failures (scratch copy)

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.

7. Clean up and prove the suite count is unchanged

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.


Why this is the right fix

Anti-patterns to avoid

Evidence & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog