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
Move ESM loaders off-thread #44710
base: main
Are you sure you want to change the base?
Move ESM loaders off-thread #44710
Conversation
|
Review requested: |
| const lock = new Int32Array(comsChannel, 0, 4); | ||
| /** | ||
| * The request & response segment of the shared memory. TextEncoder/Decoder (needed to convert | ||
| * requests & responses into a format supported by the coms channel) reads and writes with |
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.
| * requests & responses into a format supported by the coms channel) reads and writes with | |
| * requests & responses into a format supported by the comms channel) reads and writes with |
Why is the other Int32Array and this is Uint8Array?
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.
The comments tell you why
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.
I saw the comment explaining why Int32Array, but why Uint8Array?
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.
It's lines 15–17 of this file, right above this PR thread (the mobile app won't let me copy+paste). It starts with TextEncoder/Decoder (the reason is because that's the numerical datatype TextEncoder/Decoder can handle).
Maybe it doesn't need to be numerically encoded at all?
Oh, duh, it does need to be numerical because some form of that is the only view option for slicing up a SharedArrayBuffer.
| */ | ||
| const requestResponseData = new Uint8Array(comsChannel, 4, 2044); | ||
|
|
||
| const worker = new Worker('./worker.mjs', { |
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.
A filename of worker.mjs implies that this is the only worker we’ll ever have under modules/esm. Perhaps user_loaders_worker.mjs? Also the actual file is named worker.js, a CommonJS file.
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.
I don't understand: it is a CJS file.
RE file name: cross that bridge when we come to it?
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.
The code says worker.mjs. If the file is named worker.js, shouldn’t the code say the same?
Re cross that bridge: renaming a file often messes with git history. It’s better to try to future-proof names as much as we can from the start. It doesn’t hurt to be as specific as possible with the filename, that will encourage future additions to be added into new files rather than bloating existing files.
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.
OH! I think it's copy+pasta from my experiment (which was esm).
One of the major downsides of using this worker is errors get swallowed.
lib/internal/process/esm_loader.js
Outdated
| } = primordials; | ||
|
|
||
| const { | ||
| ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING, | ||
| } = require('internal/errors').codes; | ||
| const { ESMLoader } = require('internal/modules/esm/loader'); | ||
| const publicESMLoader = require('internal/modules/esm/public_loader_proxy'); |
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.
What does “public” mean here? As in there are two ESMLoader instances, one for Node internals and one for user code, and this is the latter? Is it publicly accessible to user code?
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.
Correct: internal is used internally by node for its own code. Public handles userland code. Neither are accessible in userland.
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.
If it’s not accessible to application code, “public” feels like a poor choice of name. Maybe applicationESMLoader or userCodeESMLoader?
|
|
||
| Atomics.wait(lock, 0, 1); // ! Block this module until the worker is ready. | ||
|
|
||
| function makeRequest(type, data) { |
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.
| function makeRequest(type, data) { | |
| function makeRequest(method, data) { |
lib/internal/modules/esm/worker.js
Outdated
| (new TextDecoder().decode(requestResponseData)) | ||
| .replaceAll('\x00', '') // strip empty space (not a great solution) | ||
| ); | ||
| const response = await publicESMLoader[type](data); |
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.
| const response = await publicESMLoader[type](data); | |
| const response = await publicESMLoader[method](data); |
lib/internal/process/esm_loader.js
Outdated
| @@ -85,21 +84,21 @@ function loadModulesInIsolation(specifiers, loaders = []) { | |||
| // A separate loader instance is necessary to avoid cross-contamination | |||
| // between internal Node.js and userland. For example, a module with internal | |||
| // state (such as a counter) should be independent. | |||
| const internalEsmLoader = new ESMLoader(); | |||
| internalEsmLoader.addCustomLoaders(loaders); | |||
| const internalESMLoader = new ESMLoader(); | |||
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.
should --loader and --import run on the main thread?
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.
Moving --loader off the main thread is the point of this PR, so I’m not sure I understand the question?
--import is user application code so it needs to stay on the main thread.
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.
I think Moshe is asking whether both "internal" and "public" loaders should be in the worker thread (at the moment in this PR, only the public is—once I get the public, I'll move internal).
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.
If we move the “internal” loader onto the worker thread, wouldn’t that break user code that monkey-patches Node stuff?
Perhaps we could do that as a follow-up, if we do it at all. It feels like it might be a semver-major change.
lib/internal/modules/esm/worker.js
Outdated
| (new TextDecoder().decode(requestResponseData)) | ||
| .replaceAll('\x00', '') // strip empty space (not a great solution) | ||
| ); | ||
| const response = await publicESMLoader[type](data); |
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.
this lacks error handling, I would expect the worker to somehow let the main thread via the proxy about the failure
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.
It's a draft because it's not done yet
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.
I'l give another peak when I see it moves to ready for review :)
2265b79
to
ce94ba6
Compare
lib/internal/modules/esm/worker.js
Outdated
| Atomics.store(lock, 0, 1); // Send 'ready' signal to main | ||
| Atomics.notify(lock, 0); // Notify main of signal | ||
|
|
||
| while (true) { // event loop |
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.
I think it's not a good idea to call that "event loop" when event loop already means something else in JS-land.
| while (true) { // event loop | |
| while (true) { |
lib/internal/worker.js
Outdated
| @@ -166,7 +167,7 @@ class Worker extends EventEmitter { | |||
| RegExpPrototypeExec(/^\.\.?[\\/]/, filename) !== null) { | |||
| filename = path.resolve(filename); | |||
| url = pathToFileURL(filename); | |||
| } else { | |||
| } else if (!isInternal) { | |||
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.
nit:
considering the above change on Line 141:
if (options.eval || isInternal)
This check is not needed as isInternal must be false to get here
|
Does each worker thread get its own loader thread? E.g. if an app launches 2x worker threads, there will be 6x threads total: one main, 2x worker, 3x loader? |
|
The current design puts all loaders in the same worker thread (eg there are a total of 2 threads: the main and the worker). Internal things are handled via the main thread; userland things are handled by the worker thread. |
|
I think that's slightly different than what I'm asking about. If a node app uses the worker threads API to launch worker threads, does each get its own loader thread? (where each loader thread may have one or more loaders running within it) Not does each loader get a thread, but does each user thread get a loader thread. |
I think the current state (before this PR) is that loaders always execute in the main thread, even if they’re “for” user code that is in a worker thread. So I would think that after this PR, loaders would run inside their worker thread, regardless of whether they’re customizing main or worker thread user code. So in other words, for an app using custom loaders where user code that spawns three workers, there are five threads total: main, loaders, and the three workers spawned by the user code. This is just my guess, others can please correct me if I’m mistaken. |
|
I don't know how it's implemented but I would expect that each Node.js thread (main and workers) has its own separate loaders thread, at least for two reasons:
|
|
First pass this seems fine but likely should investigate a thread per
loader URL/some key. Having it per thread they instrument would be more
costly if you spawn/tear down threads.
…On Tue, Sep 27, 2022, 1:24 PM Michaël Zasso ***@***.***> wrote:
I don't know how it's implemented but I would expect that each Node.js
thread (main and workers) has its own separate loaders thread, at least for
two reasons:
- Worker threads are supposed to be as isolated as possible from the
main thread and from each other
- You can spawn a worker thread with a different set of --loader flags.
—
Reply to this email directly, view it on GitHub
<#44710 (comment)>, or
unsubscribe
<https://github.com/notifications/unsubscribe-auth/AABZJI7YII3ZMWWAGIMOC6LWAM3WJANCNFSM6AAAAAAQPNEM7E>
.
You are receiving this because you are on a team that was mentioned.Message
ID: ***@***.***>
|
|
What remains to be done? |
|
Something in this implementation is causing node to hang on startup. We suspect it's some kind of circular dependency, and the subsequent dependency smacks into the Atomics lock (preventing the rest of the flow to complete, which would release the initial lock). I think this circular dep is between ESMLoader and Worker (it's rather difficult to troubleshoot as output is swallowed). There is a working PoC, so we know this works in principle (and that the Atomics otherwise behave appropriately, so the problem lies with integrating it into node). This was on hold whilst I was on holiday. I'm working on it today and tomorrow (but will be away on a business trip next week). |
|
I found the circular dep:
|
|
Gah, it wasn't a circular dependency Lines 141 to 149 in 9836c67
which caused the worker to spawn with nothing in it. (I shouldn't have copied it over from the previous attempt at off-threading, which was later hard-coding the I'm not sure if the internal worker should go through the There is (now was) a circular dependency issue after the empty worker issue is addressed in node/lib/internal/main/worker_thread.js Line 142 in 9836c67
that's now fixed too.
|
wordsmith comments Co-authored-by: Geoffrey Booth <webadmin@geoffreybooth.com> Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
`__dirname` is not defined? O.o
- Let's not create a debug file, otherwise we create a new instance each time instead of re-using the existing one. - Internal modules are not classic scripts. - Fix `await` use outside of async function.
a75ed16
to
38bec52
Compare
|
I just rebased on ./node --trace-uncaught --test ./test/es-module/test-esm-loader-chaining.mjs
TAP version 13
node:internal/test_runner/harness:37
throw err;
^
Error [ERR_UNHANDLED_ERROR]: Unhandled error. ('This Environment was initialized without a V8::Inspector')
at new NodeError (node:internal/errors:400:5)
at InternalWorker.emit (node:events:508:17)
at [kOnErrorMessage] (node:internal/worker:312:10)
at [kOnMessage] (node:internal/worker:323:37)
at MessagePort.<anonymous> (node:internal/worker:218:57)
at [nodejs.internal.kHybridDispatch] (node:internal/event_target:734:20)
at exports.emitMessage (node:internal/per_context/messageport:23:28) {
code: 'ERR_UNHANDLED_ERROR',
context: 'This Environment was initialized without a V8::Inspector'
}./node --eval 'console.log(1)'
node:internal/modules/cjs/loader:1165
throw err;
^
TypeError: Cannot read properties of undefined (reading 'set')
at internalCompileFunction (node:internal/vm:102:17)
at wrapSafe (node:internal/modules/cjs/loader:1141:20)
at Module._compile (node:internal/modules/cjs/loader:1182:27)
at runScript (node:internal/process/execution:82:27)
at evalScript (node:internal/process/execution:104:10)
at node:internal/main/eval_string:50:3./node --input-type=module --eval 'console.log(1)'
^C # Kill hung process |
|
@JakobJingleheimer @nodejs/v8 @targos @MoLow I’ve made a small discovery that might point to the cause of the inline bool Environment::should_create_inspector() const {
- return (flags_ & EnvironmentFlags::kNoCreateInspector) == 0 &&
- !options_->test_runner && !options_->watch_mode;
+ return (flags_ & EnvironmentFlags::kNoCreateInspector) == 0;
}Then the error disappears, and the process hangs like it does for While this @JakobJingleheimer For the purposes of this PR I would just test it using a command other than |
the objective of the change in #44366 was to make we might want to only pass |
./node --trace-uncaught ./test/es-module/test-esm-loader-chaining.mjsis now passing, and errors from the worker are now surfaced (and the process terminates gracefully) Something related to the ./node --trace-uncaught --test ./test/es-module/test-esm-loader-chaining.mjsAlso, I believe I've found the cause of the ./node --input-type="module" -e "console.log(1)"
|
@MoLow it seems like this change breaks |
|
@GeoffreyBooth Il take a deep look at this on this weekend |
|
@GeoffreyBooth @JakobJingleheimer I tryed reproducing with // worker.js
'use strict';
const { parentPort } = require('worker_threads');
parentPort.on('message', (msg) => {
parentPort.postMessage(msg);
setTimeout(() => { throw new Error('test'); }, 10);
});import { describe, it } from 'node:test'
import { deepEqual, fail } from 'node:assert'
import { Worker } from 'node:worker_threads'
it('test', async () => {
const worker = new Worker("./worker.js");
worker.on('online', console.log);
worker.postMessage('hello');
await new Promise((resolve) => {
worker.on('message',(...args) => {
deepEqual(args, ['hello']);
setTimeout(() => {
worker.terminate();
resolve();
}, 100);
});
});
});but this does not seem to reproduce any issues with |
|
@MoLow the issue is with |
|
I have tried both, and ran |
| @@ -0,0 +1,69 @@ | |||
| 'use strict'; | |||
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.
If this is supposed to be a script instead of a module, you can put it in lib/internal/main/ instead.
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.
What's different about that location? I put it here because it's exclusively for the use of ESM (loaders)
| */ | ||
| const requestResponseData = new Uint8Array(commsChannel, 4, 2044); | ||
|
|
||
| debug('creating worker for publicESMLoader'); |
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.
Please don't do debug() at the top-level in a module that's supposed to be require()ed, you can wrap this in a function that gets called by who needs this to happen, on-demand.
| const esmLoader = new ESMLoader(); | ||
| exports.esmLoader = esmLoader; | ||
| debug('hooking up public ESMLoader'); | ||
| const customLoaders = getOptionValue('--experimental-loader'); |
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.
Please avoid accessing getOptionValue() at the top level in a module that's supposed to be require()ed. That makes the module immediately runtime-dependent and unsnapshottable. The loader can be created when the loader initialization is actually run for the first time, see #45828. (In general internal modules are not supposed to cause side-effects or query runtime-states at the top-level i.e. when they are require()ed. Most of those things can always be done on-demand in a function instead. It only makes sense to do that at the top-level in main scripts).
| @@ -0,0 +1,74 @@ | |||
| 'use strict'; | |||
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.
This can be merged into esm/loader or just process/esm_loader too if you wrap the side-effect below into a function that produces a loader proxy and use a boolean to make sure it only gets created once. That reduces one more require() call. (In general, avoid doing side-effects at the top-level in internal modules, that makes the modules themselves unsnapshottable. Most things can be done in functions that get exported and called on-demand. It's an overkill to rely on the internal module loader to implement call-only-once, that can be easily achieved with a if (called) { return; } else { called = true; } pattern.
| } | ||
|
|
||
| // ! maybe move this outside | ||
| const { ESMLoader } = require('internal/modules/esm/loader'); |
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.
This file can be merged into esm/loader and gets exported as part of that module.exports, then you no longer need to require that file below. And there would be one less require() call from modules/esm/worker.js as well.
(And to the comment above - that creates a new process/esm_loader -> esm/load_modules_in_isolation -> internal/modules/esm/loader -> internal/modules/esm/* -> process/esm_loader circular dependency if you move it outside, the process/esm_loader -> internal/modules/esm/loader -> process/esm_loader is already inevitable (but should be made lazy, as done in #45828), but let's try not to make the mess worse by adding a new circular path, which might work if you are lucky but makes the code harder to reason about especially when there are TDZs around).
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.
Thanks for the insight!
I need to pivot approach on this PR, and in the pivot, this can go back to where it was.
|
We realised the serialisation issues are indeed caused by So we're planning to pivot the approach here: Instead of instantiating the whole public ESMLoader within the worker (what we're calling the "heavy" approach), we're going to pursue two others. A "thin" worker that is basically just a sandbox; the public ESMLoader's relevant methods currently contain loops. With this approach, each iteration of the loop would make a request to the worker, wherein the hook is merely executed and its output returned as the worker's response. The benefits of this approach are that it's very simple (but proves it works); the cons are it will likely have unideal performance (due to many requests/responses, and thus a lot of serialisation and deserialisation). A "thick" worker wherein we extract hook-related methods off the current ESMLoader class into a separate Hooks class (or other structure). When the worker is needed, it is spawns with the Hooks class inside; then the worker responds with the final result of a hook chain. The response of both approaches has the same shape (a plain object with a few properties like We may first go the thin option as a PoC and then land the thick. |


Resolves #43658
To-dos:
Moduleserialisation issue (Proxies aren't serialisable)import.meta.resolve()to synchronous