Skip to content

node:http2: don't run an earlier write's callback mid-payload - #46823

Open
dylan-conway wants to merge 2 commits into
mainfrom
claude/epic-cerf-d4d1t9
Open

dylan-conway wants to merge 2 commits into
mainfrom
claude/epic-cerf-d4d1t9

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Fixes a use-after-free in the native node:http2 frame parser: writeStream() could run an earlier write's callback while it was still reading the caller's ArrayBuffer.

A queued DATA frame holds one write callback. When a later write tops up that frame, the earlier callback is called at once. That happened inside Stream::queue_frame, in the middle of send_data's walk over the payload, which on a native socket is borrowed rather than copied.

  • A callback that called transfer(0) or resize(0) on the buffer made the remaining slices be copied from freed memory. resize(0) segfaults on release 1.4.4.
  • A callback that wrote to the same stream put its bytes in the middle of the other write's payload.

queue_frame now returns the displaced callback. writeStream() and noTrailers() call it once the whole payload is queued and the &mut Stream borrow has ended, still before they return. No JS runs inside queue_frame any more, so its black_box and raw-pointer code is gone.

node:http2 keeps one write in flight per stream, so only direct use of the native parser reaches this.

A queued END_STREAM now also stays on the last queued frame. On main, a non-closing write behind a queued closing write cleared the flag, or was sent as DATA after it. With the callback running later, a write made from it became such a write, so both are fixed here.

How did you verify your code works?

  • New test in test/js/node/http2/node-http2.test.js (transfer, resize, overwrite, re-entrant write, write behind a closing write). It checks the bytes and END_STREAM flags the peer receives, and the callback order. It passes with bun bd test and fails on an unfixed debug build and with USE_SYSTEM_BUN=1.
  • With Malloc=1, an unfixed debug build reports heap-use-after-free in Vec::extend_from_slice under queue_frame. The fixed build ends cleanly.
  • test/js/node/http2/: no new failures against an unfixed build.
  • A 256 KB upload through the public API over TCP gives the same bytes and callback order as Node.

Generated by Claude Code

…laced it is queued

A queued DATA frame holds one write callback. When a later write tops up
that frame, the earlier callback is called at once. That happened in the
middle of the walk over the later write's payload, which on a native socket
is borrowed from the caller's ArrayBuffer.

- A callback that called transfer() or resize() on that buffer made the
  remaining slices be copied from freed memory.
- A callback that wrote to the same stream put its bytes in the middle of
  the payload.

queue_frame() now returns the displaced callback. writeStream() and
noTrailers() call it once the whole payload is queued and the stream borrow
has ended. It is still called before they return.
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 6:55 PM PT - Oct 24th, 2026

❌ @claude, your commit 1324cde has 3 failures in Build #132298 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46823

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

bun-46823 --bun

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between fccbc18 and 1324cde.

Files selected for processing: 2
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/node-http2.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

Walkthrough

The HTTP/2 frame parser returns callbacks displaced during DATA frame queuing and dispatches them after send processing. The changes also preserve END_STREAM when queued writes are merged. Regression tests check payload integrity, callback order, and END_STREAM placement.

Changes

HTTP/2 queued callback dispatch

Layer / File(s) Summary
Queue displaced callbacks
src/runtime/api/bun/h2_frame_parser.rs
Stream::queue_frame returns displaced callbacks and preserves a queued frame’s END_STREAM flag when merging writes. send_data returns displaced callbacks with the settled stream state and deferred-callback flag.
Dispatch callbacks and verify writes
src/runtime/api/bun/h2_frame_parser.rs, test/js/node/http2/node-http2.test.js
no_trailers and write_stream dispatch displaced callbacks after send processing. write_stream drops its payload, buffer, and guarded stream before dispatch. Tests check payload bytes, callback order, and END_STREAM placement.

Suggested reviewers: jarred-sumner, alii

Priority: Normal

Merge Risk: Minimal · up to 1324c

No actionable issue remains in the reviewed HTTP/2 callback and payload changes. The PR is mergeable after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names the main change: a displaced write callback no longer runs in the middle of a payload in node:http2. It is short, specific, and matches the objectives and the file summaries, so it fit…
Description check Passed The description follows the template. 'What does this PR do?' explains the use-after-free, the fix in queue_frame, writeStream() and noTrailers(), and the END_STREAM placement change. 'How did you ver…
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.
Full details: Title check

Explanation

The title names the main change: a displaced write callback no longer runs in the middle of a payload in node:http2. It is short, specific, and matches the objectives and the file summaries, so it fits the changeset.

Full details: Description check

Explanation

The description follows the template. 'What does this PR do?' explains the use-after-free, the fix in queue_frame, writeStream() and noTrailers(), and the END_STREAM placement change. 'How did you verify your code works?' lists the new test, the Malloc=1 debug run, the existing http2 test directory run, and the 256 KB TCP comparison with Node. Both required sections are filled in with specific detail.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Member Author

buildkite/bun #132287 is red because of one job, and that job ran no tests.

  • Job: :darwin: any aarch64 - test-bun, shard 2 of 2, on agent darwin-aarch64-26.6.1-1.
  • Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun', thrown by getExecPathFromBuildkite in scripts/runner.node.ts.
  • Cause: the first zip (39 MiB) took 46 s to download and the second did not finish in time. The job exited 2 minutes after it started, before the first test file.

This is a slow download on that agent, not something in this diff, so there is nothing to fix here. The other darwin aarch64 shard got its binary and is running.

I can't retry BuildKite jobs from here. Could someone with access retry that one job?


Generated by Claude Code

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked two things in the new displaced-callback plumbing: no_trailers dispatching while its &mut Stream binding is still in scope is not an aliasing problem in practice, since nothing touches that binding after send_data returns and the dispatch goes through enter_dispatch; and the chunked path keeping only the first slice's displaced value is sound on a native socket, because no JS runs between slices so later slices can only land on frames this write queued with an UNDEFINED callback.

Extended reasoning...

The change moves the displaced write-callback dispatch out of Stream::queue_frame into the two send_data callers in src/runtime/api/bun/h2_frame_parser.rs, deletes the black_box/raw-pointer scaffolding, and adds a native-parser test in test/js/node/http2/node-http2.test.js. It touches unsafe lifetime handling around a borrowed JS ArrayBuffer in the HTTP/2 writer, which is memory-safety-sensitive native code. One confirmed finding on END_STREAM being cleared by a re-entrant write from the displaced callback is posted inline, so a human look is already signalled; this note only records what else was examined.

Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
A non-closing write that landed behind a queued closing write cleared the
queued END_STREAM, or was queued after the frame that carried it. The peer
then never saw the stream end, or got DATA after END_STREAM.

Calling the displaced callback after the payload is queued made a write
from that callback one such write. queue_frame() now moves the flag to
whichever frame ends up last.

Also drop the payload borrow before the displaced callback runs and mark
SentData as must_use.

@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.

I re-reviewed after 1324cde and didn't find any bugs; since this rewrites the re-entrancy handling in the native http2 frame parser and picks a semantics for writes that arrive behind a queued closing frame, a human look would still be worthwhile.

What was reviewed:

  • queue_frame returning the displaced callback: traced the cork/partial-cork/recursion paths — the old callback stays on its own frame when the new write spills into a new frame, and only the first queued slice in send_data can displace a pre-existing callback, so later displaced values are from this write's own frames (empty/undefined, and call_write_callback no-ops on non-callables).
  • Callers: write_stream drops payload/buffer/stream before dispatching, and no_trailers dispatches after send_data; both are the only call sites of queue_frame/send_data. Callback order (displaced before the new write's callback) matches the old inline dispatch.
  • 1324cde (end_stream |= take(last_frame.end_stream)): END_STREAM now migrates onto whatever frame ends up last, so a non-closing write made behind a queued closing write is sent as part of the stream rather than leaving it unended or being rejected — a design choice worth a maintainer's confirmation. The PR description's "Not changed: ... clears the queued END_STREAM" paragraph now describes the pre-1324cde9 behavior.
  • Test: six modes over a real TCP socket, awaits dataSeen, asserts exact callback order, byte diff and END_STREAM-on-last-frame; covers both the UAF modes and the END_STREAM change.
Extended reasoning...

The change touches src/runtime/api/bun/h2_frame_parser.rs (Stream::queue_frame, H2FrameParser::send_data, write_stream, no_trailers) and adds one test in test/js/node/http2/node-http2.test.js. It removes the black_box/raw-pointer dispatch of a displaced write callback from inside queue_frame and defers that dispatch to the host-fn callers after all borrows end, fixing a use-after-free reachable by direct users of the native parser; the second commit moves a queued END_STREAM flag to the last queued frame, which addresses the inline finding from the prior review. No security-sensitive surface (auth, crypto, injection) is involved. The bug hunt ran dry and no CODEOWNER covers these paths, but the code is unsafe-adjacent native protocol code and the END_STREAM migration is a user-visible semantics choice, so a human confirmation is preferable to an automated approve.

Copy link
Copy Markdown
Member Author

buildkite/bun #132298 (1324cde) is red because of one job. The other 178 finished jobs passed.

Job: :darwin: any aarch64 - test-bun, shard 2 of 2, on agent darwin-aarch64-26.6.1-1. It hit the job time limit after 46 minutes. That is the same agent whose artifact download timed out in #132287.

What failed on it, all 4 attempts each:

  • next-pages/test/dev-server.test.ts and dev-server-ssr-100.test.ts: bun install was killed when the test timed out (150 s and 100 s).
  • test/js/bun/http/serve.test.ts: "1000 uploads & downloads in batches of 64 do not leak ReadableStream > direct" timed out at 100 s. It uses Bun.serve and fetch over HTTP/1, not the node:http2 parser.

Why this is the agent and not this diff:

  • test/js/node/http2/node-http2.test.js, the file this PR changes, passed on that same job: 419 pass, 0 fail.
  • In builds #132262 to #132318, that agent finished 15 darwin aarch64 test jobs and failed 8, on 7 different branches. Six died in the artifact download and two hit the job time limit (#132265, on another branch, is the other).
  • Every other darwin aarch64 agent together: 48 passed, 1 failed.

There is nothing to fix in this PR, and I can't retry BuildKite jobs from here. Could someone with access retry that job, ideally on another agent? The agent itself may need a look.

The two :darwin: any x64 - test-bun jobs have not been given an agent yet.


Generated by Claude Code

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants