Skip to content

Commit 65c8dbe

Browse files
proggeramlugRalph Küpper
andauthored
feat(fetch): honor AbortSignal — cancel/timeout in-flight requests (#5690)
`fetch(url, { signal })` ignored the signal: `js_fetch_with_options` took only url/method/body/headers and ran to completion, so neither `controller.abort()` nor `AbortSignal.timeout(ms)` could cancel it. And `AbortSignal.timeout(ms)` was a no-op stub returning a never-aborting signal. Together, `fetch(url, { signal: AbortSignal.timeout(ms) })` on a slow/held response hung forever instead of rejecting at the deadline. This wires AbortSignal through the fetch path: - `AbortSignal.timeout(ms)` (`url/abort.rs`) now schedules a real (unref'd) callback timer that aborts the signal with a `TimeoutError` and fires its `abort` listeners when the deadline elapses on the main thread. - The signal reaches the stdlib fetch via a thread-local stash (`js_fetch_set_pending_signal`), keeping the 4-arg ABI unchanged. The runtime fetch thunk sets it for the dynamic/aliased path; codegen emits it before the call for the `fetch(url, { … })` fast path (a new `signal` field on the `FetchWithOptions` HIR node). - `js_fetch_with_options` registers a per-request `tokio::sync::Notify` keyed to the signal and `select!`s the request against it; on abort it drops the request future (cancelling the in-flight reqwest request) and rejects with an `AbortError`. An already-aborted signal rejects up front. The abort reaches the request through the runtime: `fire_abort_listeners` calls `js_fetch_notify_signal_aborted` on every abort — so there is no per-fetch JS listener to accumulate on reused signals. The pending signal is consumed before the promise is allocated (so a GC during allocation can't move the TLS-stashed signal). No behavior change for signal-less fetches; the WASM fetch backend keeps its existing behavior. Co-authored-by: Ralph Küpper <ralph2@skelpo.com>
1 parent 5ed83fb commit 65c8dbe

20 files changed

Lines changed: 455 additions & 79 deletions

File tree

crates/perry-codegen-js/src/emit/exprs_more.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ impl JsEmitter {
3939
body,
4040
headers,
4141
headers_dynamic,
42+
signal,
4243
} => {
4344
self.output.push_str("fetch(");
4445
self.emit_expr(url);
@@ -63,6 +64,10 @@ impl JsEmitter {
6364
}
6465
self.output.push('}');
6566
}
67+
if let Some(sig) = signal {
68+
self.output.push_str(", signal: ");
69+
self.emit_expr(sig);
70+
}
6671
self.output.push_str("})");
6772
}
6873
Expr::FetchGetWithAuth { url, auth_header } => {

crates/perry-codegen-wasm/src/emit/expr/net_fetch_crypto.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ impl<'a> FuncEmitCtx<'a> {
2929
body,
3030
headers,
3131
headers_dynamic,
32+
// The WASM fetch backend has no AbortSignal cancellation (it is a
33+
// separate target from the native binary), so the signal is unused.
34+
signal: _,
3235
} => {
3336
self.emit_expr(func, url);
3437
self.emit_expr(func, method);

crates/perry-codegen-wasm/src/emit/js_fallback.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ impl WasmModuleEmitter {
354354
body,
355355
headers,
356356
headers_dynamic,
357+
signal: _,
357358
} => {
358359
let url_js = self.emit_js_expr(url, locals);
359360
let method_js = self.emit_js_expr(method, locals);

crates/perry-codegen-wasm/src/emit/string_collection.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,7 @@ impl WasmModuleEmitter {
972972
body,
973973
headers,
974974
headers_dynamic,
975+
signal,
975976
} => {
976977
self.collect_strings_in_expr(url);
977978
self.collect_strings_in_expr(method);
@@ -983,6 +984,9 @@ impl WasmModuleEmitter {
983984
if let Some(hd) = headers_dynamic {
984985
self.collect_strings_in_expr(hd);
985986
}
987+
if let Some(s) = signal {
988+
self.collect_strings_in_expr(s);
989+
}
986990
}
987991
Expr::FetchGetWithAuth { url, auth_header } => {
988992
self.collect_strings_in_expr(url);

crates/perry-codegen/src/collectors/escape_check.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,7 @@ pub fn check_escapes_in_expr(
669669
body,
670670
headers,
671671
headers_dynamic,
672+
signal,
672673
} => {
673674
check_escapes_in_expr(url, candidates, classes, escaped);
674675
check_escapes_in_expr(method, candidates, classes, escaped);
@@ -679,6 +680,9 @@ pub fn check_escapes_in_expr(
679680
if let Some(hd) = headers_dynamic {
680681
check_escapes_in_expr(hd, candidates, classes, escaped);
681682
}
683+
if let Some(s) = signal {
684+
check_escapes_in_expr(s, candidates, classes, escaped);
685+
}
682686
}
683687
Expr::SuperCall(args)
684688
| Expr::StaticMethodCall { args, .. }

crates/perry-codegen/src/collectors/escape_news.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ fn collect_used_new_fields_in_expr(
754754
body,
755755
headers,
756756
headers_dynamic,
757+
signal,
757758
} => {
758759
collect_used_new_fields_in_expr(url, non_escaping_news, used);
759760
collect_used_new_fields_in_expr(method, non_escaping_news, used);
@@ -764,6 +765,9 @@ fn collect_used_new_fields_in_expr(
764765
if let Some(hd) = headers_dynamic {
765766
collect_used_new_fields_in_expr(hd, non_escaping_news, used);
766767
}
768+
if let Some(s) = signal {
769+
collect_used_new_fields_in_expr(s, non_escaping_news, used);
770+
}
767771
}
768772
Expr::FetchGetWithAuth { url, auth_header } => {
769773
collect_used_new_fields_in_expr(url, non_escaping_news, used);

crates/perry-codegen/src/expr/logical_collections.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
8080
body,
8181
headers,
8282
headers_dynamic,
83+
signal,
8384
} => {
8485
let url_box = lower_expr(ctx, url)?;
8586
let method_box = lower_expr(ctx, method)?;
8687
let body_box = lower_expr(ctx, body)?;
88+
// Lower `init.signal` (if any) up front so it can be stashed for
89+
// `js_fetch_with_options` right before the call below.
90+
let signal_box = match signal {
91+
Some(s) => Some(lower_expr(ctx, s)?),
92+
None => None,
93+
};
8794

8895
// Obtain the headers as a NaN-boxed object value, then JSON-stringify
8996
// it below. Two cases:
@@ -141,6 +148,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
141148
let url_handle = unbox_to_i64(blk, &url_box);
142149
let method_handle = unbox_to_i64(blk, &method_box);
143150
let body_handle = unbox_to_i64(blk, &body_box);
151+
// Stash the AbortSignal so `js_fetch_with_options` can cancel the
152+
// request when it aborts (`controller.abort()` / `AbortSignal.timeout`).
153+
if let Some(sig) = &signal_box {
154+
blk.call_void("js_fetch_set_pending_signal", &[(DOUBLE, sig)]);
155+
}
144156
let promise = blk.call(
145157
I64,
146158
"js_fetch_with_options",

crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ pub(crate) fn declare_core(module: &mut LlModule) {
147147
module.declare_function("js_fetch_stream_status", DOUBLE, &[DOUBLE]);
148148
module.declare_function("js_fetch_text", I64, &[I64]);
149149
module.declare_function("js_fetch_with_options", I64, &[I64, I64, I64, I64]);
150+
// Stashes the `fetch(url, { signal })` AbortSignal for the next
151+
// `js_fetch_with_options` so the request can be aborted.
152+
module.declare_function("js_fetch_set_pending_signal", VOID, &[DOUBLE]);
150153
// Headers-aware JSON stringify for the `fetch(url, { headers })` request
151154
// path: takes the headers value (f64) and returns a `*const StringHeader`
152155
// (i64) holding `{name:value}` JSON, treating a `Headers` handle safely.

crates/perry-hir/src/capability.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ mod tests {
515515
body: Box::new(Expr::Undefined),
516516
headers: vec![],
517517
headers_dynamic: None,
518+
signal: None,
518519
}));
519520
let v = audit_module_capabilities(
520521
&m,

crates/perry-hir/src/egress.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ mod tests {
489489
body: Box::new(Expr::Undefined),
490490
headers: vec![],
491491
headers_dynamic: None,
492+
signal: None,
492493
}));
493494
let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false);
494495
assert_eq!(v.len(), 1);
@@ -506,6 +507,7 @@ mod tests {
506507
body: Box::new(Expr::Undefined),
507508
headers: vec![],
508509
headers_dynamic: None,
510+
signal: None,
509511
}));
510512
let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false);
511513
assert!(v.is_empty());
@@ -520,6 +522,7 @@ mod tests {
520522
body: Box::new(Expr::Undefined),
521523
headers: vec![],
522524
headers_dynamic: None,
525+
signal: None,
523526
}));
524527
let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false);
525528
assert_eq!(v.len(), 1);
@@ -539,6 +542,7 @@ mod tests {
539542
body: Box::new(Expr::Undefined),
540543
headers: vec![],
541544
headers_dynamic: None,
545+
signal: None,
542546
}));
543547
let v = audit_module_egress(
544548
&m,

0 commit comments

Comments
 (0)