Skip to content

node:http: dispatch request on first write() and emit response in duplex mode#30377

Merged
dylan-conway merged 7 commits into
mainfrom
farm/8c11b358/http-client-duplex-response
May 11, 2026
Merged

node:http: dispatch request on first write() and emit response in duplex mode#30377
dylan-conway merged 7 commits into
mainfrom
farm/8c11b358/http-client-duplex-response

Conversation

@robobun

@robobun robobun commented May 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13696
Fixes #23970

Related: #21342, #29012, #18982.

Reproduction

const req = http.request({
  method: "POST",
  headers: { "Transfer-Encoding": "chunked" },
  ...
});
req.on("response", res => res.on("data", ...));
req.write(body);
// req.end() intentionally NOT called — request body stream kept open

In Node, the request goes out on the first write() and the 'response' event fires as soon as headers arrive, while the request body stream stays open for further writes.

In Bun, the request was never sent and 'response' was never emitted.

Cause

In _http_client.ts:

  1. pushChunk only called startFetch() when writeCount > 1, so a single write() without end()/flushHeaders() never dispatched the request.
  2. When startFetch() ran with isDuplex = true (body still streaming), handleResponse() was gated on !keepOpen and otherwise deferred until the body generator finished — i.e. until req.end().

docker-modem hits both for container.exec({ stdin: true }): it sends a chunked POST, writes the JSON options once, and keeps the request open so it can stream stdin to the container. This is what makes testcontainers' default HostPortWaitStrategy (which shells into the container via exec to check ports) hang until timeout.

Fix

  • First write() now schedules startFetch() for the next tick. If end() runs in the same tick, send() still takes the non-duplex fast path (fetching / finished guards prevent the deferred start from doing anything).
  • handleResponse() is now called unconditionally when response headers arrive, matching Node's event ordering. The existing self-clear (handleResponse = undefined) keeps later call sites no-ops.

Verification

test/regression/issue/13696.test.ts simulates docker-modem's exec pattern against a raw TCP server and a Unix-socket server (Docker daemon uses a Unix socket). Both, plus the flushHeaders() variant, hang/timeout on current main and pass with this change.

Existing write()/end() combinations (same-tick, next-tick, multi-chunk, empty) match Node output unchanged.

Related issues checked

…lex mode

ClientRequest previously only called startFetch() after the second
write(), or when end()/flushHeaders() ran. A single req.write(body)
without req.end() therefore never sent the request at all. And when
the request was started in duplex mode (body still streaming),
handleResponse() was deferred until the body generator finished, so
the 'response' event was held back until req.end().

docker-modem hits both of these when driving Docker's exec API with
stdin enabled: it sends a chunked POST, writes the JSON options once,
and intentionally keeps the request open for stdin. Bun never sent the
request, so testcontainers' HostPortWaitStrategy (and anything else
using container.exec) hung until timeout.

Now the first write() schedules startFetch() for the next tick (so a
synchronous write()+end() still takes the non-duplex fast path), and
handleResponse() runs as soon as response headers arrive regardless of
whether the request body stream is still open.

Fixes #13696
@robobun

robobun commented May 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:12 PM PT - May 8th, 2026

@robobun, your commit 86a10a0 has 2 failures in Build #52933 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30377

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

bun-30377 --bun

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. dockerode exec.start({ hijack: true, stdin: true }) hangs under Bun but works under Node.js #29012 - dockerode exec.start({ hijack: true, stdin: true }) hangs: exact same docker-modem pattern where a single write() with no end() expects an immediate response
  2. Dockerode attach hangs #18982 - Dockerode attach hangs: same docker-modem hijack pattern; container.attach() sends a chunked POST, writes once, never calls end()
  3. Request headers not logged unless more than 1 chunked message is sent #23970 - Request headers not logged unless more than 1 chunked message is sent: single req.write() without end() never dispatches the request due to the writeCount > 1 gate
  4. Multiple data writes on outgoing http/https request cause the connection to hang #21620 - Multiple data writes on outgoing http/https request cause hang: first write() alone in a tick never dispatched the request; the process.nextTick fix ensures it does

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

Fixes #29012
Fixes #18982
Fixes #23970
Fixes #21620

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR defers request startup to process.nextTick on the first body write, centralizes abort wiring in startFetch, emits response headers immediately when fetch resolves, defers request close while body streams, and adds regression tests for open-body and duplex scenarios.

Changes

Duplex HTTP Request Streaming

Layer / File(s) Summary
startFetch AbortController wiring
src/js/node/_http_client.ts
startFetch() now creates/registers an AbortController if missing so fetch dispatch points have cancellation support.
Request Body Deferral
src/js/node/_http_client.ts
On first write() (writeCount === 1), startFetch() is scheduled on process.nextTick when !fetching && !destroyed && !finished, deferring synchronous fetch start.
Immediate Response Emission
src/js/node/_http_client.ts
handleResponse() is called as soon as the fetch resolves so the response event (headers) is emitted when headers arrive, independent of request body completion.
Deferred Request Close Handling
src/js/node/_http_client.ts
If the request is not finished when handleResponse() runs, a deferredRequestClose flag is set instead of immediately emitting close/prefinish, preserving ordering with finish.
send() nextTick: maybeEmitFinish then maybeEmitClose
src/js/node/_http_client.ts
send() schedules a process.nextTick callback that runs maybeEmitFinish() and then runs maybeEmitClose() only if deferredRequestClose was set.
Regression Tests
test/regression/issue/13696.test.ts
Adds fixture and two tests: (1) TCP/Unix raw-socket test asserting request seen, response status, streamed chunks, and response end while client ends after response; (2) duplex-mode test using flushHeaders() then write() without end() until after response end.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'node:http: dispatch request on first write() and emit response in duplex mode' clearly summarizes the main changes—enabling request dispatch on first write and response emission in streaming scenarios.
Description check ✅ Passed The description provides detailed reproduction case, root causes, fixes, and verification steps, exceeding the template structure.
Linked Issues check ✅ Passed The PR directly addresses #13696 (incremental response streaming) and #23970 (single write dispatch) through synchronous response emission and deferred startFetch scheduling.
Out of Scope Changes check ✅ Passed Code changes are tightly scoped to _http_client.ts request dispatch and response handling logic, with a focused regression test; no unrelated modifications detected.

✏️ 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.

@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 `@test/regression/issue/13696.test.ts`:
- Around line 133-136: The test currently asserts stderr === "" unconditionally
which is flaky; keep the stdout handling (const [stdout, stderr, exitCode] =
await Promise.all([...]) and const lines = stdout.trim().split("\n")), remove
the expect(stderr).toBe("") check, then assert success by checking exitCode
(await or expect exitCode === 0) as the primary signal and only inspect or dump
stderr when exitCode is non‑zero (e.g., if (exitCode !== 0) fail with stderr
content) so ASAN/debug warnings on stderr don't cause false failures; apply same
change pattern to the other occurrences referenced around lines 209-212.
- Around line 1-19: Replace the long multi-paragraph preamble comment at the top
of issue/13696.test.ts with the repository’s two-line regression header: first
the issue URL (https://github.com/oven-sh/bun/issues/13696) and second a single
concise one-line bug description summarizing the regression (e.g., "node:http
client deferred sending single write() and delayed 'response' in duplex mode
causing hangs"). If any of the omitted historical rationale affects test design,
move just that specific rationale into a short inline comment next to the
relevant test or helper (refer to the top comment block and any mentions in the
test body to decide what to keep).
🪄 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: 988cc34c-6a6a-44d2-a934-f2ee72500f0f

📥 Commits

Reviewing files that changed from the base of the PR and between 9ed6e89 and 210c3c8.

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

Comment thread test/regression/issue/13696.test.ts
Comment thread test/regression/issue/13696.test.ts
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. http: emit 'upgrade' event on 101 and honor chunked body on upgrade #29015 - Both fix the !keepOpen gate on handleResponse() in _http_client.ts to allow response emission in duplex mode (write-without-end pattern used by dockerode/docker-modem)

🤖 Generated with Claude Code

@robobun

robobun commented May 7, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the duplicate flag for #29015 — that PR is the larger 'upgrade'-event fix for hijack: true (status 101 → 'upgrade' + hijacked Duplex socket, plus the Zig-level chunked-framing-during-upgrade changes). This PR is the narrower non-upgrade case: a single write() with no end() on a plain request never dispatched, and 'response' was held until end() in duplex mode. They touch one overlapping line in _http_client.ts (the !keepOpen gate on handleResponse()) but fix different issues — #29015 targets #29012/#18982, this one targets #13696/#23970.

@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: 1

🤖 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 `@test/regression/issue/13696.test.ts`:
- Around line 51-53: Replace the ad-hoc temp dir creation using fs.mkdtempSync
when constructing socketPath with the harness-provided tempDir helper: allocate
the unix socket directory via tempDir(...) in the parent test scope and use that
returned path to build socketPath, then update listenArgs and requestOpts to use
that socketPath; ensure the test fixture receives or references the
harness-created directory instead of relying on fs.mkdtempSync so cleanup is
managed by the harness.
🪄 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: 5454d308-6b5d-4df8-837b-1c646bfa948e

📥 Commits

Reviewing files that changed from the base of the PR and between 0b99d1e and 5fdacb8.

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

Comment thread test/regression/issue/13696.test.ts Outdated
Comment thread src/js/node/_http_client.ts
Comment thread src/js/node/_http_client.ts Outdated
robobun added 2 commits May 7, 2026 23:39
…l body finishes

Two follow-ups from review on the duplex-response change:

- startFetch() now creates the AbortController and registers the onAbort
  listener itself, so every dispatch path (send, flushHeaders, and both
  pushChunk branches) gets a cancellable fetch. Previously the
  write()-triggered paths left kAbortController null, so req.abort(),
  req.destroy(), timeouts and options.signal were no-ops on the
  in-flight request. Centralising also drops the duplicate onAbort
  listener that flushHeaders()+send() used to add.

- handleResponse()'s nextTick no longer calls maybeEmitClose() while the
  request body is still streaming. Doing so emitted 'prefinish'/'close'
  before 'finish' (or with no 'finish' at all) on a writable-open
  request. Instead it sets a flag that send()'s finish-tick drains, so
  the duplex case orders socket→response→prefinish→finish→close once
  req.end() runs.
Comment thread src/js/node/_http_client.ts Outdated
@robobun

robobun commented May 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 52695 failures are all pre-existing flakes that also appear on unrelated branches — none touch _http_client.ts:

Test Targets Also failing on
test-http-should-emit-close-when-connection-is-aborted.ts win×3 52620 (node-sqlite), 52600 (ghcr-docker-mirror), 52687 (s3-path), 52678 (wasm-esm)
node-http-connect.test.ts win x64-baseline 52649, 52667 (serve-listen-errno). Failing sub-test runs via nodeExe(), not Bun.
fetch-http2-client.test.ts debian asan AtomStringImpl assertion in experimental H2 client
jsc-stress / hot.test / spawn-ipc-gc / serve-http3 / bun-install-registry various 52678, 52687

test/regression/issue/13696.test.ts passed on all completed jobs (171 passed, 54 running).

The self.finished gate added above already covers both paths:
finished → maybeEmitClose() ran at line 451; not finished →
deferredRequestClose is set and send() drains it after 'finish'.
Calling it again here bypassed that gate for a 304 arriving while
the request body is still open.
@robobun

robobun commented May 8, 2026

Copy link
Copy Markdown
Collaborator Author

Build 52706 (d6982eb) complete: 218/222 test jobs passed, 13696.test.ts green everywhere. All 9 failing tests are pre-existing flakes confirmed on other builds in the 52550–52710 range:

Test Also on
hot.test.ts 52687, 52678, 52695
bun-install-registry.test.ts 52687, 52678, 52695
bun-security-scanner-matrix-with-node-modules.test.ts 18 builds incl. 52687, 52700, 52705
multi-run.test.ts 52554, 52700
s3-storage-class.test.ts 52687, 52678
spawn-ipc-gc.test.ts 52678, 52695
spawn.test.ts 10+ builds in 52544–52589
test-http-should-emit-close-when-connection-is-aborted.ts 52620, 52600, 52687, 52678, 52695
09041.test.ts 52568, 52583, 52587, 52591, 52608, 52673, 52695, 52698

None touch _http_client.tsspawn, hot, install, s3 are unrelated subsystems; the one http-adjacent test uses native fetch() + server-side IncomingMessage and fails identically on branches that only touch sqlite/wasm/docker.

@cirospaciari

Copy link
Copy Markdown
Member

@robobun check my last prs this will cause problems with content-length fix it, adopt it

@robobun

robobun commented May 8, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari I looked through your recent PRs (open + merged) and tested the Content-Length paths — I can't find the breakage you're referring to. Could you point me at the specific PR or scenario?

What I've verified with this change vs Node vs 1.3.13 (raw net server capturing the wire bytes):

Scenario Node This PR 1.3.13
write(5 bytes), explicit Content-Length: 5, no end() CL: 5 + body CL: 5 + body ✓ never sent
write(5), explicit CL: 5, setTimeout(end, 200) CL: 5 + body CL: 5 + body ✓ CL: 5 + body
write(5) + write(5) across ticks, explicit CL: 10 CL: 10 + body CL: 10 + body ✓ CL: 10 + body
write(5), explicit CL: 20, no end() CL: 20 + 5 bytes, waits CL: 20 + 5 bytes, waits ✓ never sent
write(5), no CL/TE, setTimeout(end, 100) TE: chunked TE: chunked CL: 5

The last row is the only behavior change — and it now matches Node. An explicit Content-Length is still honored in every case because FetchTasklet.skipChunkedFraming() + http.zig:1030-1040 preserve it for streaming bodies. test-http-content-length-mismatch.js passes.

If there's a PR of yours I should rebase onto / adopt the approach from, happy to — I just couldn't find one touching _http_client.ts or the request-body framing path.

Comment thread src/js/node/_http_client.ts Outdated
Comment thread src/js/node/_http_client.ts Outdated
@robobun

robobun commented May 9, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari ran the full Node.js parallel http suite side-by-side (213 tests on main @ 6d0d86b vs this branch @ d6982eb): identical results — same 3 pre-existing env failures (autoselectfamily, chunk-problem, dns-error), zero divergence.

Wire-level comparison with a raw net server capturing every byte:

Scenario Node This PR Match
CL:5 + write(5) + no end() CL:5 hello CL:5 hello
CL:5 + write(5) + setTimeout(end) CL:5 hello CL:5 hello
CL:10 + write(5) + async write(5) + end() CL:10 helloworld CL:10 helloworld
TE:chunked + write(5) + no end() TE:chunked 5\r\nhello\r\n TE:chunked 5\r\nhello\r\n
no hdr + write(5) + setTimeout(end) TE:chunked TE:chunked
no hdr + write(5) + end() same tick TE:chunked CL:5

Every case where the user sets Content-Length is byte-identical to Node. An explicit Content-Length is always honored because FetchTasklet.skipChunkedFraming() checks for it and http.zig:1030 writes it instead of Transfer-Encoding: chunked even when the body is a streaming generator.

The one divergence (last row) is the pre-existing Bun fast-path that my nextTick deferral explicitly preserves: write(); end(); in the same tick still goes through send() with a pre-assembled body, so Bun sends Content-Length instead of chunked. That behavior is unchanged from main.

Also hoisted both process.nextTick callbacks in 86a10a0 per your review comments.

Comment thread src/js/node/_http_client.ts
@robobun

robobun commented May 9, 2026

Copy link
Copy Markdown
Collaborator Author

Build 52933 (86a10a0): 35 passed, 2 failed, 13 running — same flake pattern as 52695 and 52706:

Test Targets Nature
test-http-should-emit-close-when-connection-is-aborted.ts win ×3 Appears on 8/12 sampled builds 52700–52920 (unrelated branches). Uses native fetch() + server-side IncomingMessage — doesn't touch _http_client.ts.
hot.test.ts win x64 Known flake (52687, 52678, 52695, 52706).
serve.test.ts fedora aarch64 Bun.serve Content-Range handling, "(1 retry)". Unrelated subsystem.

test/regression/issue/13696.test.ts is green on all completed jobs across all three builds.


Status: this diff is green. Across 3 CI builds, every failure cross-references to unrelated branches and none touch the node:http client path I changed. The Windows emit-close-when-connection-is-aborted flake recurs on ~67% of recent builds regardless of branch, so a re-roll won't clear it — needs a maintainer to merge past it (or that test needs a separate fix on main).

All review feedback addressed:

  • ✅ Hoisted both process.nextTick callbacks (86a10a0)
  • ✅ AbortController wiring centralized in startFetch() (67f8d86)
  • 'close' ordering preserved relative to 'finish' in duplex mode (67f8d86, d6982eb)
  • ✅ Content-Length: all 213 Node parallel http tests identical main↔branch; explicit Content-Length byte-identical to Node on the wire

@dylan-conway dylan-conway merged commit 4c0a5a7 into main May 11, 2026
75 of 77 checks passed
@dylan-conway dylan-conway deleted the farm/8c11b358/http-client-duplex-response branch May 11, 2026 19:58
robobun added a commit that referenced this pull request May 30, 2026
Emit 'upgrade' on http.ClientRequest when the server responds 101, with
a usable Duplex socket bridging the underlying fetch streams. Gates
duplex: "half" on the Upgrade header so non-upgrade requests keep their
connection-reuse behavior.

Rebased onto main; reconciled with the duplex-on-first-write dispatch
(#30377). Unhandled 101 now also aborts the fetch so the connection is
torn down, not just the socket.

Fixes #18982.
robobun added a commit that referenced this pull request May 30, 2026
Emit 'upgrade' on http.ClientRequest when the server responds 101, with
a usable Duplex socket bridging the underlying fetch streams. Gates
duplex: "half" on the Upgrade header so non-upgrade requests keep their
connection-reuse behavior.

Rebased onto main; reconciled with the duplex-on-first-write dispatch
(#30377). Unhandled 101 now also aborts the fetch so the connection is
torn down, not just the socket.

Fixes #18982.
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.

Request headers not logged unless more than 1 chunked message is sent Streaming http responses are delayed until more chunks arrive

3 participants