diff --git a/package.json b/package.json index 2d44950508..99da266671 100644 --- a/package.json +++ b/package.json @@ -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:", diff --git a/packages/utils/src/import.ts b/packages/utils/src/import.ts index d1681c4628..b5b7688f96 100644 --- a/packages/utils/src/import.ts +++ b/packages/utils/src/import.ts @@ -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>(); + export async function importModule(filepath: string, options?: ImportModuleOptions): Promise { const _bundleModuleLoader = globalThis.__EGG_BUNDLE_MODULE_LOADER__; if (_bundleModuleLoader) { @@ -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); // { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6c24f063d7..8029106a4b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/tegg/core/vitest/test/setup.ts b/tegg/core/vitest/test/setup.ts index d43f5f9001..d0abe79747 100644 --- a/tegg/core/vitest/test/setup.ts +++ b/tegg/core/vitest/test/setup.ts @@ -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(); } diff --git a/vitest.config.ts b/vitest.config.ts index c8077f885c..8974b21dfd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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', }, // poolOptions: { // forks: { diff --git a/wiki/concepts/vitest-isolate-false-state-leaks.md b/wiki/concepts/vitest-isolate-false-state-leaks.md index 74be4f85ce..7e1ee49850 100644 --- a/wiki/concepts/vitest-isolate-false-state-leaks.md +++ b/wiki/concepts/vitest-isolate-false-state-leaks.md @@ -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 --- @@ -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/` / `Cannot find module -'@eggjs//package.json'`. This single leak caused most of the cross-project + with `Can not find plugin @eggjs/` / `Cannot find module '@eggjs//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`. @@ -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 `?` 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 diff --git a/wiki/log.md b/wiki/log.md index c3d64c6cf6..207384ed71 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 `?`, 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` @@ -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