diff --git a/ext/node/lib.rs b/ext/node/lib.rs index a4a757996e..74c528b0c2 100644 --- a/ext/node/lib.rs +++ b/ext/node/lib.rs @@ -181,15 +181,6 @@ fn op_node_build_os() -> String { env!("TARGET").split('-').nth(2).unwrap().to_string() } -#[op2(fast)] -fn op_node_is_promise_rejected(value: v8::Local) -> bool { - let Ok(promise) = v8::Local::::try_from(value) else { - return false; - }; - - promise.state() == v8::PromiseState::Rejected -} - #[op2] #[string] fn op_npm_process_state(state: &mut OpState) -> Result { @@ -350,7 +341,6 @@ deno_core::extension!(deno_node, ops::os::op_cpus

, ops::os::op_homedir

, op_node_build_os, - op_node_is_promise_rejected, op_npm_process_state, ops::require::op_require_init_paths, ops::require::op_require_node_module_paths

, diff --git a/ext/node/polyfills/_next_tick.ts b/ext/node/polyfills/_next_tick.ts index 5915c750ee..5d895012ee 100644 --- a/ext/node/polyfills/_next_tick.ts +++ b/ext/node/polyfills/_next_tick.ts @@ -5,6 +5,10 @@ // deno-lint-ignore-file prefer-primordials import { core } from "ext:core/mod.js"; +import { + getAsyncContext, + setAsyncContext, +} from "ext:runtime/01_async_context.js"; import { validateFunction } from "ext:deno_node/internal/validators.mjs"; import { _exiting } from "ext:deno_node/_process/exiting.ts"; @@ -13,6 +17,7 @@ import { FixedQueue } from "ext:deno_node/internal/fixed_queue.ts"; interface Tock { callback: (...args: Array) => void; args: Array; + snapshot: unknown; } let nextTickEnabled = false; @@ -23,7 +28,7 @@ export function enableNextTick() { const queue = new FixedQueue(); export function processTicksAndRejections() { - let tock; + let tock: Tock; do { // deno-lint-ignore no-cond-assign while (tock = queue.shift()) { @@ -31,9 +36,11 @@ export function processTicksAndRejections() { // const asyncId = tock[async_id_symbol]; // emitBefore(asyncId, tock[trigger_async_id_symbol], tock); + const oldContext = getAsyncContext(); try { - const callback = (tock as Tock).callback; - if ((tock as Tock).args === undefined) { + setAsyncContext(tock.snapshot); + const callback = tock.callback; + if (tock.args === undefined) { callback(); } else { const args = (tock as Tock).args; @@ -58,6 +65,7 @@ export function processTicksAndRejections() { // FIXME(bartlomieju): Deno currently doesn't support async hooks // if (destroyHooksExist()) // emitDestroy(asyncId); + setAsyncContext(oldContext); } // FIXME(bartlomieju): Deno currently doesn't support async hooks @@ -143,6 +151,7 @@ export function nextTick>( // FIXME(bartlomieju): Deno currently doesn't support async hooks // [async_id_symbol]: asyncId, // [trigger_async_id_symbol]: triggerAsyncId, + snapshot: getAsyncContext(), callback, args: args_, }; diff --git a/ext/node/polyfills/async_hooks.ts b/ext/node/polyfills/async_hooks.ts index f94b8d2c64..ad720d9360 100644 --- a/ext/node/polyfills/async_hooks.ts +++ b/ext/node/polyfills/async_hooks.ts @@ -1,191 +1,34 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. // Copyright Joyent and Node contributors. All rights reserved. MIT license. -// This implementation is inspired by "workerd" AsyncLocalStorage implementation: -// https://github.com/cloudflare/workerd/blob/77fd0ed6ddba184414f0216508fc62b06e716cab/src/workerd/api/node/async-hooks.c++#L9 - // TODO(petamoriken): enable prefer-primordials for node polyfills // deno-lint-ignore-file prefer-primordials -import { core } from "ext:core/mod.js"; -import { op_node_is_promise_rejected } from "ext:core/ops"; +import { primordials } from "ext:core/mod.js"; +import { + AsyncVariable, + getAsyncContext, + setAsyncContext, +} from "ext:runtime/01_async_context.js"; import { validateFunction } from "ext:deno_node/internal/validators.mjs"; import { newAsyncId } from "ext:deno_node/internal/async_hooks.ts"; -function assert(cond: boolean) { - if (!cond) throw new Error("Assertion failed"); -} -const asyncContextStack: AsyncContextFrame[] = []; - -function pushAsyncFrame(frame: AsyncContextFrame) { - asyncContextStack.push(frame); -} - -function popAsyncFrame() { - if (asyncContextStack.length > 0) { - asyncContextStack.pop(); - } -} - -let rootAsyncFrame: AsyncContextFrame | undefined = undefined; -let promiseHooksSet = false; - -const asyncContext = Symbol("asyncContext"); - -function setPromiseHooks() { - if (promiseHooksSet) { - return; - } - promiseHooksSet = true; - - const init = (promise: Promise) => { - const currentFrame = AsyncContextFrame.current(); - if (!currentFrame.isRoot()) { - if (typeof promise[asyncContext] !== "undefined") { - throw new Error("Promise already has async context"); - } - AsyncContextFrame.attachContext(promise); - } - }; - const before = (promise: Promise) => { - const maybeFrame = promise[asyncContext]; - if (maybeFrame) { - pushAsyncFrame(maybeFrame); - } else { - pushAsyncFrame(AsyncContextFrame.getRootAsyncContext()); - } - }; - const after = (promise: Promise) => { - popAsyncFrame(); - if (!op_node_is_promise_rejected(promise)) { - // @ts-ignore promise async context - promise[asyncContext] = undefined; - } - }; - const resolve = (promise: Promise) => { - const currentFrame = AsyncContextFrame.current(); - if ( - !currentFrame.isRoot() && op_node_is_promise_rejected(promise) && - typeof promise[asyncContext] === "undefined" - ) { - AsyncContextFrame.attachContext(promise); - } - }; - - core.setPromiseHooks(init, before, after, resolve); -} - -class AsyncContextFrame { - storage: StorageEntry[]; - constructor( - maybeParent?: AsyncContextFrame | null, - maybeStorageEntry?: StorageEntry | null, - isRoot = false, - ) { - this.storage = []; - - setPromiseHooks(); - - const propagate = (parent: AsyncContextFrame) => { - parent.storage = parent.storage.filter((entry) => !entry.key.isDead()); - parent.storage.forEach((entry) => this.storage.push(entry.clone())); - - if (maybeStorageEntry) { - const existingEntry = this.storage.find((entry) => - entry.key === maybeStorageEntry.key - ); - if (existingEntry) { - existingEntry.value = maybeStorageEntry.value; - } else { - this.storage.push(maybeStorageEntry); - } - } - }; - - if (!isRoot) { - if (maybeParent) { - propagate(maybeParent); - } else { - propagate(AsyncContextFrame.current()); - } - } - } - - static tryGetContext(promise: Promise) { - // @ts-ignore promise async context - return promise[asyncContext]; - } - - static attachContext(promise: Promise) { - // @ts-ignore promise async context - promise[asyncContext] = AsyncContextFrame.current(); - } - - static getRootAsyncContext() { - if (typeof rootAsyncFrame !== "undefined") { - return rootAsyncFrame; - } - - rootAsyncFrame = new AsyncContextFrame(null, null, true); - return rootAsyncFrame; - } - - static current() { - if (asyncContextStack.length === 0) { - return AsyncContextFrame.getRootAsyncContext(); - } - - return asyncContextStack[asyncContextStack.length - 1]; - } - - static create( - maybeParent?: AsyncContextFrame | null, - maybeStorageEntry?: StorageEntry | null, - ) { - return new AsyncContextFrame(maybeParent, maybeStorageEntry); - } - - static wrap( - fn: () => unknown, - maybeFrame: AsyncContextFrame | undefined, - // deno-lint-ignore no-explicit-any - thisArg: any, - ) { - // deno-lint-ignore no-explicit-any - return (...args: any) => { - const frame = maybeFrame || AsyncContextFrame.current(); - Scope.enter(frame); - try { - return fn.apply(thisArg, args); - } finally { - Scope.exit(); - } - }; - } - - get(key: StorageKey) { - assert(!key.isDead()); - this.storage = this.storage.filter((entry) => !entry.key.isDead()); - const entry = this.storage.find((entry) => entry.key === key); - if (entry) { - return entry.value; - } - return undefined; - } - - isRoot() { - return AsyncContextFrame.getRootAsyncContext() == this; - } -} +const { + ObjectDefineProperties, + ReflectApply, + FunctionPrototypeBind, + ArrayPrototypeUnshift, + ObjectFreeze, +} = primordials; export class AsyncResource { - frame: AsyncContextFrame; type: string; + #snapshot: unknown; #asyncId: number; constructor(type: string) { this.type = type; - this.frame = AsyncContextFrame.current(); + this.#snapshot = getAsyncContext(); this.#asyncId = newAsyncId(); } @@ -198,35 +41,38 @@ export class AsyncResource { thisArg: unknown, ...args: unknown[] ) { - Scope.enter(this.frame); - + const previousContext = getAsyncContext(); try { - return fn.apply(thisArg, args); + setAsyncContext(this.#snapshot); + return ReflectApply(fn, thisArg, args); } finally { - Scope.exit(); + setAsyncContext(previousContext); } } emitDestroy() {} - bind(fn: (...args: unknown[]) => unknown, thisArg = this) { + bind(fn: (...args: unknown[]) => unknown, thisArg) { validateFunction(fn, "fn"); - const frame = AsyncContextFrame.current(); - const bound = AsyncContextFrame.wrap(fn, frame, thisArg); - - Object.defineProperties(bound, { + let bound; + if (thisArg === undefined) { + // deno-lint-ignore no-this-alias + const resource = this; + bound = function (...args) { + ArrayPrototypeUnshift(args, fn, this); + return ReflectApply(resource.runInAsyncScope, resource, args); + }; + } else { + bound = FunctionPrototypeBind(this.runInAsyncScope, this, fn, thisArg); + } + ObjectDefineProperties(bound, { "length": { + __proto__: null, configurable: true, enumerable: false, value: fn.length, writable: false, }, - "asyncResource": { - configurable: true, - enumerable: true, - value: this, - writable: true, - }, }); return bound; } @@ -236,95 +82,54 @@ export class AsyncResource { type?: string, thisArg?: AsyncResource, ) { - type = type || fn.name; - return (new AsyncResource(type || "AsyncResource")).bind(fn, thisArg); + type = type || fn.name || "bound-anonymous-fn"; + return (new AsyncResource(type)).bind(fn, thisArg); } } -class Scope { - static enter(maybeFrame?: AsyncContextFrame) { - if (maybeFrame) { - pushAsyncFrame(maybeFrame); - } else { - pushAsyncFrame(AsyncContextFrame.getRootAsyncContext()); - } - } - - static exit() { - popAsyncFrame(); - } -} - -class StorageEntry { - key: StorageKey; - value: unknown; - constructor(key: StorageKey, value: unknown) { - this.key = key; - this.value = value; - } - - clone() { - return new StorageEntry(this.key, this.value); - } -} - -class StorageKey { - #dead = false; - - reset() { - this.#dead = true; - } - - isDead() { - return this.#dead; - } -} - -const fnReg = new FinalizationRegistry((key: StorageKey) => { - key.reset(); -}); - export class AsyncLocalStorage { - #key; - - constructor() { - this.#key = new StorageKey(); - fnReg.register(this, this.#key); - } + #variable = new AsyncVariable(); + enabled = false; // deno-lint-ignore no-explicit-any run(store: any, callback: any, ...args: any[]): any { - const frame = AsyncContextFrame.create( - null, - new StorageEntry(this.#key, store), - ); - Scope.enter(frame); - let res; + this.enabled = true; + const previous = this.#variable.enter(store); try { - res = callback(...args); + return ReflectApply(callback, null, args); } finally { - Scope.exit(); + setAsyncContext(previous); } - return res; } // deno-lint-ignore no-explicit-any exit(callback: (...args: unknown[]) => any, ...args: any[]): any { - return this.run(undefined, callback, args); + if (!this.enabled) { + return ReflectApply(callback, null, args); + } + this.enabled = false; + try { + return ReflectApply(callback, null, args); + } finally { + this.enabled = true; + } } // deno-lint-ignore no-explicit-any getStore(): any { - const currentFrame = AsyncContextFrame.current(); - return currentFrame.get(this.#key); + if (!this.enabled) { + return undefined; + } + return this.#variable.get(); } enterWith(store: unknown) { - const frame = AsyncContextFrame.create( - null, - new StorageEntry(this.#key, store), - ); - Scope.enter(frame); + this.enabled = true; + this.#variable.enter(store); + } + + disable() { + this.enabled = false; } static bind(fn: (...args: unknown[]) => unknown) { @@ -335,14 +140,24 @@ export class AsyncLocalStorage { return AsyncLocalStorage.bind(( cb: (...args: unknown[]) => unknown, ...args: unknown[] - ) => cb(...args)); + ) => ReflectApply(cb, null, args)); } } export function executionAsyncId() { - return 1; + return 0; } +export function triggerAsyncId() { + return 0; +} + +export function executionAsyncResource() { + return {}; +} + +export const asyncWrapProviders = ObjectFreeze({ __proto__: null }); + class AsyncHook { enable() { } @@ -355,12 +170,12 @@ export function createHook() { return new AsyncHook(); } -// Placing all exports down here because the exported classes won't export -// otherwise. export default { - // Embedder API - AsyncResource, - executionAsyncId, - createHook, AsyncLocalStorage, + createHook, + executionAsyncId, + triggerAsyncId, + executionAsyncResource, + asyncWrapProviders, + AsyncResource, }; diff --git a/ext/web/02_timers.js b/ext/web/02_timers.js index 5591478619..a651df5a5a 100644 --- a/ext/web/02_timers.js +++ b/ext/web/02_timers.js @@ -2,6 +2,10 @@ import { core, primordials } from "ext:core/mod.js"; import { op_defer, op_now } from "ext:core/ops"; +import { + getAsyncContext, + setAsyncContext, +} from "ext:runtime/01_async_context.js"; const { Uint8Array, Uint32Array, @@ -33,14 +37,16 @@ function checkThis(thisArg) { * Call a callback function immediately. */ function setImmediate(callback, ...args) { - if (args.length > 0) { - const unboundCallback = callback; - callback = () => ReflectApply(unboundCallback, globalThis, args); - } - - return core.queueImmediate( - callback, - ); + const asyncContext = getAsyncContext(); + return core.queueImmediate(() => { + const oldContext = getAsyncContext(); + try { + setAsyncContext(asyncContext); + return ReflectApply(callback, globalThis, args); + } finally { + setAsyncContext(oldContext); + } + }); } /** @@ -53,10 +59,17 @@ function setTimeout(callback, timeout = 0, ...args) { const unboundCallback = webidl.converters.DOMString(callback); callback = () => indirectEval(unboundCallback); } - if (args.length > 0) { - const unboundCallback = callback; - callback = () => ReflectApply(unboundCallback, globalThis, args); - } + const unboundCallback = callback; + const asyncContext = getAsyncContext(); + callback = () => { + const oldContext = getAsyncContext(); + try { + setAsyncContext(asyncContext); + ReflectApply(unboundCallback, globalThis, args); + } finally { + setAsyncContext(oldContext); + } + }; timeout = webidl.converters.long(timeout); return core.queueUserTimer( core.getTimerDepth() + 1, @@ -75,10 +88,17 @@ function setInterval(callback, timeout = 0, ...args) { const unboundCallback = webidl.converters.DOMString(callback); callback = () => indirectEval(unboundCallback); } - if (args.length > 0) { - const unboundCallback = callback; - callback = () => ReflectApply(unboundCallback, globalThis, args); - } + const unboundCallback = callback; + const asyncContext = getAsyncContext(); + callback = () => { + const oldContext = getAsyncContext(asyncContext); + try { + setAsyncContext(asyncContext); + ReflectApply(unboundCallback, globalThis, args); + } finally { + setAsyncContext(oldContext); + } + }; timeout = webidl.converters.long(timeout); return core.queueUserTimer( core.getTimerDepth() + 1, diff --git a/runtime/js/01_async_context.js b/runtime/js/01_async_context.js new file mode 100644 index 0000000000..9c0236fbe0 --- /dev/null +++ b/runtime/js/01_async_context.js @@ -0,0 +1,45 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { primordials } from "ext:core/mod.js"; +import { op_get_extras_binding_object } from "ext:core/ops"; + +const { + SafeWeakMap, +} = primordials; + +const { + getContinuationPreservedEmbedderData, + setContinuationPreservedEmbedderData, +} = op_get_extras_binding_object(); + +let counter = 0; + +export const getAsyncContext = getContinuationPreservedEmbedderData; +export const setAsyncContext = setContinuationPreservedEmbedderData; + +export class AsyncVariable { + #id = counter++; + #data = new SafeWeakMap(); + + enter(value) { + const previousContextMapping = getAsyncContext(); + const entry = { id: this.#id }; + const asyncContextMapping = { + __proto__: null, + ...previousContextMapping, + [this.#id]: entry, + }; + this.#data.set(entry, value); + setAsyncContext(asyncContextMapping); + return previousContextMapping; + } + + get() { + const current = getAsyncContext(); + const entry = current?.[this.#id]; + if (entry) { + return this.#data.get(entry); + } + return undefined; + } +} diff --git a/runtime/ops/runtime.rs b/runtime/ops/runtime.rs index 306e6ce8f7..b52a23f30c 100644 --- a/runtime/ops/runtime.rs +++ b/runtime/ops/runtime.rs @@ -2,13 +2,14 @@ use deno_core::error::AnyError; use deno_core::op2; +use deno_core::v8; use deno_core::ModuleSpecifier; use deno_core::OpState; use deno_permissions::PermissionsContainer; deno_core::extension!( deno_runtime, - ops = [op_main_module, op_ppid], + ops = [op_main_module, op_ppid, op_get_extras_binding_object], options = { main_module: ModuleSpecifier }, state = |state, options| { state.put::(options.main_module); @@ -94,3 +95,11 @@ pub fn op_ppid() -> i64 { parent_id().into() } } + +#[op2] +pub fn op_get_extras_binding_object<'a>( + scope: &mut v8::HandleScope<'a>, +) -> v8::Local<'a, v8::Value> { + let context = scope.get_current_context(); + context.get_extras_binding_object(scope).into() +} diff --git a/runtime/shared.rs b/runtime/shared.rs index 1b2136c638..185cbc0a9a 100644 --- a/runtime/shared.rs +++ b/runtime/shared.rs @@ -38,6 +38,7 @@ extension!(runtime, dir "js", "01_errors.js", "01_version.ts", + "01_async_context.js", "06_util.js", "10_permissions.js", "11_workers.js", diff --git a/tests/unit_node/async_hooks_test.ts b/tests/unit_node/async_hooks_test.ts index f153f67532..91130972c5 100644 --- a/tests/unit_node/async_hooks_test.ts +++ b/tests/unit_node/async_hooks_test.ts @@ -1,5 +1,7 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { AsyncLocalStorage, AsyncResource } from "node:async_hooks"; +import process from "node:process"; +import { setImmediate } from "node:timers"; import { assert, assertEquals } from "@std/assert"; Deno.test(async function foo() { @@ -92,7 +94,7 @@ Deno.test(async function enterWith() { }); assertEquals(await deferred.promise, { x: 2 }); - assertEquals(await deferred1.promise, { x: 1 }); + assertEquals(await deferred1.promise, null); }); Deno.test(async function snapshot() { @@ -135,3 +137,26 @@ Deno.test(function emitDestroyStub() { const resource = new AsyncResource("foo"); assert(typeof resource.emitDestroy === "function"); }); + +Deno.test(async function worksWithAsyncAPIs() { + const store = new AsyncLocalStorage(); + const test = () => assertEquals(store.getStore(), "data"); + await store.run("data", async () => { + test(); + queueMicrotask(() => test()); + process.nextTick(() => test()); + setImmediate(() => test()); + setTimeout(() => test(), 0); + const intervalId = setInterval(() => { + test(); + clearInterval(intervalId); + }, 0); + + store.run("data2", () => { + assertEquals(store.getStore(), "data2"); + }); + + await new Promise((r) => setTimeout(r, 50)); + test(); + }); +});