Skip to content

Commit fe0d497

Browse files
proggeramlugRalph Küpper
andauthored
fix(gc): preserve request graphs across route imports (#8044)
* fix(gc): preserve requests across route imports * docs: add changelog for request import rooting --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent a9a99d8 commit fe0d497

12 files changed

Lines changed: 312 additions & 25 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
### Fixed
2+
3+
- Preserve request objects and their nested URL, search-parameter, header, and
4+
body state when generated route modules re-export imported handlers. Imported
5+
handler closures and complete expired-timer batches now remain rooted across
6+
allocation, promise/timer continuations, microtask checkpoints, and moving GC.

crates/perry-codegen/src/lower_call/extern_func.rs

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,26 +1688,48 @@ pub fn try_lower_extern_func_call(
16881688
// an arrow-bound exported value (hono's `mergePath` from utils/url.js,
16891689
// any `export const foo = () => …` cross-module use).
16901690
if ctx.imported_vars.contains(name) {
1691-
ctx.pending_declares.push((fname.clone(), DOUBLE, vec![]));
1692-
let closure_box = ctx.block().call(DOUBLE, &fname, &[]);
1693-
let mut lowered_args: Vec<String> = Vec::with_capacity(args.len());
1694-
for a in args {
1695-
lowered_args.push(lower_expr(ctx, a)?);
1696-
}
1697-
if lowered_args.len() > 16 {
1691+
if args.len() > 16 {
16981692
anyhow::bail!(
16991693
"perry-codegen Phase D.1: closure call with {} args (max 16)",
1700-
lowered_args.len()
1694+
args.len()
17011695
);
17021696
}
1703-
let blk = ctx.block();
1704-
let closure_handle = unbox_to_i64(blk, &closure_box);
1705-
let runtime_fn = format!("js_closure_call{}", lowered_args.len());
1706-
let mut call_args: Vec<(crate::types::LlvmType, &str)> = vec![(I64, &closure_handle)];
1707-
for v in &lowered_args {
1708-
call_args.push((DOUBLE, v.as_str()));
1709-
}
1710-
return Ok(Some(blk.call(DOUBLE, &runtime_fn, &call_args)));
1697+
ctx.pending_declares.push((fname.clone(), DOUBLE, vec![]));
1698+
// Fetch the callee before evaluating arguments, as JavaScript requires,
1699+
// but keep that closure rooted while argument expressions run. Next's
1700+
// App Route exports are commonly `export const GET = ...`; constructing
1701+
// a request argument can allocate and evacuate the closure before this
1702+
// path finally dispatches it (#8036).
1703+
let closure_box = ctx.block().call(DOUBLE, &fname, &[]);
1704+
let arg_refs: Vec<&Expr> = args.iter().collect();
1705+
let lowered_args = std::cell::RefCell::new(Vec::<String>::new());
1706+
let result = crate::rooting::with_rooted_accumulator(
1707+
ctx,
1708+
crate::rooting::Repr::Boxed,
1709+
&closure_box,
1710+
crate::rooting::any_operand_may_collect(ctx, args.iter()),
1711+
|ctx, _closure| {
1712+
crate::rooting::with_operands_rooted(ctx, &arg_refs, |_ctx, vals| {
1713+
*lowered_args.borrow_mut() = vals.to_vec();
1714+
Ok(())
1715+
})
1716+
},
1717+
|ctx, closure_box| {
1718+
// Re-read and unbox only after every collecting argument and
1719+
// after the argument group's own re-reads have completed.
1720+
let lowered = lowered_args.borrow();
1721+
let blk = ctx.block();
1722+
let closure_handle = unbox_to_i64(blk, closure_box);
1723+
let runtime_fn = format!("js_closure_call{}", lowered.len());
1724+
let mut call_args: Vec<(crate::types::LlvmType, &str)> =
1725+
vec![(I64, &closure_handle)];
1726+
for value in lowered.iter() {
1727+
call_args.push((DOUBLE, value.as_str()));
1728+
}
1729+
Ok(blk.call(DOUBLE, &runtime_fn, &call_args))
1730+
},
1731+
)?;
1732+
return Ok(Some(result));
17111733
}
17121734
// Record the cross-module call so the caller can add a `declare`
17131735
// line for it after the &mut LlFunction borrow is released. The

crates/perry-runtime/src/gc/tests/runtime_roots.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,17 +485,24 @@ extern "C" fn test_async_hook_event_force_minor_gc(
485485
thread_local! {
486486
static TEST_TIMER_ARG_BITS: Cell<u64> = const { Cell::new(0) };
487487
static TEST_TIMER_CALLED: Cell<bool> = const { Cell::new(false) };
488+
static TEST_TIMER_CALLBACK_PTR: Cell<usize> = const { Cell::new(0) };
488489
}
489490

490491
extern "C" fn test_timer_capture_arg(
491-
_closure: *const crate::closure::ClosureHeader,
492+
closure: *const crate::closure::ClosureHeader,
492493
arg: f64,
493494
) -> f64 {
495+
TEST_TIMER_CALLBACK_PTR.with(|slot| slot.set(closure as usize));
494496
TEST_TIMER_ARG_BITS.with(|slot| slot.set(arg.to_bits()));
495497
TEST_TIMER_CALLED.with(|slot| slot.set(true));
496498
f64::from_bits(crate::value::TAG_UNDEFINED)
497499
}
498500

501+
extern "C" fn test_timer_force_minor_gc(_closure: *const crate::closure::ClosureHeader) -> f64 {
502+
let _ = crate::gc::gc_collect_minor();
503+
f64::from_bits(crate::value::TAG_UNDEFINED)
504+
}
505+
499506
extern "C" fn test_rest_first_value(
500507
_closure: *const crate::closure::ClosureHeader,
501508
rest: f64,

crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,53 @@ fn test_timer_tick_roots_callback_args_and_previous_context_across_hooks() {
133133
crate::timer::clearTimeout(timer_id);
134134
}
135135

136+
#[test]
137+
fn test_timer_tick_roots_the_complete_detached_expired_batch() {
138+
let _async_hook_guard = AsyncHookRuntimeTestGuard::new();
139+
let _guard = CopyingNurseryTestGuard::new(0);
140+
let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers();
141+
register_runtime_handle_root_scanner_for_tests();
142+
gc_register_mutable_root_scanner(crate::timer::scan_timer_roots_mut);
143+
144+
let collecting_callback =
145+
crate::closure::js_closure_alloc(test_timer_force_minor_gc as *const u8, 0);
146+
let second_callback = crate::closure::js_closure_alloc(test_timer_capture_arg as *const u8, 0);
147+
let second_callback_original = second_callback as usize;
148+
let second_arg = test_string_value(b"detached-timer-batch");
149+
let second_arg_original = (second_arg.to_bits() & POINTER_MASK) as usize;
150+
let second_args = [second_arg];
151+
152+
let first_id = crate::timer::js_set_timeout_callback(collecting_callback as i64, 0.0);
153+
let second_id = unsafe {
154+
crate::timer::js_set_timeout_callback_args(
155+
second_callback as i64,
156+
0.0,
157+
second_args.as_ptr(),
158+
1,
159+
)
160+
};
161+
TEST_TIMER_ARG_BITS.with(|slot| slot.set(0));
162+
TEST_TIMER_CALLED.with(|slot| slot.set(false));
163+
TEST_TIMER_CALLBACK_PTR.with(|slot| slot.set(0));
164+
165+
assert_eq!(crate::timer::js_callback_timer_tick(), 2);
166+
assert!(TEST_TIMER_CALLED.with(|slot| slot.get()));
167+
let dispatched_callback = TEST_TIMER_CALLBACK_PTR.with(|slot| slot.get());
168+
assert_ne!(
169+
dispatched_callback, second_callback_original,
170+
"detached timer callback should be refreshed after copied-minor GC"
171+
);
172+
assert!(crate::closure::is_closure_ptr(dispatched_callback));
173+
assert_moved_string_value(
174+
f64::from_bits(TEST_TIMER_ARG_BITS.with(|slot| slot.get())),
175+
second_arg_original,
176+
b"detached-timer-batch",
177+
);
178+
179+
crate::timer::clearTimeout(first_id);
180+
crate::timer::clearTimeout(second_id);
181+
}
182+
136183
#[test]
137184
fn test_next_tick_previous_context_survives_hook_gc() {
138185
const ALS_HANDLE: i64 = -8_502;

crates/perry-runtime/src/timer.rs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,24 +1116,55 @@ pub extern "C" fn js_callback_timer_tick() -> i32 {
11161116
// #6287: timers phase (by deadline) before check phase (FIFO immediates).
11171117
order_expired_callback_batch(&mut expired);
11181118

1119+
// #8036: draining removes the WHOLE expired batch from CALLBACK_TIMERS
1120+
// before the first callback runs. A callback can run arbitrary JS and the
1121+
// microtask checkpoint below can collect, so rooting only the timer being
1122+
// dispatched leaves every later callback, argument, and captured async
1123+
// context sitting in an ordinary Rust Vec the collector cannot see. With
1124+
// concurrent request timers, the first resolve callback's checkpoint moved
1125+
// the next resolve closure and the second timer called its from-space
1126+
// address (`TypeError: value is not a function`). Protect the complete
1127+
// detached batch for the complete dispatch loop.
1128+
let batch_scope = crate::gc::RuntimeHandleScope::new();
1129+
let callback_handles: Vec<_> = expired
1130+
.iter()
1131+
.map(|timer| {
1132+
batch_scope.root_raw_const_ptr(timer.callback as *const crate::closure::ClosureHeader)
1133+
})
1134+
.collect();
1135+
let arg_handles: Vec<_> = expired
1136+
.iter()
1137+
.map(|timer| batch_scope.root_nanbox_f64_slice(&timer.args))
1138+
.collect();
1139+
let context_roots: Vec<_> = expired
1140+
.iter()
1141+
.map(|timer| crate::async_context::root_snapshot(&batch_scope, &timer.context))
1142+
.collect();
1143+
11191144
let mut fired = 0;
11201145
// Call the callbacks, forwarding any trailing args captured at
11211146
// `setTimeout(fn, delay, ...args)` time. Refs #665.
1122-
for timer in expired {
1147+
for (index, mut timer) in expired.into_iter().enumerate() {
11231148
if !timer.cleared {
1124-
let scope = crate::gc::RuntimeHandleScope::new();
1125-
let cb_handle =
1126-
scope.root_raw_const_ptr(timer.callback as *const crate::closure::ClosureHeader);
1127-
let arg_handles = scope.root_nanbox_f64_slice(&timer.args);
1149+
crate::async_context::refresh_snapshot_from_roots(
1150+
&mut timer.context,
1151+
&context_roots[index],
1152+
);
11281153
let previous = crate::async_context::enter_context(&timer.context);
11291154
let mut previous = previous;
1130-
let previous_roots = crate::async_context::root_snapshot(&scope, &previous);
1155+
let previous_roots = crate::async_context::root_snapshot(&batch_scope, &previous);
11311156
crate::async_hooks::before(timer.async_id, timer.trigger_async_id);
1132-
let a = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles);
1133-
let cb = cb_handle.get_raw_const_ptr::<crate::closure::ClosureHeader>();
11341157
let prev_this = crate::object::js_implicit_this_set(timer_handle_value(timer.id));
11351158
enter_timer_callback_dispatch();
11361159
with_timer_uncaught_trap(|| {
1160+
// Installing the timer receiver above is itself a collecting
1161+
// boundary. Re-read both roots inside the trap, immediately
1162+
// before dispatch, so this callback cannot be evacuated in
1163+
// between the handle read and js_closure_callN.
1164+
let a =
1165+
crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles[index]);
1166+
let cb =
1167+
callback_handles[index].get_raw_const_ptr::<crate::closure::ClosureHeader>();
11371168
match a.len() {
11381169
0 => {
11391170
js_closure_call0(cb);

crates/perry/src/commands/compile/run_pipeline.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4313,6 +4313,22 @@ pub fn run_with_parse_cache(
43134313
}
43144314
if references_interface {
43154315
for (src_pathbuf, src_hir) in &ctx.native_modules {
4316+
// #8036: this augmentation supplies FOREIGN implementors
4317+
// to a consumer's polymorphic dispatch tower. Feeding the
4318+
// module its own exported classes back through
4319+
// `imported_classes` creates a second, imported-constructor
4320+
// identity for each local class. `lower_new` then treats a
4321+
// local class as cross-module and calls its standalone
4322+
// constructor metadata instead of the local implicit-super
4323+
// path. That is observably wrong for native-backed derived
4324+
// classes such as Next's ReadonlyURLSearchParams: the local
4325+
// path installs the URLSearchParams backing, while the
4326+
// accidental self-import path loses it. Local classes are
4327+
// already present in codegen's class table, so they must
4328+
// never be added by this foreign-class fallback.
4329+
if src_pathbuf == path {
4330+
continue;
4331+
}
43164332
let src_path = src_pathbuf.to_string_lossy().to_string();
43174333
for class in &src_hir.classes {
43184334
if !class.is_exported {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function checksum(iterations: number): number {
2+
let value = 0x811c9dc5;
3+
for (let index = 0; index < iterations; index += 1) {
4+
value = Math.imul(value ^ index, 0x01000193) >>> 0;
5+
}
6+
return value;
7+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export class ReadonlyFixtureSearchParams extends URLSearchParams {
2+
set(_name: string, _value: string): void {
3+
throw new TypeError("readonly search params");
4+
}
5+
}
6+
7+
export class FixtureNextUrl {
8+
readonly pathname: string;
9+
readonly searchParams: ReadonlyFixtureSearchParams;
10+
11+
constructor(url: string) {
12+
const parsed = new URL(url);
13+
this.pathname = parsed.pathname;
14+
this.searchParams = new ReadonlyFixtureSearchParams(parsed.search);
15+
}
16+
}
17+
18+
export class FixtureNextRequest {
19+
readonly method: string;
20+
readonly url: string;
21+
readonly nextUrl: FixtureNextUrl;
22+
readonly headers: Headers;
23+
private readonly requestBody: string;
24+
25+
constructor(method: string, url: string, id: string, requestBody = "") {
26+
this.method = method;
27+
this.url = url;
28+
this.nextUrl = new FixtureNextUrl(url);
29+
this.headers = new Headers({ "x-request-id": id });
30+
this.requestBody = requestBody;
31+
}
32+
33+
async text(): Promise<string> {
34+
await Promise.resolve();
35+
return this.requestBody;
36+
}
37+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { GET, POST, syncSummary } from "./route_impl.ts";
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { FixtureNextRequest } from "./request.ts";
2+
3+
function churn(seed: number): number {
4+
const allocations: Array<{ index: number; label: string; pad: number[] }> = [];
5+
for (let index = 0; index < 300; index += 1) {
6+
allocations.push({
7+
index,
8+
label: "request-churn-" + seed + "-" + index,
9+
pad: [seed, index, seed + index],
10+
});
11+
}
12+
return allocations.length;
13+
}
14+
15+
function snapshot(request: FixtureNextRequest): string {
16+
return [
17+
request.method,
18+
request.url,
19+
request.nextUrl.pathname,
20+
request.nextUrl.searchParams.get("id"),
21+
request.nextUrl.searchParams.get("iterations"),
22+
request.headers.get("x-request-id"),
23+
].join("|");
24+
}
25+
26+
async function handle(request: FixtureNextRequest): Promise<Record<string, unknown>> {
27+
const beforeAwait = snapshot(request);
28+
const { checksum } = await import("./lazy_work.ts");
29+
const iterations = Number(request.nextUrl.searchParams.get("iterations"));
30+
const checksumValue = checksum(iterations);
31+
const allocationCount = churn(iterations);
32+
await new Promise<void>((resolve) => setTimeout(resolve, 1));
33+
const afterAwait = snapshot(request);
34+
const requestBody = request.method === "POST" ? await request.text() : "";
35+
const method = request.method;
36+
const pathname = request.nextUrl.pathname;
37+
const header = request.headers.get("x-request-id");
38+
const id = request.nextUrl.searchParams.get("id");
39+
40+
return {
41+
beforeAwait,
42+
afterAwait,
43+
method,
44+
pathname,
45+
id,
46+
header,
47+
iterations,
48+
checksum: checksumValue,
49+
allocationCount,
50+
requestBody,
51+
};
52+
}
53+
54+
export function syncSummary(request: FixtureNextRequest): string {
55+
churn(17);
56+
return snapshot(request);
57+
}
58+
59+
export const GET = handle;
60+
export const POST = handle;

0 commit comments

Comments
 (0)