Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"devDependencies": {
"@eggjs/bin": "workspace:*",
"@eggjs/tsconfig": "workspace:*",
"@oxc-node/core": "catalog:",
"@types/content-type": "catalog:",
"@types/js-yaml": "catalog:",
"@types/koa-compose": "catalog:",
Expand Down
29 changes: 28 additions & 1 deletion packages/utils/src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ export function setBundleModuleLoader(loader: BundleModuleLoader | undefined): v
globalThis.__EGG_BUNDLE_MODULE_LOADER__ = loader;
}

// Shared promises for ESM imports that are currently in flight, keyed by file URL.
// See the usage site in `importModule` for why this is needed.
const _inflightImports = new Map<string, Promise<any>>();

export async function importModule(filepath: string, options?: ImportModuleOptions): Promise<any> {
const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__;
if (_bundleModuleLoader) {
Expand Down Expand Up @@ -525,7 +529,30 @@ export async function importModule(filepath: string, options?: ImportModuleOptio
if (_bundleModuleLoader) {
obj = await getNativeDynamicImport()(fileUrl);
} else {
obj = await import(fileUrl);
// Dedupe concurrent in-flight imports of the same URL. The runtime TS
// transpile loaders (tsx, @oxc-node/core) recompile a module on every
// `import()` (tsx appends a cache-busting query), so when several apps boot
// concurrently in one process (e.g. tegg multi-app isolation) two loaders can
// trigger two simultaneous compiles of the SAME module and one may observe a
// partially-initialized namespace (an `undefined` default export) — surfacing
// downstream as `Cannot convert undefined or null to object` in `loadExtend`
// or a plugin that lost its `path`. Sharing a single `import()` per URL
// serializes those concurrent first-loads.
let pending = _inflightImports.get(fileUrl);
if (pending === undefined) {
pending = import(fileUrl);
_inflightImports.set(fileUrl, pending);
const clearInflight = () => {
if (_inflightImports.get(fileUrl) === pending) {
_inflightImports.delete(fileUrl);
}
};
// `then(clear, clear)` (not `finally`) so a failed import settles the
// cleanup chain without leaving an unhandled rejection — the awaiting
// caller below still observes and propagates the original error.
pending.then(clearInflight, clearInflight);
}
obj = await pending;
}
debug('[importModule:success] await import %o', fileUrl);
// {
Expand Down
2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ catalog:
'@eggjs/scripts': ^4.0.0
'@fengmk2/ps-tree': ^2.0.1
'@oclif/core': ^4.2.0
'@oxc-node/core': ^0.0.35
'@oxc-node/core': ^0.1.0
'@swc-node/register': ^1.11.1
'@swc/core': ^1.15.1
'@types/accepts': ^1.3.7
Expand Down
8 changes: 6 additions & 2 deletions tegg/core/vitest/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ if (!process.env.EGG_TYPESCRIPT) {
process.env.EGG_TYPESCRIPT = 'true';
}

// Ensure child processes spawned by these tests inherit a runtime TS loader so
// Egg can load .ts via `import()`. The root vitest config already injects
// `--import=@oxc-node/core/register`; only inject it ourselves when neither it
// nor the legacy `tsx/esm` hook is present, to avoid enabling both at once.
const nodeOptions = process.env.NODE_OPTIONS ?? '';
if (!nodeOptions.includes('tsx/esm')) {
process.env.NODE_OPTIONS = `${nodeOptions} --import=tsx/esm`.trim();
if (!nodeOptions.includes('@oxc-node/core/register') && !nodeOptions.includes('tsx/esm')) {
process.env.NODE_OPTIONS = `${nodeOptions} --import=@oxc-node/core/register`.trim();
}
10 changes: 6 additions & 4 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ const config: UserWorkspaceConfig = defineConfig({
env: {
// disable tegg plugins by default on unittest, make test speed up
DISABLE_TEGG_PLUGINS: 'true',
// TODO: aop plugin required this flag, otherwise there will be a SyntaxError: Invalid or unexpected token
NODE_OPTIONS: '--import=tsx/esm',
// FIXME: TypeError: Cannot read properties of undefined (reading 'mode')
// NODE_OPTIONS: '--import=@oxc-node/core/register',
// Transpile runtime `import()` of .ts files (egg loader resolving
// fixtures/plugins/app code, and the workspace `src` exports under
// node_modules) with oxc-node — noticeably faster than tsx and it handles
// decorators correctly. Requires @oxc-node/core >= 0.1.0, which fixes the
// earlier "Cannot read properties of undefined (reading 'mode')" crash.
NODE_OPTIONS: '--import=@oxc-node/core/register',
Comment on lines +48 to +52

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6c34277. tegg/core/vitest/test/setup.ts now skips injecting a loader when either @oxc-node/core/register or tsx/esm is already present in NODE_OPTIONS (and falls back to oxc-node otherwise), so the two hooks are no longer enabled together. Verified the tegg/core/* project tests still pass.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
// poolOptions: {
// forks: {
Expand Down
32 changes: 29 additions & 3 deletions wiki/concepts/vitest-isolate-false-state-leaks.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ source_files:
- plugins/multipart/test/file-mode.test.ts
- packages/core/src/lifecycle.ts
- packages/egg/src/lib/egg.ts
updated_at: 2026-06-20
- tegg/plugin/tegg/test/MultiAppParallel.test.ts
updated_at: 2026-06-27
status: active
---

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

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

## Not isolate bugs (do not chase as such)

- `orm-plugin` (`Table 'test.apps' doesn't exist`) needs MySQL; `redis` needs a
Expand Down
8 changes: 7 additions & 1 deletion wiki/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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

## [2026-06-27] concept | fix concurrent-import race in multi-app boot (oxc-node PR #5965)

- sources touched: `packages/utils/src/import.ts`
- pages updated: `wiki/log.md`, `wiki/concepts/vitest-isolate-false-state-leaks.md`
- 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.

## [2026-06-27] workflow | CI surfaces single-run parallelism metrics for the isolate:false suite

- 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`
Expand All @@ -26,7 +32,7 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m

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

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

Expand Down
Loading