Skip to main content

nodejs/stdlib/
async_hooks.rs

1//! Node `async_hooks` module — honest minimal implementation.
2//!
3//! There IS a real async-resource id graph, but only over the resources this
4//! module itself creates:
5//!
6//!   - Every `new AsyncResource(type)` takes the next monotonically increasing
7//!     `asyncId` (from 2; Node reserves 1 for the root context) and records the
8//!     creating context's id as its `triggerAsyncId`.
9//!   - `runInAsyncScope` makes that pair the current execution context for the
10//!     duration of the call, so `executionAsyncId()` inside reports the
11//!     resource and a resource constructed there inherits it as its parent.
12//!     Nesting `runInAsyncScope` therefore builds a real parent chain.
13//!
14//! What is NOT modeled: node-js does not instrument timers, promises, sockets
15//! or any other engine-level async resource, so `executionAsyncId()` inside a
16//! `setTimeout`/`.then` callback reports the ROOT context (1) rather than a
17//! per-callback id, and `triggerAsyncId()` there reports 0. Only the
18//! `AsyncResource` graph above is real. Consequently:
19//!
20//!   - `createHook({ init, before, after, destroy })` returns a hook object with
21//!     chainable `enable()`/`disable()`. The registered callbacks are stored
22//!     nowhere and NEVER FIRE — node-js does not instrument async resource
23//!     lifetimes. This is intentional; do not treat it as a gap to "fill" by
24//!     faking hook invocations.
25//!
26//! What IS real is `AsyncLocalStorage` for the SYNCHRONOUS case: `run(store, cb)`
27//! makes `getStore()` return `store` for the duration of `cb` (and restores the
28//! previous store afterwards), and `enterWith(store)` sets the current store for
29//! subsequent synchronous `getStore()` calls. Because there is no async-context
30//! propagation, a store set with `enterWith` (or visible inside `run`) does NOT
31//! automatically follow into `setTimeout`/Promise callbacks — cross-async
32//! propagation is not modeled. Within straight-line synchronous code the store is
33//! correct.
34//!
35//! Instances are `@@native`-tagged objects (`AsyncLocalStorage` / `AsyncHook`)
36//! dispatched through `instance_call`; the parent wires `construct`,
37//! `native_tag`, `instance_has_method`, and `instance_call` (see the report).
38
39use crate::host::{invoke, with_host};
40use fusevm::Value;
41use indexmap::IndexMap;
42use std::cell::RefCell;
43use std::collections::HashMap;
44
45thread_local! {
46    /// Per-`AsyncLocalStorage`-instance store stack, keyed by the instance's heap
47    /// index. Push on `run`/`enterWith`, pop on `run` exit. The top is what
48    /// `getStore()` returns. A stack (not a single slot) so nested `run` calls
49    /// restore the enclosing store correctly.
50    static STORES: RefCell<HashMap<u32, Vec<Value>>> = RefCell::new(HashMap::new());
51
52    /// Monotonic async-id source. Node reserves 1 for the root execution
53    /// context, so fresh resources start at 2.
54    static NEXT_ASYNC_ID: RefCell<f64> = const { RefCell::new(2.0) };
55
56    /// The execution-context stack as `(asyncId, triggerAsyncId)` pairs, rooted
57    /// at Node's `(1, 0)`. `runInAsyncScope` pushes the resource's pair for the
58    /// duration of the call, which is what makes `executionAsyncId()` inside the
59    /// scope report the resource and a resource *created* in that scope inherit
60    /// it as its `triggerAsyncId`.
61    static EXEC_STACK: RefCell<Vec<(f64, f64)>> = const { RefCell::new(Vec::new()) };
62}
63
64/// The id of the currently-executing async context (Node's `executionAsyncId`).
65pub fn execution_async_id() -> f64 {
66    EXEC_STACK.with(|s| s.borrow().last().map(|p| p.0).unwrap_or(1.0))
67}
68
69/// The id of the context that *created* the currently-executing one.
70pub fn trigger_async_id() -> f64 {
71    EXEC_STACK.with(|s| s.borrow().last().map(|p| p.1).unwrap_or(0.0))
72}
73
74/// Take the next async id.
75fn fresh_async_id() -> f64 {
76    NEXT_ASYNC_ID.with(|n| {
77        let mut n = n.borrow_mut();
78        let id = *n;
79        *n += 1.0;
80        id
81    })
82}
83
84/// Run `f` with `(async_id, trigger_id)` as the current execution context,
85/// restoring the previous context even if `f` fails.
86fn in_async_scope<T>(async_id: f64, trigger_id: f64, f: impl FnOnce() -> T) -> T {
87    EXEC_STACK.with(|s| s.borrow_mut().push((async_id, trigger_id)));
88    let r = f();
89    EXEC_STACK.with(|s| {
90        s.borrow_mut().pop();
91    });
92    r
93}
94
95/// Module-level callable members.
96pub const METHODS: &[&str] = &["executionAsyncId", "triggerAsyncId", "createHook"];
97
98/// Instance method names by native tag — for the parent's `instance_has_method`
99/// so a method *read* (`als.run.bind(...)`) resolves before it is invoked.
100pub const ALS_METHODS: &[&str] = &["getStore", "run", "enterWith", "exit", "disable"];
101pub const HOOK_METHODS: &[&str] = &["enable", "disable"];
102pub const RESOURCE_METHODS: &[&str] = &[
103    "runInAsyncScope",
104    "emitDestroy",
105    "asyncId",
106    "triggerAsyncId",
107    "bind",
108];
109
110/// Static members on the `AsyncResource` constructor itself.
111pub const RESOURCE_STATIC_METHODS: &[&str] = &["bind"];
112
113/// `AsyncResource.bind(fn[, type[, thisArg]])` — with no async-context graph to
114/// capture there is nothing to restore, so the bound function IS `fn` (bound to
115/// `thisArg` when one is given). Node's own semantics reduce to this whenever no
116/// context is active.
117pub fn static_call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
118    match method {
119        "bind" => {
120            let f = args.first().cloned().unwrap_or(Value::Undef);
121            Some(Ok(bind_to(f, args.get(2).cloned())))
122        }
123        _ => None,
124    }
125}
126
127/// `fn` itself, or a `fn.bind(thisArg)` when a non-nullish receiver is supplied.
128fn bind_to(f: Value, this: Option<Value>) -> Value {
129    match this.filter(|t| !with_host(|h| h.is_nullish(t))) {
130        Some(t) => with_host(|h| {
131            h.alloc(crate::host::JsObj::BoundFunc {
132                target: f,
133                this: t,
134                args: Vec::new(),
135            })
136        }),
137        None => f,
138    }
139}
140
141pub fn call(method: &str, _args: &[Value]) -> Option<Result<Value, String>> {
142    Some(match method {
143        "executionAsyncId" => Ok(Value::Float(execution_async_id())),
144        "triggerAsyncId" => Ok(Value::Float(trigger_async_id())),
145        // The hook object; its callbacks never fire (see module docs).
146        "createHook" => Ok(new_hook()),
147        _ => return None,
148    })
149}
150
151/// Construct a stdlib class instance (`new AsyncLocalStorage()`). `None` for any
152/// other name so the parent's `construct` can fall through.
153pub fn construct(name: &str, args: &[Value]) -> Option<Result<Value, String>> {
154    match name {
155        "AsyncLocalStorage" => Some(Ok(new_native("AsyncLocalStorage"))),
156        // `new AsyncResource(type[, options])` takes the next monotonic async id
157        // and records the creating context as its `triggerAsyncId`, so a graph
158        // built by nesting `runInAsyncScope` calls has real parent links.
159        // `options.triggerAsyncId` overrides the inherited parent, as in Node.
160        "AsyncResource" => {
161            let r = new_native("AsyncResource");
162            let trigger = args
163                .get(1)
164                .and_then(|o| {
165                    with_host(|h| match h.get(o) {
166                        Some(crate::host::JsObj::Object(p)) => p
167                            .get("triggerAsyncId")
168                            .filter(|v| !matches!(v, Value::Undef))
169                            .map(|v| h.to_number(v)),
170                        _ => None,
171                    })
172                })
173                .unwrap_or_else(execution_async_id);
174            with_host(|h| {
175                if let Some(crate::host::JsObj::Object(p)) = h.get_mut(&r) {
176                    p.insert("@@asyncId".into(), Value::Float(fresh_async_id()));
177                    p.insert("@@triggerAsyncId".into(), Value::Float(trigger));
178                }
179            });
180            Some(Ok(r))
181        }
182        _ => None,
183    }
184}
185
186/// A fresh `@@native`-tagged object carrying `tag`.
187fn new_native(tag: &'static str) -> Value {
188    with_host(|h| {
189        let mut m = IndexMap::new();
190        m.insert("@@native".into(), h.new_str(tag));
191        h.new_object(m)
192    })
193}
194
195/// A hidden numeric slot on a native instance (`@@asyncId`), or `fallback`.
196fn hidden_num(recv: &Value, key: &str, fallback: f64) -> f64 {
197    with_host(|h| match h.get(recv) {
198        Some(crate::host::JsObj::Object(p)) => {
199            p.get(key).map(|v| h.to_number(v)).unwrap_or(fallback)
200        }
201        _ => fallback,
202    })
203}
204
205/// The object returned by `createHook`. Its `enable`/`disable` are no-ops that
206/// return the hook itself (Node's chainable API); no callbacks are ever invoked.
207fn new_hook() -> Value {
208    new_native("AsyncHook")
209}
210
211/// Dispatch a method on a native `async_hooks` instance.
212pub fn instance_call(
213    tag: &str,
214    recv: &Value,
215    method: &str,
216    args: Vec<Value>,
217) -> Result<Value, String> {
218    match tag {
219        // A createHook() result: enable/disable are no-ops returning `this` so
220        // `createHook(...).enable()` chains work. No hook callbacks fire.
221        "AsyncHook" => match method {
222            "enable" | "disable" => Ok(recv.clone()),
223            _ => Err(crate::host::type_error(&format!(
224                "{method} is not a function"
225            ))),
226        },
227        "AsyncLocalStorage" => als_call(recv, method, args),
228        "AsyncResource" => resource_call(recv, method, args),
229        _ => Err(crate::host::type_error(&format!(
230            "{method} is not a function"
231        ))),
232    }
233}
234
235/// An `AsyncResource` instance. Each carries a real monotonic `asyncId` and the
236/// `triggerAsyncId` of the context that created it, and `runInAsyncScope` makes
237/// that pair the current execution context for the duration of the call — so
238/// `executionAsyncId()` inside the callback reports the resource, and a resource
239/// constructed there records this one as its parent. `emitDestroy` still has
240/// nothing to destroy (no `destroy` hooks fire; see the module docs).
241fn resource_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
242    match method {
243        // runInAsyncScope(fn[, thisArg[, ...args]]) === fn.apply(thisArg, args),
244        // run under this resource's async context.
245        "runInAsyncScope" => {
246            let f = args.first().cloned().unwrap_or(Value::Undef);
247            // Pass the receiver through verbatim (Node uses `ReflectApply`), so
248            // an explicit `null` gets the same sloppy-mode coercion `fn.call(null)`
249            // already applies rather than silently becoming "no receiver".
250            let this = args.get(1).cloned();
251            let rest = args.get(2..).map(|s| s.to_vec()).unwrap_or_default();
252            let id = hidden_num(recv, "@@asyncId", 1.0);
253            let trigger = hidden_num(recv, "@@triggerAsyncId", 0.0);
254            in_async_scope(id, trigger, || invoke(&f, rest, this))
255        }
256        "bind" => {
257            let f = args.first().cloned().unwrap_or(Value::Undef);
258            Ok(bind_to(f, args.get(1).cloned()))
259        }
260        "emitDestroy" => Ok(recv.clone()),
261        "asyncId" => Ok(Value::Float(hidden_num(recv, "@@asyncId", 1.0))),
262        "triggerAsyncId" => Ok(Value::Float(hidden_num(recv, "@@triggerAsyncId", 0.0))),
263        _ => Err(crate::host::type_error(&format!(
264            "{method} is not a function"
265        ))),
266    }
267}
268
269/// The instance's heap index (its store-stack key), or `0` for a non-heap value.
270fn key(recv: &Value) -> u32 {
271    match recv {
272        Value::Obj(i) => *i,
273        _ => 0,
274    }
275}
276
277fn als_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
278    let id = key(recv);
279    match method {
280        // The current store (top of this instance's stack), or undefined.
281        "getStore" => Ok(STORES.with(|s| {
282            s.borrow()
283                .get(&id)
284                .and_then(|v| v.last().cloned())
285                .unwrap_or(Value::Undef)
286        })),
287        // run(store, callback, ...args): set the store, call the callback with the
288        // remaining args, restore the previous store, return the callback result.
289        "run" => {
290            let store = args.first().cloned().unwrap_or(Value::Undef);
291            let cb = args.get(1).cloned().unwrap_or(Value::Undef);
292            let rest = args.get(2..).map(|s| s.to_vec()).unwrap_or_default();
293            with_store(id, store, cb, rest)
294        }
295        // exit(callback, ...args): run the callback with the store unset (undefined
296        // pushed) for its duration.
297        "exit" => {
298            let cb = args.first().cloned().unwrap_or(Value::Undef);
299            let rest = args.get(1..).map(|s| s.to_vec()).unwrap_or_default();
300            with_store(id, Value::Undef, cb, rest)
301        }
302        // enterWith(store): set the current store for subsequent synchronous
303        // getStore() calls (not popped automatically; not propagated across async).
304        "enterWith" => {
305            let store = args.first().cloned().unwrap_or(Value::Undef);
306            STORES.with(|s| s.borrow_mut().entry(id).or_default().push(store));
307            Ok(Value::Undef)
308        }
309        // disable(): drop all stores for this instance.
310        "disable" => {
311            STORES.with(|s| {
312                s.borrow_mut().remove(&id);
313            });
314            Ok(Value::Undef)
315        }
316        _ => Err(crate::host::type_error(&format!(
317            "{method} is not a function"
318        ))),
319    }
320}
321
322/// Push `store`, invoke `cb` with `rest` (releasing every host borrow first, so
323/// the callback may re-enter the host), then always pop — even on error.
324fn with_store(id: u32, store: Value, cb: Value, rest: Vec<Value>) -> Result<Value, String> {
325    STORES.with(|s| s.borrow_mut().entry(id).or_default().push(store));
326    let r = invoke(&cb, rest, None);
327    STORES.with(|s| {
328        if let Some(v) = s.borrow_mut().get_mut(&id) {
329            v.pop();
330        }
331    });
332    r
333}