Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
82 changes: 82 additions & 0 deletions test/regression/issue/29219.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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<void>();
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);
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();
});
});

// Both events must fire. Pre-fix, res close never did — this would
// hang and timeout the test instead of reaching the assertion.
await Promise.all([resClose, reqClose]);
expect(true).toBe(true);
Comment thread
robobun marked this conversation as resolved.
Outdated
} finally {
Comment thread
robobun marked this conversation as resolved.
Outdated
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
Loading