-
Notifications
You must be signed in to change notification settings - Fork 29.8k
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
module: allow module.register from workers #53200
base: main
Are you sure you want to change the base?
Conversation
Review requested:
|
@nodejs/loaders @mcollina @ShogunPanda |
I don't think you need to modify on the C++. |
in current form this should fix all the regressions that I was made aware of. |
Have you tried the following case: hooks.mjs
t1.js
t2.js
t3.js
Note that there is no |
@ShogunPanda I tried that now. Works IMO exactly like v22.1 worked (and like 22.2.0 for that matter). What is the expectation? |
Well, that the hooks are executed. Which is not the case even on 22.1. I think this should be fixed as well as part of this. |
the reason for that is unrelated to the single hooks thread. If that should work I am for creating a separate issue and deal with that on its own. What do you think? |
Looks fine to me. |
Can this get a new test or two from https://github.com/ShogunPanda/hooks-repro, the ones that we expect to pass now thanks to this change? In particular I want a test that includes |
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.
lgtm
@dygabo As said, it looks fine to me but I don't really like the C++ change. If you can find a JS it would be better but I'm not gonna block for this thing only. |
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.
LGTM!
src/node_worker.cc
Outdated
@@ -46,6 +46,7 @@ namespace node { | |||
namespace worker { | |||
|
|||
constexpr double kMB = 1024 * 1024; | |||
std::atomic_bool Worker::internalExists{false}; |
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 rename this and other variables/parameters named "internal" but actually refer to loader hooks thread in this PR or some followup.
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.
good point, that should be done. Will check and make a separate commit for that renaming
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.
renamed to HooksWorker
instead of InternalWorker
lib/internal/modules/esm/hooks.js
Outdated
const lock = new SharedArrayBuffer(SHARED_MEMORY_BYTE_LENGTH); | ||
this.#lock = new Int32Array(lock); | ||
|
||
if (isMainThread) { | ||
if (!hasHooksThread()) { |
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.
Is this really thread safe?
If serveral workers are started in parallel (maybe even from serverl workers already running in parallel) more then one might get false here and as a result more then one starts a new InternalWorker
.
The atomic just ensures that it is set/read consistent but the depending code around is not synced.
I know this race isn't easy to hit but I think a critical section/mutex would be needed here.
An alternative might be to change hasHooksThread()
to check and set the flag atomic. But this might result in another race that the worker which set the flag and will actually start the hooks thread might be slower then others in doing so.
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.
There is no race condition. There are two scenarios:
.register()
is called in main, and the parent thread is inherited by other threads.register()
is called in the worker thread, and a new loader is used
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.
that did have the potential for a race. The last commit addresses that. I left HasHooksThread
in the code for now because that would be the tool to solve another usecase once we agree on the behaviour. Similar to the usecase @ShogunPanda pointed out yesterday: main thread start without hooks, it starts a Worker with hooks (using execArgv
and --import
). This will create the hooks thread but that would only propagate for the cases where we have implicit propagation of the arguments. Should mainthread use the hooks for the subsequent imports (if any?).
Does this impact the use case to communicate with worker described here? |
lib/internal/modules/esm/hooks.js
Outdated
const alreadyKnown = ArrayPrototypeSome(ArrayPrototypeMap(['initialize', 'resolve', 'load'], (hookName) => { | ||
if (this.#chains[hookName]) { | ||
return ArrayPrototypeFilter(this.#chains[hookName], (el) => el.url === url).length === 1; | ||
} | ||
}), (el) => el); | ||
|
||
if (alreadyKnown) { | ||
return undefined; | ||
} | ||
|
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 think we should add that, it should be the responsibility of the user to not register a loader twice
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 agree.
Also, the way it's written now does not checks for the data. Which means that if a hook is registered twice with different data the latter won't be applied.
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.
considering the data
could and should be added to the filtering. The reason why I added it was to prevent automatic reregistration due to not only user re-registering unintendedly the same hooks but also because of the automatic spreading that happens in node code. Think of the main usecase: node --import registerHooks.mjs app.js
:
this gets passed to the hooks thread itself on initialization => two times registered
it also automatically gets passed to any new Worker()
(in some conditions. Means each such worker will reregister. And now combined with a scenario where the app has short-lived ephemeral threads => longer and longer chain.
Alternative ideas are welcome. But I think it has a usecase that is also beyond what the user app does.
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.
Let's remove it and discuss it in a separate PR. In the mean time, it's up to the user to ensure they don't register the same loader twice.
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.
@aduh95 I think the issue is that if you start your app with a flag, like node --import hooks.js app.js
, those flags get automatically inherited by workers unless the user launches them via new Worker('./worker.js', { execArgv: [] }
. So the default case of new Worker('./worker.js')
would mean that hooks.js
gets run again, and those hooks re-registered, for every worker thread that the user creates.
I think the naïve user using a library like node --import tsx app.js
won’t have any idea that they need to pass execArgv: []
to avoid tsx
getting registered a second time; nor should they need to know that. Perhaps tsx
can somehow be smart enough to know that it’s already registered and therefore avoid calling register
a second time; is that what you’re proposing?
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 added logic to consider different data
with same url
to be its own loader hook in the chain. Tests are still missing, ongoing work.
Are hooks added by workers removed automatically if the worker is ended? If not this seems like a leak. What happens if the worker which eventually created the hooks thread terminates? Does it end also the hooks thread? What happens if the hooks thread exists/crashes? As far as I know before this PR the main thread got notified and as a result the process exited. Is this still the case or does this just end the worker which created the hooks thread? |
Is there a reason why test-esm-virtual-json is still disabled for workers? |
I will look into the comments tomorrow and add more tests. Will also check for alternative solutions to the c++ change. |
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.
Unfortunately, this is not solving #53195, because in that use case we want explicitly to have a loader thread per worker (or at least per worker pool).
I think one of the requirements needs to be that the hooks thread is never created until the first |
const alreadyKnown = ArrayPrototypeSome(ArrayPrototypeMap(['initialize', 'resolve', 'load'], (hookName) => { | ||
if (this.#chains[hookName]) { | ||
return ArrayPrototypeFilter( | ||
this.#chains[hookName], (el) => el.url === url && el.data === data).length === 1; |
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'm not sure this comparison of data is correct and this might actually never be triggered. Can you add a test?
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 one good test would be (all filenames for illustration only, please use whatever names correspond to the appropriate fixtures):
- Node is run via
node --import=register.js app.js
register.js
contains aregister
call that registers some hooks, and one of these hooks prints something on initializationapp.js
containsnew Worker('./worker.js')
to create a worker thread without any specific customization (noexecArgv
)- Verify that the “print on initialization” doesn’t happen a second time
The point of this test is to ensure that even though new Worker
without execArgv
inherits the --import
flag from the initial Node process, and even though register.js
runs twice, the hooks don’t get registered twice. This filter check prevents double registration of the same hooks.
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'm not sure this comparison of data is correct and this might actually never be triggered. Can you add a test?
Of course. This must be a deep equality compare. Will update. Tests need generally to be added for quite a few things. Just wanted to make sure there is agreement on the approach and featureset/constraints before.
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.
@dygabo In my (soon to be published) PR I used isDeepStrictEqual
from internal/util/comparisons
, which is the one internally used from assert
.
@GeoffreyBooth Since #53183 landed, shouldn't he just rebase on top of main so we won't need a revert-revert PR before? |
I would do a rebase on tip of main that includes both. Wdyt? |
14d3115
to
f7cf9b6
Compare
`module.register` not supported when called from worker threads with this commit it only works for the main thread.
Co-authored-by: Geoffrey Booth <webadmin@geoffreybooth.com>
f7cf9b6
to
d9169d4
Compare
Status update: today I tried to implement a solution for the new requirements and bring it to a runnable state but it's not yet there. The current state as mentioned before is:
So something like this where T1 and T2 call
would not work well for T2. The idea that I tried to prove was:
I think that this might work from a design point of view, but it takes more time to implement than I would have expected and I don't want to be the blocker here. If the approach is considered valid (even though it is IMO complex but the requirements are too) and if someone wants to look into it we could further discuss it and they could either push to my branch or start a branch on top of this and add that functionality. Having more of this on native side, implemented in C++ might be an option too but also one that exceeds my available bandwidth so I would not even go into details. Also if there are other ideas for working solutions, let's discuss them. This one tends to be difficult in maintenance because of the messaging complexity and the synchronous I would also consider the current version (N workers => N customization hooks threads) an idea worth keeping because at least it seems many users are happy with it. The single thread approach has one additional drawback: one failed hook initiated by one thread would imply that all threads that need hooks must fail. With the current approach there's a better isolation and that is IMO important. If in the above example Also as discussed in the last loaders meeting, there is no way of guaranteeing complete isolation if hooks from different threads and different contributing loaders would run on the same thread. They might affect each other unintendedly in weird ways that would make troubleshooting very hard. That might happen even with the chain isolation implemented by @ShogunPanda which is IMO a good solution for logical isolation but it is not side-effect-free. Let's please start the discussion and see where it goes. Current state on my side: it's complicated :) |
@dygabo Thanks for the update. Maybe I missed something, but did the |
I'm typing from the phone but I'll also give you a small update here which might be a game-changer for this feature. I have a local working POC, which I plan to translate into a PR in few days which will enable inter-worker (thread) communication. By retaining the current hooksPort architecture, this means that, once the hooks thread is created, a BroadcastChannel can be used to notify all threads and then they can use the new feature to handshake the hooksPort. In the future this feature would also allow us to completely remove the need for the hooksPort as each thread can connect directly to the hooks thread itself. Just quick thoughts tho, I need to think more deeply about the implications. |
I tried to use the BoradcastChannel idea from @ShogunPanda but kindof hit the wall because of two reasons:
We might have a race on creating the HooksThread. On the above example consider MT and all T1...TN don't need customization hooks. They all are different OS threads created here which results in a Now let's consider I could not find a way to implement this in a threadsafe way by using the
I am quite happy to hear of your idea @ShogunPanda and I hope it covers all the cases. The only part that I am a bit skeptical about is the handshake of the I would also be very happy if the implementation of this feature would have as a beneficial side-effect a new and easy worker thread communication mechanism like you describe it. |
this is a fix for #53182
by implementing a guard on the native side, we can ensure that the internal customization hooks thread is instantiated only once but not necessarily from the main thread.
https://github.com/ShogunPanda/hooks-repro example is not hanginng
the second commit reenables the tests that were skipped because
module.register()
was temporarily not supported on worker threads.One is still failing on workers. Currently analyzing.
This implements the fix based on solution number 2. from this comment
alternative to #53183