Skip to content

node:http2: don't hold a thread-local borrow across the padded DATA write - #36917

Merged
Jarred-Sumner merged 7 commits into
mainfrom
farm/fce47b92/h2-padded-write-refcell
Aug 5, 2026
Merged

node:http2: don't hold a thread-local borrow across the padded DATA write#36917
Jarred-Sumner merged 7 commits into
mainfrom
farm/fce47b92/h2-padded-write-refcell

Conversation

@robobun

@robobun robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What

node:http2 over a user-supplied Duplex transport (createConnection) with paddingStrategy enabled: writing to a second stream from inside the transport's _write aborts the process with panic: RefCell already borrowed.

Reproduction

import http2 from "node:http2";
import { Duplex } from "node:stream";
let req2, armed = false, fired = 0;
const duplex = new Duplex({
  read() {},
  write(chunk, enc, cb) { if (armed && fired++ === 0) req2.write(new Uint8Array(3000).fill(0x42)); cb(); },
  final(cb) { cb(); },
});
const session = http2.connect("http://localhost:1", { createConnection: () => duplex, paddingStrategy: http2.constants.PADDING_STRATEGY_MAX });
session.on("error", () => {});
await new Promise(r => session.once("connect", r));
const f = (t, fl, sid, p) => { const h = Buffer.alloc(9); h.writeUIntBE(p.length, 0, 3); h[3] = t; h[4] = fl; h.writeUInt32BE(sid, 5); return Buffer.concat([h, p]); };
const set = Buffer.alloc(6); set.writeUInt16BE(4, 0); set.writeUInt32BE(0x7fffffff, 2);
const wu = Buffer.alloc(4); wu.writeUInt32BE(0x70000000, 0);
duplex.push(Buffer.concat([f(4, 0, 0, set), f(8, 0, 0, wu), f(4, 1, 0, Buffer.alloc(0))])); // server preface by hand
await new Promise(r => setTimeout(r, 50));
req2 = session.request({ ":method": "POST", ":path": "/side" }, { endStream: false }); req2.on("error", () => {});
const req = session.request({ ":method": "POST", ":path": "/" }, { endStream: false }); req.on("error", () => {});
await new Promise(r => setTimeout(r, 30));
const pad = session.request({ ":method": "POST", ":path": "/pad", "x-pad": "q".repeat(15000) }, { endStream: false }); pad.on("error", () => {}); // ~13 KB corked HEADERS
armed = true;
req.write(new Uint8Array(12000).fill(0x41));   // padded DATA (12256+9) crosses the 16 KiB cork -> flush -> duplex.write() -> req2.write()
await new Promise(r => setTimeout(r, 100));
console.log("no crash");

Before: panic: RefCell already borrowed / "oh no: Bun has crashed", exit 134, every run (core::cell::panic_already_borrowed <- H2FrameParser::send_data <- write_stream). Node v26.3.0 prints no crash.

Cause

send_data's padded single-frame branch (and the two padded branches in Stream::flush_queue) built the payload inside SHARED_REQUEST_BUFFER.with_borrow_mut(|buffer| { ...; writer.write_all(&buffer[..payload_size]) }), so the write_all ran inside the borrow. When the frame crosses the cork boundary, write() -> flush_cork_buffer() -> _write() -> onWrite runs the Duplex _write (user JS) with the thread-local still mutably borrowed; the nested req2.write() -> send_data borrows it again and panics. Native TCP/TLS transports never run JS from _write, so only JS-backed transports are affected.

The rest of the write path already follows the rule that no thread-local borrow is held across _write (uncork, flush_batch_buffer, flush_cork_buffer move their Vec out first); these three sites predate it.

Fix

Add DirectWriterStruct::write_padded(data, padding) and use it at all three sites. The scratch stays shared and reusable, but it now lives in the VM's RareData (per review: a thread-local static is wrong with worker_threads; per-VM is the right scope). write_padded takes the buffer out of its rare_data slot by value for the duration of the write, so nothing is borrowed across write(): a re-entrant padded write finds the slot empty, allocates its own buffer, and the slot keeps one buffer for reuse when they return. SHARED_REQUEST_BUFFER is removed, along with the three unsafe { ptr::copy } blocks.

Frame ordering on the wire when a JS transport re-enters the session mid-frame is unchanged by this PR (it behaves like the unpadded path does today, see #36918 for that); this only removes the abort and keeps each frame's own bytes intact.

Verification

New test in test/js/node/http2/node-http2.test.js: three padded DATA writes in one tick over a JS Duplex (a corked fill frame, an outer frame that crosses the cork boundary, and a side-stream write issued from inside the transport's _write during that flush), then counts the payload bytes that reach the transport. Expected { reentered: true, total: 28795, A: 12000, B: 3000, C: 13000 }, which is also what node v26.3.0 produces for the same script.

  • before (release and debug+ASAN): subprocess aborts with panic: RefCell already borrowed, test fails
  • after: passes; full node-http2.test.js: 311 pass, 6 skip, 0 fail

Related: #36905 (merged) and #36910 cover the borrowed-payload side of the same re-entrant Duplex write path; this one is independent of both (the padded path already copied its payload, the problem was the held borrow).


[review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.0 (110748b30)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [762.93ms]
(pass) node none > Client Basics > should be able to send a POST request [526.52ms]
(pass) node none > Client Basics > constants [19.71ms]
(pass) node none > Client Basics > getDefaultSettings [7.26ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.03ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [4.89ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.46ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.85ms]
(pass) node none > Client Basics > should be able to send data using end [558.09ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [549.02ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release without fix: 2 failed, 6 skipped
bun test v1.4.0-canary.1 (b66764ff3)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.85ms]
(pass) node none > Client Basics > getDefaultSettings [0.15ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.40ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.10ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.04ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.06ms]
(pass) node none > Client Basics > is possible to abort request [3.70ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.83ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.75ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.18ms]
(pass) node none > Client Basics > headers cannot be bigger than 65536 bytes [57.33ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > close callback [53
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.0 (110748b30)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1184.19ms]
(pass) node none > Client Basics > should be able to send a POST request [774.97ms]
(pass) node none > Client Basics > constants [31.96ms]
(pass) node none > Client Basics > getDefaultSettings [11.95ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [33.02ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [4.86ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.52ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.46ms]
(pass) node none > Client Basics > should be able to send data using end [827.20ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [806.11ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receivin
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 930ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/124] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/124] gen cpp.rs (cppbind)
[2/124] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_alloc v0.0.0 (/workspace/bun/src/bun_alloc)
�[1m�[92m   Compiling�[0m bun_libdeflate_sys v0.0.0 (/workspace/bun/src/libdeflate_sys)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/sr
... (truncated)
diff hotspot
src/jsc/rare_data.rs                   | 27 ++++++++++--
 src/runtime/api/bun/h2_frame_parser.rs | 75 ++++++++++++++--------------------
 test/js/node/http2/node-http2.test.js  | 72 ++++++++++++++++++++++++++++++++
 3 files changed, 127 insertions(+), 47 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/jsc/rare_data.rs                        4      8      0
src/runtime/api/bun/h2_frame_parser.rs     11     13      0
test/js/node/http2/node-http2.test.js       3      5      0

…rite

send_data() and Stream::flush_queue() assembled a PADDED DATA payload inside
SHARED_REQUEST_BUFFER.with_borrow_mut() and called write_all() from inside
the closure. write() can flush the cork, and over a JS-backed transport that
re-enters JS (onWrite -> Duplex _write). A transport that writes to another
padded stream from its _write reached send_data() again with the RefCell
still mutably borrowed and the process aborted with
'panic: RefCell already borrowed'.

Move the scratch out of its thread-local slot for the duration of the write
(the same take/put-back idiom uncork()/flush_batch_buffer() use), so a nested
padded write gets its own buffer and can neither trip the borrow nor
overwrite bytes the outer write() is still copying into the cork.
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on canary b66764f and current main (exit 134, panic: RefCell already borrowed from H2FrameParser::send_data) with the script in the PR body. The new test fails on the unfixed build and passes on this branch (debug+ASAN); full node-http2.test.js is green locally. Waiting on CI.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a053cb47-1ab1-4a42-bb5d-3b030eec1e0c

📥 Commits

Reviewing files that changed from the base of the PR and between 4fde01f and be7f3b4.

📒 Files selected for processing (2)
  • src/jsc/rare_data.rs
  • src/runtime/api/bun/h2_frame_parser.rs

Walkthrough

HTTP/2 padded DATA serialization now builds an owned payload for each write. Partial, full, and single-frame paths use this logic. A subprocess test covers nested writes through a JavaScript Duplex transport.

Changes

HTTP/2 padded DATA writes

Layer / File(s) Summary
Owned padded DATA serialization
src/runtime/api/bun/h2_frame_parser.rs
DirectWriterStruct::write_padded builds the pad length, data, and zero padding in an owned buffer. All padded DATA paths use the helper.
Re-entrant transport regression coverage
test/js/node/http2/node-http2.test.js
The subprocess test triggers nested maximum-padded DATA writes and verifies process survival, wire size, errors, and payload byte counts.

Possibly related PRs

  • oven-sh/bun#33191: This PR also addresses re-entrant HTTP/2 payload writes.
  • oven-sh/bun#36910: This PR also updates padded HTTP/2 DATA serialization for JavaScript transport re-entrancy.
  • oven-sh/bun#36918: This PR also changes HTTP/2 JavaScript transport re-entrancy handling.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the node:http2 crash fix caused by holding a thread-local borrow during padded DATA writes.
Description check ✅ Passed The description explains the crash, cause, fix, reproduction, regression test, and verification results, despite using different section headings.

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 `@test/js/node/http2/node-http2.test.js`:
- Line 2024: Update the HTTP/2 fixture’s session and stream error handlers near
session.on("error") and the corresponding stream handler to route errors into
the completion promise’s rejection path instead of discarding them. Ensure the
success path around the final completion reporting only runs when no error has
occurred, so any error before completion produces a failing test result.
- Around line 2026-2051: The HTTP/2 test must validate actual outbound frame
ordering and structure rather than relying on scheduler turns and aggregate byte
counts. Update the fixture around the frame setup, `armed`, and `outer.write` to
buffer transport output and await an explicit headers-drained condition before
arming; then decode the buffered DATA frames and assert each frame’s stream ID,
PADDED flag, declared length, pad length, zero-filled padding, and expected
A/B/C payload bytes while preserving the `reentered` assertion.
🪄 Autofix

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: 09156521-4598-438a-8fa1-831eacc618b4

📥 Commits

Reviewing files that changed from the base of the PR and between 1bfcb4e and f5c28aa.

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

Comment thread test/js/node/http2/node-http2.test.js Outdated
Comment thread test/js/node/http2/node-http2.test.js

@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 — clean fix for the re-entrant padded DATA panic, following the same take/put-back pattern the rest of the write path already uses.

What was reviewed:

  • Traced payload_size bounds at all three call sites: get_padding clamps to max_len ≤ MAX_PAYLOAD_SIZE_WITHOUT_FRAME - 1, so 1 + data.len() + padding ≤ 16374 < H2_CORK_BUFFER_SIZE — the safe copy_from_slice cannot panic.
  • Verified the Cell<Option<Box<[u8]>>>::take() re-entrancy handling: outer call owns its buffer across write_all, nested call allocates fresh; the final .set() after both return drops one and retains one, no leak.
  • Confirmed SHARED_REQUEST_BUFFER had no other users; the old ptr::copy "may overlap" comment was spurious (sources never aliased the thread-local), so switching to safe copy_from_slice is a strict improvement.
  • Test is event-driven (awaits byte-count completion, no sleeps), drains pipes concurrently, and asserts byte-exact payload counts matching Node — covers both the abort and the buffer-clobber hazard.
Extended reasoning...

Overview

This PR fixes a process abort (panic: RefCell already borrowed) in node:http2 when a padded DATA write over a JS-backed Duplex transport crosses the 16 KiB cork boundary and the transport's _write re-enters send_data. Three copy-pasted padded-write blocks in h2_frame_parser.rs (two in Stream::flush_queue, one in H2FrameParser::send_data) are replaced with a single DirectWriterStruct::write_padded helper. The thread-local scratch is changed from RefCell<Box<[u8; 16384]>> (borrowed across the write) to Cell<Option<Box<[u8]>>> (moved out for the duration), so a nested padded write allocates its own buffer instead of tripping a held borrow or overwriting the outer frame's bytes mid-copy. A subprocess test in node-http2.test.js reproduces the re-entrant scenario and asserts byte-exact payload counts matching Node.

Security risks

None. This is internal buffer management for an outbound HTTP/2 frame path; no untrusted input parsing is touched. The change removes three unsafe { ptr::copy } blocks in favor of safe slice ops. Buffer indexing is bounded: get_padding(size, max_size - 1) guarantees padding ≤ max_size - 1 - size, so payload_size = 1 + size + padding ≤ max_size ≤ MAX_PAYLOAD_SIZE_WITHOUT_FRAME = 16374 < 16384 at every call site, and the buffer is 16384 bytes (same as before).

Level of scrutiny

Medium. h2_frame_parser.rs is core protocol code, but this change is narrowly scoped to one bug class already being addressed in this file (#36905 merged, #36910 open — same re-entrant JS-Duplex write path). The fix follows the exact pattern the PR body notes is already established for uncork/flush_batch_buffer/flush_cork_buffer ("move the Vec out first"), and the helper lives on DirectWriterStruct next to write_all, which is the right layer. The take/put-back idiom is standard Rust for this re-entrancy shape; on nested return the inner buffer is dropped and the outer is retained — no leak, no double-free.

Other factors

The test is well-constructed per the repo's review rules: spawned subprocess with bunEnv, pipes drained via Promise.all, stderr asserted before exitCode, awaits the observable condition (total >= EXPECTED_WIRE) rather than sleeping, and asserts the strongest invariant — exact per-byte-value counts ({A: 12000, B: 3000, C: 13000}) that would detect the outer buffer being clobbered by the nested write, not just the absence of a crash. The PR body confirms the test fails on the unfixed build. The change also deduplicates ~36 lines into one helper and deletes three unsafe blocks, which is the shape the review guide asks for ("the second time a multi-line block appears in your diff, extract a named helper").

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We shouldn't use static for any of these, there can be multiple threads due to worker_threads.

…all buffer

Also make the test fixture exit non-zero on any session/stream 'error' and
only issue the nested write from the chunk that carries the outer frame's
payload (the mid-frame cork flush).
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated

@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 didn't find any bugs. The three padded-DATA sites now emit the same bytes as before (payload_size == 1 + data.len() + padding at each caller matches what write_padded produces), SHARED_REQUEST_BUFFER has no remaining references, and three unsafe blocks are gone. Deferring to a human because the second commit swaps a reused thread-local scratch for a per-frame Vec allocation on the padded path — probably fine given padding is opt-in and flush_queue already .to_vec()s nearby, but worth a maintainer nod. Note the PR body still describes the abandoned Cell<Option<Box<[u8]>>> take/put-back approach from the first commit; the two comment-cop notes fired ~18s after 5fc4e38 landed, so they're on the current 3-line invariant comment — reads like a false positive to me, but flagging it since it's technically unresolved.

Extended reasoning...

Overview

Fixes a RefCell already borrowed panic in node:http2 when a padded DATA write over a JS-backed Duplex transport re-enters send_data from inside the transport's _write. The three call sites in Stream::flush_queue (×2) and H2FrameParser::send_data that previously assembled the padded payload inside SHARED_REQUEST_BUFFER.with_borrow_mut(|b| { ...; writer.write_all(...) }) now call a new DirectWriterStruct::write_padded(data, padding) helper that builds the payload in a per-call Vec and holds no borrow across write_all. The thread-local is removed entirely. A subprocess test in node-http2.test.js reproduces the re-entrancy and asserts the exact wire byte total and per-stream payload counts that Node produces.

Correctness verified

At all three call sites payload_size = data.len() + padding as usize + 1 when padding != 0 (h2_frame_parser.rs:1708, 1757, 7250), and the frame header's length is set to that value. write_padded emits [padding, ...data, ...zeros(padding)], i.e. exactly 1 + data.len() + padding bytes — byte-identical to the old buffer[0..payload_size] write, so the wire format is unchanged. Grep confirms no remaining references to SHARED_REQUEST_BUFFER. The old ptr::copy overlap concern is moot since the destination is a fresh Vec.

Security risks

None identified. The change removes unsafe code and a shared-mutable-state hazard; padding bytes are still zero-filled; frame lengths are unchanged.

Level of scrutiny

Medium-high. HTTP/2 framing is protocol-critical, but the diff is a mechanical consolidation of three identical blocks into one helper with equivalent output. The interesting decision is the second commit (5fc4e38): it drops buffer reuse in favor of a per-frame allocation. That's on an opt-in path (paddingStrategy) and adjacent code already allocates (able_to_send = ....to_vec() at :1700), so it's likely acceptable — but it's a perf/simplicity tradeoff a maintainer should acknowledge rather than a bot.

Other factors

  • PR description is stale: it describes a Cell<Option<Box<[u8]>>> take/put-back scheme, but the shipped code (after 5fc4e38) uses a plain per-call Vec.
  • comment-cop fired at 22:00:10Z on lines 6145/6149; commit 5fc4e38 landed at 21:59:52Z, so those flags are on the current code. The flagged comment is a 3-line note explaining why no shared scratch may be held across write() — that's a load-bearing invariant, not a workaround justification, so I read it as a heuristic false positive.
  • CodeRabbit's "fail on error events" note appears already addressed in the current diff (fail(...)process.exit(1)); its "validate frame structure" suggestion would strengthen the test but the existing exact-byte-count assertion (matching Node) is a reasonable invariant for what this PR fixes.
  • robobun confirms fails-before/passes-after on debug+ASAN with the full suite green.

Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Dropped it. write_padded now assembles [pad length | data | zero padding] in a Vec the call owns and writes that, so this path has no static or thread-local state at all (and SHARED_REQUEST_BUFFER is gone). The allocation only happens on the opt-in padding path, for single-frame writes and queued-frame drains; the multi-frame batch path already builds padded frames into its own Vec.

For what it's worth, the slot I had added in the first commit lived inside the existing thread_local! block next to CORK_BUFFER/BATCH_BUFFER, so each worker thread had its own; a per-call buffer is simpler and sidesteps the question. The cork/batch buffers in that block predate this PR and are still per-thread. If you would rather have those hang off the parser too, I can do that as a separate change.

Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/runtime/api/bun/h2_frame_parser.rs
Per review: a shared reusable buffer is right, but it belongs to the VM
(worker_threads means multiple JS threads), not a static. write_padded takes
the buffer out of rare_data by value for the duration of the write, so the
re-entrant case allocates its own instead of aliasing the outer one, and the
slot keeps one buffer for reuse.
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Moved the scratch into the VM's RareData (18b1b6a): shared and reused as before, but scoped per VM so each worker thread has its own. write_padded takes the buffer out of the rare_data slot by value for the duration of the write; a re-entrant padded write finds the slot empty and allocates its own, and the slot keeps one buffer for reuse. No thread-local statics on this path anymore. Re-verified: repro clean 3/3, fail-before still red on the unfixed build, full node-http2.test.js 311 pass / 0 fail (debug+ASAN).

Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/jsc/rare_data.rs
Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/jsc/rare_data.rs

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

No bugs found; all earlier nits (stale TLS comment, PR-description drift, doc-comment consolidation) are addressed. Deferring rather than approving because the fix places new per-VM state in RareData and relies on take-by-value semantics across a JS re-entry point in the h2 write path — sound as far as I can tell, but the design iterated three times during review and a maintainer should sign off on where the scratch lives.

What was reviewed:

  • write_padded holds no borrow across write_all; a re-entrant call finds the slot empty and boxed_zeroeds its own; put_back via get_or_insert correctly drops the second-returned buffer.
  • Payload bound: all three call sites cap data.len() and padding via MAX_PAYLOAD_SIZE_WITHOUT_FRAME, so 1 + data.len() + padding ≤ 16374 < 16384 — the fixed buffer cannot overflow.
  • The three removed unsafe { ptr::copy } blocks are behavior-equivalent to copy_from_slice + fill(0) for these bounds; the new path is strictly safer.
Extended reasoning...

Overview

Fixes a RefCell already borrowed panic in node:http2 when a JS-backed Duplex transport (via createConnection) re-enters the padded DATA send path from inside its own _write. Three sites in h2_frame_parser.rs (Stream::flush_queue × 2, H2FrameParser::send_data) previously assembled the padded payload inside SHARED_REQUEST_BUFFER.with_borrow_mut(|b| { …; writer.write_all(&b[..]) }), so the thread-local RefCell was still mutably borrowed when write_all → cork flush → onWrite ran user JS that called back into send_data.

The fix removes SHARED_REQUEST_BUFFER, adds an Option<Box<[u8; 16384]>> slot to RareData with take_/put_back_ accessors, and adds DirectWriterStruct::write_padded that takes the buffer by value, writes, then puts it back — so nothing is borrowed across the write and a nested caller allocates its own. Also updates the stale "32 KB combined" TLS comment and adds a subprocess regression test that fails-before / passes-after (verified in the PR evidence block).

Security risks

None identified. This is internal buffer management on the outbound path; no untrusted input parsing changes. The removed unsafe { ptr::copy } blocks are replaced with checked copy_from_slice, so bounds handling is stricter than before.

Level of scrutiny

Medium-high. The h2 outbound write path is production-critical for node:http2 compat, and the change threads through a JS re-entry point (write_all can synchronously run user JS over a Duplex transport). I verified: (1) the take-by-value pattern means no &mut or RefCell borrow spans the re-entry; (2) bun_vm().as_mut().rare_data() is called once before and once after write_all, never held across it, matching the established single-JS-thread escape-hatch pattern in this file and rare_data.rs; (3) payload_size is bounded by MAX_PAYLOAD_SIZE_WITHOUT_FRAME = 16374 at every call site (get_padding returns min(255, max_len - frame_len)), so the 16 KB fixed buffer cannot be overrun; (4) put_back uses get_or_insert, so when the outer call returns after a nested one already put its buffer back, the outer's buffer drops harmlessly.

Other factors

The design iterated three times during review (thread-local Cell<Option<Box>> → per-call VecRareData take/put). The final placement follows the existing temp_pipe_read_buffer pattern in RareData and is well-scoped, but adding per-VM state and choosing shared-scratch-vs-per-call-alloc is the kind of tradeoff a maintainer typically confirms. All prior review threads (CodeRabbit, comment-cop, my earlier nits) are resolved. The regression test is subprocess-based, drains stdout/stderr/exited concurrently, uses bunEnv, and asserts an exact node-matched {reentered, total, A, B, C} object rather than a loose match; frame-ordering under re-entrancy is explicitly out of scope (#36918).

@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 — the take-by-value RareData scratch cleanly removes the held RefCell borrow across the re-entrant write, and my earlier notes (stale PT_TLS comment, description sync, comment consolidation) are all addressed.

What was reviewed

  • write_padded: buffer is moved out of the slot before write_all, so the re-entrant path finds None and allocates its own; no borrow held across the JS-reachable write.
  • Buffer sizing: H2PaddedFrameBuffer = [u8; 16384] matches the removed SHARED_REQUEST_BUFFER; payload_size bounds at all three call sites are unchanged (capped by MAX_PAYLOAD_SIZE_WITHOUT_FRAME / get_padding), so no new panic surface.
  • RareData addition mirrors the existing temp_pipe_read_buffer pattern (Option<Box<[u8; N]>>, boxed_zeroed, auto-dropped); put_back keeping the first-returned buffer is fine — the extra one just drops.
  • Three unsafe { ptr::copy } blocks replaced with safe copy_from_slice; SHARED_REQUEST_BUFFER fully removed (no remaining references).
Extended reasoning...

Overview

The PR fixes a reproducible process abort (panic: RefCell already borrowed, exit 134) in node:http2 when a client uses paddingStrategy over a user-supplied JS Duplex transport whose _write re-enters the session by writing to another stream. Three padded-DATA write sites in h2_frame_parser.rs previously called writer.write_all(...) from inside SHARED_REQUEST_BUFFER.with_borrow_mut(...); when that write crossed the cork boundary and flushed into the JS transport, the nested send_data tried to borrow the same thread-local RefCell again and panicked.

The fix adds DirectWriterStruct::write_padded, which takes a 16 KiB scratch buffer by value from a new per-VM RareData::h2_padded_frame_buffer slot, assembles [pad-length | data | zero padding], calls write_all, then hands the buffer back. A re-entrant call finds the slot empty and allocates its own via boxed_zeroed. SHARED_REQUEST_BUFFER is removed along with three unsafe ptr::copy blocks. The stale "32 KB combined" comment on the thread_local! block is corrected. A subprocess regression test in node-http2.test.js reproduces the re-entry over a fake Duplex, asserts the exact byte totals node produces, and is gate-verified to fail before / pass after.

Security risks

None. This is internal buffer plumbing on an outbound write path; no untrusted-input parsing, auth, or crypto is touched. The buffer size and indexing bounds are identical to the code being replaced.

Level of scrutiny

Moderate. The change touches a native re-entrancy path where JS can run mid-write, which is exactly the class REVIEW.md warns about ("anything that can run user JS can synchronously free your state"). The fix applies the correct pattern for that class: move the shared state out by value before the call that can re-enter, so nothing is borrowed or aliased across it. The RareData placement follows the repo rule ("Per-VM state goes on VirtualMachine/RareData, never process globals or thread-locals") and mirrors the neighboring temp_pipe_read_buffer slot exactly — same Option<Box<[u8; N]>> shape, same boxed_zeroed lazy init, same automatic Drop.

I checked that the &mut RareData obtained via bun_vm().as_mut().rare_data() is a temporary that drops after take_h2_padded_frame_buffer() returns and before write_all runs, so the re-entrant call's own &mut RareData doesn't stack-borrows-alias. The put_back semantics (get_or_insert, drop the second buffer) are correct for the nested-return case and documented.

Other factors

  • The gate output confirms fail-before (both release and debug+ASAN abort) and pass-after (311 pass / 0 fail on the full node-http2.test.js).
  • All prior review threads are resolved: coderabbit's concern about tying reentered to the outer flush was addressed by the chunk.includes(0x41) guard; my three earlier notes (stale PT_TLS comment, PR-description sync, cross-site comment repetition) were fixed in 18b1b6a / be7f3b4; the comment-cop flags were either trimmed or correctly identified as collateral (pre-existing/shortened comments).
  • The payload_size computed in write_padded (1 + data.len() + padding) matches the caller-side payload_size written into the frame header, and the 16384-byte buffer bound is the same as the removed SHARED_REQUEST_BUFFER — no behavior change on the wire beyond removing the abort.
  • No CODEOWNERS cover these paths; no human reviewer has requested changes.

@Jarred-Sumner
Jarred-Sumner merged commit 3179434 into main Aug 5, 2026
54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/fce47b92/h2-padded-write-refcell branch August 5, 2026 06:04
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.

2 participants