Repository: denoland/deno
Pull request: fix(node/crypto): integrate async_hooks for domain error handling

Closes https://github.com/denoland/deno/issues/31636

This PR adds:
  - `AsyncHook` in `domain.ts` with `init`/`before`/`after`/`destroy` callbacks to associate async operations with domains
  - `emitInit`/`emitBefore`/`emitAfter`/`emitDestroy` calls in crypto functions
  - Error catching in callbacks that routes errors to the active domain


Changed files:
filename: ext/node/polyfills/domain.ts
patch: @@ -5,8 +5,10 @@
 
 import { primordials } from "ext:core/mod.js";
 import { ERR_UNHANDLED_ERROR } from "ext:deno_node/internal/errors.ts";
+import { AsyncHook } from "ext:deno_node/internal/async_hooks.ts";
 const {
   ArrayPrototypeIndexOf,
+  ArrayPrototypeLastIndexOf,
   ArrayPrototypePush,
   ArrayPrototypeSlice,
   ArrayPrototypeSplice,
@@ -15,6 +17,7 @@ const {
   ObjectDefineProperty,
   ObjectPrototypeIsPrototypeOf,
   ReflectApply,
+  SafeMap,
 } = primordials;
 import { EventEmitter } from "node:events";
 
@@ -26,6 +29,44 @@ let stack = [];
 export let _stack = stack;
 export let active = null;
 
+// Map asyncId -> domain for tracking async operations
+const pairing = new SafeMap();
+
+// Async hook to track domain associations across async operations
+const asyncHook = new AsyncHook({
+  init(asyncId, _type, _triggerAsyncId, resource) {
+    if (process.domain !== null && process.domain !== undefined) {
+      // Record which domain this async operation belongs to
+      pairing.set(asyncId, process.domain);
+      // Attach domain to resource
+      if (typeof resource === "object" && resource !== null) {
+        ObjectDefineProperty(resource, "domain", {
+          __proto__: null,
+          configurable: true,
+          enumerable: false,
+          value: process.domain,
+          writable: true,
+        });
+      }
+    }
+  },
+  before(asyncId) {
+    const domain = pairing.get(asyncId);
+    if (domain !== undefined) {
+      domain.enter();
+    }
+  },
+  after(asyncId) {
+    const domain = pairing.get(asyncId);
+    if (domain !== undefined) {
+      domain.exit();
+    }
+  },
+  destroy(asyncId) {
+    pairing.delete(asyncId);
+  },
+});
+
 export function create() {
   return new Domain();
 }
@@ -40,6 +81,7 @@ export class Domain extends EventEmitter {
   constructor() {
     super();
     patchEventEmitter();
+    asyncHook.enable();
   }
 
   add(ee) {
@@ -107,13 +149,17 @@ export class Domain extends EventEmitter {
     };
   }
 
-  run(fn) {
+  run(fn, ...args) {
+    this.enter();
     try {
-      return fn();
+      const ret = FunctionPrototypeApply(fn, this, args);
+      this.exit();
+      return ret;
     } catch (e) {
+      // Exit the domain BEFORE emitting the error so the error handler
+      this.exit();
       FunctionPrototypeCall(emitError, this, e);
     }
-    return this;
   }
 
   dispose() {
@@ -122,10 +168,17 @@ export class Domain extends EventEmitter {
   }
 
   enter() {
+    active = process.domain = this;
+    ArrayPrototypePush(stack, this);
     return this;
   }
 
   exit() {
+    const index = ArrayPrototypeLastIndexOf(stack, this);
+    if (index === -1) return this;
+    ArrayPrototypeSplice(stack, index, 1);
+    active = stack.length === 0 ? null : stack[stack.length - 1];
+    process.domain = active;
     return this;
   }
 }

filename: ext/node/polyfills/internal/crypto/_randomBytes.ts
patch: @@ -4,6 +4,15 @@
 // deno-lint-ignore-file prefer-primordials
 
 import { Buffer } from "node:buffer";
+import {
+  emitAfter,
+  emitBefore,
+  emitDestroy,
+  emitInit,
+  executionAsyncId,
+  newAsyncId,
+} from "ext:deno_node/internal/async_hooks.ts";
+import process from "node:process";
 
 export const MAX_RANDOM_VALUES = 65536;
 export const MAX_SIZE = 4294967295;
@@ -61,11 +70,31 @@ export default function randomBytes(
         err = new Error("[non-error thrown]");
       }
     }
+
+    // Set up async_hooks tracking
+    const asyncId = newAsyncId();
+    const triggerAsyncId = executionAsyncId();
+    const resource = {};
+    emitInit(asyncId, "RANDOMBYTESREQUEST", triggerAsyncId, resource);
+
     setTimeout(() => {
-      if (err) {
-        cb(err);
-      } else {
-        cb(null, bytes);
+      emitBefore(asyncId);
+      try {
+        if (err) {
+          cb(err);
+        } else {
+          cb(null, bytes);
+        }
+      } catch (callbackErr) {
+        // If there's an active domain, emit error to it
+        if (process.domain && process.domain.listenerCount("error") > 0) {
+          process.domain.emit("error", callbackErr);
+        } else {
+          throw callbackErr;
+        }
+      } finally {
+        emitAfter(asyncId);
+        emitDestroy(asyncId);
       }
     }, 0);
   } else {

filename: ext/node/polyfills/internal/crypto/pbkdf2.ts
patch: @@ -21,6 +21,15 @@ import {
   ERR_CRYPTO_INVALID_DIGEST,
   ERR_OUT_OF_RANGE,
 } from "ext:deno_node/internal/errors.ts";
+import {
+  emitAfter,
+  emitBefore,
+  emitDestroy,
+  emitInit,
+  executionAsyncId,
+  newAsyncId,
+} from "ext:deno_node/internal/async_hooks.ts";
+import process from "node:process";
 
 export const MAX_ALLOC = Math.pow(2, 30) - 1;
 export const MAX_I32 = 2 ** 31 - 1;
@@ -129,16 +138,66 @@ export function pbkdf2(
   digest = digest.toLowerCase() as NormalizedAlgorithms;
   op_node_pbkdf2_validate(digest);
 
+  // Set up async_hooks tracking
+  const asyncId = newAsyncId();
+  const triggerAsyncId = executionAsyncId();
+  const resource = {};
+  emitInit(asyncId, "PBKDF2REQUEST", triggerAsyncId, resource);
+
+  // Track if callback was already invoked (to avoid calling twice if callback throws)
+  let callbackInvoked = false;
+
   op_node_pbkdf2_async(
     password,
     salt,
     iterations,
     digest,
     keylen,
   ).then(
-    (DK) => callback(null, Buffer.from(DK)),
+    (DK) => {
+      callbackInvoked = true;
+      emitBefore(asyncId);
+      try {
+        callback(null, Buffer.from(DK));
+      } catch (err) {
+        // If there's an active domain, emit error to it
+        if (process.domain && process.domain.listenerCount("error") > 0) {
+          process.domain.emit("error", err);
+        } else {
+          throw err;
+        }
+      } finally {
+        emitAfter(asyncId);
+        emitDestroy(asyncId);
+      }
+    },
   )
-    .catch((err) => callback(err));
+    .catch((err) => {
+      // Don't call callback again if error was thrown by the callback itself
+      if (callbackInvoked) {
+        // If there's an active domain, emit error to it
+        if (process.domain && process.domain.listenerCount("error") > 0) {
+          process.domain.emit("error", err);
+        } else {
+          throw err;
+        }
+        return;
+      }
+      emitBefore(asyncId);
+      try {
+        callback(err);
+      } catch (callbackErr) {
+        // If there's an active domain, emit error to it
+        if (process.domain && process.domain.listenerCount("error") > 0) {
+          process.domain.emit("error", callbackErr);
+        } else {
+          throw callbackErr;
+        }
+      } finally {
+        emitAfter(asyncId);
+        emitDestroy(asyncId);
+      }
+    });
 }
 
 export default {

filename: tests/node_compat/config.toml
patch: @@ -202,7 +202,8 @@
 "parallel/test-crypto-dh-leak.js" = {}
 "parallel/test-crypto-dh-odd-key.js" = {}
 "parallel/test-crypto-dh-shared.js" = {}
-"parallel/test-crypto-domain.js" = { darwin = false, linux = false, windows = false, reason = "https://github.com/denoland/deno/issues/31636" }
+"parallel/test-crypto-domain.js" = {}
+"parallel/test-crypto-domains.js" = {}
 "parallel/test-crypto-ecb.js" = {}
 "parallel/test-crypto-from-binary.js" = {}
 "parallel/test-crypto-keygen-async-elliptic-curve-jwk-rsa.js" = {}