Skip to content

http: emit ServerResponse 'close' on client disconnect#29220

Open
robobun wants to merge 5 commits intomainfrom
farm/127b442d/fix-server-response-close-on-abort
Open

http: emit ServerResponse 'close' on client disconnect#29220
robobun wants to merge 5 commits intomainfrom
farm/127b442d/fix-server-response-close-on-abort

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 12, 2026

Summary

http.ServerResponse never fired its 'close' event when the client aborted mid-response. Reported in #29219; same root cause as #14697.

Repro

import http from 'node:http';
import net from 'node:net';

const server = http.createServer((req, res) => {
  let closed = false;
  res.on('close', () => { closed = true; console.log('res close fired'); });
  res.write('hello\n');
  req.on('error', () => {
    server.close(() => console.log('server closed, res closed =', closed));
  });
});

server.listen(3000, () => {
  const client = net.createConnection({ port: 3000 }, () => {
    client.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
    client.on('data', () => client.end());
  });
});

Node / Deno:

res close fired
server closed, res closed = true

Bun, pre-fix:

server closed, res closed = false

Cause

assignSocketInternal takes the canUseInternalAssignSocket fast path and stores onServerResponseClose on the socket via setCloseCallback (i.e. socket[kCloseCallback] = onServerResponseClose) instead of registering a real .once('close', ...) listener — this is a ~10% perf win in JSC.

When the underlying native handle was closed (via handle.onclose), NodeHTTPServerSocket#onClose destroyed the IncomingMessage but never invoked the stored kCloseCallback. The onServerResponseCloseemitCloseNT(httpMessage) chain that forwards the event to the attached ServerResponse therefore never ran.

Fix

Call callCloseCallback(this) at the end of NodeHTTPServerSocket#onClose, after the request-destroy step. This runs whether the handle was closed by the native side (abort) or via #onCloseForDestroy, so both the mid-response case (#29219) and the no-write case (#14697) are covered.

Tests

test/regression/issue/29219.test.ts covers two scenarios:

  1. Client aborts after receiving the first chunk of a response that's already in-flight (Bun does not emit ServerResponse "close" event on client disconnect during active HTTP response #29219 repro).
  2. Client destroys the socket before the handler writes anything (node:http ServerResponse doesn't emit close event #14697 repro).

Verified with the gate:

  • without fix → both tests fail (resClosed: false, second test times out)
  • with fix → both tests pass

Fixes #29219
Fixes #14697

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

Updated 7:05 PM PT - Apr 12th, 2026

@robobun, your commit 5bccdd4 has 1 failures in Build #45389 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29220

That installs a local version of the PR into your bun-29220 executable, so you can run:

bun-29220 --bun

@github-actions
Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. node:http ServerResponse doesn't emit close event #14697 - ServerResponse close event never fires on client disconnect — exact same root cause
  2. Express: req/res/socket 'close' events never fire on client abort for POST requests with a body #28976 - Express req/res/socket close events never fire on client abort for POST requests with a body — same missing close event

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #14697
Fixes #28976

🤖 Generated with Claude Code

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 12, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 26f4c2ef-fa8b-42ad-8a1e-7a3c114e559c

📥 Commits

Reviewing files that changed from the base of the PR and between c144f91 and 55e8c0b.

📒 Files selected for processing (1)
  • test/regression/issue/29219.test.ts

Walkthrough

Invoke the stored close callback from NodeHTTPServerSocket when the underlying handle closes so ServerResponse "close" listeners run on client disconnect; add two regression tests verifying ServerResponse emits "close" when a TCP client aborts mid-response or before any write.

Changes

Cohort / File(s) Summary
Socket teardown
src/js/node/_http_server.ts
Call callCloseCallback(this) at the end of #onClose() in NodeHTTPServerSocket to explicitly fire the stored close callback when the underlying handle closes.
Regression tests
test/regression/issue/29219.test.ts
Add two bun:test cases: one asserting ServerResponse emits "close" when a client aborts mid-response, and one asserting "close" when the client aborts before any write; tests use raw TCP clients and ensure server shutdown.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: ensuring http.ServerResponse emits 'close' events on client disconnect.
Description check ✅ Passed The description is comprehensive, including summary, reproduction code, root cause analysis, fix explanation, and test coverage for both linked issues.
Linked Issues check ✅ Passed The PR directly addresses both linked issues (#29219 and #14697) by calling callCloseCallback(this) in NodeHTTPServerSocket#onClose to ensure ServerResponse 'close' events emit on client disconnect, covering both mid-response abort and no-write scenarios.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the ServerResponse 'close' event issue: modification to NodeHTTPServerSocket teardown and addition of regression tests for issues #29219 and #14697.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29219.test.ts`:
- Around line 2-6: The header multi-line prose describing the bug should be
trimmed to a single-line GitHub issue URL comment in the test file's top header;
remove the detailed multi-line explanation and leave only a single-line comment
with the issue URL (i.e., collapse the existing header comment block into one
line containing the issue link) so regression tests under
test/regression/issue/*.test.ts contain just the standard issue URL line.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1005972-3f10-48f3-bc66-e8cdca757d0b

📥 Commits

Reviewing files that changed from the base of the PR and between 7c75a87 and 9044d39.

📒 Files selected for processing (2)
  • src/js/node/_http_server.ts
  • test/regression/issue/29219.test.ts

@github-actions
Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(http): emit close event on ServerResponse when client disconnects #25271 - Also fixes ServerResponse not emitting 'close' on client disconnect, with a different approach (emitting in the abort handler)
  2. fix(node:http): emit close event on ServerResponse when socket closes #27206 - Same fix mechanism (calling callCloseCallback in NodeHTTPServerSocket) for the same close event problem
  3. fix(node:http): emit close event on ServerResponse when client disconnects #28720 - Fixes the same ServerResponse 'close' event not firing on client disconnect, using emitCloseNT in #onClose()

🤖 Generated with Claude Code

When the canUseInternalAssignSocket fast path is taken,
assignSocketInternal stores onServerResponseClose on the socket via
setCloseCallback (socket[kCloseCallback] = ...) instead of registering
a real EventEmitter listener. Nothing was invoking that stored
callback when the socket's underlying handle closed, so
ServerResponse.on('close', ...) never ran on client abort.

Fire callCloseCallback(this) at the end of NodeHTTPServerSocket#onClose
after the request destroy, mirroring what Node's socket.on('close')
listener does. This covers both the mid-response case (#29219) and
the no-write case (#14697).

Fixes #29219
Fixes #14697
@robobun robobun force-pushed the farm/127b442d/fix-server-response-close-on-abort branch from 9044d39 to 6e4fcf0 Compare April 12, 2026 11:35
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/node/_http_server.ts:1109-1118 — When res.destroy() is called, the 'close' event fires twice on the ServerResponse: once synchronously from destroy()'s direct this.emit('close'), and once asynchronously from the new NodeHTTPServerSocket.emit hook added by this PR. Fix: in ServerResponse.prototype.destroy(), set this._closed = true before calling this.emit('close'), or call this.detachSocket(this.socket) to clear the socket back-reference before emitting.

    Extended reasoning...

    What the bug is and how it manifests

    ServerResponse.prototype.destroy() emits 'close' twice when called explicitly: once synchronously (via the direct this.emit('close') call in destroy()) and a second time asynchronously (via the new NodeHTTPServerSocket.emit hook added by this PR that routes the socket's 'close' event back to the attached ServerResponse). Any res.on('close', handler) listener will therefore fire twice instead of once.

    The specific code path that triggers it

    1. ServerResponse.prototype.destroy() (lines ~1626-1636) sets this.destroyed = true, calls this?.socket?.destroy() (scheduling async socket teardown), then calls this.emit('close') synchronously.
    2. ServerResponse.prototype.emit intercepts 'close' and calls callCloseCallback(this), which invokes and clears res[kCloseCallback] — but never sets res._closed = true. That flag is only set inside emitCloseNT, which destroy() bypasses entirely.
    3. destroy() also never calls detachSocket(), so socket._httpMessage still points to res and socket[kCloseCallback] still holds onServerResponseClose.
    4. Asynchronously, the socket's underlying handle is torn down and the Duplex base class emits 'close' on the socket.
    5. The new NodeHTTPServerSocket.emit override (added by this PR, lines 1109-1118) catches 'close' and calls callCloseCallback(socket).
    6. socket[kCloseCallback] is still onServerResponseClose, which inspects socket._httpMessage (still res) and calls emitCloseNT(res).
    7. emitCloseNT guards with if (\!self._closed) — but since step 2 never set _closed, this is false — so it sets _closed = true and calls res.emit('close') a second time.

    Why existing code doesn't prevent it

    The _closed guard in emitCloseNT exists precisely to prevent double emissions, but it only works when emitCloseNT is the sole emitter. destroy() short-circuits that path with a direct this.emit('close') call and relies on no second trigger ever arriving — which was true before this PR, since the socket's 'close' event never reached the response. This PR's new socket hook breaks that assumption.

    What the impact would be

    Any caller of res.destroy() — including error handlers, the captureRejectionSymbol path (line ~372), or user code that aborts a response — will observe their res.on('close') listener firing twice. This is an observable regression from pre-PR behavior and can cause double-cleanup, double-rejection, or resource-counting errors in downstream code.

    Step-by-step proof

    const http = require('http');
    let closeCount = 0;
    const server = http.createServer((req, res) => {
      res.on('close', () => closeCount++);
      res.destroy();
      // Tick 0 (sync):
      //   destroy() -> this.emit('close') -> ServerResponse.emit ->
      //     callCloseCallback(res)  [clears res[kCloseCallback], does NOT set _closed]
      //   -> Stream.prototype.emit fires listeners -> closeCount = 1
      // Tick N (async, after socket._destroy completes):
      //   Duplex emits 'close' on socket
      //   -> NodeHTTPServerSocket.emit hook -> callCloseCallback(socket)
      //   -> socket[kCloseCallback]() = onServerResponseClose()
      //   -> socket._httpMessage is still res (detachSocket never called)
      //   -> emitCloseNT(res): res._closed is false -> fires 'close' again
      //   -> closeCount = 2  ← BUG
    });

    How to fix it

    In ServerResponse.prototype.destroy(), add this._closed = true; before this.emit('close') so that the downstream emitCloseNT guard catches the duplicate. Alternatively, call this.detachSocket(this.socket) before emitting to sever both socket._httpMessage and socket[kCloseCallback], preventing onServerResponseClose from reaching the response at all.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

The double-emit analysis targets the first commit (9044d39 — socket emit() override), not the current one. Force-pushed 6e4fcf0 moves the hook into NodeHTTPServerSocket#onClose after the req destroy. Verified empirically:

const server = http.createServer((req, res) => {
  let n = 0; res.on("close", () => { console.log("close #" + ++n); });
  res.destroy();
});

Prints close #1 exactly once. The async #onClosecallCloseCallback(socket)onServerResponseCloseemitCloseNT(res) path runs after res.destroy(), but by that time callCloseCallback on the response side has already cleared res[kCloseCallback], and emitCloseNT is guarded by the normal destroy bookkeeping so no second close fires.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29219.test.ts`:
- Around line 72-79: The test uses setTimeout to wait before calling
client.destroy which makes it flaky; instead create and await a "request-seen"
signal resolved from the server's request/connection handler and call
client.destroy only after that promise resolves. Locate the test block around
server.listen and net.createConnection and replace the setTimeout usage with a
Promise that is resolved inside the server's request handler (or
server.on("connection")/server.on("request") callback), then await that promise
before invoking client.destroy so the disconnect happens only after the server
has entered the handler.
- Around line 23-38: The test is waiting on an unreliable client disconnect
signal: replace the graceful half-close call client.end() with an abrupt close
via client.destroy(), and stop relying on req.on("error"); instead resolve the
surrounding promise from the response's "close" event (use the res.close
handler) and verify the response was cut off by checking res.writableEnded ===
false before resolving (keep the existing server/connection setup and adjust
handlers around client and res accordingly).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f2c193fa-75b4-4e38-8cb1-3f7d4326d127

📥 Commits

Reviewing files that changed from the base of the PR and between 9044d39 and 6e4fcf0.

📒 Files selected for processing (2)
  • src/js/node/_http_server.ts
  • test/regression/issue/29219.test.ts

Address coderabbit review:
- First test resolves from res.on('close') and asserts
  res.writableEnded === false, instead of relying on req.on('error').
  Uses client.destroy() for an abrupt abort.
- Second test awaits a request-seen signal before calling
  client.destroy(), replacing the setTimeout(100) tick hack.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29219.test.ts`:
- Around line 77-79: The test currently contains a tautological assertion
expect(true).toBe(true) after awaiting Promise.all([resClose, reqClose]); remove
that no-op assertion and either leave the awaited Promise.all([resClose,
reqClose]) as the verification or replace it with a concrete assertion about the
closure outcome (for example assert the resolved value or a socket/connection
closed flag associated with resClose/reqClose). Update the block around resClose
and reqClose so the test verifies the actual signal (remove
expect(true).toBe(true) or replace it with an assertion referencing the
Promise.all result or the relevant closed-state variables).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7653fb2d-a7fe-41cb-82bc-2e6716ff1cab

📥 Commits

Reviewing files that changed from the base of the PR and between 6e4fcf0 and c144f91.

📒 Files selected for processing (1)
  • test/regression/issue/29219.test.ts

robobun added 2 commits April 12, 2026 11:59
Replace expect(true).toBe(true) with a concrete assertion on the
resolved value from res.on('close').
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

CI noise unrelated to this PR:

  • Build #45331linux-aarch64-build-cpp failed fetching c-ares/c-ares@3ac47ee4 (HTTP 502 from github.com after 5 retries). Infra.
  • Build #45364test/js/bun/cron/in-process-cron.test.ts on debian-13-x64-asan panicked with EventLoop.enqueueTaskConcurrent: VM has terminated in firing > worker terminate while async callback pending releases cleanly. Cron worker-terminate race — this PR only touches src/js/node/_http_server.ts.

All 55+ other steps passed on both builds. The regression test for #29219 / #14697 passes on every platform it ran on.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 12, 2026

Third consecutive unrelated flake on this PR:

  • #45331linux-aarch64-build-cpp: GitHub HTTP 502 fetching c-ares tarball
  • #45364debian-13-x64-asan: in-process-cron.test.ts > worker terminate panic EventLoop.enqueueTaskConcurrent: VM has terminated
  • #45389debian-13-x64-asan: test-cluster-primary-kill.js timeout

None of these touch node:http. This PR only modifies src/js/node/_http_server.ts (adds callCloseCallback(this) at the end of NodeHTTPServerSocket#onClose). The regression tests for #29219 / #14697 pass on every platform where they ran. Not going to keep retriggering CI — this needs a look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun does not emit ServerResponse "close" event on client disconnect during active HTTP response node:http ServerResponse doesn't emit close event

1 participant