diff --git a/.changeset/fix-websocket-econnreset.md b/.changeset/fix-websocket-econnreset.md new file mode 100644 index 000000000000..b1f7474c1b32 --- /dev/null +++ b/.changeset/fix-websocket-econnreset.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Fix dev server crash on WebSocket client disconnect + +When a WebSocket client disconnects while an upgrade request is being processed, the server would crash with an unhandled `ECONNRESET` error. The fix adds an error handler to the socket at the start of the upgrade process. diff --git a/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts new file mode 100644 index 000000000000..3294045f48f7 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts @@ -0,0 +1,65 @@ +import http from "node:http"; +import net from "node:net"; +import { DeferredPromise, Miniflare, Response } from "miniflare"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { handleWebSocket } from "../websockets"; +import type { AddressInfo } from "node:net"; + +describe("handleWebSocket", () => { + let httpServer: http.Server; + let miniflare: Miniflare; + let port: number; + + beforeEach(async () => { + httpServer = http.createServer((_req, res) => res.end("OK")); + miniflare = new Miniflare({ + modules: true, + script: `export default { + fetch() { + const [client, server] = Object.values(new WebSocketPair()); + server.accept(); + return new Response(null, { status: 101, webSocket: client }); + } + }`, + }); + handleWebSocket(httpServer, miniflare); + await new Promise((r) => httpServer.listen(0, "127.0.0.1", r)); + port = (httpServer.address() as AddressInfo).port; + }); + + afterEach(async () => { + await miniflare?.dispose(); + await new Promise((resolve, reject) => + httpServer?.close((e) => (e ? reject(e) : resolve())) + ); + }); + + // https://github.com/cloudflare/workers-sdk/issues/12047 + test("survives client disconnect during upgrade", async () => { + // Mock dispatchFetch to simulate a slow response - the bug occurs when + // the client disconnects while dispatchFetch is pending + const deferred = new DeferredPromise(); + vi.spyOn(miniflare, "dispatchFetch").mockReturnValue(deferred); + + const socket = net.connect(port, "127.0.0.1"); + await new Promise((r) => socket.on("connect", r)); + socket.write( + "GET / HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n" + ); + + // Reset connection while dispatchFetch is pending, triggering ECONNRESET + socket.resetAndDestroy(); + + // Resolve the mock so miniflare.dispose() doesn't hang in afterEach + deferred.resolve(new Response(null)); + + // Verify server did not crash and is still responsive + const response = await fetch(`http://127.0.0.1:${port}`); + expect(response.ok).toBe(true); + }); +}); diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts index a984ea25b12f..5d1a959cd0df 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -20,6 +20,9 @@ export function handleWebSocket( httpServer.on( "upgrade", async (request: IncomingMessage, socket: Duplex, head: Buffer) => { + // Socket errors crash Node.js if unhandled + socket.on("error", () => socket.destroy()); + const url = new URL(request.url ?? "", UNKNOWN_HOST); // Ignore Vite HMR WebSockets