Skip to content

Commit 47b0ce5

Browse files
killaclaude
authored andcommitted
docs(wiki): document vitest isolate:false state leaks
Record the root causes (import.ts snapshot loader / isESM leak, mock mockContext wrong-app reuse), the CI-faithful triage method (Node 22/24 + utoo install, run-alone rule), and which remaining failures are environmental or pre-existing load flakes rather than isolate bugs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 280fa84 commit 47b0ce5

3 files changed

Lines changed: 100 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
title: Vitest isolate:false state leaks
3+
type: concept
4+
summary: Why the root vitest config (pool:threads + isolate:false) exposes cross-file/cross-project state leaks, the concrete leaks found, and how they were fixed.
5+
source_files:
6+
- vitest.config.ts
7+
- packages/utils/src/import.ts
8+
- packages/utils/test/snapshot-import.test.ts
9+
- plugins/mock/src/app/extend/application.ts
10+
- plugins/mock/src/lib/mock_agent.ts
11+
- plugins/multipart/test/file-mode.test.ts
12+
updated_at: 2026-06-08
13+
status: active
14+
---
15+
16+
## Context
17+
18+
The root `vitest.config.ts` runs the whole monorepo with `pool: 'threads'` and
19+
`isolate: false`. Under this mode every test **file** in a worker shares one
20+
Node realm: the module registry, `globalThis`, module-level `let` bindings, the
21+
undici global dispatcher, `process` env/listeners and timers are all shared
22+
across files (and across `projects`, since projects share the worker pool).
23+
24+
This is much faster, but any module that caches process- or realm-global state
25+
without resetting it leaks that state into later files. Because vitest schedules
26+
files across threads, *which* test loses is order/timing dependent — so failures
27+
are **nondeterministic** and move from run to run. That nondeterminism is the
28+
signature of this class of bug, not flaky tests per se.
29+
30+
## How to reproduce / triage (CI-faithful)
31+
32+
- Use Node 22 or 24 (CI matrix). Node 26 introduces unrelated undici/deprecation
33+
failures that are NOT isolate bugs — do not diagnose on Node 26.
34+
- Install with utoo (`ut install --from pnpm`), not a bare `pnpm install`. utoo
35+
hoists workspace packages (e.g. `egg`) to the root `node_modules`; tests like
36+
`cluster/options` and `mock/format_options` resolve the framework via
37+
`getFrameworkPath('egg')` from a fixture `baseDir` and only pass with that
38+
hoisting. A non-utoo install causes phantom "egg is not found" /
39+
"Cannot find module" failures that masquerade as isolate bugs.
40+
- Clear `dist/` first (see AGENTS.md "Local CI" / duplicate-proto note).
41+
- Triage rule: run a failing file **alone**. Passes alone but fails in the full
42+
run ⇒ genuine cross-file leak. Fails alone too ⇒ a real bug or an
43+
environmental dependency (MySQL/redis/DNS), not isolation.
44+
45+
## Root causes found
46+
47+
1. **Module-resolution poisoning via `@eggjs/utils` import.ts** (cross-project).
48+
`setSnapshotModuleLoader()` set a module-level `_snapshotModuleLoader` and
49+
flipped the module-level `isESM` to `false`, with no way to unset it.
50+
`snapshot-import.test.ts` had a no-op `afterEach`, so after it ran, every
51+
later file in the worker resolved modules in CJS + snapshot mode and failed
52+
with `Can not find plugin @eggjs/<x>` / `Cannot find module
53+
'@eggjs/<x>/package.json'`. This single leak caused most of the cross-project
54+
failures (ajv-plugin, typebox-validate, view-nunjucks, standalone, …).
55+
**Fix:** `setSnapshotModuleLoader(undefined)` now clears the loader and
56+
restores the auto-detected `isESM`; the test clears it in `afterEach`.
57+
(`setBundleModuleLoader` and the tegg/core loader tests already reset their
58+
`globalThis.__EGG_BUNDLE_MODULE_LOADER__` in `afterEach`, so they were fine.)
59+
60+
2. **Wrong-app context reuse in `@eggjs/mock` `mockContext()`.**
61+
`mockContext()` reused `this.currentContext` without checking the context
62+
belongs to `this` app. With a shared/lingering async-local context (multiple
63+
`mm.app()` apps in one realm, or `isolate:false` carry-over), `app2.mockContext()`
64+
returned app1's context, binding helpers/services to the wrong app config.
65+
This produced e.g. `security/surl` "custom white protocol" → `''` (app2's
66+
helper read app1's whitelist) and `security/csrf` 401s.
67+
**Fix:** only reuse when `this.currentContext.app === this`.
68+
69+
3. **Potential undici global dispatcher carry-over in `@eggjs/mock`
70+
`mock_agent.ts`.** Dispatcher state is stored on `globalThis`
71+
(`__globalDispatcher`, `__mockAgent`) and `__globalDispatcher` is captured
72+
once and never cleared. Mitigated in practice by the global
73+
`afterEach(mock.restore)` in `setup_vitest.ts`; noted as a latent risk.
74+
75+
## Not isolate bugs (do not chase as such)
76+
77+
- `orm-plugin` (`Table 'test.apps' doesn't exist`) needs MySQL; `redis` needs a
78+
redis server; `security/ssrf` (`IllegalAddressError: illegal address`) needs
79+
DNS/network. These pass in CI where the services exist; they fail in a bare
80+
local run regardless of isolation.
81+
- `multipart/file-mode` is **load-sensitive and flakes even with `isolate:true`**
82+
(fails ~1/4 when run alongside the other multipart files). Deep tracing showed
83+
the uploaded file is reported (200 + path) but `existsSync` is already false
84+
right after the save `pipeline()` resolves under load — a race in the multipart
85+
file-save path, not a state leak. It passes 100% as a single file. Treat as a
86+
pre-existing flaky test to fix in the multipart save path or test, separately
87+
from the isolate:false work.
88+
89+
## Result
90+
91+
Full Node-22 suite under `isolate:false`: 15 failing files → 3, of which 2 are
92+
environmental (MySQL/DNS, green in CI) and 1 (`multipart/file-mode`) is a
93+
pre-existing load flake independent of isolation.

wiki/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Read this file before exploring raw sources.
77
## Concepts
88

99
- [Repository Map](./concepts/repository-map.md) - High-level map of the main repository areas and where to look first.
10+
- [Vitest isolate:false state leaks](./concepts/vitest-isolate-false-state-leaks.md) - Why pool:threads + isolate:false exposes cross-file/cross-project state leaks, the concrete leaks (import.ts snapshot loader, mock mockContext), and how to triage them.
1011

1112
## Workflows
1213

wiki/log.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
Dates use the workspace-local Asia/Shanghai calendar date.
44

5+
## [2026-06-08] concept | vitest isolate:false state leaks diagnosed and fixed
6+
7+
- sources touched: `packages/utils/src/import.ts`, `packages/utils/test/snapshot-import.test.ts`, `plugins/mock/src/app/extend/application.ts`
8+
- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/concepts/vitest-isolate-false-state-leaks.md`
9+
- note: Under root `pool:threads` + `isolate:false`, two realm-global leaks caused nondeterministic cross-file/cross-project failures. (1) `setSnapshotModuleLoader` left module-level `_snapshotModuleLoader`/`isESM=false` set (no-op test teardown), poisoning module resolution for later files (`Can not find plugin …`). (2) `mock.mockContext()` reused `currentContext` from a different app, binding helpers to the wrong app config (surl/csrf failures). Fixed both at the source. Full Node-22 suite: 15 → 3 failing files (remaining 2 environmental MySQL/DNS; `multipart/file-mode` is a pre-existing load flake that also fails under `isolate:true`). Reproduce on Node 22/24 with a utoo install — not Node 26 / bare pnpm.
10+
511
## [2026-05-10] package | extract shared LoaderFS package
612

713
- sources touched: `packages/loader-fs/src/index.ts`, `packages/loader-fs/package.json`, `packages/core/src/index.ts`, `packages/core/src/loader/file_loader.ts`, `packages/core/src/loader/egg_loader.ts`

0 commit comments

Comments
 (0)