Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/js/node/_http_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,12 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
req.destroy();
}
}

// Fire the close callback set by assignSocketInternal
// (onServerResponseClose). This forwards the socket close to the
// attached ServerResponse so `res.on("close", ...)` runs on client
// disconnect, matching Node.js. See issues #29219, #14697.
callCloseCallback(this);
Comment thread
robobun marked this conversation as resolved.
}
#onCloseForDestroy(closeCallback) {
this.#onClose();
Expand Down
85 changes: 85 additions & 0 deletions test/regression/issue/29219.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// https://github.com/oven-sh/bun/issues/29219
import { expect, test } from "bun:test";
import http from "node:http";
import net from "node:net";

test("ServerResponse emits 'close' when the client aborts mid-response", async () => {
const { promise: closed, resolve: resolveClosed } = Promise.withResolvers<{
writableEnded: boolean;
}>();

const server = http.createServer((req, res) => {
res.on("close", () => {
resolveClosed({ writableEnded: res.writableEnded });
});

// Write some data so the response is mid-stream when the client aborts.
res.write("hello\n");
});

try {
await new Promise<void>(done => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
const client = net.createConnection({ port, host: "127.0.0.1" }, () => {
client.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
});
client.on("data", () => {
// Abrupt close once the server has written its first chunk.
client.destroy();
});
client.on("error", () => {});
done();
});
});

const result = await closed;
// Pre-fix, this promise never resolved. Post-fix, it resolves and
// writableEnded is false because the client yanked the socket before
// res.end() could run.
expect(result).toEqual({ writableEnded: false });
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});

// Also covers https://github.com/oven-sh/bun/issues/14697 — same root
// cause, but the handler never writes a response before the client
// disconnects. Pre-fix, only `req.on("close")` fired.
test("ServerResponse emits 'close' when the client aborts before any write", async () => {
const { promise: resClose, resolve: resolveResClose } = Promise.withResolvers<{
writableEnded: boolean;
}>();
const { promise: reqClose, resolve: resolveReqClose } = Promise.withResolvers<void>();
const { promise: requestSeen, resolve: markRequestSeen } = Promise.withResolvers<void>();

const server = http.createServer((req, res) => {
res.once("close", () => resolveResClose({ writableEnded: res.writableEnded }));
req.once("close", () => resolveReqClose());
markRequestSeen();
// Deliberately don't write or end — wait for the client to go away.
});

try {
await new Promise<void>(done => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
const client = net.createConnection({ port, host: "127.0.0.1" }, () => {
client.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
// Destroy the client only after the server has entered the handler.
requestSeen.then(() => client.destroy());
});
client.on("error", () => {});
done();
});
});

// Pre-fix, `res.on("close")` never fired, so awaiting resClose would
// hang and the test would timeout. Post-fix, both events fire and
// writableEnded is false because the handler never called res.end().
const [resResult] = await Promise.all([resClose, reqClose]);
expect(resResult).toEqual({ writableEnded: false });
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
Loading