Skip to content

Commit 22d5585

Browse files
killagukillaclaude
authored
perf(test): transpile runtime .ts imports with oxc-node instead of tsx (#5965)
## Motivation The vitest run loads many `.ts` files at runtime via Node's native `import()` — the egg loader resolving fixtures / plugins / app code, plus the workspace `src` exports that resolve under `node_modules` (e.g. `node_modules/egg/src/index.ts`). That path is driven by the `--import` hook in `test.env.NODE_OPTIONS`, which was `tsx/esm`. Swap it to `@oxc-node/core/register` (oxc / Rust-based): noticeably faster than tsx, and it transpiles workspace `src` + decorators correctly. `@oxc-node/core` >= `0.1.0` fixes the earlier `Cannot read properties of undefined (reading 'mode')` crash that had blocked this (the stale FIXME in the config). ## Changes - `vitest.config.ts`: `NODE_OPTIONS` hook `tsx/esm` → `@oxc-node/core/register`; replace the stale FIXME comment. - `pnpm-workspace.yaml`: catalog `@oxc-node/core` `^0.0.35` → `^0.1.0`. - `package.json`: add `@oxc-node/core: catalog:` to root devDependencies. `tsx` is intentionally **kept** as a dependency — it is still used by the `worker_threads` tests, egg-bundler, and a couple of other spots. ## Test evidence (local, Node 22, `isolate:false`, `--retry 2`) Full suite, oxc-node vs tsx — identical pass/fail (only the env-dependent `orm-plugin`/`ssrf` failures), **zero transpile errors** across all decorator-heavy tegg / cluster / mock packages: | | tsx | oxc-node | |---|---|---| | wall | 84s | **79s** | | `tests` (cumulative; where the runtime hook applies) | 817s | **764s** (~6.5% faster) | A transpile-heavy subset showed up to ~13%. The win is on the runtime-transpilation portion; overall suite time is dominated by egg app boot + real I/O, so the wall-clock delta is modest but consistent. ## Note Based on `worktree-vitest-isolate-false` (the isolate:false fix branch / #5964) so CI runs on a green base and the diff is only this change. Will retarget to `next` once that lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Updated the runtime TypeScript loader used for dynamic `.ts` imports during tests to the newer register-based approach, reducing crashes. * Improved Vitest child-process configuration to avoid enabling conflicting TypeScript loader hooks at the same time. * Prevented duplicate work during concurrent dynamic imports by sharing in-flight module loading. * **Documentation** * Refreshed and expanded the Vitest isolation leak troubleshooting guide with an added concurrency race explanation and fix notes. * Updated wording in the existing isolate:false diagnosis notes and added a new changelog entry describing the fix. * **Chores** * Updated the development tooling version for the TypeScript runtime register package in the workspace catalog. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: killa <killa@killadeMacBook-Pro.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4fa509e commit 22d5585

7 files changed

Lines changed: 78 additions & 12 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"devDependencies": {
5353
"@eggjs/bin": "workspace:*",
5454
"@eggjs/tsconfig": "workspace:*",
55+
"@oxc-node/core": "catalog:",
5556
"@types/content-type": "catalog:",
5657
"@types/js-yaml": "catalog:",
5758
"@types/koa-compose": "catalog:",

packages/utils/src/import.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,10 @@ export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): v
474474
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
475475
}
476476

477+
// Shared promises for ESM imports that are currently in flight, keyed by file URL.
478+
// See the usage site in `importModule` for why this is needed.
479+
const _inflightImports = new Map<string, Promise<any>>();
480+
477481
export async function importModule(filepath: string, options?: ImportModuleOptions): Promise<any> {
478482
const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__;
479483
if (_bundleModuleLoader) {
@@ -525,7 +529,30 @@ export async function importModule(filepath: string, options?: ImportModuleOptio
525529
if (_bundleModuleLoader) {
526530
obj = await getNativeDynamicImport()(fileUrl);
527531
} else {
528-
obj = await import(fileUrl);
532+
// Dedupe concurrent in-flight imports of the same URL. The runtime TS
533+
// transpile loaders (tsx, @oxc-node/core) recompile a module on every
534+
// `import()` (tsx appends a cache-busting query), so when several apps boot
535+
// concurrently in one process (e.g. tegg multi-app isolation) two loaders can
536+
// trigger two simultaneous compiles of the SAME module and one may observe a
537+
// partially-initialized namespace (an `undefined` default export) — surfacing
538+
// downstream as `Cannot convert undefined or null to object` in `loadExtend`
539+
// or a plugin that lost its `path`. Sharing a single `import()` per URL
540+
// serializes those concurrent first-loads.
541+
let pending = _inflightImports.get(fileUrl);
542+
if (pending === undefined) {
543+
pending = import(fileUrl);
544+
_inflightImports.set(fileUrl, pending);
545+
const clearInflight = () => {
546+
if (_inflightImports.get(fileUrl) === pending) {
547+
_inflightImports.delete(fileUrl);
548+
}
549+
};
550+
// `then(clear, clear)` (not `finally`) so a failed import settles the
551+
// cleanup chain without leaving an unhandled rejection — the awaiting
552+
// caller below still observes and propagates the original error.
553+
pending.then(clearInflight, clearInflight);
554+
}
555+
obj = await pending;
529556
}
530557
debug('[importModule:success] await import %o', fileUrl);
531558
// {

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ catalog:
1818
'@eggjs/scripts': ^4.0.0
1919
'@fengmk2/ps-tree': ^2.0.1
2020
'@oclif/core': ^4.2.0
21-
'@oxc-node/core': ^0.0.35
21+
'@oxc-node/core': ^0.1.0
2222
'@swc-node/register': ^1.11.1
2323
'@swc/core': ^1.15.1
2424
'@types/accepts': ^1.3.7

tegg/core/vitest/test/setup.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ if (!process.env.EGG_TYPESCRIPT) {
22
process.env.EGG_TYPESCRIPT = 'true';
33
}
44

5+
// Ensure child processes spawned by these tests inherit a runtime TS loader so
6+
// Egg can load .ts via `import()`. The root vitest config already injects
7+
// `--import=@oxc-node/core/register`; only inject it ourselves when neither it
8+
// nor the legacy `tsx/esm` hook is present, to avoid enabling both at once.
59
const nodeOptions = process.env.NODE_OPTIONS ?? '';
6-
if (!nodeOptions.includes('tsx/esm')) {
7-
process.env.NODE_OPTIONS = `${nodeOptions} --import=tsx/esm`.trim();
10+
if (!nodeOptions.includes('@oxc-node/core/register') && !nodeOptions.includes('tsx/esm')) {
11+
process.env.NODE_OPTIONS = `${nodeOptions} --import=@oxc-node/core/register`.trim();
812
}

vitest.config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ const config: UserWorkspaceConfig = defineConfig({
4747
env: {
4848
// disable tegg plugins by default on unittest, make test speed up
4949
DISABLE_TEGG_PLUGINS: 'true',
50-
// TODO: aop plugin required this flag, otherwise there will be a SyntaxError: Invalid or unexpected token
51-
NODE_OPTIONS: '--import=tsx/esm',
52-
// FIXME: TypeError: Cannot read properties of undefined (reading 'mode')
53-
// NODE_OPTIONS: '--import=@oxc-node/core/register',
50+
// Transpile runtime `import()` of .ts files (egg loader resolving
51+
// fixtures/plugins/app code, and the workspace `src` exports under
52+
// node_modules) with oxc-node — noticeably faster than tsx and it handles
53+
// decorators correctly. Requires @oxc-node/core >= 0.1.0, which fixes the
54+
// earlier "Cannot read properties of undefined (reading 'mode')" crash.
55+
NODE_OPTIONS: '--import=@oxc-node/core/register',
5456
},
5557
// poolOptions: {
5658
// forks: {

wiki/concepts/vitest-isolate-false-state-leaks.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ source_files:
1111
- plugins/multipart/test/file-mode.test.ts
1212
- packages/core/src/lifecycle.ts
1313
- packages/egg/src/lib/egg.ts
14-
updated_at: 2026-06-20
14+
- tegg/plugin/tegg/test/MultiAppParallel.test.ts
15+
updated_at: 2026-06-27
1516
status: active
1617
---
1718

@@ -51,8 +52,8 @@ signature of this class of bug, not flaky tests per se.
5152
flipped the module-level `isESM` to `false`, with no way to unset it.
5253
`snapshot-import.test.ts` had a no-op `afterEach`, so after it ran, every
5354
later file in the worker resolved modules in CJS + snapshot mode and failed
54-
with `Can not find plugin @eggjs/<x>` / `Cannot find module
55-
'@eggjs/<x>/package.json'`. This single leak caused most of the cross-project
55+
with `Can not find plugin @eggjs/<x>` / `Cannot find module '@eggjs/<x>/package.json'`.
56+
This single leak caused most of the cross-project
5657
failures (ajv-plugin, typebox-validate, view-nunjucks, standalone, …).
5758
**Fix:** `setSnapshotModuleLoader(undefined)` now clears the loader and
5859
restores the auto-detected `isESM`; the test clears it in `afterEach`.
@@ -101,6 +102,31 @@ signature of this class of bug, not flaky tests per se.
101102
so they do not leak across files, then returns without loading a torn-down
102103
app. New `Lifecycle.isClosed` / `isClosing` getters expose the state.
103104

105+
5. **Concurrent first-`import()` of the same module returns an `undefined`
106+
namespace** (`@eggjs/utils` `importModule`). This is _not_ a state leak but a
107+
concurrency race the same env exposes. When several apps boot **at the same
108+
time** in one process (`describe.concurrent` in
109+
`tegg/plugin/tegg/test/MultiAppParallel.test.ts`, or any concurrent `mm.app`),
110+
multiple loaders call `importModule()` on the **same `.ts` file** simultaneously.
111+
The runtime transpile loaders (`tsx`, `@oxc-node/core`) recompile on every
112+
`import()` — tsx appends a cache-busting `?<ts>` query, so Node does not dedupe
113+
the two compiles — and one caller can observe a partially-initialized namespace
114+
whose `default` is `undefined`. It surfaced two ways, both order/timing
115+
dependent: `Object.getOwnPropertyNames(undefined)`
116+
`Cannot convert undefined or null to object` in `loadExtend`, and a built-in
117+
plugin loaded without its `path``Can not find plugin watcher` (the
118+
framework `config/plugin` module came back empty, so `eggPlugins` was `{}` and
119+
the only `watcher` left was the app's path-less `watcher: false` entry). Fails
120+
under **both** transpilers (~12% tsx, ~24% oxc-node), so it is not transpiler
121+
specific; it also affects real production concurrent multi-app boot, not just
122+
tests. **Fix:** `importModule` shares a single in-flight `import()` per file URL
123+
(`_inflightImports` map, cleared on settle via `then(clear, clear)`), serializing
124+
concurrent first-loads. Note the long detour this took to find: instrumenting
125+
the loader perturbs the timing enough to mask it (a Heisenbug), and the manifest
126+
(`.egg/manifest.json`) read/write looked guilty but was a red herring (disabling
127+
it entirely did not help). The decisive signal was a low-perturbation capture
128+
showing `requireFile` returning `undefined` for a plugin's `app/extend` module.
129+
104130
## Not isolate bugs (do not chase as such)
105131

106132
- `orm-plugin` (`Table 'test.apps' doesn't exist`) needs MySQL; `redis` needs a

wiki/log.md

Lines changed: 7 additions & 1 deletion
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-27] concept | fix concurrent-import race in multi-app boot (oxc-node PR #5965)
6+
7+
- sources touched: `packages/utils/src/import.ts`
8+
- pages updated: `wiki/log.md`, `wiki/concepts/vitest-isolate-false-state-leaks.md`
9+
- note: `tegg/plugin/tegg/test/MultiAppParallel.test.ts` ("…under concurrent boot") flaked ~12% (tsx) / ~24% (oxc-node) on macOS CI with `Can not find plugin watcher` or `Cannot convert undefined or null to object`. NOT caused by the tsx→oxc-node switch (both transpilers flake). Root cause: under `describe.concurrent`, multiple app loaders call `importModule()` on the same `.ts` module simultaneously; the transpile loaders recompile per-`import()` (tsx appends `?<ts>`, defeating Node's dedup) so a concurrent first-load can return a namespace whose `default` is `undefined` → empty framework `config/plugin` (watcher loses its `path`) or `Object.getOwnPropertyNames(undefined)` in `loadExtend`. Fix: `importModule` shares one in-flight `import()` per URL. 40/40 green under both transpilers after; full suite stays 527 files / 3430 tests, 0 failures. Heisenbug (instrumentation masks it); the `.egg/manifest.json` read/write race was a red herring. Recorded as root cause #5 on the concept page.
10+
511
## [2026-06-27] workflow | CI surfaces single-run parallelism metrics for the isolate:false suite
612

713
- sources touched: `vitest.config.ts`, `.github/workflows/ci.yml`, `scripts/ci-test-benchmark/{index,vitest-summary,report,cli,fs,environment}.js`, `benchmark/ci-test/README.md`, `.gitignore`, `packages/supertest/test/supertest.test.ts`
@@ -26,7 +32,7 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m
2632

2733
- sources touched: `packages/utils/src/import.ts`, `packages/utils/test/snapshot-import.test.ts`, `plugins/mock/src/app/extend/application.ts`
2834
- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/concepts/vitest-isolate-false-state-leaks.md`
29-
- 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.
35+
- 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) `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.
3036

3137
## [2026-05-10] package | extract shared LoaderFS package
3238

0 commit comments

Comments
 (0)