|
| 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. |
0 commit comments