authorgravatar for 153842990+yashranaway@users.noreply.github.comAditya Garud <153842990+yashranaway@users.noreply.github.com> 2026-08-15 18:01:32+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:31:32+00:00
log931e91527c285c49d8482328877c93c8c684591a
tree8d3a5f93a7e6e7d07f7b62a1d3bf9fd7aa04529d
parent04f23098e13410a325fd2b997cbf7144e872470e
signature Signed by PGP key B5690EEEBB952194

Keep the server alive when a response write hits a dead socket (#4470)


3 files changed, 145 insertions(+), 1 deletions(-)

apps/server/src/httpResponseErrorGuard.test.ts created+104
...@@ -0,0 +1,104 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeHttp from "node:http";
3import * as NodeNet from "node:net";
4import { afterEach, describe, expect, it } from "vite-plus/test";
5
6import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
7
8const servers: NodeHttp.Server[] = [];
9
10function listen(server: NodeHttp.Server): Promise<number> {
11 servers.push(server);
12 return new Promise((resolve) => {
13 server.listen(0, "127.0.0.1", () => {
14 resolve((server.address() as NodeNet.AddressInfo).port);
15 });
16 });
17}
18
19function fetchStatus(port: number, path: string): Promise<number> {
20 return new Promise((resolve, reject) => {
21 const request = NodeHttp.get({ host: "127.0.0.1", port, path }, (response) => {
22 response.resume();
23 resolve(response.statusCode ?? 0);
24 });
25 request.on("error", reject);
26 request.setTimeout(5_000, () => reject(new Error("request timed out")));
27 });
28}
29
30afterEach(() => {
31 for (const server of servers.splice(0)) {
32 server.close();
33 }
34});
35
36describe("guardHttpResponseWriteErrors", () => {
37 it("contains an upgrade socket write failure instead of crashing the process", async () => {
38 const writeErrors: unknown[] = [];
39 const failureObserved = Promise.withResolvers<void>();
40 const server = guardHttpResponseWriteErrors(NodeHttp.createServer(), (error) => {
41 writeErrors.push(error);
42 failureObserved.resolve();
43 });
44
45 server.on("upgrade", (_request, socket) => {
46 // Simulate the client vanishing while the auth rejection response is
47 // written to the upgrade socket: the write failure surfaces as an
48 // "error" event on a socket Node's http server no longer listens to.
49 socket.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
50 });
51
52 const port = await listen(server);
53
54 const client = NodeNet.connect(port, "127.0.0.1", () => {
55 client.write(
56 [
57 "GET /rpc HTTP/1.1",
58 "Host: 127.0.0.1",
59 "Connection: Upgrade",
60 "Upgrade: websocket",
61 "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
62 "Sec-WebSocket-Version: 13",
63 "",
64 "",
65 ].join("\r\n"),
66 );
67 });
68 client.on("error", () => {});
69
70 await failureObserved.promise;
71 client.destroy();
72
73 expect(writeErrors).toHaveLength(1);
74 expect(writeErrors[0]).toBeInstanceOf(Error);
75 expect((writeErrors[0] as NodeJS.ErrnoException).code).toBe("EPIPE");
76
77 // The process survived the failed write and the server keeps serving.
78 server.on("request", (_request, response) => {
79 response.writeHead(200, { "content-type": "text/plain" });
80 response.end("ok");
81 });
82 await expect(fetchStatus(port, "/")).resolves.toBe(200);
83 });
84
85 it("arms every response with an error listener without disturbing normal traffic", async () => {
86 const writeErrors: unknown[] = [];
87 let responseErrorListeners = -1;
88 const server = guardHttpResponseWriteErrors(NodeHttp.createServer(), (error) => {
89 writeErrors.push(error);
90 });
91
92 server.on("request", (_request, response) => {
93 responseErrorListeners = response.listenerCount("error");
94 response.writeHead(200, { "content-type": "text/plain" });
95 response.end("ok");
96 });
97
98 const port = await listen(server);
99
100 await expect(fetchStatus(port, "/")).resolves.toBe(200);
101 expect(responseErrorListeners).toBeGreaterThan(0);
102 expect(writeErrors).toEqual([]);
103 });
104});
apps/server/src/httpResponseErrorGuard.ts created+39
...@@ -0,0 +1,39 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import type * as NodeHttp from "node:http";
3
4/**
5 * Node surfaces late socket write failures (EPIPE, ECONNRESET,
6 * ERR_STREAM_DESTROYED) as "error" events. An "error" event without a
7 * listener escalates into an uncaught exception and terminates the whole
8 * server process, taking every other client and all in-flight provider
9 * work with it.
10 *
11 * Two emitters need coverage:
12 *
13 * - Upgrade sockets. Once a connection upgrades (the websocket RPC path,
14 * including its auth rejection responses), Node's http server detaches
15 * its own socket error handling, so the raw socket has no listener at
16 * all until the websocket server adopts it.
17 * - Server responses. Response streams have no default error listener
18 * either.
19 *
20 * A disconnected client only affects its own request: the request fiber is
21 * already interrupted through the response "close" event, so the write
22 * failure needs no handling beyond being observed.
23 */
24export function guardHttpResponseWriteErrors<T extends NodeHttp.Server>(
25 server: T,
26 onError?: (error: unknown) => void,
27): T {
28 server.on("request", (_request, response) => {
29 response.on("error", (error) => {
30 onError?.(error);
31 });
32 });
33 server.on("upgrade", (_request, socket) => {
34 socket.on("error", (error) => {
35 onError?.(error);
36 });
37 });
38 return server;
39}
apps/server/src/server.ts+2-1
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 browserApiCorsLayer,18 browserApiCorsLayer,
19 httpCompressionLayer,19 httpCompressionLayer,
20} from "./http.ts";20} from "./http.ts";
21import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
21import { fixPath } from "./os-jank.ts";22import { fixPath } from "./os-jank.ts";
22import { websocketRpcRouteLayer } from "./ws.ts";23import { websocketRpcRouteLayer } from "./ws.ts";
23import * as ExternalLauncher from "./process/externalLauncher.ts";24import * as ExternalLauncher from "./process/externalLauncher.ts";
...@@ -209,7 +210,7 @@ const HttpServerLive = Layer.unwrap(...@@ -209,7 +210,7 @@ const HttpServerLive = Layer.unwrap(
209 Effect.promise(() => import("@effect/platform-node/NodeHttpServer")),210 Effect.promise(() => import("@effect/platform-node/NodeHttpServer")),
210 Effect.promise(() => import("node:http")),211 Effect.promise(() => import("node:http")),
211 ]);212 ]);
212 return NodeHttpServer.layer(NodeHttp.createServer, {213 return NodeHttpServer.layer(() => guardHttpResponseWriteErrors(NodeHttp.createServer()), {
213 host: config.host ?? "127.0.0.1",214 host: config.host ?? "127.0.0.1",
214 port: config.port,215 port: config.port,
215 gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS,216 gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS,