From dffdcd561b83a7c5a4a62a2cac4cc109e7d17802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 15:08:07 +0200 Subject: [PATCH 1/2] fix(async-hooks): share ALS context with runtime provider --- crates/perry-runtime/src/async_context.rs | 85 +++++++++++++++ .../perry-stdlib/src/async_local_storage.rs | 50 ++++----- .../propagation/fixtures/next-route-lazy.js | 7 ++ .../propagation/next-route-continuations.ts | 103 ++++++++++++++++++ 4 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 test-parity/node-suite/async_hooks/propagation/fixtures/next-route-lazy.js create mode 100644 test-parity/node-suite/async_hooks/propagation/next-route-continuations.ts diff --git a/crates/perry-runtime/src/async_context.rs b/crates/perry-runtime/src/async_context.rs index ffe3c2cf79..770d1bafe3 100644 --- a/crates/perry-runtime/src/async_context.rs +++ b/crates/perry-runtime/src/async_context.rs @@ -49,6 +49,60 @@ pub fn restore_context(snapshot: AsyncContextSnapshot) { }); } +// --------------------------------------------------------------------------- +// AsyncLocalStorage provider ABI +// --------------------------------------------------------------------------- +// +// These entry points deliberately keep the active context and its throw guards +// in perry-runtime. An app-only dylib calls the AsyncLocalStorage methods in a +// separately loaded perry-stdlib provider, while promises, timers and +// microtasks snapshot context in the runtime provider. Calling this module's +// Rust-private symbols directly from perry-stdlib either makes that provider +// fail eager relocation or makes it link a second runtime whose thread-local +// ACTIVE_CONTEXT is invisible to the schedulers. The C ABI is therefore the +// ownership boundary: every image resolves these calls to the one runtime +// provider loaded by the host. + +/// Enter an `AsyncLocalStorage#run` scope and register its throw-safe restore. +#[no_mangle] +pub extern "C" fn js_async_context_als_run_enter(handle: i64, store: f64) { + push_store(handle, store); + push_context_guard(ContextGuardAction::PopStore(handle)); +} + +/// Enter an `AsyncLocalStorage#exit` scope and register its throw-safe restore. +#[no_mangle] +pub extern "C" fn js_async_context_als_exit_enter(handle: i64) { + let saved = take_store(handle); + push_context_guard(ContextGuardAction::RestoreStores(handle, saved)); +} + +/// Leave the most recently entered ALS `run`/`exit` scope normally. +#[no_mangle] +pub extern "C" fn js_async_context_als_scope_leave() { + if let Some(action) = pop_context_guard() { + apply_context_guard(action); + } +} + +/// Return the current store for one ALS instance, or JavaScript `undefined`. +#[no_mangle] +pub extern "C" fn js_async_context_als_get_store(handle: i64) -> f64 { + get_store(handle).unwrap_or_else(|| f64::from_bits(crate::value::TAG_UNDEFINED)) +} + +/// Implement `AsyncLocalStorage#enterWith` in the runtime-owned context. +#[no_mangle] +pub extern "C" fn js_async_context_als_enter_with(handle: i64, store: f64) { + enter_with(handle, store); +} + +/// Remove one ALS instance from the runtime-owned active context. +#[no_mangle] +pub extern "C" fn js_async_context_als_clear(handle: i64) { + clear_store(handle); +} + pub fn push_store(handle: i64, store: f64) { ACTIVE_CONTEXT.with(|ctx| { let mut ctx = ctx.borrow_mut(); @@ -347,3 +401,34 @@ pub(crate) fn test_snapshot_first_store(snapshot: &AsyncContextSnapshot) -> Opti .first() .and_then(|entry| entry.stores.first().copied()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn als_provider_abi_keeps_nested_run_and_exit_scopes_balanced() { + let handle = -8037; + js_async_context_als_clear(handle); + js_async_context_als_enter_with(handle, 1.0); + + js_async_context_als_run_enter(handle, 2.0); + assert_eq!(js_async_context_als_get_store(handle), 2.0); + js_async_context_als_scope_leave(); + assert_eq!(js_async_context_als_get_store(handle), 1.0); + + js_async_context_als_exit_enter(handle); + assert_eq!( + js_async_context_als_get_store(handle).to_bits(), + crate::value::TAG_UNDEFINED + ); + js_async_context_als_scope_leave(); + assert_eq!(js_async_context_als_get_store(handle), 1.0); + + js_async_context_als_clear(handle); + assert_eq!( + js_async_context_als_get_store(handle).to_bits(), + crate::value::TAG_UNDEFINED + ); + } +} diff --git a/crates/perry-stdlib/src/async_local_storage.rs b/crates/perry-stdlib/src/async_local_storage.rs index f49596ec90..4ab3b9c8bb 100644 --- a/crates/perry-stdlib/src/async_local_storage.rs +++ b/crates/perry-stdlib/src/async_local_storage.rs @@ -4,7 +4,6 @@ //! Provides run(), getStore(), enterWith(), exit(), and disable(). use perry_runtime::array::{js_array_length, ArrayHeader}; -use perry_runtime::async_context; use perry_runtime::closure::{is_closure_ptr, js_closure_call_array, ClosureHeader}; use crate::common::{get_handle_mut, register_handle, Handle}; @@ -14,6 +13,20 @@ const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +// Keep the active context in the single perry-runtime provider. These must be +// extern calls rather than Rust-path calls: in app-only dylib deployments the +// stdlib is a separate image, and linking its own perry-runtime dependency +// would create a second ACTIVE_CONTEXT that promise/timer schedulers cannot +// see (#8037). +extern "C" { + fn js_async_context_als_run_enter(handle: i64, store: f64); + fn js_async_context_als_exit_enter(handle: i64); + fn js_async_context_als_scope_leave(); + fn js_async_context_als_get_store(handle: i64) -> f64; + fn js_async_context_als_enter_with(handle: i64, store: f64); + fn js_async_context_als_clear(handle: i64); +} + /// #3092 — `AsyncLocalStorage#run`/`#exit` must reject a non-callable callback /// with a `TypeError`, matching Node (which throws through its function-apply /// path). Returns the validated `ClosureHeader` pointer for a callable value, @@ -96,12 +109,9 @@ pub unsafe extern "C" fn js_async_local_storage_run( // A context guard mirrors the pop below: if the callback throws, // `js_throw` applies the guard while unwinding so the catch site still // observes the pre-`run` store (#788, Node restores via try/finally). - async_context::push_store(handle, store); - async_context::push_context_guard(async_context::ContextGuardAction::PopStore(handle)); + js_async_context_als_run_enter(handle, store); let result = call_with_forwarded_args(cb, args_array); - if let Some(action) = async_context::pop_context_guard() { - async_context::apply_context_guard(action); - } + js_async_context_als_scope_leave(); result } @@ -111,9 +121,7 @@ pub unsafe extern "C" fn js_async_local_storage_run( #[no_mangle] pub extern "C" fn js_async_local_storage_get_store(handle: Handle) -> f64 { if get_handle_mut::(handle).is_some() { - if let Some(store) = async_context::get_store(handle) { - return store; - } + return unsafe { js_async_context_als_get_store(handle) }; } f64::from_bits(TAG_UNDEFINED) } @@ -123,7 +131,7 @@ pub extern "C" fn js_async_local_storage_get_store(handle: Handle) -> f64 { #[no_mangle] pub extern "C" fn js_async_local_storage_enter_with(handle: Handle, store: f64) { if get_handle_mut::(handle).is_some() { - async_context::enter_with(handle, store); + unsafe { js_async_context_als_enter_with(handle, store) }; } } @@ -141,27 +149,15 @@ pub unsafe extern "C" fn js_async_local_storage_exit( // without disturbing the saved store (#3092). let cb = validate_callback(callback); - let saved = if get_handle_mut::(handle).is_some() { - Some(async_context::take_store(handle)) - } else { - None - }; - - // Guarded like run(): a throwing callback must still restore the saved - // store stack at the catch site (#788). - let guarded = saved.is_some(); - if let Some(saved) = saved { - async_context::push_context_guard(async_context::ContextGuardAction::RestoreStores( - handle, saved, - )); + let guarded = get_handle_mut::(handle).is_some(); + if guarded { + js_async_context_als_exit_enter(handle); } let result = call_with_forwarded_args(cb, args_array); if guarded { - if let Some(action) = async_context::pop_context_guard() { - async_context::apply_context_guard(action); - } + js_async_context_als_scope_leave(); } result @@ -172,6 +168,6 @@ pub unsafe extern "C" fn js_async_local_storage_exit( #[no_mangle] pub extern "C" fn js_async_local_storage_disable(handle: Handle) { if get_handle_mut::(handle).is_some() { - async_context::clear_store(handle); + unsafe { js_async_context_als_clear(handle) }; } } diff --git a/test-parity/node-suite/async_hooks/propagation/fixtures/next-route-lazy.js b/test-parity/node-suite/async_hooks/propagation/fixtures/next-route-lazy.js new file mode 100644 index 0000000000..82e4db7855 --- /dev/null +++ b/test-parity/node-suite/async_hooks/propagation/fixtures/next-route-lazy.js @@ -0,0 +1,7 @@ +export function checksum(iterations) { + let value = 0x811c9dc5; + for (let index = 0; index < iterations; index += 1) { + value = Math.imul(value ^ index, 0x01000193) >>> 0; + } + return value; +} diff --git a/test-parity/node-suite/async_hooks/propagation/next-route-continuations.ts b/test-parity/node-suite/async_hooks/propagation/next-route-continuations.ts new file mode 100644 index 0000000000..67f52729ce --- /dev/null +++ b/test-parity/node-suite/async_hooks/propagation/next-route-continuations.ts @@ -0,0 +1,103 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +// Focused lower-level mirror of #8034's production Next App Route sequence. +// These are the four independent stores Next wires around routeModule.handle. +const actionStorage = new AsyncLocalStorage(); +const requestStorage = new AsyncLocalStorage(); +const workStorage = new AsyncLocalStorage(); +const workUnitStorage = new AsyncLocalStorage(); + +function stores(): string { + return [ + actionStorage.getStore(), + requestStorage.getStore(), + workStorage.getStore(), + workUnitStorage.getStore(), + ] + .map((value) => value ?? "none") + .join("/"); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const firstGate = deferred(); +const secondStarted = deferred(); + +async function handle(id: string, iterations: number): Promise { + return actionStorage.run(`action-${id}`, () => + requestStorage.run(`request-${id}`, () => + workStorage.run(`work-${id}`, () => + workUnitStorage.run(`unit-${id}`, async () => { + const observations = [stores()]; + + if (id === "first") { + secondStarted.resolve(); + await firstGate.promise; + } else if (id === "second") { + await secondStarted.promise; + firstGate.resolve(); + } + + const { checksum } = await import("./fixtures/next-route-lazy.js"); + observations.push(stores()); + + await Promise.resolve().then(() => { + observations.push(stores()); + }); + await new Promise((resolve) => setTimeout(resolve, 1)); + observations.push(stores()); + + try { + requestStorage.run(`request-${id}-throw`, () => { + observations.push(stores()); + throw new Error(`throw-${id}`); + }); + } catch (error) { + observations.push(`${(error as Error).message}:${stores()}`); + } + + try { + await requestStorage.run(`request-${id}-nested`, async () => { + await Promise.resolve(); + observations.push(stores()); + throw new Error(`reject-${id}`); + }); + } catch (error) { + observations.push(`${(error as Error).message}:${stores()}`); + } + + const exited = await workUnitStorage.exit(async () => { + await new Promise((resolve) => queueMicrotask(resolve)); + return stores(); + }); + observations.push(`exit=${exited}`); + observations.push(stores()); + + const stream = new ReadableStream({ + start(controller) { + queueMicrotask(() => { + controller.enqueue(stores()); + controller.close(); + }); + }, + }); + const streamed = await stream.getReader().read(); + observations.push(`stream=${streamed.value}`); + + return `${id}:${checksum(iterations)}:${observations.join("|")}`; + }), + ), + ), + ); +} + +const results = await Promise.all([handle("first", 7), handle("second", 11)]); +for (const result of results) console.log(result); +console.log(await handle("after-rejection", 13)); +console.log("next route outside:", stores()); From d93adeaa31de1562b541e57acf35ff7991fb3349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 15:09:10 +0200 Subject: [PATCH 2/2] docs(changelog): record Next route ALS provider fix --- changelog.d/8042-next-route-async-local-storage.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog.d/8042-next-route-async-local-storage.md diff --git a/changelog.d/8042-next-route-async-local-storage.md b/changelog.d/8042-next-route-async-local-storage.md new file mode 100644 index 0000000000..7af5e9d4e7 --- /dev/null +++ b/changelog.d/8042-next-route-async-local-storage.md @@ -0,0 +1,12 @@ +### Preserve Next App Route async-local state across provider boundaries + +`AsyncLocalStorage` now reads and mutates context through a runtime-owned C +ABI when the app, runtime, and standard library are separate shared-library +images. Promise, `async`/`await`, dynamic-import, microtask, timer, and stream +continuations therefore snapshot the same action, request, work, and work-unit +stores that `run()` entered, without cross-request contamination. + +Nested `run()` and `exit()` scopes still restore their prior state after normal +completion, throws, and rejected promises. Focused Next-style coverage also +checks two interleaved request IDs, a request after rejection, and an empty +context after route completion.