Skip to content

s3: stop retrying an AbortMultipartUpload that succeeded - #43099

Closed
robobun wants to merge 3 commits into
mainfrom
robobun/eed1638e/s3-multipart-abort-204
Closed

robobun wants to merge 3 commits into
mainfrom
robobun/eed1638e/s3-multipart-abort-204

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A failed multipart upload sends AbortMultipartUpload (DELETE ?uploadId=) to delete the stored parts. The success answer is 204 No Content (sources in Notes). Bun reads the 204 as a failure and sends the abort again: 1 + retry requests, 4 with the default retry: 3.
  • The cause: rollback_multi_part_request (multipart.rs:848) registers S3Callback::Upload. That arm of on_response (simple_request.rs:344) accepts only 200.
  • No user reported this. JS sees no change, because the rollback reports no result. The extra signed requests are the only symptom.

Fix

  • Register the rollback as S3Callback::Delete. That arm (simple_request.rs:321) accepts 200 and 204, and reports 404 as NotFound.
  • on_rollback_multi_part_request stops on Success and on a 404 with the code NoSuchUpload: the upload id is gone. Every other failure still retries (403, 5xx, network error, a 404 from a proxy).
  • Verified: test/js/bun/s3/s3.test.ts, "s3 multipart upload rollback". Without the fix, the 204 and NoSuchUpload cases fail with 4 abort requests. The 500 and proxy 404 cases pin the retries that remain. Also ran the other files in test/js/bun/s3/ (details in Notes).
  • Self-reviewed: 7 changes requested, all to the title and this text. All made.

Background

  • S3File.writer() and S3Client.write() go multipart when the data reaches partSize: CreateMultipartUpload, one UploadPart per part, then CompleteMultipartUpload.
  • If a part fails after its retries, MultiPartUpload::fail rejects the caller's promise, then starts the rollback.
  • execute_simple_s3_request takes a Callback enum. The variant selects which statuses on_response counts as success, and the handler's result type.
Notes

History. This is #33682 again. A cleanup of stale pull requests closed that PR on 2026-09-13 without a review of the fix. It passed CI on every lane that ran. The test moved from a new file into s3.test.ts.

Measured. Local stub, every part fails with 500, retry: 3. DELETE requests sent:

abort answer 1.4.3-canary.1+c6b7fcb5b this PR
204 4 1
404 NoSuchUpload 4 1
404, no S3 error code 4 4
500 4 4
200 1 1

Sources for 204.

404 handling. The first commit stopped on every 404. The review on this PR pointed out two facts. A proxy in front of the store can answer 404 while the upload still exists. error_with_body reports a 404 without a <Code> as NoSuchKey, so the handler cannot tell that case from a store answer by the variant alone. The second commit stops only on NoSuchUpload, the one error that the AWS model lists for this operation. Any other 404 retries, as it did before this PR.

Not changed, on purpose.

  • The part, single PUT and commit loops (multipart.rs:284, :410, :731) still retry on every failure, 403 included. A retry policy by error class is a separate change.
  • A CompleteMultipartUpload answered with 404 stays a plain failure.
  • CreateMultipartUpload still registers S3Callback::Download. Its success status is 200, and that arm accepts 200.
  • The rollback is the only S3Callback registration whose variant rejects the success status of its operation.

Suites. I ran all 18 test files in test/js/bun/s3/ on the debug build. All pass except one test in s3-list-objects.test.ts: "Should fall back to NoSuchKey for a 404 whose has no usable " times out at 5000 ms when the whole file runs on a debug build. It passes alone. #38357 documents that timeout on main and fixes it. This PR does not touch list().

Related. #33681, #33686, #34045 and #35875 touched the same rollback and retry code. The same cleanup closed them. Open #41688 and #42590 add new rollback_multi_part_request() call sites. Each new call site sends the same repeated aborts until this lands.

Conflict with #40252. That PR rewrites on_rollback_multi_part_request and still registers S3Callback::Upload with S3UploadResult. When the two conflict, keep S3Callback::Delete and S3DeleteResult. A resolution that takes the other side compiles and drops the fix. The test in this PR then fails.


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 286 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/s3/s3.test.ts"
bun test v1.4.3 (c6b7fcb5b)

test/js/bun/s3/s3.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(skip) R2 > s3 > fetch > bucket in path > should download file via fetch GET
(skip) R2 > s3 > fetch > bucket in path > should download range
(skip) R2 > s3 > fetch > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > fetch > bucket in path > should check if a key does not exist
(skip) R2 > s3 > fetch > bucket in path > should be able to set content-type
(skip) R2 > s3 > fetch > bucket in path > should be able to upload large files
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download file via Bun.s3().text()
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range with 0 offset
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check i
... (truncated)

release without fix: 2 failed, 286 skipped
bun test v1.4.3-canary.1 (c6b7fcb5b)

test/js/bun/s3/s3.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(skip) R2 > s3 > fetch > bucket in path > should download file via fetch GET
(skip) R2 > s3 > fetch > bucket in path > should download range
(skip) R2 > s3 > fetch > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > fetch > bucket in path > should check if a key does not exist
(skip) R2 > s3 > fetch > bucket in path > should be able to set content-type
(skip) R2 > s3 > fetch > bucket in path > should be able to upload large files
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download file via Bun.s3().text()
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range with 0 offset
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check if a key does not exist
(skip) R2 > s3 > Bun.S3Client > 
... (truncated)
passes on PR (with fix)
ASAN with fix: 286 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/s3/s3.test.ts"
bun test v1.4.3 (c6b7fcb5b)

test/js/bun/s3/s3.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(skip) R2 > s3 > fetch > bucket in path > should download file via fetch GET
(skip) R2 > s3 > fetch > bucket in path > should download range
(skip) R2 > s3 > fetch > bucket in path > should check if a key exists or content-length
(skip) R2 > s3 > fetch > bucket in path > should check if a key does not exist
(skip) R2 > s3 > fetch > bucket in path > should be able to set content-type
(skip) R2 > s3 > fetch > bucket in path > should be able to upload large files
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download file via Bun.s3().text()
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range
(skip) R2 > s3 > Bun.S3Client > bucket in path > should download range with 0 offset
(skip) R2 > s3 > Bun.S3Client > bucket in path > should check i
... (truncated)

release with fix: 286 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 624ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 242 extern-C blocks audited
[1/7] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[94m|�[0m
�[1m�[33mwarning�[0m: `bun_shim_impl` (manifest) generated 1 warning
�[1m�[33mwarning�[0m�[1m: `feature(generic_const_exprs)` is not supported with
... (truncated)
diff hotspot
src/runtime/webcore/s3/multipart.rs |  24 +++++----
 test/js/bun/s3/s3.test.ts           | 102 ++++++++++++++++++++++++++++++++++++
 2 files changed, 117 insertions(+), 9 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                 reads  edits  tests
src/runtime/webcore/s3/multipart.rs      3      6     19
test/js/bun/s3/s3.test.ts                2      4     19

When a multipart upload fails, the writer sends AbortMultipartUpload
to delete the stored parts. The success answer to that request is 204
No Content (AWS service model, MinIO). The rollback registered its
callback as S3Callback::Upload, and that arm of on_response accepts
only 200. So the handler saw each 204 as a failure and sent the abort
again: 1 + retry requests for each failed upload, 4 with the default.

Register the rollback as S3Callback::Delete. That arm accepts 200 and
204 as success. It reports 404 as NotFound, and the handler stops
there too: the upload id is already gone. Every other failure still
retries.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 3 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 3 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 4 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 5bd1c228-9872-4d75-aa7c-931ce3450a68

📥 Commits

Reviewing files that changed from the base of the PR and between b52d513 and 189e1de.

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

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

@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.3-canary.1+c6b7fcb5b and on a debug build of main (630e921) with a local S3 stub. The stub fails every UploadPart with 500 and answers AbortMultipartUpload with a chosen status. DELETE requests that bun sends with retry: 3:

abort answer before with this PR
204 No Content 4 1
404 NoSuchUpload 4 1
404 with no S3 error code (a proxy) 4 4
500 InternalError 4 4

To run the repro: bun bd test test/js/bun/s3/s3.test.ts -t "s3 multipart upload rollback". With src/ at the base commit, the 204 and NoSuchUpload cases fail with 4 abort requests. With this PR, all 4 cases pass.

This PR replaces #33682, which a cleanup of stale pull requests closed.

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

Beyond the inline finding, I also checked that Callback::Delete in simple_request.rs still treats 200 as success (so the pre-fix 200 behavior is unchanged), that the Failure arm keeps the same retry-and-deref sequence as before, and that the missing abort after a failed CompleteMultipartUpload is pre-existing and not introduced here.

Extended reasoning...

The diff is a two-line callback-variant swap plus a matching enum change in on_rollback_multi_part_request, and the new test pins 204/404/500 abort responses via a loopback stub. The only open question is the policy choice in the NotFound arm (already raised inline); the rest of the change preserves existing behavior for 200 and for all non-404 failures, and the deref_(this) calls remain on every terminal path.

Comment thread src/runtime/webcore/s3/multipart.rs Outdated
Only a 404 with the code NoSuchUpload says the upload id is gone. A
proxy in front of the store can answer 404 too, and that answer says
nothing about the upload. Such a 404 retries like any other failure,
as it did before.
@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

The second commit (e6c2af7) applies the optional suggestion from the review. The rollback now stops only on a 404 with the code NoSuchUpload. Any other 404 retries, as it does on main, and a new test case pins that. The PR text and the status comment describe the change as it now stands.

@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 — the follow-up commit addresses the earlier inline concern: a 404 now only stops the rollback when the parsed <Code> is exactly NoSuchUpload, and every other 404 (including the bare gateway 404 that error_with_body maps to NoSuchKey) falls through to the same retry loop as Failure.

What was reviewed:

  • Refcount balance in on_rollback_multi_part_request: each terminal arm calls deref_ exactly once, and the retry arm hands the ref to the re-issued request unchanged from the previous code.
  • The Delete arm of on_response in simple_request.rs accepts 200/204 and routes 404 through error_with_body, so the NoSuchUpload match is reachable only with a real XML error code.
  • The new it.each covers 204, 404 NoSuchUpload, plain-text 404, and 500 with exact request-order assertions in a fresh subprocess against a loopback stub.
Extended reasoning...

Overview

The PR changes one callback registration in src/runtime/webcore/s3/multipart.rs (S3Callback::Upload → S3Callback::Delete) and rewrites the match in on_rollback_multi_part_request to take S3DeleteResult. Success and a 404 whose parsed code is NoSuchUpload stop immediately; any other 404 or failure decrements options.retry and re-issues the abort. A describe.concurrent block with four it.each cases is added to test/js/bun/s3/s3.test.ts.

Security risks

None identified. The change only affects how many times a best-effort AbortMultipartUpload is re-sent after a failed upload. No credential handling, signing, or user-controlled parsing changes; the NoSuchUpload comparison is a slice equality on bytes already parsed by the existing XML error parser.

Level of scrutiny

Moderate. The function is a raw-pointer callback that owns a ref on MultiPartUpload, so the refcount balance per arm needed checking: both terminal arms deref_ once, and the retry arm returns without deref because the re-issued request inherits the ref — identical to the pre-existing structure. The second commit (e6c2af7) changed the NotFound handling from unconditional stop to code-gated stop, which was the substance of the prior inline comment; I verified in simple_request.rs:239-245 that a 404 with no XML <Code> is reported as NoSuchKey, so a proxy 404 does not match the NoSuchUpload arm and still retries.

Other factors

The test spawns a subprocess per case with a loopback Bun.serve stub, drains stdout/stderr/exited concurrently, asserts exact request order before exit code, and uses port: 0. The 204 and 404-NoSuchUpload cases fail on the base (4 aborts vs 1), and the proxy-404 and 500 cases pin the retry behavior that remains. No CODEOWNER covers these paths, and the only prior objection (mine) was addressed in code rather than by thread resolution. The doc comment above the function ("if it fails we will retry, if it still we just deinit") still describes the behavior accurately.

needless_pass_by_value is denied in the workspace. The callback type
fixes the by-value signature, so the retry arm binds the error, as the
handler did before.

@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 — the follow-up commits address the 404 concern from the earlier review: the rollback now stops only on Success or a 404 whose parsed <Code> is NoSuchUpload, and every other 404 or failure still goes through the existing retry loop.

What was reviewed:

  • Callback::Delete routing in simple_request.rs: 200|204 → Success, 404 → error_with_body(NotFound) (which substitutes NoSuchKey only when no <Code> is present, so a bare proxy 404 cannot match the NoSuchUpload arm), everything else → Failure.
  • Ref balance in on_rollback_multi_part_request: each terminal arm derefs exactly once; the retry arm re-issues the request and returns without deref, matching the pre-existing shape.
  • The it.each test covers 204, NoSuchUpload, bare 404, and 500 with exact request sequences and runs in a subprocess against a loopback stub, with proxy env vars cleared.
Extended reasoning...

Overview

The PR changes one callback registration in src/runtime/webcore/s3/multipart.rs (S3Callback::Upload → S3Callback::Delete for the AbortMultipartUpload rollback) and rewrites the match in on_rollback_multi_part_request to take an S3DeleteResult. Success and a NotFound carrying code NoSuchUpload deref immediately; any other NotFound or Failure decrements retry and re-issues the abort, dereffing only when retries are exhausted. A new describe.concurrent block in test/js/bun/s3/s3.test.ts pins the number of abort requests for four stub responses.

Security risks

None. The change affects only the response handling of a best-effort cleanup request against an endpoint the user already configured; it does not alter signing, credentials, or any user-facing result. The NoSuchUpload match is a byte-exact comparison against the parsed XML code, so a response body cannot widen the stop condition beyond that single value.

Level of scrutiny

Moderate. It is a small, self-contained change in the S3 client, but it involves a raw-pointer callback with manual refcount handling. I traced Callback::Delete through on_response, error_with_body, and not_found in simple_request.rs to confirm the status → variant mapping and the NoSuchKey fallback; the handler's ref discipline is unchanged from the base (one deref per terminal path, none on the retry path). The concern raised in the previous review — treating every 404 as terminal — was addressed in commit e6c2af7 by narrowing the stop condition to NoSuchUpload, and the third commit is a clippy-driven binding rename with no behavior change.

Other factors

The test exercises the behavior through a spawned child against an in-process Bun.serve({ port: 0 }) stub, drains stdout/stderr/exit concurrently, asserts the exact request sequence via toEqual, and clears proxy env vars so the loopback endpoint is reached directly. The 204 and NoSuchUpload cases fail on the base (4 aborts instead of 1), and the bare-404 and 500 cases pin the retained retry behavior. No CODEOWNERS entry covers the changed paths, and there is no outstanding third-party objection in the timeline.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:39 AM PT - Sep 17th, 2026

✅ @robobun, your commit 189e1de8cc274d87778e84a4827781beac7f22fb passed in Build #117099! 🎉


🧪   To try this PR locally:

bunx bun-pr 43099

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

bun-43099 --bun

@robobun

robobun commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #41688 landed this fix. On main the rollback completes through the Delete callback kind, so a 204 answer to AbortMultipartUpload is final and the abort goes out once.

I checked this with the stub from this PR against a debug build of main (6d504dd), with retry: 3. DELETE requests sent: 204 gives 1, 404 NoSuchUpload gives 1, 500 gives 4. The build before #41688 (367d939) sends 4 in every case.

One difference remains. Main treats every 404 answer to the abort as final. This PR retried a 404 that has no NoSuchUpload code, for example one from a proxy in front of the store. #41688 made that choice on purpose. If that case matters, it can be a follow-up on top of #41688.

@robobun robobun closed this Sep 23, 2026
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