Skip to content

s3: S3File.writer().end(error) aborts the upload instead of committing - #33681

Open
robobun wants to merge 9 commits into
mainfrom
farm/b1f3dbf2/s3-writer-end-error-aborts
Open

s3: S3File.writer().end(error) aborts the upload instead of committing#33681
robobun wants to merge 9 commits into
mainfrom
farm/b1f3dbf2/s3-writer-end-error-aborts

Conversation

@robobun

@robobun robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

S3File.writer().end(new Error(...)) now aborts the in-progress multipart upload and rejects with the caller's error. Previously the error argument was dropped, the buffered tail was flushed, CompleteMultipartUpload was sent, and the promise resolved, silently publishing a truncated object under the final key.

Reproduction

const w = s3.file("obj.bin").writer({ partSize: 5 * 1024 * 1024 });
w.write(new Uint8Array(5 * 1024 * 1024));
w.write(new Uint8Array(100));
await w.end(new Error("source failed mid-stream"));
// before: resolves, server receives CompleteMultipartUpload, object committed at 5242980 bytes
// after:  rejects with the caller's error, server receives AbortMultipartUpload, no object

The sibling s3.write(key, new Response(readableStream)) path already aborted on stream error; this brings the .writer() path to the same behavior.

Cause

JSSink::js_end in Sink.rs never read frame.argument(0), so the documented end(error?: Error) argument was lost. NetworkSink::end_from_js then called self.end(None) unconditionally, sending EOF (commit) regardless.

Fix

  • JsSinkType::end_from_js now receives the JS error argument; js_end reads frame.argument(0) and passes it through. ArrayBufferSink, FileSink, and HTTPServerWritable ignore it as before.
  • NetworkSink::end_from_js routes a non-null error to a new fail_from_js, which rejects any pending end/flush promise with the caller's error and drives MultiPartUpload::fail(). fail() cancels queued parts and issues AbortMultipartUpload when an upload id has been received.

Verification

New test/js/bun/s3/s3-writer-end-error.test.ts runs a local S3 stub and asserts, for both the mid-multipart and pre-upload cases, that end(error) rejects with the caller's message, no CompleteMultipartUpload or single-file PUT is sent, and the object is never committed. A control case confirms end() with no error still commits.

The NetworkSink backing S3File.writer() documented end(error?: Error)
but the error argument never reached the sink: js_end in Sink.rs did
not read frame.argument(0), and NetworkSink.end_from_js called
self.end(None) unconditionally. Passing an Error flushed the buffered
tail, sent CompleteMultipartUpload, and resolved, publishing a
truncated object under the real key with no way for the caller to
prevent it.

Plumb the error argument through JsSinkType::end_from_js. NetworkSink
now rejects both the end and flush promises with the caller's error
and drives MultiPartUpload.fail(), which cancels queued parts and
issues AbortMultipartUpload when an upload id exists. This matches the
behavior of the Response(ReadableStream) upload path.
@coderabbitai

coderabbitai Bot commented Jul 7, 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: 2 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: e0476d29-78d0-4a3d-aa4f-9f5e37e0bc6a

📥 Commits

Reviewing files that changed from the base of the PR and between 7cd5724 and 48f520f.

📒 Files selected for processing (3)
  • src/jsc/JSValue.rs
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/s3/s3-writer-end-error.test.ts

Walkthrough

The sink end_from_js contract now accepts a JS error value and propagates it through host-fn wiring and sink implementations. NetworkSink uses that value to trigger abort/reject handling for multipart uploads. Multipart upload parsing and rollback behavior were updated, and a test covers S3 writer end(error) outcomes.

Changes

Sink end_from_js error propagation

Layer / File(s) Summary
Trait signature and host-fn wiring
src/runtime/webcore/Sink.rs, src/js/internal/streams/iter/pull.ts
JsSinkType::end_from_js gains an err: JSValue parameter; the __end host-fn reads and roots frame.argument(0) as err, __endWithSink passes JSValue::UNDEFINED, and pipeTo() stops forwarding a signal object into writer.end.
Passthrough sink implementations
src/runtime/webcore/ArrayBufferSink.rs, src/runtime/webcore/FileSink.rs, src/runtime/webcore/streams.rs
ArrayBufferSink, FileSink, and HTTPServerWritable update end_from_js signatures to accept the new trailing JSValue parameter while preserving existing forwarding behavior.
NetworkSink error-driven teardown
src/runtime/webcore/streams.rs
NetworkSink::end_from_js branches on err to a new fail_from_js helper that closes the signal, rejects flush_promise and end_promise, and fails the multipart task with an abort-style S3Error; bun_s3 re-exports MultiPartUploadState and S3Error.
Multipart upload validation and rollback
src/runtime/webcore/s3/multipart.rs
Adds parse_upload_id, reuses it in multipart-init success handling, and rolls back a valid upload id when init finishes after the sink has already finished.
S3 writer end(error) test
test/js/bun/s3/s3-writer-end-error.test.ts
Adds a fixture HTTP server and spawned-process test covering multipart abort, buffered rejection, and normal commit cases for end(error) and end().

Possibly related PRs

  • oven-sh/bun#31559: Also changes multipart UploadId validation in src/runtime/webcore/s3/multipart.rs, which is the same validation surface used by this PR’s rollback path.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states that S3File.writer().end(error) now aborts instead of committing, matching the main change.
Description check ✅ Passed The description covers the change, cause, fix, and verification, satisfying the template's required 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.

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

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:01 PM PT - Jul 7th, 2026

@robobun, your commit 48f520f has 2 failures in Build #70061 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33681

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

bun-33681 --bun

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

@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: 3

🤖 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 `@src/runtime/webcore/streams.rs`:
- Around line 2444-2458: The returned promise in the `end_promise`/`value` flow
can be collected too early because `self.end_promise.reject(...)` clears the
Strong handle before the later `task.fail(...)` path finishes. In
`src/runtime/webcore/streams.rs`, keep the `value` from
`self.end_promise.value()` rooted for the entire abort/failure path by ensuring
it stays alive before calling `reject()` and before any
`task_mut().unwrap().fail(...)` work, so the promise cannot be dropped mid-path.

In `@test/js/bun/s3/s3-writer-end-error.test.ts`:
- Line 148: The strict empty-stderr check in the S3 writer error test is too
brittle for debug/ASAN runs. Update the test around the stderr assertion to stop
requiring exact emptiness on the happy path, and instead only report stderr in
failure diagnostics or make the check conditional; use the existing test flow in
s3-writer-end-error.test.ts to keep the assertion from flaking while preserving
useful output when the test fails.
- Around line 106-114: The `single.bin` assertion is using a fixed
`Bun.sleep(50)` to prove no request was sent, which is flaky. In the
`s3-writer-end-error.test.ts` flow around `settle()` for the `single.bin` case,
replace that one-off sleep with the same bounded polling approach used by
`waitFor` in the other cases, repeatedly confirming `reqs.length === 0` stays
true for the window. Keep the check tied to `settle`, `summary`, and the
`single.bin` writer path so the negative assertion remains stable under slow CI.
🪄 Autofix (Beta)

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: dc4179ba-8cb9-4683-916d-e8392dbe2251

📥 Commits

Reviewing files that changed from the base of the PR and between 3f5d816 and 12d8fab.

📒 Files selected for processing (5)
  • src/runtime/webcore/ArrayBufferSink.rs
  • src/runtime/webcore/FileSink.rs
  • src/runtime/webcore/Sink.rs
  • src/runtime/webcore/streams.rs
  • test/js/bun/s3/s3-writer-end-error.test.ts

Comment thread src/runtime/webcore/streams.rs Outdated
Comment thread test/js/bun/s3/s3-writer-end-error.test.ts
Comment thread test/js/bun/s3/s3-writer-end-error.test.ts Outdated
…ct stderr check; bounded poll for negative case
Comment thread src/runtime/webcore/streams.rs Outdated
MultiPartUpload::fail() synchronously invokes the wrapper callback with
callback_context = *mut NetworkSink, which would form a second &mut
NetworkSink while fail_from_js's &mut self is live (a callback_ctx
contract violation). Detach self.task first and replace the callback
with a no-op so fail() cannot re-enter the sink; release the sink's
ref explicitly afterwards.

Also disable LSan in the test subprocess: the -e fixture triggers
pre-existing transpiler/sourcemap leaks that are suppressed in the
outer runner via leaksan.supp but not in the child.
Comment thread src/runtime/webcore/streams.rs Outdated
robobun added 2 commits July 7, 2026 17:24
When fail() runs while InitiateMultipartUpload is still in flight
(state == MultipartStarted), start_multi_part_request_result used to
early-return on state == Finished without reading the UploadId the
server just assigned, leaving the upload orphaned. Parse the id and
issue AbortMultipartUpload on that path so end(error) called in the
same turn as the write that dispatched the init still cleans up. A
new race test case covers this.
Comment thread src/runtime/webcore/s3/multipart.rs
Deduplicate the <UploadId> extraction and validation between the
Finished and Success branches of start_multi_part_request_result.
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is green. The new test test/js/bun/s3/s3-writer-end-error.test.ts passes on all lanes that ran including x64-asan with detect_leaks=1 and validateExceptionChecks=1, and the existing S3/sink/stream-iter/expect suites are unaffected.

The remaining CI red across the last three builds is unrelated to this change:

  • darwin-26-aarch64-test-bun (70025, 70036): buildkite-agent artifact download timed out after 120s, the lane never ran any tests.
  • test-worker-message-port-transfer-terminate.js on x64-asan (70061): JSC exception-scope assertion in JSValue::get() during a worker-termination/message-port race. Not reachable from this diff (isAnyError walks ClassInfo, not properties; no worker or message-port code was touched). Passes locally 5/5 with validateExceptionChecks=1.
  • test/package.json verdaccio integrity / cpu-prof.test.ts / bun-install.test.ts / bake/dev-and-prod.test.ts / serve.test.ts on Windows: already classified as flaky warnings by the runner.

Ready for review/merge.

Comment thread src/runtime/webcore/streams.rs Outdated
…ToSync

NetworkSink::end_from_js now gates on is_any_error() instead of
!is_empty_or_undefined_or_null(), so non-Error argument shapes (e.g.
option bags) are ignored and the upload commits, matching the
documented end(error?: Error) contract.

node:stream/iter pipeTo was passing {signal} to writer.end() on the
success path while the sibling pipeToSync passes no argument; align
the async path so neither trips a sink that interprets the first
argument. A new test case confirms end({signal}) commits.

@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: 1

🤖 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 `@src/js/internal/streams/iter/pull.ts`:
- Around line 955-957: Recheck the abort signal in the close/commit path of the
iterator pull flow so a late abort isn’t skipped after the last write completes.
In the logic around writer.endSync() and writer.end?.() in pull.ts, add a final
signal check before proceeding to close/return, and route that case through the
same abort handling used by the catch path instead of committing normally.
🪄 Autofix (Beta)

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: 44ad2c1e-9678-47d2-99ac-8cbc59d37524

📥 Commits

Reviewing files that changed from the base of the PR and between 1af7195 and 7cd5724.

📒 Files selected for processing (3)
  • src/js/internal/streams/iter/pull.ts
  • src/runtime/webcore/streams.rs
  • test/js/bun/s3/s3-writer-end-error.test.ts

Comment thread src/js/internal/streams/iter/pull.ts
Comment thread src/runtime/webcore/streams.rs
JSDOMException uses JSC::ObjectType rather than ErrorInstanceType, so
is_any_error() returned false for it even though its prototype chains
through Error.prototype and DOMException instanceof Error is true.
AbortController.abort() produces a DOMException as signal.reason, so
end(signal.reason) was falling through to the commit path.

Extend JSC__JSValue__isAnyError to also match WebCore::JSDOMException.
This is semantically correct for every existing caller (expect
matchers, napi_is_error, promise wrapping, handler-returned-error
checks) since Node treats DOMException as a native Error too. A new
test case covers end(AbortSignal.reason).

@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 new issues found on this pass, but the unsafe task-detach/callback-swap/deref_ sequence in fail_from_js and the widening of JSC__JSValue__isAnyError to include DOMException (which changes semantics for every existing is_any_error() caller — napi, expect matchers, etc.) are both worth a human sanity check before merge.

Extended reasoning...

Overview

This PR wires the previously-ignored end(error?) argument through the JsSinkType::end_from_js trait to NetworkSink, adds NetworkSink::fail_from_js (which detaches the multipart task, swaps its callback to a no-op, drives fail() via a raw pointer, and manually releases the sink's ref), extends MultiPartUpload::start_multi_part_request_result to roll back an UploadId that arrives after fail(), extracts a shared parse_upload_id helper, extends JSC__JSValue__isAnyError in bindings.cpp to also recognize WebCore::JSDOMException, drops the {signal} argument from pipeTo's success-path writer.end?.(), and adds a 263-line local-stub S3 test covering six scenarios.

Security risks

None identified. The change is data-integrity oriented (don't commit truncated S3 objects). No auth, crypto, or input-validation surface is touched.

Level of scrutiny

High. This went through five prior bug-finding rounds on this PR, each surfacing a real issue (Stacked-Borrows/noalias UB from re-entrant &mut NetworkSink, orphaned upload when init races with fail(), pipeTo regression, DOMException falling through the is_any_error() gate). All were addressed, and the current bug-hunting pass found nothing new. But the final shape still contains:

  • An unsafe block in fail_from_js that manually manages a MultiPartUpload refcount (self.task.take() → mutate fields via raw ptr → fail()deref_), replacing what wrapper_callback → detach_writable used to do. Refcount balance across the NotStarted/MultipartStarted/MultipartCompleted/Finished state matrix is subtle and exactly the class CLAUDE.md flags as most-blocked.
  • A cross-cutting semantic change: isAnyError now returns true for DOMException. The author asserts this is correct for every existing caller (napi_is_error, expect matchers, promise wrapping, handler-returned-error checks), which is plausible given DOMException instanceof Error === true, but it's a global behavior change riding on an S3 fix and deserves a maintainer nod.

Other factors

All prior review threads (mine and CodeRabbit's) are resolved. Test coverage is thorough for the S3 path (multipart mid-flight, init race, DOMException, sub-part, no-error control, non-Error-arg control). The pull.ts change is a behavioral no-op (the arg was never read before this PR) and the author correctly declined CodeRabbit's out-of-scope throwIfAborted suggestion. Given the memory-safety surface and the isAnyError widening, I'm deferring rather than approving.

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