Skip to content

Commit fedafc3

Browse files
authored
fix(runtime): refactor ESM code to avoid race condition (#11150)
1 parent 1d8f955 commit fedafc3

File tree

2 files changed

+61
-73
lines changed

2 files changed

+61
-73
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
- `[jest-runtime]` [**BREAKING**] remove long-deprecated `jest.addMatchers`, `jest.resetModuleRegistry`, and `jest.runTimersToTime` ([#9853](https://github.com/facebook/jest/pull/9853))
5252
- `[jest-runtime]` Fix stack overflow and promise deadlock when importing mutual dependant ES module ([#10892](https://github.com/facebook/jest/pull/10892))
5353
- `[jest-runtime]` Prevent global module registry from leaking into `isolateModules` registry ([#10963](https://github.com/facebook/jest/pull/10963))
54+
- `[jest-runtime]` Refactor to prevent race condition when linking and evaluating ES Modules ([#11150](https://github.com/facebook/jest/pull/11150))
5455
- `[jest-transform]` Show enhanced `SyntaxError` message for all `SyntaxError`s ([#10749](https://github.com/facebook/jest/pull/10749))
5556
- `[jest-transform]` [**BREAKING**] Refactor API to pass an options bag around rather than multiple boolean options ([#10753](https://github.com/facebook/jest/pull/10753))
5657
- `[jest-transform]` [**BREAKING**] Refactor API of transformers to pass an options bag rather than separate `config` and other options ([#10834](https://github.com/facebook/jest/pull/10834))

packages/jest-runtime/src/index.ts

+60-73
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,6 @@ import type {Context} from './types';
5757

5858
export type {Context} from './types';
5959

60-
interface EsmModuleCache {
61-
beforeEvaluation: Promise<VMModule>;
62-
fullyEvaluated: Promise<VMModule>;
63-
}
64-
6560
const esmIsAvailable = typeof SourceTextModule === 'function';
6661

6762
interface JestGlobals extends Global.TestFrameworkGlobals {
@@ -176,8 +171,9 @@ export default class Runtime {
176171
private readonly _moduleMocker: ModuleMocker;
177172
private _isolatedModuleRegistry: ModuleRegistry | null;
178173
private _moduleRegistry: ModuleRegistry;
179-
private readonly _esmoduleRegistry: Map<Config.Path, EsmModuleCache>;
174+
private readonly _esmoduleRegistry: Map<Config.Path, VMModule>;
180175
private readonly _cjsNamedExports: Map<Config.Path, Set<string>>;
176+
private readonly _esmModuleLinkingMap: WeakMap<VMModule, Promise<unknown>>;
181177
private readonly _testPath: Config.Path;
182178
private readonly _resolver: Resolver;
183179
private _shouldAutoMock: boolean;
@@ -227,6 +223,7 @@ export default class Runtime {
227223
this._moduleRegistry = new Map();
228224
this._esmoduleRegistry = new Map();
229225
this._cjsNamedExports = new Map();
226+
this._esmModuleLinkingMap = new WeakMap();
230227
this._testPath = testPath;
231228
this._resolver = resolver;
232229
this._scriptTransformer = new ScriptTransformer(config, this._cacheFS);
@@ -368,10 +365,10 @@ export default class Runtime {
368365
);
369366
}
370367

368+
// not async _now_, but transform will be
371369
private async loadEsmModule(
372370
modulePath: Config.Path,
373371
query = '',
374-
isStaticImport = false,
375372
): Promise<VMModule> {
376373
const cacheKey = modulePath + query;
377374

@@ -383,14 +380,11 @@ export default class Runtime {
383380

384381
const context = this._environment.getVmContext();
385382

386-
invariant(context);
383+
invariant(context, 'Test environment has been torn down');
387384

388385
if (this._resolver.isCoreModule(modulePath)) {
389386
const core = this._importCoreModule(modulePath, context);
390-
this._esmoduleRegistry.set(cacheKey, {
391-
beforeEvaluation: core,
392-
fullyEvaluated: core,
393-
});
387+
this._esmoduleRegistry.set(cacheKey, core);
394388
return core;
395389
}
396390

@@ -405,89 +399,49 @@ export default class Runtime {
405399
const module = new SourceTextModule(transformedCode, {
406400
context,
407401
identifier: modulePath,
408-
importModuleDynamically: (
402+
importModuleDynamically: async (
409403
specifier: string,
410404
referencingModule: VMModule,
411-
) =>
412-
this.linkModules(
405+
) => {
406+
const module = await this.resolveModule(
413407
specifier,
414408
referencingModule.identifier,
415409
referencingModule.context,
416-
false,
417-
),
410+
);
411+
412+
return this.linkAndEvaluateModule(module);
413+
},
418414
initializeImportMeta(meta: ImportMeta) {
419415
meta.url = pathToFileURL(modulePath).href;
420416
},
421417
});
422418

423-
let resolve: (value: VMModule) => void;
424-
let reject: (value: any) => void;
425-
const promise = new Promise<VMModule>((_resolve, _reject) => {
426-
resolve = _resolve;
427-
reject = _reject;
428-
});
429-
430-
// add to registry before link so that circular import won't end up stack overflow
431-
this._esmoduleRegistry.set(
432-
cacheKey,
433-
// we wanna put the linking promise in the cache so modules loaded in
434-
// parallel can all await it. We then await it synchronously below, so
435-
// we shouldn't get any unhandled rejections
436-
{
437-
beforeEvaluation: Promise.resolve(module),
438-
fullyEvaluated: promise,
439-
},
440-
);
441-
442-
module
443-
.link((specifier: string, referencingModule: VMModule) =>
444-
this.linkModules(
445-
specifier,
446-
referencingModule.identifier,
447-
referencingModule.context,
448-
true,
449-
),
450-
)
451-
.then(() => module.evaluate())
452-
.then(
453-
() => resolve(module),
454-
(e: any) => reject(e),
455-
);
419+
this._esmoduleRegistry.set(cacheKey, module);
456420
}
457421

458-
const entry = this._esmoduleRegistry.get(cacheKey);
422+
const module = this._esmoduleRegistry.get(cacheKey);
459423

460-
// return the already resolved, pre-evaluation promise
461-
// is loaded through static import to prevent promise deadlock
462-
// because module is evaluated after all static import is resolved
463-
const module = isStaticImport
464-
? entry?.beforeEvaluation
465-
: entry?.fullyEvaluated;
466-
467-
invariant(module);
424+
invariant(
425+
module,
426+
'Module cache does not contain module. This is a bug in Jest, please open up an issue',
427+
);
468428

469429
return module;
470430
}
471431

472-
private linkModules(
432+
private resolveModule(
473433
specifier: string,
474434
referencingIdentifier: string,
475435
context: VMContext,
476-
isStaticImport: boolean,
477436
) {
478437
if (specifier === '@jest/globals') {
479438
const fromCache = this._esmoduleRegistry.get('@jest/globals');
480439

481440
if (fromCache) {
482-
return isStaticImport
483-
? fromCache.beforeEvaluation
484-
: fromCache.fullyEvaluated;
441+
return fromCache;
485442
}
486443
const globals = this.getGlobalsForEsm(referencingIdentifier, context);
487-
this._esmoduleRegistry.set('@jest/globals', {
488-
beforeEvaluation: globals,
489-
fullyEvaluated: globals,
490-
});
444+
this._esmoduleRegistry.set('@jest/globals', globals);
491445

492446
return globals;
493447
}
@@ -504,12 +458,37 @@ export default class Runtime {
504458
this._resolver.isCoreModule(resolved) ||
505459
this.unstable_shouldLoadAsEsm(resolved)
506460
) {
507-
return this.loadEsmModule(resolved, query, isStaticImport);
461+
return this.loadEsmModule(resolved, query);
508462
}
509463

510464
return this.loadCjsAsEsm(referencingIdentifier, resolved, context);
511465
}
512466

467+
private async linkAndEvaluateModule(module: VMModule) {
468+
if (module.status === 'unlinked') {
469+
// since we might attempt to link the same module in parallel, stick the promise in a weak map so every call to
470+
// this method can await it
471+
this._esmModuleLinkingMap.set(
472+
module,
473+
module.link((specifier: string, referencingModule: VMModule) =>
474+
this.resolveModule(
475+
specifier,
476+
referencingModule.identifier,
477+
referencingModule.context,
478+
),
479+
),
480+
);
481+
}
482+
483+
await this._esmModuleLinkingMap.get(module);
484+
485+
if (module.status === 'linked') {
486+
await module.evaluate();
487+
}
488+
489+
return module;
490+
}
491+
513492
async unstable_importModule(
514493
from: Config.Path,
515494
moduleName?: string,
@@ -523,7 +502,9 @@ export default class Runtime {
523502

524503
const modulePath = this._resolveModule(from, path);
525504

526-
return this.loadEsmModule(modulePath, query);
505+
const module = await this.loadEsmModule(modulePath, query);
506+
507+
return this.linkAndEvaluateModule(module);
527508
}
528509

529510
private loadCjsAsEsm(
@@ -1227,12 +1208,18 @@ export default class Runtime {
12271208
displayErrors: true,
12281209
filename: scriptFilename,
12291210
// @ts-expect-error: Experimental ESM API
1230-
importModuleDynamically: (specifier: string) => {
1211+
importModuleDynamically: async (specifier: string) => {
12311212
const context = this._environment.getVmContext?.();
12321213

1233-
invariant(context);
1214+
invariant(context, 'Test environment has been torn down');
1215+
1216+
const module = await this.resolveModule(
1217+
specifier,
1218+
scriptFilename,
1219+
context,
1220+
);
12341221

1235-
return this.linkModules(specifier, scriptFilename, context, false);
1222+
return this.linkAndEvaluateModule(module);
12361223
},
12371224
});
12381225
} catch (e) {

0 commit comments

Comments
 (0)