Skip to content
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

Async hooks cause programs with frozen promises to crash #42229

Open
erights opened this issue Mar 6, 2022 · 41 comments
Open

Async hooks cause programs with frozen promises to crash #42229

erights opened this issue Mar 6, 2022 · 41 comments
Labels
async_hooks Issues and PRs related to the async hooks subsystem.

Comments

@erights
Copy link
Contributor

erights commented Mar 6, 2022

Version

v16.13.1

Platform

Darwin MacBook-Pro 21.3.0 Darwin Kernel Version 21.3.0: Wed Jan 5 21:37:58 PST 2022; root:xnu-8019.80.24~20/RELEASE_ARM64_T6000 arm64

Subsystem

No response

What steps will reproduce the bug?

Type the following interactively at the Node shell.

~$ node
Welcome to Node.js v16.13.1.
Type ".help" for more information.
> const p = Promise.resolve(8);
undefined
> const names = Reflect.ownKeys(p);
undefined
> names
[
  'domain',
  Symbol(async_id_symbol),
  Symbol(trigger_async_id_symbol),
  Symbol(destroyed)
]
> for (const name of names) {
...   delete p[name];
... }
true
> Object.freeze(p);
Promise { 8 }
> const q = p.then(x => console.log(x));
Uncaught:
TypeError: Cannot add property Symbol(async_id_symbol), object is not extensible
    at getOrSetAsyncId (node:internal/async_hooks:424:34)
    at trackPromise (node:internal/async_hooks:314:35)
    at promiseInitHook (node:internal/async_hooks:322:3)
    at promiseInitHookWithDestroyTracking (node:internal/async_hooks:329:3)
    at Promise.then (<anonymous>)
> undefined
> 8

The 'domain' property is not the main concern here. Rather it is the other three symbols. Hardened JS (aka SES) avoid loading the module that would add 'domain'. However, at the vscode debug terminal, we get a result much like the above but without 'domain' showing up in the list of names. Other differences between the following and the previous section are probably due to the differences caused by Hardened JS and might or might now be relevant. Other than the expected absence of 'domain', the underlying Node bug is the same:

In the debug console:

p = Promise.resolve(8);
Promise {[[PromiseState]]: 'fulfilled', [[PromiseResult]]: 8, Symbol(async_id_symbol): 3543, Symbol(trigger_async_id_symbol): 1783, Symbol(destroyed): {}}
names = Reflect.ownKeys(p);
(3) [Symbol(async_id_symbol), Symbol(trigger_async_id_symbol), Symbol(destroyed)]
for (const name of names) {
  delete p[name];
}
true
Object.freeze(p);
Promise {[[PromiseState]]: 'fulfilled', [[PromiseResult]]: 8}
q = p.then(x => console.log(x));

That last line will terminate the debug session, with the following error appearing on vscode's JavaScript Debug Terminal

Debugger attached.
  - range queries

  Uncaught exception in test/test-rankOrder.js

  TypeError: Cannot add property Symbol(async_id_symbol), object is not extensible
    at Promise.then (<anonymous>)
    at eval (eval at <anonymous> (packages/store/test/test-rankOrder.js:94:7), <anonymous>:1:13)
    at packages/store/test/test-rankOrder.js:94:7
    at packages/store/test/test-rankOrder.js:90:12
    at async Promise.all (index 7)

  › Promise.then (<anonymous>)
  › eval (eval at <anonymous> (packages/store/test/test-rankOrder.js:94:7), <anonymous>:1:13)
  › packages/store/test/test-rankOrder.js:94:7
  › packages/store/test/test-rankOrder.js:90:12

(TypeError#1)
Waiting for the debugger to disconnect...
TypeError#1: Cannot add property Symbol(async_id_symbol), object is not extensible
  at Promise.then (<anonymous>)
  at eval (eval at <anonymous> (packages/store/test/test-rankOrder.js:94:7), <anonymous>:1:13)
  at packages/store/test/test-rankOrder.js:94:7
  at packages/store/test/test-rankOrder.js:90:12
  at async Promise.all (index 7)

  ✖ test/test-rankOrder.js exited with a non-zero exit code: 1
  ─

  1 test skipped
  1 uncaught exception
Waiting for the debugger to disconnect...
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
Waiting for the debugger to disconnect...
Waiting for the debugger to disconnect...

Putting the same code in a .js file and executing it non-interactively does not trigger the bug. The names array in that case is empty, as it should be.

How often does it reproduce? Is there a required condition?

It reproduces reliably. The explanation above should be adequate to reliably reproduce it. Please let me know if you have any trouble reproducing it.

What is the expected behavior?

names should be empty.
q should be defined as a promise that eventually fulfills to p's fulfillment, 8.

What do you see instead?

As presented above:

~$ node
Welcome to Node.js v16.13.1.
Type ".help" for more information.
> const p = Promise.resolve(8);
undefined
> const names = Reflect.ownKeys(p);
undefined
> names
[
  'domain',
  Symbol(async_id_symbol),
  Symbol(trigger_async_id_symbol),
  Symbol(destroyed)
]
> for (const name of names) {
...   delete p[name];
... }
true
> Object.freeze(p);
Promise { 8 }
> const q = p.then(x => console.log(x));
Uncaught:
TypeError: Cannot add property Symbol(async_id_symbol), object is not extensible
    at getOrSetAsyncId (node:internal/async_hooks:424:34)
    at trackPromise (node:internal/async_hooks:314:35)
    at promiseInitHook (node:internal/async_hooks:322:3)
    at promiseInitHookWithDestroyTracking (node:internal/async_hooks:329:3)
    at Promise.then (<anonymous>)
> undefined
> 8

Additional information

Googling led me to
https://stackoverflow.com/questions/70742387/where-can-i-find-any-docs-on-why-node-14-changed-promise-into-promise-undefi
which led me to
f37c26b
and
#36394

However, that PR shows Closed rather than Merged, so it may not actually be the source of the bug.

@erights
Copy link
Contributor Author

erights commented Mar 6, 2022

See also
endojs/endo#627
and
endojs/endo#126

@erights
Copy link
Contributor Author

erights commented Mar 6, 2022

Attn @bmeck

@erights
Copy link
Contributor Author

erights commented Mar 6, 2022

See also microsoft/vscode#144493

@RaisinTen
Copy link
Contributor

However, that PR shows Closed rather than Merged, so it may not actually be the source of the bug.

The commit was landed manually, see #36394 (comment).

@Divine-Cipher-Cells
Copy link

Hello I am new to open source but familiar with Mern stack and thus would be really helpful if you provide me resources to contribute to this bug

@VoltrexKeyva VoltrexKeyva added the async_hooks Issues and PRs related to the async hooks subsystem. label Mar 6, 2022
@Flarna
Copy link
Member

Flarna commented Mar 7, 2022

I'm not really sure why this should be a bug. If you use async hooks (which happens behind the scenes here by using domain) but tinker with the objects in a way that async hooks can't work anymore it's reasonable that a crash happens.

If you don't use async hooks then it's fine:

const ah = require("async_hooks");

ah.createHook({ init: () => {}}).enable(); // remove this line and it works for you

const p = Promise.resolve(8);
const names = Reflect.ownKeys(p);
console.log(names);
for (const name of names) {
  delete p[name];
}
Object.freeze(p);
const q = p.then(x => console.log(x));

There are a lot other cases where tinkering with objects result in crashes, e.g see following http sample:

const http = require("http");

const server = http.createServer((req, res) => {});
Object.freeze(server);
server.listen();

results in

node:net:1317
    this._handle = rval;
                 ^

TypeError: Cannot assign to read only property '_handle' of object '#<Server>'
    at Server.setupListenHandle [as _listen2] (node:net:1317:18)
    at listenInCluster (node:net:1378:12)
    at Server.listen (node:net:1465:7)
    at Object.<anonymous> (C:\work\node-42229\http.js:5:8)
    at Module._compile (node:internal/modules/cjs/loader:1103:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1155:10)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12)
    at node:internal/main/run_main_module:17:47

@erights
Copy link
Contributor Author

erights commented Mar 7, 2022

The two lines you mention

const ah = require("async_hooks");

ah.createHook({ init: () => {}}).enable(); // remove this line and it works for you

do not appear in my source code. Where would I remove them from? Put another way, how can I omit Node's async_hooks?

@Flarna
Copy link
Member

Flarna commented Mar 7, 2022

If I understand your initial posting correct you use repl to execute your code.
repl uses domain module see here
domain uses async hooks see here

So even the code is not visible it's executed as your setup depends on it.

Edit: As far as I know domain is the only module within nodejs itself which uses async-hooks (besides AsyncLocalStorage which is part of async_hooks module so it's quite oblivious that is it used there).
Additionally I think repl is the only internal module which uses domain. So don't use them and you should be fine.

I don't think there is any switch to remove existence of domain, async_hooks and repl from node.
Clearly once you use any 3rd party package you have to look if they rely on domain and/or async_hooks.

@mhofman
Copy link

mhofman commented Mar 7, 2022

I'm still trying to track down what entrains async_hook, but from what I've gathered so far:

  • Connecting with Chrome devtools or VS Code JS debugger does trigger this behavior
  • Connecting with node inspect 127.0.0.1:9229 does not

Here is the test program that I'm using:

const p = Promise.resolve(8);
const names = Reflect.ownKeys(p);
console.log("p own keys", names);
for (const name of names) {
  delete p[name];
}
Object.freeze(p);
debugger;
const q = p.then((x) => console.log(x));
console.log(q);

If the devtool/VSCode are attached either at start, or as late as the debugger statement, then the following line will throw during the .then with the stack reported above.

Regardless of what triggers the creation of the hooks, I believe this is a bug in the async_hooks implementation which should be resilient to the extensibility state of objects it's attempting to modify. In particular here getOrSetAsyncId should not perform an assignment without try/catch. There may be similar cases. Is there actually a reason async_hooks tries to modify objects instead of using a WeakMap to store its internal information?

@Flarna
Copy link
Member

Flarna commented Mar 7, 2022

I think the VS code debugger uses async_hooks internally.

There may be similar cases. Is there actually a reason async_hooks tries to modify objects instead of using a WeakMap to store its internal information?

I guess performance but not 100% sure if this is the only reason.

Adding a try/catch in getOrSetAsyncId would avoid the exception above but it would break the underlying functionality. I think you should not use async hooks if you don't want them.

@jasnell
Copy link
Member

jasnell commented Mar 7, 2022

Regardless of what triggers the creation of the hooks, I believe this is a bug in the async_hooks implementation which should be resilient to the extensibility state of objects it's attempting to modify. In particular here getOrSetAsyncId should not perform an assignment without try/catch. There may be similar cases. Is there actually a reason async_hooks tries to modify objects instead of using a WeakMap to store its internal information?

Node.js will attach async context metadata to any object that can be tracked with async_hooks regardless of whether async_hooks is actively being used or not within code and yes, finding a way of tracking the information without modifying the objects themselves would be ideal. I'd certainly +1 a refactor on that -- though it's likely to be a non-trivial change.

@mhofman
Copy link

mhofman commented Mar 7, 2022

I think you should not use async hooks if you don't want them.

We do not want to use async_hooks. If I could prevent it from loading I would (is there a way without modifying node?). However, we and our users need to be able to attach a debugger, without it crashing the program being debugged.

Node.js will attach async context metadata to any object that can be tracked with async_hooks regardless of whether async_hooks is actively being used or not within code

That is not my observation. If nothing triggers async_hooks (like a debugger), promise objects are not modified and everything runs fine.

@Flarna
Copy link
Member

Flarna commented Mar 7, 2022

I think for the VS code debugger it's possible to disable use of async hooks by adding "showAsyncStacks": false, into the launch config of type "type": "pwa-node".

@mhofman
Copy link

mhofman commented Mar 7, 2022

Thanks, that indeed does it for VS Code!

I would like to re-iterate that:

I'll rename the issue to capture this better.

Of course even better would be for async_hooks to not modify objects it doesn't own at all.

@erights erights changed the title Async hooks add visible junk, causing correct code to fail Async hooks cause programs with frozen promises to crash Mar 8, 2022
@Flarna
Copy link
Member

Flarna commented Mar 8, 2022

@mhofman Ar you sure about the versions? For me the switch from working to non working (using the sample I provided above) is 14.2.0 to 14.3.0 (16.0.0 fails also).

This would point to #32891 which matches actually better as with this PR promise tracking was moved away from (slow) wrapper objects.

@mhofman
Copy link

mhofman commented Mar 8, 2022

Yes, I'm sure about the versions.

I just double checked:
node ➜ ~/workspace/agoric/endo (master ✗) $ nvm use 14.17
Now using node v14.17.6 (npm v6.14.15)
node ➜ ~/workspace/agoric/endo (master ✗) $ node --inspect-brk --inspect=0.0.0.0:9231 debug-node-async-hooks-inner.mjs
Debugger listening on ws://0.0.0.0:9231/1896f9da-5c8e-4966-b6dc-db4edfeedbf3
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.
p own keys []
Promise { <pending> }
8
Waiting for the debugger to disconnect...
node ➜ ~/workspace/agoric/endo (master ✗) $ nvm use 14.18.0
Now using node v14.18.0 (npm v6.14.15)
node ➜ ~/workspace/agoric/endo (master ✗) $ node --inspect-brk --inspect=0.0.0.0:9231 debug-node-async-hooks-inner.mjs
Debugger listening on ws://0.0.0.0:9231/900c4d9c-44a5-4ff5-990d-8514a8cd7b34
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.
p own keys [
  Symbol(async_id_symbol),
  Symbol(trigger_async_id_symbol),
  Symbol(destroyed)
]
Waiting for the debugger to disconnect...
file:///home/node/workspace/agoric/endo/debug-node-async-hooks-inner.mjs:9
const q = p.then((x) => console.log(x));
            ^

TypeError: Cannot add property Symbol(async_id_symbol), object is not extensible
    at getOrSetAsyncId (internal/async_hooks.js:422:34)
    at trackPromise (internal/async_hooks.js:312:35)
    at promiseInitHook (internal/async_hooks.js:320:3)
    at promiseInitHookWithDestroyTracking (internal/async_hooks.js:327:3)
    at Promise.then (<anonymous>)
    at file:///home/node/workspace/agoric/endo/debug-node-async-hooks-inner.mjs:9:13
    at ModuleJob.run (internal/modules/esm/module_job.js:183:25)
    at async Loader.import (internal/modules/esm/loader.js:178:24)
    at async Object.loadESM (internal/process/esm_loader.js:68:5)
node ➜ ~/workspace/agoric/endo (master ✗) $ nvm use 16.5
Now using node v16.5.0 (npm v7.19.1)
node ➜ ~/workspace/agoric/endo (master ✗) $ node --inspect-brk --inspect=0.0.0.0:9231 debug-node-async-hooks-inner.mjs
Debugger listening on ws://0.0.0.0:9231/62dea3a9-fa20-4555-9680-f8d5dc553983
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.
p own keys []
Promise { <pending> }
8
Waiting for the debugger to disconnect...
node ➜ ~/workspace/agoric/endo (master ✗) $ nvm use 16.6.0
Now using node v16.6.0 (npm v7.19.1)
node ➜ ~/workspace/agoric/endo (master ✗) $ node --inspect-brk --inspect=0.0.0.0:9231 debug-node-async-hooks-inner.mjs
Debugger listening on ws://0.0.0.0:9231/9c2b105c-77b3-4593-b9a2-1139592278f6
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.
p own keys [
  Symbol(async_id_symbol),
  Symbol(trigger_async_id_symbol),
  Symbol(destroyed)
]
Waiting for the debugger to disconnect...
file:///home/node/workspace/agoric/endo/debug-node-async-hooks-inner.mjs:9
const q = p.then((x) => console.log(x));
            ^

TypeError: Cannot add property Symbol(async_id_symbol), object is not extensible
    at getOrSetAsyncId (node:internal/async_hooks:422:34)
    at trackPromise (node:internal/async_hooks:312:35)
    at promiseInitHook (node:internal/async_hooks:320:3)
    at promiseInitHookWithDestroyTracking (node:internal/async_hooks:327:3)
    at Promise.then (<anonymous>)
    at file:///home/node/workspace/agoric/endo/debug-node-async-hooks-inner.mjs:9:13
    at ModuleJob.run (node:internal/modules/esm/module_job:183:25)
    at async Loader.import (node:internal/modules/esm/loader:178:24)
    at async Object.loadESM (node:internal/process/esm_loader:68:5)
    at async handleMainPromise (node:internal/modules/run_main:63:12)

With debug-node-async-hooks-inner.mjs:

const p = Promise.resolve(8);
const names = Reflect.ownKeys(p);
console.log("p own keys", names);
for (const name of names) {
  delete p[name];
}
Object.freeze(p);
debugger;
const q = p.then((x) => console.log(x));
console.log(q);

And VS Code's launch.json:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "attach",
      "name": "Attach Inspector",
      "skipFiles": [],
      "address": "127.0.0.1",
      "port": 9231,
      "protocol": "inspector",
      "showAsyncStacks": true
    }
  ]
}

@mhofman
Copy link

mhofman commented Mar 8, 2022

Hmm I just realized that #36394 landed in 36b948560c, so would be in 16.2 but clearly the bug doesn't manifest until 16.6. Now I'm curious what the actual cause is.

@mhofman
Copy link

mhofman commented Mar 8, 2022

#39135 seems like the likely culprit. Landing in both v14.18 and v16.6

@Flarna
Copy link
Member

Flarna commented Mar 8, 2022

I think I understand the full picture now:

The inspector uses a destroy hook but my sample above does not.

@mhofman
Copy link

mhofman commented Mar 9, 2022

Thanks for clarifying the picture.

What is the way forward on this? While we have a workaround for now (disabling async stack traces in the debugger), we'd like to be able to restore that debugging information.

I understand the changes in async_hooks were performance motivated, but they came at the cost of breaking the ability to debug our environment. I assume moving to WeakMap instead of attaching symbols would alleviate this, but I understand this is a possibly large change, which might have a negative performance impact. Is that the only solution?

@devsnek
Copy link
Member

devsnek commented Mar 9, 2022

Just to throw another option out here, I don't know what the performance characteristics would be compared to weakkmaps but private symbols (a v8 hack/feature) are not effected by freezing.

https://github.com/devsnek/private-symbol/blob/master/private.cc

@mhofman
Copy link

mhofman commented Mar 9, 2022

Seems like it'd be fairly easy to try? However I'm not sure of how closely the current symbols are held inside node_internal, and if there'd be a risk to leak out this private symbol.

@erights
Copy link
Contributor Author

erights commented Mar 9, 2022

Sigh. This terrible "private symbol" kludge took all the effort recapitulates the runtime arrangement of an efficient weakmap implementation, i.e., one using the transposed representation. All the effort and none of the safety.

Perhaps, it seems possible this mechanism could be encapsulated in a shim that presents exactly the API and semantics of weakmaps, without the cost of v8 (non-transposed) weakmaps.

@devsnek
Copy link
Member

devsnek commented Mar 9, 2022

i had a feeling you might say something like that mark :)

i think as long as this is only used for internal implementation of the runtime and not more generally it should be alright. that's also assuming that it is an improvement over actual weakmaps.

@erights
Copy link
Contributor Author

erights commented Mar 9, 2022

that's also assuming that it is an improvement over actual weakmaps.

Could anyone measure? Please?

@Flarna
Copy link
Member

Flarna commented Mar 9, 2022

Are these private symbols visible anywhere? e.g. in special in debugger it's quite important to see them otherwise working with them would be quite a pain.

The JS language is still a source of surprises for me. On the one hand monkeypatching, adding this and that property at any time and at any place (e.g. mixins),... are used widely. But on the other hand there are restrictions where not even debugger or other tooling can do anything (e.g. ECMA script modules, private symbols).

@erights
Copy link
Contributor Author

erights commented Mar 9, 2022

To be clear, these private symbols are outside JS. There is no reason for JS tooling to do anything reasonable with them.

The only reason anyone would be tempted to use them is that they're much more efficient than weakmaps. If that's the case, we can encapsulate this kludgy mechanism within a shim of an efficient weakmap. We can then pressure v8 to make the actual weakmaps competitive with the shim.

Since this thread on the node rather than v8, you should simply use the actual weakmaps.

Does anyone have any measurements to indicate whether there actually is a performance issue?

@mhofman
Copy link

mhofman commented Mar 9, 2022

After chatting with @bmeck, it looks like private symbols wouldn't be efficient enough for this use case, and a WeakMap would be even worse in v8 due to the lack of transposing. A suggested alternative was (ab)using private fields (through return override), which should be much faster than either. Both WeakMap and private fields would however require a refactor away from a "symbol" attached/read like a property. A temporary compromise might be to keep the existing symbols but have a fallback to a WeakMap in getOrSetAsyncId if !Object.isExtensible(object). That would probably satisfy our use case while not changing existing behavior.

@Flarna
Copy link
Member

Flarna commented Mar 9, 2022

I'm not aware of any actual measurements. As far as I remember the memory overhead of WeakMaps was quite bad a few years ago but that's too long ago to count as source of truth.

But if it goes towards overhead it's needed to look at CPU and Memory overhead.

Edit: found the issue where this is mentioned: nodejs/diagnostics#188

@mhofman
Copy link

mhofman commented Mar 9, 2022

Just took a look at what adding a fallback mechanism would look like (which is basically a targeted refactor to get/set helpers just for promises), and I'm running into a complication as it seems the native side is directly accessing these symbols as well:

node/src/node_task_queue.cc

Lines 116 to 151 in e8ac3b4

double async_id = AsyncWrap::kInvalidAsyncId;
double trigger_async_id = AsyncWrap::kInvalidAsyncId;
TryCatchScope try_catch(env);
if (!GetAssignedPromiseAsyncId(env, promise, env->async_id_symbol())
.To(&async_id)) return;
if (!GetAssignedPromiseAsyncId(env, promise, env->trigger_async_id_symbol())
.To(&trigger_async_id)) return;
if (async_id == AsyncWrap::kInvalidAsyncId &&
trigger_async_id == AsyncWrap::kInvalidAsyncId) {
// That means that promise might be a PromiseWrap, so we'll
// check there as well.
if (!GetAssignedPromiseWrapAsyncId(env, promise, env->async_id_symbol())
.To(&async_id)) return;
if (!GetAssignedPromiseWrapAsyncId(
env, promise, env->trigger_async_id_symbol())
.To(&trigger_async_id)) return;
}
if (async_id != AsyncWrap::kInvalidAsyncId &&
trigger_async_id != AsyncWrap::kInvalidAsyncId) {
env->async_hooks()->push_async_context(
async_id, trigger_async_id, promise);
}
USE(callback->Call(
env->context(), Undefined(isolate), arraysize(args), args));
if (async_id != AsyncWrap::kInvalidAsyncId &&
trigger_async_id != AsyncWrap::kInvalidAsyncId &&
env->execution_async_id() == async_id) {
// This condition might not be true if async_hooks was enabled during
// the promise callback execution.
env->async_hooks()->pop_async_context(async_id);
}

I think this is where my abilities stop. Does anyone have suggestions on how best to proceed?

@devsnek
Copy link
Member

devsnek commented Mar 9, 2022

you could modify GetAssignedPromiseAsyncId and friends to fall back to the weakmap perhaps?

@mhofman
Copy link

mhofman commented Mar 9, 2022

I'm sure someone could, but I'm not able to myself (it's been 15 years since I touched C code)

@bmeck
Copy link
Member

bmeck commented Mar 9, 2022

These get measured various times all over every year, most recent one on social media was : https://github.com/RafaelGSS/nodejs-bench-operations/blob/main/bench/private-property.js some tweaks to add WeakMap backed checks w/ grouping to ease cost of lookup for example give my local machine:

Node.js Benchmark Operations

  • Machine: darwin x64 | 8 vCPUs | 32.0GB Mem
  • Node: v17.2.0
  • Run: Wed Mar 09 2022 13:59:32 GMT-0600 (Central Standard Time)

private-property.js

Raw usage private field x 13,704,157 ops/sec ±1.53% (86 runs sampled)
Raw usage underscore usage x 9,562,654 ops/sec ±1.74% (88 runs sampled)
Raw usage weakmap usage x 2,224,267 ops/sec ±3.02% (68 runs sampled)
Manipulating private properties using # x 3,715,253 ops/sec ±1.82% (90 runs sampled)
Manipulating "private" properties using underscore(_) x 797,982,230 ops/sec ±0.66% (91 runs sampled)
Manipulating "private" properties using Symbol x 797,110,945 ops/sec ±0.76% (93 runs sampled)
Manipulating private properties using PrivateSymbol x 44,934,479 ops/sec ±1.55% (86 runs sampled)
Manipulating private properties using WeakMap x 979,550 ops/sec ±22.24% (24 runs sampled)
Manipulating private properties using Return Override and private fields x 9,728,517 ops/sec ±3.49% (88 runs sampled)

Newer V8 has fixes for private fields:

Node.js Benchmark Operations

  • Machine: darwin x64 | 8 vCPUs | 32.0GB Mem
  • Node: v18.0.0-pre
  • Run: Wed Mar 09 2022 13:56:45 GMT-0600 (Central Standard Time)

private-property.js

Raw usage private field x 779,883,070 ops/sec ±1.63% (85 runs sampled)
Raw usage underscore usage x 799,159,114 ops/sec ±0.85% (91 runs sampled)
Raw usage weakmap usage x 2,254,865 ops/sec ±4.79% (72 runs sampled)
Manipulating private properties using # x 792,309,165 ops/sec ±0.59% (93 runs sampled)
Manipulating "private" properties using underscore(_) x 797,127,594 ops/sec ±0.43% (92 runs sampled)
Manipulating "private" properties using Symbol x 790,864,710 ops/sec ±1.13% (93 runs sampled)
Manipulating private properties using PrivateSymbol x 46,016,836 ops/sec ±1.23% (87 runs sampled)
Manipulating private properties using WeakMap x 796,352 ops/sec ±37.79% (22 runs sampled)
Manipulating private properties using Return Override and private fields x 53,340,341 ops/sec ±3.59% (86 runs sampled)

WeakMaps are > 600 times slower even with grouping to reduce cost and Return Override is also hellish at ~15 times slower. I don't think they should be used in normal operation.

@naugtur
Copy link

naugtur commented Mar 9, 2022

I'll just leave this (monkeypatch) workaround here. No need to tell the developer to turn off async stacktraces manually

const a = require('async_hooks');
const bkp = a.createHook
const noop = _ => { }
a.createHook = function () {
    //process._rawDebug(Error().stack)
    if (Error().stack.includes('node:internal/inspector_async_hook')) {
        return { enable: noop, disable: noop }
    }
    return bkp.apply(this, arguments)
}

@erights
Copy link
Contributor Author

erights commented Mar 10, 2022

WeakMaps are > 600 times slower [...]

FWIW, this is ridiculous. We should explain the brilliant weakmap representation and algorithm Moddable recently invented and implemented, which avoids the difficult choice between transposed and non-transposed representation. With one adjustment, it could provide reasonable efficiency and complexity measure under all scenarios. Having thought about ephemeron algorithms for a long time, the Moddable solution is by far the best solution I've seen. We should encourage v8 to adopt it.

@phoddie @patrick-soquet , we should talk about publishing your weakmap gc algorithm!

@phoddie
Copy link

phoddie commented Mar 10, 2022

Our implementation of weakmaps in XS is in our repository for all to see. It could be a little challenging to infer the details from the code though. Perhaps we should write something about that.

@devsnek
Copy link
Member

devsnek commented Mar 11, 2022

@phoddie to see if i'm understanding this correctly, wm.set(key, value) basically stores value in a slot on key, and then stores key in a slot on map, so then you can either collect individual entries by visiting key or all entries by visiting map?

@erights
Copy link
Contributor Author

erights commented Mar 11, 2022

Here's my first step to understanding it.

Prior to any form of weakness, normal gc only has OR joins: If A points at C and B points at C then C is reachable if either A is reachable OR B is reachable.

From an API and authority perspective, WeakMaps are asymmetric. But from a GC algorithm perspective, WeakMaps introduce symmetric AND joins into GC. After M[K] = V, V is reachable if M is reachable AND K is reachable. AND is commutative.

The Moddable GC algorithm might notice first either that reachable M at K points at V or that reachable K at M points at V. Whichever it notices first, it then transitions into a representation such that iff it notices the other one is also reachable, then it decides that V is reachable.

@Flarna
Copy link
Member

Flarna commented Mar 11, 2022

While I find the discussions regarding WeakMap algorithms here really interesting I doubt that this will result in a fast fix.

May I ask what are the exact requirements for SES?
If you omit deleting the keys added by async hooks the problem you see vanishes. Is deleting the keys a requirement for SES?

Please note that async hooks expose quite some internals in general. On every init hook you get a reference to the object created. Some of them are node internal objects which end user may never see via the public APIs (e.g. an HTTP Parser,...). This is one of the main reasons why async hooks never exited experimental status.

Therefore I wonder if SES can accept the existence of an installed async hook at all - independent of implementation details like WeakMap/private symbols,...

@naugtur
Copy link

naugtur commented Mar 11, 2022

@Flarna SES guarantees could be broken by the program running in a hardened compartment accessing hooks. That won't be allowed. The issue here is developers using SES should still be able to use devtools/debugger.

@mhofman
Copy link

mhofman commented Mar 14, 2022

To expand on @naugtur's point:

  • async hooks are probably powerful enough to break out of a locked down Compartment. However that's not relevant, as it wouldn't be a power that should be endowed to untrusted compartments.
  • The modifications to promises that async hooks cause when enabled leak information to the program on execution across the realm, which poses a confidentiality issue. As such, async_hooks should not be enabled by trusted code, unless it accepts those risks. Using private symbols, private fields, or weakmap by default would remove this confidentiality issue.

In the case of async hooks triggered by debugging, the hooks themselves are never exposed to the program, so I believe there is no endowment issue. Also the action of debugging can only be taken if you are already in a privileged position.

I have some workarounds we can put in place to mitigate this debugging use case, avoiding crashes on frozen promises, while maintaining debugger async traces. However they are not optimal, and I would like to know if some of the following could be considered:

  • Relax the follwing hasOwn check (it's inconsistent with simple assignment anywhere else):
    if (ObjectPrototypeHasOwnProperty(object, async_id_symbol)) {

    Background: the current workaround involves installing accessors on the Promise.prototype to transform into a defineProperty or storing in a WeakMap instance, depending on the extensibility of the promise object. This is probably the easiest change.
  • Add an internal WeakMap fallback if promise objects are not extensible. I am not sure how to deal with the native code expecting the presence of those symbols tho, as I mentioned above.
  • Remove the destroyed object bag logic for promises. From what I can tell, it no longer serves any purpose: the native side only ever reads that the destroyed prop is false, and always emits destroy. There isn't really any confidentiality issue or side channel created by this destroyed object bag, since it's simply attached to the promise object and can be frozen just as well, it's just a wart that doesn't seem to have any purpose anymore.

I'd still like to find a solution to avoid leaking the async ids for all promise objects, or at least an option to put the async hooks into a mode where it doesn't create those props, but as I mentioned, there is the issue of native code requiring these symbols's presence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
async_hooks Issues and PRs related to the async hooks subsystem.
Projects
None yet
Development

No branches or pull requests