Skip to content

s3: keep response metadata across progress callbacks - #33501

Merged
Jarred-Sumner merged 2 commits into
mainfrom
claude/71b9a6a4/fix-s3-close-delimited-panic
Jul 17, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
claude/71b9a6a4/fix-s3-close-delimited-panic

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.S3Client aborts the whole process (Rust panic → SIGABRT, uncatchable from JS) on any successful response whose body is close-delimited: a plain HTTP/1.1 200 with no Content-Length, no Transfer-Encoding, and Connection: close + FIN. That is a legal response (RFC 9112 §6.3, rule 8) and exactly what HTTP/1.0 upstreams and framing-stripping reverse proxies in front of S3-compatible services emit, so a server Bun connects to can take the process down with a well-formed 200.

// bun repro.ts -> exit 134 (SIGABRT); the try/catch never sees it
import * as net from "node:net";

const srv = net.createServer(s => {
  let buf = "";
  s.on("data", d => {
    buf += d.toString("latin1");
    if (buf.includes("\r\n\r\n")) {
      buf = "";
      // legal 200 + body: no Content-Length, no Transfer-Encoding, body ends at FIN
      s.write(`HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nETag: "x"\r\nConnection: close\r\n\r\nhello-close-delimited`);
      s.end();
    }
  });
});
await new Promise(r => srv.listen(0, "127.0.0.1", r));
const port = srv.address().port;

// control: plain fetch() of the identical bytes is fine
console.log(await (await fetch(`http://127.0.0.1:${port}/b/k`)).text()); // "hello-close-delimited"

const c = new Bun.S3Client({ endpoint: `http://127.0.0.1:${port}`, bucket: "b",
  accessKeyId: "AK", secretAccessKey: "SK", region: "us-east-1" });
try { console.log(await c.file("k").text()); } catch (e) { console.log("caught", e); }
console.log("SURVIVED"); // never printed
panic: called `Option::unwrap()` on a `None` value

…and under a debug build, the assertion that encodes the violated assumption:

panic: assertion failed: this.result.metadata.is_some()
S3HttpSimpleTask::on_response  src/runtime/webcore/s3/simple_request.rs:347

Cause

HTTPClient::to_result() hands HTTPResponseMetadata to a task exactly once, via state.cloned_metadata.take() — on the first progress callback that carries response headers.

When the body is close-delimited, content_length is None, so handle_response_body_from_multiple_packets reports progress on every read. The headers therefore ship on an early callback (has_more = true, metadata = Some), and the completing callback arrives separately at EOF carrying metadata: None.

S3HttpSimpleTask::http_callback overwrote this.result wholesale on every callback, so that assignment dropped the stored metadata. on_response then did this.result.metadata.as_ref().unwrap() and aborted.

Responses with a Content-Length or Transfer-Encoding: chunked deliver metadata and completion on the same callback, which is why this only ever showed up on close-delimited bodies.

Fix

Carry the earlier metadata across the assignment. This is the idiom the sibling consumers of HTTPClientResultCallback already use — install::NetworkTask::notify and FetchTasklet::callback both preserve metadata across callbacks, and s3::download_stream latches the status code instead; only s3::simple_request dropped it.

With metadata preserved, the debug_assert! + unwrap() in on_response become a provable invariant for this path: for S3 the CertErrors signal is never enabled, so certificate_info is always None and cannot pre-empt metadata in to_result(); and a terminal success dispatch requires state.is_done(), which is only ever reachable downstream of clone_metadata().

Verification

Covers .text(), .bytes(), and a 256KB body that spans several progress updates so the headers land many callbacks behind the completion. All three abort with exit 134 on the unfixed build and pass on the fixed one; the large body comes back byte-intact, which is what proves the metadata survives repeated callbacks rather than just the two-callback case.

test/js/bun/s3/s3-connection-close.test.ts   7 pass  0 fail

Not fixed here

AsyncHTTP::send_sync has a related problem with the same trigger: SingleHTTPChannel::write_item replaces the stored result on each callback, and send_sync does a real (non-debug) result.metadata.unwrap(). A close-delimited response can therefore panic it in release, or unblock the reader on the partial first result. It needs completion-gating rather than a carry-forward, has its own copy-back ordering hazard around real.response_headers, and its callers (bun create, bun publish, bun pm view, bun audit) only ever talk to registries that frame their responses, so it is left for a separate change.

The list() path is affected by this crash too, but its parsed result is separately truncated by a scan-window bug already fixed in #33497, so list() is not asserted on here.

The HTTP client hands `HTTPResponseMetadata` to a task exactly once, on the
first progress callback that carries response headers (`to_result()` does
`state.cloned_metadata.take()`).

A response body delimited by connection close (no Content-Length, no
Transfer-Encoding) reports progress once with the headers and again at EOF,
and the second callback carries no metadata. `S3HttpSimpleTask::http_callback`
overwrote `this.result` wholesale on every callback, dropping the stored
metadata, so `on_response` found `None` and unwrapped it, aborting the
process on a legal HTTP/1.1 200.

Carry the earlier metadata across the assignment, matching what
`install::NetworkTask::notify` and `FetchTasklet::callback` already do.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 17 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: a4faa7c2-3b61-418d-bc03-27dfa94c8ef9

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and d949f7a.

📒 Files selected for processing (2)
  • src/runtime/webcore/s3/simple_request.rs
  • test/js/bun/s3/s3-connection-close.test.ts

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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:08 PM PT - Jul 6th, 2026

❌ @robobun, your commit d949f7a has some failures in Build #69106 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33501

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

bun-33501 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. S3Client.list() throws a error with a production MinIO cluster #19076 - S3Client.list() throws UnknownError with production MinIO cluster, which may use close-delimited responses (no Content-Length) behind a reverse proxy, matching the metadata-overwrite bug this PR fixes

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

Fixes #19076

🤖 Generated with 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.

LGTM — minimal carry-forward matching the sibling FetchTasklet/NetworkTask idiom, with solid multi-callback test coverage.

Extended reasoning...

Overview

This PR fixes a server-triggerable process abort in Bun.S3Client when receiving a close-delimited HTTP/1.1 response body (200 with no Content-Length, no Transfer-Encoding, body ends at FIN). The Rust change is 4 effective lines in S3HttpSimpleTask::http_callback (src/runtime/webcore/s3/simple_request.rs): .take() the previously-stored metadata before the wholesale this.result = ... assignment, then restore it if the incoming result carries none. The test file adds three new concurrent cases (.text(), .bytes(), 256KB body) and hoists the existing envWithoutProxy object into a shared const.

Security risks

None. The change is purely additive state preservation in an internal callback — no new parsing, no new inputs, no auth/crypto surface. It removes a remote-triggerable abort (a well-formed 200 from any S3-compatible endpoint could SIGABRT the process), so the security posture strictly improves.

Level of scrutiny

Low-to-medium. While http_callback runs on the HTTP thread and the surrounding function contains unsafe, the added lines themselves are safe Rust operating on an owned Option field. I verified the fix is byte-for-byte the same idiom already used by the two sibling HTTPClientResultCallback consumers: FetchTasklet.rs:2337 (prev_metadata = task_ref.result.metadata.take() → .or(prev_metadata)) and install/NetworkTask.rs:315 (saved_metadata = this.response.metadata.take()). This PR simply brings the S3 path in line with the established pattern; it is not a novel design.

Other factors

The tests follow harness conventions precisely: tempDir, bunExe(), {...bunEnv, ...}, server.listen(0, ...), concurrent Promise.all pipe drain, combined {stdout, exitCode} assertion before checking signalCode, and test.concurrent.each for the variant matrix. The 256KB body case ensures metadata survives many progress callbacks, not just two. The envWithoutProxy extraction is a pure dedup of code that already existed in the file. No CODEOWNERS cover these paths, no outstanding reviewer comments, and the bug-hunting system found nothing. The PR description also correctly scopes out the related AsyncHTTP::send_sync issue as needing a different fix shape.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

On the Fixes #19076 suggestion above: I looked into it and think it is probably right, but I'm deliberately not adding the auto-close keyword, because merging on a wrong guess would close a live issue and the last link in the chain isn't something I can verify from here.

The reasoning, for whoever picks this up:

#19076 reports a catchable S3Error whose message is the entire <ListBucketResult> XML with code: "UnknownError" — not the SIGABRT this PR fixes. That looked like a mismatch at first, but it's what you'd expect from the same root cause on the Zig-era code this was ported from:

bun.assert(this.result.metadata != null);        // debug-only, compiled out in release
const response = this.result.metadata.?.response; // null unwrap = UB under ReleaseFast

With metadata == null, response.status_code is garbage. For .listObjects that falls to else => errorWithBody(.failure), which sets message to the whole response body and leaves code as "UnknownError" (a ListBucketResult has no <Code> element) — exactly the reported output. The Rust port turned that same null unwrap into the hard panic this PR fixes, so the symptom changed from a throw to an abort.

The alternative path to that identical message is parseS3ListObjectsResult returning an error, but its only fallible operations are allocations (the &quot; replacement buffer for ETag, and two appends), so it can realistically only fail on OOM. That leaves a garbage status_code as the plausible explanation, and a valid intact ListBucketResult body is only ever sent with a 200.

That fits the reporter's own observation that their standalone reproduction works while the production cluster does not: close-delimited framing (no Content-Length, no Transfer-Encoding) is the kind of thing a reverse proxy in front of MinIO introduces and a direct MinIO does not.

What would confirm it: a curl -sv against that endpoint's list request showing the response has no Content-Length and no Transfer-Encoding. If it does, this PR fixes it. Worth noting either way that on current main that setup no longer throws — it aborts the process — so #19076 as filed doesn't reproduce verbatim anymore.

@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 — this mirrors the exact metadata-preservation idiom already used in install::NetworkTask::notify and FetchTasklet::callback, fixing a process-abort on close-delimited S3 responses.

Extended reasoning...

Overview

This PR is a 4-line fix in src/runtime/webcore/s3/simple_request.rs (S3HttpSimpleTask::http_callback) that preserves result.metadata across successive HTTP progress callbacks, plus ~100 lines of new regression tests in test/js/bun/s3/s3-connection-close.test.ts. The bug was that a close-delimited HTTP/1.1 200 response (no Content-Length, no Transfer-Encoding) delivers headers on an early callback and completion on a later one with metadata: None; the wholesale this.result = result assignment dropped the earlier metadata, and on_response then panicked on .unwrap(), aborting the whole process.

Security risks

None introduced. The change actually removes a DoS vector: previously any server Bun's S3 client connected to could abort the process with a well-formed 200. The fix touches no auth, crypto, signing, or input-validation logic — it only carries an already-owned Option<HTTPResponseMetadata> forward across an assignment.

Level of scrutiny

Low-to-moderate. While http_callback runs on the HTTP thread and sits next to unsafe lifetime-extension and MaybeUninit writes, none of that is touched. The added lines are plain safe Rust (Option::take() → assign → restore-if-none) and are byte-for-byte identical to the established pattern in src/install/NetworkTask.rs:315-323 and structurally the same as FetchTasklet.rs:2337-2361. There is no new ownership hazard: .take() moves the value out before the old this.result is dropped, and if the new result already carries metadata, previous_metadata simply drops at scope exit.

Other factors

  • The PR description is unusually thorough: it traces the root cause through to_result() / state.cloned_metadata.take(), cites the sibling consumers that already handle this, argues why the remaining unwrap() is now a provable invariant, and explicitly scopes out AsyncHTTP::send_sync (a related-but-distinct issue) with justification.
  • Tests follow repo conventions exactly (tempDir, bunEnv spread, concurrent pipe drain via Promise.all, test.concurrent.each, hermetic in-process TCP server, assertions on stdout before signalCode). The 256KB variant covers the multi-callback case, not just the two-callback minimum.
  • The existing envWithoutProxy refactor is a pure de-duplication of an inline object literal already in the file.
  • The bug-hunting system found no issues.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green; the red lane is Buildkite infrastructure

This needs a maintainer to merge past a red buildkite/bun check. Both builds failed on infrastructure, never on this diff.

Build #69106 (d949f7a9) — 278 jobs passed, 1 failed:

  • darwin 26 aarch64 - test-bun never ran a single test. It died in getExecPathFromBuildKite while fetching the compiled binary:
    Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
           Refusing to continue with a partial download
    
  • The only other annotation is warning / flaky: bun-install-security-provider.test.ts (a UV_EBADF writing to C:\Windows\Temp) and bun-install.test.ts, both on Windows 2019 x64, both passed on retry. Neither touches S3 or the HTTP client.

Build #69020 (5e6ac7db) — 279 jobs passed, 0 failed. It was marked failing only because two aarch64 jobs expired waiting for an agent. I confirmed that was a transient congestion window (0 expired jobs across the 12 builds surrounding it) before spending the one re-roll, and the re-roll did clear it: #69106 has no expired jobs.

The test added here ran and passed on every lane that scheduled it:

--- [3/239] test/js/bun/s3/s3-connection-close.test.ts
7 pass  0 fail  21 expect() calls   (debian 13 aarch64, 143ms)
7 pass  0 fail  21 expect() calls   (ubuntu 25.04 aarch64, 181ms)

Across both builds, zero failures mention s3, s3-connection-close, or simple_request, and zero error-style annotations were produced.

I've used my one re-roll, so I'm not pushing another ci: retrigger — re-running ~280 green jobs to shake loose one artifact-download timeout isn't a good trade for CI capacity. Happy to rebase or re-run if you'd like a fresh build before merging.

@Jarred-Sumner
Jarred-Sumner merged commit 8adb8de into main Jul 17, 2026
75 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/71b9a6a4/fix-s3-close-delimited-panic branch July 17, 2026 00:33
Jarred-Sumner pushed a commit that referenced this pull request Jul 17, 2026
## Problem

`test/cli/install/bun-create.test.ts` crashed in CI (build 74166, Ubuntu
25.04 aarch64) during the api.github.com outage on 2026-07-16:

```
panic: called `Option::unwrap()` on a `None` value
```

The trigger is a close-delimited HTTP/1.1 body (no `Content-Length`, no
`Transfer-Encoding: chunked`, body ends at FIN).
`handle_response_body_from_multiple_packets` enters its processing block
on every packet for such a body (the `content_length.is_none()` term)
and returns `true` whenever the output buffer is non-empty, so
`progress_update` fires once per packet regardless of whether the
consumer asked for streaming. `to_result()` attaches the response
metadata only to the first of those callbacks.

Every non-streaming `HTTPClientResultCallback` consumer then has to be
taught the same stash-and-reassemble dance. #33501 did it for
`S3HttpSimpleTask`; `NetworkTask::notify` already does it; `send_sync`
and `RemoteImageDownload::on_done` do not, so a close-delimited body
split across packets either hands them a truncated body, panics
`send_sync` on the missing metadata, or writes to state they have
already freed.

## Fix

Gate the per-packet progress return on the same streaming signals that
already select per-packet delivery everywhere else:

```rust
return Ok(is_done || (processed && is_streaming));
```

where `is_streaming` is `ResponseBodyStreaming ||
body_receive_mode.is_some()`. The block is still entered per packet so
incremental decompression keeps running; only the `progress_update`
trigger is suppressed. Non-streaming consumers now receive exactly one
terminal callback with `metadata: Some` (because `cloned_metadata` is
still held when `on_close` drives the terminal `progress_update`).
Streaming consumers are unchanged: `fetch` wires `body_receive_mode` via
`to_with_backpressure()`, `NetworkTask` sets `response_body_streaming`,
and the S3 download stream calls `enable_response_body_streaming()`.

In `send_sync` the invariant is now asserted
(`debug_assert!(!result.has_more)`), and the `metadata.unwrap()` becomes
a `ConnectionClosed` error so a future regression surfaces as a
catchable CLI error rather than a process abort.
`RemoteImageDownload::on_done` is covered by the same change with no
edits needed.

`create_command::fetch_all` replaces its chained `.unwrap()`s on
registry-supplied `version`/`description` with optional lookups that
default to empty (display-only values).

## Test

A hermetic test in `bun-create.test.ts` points `GITHUB_API_DOMAIN` at a
local TLS server that serves a valid gzip tar with `content-type:
application/x-gzip`, no `Content-Length`, and the body dripped across
twelve `setImmediate`-separated writes with Nagle disabled. On main this
fails with a truncated-body `ZlibError` (or the unwrap panic depending
on scheduling); with the fix the full tarball is received and extracted.

```
# 30 iterations on the unfixed build: 0 pass / 30 fail
USE_SYSTEM_BUN=1 bun test test/cli/install/bun-create.test.ts -t close-delimited

bun bd test test/cli/install/bun-create.test.ts            # 15 pass
bun bd test test/cli/install/bun-upgrade.test.ts           # 8 pass
bun bd test test/cli/install/bun-audit.test.ts             # 16 pass
bun bd test test/js/bun/s3/s3-connection-close.test.ts     # 4 pass

# close-delimited fetch() smoke: buffered body intact, stream still per-chunk
status: 200 body: "Hello, close-delimited World!"
stream chunks: 10 body: "Hello, close-delimited World!"
```

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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