Repository: denoland/deno
Pull request: feat(ext/http): abort signal when request is cancelled

Closes https://github.com/denoland/deno/issues/21653


Changed files:
filename: ext/fetch/23_request.js
patch: @@ -269,19 +269,25 @@ class Request {
   /** @type {AbortSignal} */
   get [_signal]() {
     const signal = this[_signalCache];
-    // This signal not been created yet, and the request is still in progress
-    if (signal === undefined) {
+    // This signal has not been created yet, but the request has already completed
+    if (signal === false) {
       const signal = newSignal();
       this[_signalCache] = signal;
+      signal[signalAbort](signalAbortError);
       return signal;
     }
-    // This signal has not been created yet, but the request has already completed
-    if (signal === false) {
+
+    // This signal not been created yet, and the request is still in progress
+    if (signal === undefined) {
       const signal = newSignal();
       this[_signalCache] = signal;
-      signal[signalAbort](signalAbortError);
       return signal;
     }
+
+    if (!signal.aborted && this[_request].isCancelled) {
+      signal[signalAbort](signalAbortError);
+    }
+
     return signal;
   }
   get [_mimeType]() {

filename: ext/http/00_serve.ts
patch: @@ -11,6 +11,7 @@ import {
   op_http_cancel,
   op_http_close,
   op_http_close_after_finish,
+  op_http_get_request_cancelled,
   op_http_get_request_headers,
   op_http_get_request_method_and_url,
   op_http_read_request_body,
@@ -373,6 +374,13 @@ class InnerRequest {
   get external() {
     return this.#external;
   }
+
+  get isCancelled() {
+    if (this.#external === null) {
+      return true;
+    }
+    return op_http_get_request_cancelled(this.#external);
+  }
 }
 
 class CallbackContext {

filename: ext/http/http_next.rs
patch: @@ -700,6 +700,14 @@ fn set_response(
   http.complete();
 }
 
+#[op2(fast)]
+pub fn op_http_get_request_cancelled(external: *const c_void) -> bool {
+  let http =
+    // SAFETY: op is called with external.
+    unsafe { clone_external!(external, "op_http_get_request_cancelled") };
+  http.cancelled()
+}
+
 /// Returned promise resolves when body streaming finishes.
 /// Call [`op_http_close_after_finish`] when done with the external.
 #[op2(async)]

filename: ext/http/lib.rs
patch: @@ -113,6 +113,7 @@ deno_core::extension!(
     http_next::op_http_get_request_header,
     http_next::op_http_get_request_headers,
     http_next::op_http_get_request_method_and_url<HTTP>,
+    http_next::op_http_get_request_cancelled,
     http_next::op_http_read_request_body,
     http_next::op_http_serve_on<HTTP>,
     http_next::op_http_serve<HTTP>,

filename: tests/unit/serve_test.ts
patch: @@ -4270,3 +4270,32 @@ Deno.test({
   assertEquals(hostname, "0.0.0.0");
   await server.shutdown();
 });
+
+Deno.test({
+  name: "AbortSignal aborted when request is cancelled",
+}, async () => {
+  const { promise, resolve } = Promise.withResolvers<void>();
+
+  let cancelled = false;
+
+  const server = Deno.serve({
+    hostname: "0.0.0.0",
+    port: servePort,
+    onListen: () => resolve(),
+  }, async (request) => {
+    request.signal.addEventListener("abort", () => cancelled = true);
+    assert(!request.signal.aborted);
+    await new Promise((resolve) => setTimeout(resolve, 3000)); // abort during waiting
+    assert(request.signal.aborted);
+    return new Response("Ok");
+  });
+
+  await promise;
+  await fetch(`http://localhost:${servePort}/`, {
+    signal: AbortSignal.timeout(1000),
+  }).catch(() => {});
+
+  await server.shutdown();
+
+  assert(cancelled);
+});