From 3f2ddc21a83f3ebf16be50a1b31982ee9060266f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 29 Aug 2026 18:08:13 +0200 Subject: [PATCH] fix(vm): link module graphs one at a time (#11096) --- .../vitest/src/runtime/vm/esm-executor.ts | 37 ++++++++++++++----- test/e2e/test/vm-threads.test.ts | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/vitest/src/runtime/vm/esm-executor.ts b/packages/vitest/src/runtime/vm/esm-executor.ts index 5bc3c3bc1..7c6eb3183 100644 --- a/packages/vitest/src/runtime/vm/esm-executor.ts +++ b/packages/vitest/src/runtime/vm/esm-executor.ts @@ -92,6 +92,7 @@ export class EsmExecutor { private moduleCache = new Map>() private esmLinkMap = new WeakMap>() + private linkQueue: Promise = Promise.resolve() private context: vm.Context #httpIp = IPnumber('127.0.0.0') @@ -109,16 +110,7 @@ export class EsmExecutor { if (m.status === 'errored') { throw m.error } - if (m.status === 'unlinked') { - this.esmLinkMap.set( - m, - m.link((identifier, referencer) => - this.executor.resolveModule(identifier, referencer.identifier), - ), - ) - } - - await this.esmLinkMap.get(m) + await this.linkModule(m) if (m.status === 'linked') { await m.evaluate() @@ -127,6 +119,31 @@ export class EsmExecutor { return m } + // Roots are linked one at a time: Node's link() does not wait for a + // dependency that another root is still linking, and instantiate() then + // fails on it. Sharing the queue with all roots keeps cycle handling to + // Node's own single-root linker, which never has to wait. + private linkModule(m: VMModule): Promise { + const pending = this.esmLinkMap.get(m) + if (pending) { + return pending + } + if (m.status !== 'unlinked' && m.status !== 'linking') { + return Promise.resolve() + } + const linking = this.linkQueue.then(() => { + if (m.status === 'unlinked') { + return m.link(this.linker) + } + }) + this.esmLinkMap.set(m, linking) + this.linkQueue = linking.catch(() => {}) + return linking + } + + private linker = (identifier: string, referencer: VMModule): Promise => + this.executor.resolveModule(identifier, referencer.identifier) + public async createEsModule( fileURL: string, getCode: () => Promise | string, diff --git a/test/e2e/test/vm-threads.test.ts b/test/e2e/test/vm-threads.test.ts index 7b4f68547..79d075dd4 100644 --- a/test/e2e/test/vm-threads.test.ts +++ b/test/e2e/test/vm-threads.test.ts @@ -102,6 +102,43 @@ test('vm pools report errors from modules covered by the graph prewarm', async ( expect(stderr).toContain('does-not-exist.js') }) +// Node's link() does not wait for a dependency that another root is still +// linking. The shared module needs an import that resolves asynchronously +// (here a Vite-transformed stylesheet) to open the window +test.for(['vmThreads', 'vmForks'] as const)( + '%s links a dependency shared by concurrent imports once', + async (pool) => { + const { stderr, exitCode } = await runInlineTests({ + 'node_modules/shared-dep/package.json': JSON.stringify({ + name: 'shared-dep', + type: 'module', + exports: { './a': './a.js', './b': './b.js' }, + }), + 'node_modules/shared-dep/a.js': `export { value } from './shared.js'; export const fromA = 'a'`, + 'node_modules/shared-dep/b.js': `export { value } from './shared.js'; export const fromB = 'b'`, + 'node_modules/shared-dep/shared.js': `import './style.css'; export const value = 'shared'`, + 'node_modules/shared-dep/style.css': `.a { color: red }`, + 'basic.test.js': ` + import { expect, test } from 'vitest' + + test('concurrent imports share a dependency', async () => { + const [a, b] = await Promise.all([ + import('shared-dep/a'), + import('shared-dep/b'), + ]) + expect(a.fromA).toBe('a') + expect(b.fromB).toBe('b') + expect(a.value).toBe('shared') + expect(b.value).toBe('shared') + }) + `, + }, { pool }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }, +) + // The module-sync condition was added in Node 22.12/20.19 when require(esm) // was unflagged. The fix uses the _resolveFilename conditions option which // is only available on Node 22.12+. Node 20 is unfixable and reaches EOL -- 2.51.2