-
Notifications
You must be signed in to change notification settings - Fork 29.6k
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
esm: do not lazy instantiate loaders to avoid infinite loop #47599
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 | ||
---|---|---|---|---|
@@ -1,21 +1,59 @@ | ||||
'use strict'; | ||||
|
||||
const { | ||||
ArrayPrototypePush, | ||||
PromisePrototypeThen, | ||||
ReflectApply, | ||||
SafeMap, | ||||
} = primordials; | ||||
|
||||
const assert = require('internal/assert'); | ||||
const { createModuleLoader } = require('internal/modules/esm/loader'); | ||||
const { getOptionValue } = require('internal/options'); | ||||
const { | ||||
hasUncaughtExceptionCaptureCallback, | ||||
} = require('internal/process/execution'); | ||||
const { pathToFileURL } = require('internal/url'); | ||||
const { kEmptyObject } = require('internal/util'); | ||||
const { kEmptyObject, createDeferredPromise } = require('internal/util'); | ||||
|
||||
let esmLoader; | ||||
let init = false; | ||||
|
||||
module.exports = { | ||||
get esmLoader() { | ||||
return esmLoader ??= createModuleLoader(true); | ||||
return esmLoader ??= { __proto__: null, cjsCache: new SafeMap(), import() { | ||||
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.
|
||||
const { promise, resolve, reject } = createDeferredPromise(); | ||||
ArrayPrototypePush(this.importRequests, { arguments: arguments, resolve, reject }); | ||||
return promise; | ||||
}, importRequests: [] }; | ||||
}, | ||||
initIfNeeded() { | ||||
// TODO: we could try to avoid loading ESM loader on CJS-only codebase | ||||
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. I don't think we should initialize the ESM loader in CJS-only code base. At the very least the ESM internals must be snapshotable before we consider this (I suspect if we somehow make that happen, e.g. #45828, which requires resolving some of the the problematic circular dependencies, the bug here already might go away..) 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. We have to, because dynamic 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.
We have already been doing it in the main branch: node/lib/internal/modules/cjs/loader.js Line 1196 in 2ec4ef7
|
||||
return module.exports.init(); | ||||
}, | ||||
init(loader = undefined) { | ||||
Comment on lines
+30
to
+34
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. This is a bit convoluted, and not clear when which pieces should be called. Could it be baked into the |
||||
assert(!init); | ||||
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. won't this throw? (I would think |
||||
init = true; | ||||
|
||||
loader ??= createModuleLoader(true); | ||||
|
||||
if (esmLoader != null) { | ||||
for (const { 0: key, 1: value } of esmLoader.cjsCache) { | ||||
// Getting back the values from the mocked loader. | ||||
loader.cjsCache.set(key, value); | ||||
} | ||||
for (let i = 0; i < esmLoader.importRequests.length; i++) { | ||||
PromisePrototypeThen( | ||||
ReflectApply(loader.import, loader, esmLoader.importRequests[i].arguments), | ||||
esmLoader.importRequests[i].resolve, | ||||
esmLoader.importRequests[i].reject, | ||||
); | ||||
} | ||||
} | ||||
esmLoader = loader; | ||||
}, | ||||
async loadESM(callback) { | ||||
esmLoader ??= createModuleLoader(true); | ||||
const { esmLoader } = module.exports; | ||||
try { | ||||
const userImports = getOptionValue('--import'); | ||||
if (userImports.length > 0) { | ||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -195,4 +195,43 @@ describe('Loader hooks', { concurrency: true }, () => { | |
assert.strictEqual(code, 0); | ||
assert.strictEqual(signal, null); | ||
}); | ||
|
||
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. Let’s add a test for the simplest case, 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. Tests that deal with REPL are simply not convenient and hard to not have flaky. Let's not do that? |
||
it('should let users require and import along loaders', async () => { | ||
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ | ||
'--no-warnings', | ||
'--require', | ||
fixtures.path('printA.js'), | ||
'--import', | ||
fixtures.fileURL('printB.js'), | ||
'--experimental-loader', | ||
fixtures.fileURL('empty.js'), | ||
'--eval', | ||
'setTimeout(() => console.log("C"),99)', | ||
]); | ||
|
||
assert.strictEqual(stderr, ''); | ||
assert.match(stdout, /^A\r?\nA\r?\nB\r?\nC\r?\n$/); | ||
assert.strictEqual(code, 0); | ||
assert.strictEqual(signal, null); | ||
}); | ||
|
||
it('should let users require and import along loaders with ESM', async () => { | ||
const { code, signal, stdout, stderr } = await spawnPromisified(execPath, [ | ||
'--no-warnings', | ||
'--require', | ||
fixtures.path('printA.js'), | ||
'--import', | ||
fixtures.fileURL('printB.js'), | ||
'--experimental-loader', | ||
fixtures.fileURL('empty.js'), | ||
'--input-type=module', | ||
'--eval', | ||
'setTimeout(() => console.log("C"),99)', | ||
]); | ||
|
||
assert.strictEqual(stderr, ''); | ||
assert.match(stdout, /^A\r?\nA\r?\nB\r?\nC\r?\n$/); | ||
assert.strictEqual(code, 0); | ||
assert.strictEqual(signal, null); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -93,6 +93,11 @@ const expectedModules = new Set([ | |||||
'NativeModule internal/net', | ||||||
'NativeModule internal/dns/utils', | ||||||
'NativeModule internal/process/pre_execution', | ||||||
'NativeModule internal/modules/esm/loader', | ||||||
'NativeModule internal/process/esm_loader', | ||||||
'NativeModule internal/modules/esm/assert', | ||||||
'NativeModule internal/modules/esm/module_map', | ||||||
'NativeModule internal/modules/esm/translators', | ||||||
Comment on lines
+96
to
+100
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. Are we OK with this change? /cc @joyeecheung 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. From the description in the issue:
I am not very convinced that this is the right fix. It seems some refactoring or synchronization should be done to avoid this instead. The ESM loader is full of circular dependencies and weird TDZs so I am not quite surprised that this happens, as that has already resulted in a deadlock in that off-thread PR before. I don't think eager loading actually make those problematic dependencies go away, and I suspect some other internal code changes that alter the dependency in 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. Now that off-thread has landed I would be happy to combine some of the ESM files to make them snapshotable. I assume the goal is to add as much of the ESM loader as possible to the snapshot? I think it’s safe to say that ESM code is common enough at this point that most projects are likely to have at least some ESM code loaded between the main app and/or its dependencies. Not that we should do this refactor in this PR, but I’m assuming it’s something we’d like to do? 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. I think that might just happen to be an alternative fix to the bug. Regardless of whether that's the right fix, I still don't think what this PR does the right fix - there are several comments in the loader assuming that there can only be only worker being spawned. If the code in the loader implementation is not enough to guard against infinite worker spawning, then it's a bug in the loader implementation (which I suspect is a result of circular dependency + TDZ invalidating the guards). The loader had been lazy-loadable without infinite worker spawning, the fact that it now does is a regression of the loader implementation. Eager-loading the loader only sweeps the bug even further deeper under the rug. To quote myself from 4 months ago: #45828 (comment)
And I suspect that we are getting hit by the technical debt again and we are trying to run away from actually cleaning up the minefield again. 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. So what fix do you recommend @joyeecheung? If I understand you correctly, you agree that adding more of the ESM loader into the snapshot is a fix and is also something we want to do anyway—but you think that this particular bug should be fixed first before taking that step, to avoid sweeping this bug under the rug? I think what @aduh95 is aiming for here is to try to be explicit about when we spawn the worker thread. Maybe we get that right first and then deal with the snapshotting? 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.
That wasn't what I was saying. I was suggesting that this could be caused by circular dependencies in the ESM loader, which is also a blocker of the snapshotability of the ESM (there are other blockers).
My point is that I am not convinced getting this right requires eager-loading the ESM loader. The assumption that fixing this requires eager-loading the ESM loader might be as incorrect as the assumption that supporting Eager-loading the ESM loader leads to an unnecessary penalty to startup performance of CJS-only graphs and I don't think we should do that until the ESM loader can be snapshotted (to reduce the performance penalty). So if we have to go down the eager-loading ESM loader path, I would say including them into the snapshot is a blocker - but then I don't think that's the only path. As I said before, this more likely requires synchronization within the loader code itself to avoid the infinite spawning. What this PR currently does does not seem to introduce synchronization to the loader code, it only works around the bug by creating the loader before anything else, which isn't robust. 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. Point taken, I still think the current implementation that lazy instantiate the loader is a mistake, I'll try to see if I can find another way without introducing non-snapshotable module in the bootstrap path. Help welcome if someone has another implementation candidate. 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. From what I can tell by just looking at the code (without being able to reproduce this locally), the suspicious code is:
I think the proper synchronization should be done in 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. I have an alternate fix here: #47620 with a test that locally reproduces the bug for me. I am not sure if I am supposed to invest energy to push it forward soon (still recovering from COVID), I think I should keep myself away from the computer for the day :3 feel free to take over if I disappear from GitHub. |
||||||
]); | ||||||
|
||||||
if (!common.isMainThread) { | ||||||
|
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.