Skip to content

node:http: close the connection after rejecting Expect: 100-continue - #33293

Closed
robobun wants to merge 3 commits into
mainfrom
farm/91fd96f0/expect-continue-keepalive
Closed

robobun wants to merge 3 commits into
mainfrom
farm/91fd96f0/expect-continue-keepalive

Conversation

@robobun

@robobun robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Repro

A client that sends Expect: 100-continue withholds the request body until it sees a 100 Continue. Rejecting the expectation from checkContinue (the documented way to turn away a large upload early on auth/quota) therefore leaves the announced Content-Length unsent:

const srv = http.createServer();
srv.on("checkContinue", (req, res) => {
  res.writeHead(417);
  res.end();
});
// client: POST with `Expect: 100-continue` and `Content-Length: 36`, gets the 417,
// and per RFC 9110 10.1.1 never sends the body.
node: HTTP/1.1 417 Expectation Failed ... Connection: close
bun:  HTTP/1.1 417 Expectation Failed ... Connection: keep-alive

Bun keeps the connection alive while the parser is still armed for the 36 body bytes that will never arrive, so the client's next request on that connection gets consumed as the previous request's body: it is silently swallowed and never dispatched, and the connection stalls.

Cause

Node's lib/_http_server.js records res._expect_continue = true when it parses the expectation, and writeHead then does:

// Don't keep alive connections where the client expects 100 Continue
// but we sent a final status; they may put extra bytes on the wire.
if (this._expect_continue && !this._sent100) {
  this.shouldKeepAlive = false;
}

Bun's ServerResponse already tracks _sent100 and already honours shouldKeepAlive === false (renderNativeHeaders answers Connection: close and sets kMustCloseConnection, whose finish listener ends the socket), but nothing ever set _expect_continue, so the rule never fired.

Fix

Set _expect_continue where the expectation is parsed, and clear shouldKeepAlive for a final response that was not preceded by writeContinue(). Note this is not gated on the status code: any final response, 417 or 200, leaves the announced body unsent, and Node closes in both cases.

The check runs in two places because Bun renders headers lazily: ServerResponse.prototype.writeHead (Node's location, which also makes res.shouldKeepAlive observably false inside the handler) and renderNativeHeaders, since callWriteHeadIfObservable skips writeHead entirely when the user never overrode it, so res.statusCode = 417; res.end() would otherwise miss the rule. _sent100 cannot change between the two (writeContinue() throws once headers are sent), so they always agree.

Responses that did send the 100 Continue, and the default 417 for any other expectation (which never withholds a body), keep the connection reusable exactly as before. node:http2 is untouched: a rejected h2 stream does not poison the connection.

Verification

Five new raw-socket tests in test/js/node/http/node-http.test.ts. Three of them fail on unpatched Bun, and all five pass verbatim on real Node v26.3.0 (the file requires Node parity):

variant before after / node
checkContinue → writeHead(417) keep-alive, socket stays open Connection: close + FIN
checkContinue → statusCode = 417, no writeHead keep-alive Connection: close + FIN
checkContinue → 200 without writeContinue() keep-alive Connection: close + FIN
checkContinue → writeContinue() then 200 keep-alive keep-alive, 2nd request answered
Expect: meoww → default 417 keep-alive keep-alive, 2nd request answered

The first test pipelines the request a client would send next and asserts it is never answered and that the server sends FIN on its own, so the swallowed-request shape is what is actually being checked.

Regression sweep
  • test/js/node/http/node-http.test.ts (only pre-existing issue#4295 external-proxy failure, fails identically without the change)
  • 29 vendored Node keep-alive / pipeline / response tests, including test-http-expect-continue-reuse-race.js and test-https-expect-continue-reuse-race.js, which each reuse one keep-alive socket for 100 Expect: 100-continue requests
  • test-http-expect-continue.js, test-http-expect-handling.js, test-http-sync-write-error-during-continue.js, test-http-write-callbacks.js, the three test-http2-compat-expect-* tests

A client that sends Expect: 100-continue withholds the request body until
it receives the 100 Continue, so answering with a final status leaves the
announced Content-Length unsent and the connection no longer framed. The
server kept it alive anyway, and the parser still expected the body: the
client's next request on that connection was consumed as the previous
request's body, silently swallowing it.

Mirror Node.js: record _expect_continue when the expectation is parsed, and
clear shouldKeepAlive for a final response that was not preceded by
writeContinue(), which answers Connection: close and ends the socket after
'finish'. Responses that did send the 100 Continue, and the 417 for any
other expectation, keep the connection reusable as before.
@github-actions github-actions Bot added the claude label Jul 3, 2026
@robobun

robobun commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:16 PM PT - Jul 2nd, 2026

✅ @robobun, your commit 1c11253a25d4d8b2d21470b6c11208a4a46534ce passed in Build #68164! 🎉


🧪   To try this PR locally:

bunx bun-pr 33293

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

bun-33293 --bun

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. 417 Expectation Failed #20415 - Reports 417 Expectation Failed errors with Expect header in node:http server; this PR fixes the connection handling when rejecting 100-continue

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

Fixes #20415

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

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: f78d5fae-c3b6-4f43-b073-d72e039c03cb

📥 Commits

Reviewing files that changed from the base of the PR and between e5cc6e3 and 1c11253.

📒 Files selected for processing (1)
  • test/js/node/http/node-http.test.ts

Walkthrough

The server now tracks Expect: 100-continue requests, clears keep-alive when a final response is sent without 100 Continue, and adds raw-socket tests for closed and reusable connection behavior.

Changes

Expect: 100-continue keep-alive fix

Layer / File(s) Summary
Track expect_continue state and clear keep-alive
src/js/node/_http_server.ts
Sets _expect_continue when the Expect header matches 100-continue, initializes the flag on ServerResponse, and clears keep-alive during header rendering and writeHead() when 100 Continue was not sent.
Tests for expect-continue keep-alive behavior
test/js/node/http/node-http.test.ts
Adds a raw-socket dial() helper and five scenarios covering closed connections for unanswered Expect: 100-continue responses and reusable connections after writeContinue() or non-100-continue expectations.

Related PRs: None identified.

Suggested labels: node.js, http

Suggested reviewers: None identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: closing node:http connections after rejecting Expect: 100-continue.
Description check ✅ Passed The description covers what changed and how it was verified, with repro, cause, fix, and test evidence.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/_http_server.ts`:
- Around line 1461-1464: The keep-alive clear path in
clearKeepAliveIfContinueNotSent only updates shouldKeepAlive, so a checkContinue
response that explicitly sets Connection: keep-alive can still be treated as
reusable. Update the response handling in clearKeepAliveIfContinueNotSent and
the header flattening flow in renderNativeHeaders so the close transport state
is forced as well, and any explicit keep-alive Connection header is overridden
or removed before headers are finalized. Make sure the fix covers the related
continue/connection handling paths associated with renderNativeHeaders and the
explicit Connection header logic.

In `@test/js/node/http/node-http.test.ts`:
- Around line 3528-3530: The pending-read helper in the socket-based test only
rejects on error and end, so a raw socket close can leave until() waiting
indefinitely. Update the socket event wiring in the test helper to also fail on
close by adding a close handler alongside the existing error and end handlers,
and ensure the same pattern is applied to any awaited condition in this area so
every failure path rejects the pending promise.
🪄 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: 7186ae5e-d79b-4723-9b77-88dffeab3e37

📥 Commits

Reviewing files that changed from the base of the PR and between 1498d7b and e5cc6e3.

📒 Files selected for processing (2)
  • src/js/node/_http_server.ts
  • test/js/node/http/node-http.test.ts

Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/http/node-http.test.ts
A raw socket can close without emitting 'end', which would leave until()
waiting until the test timeout instead of failing with the bytes it had.
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Re the suggested Fixes #20415: that is a different bug, so I have deliberately left it out of the description.

#20415 is about the server sending a 417 Expectation Failed for a request that should have succeeded. This PR does not change whether a 417 is sent; it changes what happens to the connection after a legitimate rejection (Connection: close + teardown instead of a poisoned keep-alive). A server that wrongly answers 417 today will still wrongly answer 417 with this patch, just on a connection that now closes cleanly.

Adding the auto-close keyword would silently close a real, still-open bug on merge.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — direct port of Node's _expect_continue/shouldKeepAlive rule with thorough positive/negative test coverage.

Extended reasoning...

Overview

This PR fixes node:http server behavior when a checkContinue handler rejects an Expect: 100-continue request without calling writeContinue(). Previously Bun kept the connection alive with the parser still expecting the announced body, causing the next pipelined request to be silently swallowed. The fix adds an _expect_continue flag on ServerResponse and a small helper clearKeepAliveIfContinueNotSent() that clears shouldKeepAlive — a direct 1:1 port of Node.js's writeHead logic. The helper is called from both writeHead() and renderNativeHeaders() to cover Bun's lazy-header-rendering fast path. Source change is ~20 lines in src/js/node/_http_server.ts; ~175 lines of tests added to test/js/node/http/node-http.test.ts.

Security risks

None. This is HTTP/1.1 connection-lifecycle framing. Closing a mis-framed connection after a rejected expectation is strictly safer than the previous behavior of keeping it alive and mis-parsing subsequent bytes. No auth, crypto, permissions, or untrusted-input parsing is touched.

Level of scrutiny

Moderate. node:http keep-alive is production-critical, but the change is tightly scoped and purely additive: _expect_continue defaults to false and is only set to true on the existing Expect: 100-continue branch, so every other request path is unaffected. The helper reuses the existing shouldKeepAlive → kMustCloseConnection → Connection: close + socket-end machinery rather than adding new close logic. The dual call site (writeHead + renderNativeHeaders) is well-justified in the PR description and cannot disagree since _sent100 cannot change between them.

Other factors

  • Five new raw-socket tests cover the full variant matrix: 417 via writeHead, 417 via statusCode= (no writeHead), 200 without writeContinue (all close), plus two negative cases proving keep-alive is preserved after writeContinue() and for non-100 expectations. All verified to pass verbatim on Node v26.3.0.
  • The first test pipelines a follow-up request and asserts it is never answered and the server FINs on its own — directly checking the swallowed-request bug shape.
  • Both CodeRabbit comments were resolved: the explicit Connection: keep-alive override concern was withdrawn after the author demonstrated Node behaves identically; the test-helper close handler was added in 3dc080d.
  • PR description documents a regression sweep including the vendored Node test-http-expect-continue-reuse-race.js / test-https-expect-continue-reuse-race.js tests (100 keep-alive reuses each), which would catch over-eager closing.
  • No CODEOWNERS entries for these paths. No bugs found by the bug hunting system.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on 3dc080d

The two red lanes in build 68161 are both off this diff. Details so the next reader does not have to re-derive them:

darwin 26 aarch64 - test-bun (exit 1) — infrastructure, no test ran:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

Its upstream darwin aarch64 - build-bun passed (exit 0), so the artifact existed; the agent just could not pull it.

debian 13 x64-asan - test-bun (exit 2) — a JSC exception-scope assertion in test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js:

ASSERTION FAILED: !scope.exception() || !hasSlot
.../JavaScriptCore/JSCJSValuePropertyInlines.h(51) : JSC::JSValue::get(JSGlobalObject *, PropertyName, PropertySlot &) const
main process killed by SIGABRT

That test requires only worker_threads, never node:http, so the module this PR changes is not even evaluated in that process. It is a worker-termination race (a pending termination exception while a property get runs under a scope), and it does not reproduce locally: 0 failures in 12 consecutive runs on an ASAN debug build of this branch.

The change itself is green. On debian 13 aarch64 - test-bun, the shard carrying test/js/node/http/node-http.test.ts reported 133 pass, 0 fail, including the five new tests.

All 15 platforms' build-bun steps passed, which is expected: the diff is ~20 lines of JavaScript in src/js/node/_http_server.ts.

The build is still running, so I am leaving it to finish rather than superseding it with a retrigger.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Build 68161 finished: 284 passed, 2 failed, and the two failures are the ones described above with nothing new. So I have pushed a single ci: retrigger to re-roll them.

One loose end closed from that comment: I had argued the x64-asan abort could not involve this diff because test-worker-message-port-transfer-terminate.js never requires node:http. That is now confirmed rather than assumed. test/js/node/test/common/index.js requires no http at module scope (only assert, child_process, fs, net, path, util, worker_threads), and running the test's body shows node:http never enters the module cache. src/js/node/_http_server.ts is therefore never evaluated in that process, so it cannot appear in the stack of a JSValue::get exception-scope assertion raised during worker termination.

If the re-roll comes back red on the same two lanes, I will leave it and defer to a maintainer rather than keep retriggering.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — small, well-tested Node.js parity fix that only affects Expect: 100-continue requests.

Extended reasoning...

Overview

This PR ports Node.js's _expect_continue / shouldKeepAlive interaction from lib/_http_server.js into Bun's node:http server. When a client sends Expect: 100-continue and the handler responds with a final status without first calling writeContinue(), the announced request body is never sent, so the connection is unframed and must be closed. The production change is ~20 lines across three sites in src/js/node/_http_server.ts: set _expect_continue = true where the expectation is parsed, initialize the field in the ServerResponse constructor, and add a clearKeepAliveIfContinueNotSent() helper invoked from both writeHead() and renderNativeHeaders() (the latter to cover the fast path where callWriteHeadIfObservable skips writeHead). Five new raw-socket tests in test/js/node/http/node-http.test.ts cover the variant matrix (417 via writeHead, 417 via statusCode only, 2xx without writeContinue, keep-alive preserved after writeContinue, and keep-alive preserved for non-100-continue expectations); all five are verified to pass on real Node v26.3.0.

Security risks

None. This is HTTP/1.1 connection-lifecycle management in the Node compat layer — no auth, crypto, permissions, or untrusted-input parsing is touched. The change is strictly more conservative than before: it closes connections that were previously left in a poisoned state where the next pipelined request would be silently consumed as the prior request's unsent body.

Level of scrutiny

renderNativeHeaders runs for every server response, so I looked carefully at blast radius. The new helper is gated on res._expect_continue, a new field that defaults to false and is only set to true in one place (when continueExpression matches the Expect header). For every request without Expect: 100-continue, the added code is a no-op and behavior is byte-identical. The helper only ever clears shouldKeepAlive, and it is placed before the _removedConnection / !hasConnection branches that read it, so ordering is correct. The double invocation (once in writeHead, once in renderNativeHeaders) is idempotent, and _sent100 cannot flip between them since writeContinue() throws once headers are sent. This is a direct 1:1 port of Node's own two-line check, well within "following established patterns with no ambiguity."

Other factors

Both CodeRabbit inline comments are resolved: the explicit Connection: keep-alive override case was verified against live Node v26.3.0 (Node keeps the connection alive there too, so matching is deliberate parity), and the test helper now rejects pending reads on close as well as error/end. No CODEOWNERS covers these files. The PR description documents a regression sweep across 29+ vendored Node keep-alive/pipeline/expect-continue tests, and CI shows the affected test file passing (133 pass, 0 fail) on the shard that carries it; the two red lanes are unrelated infrastructure/pre-existing flakes as detailed in the author's CI status comment.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

The re-roll came back clean: build 68164 is 286 passed, 0 failed, including the two lanes that were red before (darwin-26-aarch64-test-bun and debian-13-x64-asan-test-bun). Both were transient, as expected.

Nothing outstanding on my side: CI is green, and the two review threads are resolved.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-03 and it conflicts with main. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

1 participant