http2: resolve BatchSegment to its bytes in one place - #39139
Conversation
|
Warning Review limit reached
Next review available in: 15 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
There was a problem hiding this comment.
LGTM — mechanical extract-method that collapses three identical match BatchSegment sites into one bytes() helper.
What was reviewed:
- Verified each of the three call sites (iovec build, copy fallback, partial-write tail) produces the same pointer/length as before; the tail's
&bytes[skip..]is equivalent to the oldfrom_raw_parts(ptr.add(skip), len - skip). - Checked the lifetime elision on
bytes(self, batch: &[u8]) -> &[u8]— ties the return tobatch, which is strictly shorter than theExtpayload's actual lifetime under the stated safety contract. - The
Batcharm now bounds-checksoff + leninstead of justoff— a strengthening, and the PR explains why it can't fire. - The two extra
mordant-baseline.tomldeletions are accounted for as already-fixed-on-main entries picked up by regeneration.
Extended reasoning...
Overview
This PR deduplicates three copies of the same match BatchSegment { Batch {..} => .., Ext {..} => .. } in H2FrameParser::flush_batch_vectored into a single BatchSegment::bytes() method, and drops the corresponding same_match_twice entry (plus two stale entries) from mordant-baseline.toml. Net diff in the .rs file is roughly -20/+20 lines.
Security risks
None. The change is a pure refactor of how bytes are read from an existing enum; no new inputs are parsed, no bounds are relaxed. The one bounds check that changes gets tighter (the Batch arm now checks off + len against batch.len() via safe slicing, where the original iovec path only checked off). The unsafe surface shrinks: one documented unsafe fn with a SAFETY contract replaces scattered from_raw_parts calls.
Level of scrutiny
Medium — this is HTTP/2 wire-write code with raw pointers, so I traced each of the three sites against its original to confirm byte-for-byte equivalence. All three match: the iovec build's bytes.as_ptr()/bytes.len() equals the old (ptr, len) tuple; the copy fallback was already using the exact slice expression the helper now returns; the tail copy's &bytes[skip..] is the safe-slice spelling of the old from_raw_parts(ptr.add(skip), len - skip). Method resolution on seg.bytes(batch) works via Copy auto-deref (BatchSegment derives Copy). The elided return lifetime ties to batch, which is conservative and correct.
Other factors
The PR description names the existing test that exercises both the iovec and partial-write paths (node-http2-syscall-fault.test.ts faulting writev to return 0 on a 40 KB body), plus the full test/js/node/http2/ suite and eleven upstream Node http2 tests — appropriate coverage for a no-behavior-change refactor. The two extra baseline deletions are explained with the PRs that removed the underlying findings. No prior reviewer comments to address.
There was a problem hiding this comment.
LGTM — mechanical dedup of three identical BatchSegment matches into one unsafe fn bytes(), no behavior change.
Checked that all three call sites resolve to the same pointer/length as before; the Batch arm now bounds-checks off + len (previously only off), and every construction site records off + len == batch.len() into an append-only buffer, so it never fires. The tail-copy &bytes[skip..] is the same range as the old from_raw_parts(ptr.add(skip), len - skip). Lifetime elision ties the returned slice to batch, which is fine under the documented safety contract (only caller runs inside send_data). New tests exercise both the writev path and the writev→0 re-buffer path with an offset-stamped body that would catch any slicing error byte-for-byte.
Extended reasoning...
Overview
Three files touched: src/runtime/api/bun/h2_frame_parser.rs collapses three duplicate match BatchSegment blocks in flush_batch_vectored into a single BatchSegment::bytes(self, batch) helper; mordant-baseline.toml drops the corresponding same_match_twice entry plus two entries already fixed on main; and test/js/node/http2/node-http2-syscall-fault.test.ts gains two test.each cases that POST a 53 KiB offset-stamped body over h2c and verify byte-for-byte receipt, once with writev succeeding and once with writev faulted to 0.
Security risks
None. This is a pure refactor of an internal HTTP/2 send-batch flush path; no new inputs are parsed, no bounds are loosened (the Batch arm's slice indexing is now strictly tighter, checking off + len instead of only off), and the unsafe block for Ext is the same from_raw_parts that was already present at all three call sites, now behind a documented # Safety contract.
Level of scrutiny
Medium — it's hot-path native code with raw pointers, but the change is a textbook extract-method refactor with no control-flow or arithmetic change. I verified: (1) every BatchSegment::Batch construction site records {off, len} such that off + len == batch.len() at recording time and batch is append-only until flush, so the new full-range bounds check never panics; (2) the elided output lifetime binds to batch (since self is by-value), which is sound because the only caller holds both batch and the Ext payload for the whole function; (3) each of the three sites produces the same (ptr, len) pair as before — the iovec build, the copy fallback's extend_from_slice, and the partial-write tail's &bytes[skip..] (equivalent to the old from_raw_parts(ptr.add(skip), len - skip) given skip < len).
Other factors
The comment-cop bot's flag on the doc comment was addressed in a6dcb85 (thread resolved). The new tests follow the file's existing conventions (fault injection, makeServer, once, cleanup in finally), and the offset-stamped body means any mis-sliced segment would show up as a byte mismatch rather than only a length mismatch. The two extra mordant-baseline deletions are documented as already-fixed-on-main entries picked up by the baseline regeneration. The full test/js/node/http2/ suite and eleven upstream Node http2 large-write tests pass per the PR description.
There was a problem hiding this comment.
LGTM — mechanical dedup of three identical BatchSegment matches into one unsafe fn bytes(), no behavior change.
What was reviewed:
- Traced each of the three call sites against the old inline matches: iovec build (
as_ptr()/len()identical), copy fallback (same range, nowoff as usize + len as usizeinstead of(off + len) as usize), and tail copy (&bytes[skip..]replacesfrom_raw_parts(ptr.add(skip), len - skip)). - Checked the
Batcharm's tightened bounds (off..off+len) against everyBatchSegment::Batchpush site insend_data— all recordlen = batch.len() - off, so the range holds. - Verified the elided lifetime on
bytes()ties the return tobatch, which is conservative for theExtarm and sound withinflush_batch_vectored. - New
test.eachcases cover the writev-accepts and writev→0 re-buffer paths byte-for-byte; comment-cop feedback was addressed in a6dcb85.
Extended reasoning...
Overview
This PR extracts three duplicated match BatchSegment blocks in H2FrameParser::flush_batch_vectored into a single BatchSegment::bytes(self, batch: &[u8]) -> &[u8] helper. The three sites (iovec build, socket-detached copy fallback, partial-write tail copy) now all resolve segments through the same code path. The mordant baseline drops the corresponding same_match_twice entry plus two entries already fixed on main. Two new test cases in node-http2-syscall-fault.test.ts POST a 53 KiB body (four DATA frames) over h2c and verify byte-for-byte delivery, once with writev succeeding and once with writev faulted to return 0.
Security risks
None. This is a pure refactor of an internal wire-assembly path; no new inputs are parsed and no trust boundaries change. The unsafe block is the same from_raw_parts on the Ext payload pointer that was already present at all three original sites — it's now written once with a proper # Safety contract and per-call-site // SAFETY: comments naming why the contract holds (segments are consumed inside the send_data call that recorded them).
Level of scrutiny
Medium — this is hot-path HTTP/2 frame emission with raw pointers, so I traced each call site against the removed code line by line. The iovec build produces the same (ptr, len) pair; the copy fallback produces the same slice (with the minor improvement that the addition now happens in usize rather than u32); the tail copy's &bytes[skip..] is exactly from_raw_parts(ptr.add(skip), len - skip). The Batch arm now bounds-checks off + len (previously only off was checked in the iovec loop); I verified every push site records len as batch.len() - off on the same append-only buffer, so this cannot fire. The elided return lifetime ties to batch, which is conservative for the Ext arm (it actually points elsewhere) but sound because both allocations outlive flush_batch_vectored.
Other factors
The comment-cop bot flagged an over-long doc comment; the author trimmed it in a6dcb85 to just the # Safety contract, and that thread is resolved. The new tests follow harness conventions (test.each, using for the server, try/finally for fault cleanup, offset-encoded body so mis-sliced bytes fail on content not just length). The PR description documents that the copy-fallback branch (socket detached mid-call) has no test — reasonable given it's not reachable via fault injection. No CODEOWNERS entry covers this file. The full test/js/node/http2/ suite and eleven upstream Node http2 large-write tests were run against the debug build.
|
CI state: every lane that ran passed on all three builds of this branch, most recently 177 of 179 jobs on the build for 458d91f. The two remaining jobs are always the darwin 14 aarch64 test lane, which is working through a multi-hour backlog: on each build they never got an agent, and the build was canceled while they waited. The only real reds were on the first build and unrelated to this change (an Azure agent that failed to provision for the Windows x64 baseline check, and a 30s timeout in test/js/web/timers/setInterval.test.js on the x64 ASAN lane, which passed on the two later builds). Nothing here touches the code in this PR; the new http2 tests passed on every lane that ran. |
There was a problem hiding this comment.
LGTM — mechanical extract-method refactor with byte-exact test coverage.
What was reviewed:
- Traced all three call sites: iovec build, copy fallback, and partial-write tail each produce the same
(ptr, len)as before; theBatcharm now bounds-checksoff+len(previously onlyoffin two sites), and the tail copy uses safe&bytes[skip..]instead offrom_raw_parts(ptr.add(skip), ..). BatchSegmentisCopy, soseg.bytes(batch)on&BatchSegmentcompiles via auto-deref; the elided output lifetime binds tobatch, which outlives every use.- New
test.eachcases: freshchunks/gotBodyper iteration, fault armed after connect and cleared infinally(plus the file-levelafterEach), and the offset-encoded body makes wrong-slice bugs byte-visible. - Both review threads (comment-cop and the
// SAFETY:suggestion) are addressed in the current diff.
Extended reasoning...
Overview
This PR deduplicates three identical match BatchSegment { Batch | Ext } blocks in H2FrameParser::flush_batch_vectored (src/runtime/api/bun/h2_frame_parser.rs) into a single BatchSegment::bytes(self, batch: &[u8]) -> &[u8] helper. It also removes the corresponding same_match_twice mordant baseline entry plus two unrelated entries already fixed on main, and adds two byte-for-byte test cases exercising the writev-accepts and writev-returns-0 paths.
Security risks
None. This is pure internal refactoring of the h2 client's batched-write flush; no user-facing surface, parsing, or trust boundary changes. The refactor is strictly safer than the original: the Batch arm now bounds-checks the full [off, off+len) range (via safe slice indexing) where two of three original sites only checked off, and the partial-write tail replaces from_raw_parts(ptr.add(skip), len - skip) with a safe &bytes[skip..]. The Ext arm's from_raw_parts is unchanged and correctly gated behind an unsafe fn whose SAFETY contract (the recording send_data call is still on the stack) is documented and satisfied at every call site.
Level of scrutiny
Medium — this is a hot path in HTTP/2 DATA frame batching that handles raw pointers into caller-owned payload buffers, so any slicing error would corrupt request bodies on the wire. However the change is a textbook extract-method: I traced each of the three sites against its original and confirmed identical (ptr, len) output for both enum variants. BatchSegment derives Copy so the by-value self receiver works with the for seg in &[BatchSegment] iteration. The off as usize + len as usize addition (usize domain) is also safer than the old copy-fallback's (off + len) as usize (u32 wrap then widen).
Other factors
- The new tests use an offset-encoded 53248-byte body so a mis-sliced segment shows up as wrong bytes, not just wrong length; one case exercises the iovec path, the other faults
writevto 0 to force the re-buffer path. The copy-fallback path (socket detached mid-call) is noted as untestable, which is reasonable. - Both prior review comments (comment-cop's long-comment warning and alii's
// SAFETY:suggestion) are addressed in the head commit — the helper now has a plain// SAFETY:comment rather than a doc comment. - CI passed on all lanes that got agents per the author's status comment.
- No CODEOWNERS entry covers this file.
|
@robobun rebase |
flush_batch_vectored matched on BatchSegment three times (iovec build, copy fallback, partial-write tail) to turn a segment into the bytes it puts on the wire. Move that mapping into BatchSegment::bytes and call it from all three sites, and drop the file's same_match_twice entry from mordant-baseline.toml.
…out writev taking it Covers both loops of flush_batch_vectored that the previous commit rewrote: the iovec build (writev delivers the batch) and the partial-write tail (writev faulted to 0, so the batch is copied into the write buffer and drained on writable). The body stamps each u32 with its offset so a slice taken from the wrong place or with the wrong length is detected, not only a wrong byte count.
458d91f to
8753a0d
Compare
|
Rebased onto current main (8753a0d). The only conflicts were mordant-baseline.toml, where main had already dropped the two stale entries so this PR now removes just the h2_frame_parser line, and #39148's SendDataOptions landing next to the new impl block. Rebuilt and reran test/js/node/http2/ on the rebased tree: 475 pass, 0 fail. |
There was a problem hiding this comment.
LGTM — mechanical dedup of three identical BatchSegment matches into one helper, behavior-preserving.
What was reviewed
- Traced all three call sites: iovec build, copy fallback, and partial-write tail each produce the same
(ptr, len)as before; the only difference isBatchnow bounds-checksoff+len(strictly safer). - Checked lifetime elision on
bytes(self, batch: &[u8]) -> &[u8]: the returned slice is tied tobatch, and every caller consumes it immediately inside thesend_datascope where theExtpayload is alive. - New tests encode byte offsets into the body so a wrong slice changes bytes, not just length; both writev-accepts and writev→0 re-buffer paths are covered.
Extended reasoning...
Overview
This PR collapses three duplicate match *seg { Batch{..} => .., Ext{..} => .. } blocks in H2FrameParser::flush_batch_vectored into a single BatchSegment::bytes(self, batch) -> &[u8] helper, removes the corresponding same_match_twice entry from mordant-baseline.toml, and adds two byte-exact tests to node-http2-syscall-fault.test.ts covering the iovec and re-buffer paths.
Security risks
None. No new unsafe operations are introduced — the from_raw_parts on the Ext arm is the same call that was inlined at each site before, now behind an unsafe fn with a SAFETY contract. The Batch arm gains a full off..off+len bounds check where the iovec build previously only checked off, which is a strict tightening.
Level of scrutiny
This is hot-path HTTP/2 write code with raw pointers, so I traced each of the three call sites against the removed code:
- iovec build:
bytes.as_ptr()/bytes.len()produce the same pointer/length pair as the old(batch[off..].as_ptr(), len)and(ptr, len); empty-skip preserved. - copy fallback:
extend_from_slice(seg.bytes(batch))is identical to the two old arms; the old(off + len) as usizevs newoff as usize + len as usizeonly differ on u32 overflow (>4 GiB scratch buffer, not reachable). - partial-write tail:
&bytes[skip..]withskip < bytes.len()is exactlyfrom_raw_parts(ptr.add(skip), len - skip).
The elided output lifetime binds to batch; for Ext that's technically narrower than the true payload lifetime, but every call consumes the slice immediately within the same borrow, and flush_batch_vectored runs inside send_data, so no hazard.
Other factors
BatchSegment is #[derive(Clone, Copy)], so seg.bytes(batch) on a &BatchSegment iterator element compiles via copy. CI passed on all lanes that got agents. A maintainer already reviewed and their only feedback (comment style) was addressed and resolved. The mordant baseline diff is exactly the one line this refactor eliminates.
…op stays as it was Returning a slice made the Batch arm compute and range check off + len on the vectored flush path, which the original inline match did not do. raw_parts() returns exactly what the three matches used to produce and is inlined, so the iovec build and the partial-write tail are the original loops with the match hoisted out; the two places that form a slice keep their SAFETY comments as before.
…ocs already say it
|
Addressed in 6b7e252 (plus e03997d dropping the helper's doc comment): the helper now returns the same (ptr, len) the inline matches produced and is inline(always), so the iovec build and the partial-write tail are the original loops with only the match hoisted out, no added instructions on that path. Rebuilt and reran test/js/node/http2/ (475 pass, 0 fail); the regenerated mordant baseline still differs from main by exactly the one h2_frame_parser line. |
There was a problem hiding this comment.
LGTM — mechanical extraction of three identical BatchSegment matches into one #[inline(always)] helper, with the maintainer's perf feedback already applied.
What was reviewed:
raw_partsreturns the same(ptr, len)the inline matches produced; the iovec build and partial-write tail loops are byte-for-byte the original code with the match hoisted out.- The one semantic delta — the cold copy-fallback's
Batcharm now usesfrom_raw_partsinstead of a checked slice — is sound:Batch { off, len }ranges are recorded from within the samebatchbuffer being flushed. - New tests exercise both the writev path and the writev→0 re-buffer path with offset-as-content bytes;
{ syscall: "writev", action: "zero" }is an existing fault-injection combo used elsewhere in the suite.
Extended reasoning...
Overview
This PR deduplicates three identical match expressions on BatchSegment inside H2FrameParser::flush_batch_vectored (src/runtime/api/bun/h2_frame_parser.rs) into a single #[inline(always)] fn raw_parts(self, batch: &[u8]) -> (*const u8, usize) on the enum. It removes the corresponding same_match_twice entry from mordant-baseline.toml and adds two byte-exact tests to test/js/node/http2/node-http2-syscall-fault.test.ts covering the iovec-build path and the partial-write re-buffer path.
Security risks
None. No new inputs are parsed and no trust boundary is crossed. The only new unsafe surface is that the cold copy-fallback branch (socket detached mid-call) now forms the Batch slice via from_raw_parts(batch[off..].as_ptr(), len) instead of &batch[off..off+len]. This is safe by construction: Batch { off, len } segments are recorded as ranges into the very batch buffer passed here, so off + len <= batch.len(). The Ext arm's from_raw_parts was already present at all three sites before this change. The SAFETY comment states both invariants.
Level of scrutiny
Medium — this is hot-path unsafe Rust in the HTTP/2 send path, which normally warrants a close look. But the change is a pure match-extraction: the helper body is verbatim the arms of the removed matches, takes self by value on a Copy enum (so seg.raw_parts(batch) on &BatchSegment auto-derefs+copies exactly as match *seg did), and is #[inline(always)]. A maintainer (alii) already reviewed two rounds — first requesting the SAFETY-comment form, then flagging that the earlier &[u8]-returning revision added instructions on the hot path; the final (ptr, len) shape is precisely what was asked for, and all their inline threads are marked resolved.
Other factors
The two new test.each cases follow the file's existing conventions (makeServer with using, once for events, fault.clear() in finally) and use offset-as-content bytes so a wrong slice base or length would show as a byte mismatch, not just a length mismatch. The { syscall: "writev", action: "zero" } fault is already used in test/js/bun/net/tls-low-prio-queue-fixture.ts, so it's a known-supported combination. The PR description reports bun bd test test/js/node/http2/ passing 475/0 on the rebased tree and clean clippy/mordant. CI passed on all lanes that ran per the timeline. The bug-hunting system found nothing.
…austed (#39183) ### Problem - The `Comment Cop` check is red on every claude-labeled PR whenever the repo's GraphQL quota is used up. The step dies before it scans anything: ``` GraphqlResponseError: Request failed due to following response errors: - API rate limit already exceeded for site ID installation. ##[error]Unhandled error: GraphqlResponseError: ... ``` with `x-ratelimit-resource: graphql`, `x-ratelimit-limit: 10000`, `x-ratelimit-remaining: 0` (run [31898901053](https://github.com/oven-sh/bun/actions/runs/31898901053) on #39139). All 33 comment-cop failures on Aug 15 are this error (197 runs succeeded, 171 were skipped); they hit unrelated PRs at the same time because the quota is shared by every workflow run in the repo, and they come back whenever PR volume is high. - Cause, `.github/workflows/comment-cop.yml:124` on main: the step reads the PR's existing review threads with `github.graphql()`, outside any `try`, and dedup and posting both sit behind that call. The REST call just before it (`pulls.listFiles`) had succeeded in each failing run, so the step had the diff; it only lacked the dedup data, which it was reading from the exhausted quota. - The same thread data also fed an auto-resolve of stale threads. That part does not work: under the Actions `GITHUB_TOKEN` every `resolveReviewThread` mutation fails with `Resource not accessible by integration`, after which the step still logs `Resolved N stale comment-cop thread(s).` Two successful runs from today: [31900717034](https://github.com/oven-sh/bun/actions/runs/31900717034) (20 attempted, 20 failed, "Resolved 20") and [31900354924](https://github.com/oven-sh/bun/actions/runs/31900354924) (4 attempted, 4 failed, "Resolved 4"). #36959 documents the same thing and is the PR that moves resolution to a token that can do it. So the GraphQL query was paying for one thing REST can provide and one thing that does not happen. ### Fix - Dedup reads the PR's review comments with `pulls.listReviewComments` (REST) and collects the `<!-- comment-cop:KEY -->` markers from them. Every comment the step posts starts with that marker, so the review comments carry the same keys the thread roots did; REST is the quota the step already needs for `listFiles` and `createReviewComment`, and it was available in every failing run. - The GraphQL query and the resolve loop are removed, so the step makes no GraphQL request at all; the failure in the Problem section cannot happen, rather than being caught. Nothing observable is lost: the mutations the loop issued all fail today, and the only other thing it did was log `Resolved N stale comment-cop thread(s).` after they had failed. Resolving stale threads stays with #36959, which will also need to move its thread lookup into its token step, since this PR removes the `GITHUB_TOKEN` lookup it currently reuses (noted there). - What the step posts is unchanged: the script on main and the REST dedup were dry-run (real reads, writes recorded) against 9 PRs carrying existing comment-cop threads (#35988, #36956, #36713, #35635, #33632, #35596, #39139, #30609, #36959) and chose the same comments to post on every one of them; the canned scenarios below show the same thing with GraphQL working and with it exhausted. - Test: `test/internal/source-lints/comment-cop.test.ts` extracts the script from the workflow and runs it the way `actions/github-script` does, against a fake `github` whose GraphQL requests all fail with the rate limit error above. It checks that groups not yet flagged are posted with the right line ranges, that a group already flagged (and a stale marker) are left alone, that a second run recognizes the comments the first run posted and posts nothing, and that no GraphQL request is made. Both tests fail against the workflow on main (the script throws the error above) and pass with this change. `source-lints.yml` now also triggers on changes to `comment-cop.yml`, so the test runs whenever the script is edited; it is excluded from the Buildkite shards like the rest of that directory. - Also ran: `bun bd test test/internal/source-lints/comment-cop.test.ts`, `bun test test/internal/source-lints/` (whole directory green), prettier on the three files. The comment-cop run on this PR itself still executes the script from main (`pull_request_target`), so the `Source lints` job is the one that exercises the change here. - Does not overlap with #37948 (which groups are flagged) or #38127 (where the file list comes from); both touch other parts of the script. #36959 rewrites the block this PR deletes and will need a rebase either way. ### Background - Comment Cop (`.github/workflows/comment-cop.yml`): on each push to a claude-labeled PR it reads the PR diff, finds multi-line comments added under `src/`, and posts one review comment per comment block. Each bot comment starts with `<!-- comment-cop:KEY -->`, KEY being the file path plus a hash of the block's text; a block whose KEY is already on the PR is not posted again. The check is advisory (not required for merge). - GitHub API quotas: REST and GraphQL requests count against separate hourly quotas (`x-ratelimit-resource` is `core` for REST and `graphql` for GraphQL). For the `GITHUB_TOKEN` Actions hands out, each quota is per repository, so every workflow run in oven-sh/bun draws on the same two pools, and GraphQL-heavy automation (the `gh pr` / `gh issue` / `gh search` commands used by other workflows go through GraphQL) empties the GraphQL pool for everything else when PR volume is high. - Review threads vs review comments: a review thread is GraphQL's grouping of a line comment with its replies, and is the only place a thread's id (what `resolveReviewThread` takes) and its resolved flag exist. `GET /repos/{owner}/{repo}/pulls/{n}/comments` returns every review comment on the PR, including each thread's root comment, so the markers are reachable from REST; only resolving needs GraphQL, and under `GITHUB_TOKEN` GitHub refuses that mutation regardless of the `pull-requests: write` permission. <details> <summary>Script on main vs this branch against a fake github (the fake's resolve mutation fails the way GITHUB_TOKEN's does)</summary> ``` main | quota ok, stale threads present | exit 0 | posts ["src/foo.ts:5-6"] | resolve attempts ["T_STALE_OPEN"] | graphql ["query","query","mutation"] | warnings 1 main | graphql quota exhausted, stale threads present | step fails (unhandled rate limit error) | posts [] | resolve attempts [] | graphql ["query"] | warnings 0 main | graphql quota exhausted, nothing stale | step fails (unhandled rate limit error) | posts [] | resolve attempts [] | graphql ["query"] | warnings 0 main | graphql quota exhausted, PR has no review comments yet | step fails (unhandled rate limit error) | posts [] | resolve attempts [] | graphql ["query"] | warnings 0 fixed | quota ok, stale threads present | exit 0 | posts ["src/foo.ts:5-6"] | resolve attempts [] | graphql [] | warnings 0 fixed | graphql quota exhausted, stale threads present | exit 0 | posts ["src/foo.ts:5-6"] | resolve attempts [] | graphql [] | warnings 0 fixed | graphql quota exhausted, nothing stale | exit 0 | posts ["src/foo.ts:5-6"] | resolve attempts [] | graphql [] | warnings 0 fixed | graphql quota exhausted, PR has no review comments yet | exit 0 | posts ["src/foo.ts:2-3","src/foo.ts:5-6"] | resolve attempts [] | graphql [] | warnings 0 ``` </details> <details> <summary>Dry-run against live PRs: comments the script on main would post vs the REST dedup (real reads through a user token, writes recorded)</summary> ``` PR #35988: same posts (0 vs 0) 245 threads on the PR are stale; main attempts to resolve them, this branch does not PR #36956: same posts (0 vs 0) PR #36713: same posts (1832 vs 1832) the stale cached file list that #38127 fixes; identical on both PR #35635: same posts (0 vs 0) PR #33632: same posts (0 vs 0) PR #35596: same posts (0 vs 0) PR #39139: same posts (1 vs 1) the comment the failing run above did not get to post PR #30609: same posts (0 vs 0) PR #36959: same posts (0 vs 0) ``` </details> <details> <summary>Headers from the failing run</summary> ``` errors: [ { type: 'RATE_LIMIT', code: 'graphql_rate_limit', message: 'API rate limit already exceeded for site ID installation.' } ] variables: { owner: 'oven-sh', repo: 'bun', pr: 39139, after: null } 'x-ratelimit-limit': '10000' 'x-ratelimit-remaining': '0' 'x-ratelimit-resource': 'graphql' 'x-ratelimit-used': '10000' ``` </details>
Problem
H2FrameParser::flush_batch_vectored(src/runtime/api/bun/h2_frame_parser.rs, around line 3510) turns eachBatchSegmentinto the bytes it puts on the wire three separate times: when building the iovecs, in the copy fallback taken when the socket is no longer plain TCP, and when copying the unwritten tail of a partial write intowrite_buffer. The three matches have the same arms, so a change to one copy would miss the others.same_match_twice:src/runtime/api/bun/h2_frame_parser.rsentry inmordant-baseline.toml.Fix
BatchSegment::raw_parts(self, batch: &[u8]) -> (*const u8, usize),#[inline(always)], holding the one mapping: theBatcharm is the originalbatch[off as usize..].as_ptr()with its length, theExtarm is the stored pointer and length. All three loops call it.Batcharm, samefrom_raw_parts(ptr.add(skip), len - skip)in the tail). The cold copy fallback (socket detached mid-call) now forms its slice withfrom_raw_partsfor both variants instead of a checked slice forBatch; its SAFETY comment states why both variants are valid (Batchranges were recorded inside the buffer being flushed,Extpayloads belong to thesend_datacall that is still running, sinceflush_batch_bufferis only called from it).&[u8]from anunsafe fn; that added an add and a range check to the hot loop and was reverted to the shape above after review.mordant-baseline.toml: the entry above is removed. This is whatbun run rust:mordant:baselineproduced before the rebase onto current main, minus two stale entries it also dropped at the time, which main has since removed itself;bun run rust:mordantreported nothing over the regenerated baseline. After the rebase the baseline change is this one line.test/js/node/http2/node-http2-syscall-fault.test.tsgains two cases that POST a 53248 byte body (three full DATA frames plus a partial one) over h2c and compare what the server received byte for byte. Every u32 of the body holds its own offset, so a slice taken from the wrong place or with the wrong length changes the bytes, not only the count. One case letswritevtake the batch (the iovec build); the other faultswritevto return 0, so the whole batch goes through the partial-write tail intowrite_bufferand is drained on writable. With the debug logs on, both cases showus_socket_raw_writev(.., 9)(corked HEADERS frame plus four header/payload pairs), and only the faulted one shows_genericFlush 53309for the re-buffered batch. The copy fallback is only reachable when the socket is detached mid-call and has no test.bun bd test test/js/node/http2/on the final revision, rebased onto current main: 475 pass, 6 skip, 0 fail (the two new cases included).node-http2-writable-destroy-fixture.tsin the same file already drove a 40000 byte body through the faultedwritevpath, checking for a UAF rather than the bytes.test-http2-backpressure,-large-write-close,-large-write-destroy,-large-write-multiple-requests,-large-writes-session-memory-leak,-many-writes-and-destroy,-multiplex,-pipe,-write-callbacks,-write-finishes-after-stream-destroyand-compat-serverresponse-writeagainst the debug build (11 pass).cargo clippy -p bun_runtime --no-depsis clean;bun run rust:mordant:baselineon the final revision reproduces main's baseline minus exactly the h2_frame_parsersame_match_twiceline (so the tuple-returning helper adds no finding of its own).Background
send_datahas to split a payload larger than one DATA frame (16 KiB), it does not flush each frame separately. It appends the frame headers to a thread-local scratch buffer (BATCH_BUFFER) and records the wire order inBATCH_SEGMENTS: aBatchSegment::Batchis a range of that scratch buffer, aBatchSegment::Extpoints straight at a slice of the caller's payload so the 16 KiB chunks are not copied. On plain TCP the batch goes out as onewritevover those pieces;flush_batch_vectoredis that flush, and the three loops in it are the three things it may have to do with the pieces (send them, copy them when it cannot vectorize, or buffer whateverwritevdid not accept).bun run rust:mordant.mordant-baseline.tomlholds per-(lint, file) counts of the findings that predate the job; CI fails only on findings over those counts, and fixing a finding lets its entry be deleted so the ratchet tightens.no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/http2/node-http2-syscall-fault.test.ts