Repository: denoland/deno
Pull request: fix(ext/websocket): cancel in-flight handshake on close()

Fixes https://github.com/denoland/deno/issues/25126


Changed files:
filename: ext/websocket/01_websocket.js
patch: @@ -116,6 +116,7 @@ const _binaryType = Symbol("[[binaryType]]");
 const _eventLoop = Symbol("[[eventLoop]]");
 const _sendQueue = Symbol("[[sendQueue]]");
 const _queueSend = Symbol("[[queueSend]]");
+const _cancelHandle = Symbol("[[cancelHandle]]");
 
 const _server = Symbol("[[server]]");
 const _idleTimeoutDuration = Symbol("[[idleTimeout]]");
@@ -136,6 +137,7 @@ class WebSocket extends EventTarget {
     this[_idleTimeoutDuration] = 0;
     this[_idleTimeoutTimeout] = undefined;
     this[_sendQueue] = [];
+    this[_cancelHandle] = undefined;
 
     const prefix = "Failed to construct 'WebSocket'";
     webidl.requiredArguments(arguments.length, 1, prefix);
@@ -177,12 +179,6 @@ class WebSocket extends EventTarget {
     this[_url] = wsURL.href;
     this[_role] = CLIENT;
 
-    op_ws_check_permission_and_cancel_handle(
-      "WebSocket.abort()",
-      this[_url],
-      false,
-    );
-
     if (typeof protocols === "string") {
       protocols = [protocols];
     }
@@ -214,11 +210,20 @@ class WebSocket extends EventTarget {
       );
     }
 
+    const cancelRid = op_ws_check_permission_and_cancel_handle(
+      "WebSocket.abort()",
+      this[_url],
+      true,
+    );
+
+    this[_cancelHandle] = cancelRid;
+
     PromisePrototypeThen(
       op_ws_create(
         "new WebSocket()",
         wsURL.href,
         ArrayPrototypeJoin(protocols, ", "),
+        cancelRid,
       ),
       (create) => {
         this[_rid] = create.rid;
@@ -256,6 +261,12 @@ class WebSocket extends EventTarget {
         );
         this.dispatchEvent(errorEv);
 
+        if (this[_cancelHandle]) {
+          core.tryClose(this[_cancelHandle]);
+
+          this[_cancelHandle] = undefined;
+        }
+
         const closeEv = new CloseEvent("close");
         this.dispatchEvent(closeEv);
       },
@@ -397,6 +408,13 @@ class WebSocket extends EventTarget {
       );
     }
 
+    if (this[_cancelHandle]) {
+      // Cancel ongoing handshake.
+      core.tryClose(this[_cancelHandle]);
+
+      this[_cancelHandle] = undefined;
+    }
+
     if (this[_readyState] === CONNECTING) {
       this[_readyState] = CLOSING;
     } else if (this[_readyState] === OPEN) {

filename: tests/unit/websocket_test.ts
patch: @@ -1,5 +1,11 @@
 // Copyright 2018-2025 the Deno authors. MIT license.
-import { assert, assertEquals, assertThrows, fail } from "./test_util.ts";
+import {
+  assert,
+  assertEquals,
+  assertThrows,
+  delay,
+  fail,
+} from "./test_util.ts";
 
 const servePort = 4248;
 const serveUrl = `ws://localhost:${servePort}/`;
@@ -822,3 +828,42 @@ Deno.test("send to a closed socket", async () => {
   };
   await promise;
 });
+
+// https://github.com/denoland/deno/issues/25126
+Deno.test("websocket close ongoing handshake", async () => {
+  // First try to close without any delay
+  {
+    const { promise, resolve } = Promise.withResolvers<void>();
+    let gotError1 = false;
+    const ws = new WebSocket("ws://localhost:4264");
+    ws.onopen = () => fail();
+    ws.onerror = (e) => {
+      assertEquals((e as ErrorEvent).error.code, "EINTR");
+      gotError1 = true;
+    };
+    ws.onclose = () => resolve();
+    ws.close();
+    await promise;
+    assert(gotError1);
+  }
+
+  await delay(50); // Wait a bit before trying again.
+
+  {
+    const { promise: promise2, resolve: resolve2 } = Promise.withResolvers<
+      void
+    >();
+    const ws2 = new WebSocket("ws://localhost:4264");
+    ws2.onopen = () => fail();
+    let gotError2 = false;
+    ws2.onerror = (e) => {
+      assertEquals((e as ErrorEvent).error.code, "EINTR");
+      gotError2 = true;
+    };
+    ws2.onclose = () => resolve2();
+    await delay(50); // wait a bit this time before calling close
+    ws2.close();
+    await promise2;
+    assert(gotError2);
+  }
+});

filename: tests/util/server/src/lib.rs
patch: @@ -323,7 +323,7 @@ async fn get_tcp_listener_stream(
   futures::stream::select_all(listeners)
 }
 
-pub const TEST_SERVERS_COUNT: usize = 33;
+pub const TEST_SERVERS_COUNT: usize = 34;
 
 #[derive(Default)]
 struct HttpServerCount {

filename: tests/util/server/src/servers/mod.rs
patch: @@ -84,6 +84,7 @@ const WS_PORT: u16 = 4242;
 const WSS_PORT: u16 = 4243;
 const WSS2_PORT: u16 = 4249;
 const WS_CLOSE_PORT: u16 = 4244;
+const WS_HANG_PORT: u16 = 4264;
 const WS_PING_PORT: u16 = 4245;
 const H2_GRPC_PORT: u16 = 4246;
 const H2S_GRPC_PORT: u16 = 4247;
@@ -120,6 +121,7 @@ pub async fn run_all_servers() {
   let ws_ping_server_fut = ws::run_ws_ping_server(WS_PING_PORT);
   let wss_server_fut = ws::run_wss_server(WSS_PORT);
   let ws_close_server_fut = ws::run_ws_close_server(WS_CLOSE_PORT);
+  let ws_hang_server_fut = ws::run_ws_hang_handshake(WS_HANG_PORT);
   let wss2_server_fut = ws::run_wss2_server(WSS2_PORT);
 
   let tls_server_fut = run_tls_server(TLS_PORT);
@@ -162,6 +164,7 @@ pub async fn run_all_servers() {
     tls_server_fut.boxed_local(),
     tls_client_auth_server_fut.boxed_local(),
     ws_close_server_fut.boxed_local(),
+    ws_hang_server_fut.boxed_local(),
     another_redirect_server_fut.boxed_local(),
     auth_redirect_server_fut.boxed_local(),
     basic_auth_redirect_server_fut.boxed_local(),

filename: tests/util/server/src/servers/ws.rs
patch: @@ -62,6 +62,24 @@ pub async fn run_ws_close_server(port: u16) {
   }
 }
 
+pub async fn run_ws_hang_handshake(port: u16) {
+  let mut tcp = get_tcp_listener_stream("ws (hang handshake)", port).await;
+  while let Some(Ok(mut stream)) = tcp.next().await {
+    loop {
+      let mut buf = [0; 1024];
+      let n = stream.read(&mut buf).await;
+
+      if n.is_err() {
+        break;
+      }
+
+      if n.unwrap() == 0 {
+        break;
+      }
+    }
+  }
+}
+
 pub async fn run_wss2_server(port: u16) {
   let mut tls = get_tls_listener_stream(
     "wss2 (tls)",