Skip to content

s3: do not store an empty object when a failed stream is written twice - #45241

Open
robobun wants to merge 1 commit into
mainfrom
robobun/4fcfaad3/s3-stored-error-stream-retry
Open

robobun wants to merge 1 commit into
mainfrom
robobun/4fcfaad3/s3-stored-error-stream-retry

Conversation

@robobun

@robobun robobun commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A native stream (an S3 file's stream(), a fetch response body) can fail before anything reads it. A write of it to S3 rejects with the stream's error. A second write of the same stream resolves 0 and stores an empty object (S3Client.write, s3file.write, Bun.write).
  • The cause is the early check in upload_stream (src/runtime/webcore/s3/client.rs:842). It takes the stored error out of the stream and returns. The stream then reads as complete and empty.

Fix

  • Remove that check. The native attach already handles a stored error: it locks the stream, ends it with the error and fails the upload. The early check made that code unreachable.
  • The first write still rejects the same way and sends no request. A second write is refused.
  • Verified: three new rows in test/js/bun/s3/s3-stream-error-gc.test.ts fail on main (resolved 0 bytes, three empty PUTs). Also: s3.test.ts, the S3 upload suites. Self-reviewed: 3 concerns raised, 3 addressed.

Background

Downsides

Notes

Reach

  • Found while fixing a fetch("s3://") hang in the same function. There is no user issue.
  • It needs a second write of the same stream, after a failure that arrived before the first write. A write that starts before the failure arrives is not affected.
  • Reproduction (no credentials in the environment, a local Bun.serve as the bucket):
    const stream = new Bun.S3Client({}).file("source").stream(); // fails: no credentials
    await client.write("copy", new Response(stream)).catch(e => console.log(e.code)); // ERR_S3_MISSING_CREDENTIALS
    console.log(await client.write("copy", new Response(stream))); // main: 0, and PUT /bucket/copy with 0 bytes

Before and after (debug builds of main 3d6c7bc and of this PR)

second write of the same stream main this PR
client.write(key, new Response(stream)) resolves 0, empty PUT rejects TypeError
client.file(key).write(new Response(stream)) resolves 0, empty PUT rejects TypeError
Bun.write(s3file, new Response(stream)) resolves 0, empty PUT rejects TypeError

The TypeError is new Response() refusing a stream that is locked. The first write is rejected ERR_S3_MISSING_CREDENTIALS with 0 requests on both builds.

Why the sibling check stays

Cost

  • A valid upload runs one check fewer. Nothing is added to it.
  • 100 writes of a stored-error stream (gdb breakpoint hit counts, debug builds of main and of this change): JSC__JSPromise__rejectedPromise 100 to 0, JSC__JSPromise__create 0 to 100, requests at the bucket 0 to 0. All 100 reject on both.

Self-review

  • The change was part of a larger fix for fetch("s3://") in the same function. It is its own PR so that the write APIs do not wait for that one.
  • Main plus only this deletion was built and tested here: the new rows fail on main and pass with it.
  • fetch("s3://") and the sibling File check are described above.

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

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:44 AM PT - Oct 14th, 2026

@robobun, your commit 1afc09a is building: #125428

@robobun

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduction (debug build of main 3d6c7bc, no S3 credentials in the environment, a local Bun.serve as the bucket):

const client = new Bun.S3Client({ endpoint, bucket: "bucket", accessKeyId: "id", secretAccessKey: "secret" });
const stream = new Bun.S3Client({}).file("source").stream(); // the download fails: no credentials
await client.write("copy", new Response(stream)).catch(e => console.log(e.code)); // ERR_S3_MISSING_CREDENTIALS
console.log(await client.write("copy", new Response(stream))); // 0
// the bucket got: PUT /bucket/copy, 0 bytes

With this PR the second write rejects and the bucket gets no request. The same rows are in test/js/bun/s3/s3-stream-error-gc.test.ts.

PR: #45241

@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: a1a4206f-91a7-4f7e-9053-0958ab6ad329
📥 Commits

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

📒 Files selected for processing: 3
  • 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

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

Walkthrough

The S3 upload path now leaves pending byte-stream errors for native attachment. Tests cover repeated writes through three S3 write APIs, including the stored failure and the subsequent TypeError.

Changes

S3 stream error handling

Layer / File(s) Summary
Native error delivery and repeated-write coverage
src/runtime/webcore/s3/client.rs, test/js/bun/s3/s3-stream-stored-error-fixture.ts, test/js/bun/s3/s3-stream-error-gc.test.ts
The upload path no longer extracts and rejects a pending byte-stream error before native attachment. The fixture records two write outcomes for each API using the same failed stream. The test expects the stored failure first and a TypeError on the repeated write.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 1afc0

The repeated-write fix has no identified merge-blocking issue; normal checks can proceed.

Pre-merge checks: Passed 4
Passed 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 and concisely describes the main change: preventing an empty S3 object when a failed stream is written twice.
Description check Passed The description explains the problem, fix, behavior changes, verification steps, test results, and known downside. It does not use the exact template headings, but it provides the required information…
✨ 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.

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/webcore/s3/client.rs — Callers of fetch("s3://…", { body }) with a stream that already failed now get an uncatchable unhandled rejection and exit code 1 alongside the 500 response. With the early return gone, the stored error reaches src/runtime/webcore/s3/client.rs:994, which rejects ctx.end_promise; on the fetch path nobody holds that promise because fetch.rs:1810 discards upload_stream's return value. Fix: on a stored error with a native callback set (the fetch path), fail the task without rejecting an orphan end_promise, or have fetch.rs keep the returned promise and route its rejection into the fetch promise. On the base this call hangs instead, so the merged state trades a hang for a process-level error the caller cannot handle. [also at: src/runtime/webcore/s3/client.rs:639 - Users calling fetch("s3://...", { body }) with a stream that already failed now get an unhandled promise rejection where the base left the fetch pending.]

    Why this was flagged

    A ReadableStream whose native ByteStream source stored an error before any read (for example new Bun.S3Client({}).file("k").stream(), which fails for missing credentials) is passed as the body of fetch("s3://bucket/key", { method: "PUT", body }). On the base branch the deleted Bytes arm returned a rejected promise before any task was built, so the discarded promise was already rejected and unobserved and the fetch promise never settled (a hang). After this change the stored error flows to src/runtime/webcore/s3/client.rs:990-995: take_pending_error returns it, ctx.handle_reject_stream(err_js) runs, and client.rs:638 rejects self.end_promise, a promise created at client.rs:944 that no JS code holds on the fetch path. JSC reports it through the rejection tracker; in Bun's default mode (src/jsc/VirtualMachine.rs:4492-4493) that increments unhandled_error_counter and prints the error, so the script's exit code becomes 1 even though task.fail then resolves the fetch promise with a 500 via the native callback.

    Verification: On the base branch the same trigger hits the deleted Bytes arm, which returned JSPromise::rejected_promise(cx.global(), js_err).to_js(); fetch.rs:1810 discards that too. The change does not introduce the unhandled rejection; it replaces a hang with a 500 response. fetch.rs:1810 discards the promise upload_stream returns, which is the channel a stored stream error is reported on.

Comment on lines +839 to +840
// A `Bytes` source that stored an error is not refused here: the native attach
// below ends the stream with that error.

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.

🟡 nit (optional): maintainers reading this match will take the new comment as describing the File arm it now sits above, and it documents code that no longer exists. src/CLAUDE.md says never to add comments to deleted code blocks and to avoid comments that are not necessary; src/runtime/webcore/s3/client.rs:839-840 narrates the removed Bytes arm ("is not refused here") instead of anything the code does. Fix: drop the two lines, or if a pointer is wanted, put a one-line note at the native attach site (client.rs:990, take_pending_error) saying it also covers a stored error, so the match reads as plain File handling.

Why this was flagged

The diff deletes the ReadableStreamPtr::Bytes(_) arm in upload_stream and replaces it with a two-line comment at src/runtime/webcore/s3/client.rs:839-840 that describes the absence of the deleted check. The comment is placed directly above ReadableStreamPtr::File(_) => at client.rs:841, which still contains the identical take-the-error-and-reject logic, so a reader sees a sentence about a Bytes source attached to the File arm. src/CLAUDE.md:23 says NEVER add comments to deleted code blocks and src/CLAUDE.md:25 says avoid unnecessary comments; the repository review guidance also says never narrate the change. On the base branch the arm existed and the comment did not. No runtime behaviour is affected; this is a convention violation that leaves a misleading comment next to the sibling arm.

Verification: nit. Triggering condition: anyone reading the match readable_stream.ptr in upload_stream. The ReadableStreamPtr::Bytes(_) arm is deleted and replaced by the two-line comment at src/runtime/webcore/s3/client.rs:839-840, which sits directly above ReadableStreamPtr::File(_) => at line 841. src/CLAUDE.md:23 states "NEVER add comments to deleted code blocks."

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