-
Notifications
You must be signed in to change notification settings - Fork 30k
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
[DO NOT MERGE] test: duplicate async_hooks tests in esm #44323
base: main
Are you sure you want to change the base?
Conversation
I converted all these files using an automated process. This isn't a PR that's intended to be merged; it's a branch to discuss |
6c049a2
to
7156085
Compare
Here is the full list of failing tests, we should check them one-by-one and see if the differences are correct or there are actually bugs to fix:
|
I think the following test shows the problem: $ ./node test/async-hooks/test-async-await.mjs
Error: Found a handle whose promiseResolve hook was invoked but not its init hook
at ActivityCollector._getActivity (file:///Users/matteo/Repositories/node/test/async-hooks/init-hooks.mjs:168:19)
at ActivityCollector._promiseResolve (file:///Users/matteo/Repositories/node/test/async-hooks/init-hooks.mjs:213:20)
at emitHook (node:internal/async_hooks:235:38)
at promiseResolveHook (node:internal/async_hooks:364:3)
at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
at async Promise.all (index 0)
at async ESMLoader.import (node:internal/modules/esm/loader:528:24)
at async loadESM (node:internal/process/esm_loader:102:5)
at async handleMainPromise (node:internal/modules/run_main:70:12) We are already in the microtask queue when user code is executed, i.e. when we can start FWIW The following test pass: import '../common/index.mjs';
import { strictEqual } from 'assert';
import { AsyncLocalStorage } from 'async_hooks';
const asyncLocalStorage = new AsyncLocalStorage();
async function test() {
asyncLocalStorage.getStore().set('foo', 'bar');
await Promise.resolve();
strictEqual(asyncLocalStorage.getStore().get('foo'), 'bar');
}
await asyncLocalStorage.run(new Map(), test);
strictEqual(asyncLocalStorage.getStore(), undefined); |
I don't think the promises created by the loader chain are of any interest for classic APM products monitoring HTTP, GRPC, database,... requests. They start transactions in the HTTP/GRPC/... request handlers and track the context from this point on to link client requests. Even if a server would be created before the AsyncLocalStore instance has been created it's not an issue as long as incoming request are seen. But there might be other tools which would like to monitor the loading itself. To my understanding this can get quite complex if one chains serveral loaders which may fetch source from somewhere, transplile, cache,... Usually the solution is to start the monitoring tool earlier then the application. In CJS this works fine by loading the tool via I'm not an expert on ESM and I have followed the loaders topics only from time to time. If I remember correct the plan is to rename them to hooks and make them more generic. Adding a hook to load tooling before any application code would be nice and maybe solve such issues. |
The plan for loaders is in https://github.com/nodejs/loaders#status and the renaming discussion is in nodejs/loaders#95. I’d like to keep the discussion in this PR focused on the
Since we added
Yes, that’s a potential solution, in a way—”won’t fix,” basically. We document that users of |
@GeoffreyBooth What are the next steps here? I think we have diagnosed the problem. From my point of view we have three solutions:
|
I’m not sure we’ve fully gotten to the bottom of it. We know that an async resource is pending when the first line of user ESM code runs, but not what that async resource is. I’d guess it’s a promise created by the ESM loader, but where? If we can identify the code that creates the promise, perhaps we can find a way to either resolve it before user code is evaluated or delay its creation until after user code is evaluated. Either option, if possible, should also solve the problem, I’d think; and would be the most precise fix. |
I did a little experiment where I added some code to If you check out that branch and build Node, then run ./node ./debug-async-hooks.mjs you’ll get a minimal reproduction of the async hooks ESM issue. There’s only one pending async resource when user code starts running, something with ID 51. I managed to find its parent, ID 47, which I marked in a comment in that branch’s commit; but I can’t seem to find 51. If we can find 51, maybe we can find a way to make it not be pending when user code runs, and then async hooks in ESM is fixed. I tried adding ' at AsyncHook.init (node:internal/modules/run_main:27:14)\n' +
' at emitInitNative (node:internal/async_hooks:202:43)\n' +
' at emitInitScript (node:internal/async_hooks:505:3)\n' +
' at promiseInitHook (node:internal/async_hooks:324:3)\n' +
' at ModuleJob.run (node:internal/modules/esm/module_job:189:16)\n' +
' at node:internal/modules/esm/loader:527:28\n' + I’ve been going through the files in this trace and changing code from like So anyway, can anyone find what line of code triggers async resource 51? Corollary to that, if there’s a way to do this detective work that’s better than the method I’ve outlined in this comment, I’d love recommendations 😄 |
What if that resource is the user's module ? I think there has to be a pending promise to support top-level await. |
That would make a lot of sense. First I’d like to test this theory to confirm that it’s accurate; I’ve tried various tricks as discussed in my previous comment and I’ve looked all around Once we find the source then we can discuss if it’s possible to make it not pending at the time user code begins, and if it’s the user module that might be really hard; but I think first we need to confirm that it is the user’s module and find what line of code creates that promise. |
|
This looks like a problem because I'm experiencing a few random crashes when testing with tap an esm module (tap uses domain, which uses async_hooks internally). |
7156085
to
7e8977a
Compare
We got to the bottom of the problem. In my test file Basically, in Node However, we can call |
@GeoffreyBooth can you please include the example we worked on in this issue? |
On further analysis, our work during the collab summit was incomplete. Consider this const async_hooks = require('async_hooks')
async_hooks.createHook({
init: (asyncId, type, triggerAsyncId, resource) => {
process._rawDebug('>>> ', asyncId, type, triggerAsyncId)
}
}).enable() and this import asyncHooks from 'async_hooks'
process._rawDebug('The current asyncId:', asyncHooks.executionAsyncId()) Running this combo with
As you can see, the node/lib/internal/modules/esm/module_job.js Line 193 in c163bf9
I think our best option is to recommend that async_hooks is set up via preloarders/commonjs. We should also document that using async_hooks started from ESM will not track this specific promise. I'm not 100% sure it's innocuous ignoring it. On a side note, do anyone know why |
Took a fast look into this and it seems this is because startup in CJS is sync. The id 1 is created here in In ESM mode Not sure if this is a bug/missing feature in the ESM startup. One might expect that all async operations done within node core should be trackable which might require the use of |
Yep, that's basically what is going on. The 0 value is a special value meaning it doesn't have a path back to the root, which should not be the case here. It should be able to resolve that path, but there seems to be a bunch of async stuff that's not handled appropriately so it just marks everything as unrooted activity. |
@Qard this looks like a bug we should be fixing. |
Yep. I'll try to find some time to look into it when I can. |
I’d like to take another crack at trying to get the ESM loader handling all entry points, rather than being opted into by running an ESM entry or using a flag like Changing I wrote the following test file: const { createHook } = require('node:async_hooks');
const { writeSync } = require('node:fs');
const resources = new Set();
createHook({
init(asyncId, type, triggerAsyncId, resource) {
resources.add(asyncId);
},
promiseResolve(asyncId) {
if (!resources.has(asyncId)) {
writeSync(1, `orphaned promise: ${asyncId}\n`);
}
}
}).enable(); When I run this in the “always use the ESM loader” mode, it prints:
So presumably there are four promises created during startup that resolve after my script runs. I traced the flow of running a CommonJS entry point in this “always use the ESM loader” mode. This is what I found, with async functions marked:
Each async function returns a promise, so that’s at least five async resources right there? Though not four, curiously. Perhaps if promises are resolved before user code runs, that’s good enough? The questions I have at this point are:
|
The four extra promises seen by I mostly agree that the async_hooks tests suck and we should probably be just updating them, but I am a bit worried that changing the tests to match the behaviour could conceal a change that actually is breaking. My preference would be if this work be done after #48528 lands as at that point the risk of breaking async_hooks will be much reduced as the core use of it will be moved off. Ideally we'll be on a path to deprecate or make it internal, so I'd be less worried about breakage when AsyncLocalStorage is not using it anymore. |
I agree with @Qard that these promises don't matter. the async hooks tests simply assert to strict in this area. The main problem I see with removing/ignoring async hooks once ALS has moved away from it is that actual testcoverage in this regard is lost. As of now ALS works as good/bad as async hooks do and there are quite some tests for it. Once ALS is not based on it anymore we loose also this coverage. |
Spinning off from #43408 (comment) and following comments, in this PR I ported the
async_hooks
tests intest/async-hooks/*.js
into ESM. I left the original CommonJS versions side-by-side with the ESM versions, so the diff shows just 90 new.mjs
files; but the tests themselves are unchanged other thanrequire
becomingimport
and similar expected changes related to conversion to ESM (declaring__filename
, replacing top-levelreturn
withprocess.exit()
, etc.). This PR isn’t meant to be merged, which is why I’ve opened it as a draft; it’s just meant to provide a branch to test issues related toasync_hooks
and ESM. (Branches off of and includes #44322.)On my machine, running
python tools/test.py -J --mode=release async-hooks
for this branch yields 58 failing tests. Arguably there should be no failing tests here;async_hooks
should behave identically when used in ESM code as when used in CommonJS code, like most (all?) other Node APIs. So this raises a few questions:Are the failing tests simply tests that need rewriting? The tests are very specific in the assertions they validate, looking for things like the number of both user-created and internal Node async resources created over the lifetime of the Node process. Perhaps the different results are expected, and the tests should simply be changed so that the ESM versions have different expectations to validate; or the validation should become less specific, such as by filtering out all the Node internal async activity somehow.
Is something about the ESM loader broken? Is it creating async resources before it should, or not resolving all startup async resources before user code runs, denying
async_hooks
the clean environment it expects when the first line of user code runs? Is there some change that we can make to the ESM loader to make it behave more similarly to the CommonJS loader, fromasync_hooks
‘s perspective, without that change breaking anything else?Is
async_hooks
itself somehow broken or incompatible under ESM? And if so, can it be fixed? In the worst case, something like the async activity ofimport
statements themselves would be confusingasync_hooks
, presenting us with what I assume is no way to make theasync_hooks
API behave equivalently in ESM; or user code needs to somehow filter out or accommodate all these “module loading” async resources that don’t exist in the sync CommonJS.cc @nodejs/async_hooks @nodejs/modules @nodejs/loaders