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}