Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions changelog.d/8042-next-route-async-local-storage.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions crates/perry-runtime/src/async_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
);
}
}
50 changes: 23 additions & 27 deletions crates/perry-stdlib/src/async_local_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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::<AsyncLocalStorageHandle>(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)
}
Expand All @@ -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::<AsyncLocalStorageHandle>(handle).is_some() {
async_context::enter_with(handle, store);
unsafe { js_async_context_als_enter_with(handle, store) };
}
}

Expand All @@ -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::<AsyncLocalStorageHandle>(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::<AsyncLocalStorageHandle>(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
Expand All @@ -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::<AsyncLocalStorageHandle>(handle).is_some() {
async_context::clear_store(handle);
unsafe { js_async_context_als_clear(handle) };
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<string>();
const requestStorage = new AsyncLocalStorage<string>();
const workStorage = new AsyncLocalStorage<string>();
const workUnitStorage = new AsyncLocalStorage<string>();

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<void>((done) => {
resolve = done;
});
return { promise, resolve };
}

const firstGate = deferred();
const secondStarted = deferred();

async function handle(id: string, iterations: number): Promise<string> {
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<void>((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<void>((resolve) => queueMicrotask(resolve));
return stores();
});
observations.push(`exit=${exited}`);
observations.push(stores());

const stream = new ReadableStream<string>({
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());
Loading