Skip to content

s3: one result channel per streaming upload, so a failed fetch("s3://") upload is reported once - #45242

Open
robobun wants to merge 2 commits into
mainfrom
robobun/4fcfaad3/s3-fetch-stream-refusal
Open

robobun wants to merge 2 commits into
mainfrom
robobun/4fcfaad3/s3-fetch-stream-refusal

Conversation

@robobun

@robobun robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #45241.

Decision needed. An error from the body stream now rejects fetch("s3://", { body: stream }), as for an http: URL. Today it resolves a 500 response. Notes has the other option. The rest is the piece of #32131 that #36087 did not pick up.

Problem

  • fetch("s3://", { body: stream }) resolves a 500 when S3 answers 403. Bun then prints S3Error: Access Denied, which no handler can catch, and the process exits with code 1.
  • When the body is an S3 file that cannot be downloaded, the fetch never settles.
  • Cause: upload_stream (src/runtime/webcore/s3/client.rs) returns a promise and takes a callback. fetch drops the promise (fetch.rs:1810). Each failure rejects it, and the check for a used stream settles nothing else.

Fix

  • With a callback, upload_stream creates no promise. Every outcome goes to the callback, once.
  • An error from the body stream rejects the fetch. Every other failure resolves the same 500.
  • Verified: new tests in s3.test.ts, fetch-args.test.ts, s3-upload-abort.test.ts, s3-stream-error-gc.test.ts fail on main. Self-reviewed: 13 concerns raised, 13 addressed.

Background

  • upload_stream feeds a stream to a multipart upload. Its other callers (S3Client.write, Bun.write) return its promise.
  • An unhandled rejection ends a process whose main module has finished.
  • Considered a check in fetch before the call: the rejections of the dropped promise stay.

Downsides

  • A handler that reads only res.ok must now catch a body stream that fails.
  • A script that ignores the response of a failed upload now exits with code 0, not 1.
  • No cost found on a valid upload: one Promise fewer per fetch upload, release .text -1,536 B.
Notes

Severity and reach

  • Found by fuzzing. There is no user issue, so there is no Fixes line. streams: leave a stream alone when a consumer fails to lock it #45004 names the hang as reported separately.
  • Severity is high. On main, a process whose main module has finished (a server) dies with exit code 1 on a failed streamed upload, although its handler handled the 500. A script that is still in a top-level await prints the error, continues, and exits with code 1 at the end. 1.4.3-canary.1 prints the error, continues, and exits with code 1 in both cases. Checked with the script below.
  • Reach is small. The documented write calls (S3Client.write, s3file.write, Bun.write) settle once on the same failures and are a workaround. A string or buffer body takes another path and is not affected.
// bucket: a local Bun.serve that answers 403 AccessDenied
setTimeout(async () => {
  try {
    const res = await fetch("s3://backup.tar", { method: "PUT", body: stream, s3 });
    console.log("handled response:", res.status, res.statusText);
  } catch (e) {
    console.log("caught:", String(e));
  }
  setTimeout(() => console.log("still running"), 50);
}, 1);
// main:    handled response: 500 AccessDenied / S3Error: Access Denied / exit code 1
// this PR: handled response: 500 AccessDenied / still running / exit code 0

The rule that is implemented

An error object that the body stream produced rejects the fetch with that object. Every other outcome is unchanged here.

fetch("s3://", { method: "PUT", body }) main this PR
upload succeeds 200 200
S3 answers 403 (also to CreateMultipartUpload) 500 AccessDenied + orphan 500 AccessDenied
no credentials for the destination 500 ERR_S3_MISSING_CREDENTIALS + orphan 500 ERR_S3_MISSING_CREDENTIALS
stream fails without an error (controller.error(), throw undefined) 500 UnknownError + orphan 500 UnknownError
stream fails with an error (controller.error(e), direct close(e), pull() throws) 500 UnknownError + orphan rejects with e
stream of a file that does not exist 500 UnknownError + orphan rejects ENOENT
response body whose source drops the connection 500 UnknownError + orphan rejects ECONNRESET
request body relayed by a server, the client leaves 500 UnknownError + orphan rejects AbortError
stream that an s3 getter locked 500 UnknownError + orphan rejects ERR_INVALID_STATE
stream that an option getter read or cancelled never settles + orphan rejects Error: ReadableStream is already disturbed
S3 file without credentials, or its stream never settles + orphan rejects ERR_S3_MISSING_CREDENTIALS

"Orphan" is the rejection of the dropped promise. The last row is "500 UnknownError + orphan" with #45241 alone.

Questions for a maintainer

  1. Should an error from the body stream reject the fetch (this PR), or resolve the 500 UnknownError?
    • Reject: the same streams reject fetch("http://...") and S3Client.write with the same error. A handler that reads only res.ok has to catch.
    • Keep the 500: the stream's error then has no outlet, because its only outlet on main is the orphan rejection. The 500 arm needs an S3 error code and message, so it is more than a move of one match arm. A copy of a missing key then rejects or resolves 500 depending on whether the source's failure arrived before the upload attached.
    • In both cases a stream that was used before the upload rejects, and no orphan fires.
  2. Should a failure with no answer from S3 (no credentials, connection refused) reject? The buffered path rejects a sign error. This PR leaves these at the 500: the response is unchanged, the exit code is not.

The change

  • S3UploadStreamWrapper had end_promise, callback and callback_context. It has one field now, done: the promise, or the callback, or Delivered. Both delivery sites (resolve, handle_reject_stream) take it out before they use it.
  • upload_stream has one exit before the upload exists: the stream was used after the caller checked it. That takes an option getter. The exit goes through the caller's channel.
  • The checks for an Invalid source and for a File source with a stored error are deleted. Nothing gives upload_stream an Invalid source (from_tag never makes one). A FileReader puts an error in pending only while a read is pending and delivers it at once (Release the poll and surface the error when a Bun.file() stream read fails #41420). A File stream that fails is a started upload that rejects.
  • Considered sealing the return type so that a bare early return cannot compile. It needs a second function with the same 14 parameters. With one exit left, it is not built.

Not fixed here

Measurements (main 3d6c7bc and this PR, which includes #45241)

main this PR
Promise cells per fetch("s3://", stream) upload (heapStats, n = 8, 16, 32) 10 9
Promise cells per client.write upload 9 9
per 100 fetch uploads: JSPromise create / resolve / isDisturbed calls (gdb, debug builds) 400 / 300 / 400 300 / 200 / 400
per 100 client.write uploads: the same 300 / 200 / 500 300 / 200 / 500
instructions in the function's own frame, fetch upload: upload_stream / resolve / fetch's callback (gdb nexti, release) 321 / 97 / 106 300 / 74 / 107
the same, client.write upload: upload_stream / resolve 333 / 89 329 / 81
1000 refused fetches: settled / unhandledRejection / Promise cells kept per call 0 / 1000 / 2 1000 / 0 / 0
release .text (size -A) 80,509,029 B 80,507,493 B
upload_stream / resolve_thunk / s3_stream_wrapper_resolve / handle_reject_stream (nm -S) 5,859 / 1,627 / 1,689 / 424 B 4,312 / 1,699 / 1,723 / 448 B
wall-clock per upload, 10 interleaved runs of 300 uploads (median) 272 us 293 us
  • The wall-clock difference is not resolved: two series of the same main binary differ by 35 us (median of the pairs).
  • perf and valgrind are not installed here. The instruction counts step over calls, so the saved promise work is in the call counts.

Tests

  • s3.test.ts: one child runs the started-upload rows of the table, with no unhandledRejection listener. A rejection that nothing handles is in stderr and in the exit code. On main 10 rows differ, stderr has the orphans, and the exit code is 1.
  • s3-upload-abort.test.ts: the stream fails after one part was uploaded. The fetch rejects with the stream's error, and the upload is aborted (create 1, part 1, abort 1).
  • fetch-args.test.ts: 10 rows for the used-stream refusal (6 entry points, POST, 3 getters, read and cancel), all pending on main. 4 control rows for s3file.write and Bun.write, which take the promise of the same exit and reject on main too. One row checks that the rejected promise is the one that fetch() returned.
  • s3-stream-error-gc.test.ts: fetch("s3://") with a stream that stored its failure, and with the S3 file as the body.
  • Also run on the debug build: s3.test.ts (308 pass), fetch-args.test.ts (106 pass), s3-upload-stream-gc, s3-networksink-leak, s3-write-to-file-sync-close, s3-connection-close. s3-upload-memory and one test of s3-stream-cancel-leak time out at 5 s on the debug build of main on this machine too.

A native stream that failed before anything read it keeps the failure for
its first consumer. `upload_stream` checked for that before it started an
upload: it took the failure out of the stream and rejected. The stream was
left unlocked with no failure, so it read as complete and empty. A second
`S3Client.write`, `s3file.write` or `Bun.write` of the same stream
resolved 0 and stored an empty object under the key.

Remove the check for a `Bytes` source. The native attach in the same
function already handles a stored failure the way `wire_native_sink` does
for every other sink: it locks the stream, ends it with the failure and
fails the upload. The early check made that code unreachable. The first
write still rejects with the stream's failure and sends no request.
`upload_stream` had two result channels: the promise it returns and the
caller's callback. fetch() is the only caller with a callback, and it
drops the promise.

- The check that refuses a used stream ran before the callback was
  stored. It settled only the promise, so `fetch("s3://", { body })`
  never settled when an option getter had used the body stream.
- Every failed upload also rejected the dropped promise: an unhandled
  rejection beside the 500 response that fetch resolved with.

With a callback, `upload_stream` now creates no promise and sends every
outcome to the callback, once, a refusal included. The callback gets the
origin of a failure. fetch rejects with the error that the body stream
produced, as it does for an http: URL. Every other failure still
resolves the 500 response.

The checks for an `Invalid` source and for a `File` source with a stored
error are removed. Neither can fire: nothing gives `upload_stream` an
`Invalid` source, and a `FileReader` does not keep an error in
`pending` (#41420).
@robobun

robobun commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:16 AM PT - Oct 14th, 2026

✅ @robobun, your commit b5a346369b254deba9700b9403514f3b22e50e8d passed in Build #125429! 🎉


🧪   To try this PR locally:

bunx bun-pr 45242

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

bun-45242 --bun

@robobun

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduction (release build of main 3d6c7bc, a local Bun.serve as the bucket that answers 403 AccessDenied):

setTimeout(async () => {
  try {
    const res = await fetch("s3://backup.tar", { method: "PUT", body: stream, s3 });
    console.log("handled response:", res.status, res.statusText);
  } catch (e) {
    console.log("caught:", String(e));
  }
  setTimeout(() => console.log("still running"), 50);
}, 1);
// main:    handled response: 500 AccessDenied, then "S3Error: Access Denied", exit code 1
// this PR: handled response: 500 AccessDenied, then "still running", exit code 0

The hang: fetch("s3://key", { method: "PUT", body: new Bun.S3Client({}).file("source"), s3 }) with no credentials for the source stays pending on main with 0 requests. With this PR it rejects with ERR_S3_MISSING_CREDENTIALS. A body stream that an s3 or headers option getter already read does the same on main and rejects with ReadableStream is already disturbed here.

PR: #45242

@coderabbitai

coderabbitai Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 70b3fc7b-6b75-4003-9a78-face18e08771
📥 Commits

Reviewing files that changed from the base of the PR and between 408c937 and b5a3463.

📒 Files selected for processing: 7
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/s3/client.rs
  • test/js/bun/s3/s3-stream-error-gc.test.ts
  • test/js/bun/s3/s3-stream-stored-error-fixture.ts
  • test/js/bun/s3/s3-upload-abort.test.ts
  • test/js/bun/s3/s3.test.ts
  • test/js/web/fetch/fetch-args.test.ts

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

Walkthrough

S3 streaming uploads now deliver typed outcomes through a callback or returned promise. Fetch rejects for upload refusal and body-stream errors, while other S3 failures retain the 500 response path. Tests cover these outcomes and disturbed streams across S3 write APIs and fetch entry points.

Changes

S3 Streaming Uploads

Layer / File(s) Summary
Upload completion channel
src/runtime/webcore/s3/client.rs
upload_stream selects a callback or returned promise as its completion channel. It reports success, refusal, body-stream errors, and other S3 failures through that channel.
Fetch result mapping
src/runtime/webcore/fetch.rs
The fetch callback handles S3UploadStreamResult. It rejects for refusal and body-stream errors; other upload failures continue to produce a 500 response.
Upload behavior coverage
test/js/bun/s3/*, test/js/web/fetch/fetch-args.test.ts
Tests cover repeated writes, upload aborts, streaming fetch outcomes, and disturbed streams across fetch entry points.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to b5a34

S3 streaming uploads now report each outcome once. A fetch to s3:// rejects with the body stream's error, and other failures still return a 500 response. No concrete defects were identified, and the change appears ready to merge after normal CI.

Pre-merge checks: Passed 4
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly identifies the main change: using one result channel per S3 streaming upload to report failed fetch uploads once.
Description check Passed The description explains the problem, the fix, trade-offs, and verification results. It does not use the template headings, but it provides the required information for both sections.
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.
✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

❤️ Share

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

@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 reviewed this PR and didn't find any bugs. Since it changes user-visible fetch("s3://", { body: stream }) semantics (a body-stream error now rejects where it resolved a 500) and the description explicitly asks for a maintainer decision on that, a human look is still worthwhile.

What was reviewed:

  • The done: UploadStreamDone single-channel delivery: both handle_reject_stream and resolve take it via mem::replace, so the callback-first-then-task.fail ordering in handle_reject_stream lands on Delivered and cannot fire the fetch callback (and free its context) twice; _pump_ref/_upload_ref balances are unchanged.
  • The is_disturbed refusal with a callback returns undefined while fetch returns its own pre-captured promise; the Blob.rs callers pass None and keep the promise path.
  • The deleted per-source pending-error checks: the native ByteStream path still takes the stored error after attaching (take_pending_error) and routes it through handle_reject_stream, so a failed stream no longer reads as empty on a second write (covered by the "twice" fixture rows).
  • Tests exercise USE_SYSTEM_BUN-failable cases: refused-stream matrix across fetch entry points, body-failure matrix with exact outcomes, exit code 0 and no unhandled rejections.
Extended reasoning...

The change reworks how S3 streaming uploads report their outcome in src/runtime/webcore/s3/client.rs (replacing end_promise/callback fields with a single UploadStreamDone enum and adding the S3UploadStreamResult variants Refused/BodyFailed) and updates the fetch("s3://") callback in src/runtime/webcore/fetch.rs to reject on body-stream errors, plus ~500 lines of new tests. It touches refcounted native code with manual heap ownership but no auth, crypto, or injection surface beyond the existing S3 credential handling, which is unchanged. The bug hunt ran dry with no findings and the delivery-once invariant, refcount balance, and removed early-return paths check out. Deferring rather than approving because the PR intentionally changes user-facing fetch behavior (reject vs. 500) and the author flags that as an open decision for a maintainer, and the diff is large enough in a memory-sensitive module that a human owner should confirm the semantic choice.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant