-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(runtime): refactor ESM code to avoid race condition #11150
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -57,11 +57,6 @@ import type {Context} from './types'; | |
|
||
export type {Context} from './types'; | ||
|
||
interface EsmModuleCache { | ||
beforeEvaluation: Promise<VMModule>; | ||
fullyEvaluated: Promise<VMModule>; | ||
} | ||
|
||
const esmIsAvailable = typeof SourceTextModule === 'function'; | ||
|
||
interface JestGlobals extends Global.TestFrameworkGlobals { | ||
|
@@ -176,8 +171,9 @@ export default class Runtime { | |
private readonly _moduleMocker: ModuleMocker; | ||
private _isolatedModuleRegistry: ModuleRegistry | null; | ||
private _moduleRegistry: ModuleRegistry; | ||
private readonly _esmoduleRegistry: Map<Config.Path, EsmModuleCache>; | ||
private readonly _esmoduleRegistry: Map<Config.Path, VMModule>; | ||
private readonly _cjsNamedExports: Map<Config.Path, Set<string>>; | ||
private readonly _esmModuleLinkingMap: WeakMap<VMModule, Promise<unknown>>; | ||
private readonly _testPath: Config.Path; | ||
private readonly _resolver: Resolver; | ||
private _shouldAutoMock: boolean; | ||
|
@@ -227,6 +223,7 @@ export default class Runtime { | |
this._moduleRegistry = new Map(); | ||
this._esmoduleRegistry = new Map(); | ||
this._cjsNamedExports = new Map(); | ||
this._esmModuleLinkingMap = new WeakMap(); | ||
this._testPath = testPath; | ||
this._resolver = resolver; | ||
this._scriptTransformer = new ScriptTransformer(config, this._cacheFS); | ||
|
@@ -368,10 +365,10 @@ export default class Runtime { | |
); | ||
} | ||
|
||
// not async _now_, but transform will be | ||
private async loadEsmModule( | ||
modulePath: Config.Path, | ||
query = '', | ||
isStaticImport = false, | ||
): Promise<VMModule> { | ||
const cacheKey = modulePath + query; | ||
|
||
|
@@ -383,14 +380,11 @@ export default class Runtime { | |
|
||
const context = this._environment.getVmContext(); | ||
|
||
invariant(context); | ||
invariant(context, 'Test environment has been torn down'); | ||
|
||
if (this._resolver.isCoreModule(modulePath)) { | ||
const core = this._importCoreModule(modulePath, context); | ||
this._esmoduleRegistry.set(cacheKey, { | ||
beforeEvaluation: core, | ||
fullyEvaluated: core, | ||
}); | ||
this._esmoduleRegistry.set(cacheKey, core); | ||
return core; | ||
} | ||
|
||
|
@@ -405,89 +399,49 @@ export default class Runtime { | |
const module = new SourceTextModule(transformedCode, { | ||
context, | ||
identifier: modulePath, | ||
importModuleDynamically: ( | ||
importModuleDynamically: async ( | ||
specifier: string, | ||
referencingModule: VMModule, | ||
) => | ||
this.linkModules( | ||
) => { | ||
const module = await this.resolveModule( | ||
specifier, | ||
referencingModule.identifier, | ||
referencingModule.context, | ||
false, | ||
), | ||
); | ||
|
||
return this.linkAndEvaluateModule(module); | ||
}, | ||
initializeImportMeta(meta: ImportMeta) { | ||
meta.url = pathToFileURL(modulePath).href; | ||
}, | ||
}); | ||
|
||
let resolve: (value: VMModule) => void; | ||
let reject: (value: any) => void; | ||
const promise = new Promise<VMModule>((_resolve, _reject) => { | ||
resolve = _resolve; | ||
reject = _reject; | ||
}); | ||
|
||
// add to registry before link so that circular import won't end up stack overflow | ||
this._esmoduleRegistry.set( | ||
cacheKey, | ||
// we wanna put the linking promise in the cache so modules loaded in | ||
// parallel can all await it. We then await it synchronously below, so | ||
// we shouldn't get any unhandled rejections | ||
{ | ||
beforeEvaluation: Promise.resolve(module), | ||
fullyEvaluated: promise, | ||
}, | ||
); | ||
|
||
module | ||
.link((specifier: string, referencingModule: VMModule) => | ||
this.linkModules( | ||
specifier, | ||
referencingModule.identifier, | ||
referencingModule.context, | ||
true, | ||
), | ||
) | ||
.then(() => module.evaluate()) | ||
.then( | ||
() => resolve(module), | ||
(e: any) => reject(e), | ||
); | ||
this._esmoduleRegistry.set(cacheKey, module); | ||
} | ||
|
||
const entry = this._esmoduleRegistry.get(cacheKey); | ||
const module = this._esmoduleRegistry.get(cacheKey); | ||
|
||
// return the already resolved, pre-evaluation promise | ||
// is loaded through static import to prevent promise deadlock | ||
// because module is evaluated after all static import is resolved | ||
const module = isStaticImport | ||
? entry?.beforeEvaluation | ||
: entry?.fullyEvaluated; | ||
|
||
invariant(module); | ||
invariant( | ||
module, | ||
'Module cache does not contain module. This is a bug in Jest, please open up an issue', | ||
); | ||
|
||
return module; | ||
} | ||
|
||
private linkModules( | ||
private resolveModule( | ||
specifier: string, | ||
referencingIdentifier: string, | ||
context: VMContext, | ||
isStaticImport: boolean, | ||
) { | ||
if (specifier === '@jest/globals') { | ||
const fromCache = this._esmoduleRegistry.get('@jest/globals'); | ||
|
||
if (fromCache) { | ||
return isStaticImport | ||
? fromCache.beforeEvaluation | ||
: fromCache.fullyEvaluated; | ||
return fromCache; | ||
} | ||
const globals = this.getGlobalsForEsm(referencingIdentifier, context); | ||
this._esmoduleRegistry.set('@jest/globals', { | ||
beforeEvaluation: globals, | ||
fullyEvaluated: globals, | ||
}); | ||
this._esmoduleRegistry.set('@jest/globals', globals); | ||
|
||
return globals; | ||
} | ||
|
@@ -504,12 +458,37 @@ export default class Runtime { | |
this._resolver.isCoreModule(resolved) || | ||
this.unstable_shouldLoadAsEsm(resolved) | ||
) { | ||
return this.loadEsmModule(resolved, query, isStaticImport); | ||
return this.loadEsmModule(resolved, query); | ||
} | ||
|
||
return this.loadCjsAsEsm(referencingIdentifier, resolved, context); | ||
} | ||
|
||
private async linkAndEvaluateModule(module: VMModule) { | ||
if (module.status === 'unlinked') { | ||
// since we might attempt to link the same module in parallel, stick the promise in a weak map so every call to | ||
// this method can await it | ||
this._esmModuleLinkingMap.set( | ||
module, | ||
module.link((specifier: string, referencingModule: VMModule) => | ||
this.resolveModule( | ||
specifier, | ||
referencingModule.identifier, | ||
referencingModule.context, | ||
), | ||
), | ||
); | ||
} | ||
|
||
await this._esmModuleLinkingMap.get(module); | ||
|
||
if (module.status === 'linked') { | ||
await module.evaluate(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could do a weakmap here as well, but I think the |
||
} | ||
|
||
return module; | ||
} | ||
|
||
async unstable_importModule( | ||
from: Config.Path, | ||
moduleName?: string, | ||
|
@@ -523,7 +502,9 @@ export default class Runtime { | |
|
||
const modulePath = this._resolveModule(from, path); | ||
|
||
return this.loadEsmModule(modulePath, query); | ||
const module = await this.loadEsmModule(modulePath, query); | ||
|
||
return this.linkAndEvaluateModule(module); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. bug fix is mostly here - only call |
||
} | ||
|
||
private loadCjsAsEsm( | ||
|
@@ -1227,12 +1208,18 @@ export default class Runtime { | |
displayErrors: true, | ||
filename: scriptFilename, | ||
// @ts-expect-error: Experimental ESM API | ||
importModuleDynamically: (specifier: string) => { | ||
importModuleDynamically: async (specifier: string) => { | ||
const context = this._environment.getVmContext?.(); | ||
|
||
invariant(context); | ||
invariant(context, 'Test environment has been torn down'); | ||
|
||
const module = await this.resolveModule( | ||
specifier, | ||
scriptFilename, | ||
context, | ||
); | ||
|
||
return this.linkModules(specifier, scriptFilename, context, false); | ||
return this.linkAndEvaluateModule(module); | ||
}, | ||
}); | ||
} catch (e) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could also choose to just do
await
if status isunlinked
orlinking
probably. In other cases the promise will be resolved (or rejected) though, so I don't think it really matters