Conversation
Every s3.file(k).writer() leaked one NetworkSink. Two owners hold a raw pointer to it (the JSNetworkSink wrapper's m_sinkPtr and the MultiPartUpload.callback_context) and both release paths routed through NetworkSink::finalize(), which only detached the upload task. finalize_and_destroy() existed but had no callers. Give NetworkSink a CellRefCounted refcount (rc=1 from Default, +1 for callback_context in writable_stream). finalize() now also derefs; abort() stops at detach_writable() so wrapper_callback's failure path does not double-deref before its own trailing finalize().
|
Warning Review limit reached
Next review available in: 4 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
WalkthroughThis change adds a wrapper-detachment callback path, preserves pending ChangesSink lifecycle and leak handling
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 7:15 PM PT - Jul 21st, 2026
✅ @robobun, your commit 4c1d8cf025752e4d27942a4fc6a0099e1ecc97fa passed in 🧪 To try this PR locally: bunx bun-pr 34999That installs a local version of the PR into your bun-34999 --bun |
__doClose nulls m_sinkPtr before ~JSSink runs, so the destructor skipped
__finalize and the wrapper's intrusive ref on NetworkSink (and
ArrayBufferSink / FileSink) was never released on the .close() path.
__doClose now calls __finalize(ptr) after __close(ptr).
FileSink::finalize no longer clears self.pending: .close() can now reach
finalize while a backpressured write promise is still outstanding, and
run_pending settles it once the writer drains. deinit() drops the field
at rc=0.
Also: set ref_count=2 in the NetworkSink initializer (matches the other
two-owner allocations in client.rs), strip http_proxy/HTTPS_PROXY from
the leak test's env, and move {retry: 0} onto .writer() where it is
actually read.
There was a problem hiding this comment.
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/FileSink.rs`:
- Around line 935-939: Shorten the pending-lifecycle comment near
finalize/doClose to three lines or fewer while preserving the invariant that
pending is not cleared during close, run_pending settles backpressured writes
after draining, and deinit drops the field when the refcount reaches zero.
In `@src/runtime/webcore/streams.rs`:
- Around line 2136-2139: Condense the ownership comment above the raw
NetworkSink pointer to three lines or fewer while preserving that JSNetworkSink
and MultiPartUpload.callback_context own it, both release through their
respective finalization paths, and finalize() frees it via intrusive reference
counting.
In `@test/js/bun/s3/s3-networksink-leak.test.ts`:
- Around line 4-14: Remove the multi-line historical regression explanation from
the test, preserving the test name and assertions unchanged. Retain only an
issue URL comment if one is present or available, and ensure any remaining
comment is no more than three lines.
🪄 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: 39308ea1-695f-4988-a148-7c48384a1fe0
📒 Files selected for processing (5)
src/codegen/generate-jssink.tssrc/runtime/webcore/FileSink.rssrc/runtime/webcore/s3/client.rssrc/runtime/webcore/streams.rstest/js/bun/s3/s3-networksink-leak.test.ts
|
CI on 4d2f779 (build #77266): the diff is green. The only hard failure is |
There was a problem hiding this comment.
The .close() leak I flagged earlier is now fixed via ${name}__finalize(ptr) in __doClose, matching #29883's approach. I traced the refcount balance for all six affected sink types and it holds — but the codegen change fans out to every JSSink's .close() path plus the FileSink pending lifecycle tweak, so this warrants a human look.
What was reviewed:
- NetworkSink rc=2 balance across
.end()/.close()× success/failure — each path reaches exactly twofinalize()→deref()calls;abort()no longer double-derefs on the failure branch ofwrapper_callback. __doClose→__finalizefanout: ArrayBufferSink (destroy— was leaking, now freed once), FileSink (rc-based, keep-alive ref survives), HTTPServerWritable×3 (finalize()is documented idempotent, no rc).- Controller
__close/__endintentionally still skip__finalize—assign_to_streamtakes no per-wrapper +1 for the controller, so nothing to release there. - FileSink not clearing
pending: rc>1 viamust_be_kept_alive_until_eofwhile a write is outstanding, sorun_pendingstill has a live sink;deinit()drops the field at rc=0.
Extended reasoning...
Overview
Fixes an S3 NetworkSink leak by adding an intrusive CellRefCounted refcount with two owners (JS wrapper + MultiPartUpload.callback_context), and closes a related pre-existing leak where ${name}__doClose nulled m_sinkPtr before the destructor could reach __finalize. The codegen fix applies to all six JSSink types. FileSink::finalize stops clearing pending so a backpressured write promise survives .close(). abort() now stops at detach_writable() so the failure path in wrapper_callback doesn't double-deref before its trailing finalize().
Security risks
None. This is internal lifetime management; no user-controlled input reaches new validation or allocation paths.
Level of scrutiny
High. Per REVIEW.md, native memory safety is the most-blocked category. The refcount must be provably balanced on every terminal path, and the __doClose codegen edit fans out to six sink types with three different finalize() disciplines (rc-based free, direct destroy, idempotent no-free). I verified each:
- NetworkSink: rc=2 at construction;
.end()path releases via~JSNetworkSink→__finalize+wrapper_callback→finalize();.close()path releases via__doClose→__finalize+wrapper_callback→finalize().abort()sole caller iswrapper_callback's failure arm, which follows withfinalize()— soabort()dropping its ownderef()is required to avoid a double-release. - FileSink: already rc-based; wrapper's +1 is now released on
.close()where it previously leaked. If a write is pending,end(None)takes the keep-alive +1 so rc stays ≥1 throughfinalize()'sderef(), andrun_pendinglater settles the (now-preserved)pendingpromise. - ArrayBufferSink:
finalize()→destroy()frees directly;end(None)does not free, so the new__finalizecall is the single free (previously leaked). - HTTPServerWritable×3:
finalize()is explicitly designed to be called many times and does not free;end(None)may call it internally, and the second call from__doCloseseesdone=trueand the buffer-pool cleanup is idempotent. - Controller paths (
${controller}__close/__end): intentionally unchanged —assign_to_streamtakes no per-controller +1 (see the comment inFileSink::assign_to_stream), so there is no ref to release.
Other factors
My earlier review found the .close() gap; it was fixed following #29883's exact approach and the leak test now covers both .end() and .close() × success/failure under detect_leaks=1. The PR reports filesink/arraybuffersink/serve suites unchanged vs main. Despite the analysis checking out, the cross-cutting nature (generated C++ affecting six native lifecycle paths) and the FileSink.pending behavioral tweak make this worth a human maintainer's eyes rather than a bot-only approval.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
finalize is typically called during GC finalization. When it is called during GC finalization, we can't allocate JS objects or enter JavaScript. Calling pending.run would potentially enter JavaScript, and this code would crash.
Instead, we should add a different method for cleaning up and move most of FileSink's logic for finalize into it, and then both call it.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
callback_context should not call finalize? It should call deref
…ctly JsSinkType gains wrapper_detached() (called from the new __wrapperDetached extern in __doClose) so the .close() release path is distinct from the GC-sweep finalize(). FileSink::finalize is reverted to main's behaviour (clears pending) and factored through release_wrapper_ref(); wrapper_detached() calls only release_wrapper_ref() so a backpressured write promise survives .close(). NetworkSink::finalize is now a bare deref(); the sink's ref on the MultiPartUpload moves to deinit() (rc=0). wrapper_callback releases the task's +1 on the sink via NetworkSink::deref directly.
|
Addressed both in 3451a9f:
|
Moving detach_writable() to deinit() (rc=0) meant the MultiPartUpload's poll_ref stayed ref'd until the JSNetworkSink wrapper was swept, so a writer retained past await w.end() kept the process alive. wrapper_callback now releases the sink's +1 on the task before the sink deref; deinit() still handles the case where the wrapper went away first. New test covers the retained-writer exit path.
There was a problem hiding this comment.
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/FileSink.rs`:
- Around line 1205-1210: Add a regression test for FileSink covering the
wrapper_detached path: force a write to remain pending under backpressure,
invoke .close(), and assert the retained write Promise eventually settles.
Ensure the test uses FileSink rather than only S3 NetworkSink and verifies the
pending-write behavior introduced in wrapper_detached.
- Around line 931-938: Condense the ownership comment near JsSinkType::construct
to no more than three lines, while preserving the invariant that
to_js/to_js_with_destructor add a wrapper reference released by finalize,
construct’s initial ref_count belongs to the stored wrapper, and init/create
callers release their initial reference after to_js.
In `@src/runtime/webcore/s3/client.rs`:
- Around line 470-475: Update the callback containing the resolve/reject
settlement calls to capture their result instead of propagating with ?, then
always execute NetworkSink::detach_writable and the unsafe NetworkSink::deref
teardown before returning the captured result. Preserve the existing settlement
behavior while ensuring JsTerminated and other errors cannot bypass callback
cleanup.
🪄 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: 4de1daaf-8091-4cdb-bc63-a669b797dbeb
📒 Files selected for processing (6)
src/codegen/generate-jssink.tssrc/runtime/webcore/FileSink.rssrc/runtime/webcore/Sink.rssrc/runtime/webcore/s3/client.rssrc/runtime/webcore/streams.rstest/js/bun/s3/s3-networksink-leak.test.ts
…ackpressure test wrapper_callback's detach+deref now runs via a scopeguard so a JsTerminated from promise settlement cannot skip it. Added a filesink.test.ts case that fills a socket pair, calls .close(), and asserts the pending write promise still settles. Shortened the release_wrapper_ref comment to three lines.
…iter tests under ASAN The writer() tests trip LeakSanitizer on the release-asan lane via a pre-existing NetworkSink leak (fix open in #34999); skip them under ASAN until that lands.
### Problem
The JSSink finalize chain frees the sink while reference arguments to it
are still live. At `3fc747a7da`:
* generated thunk `extern "C" fn ${name}__finalize(this: &mut ${name})`
(src/codegen/generate-jssink.ts), called from `~JS${name}`,
`~JSReadable${name}Controller` and `${name}__doClose`
* `JSSink::js_finalize(this: &mut T)` (src/runtime/webcore/Sink.rs)
* `JsSinkType::finalize(&mut self)` (src/runtime/webcore/Sink.rs), whose
impls do the actual release:
* `ArrayBufferSink` (src/runtime/webcore/ArrayBufferSink.rs):
`Self::finalize(ptr::from_mut(self))` -> `destroy` -> `heap::take`,
unconditionally. The comment on the impl said the C export owned the
free; this call is the free.
* `FileSink` (src/runtime/webcore/FileSink.rs): the inherent
`finalize(&mut self)` ends in `FileSink::deref(ptr::from_mut(self))`,
which runs `deinit` -> `heap::take` whenever the wrapper's +1 was the
last ref, i.e. on an ordinary GC sweep of a sink nothing else holds. The
header comment argued this was fine because the `&mut` carries write
provenance, which is true but is not the problem.
* `FetchRequestBodySink`
(src/runtime/webcore/fetch/FetchRequestBodySink.rs): drops the tasklet
ref taken in `start_request_stream`. The tasklet owns the sink
allocation, so if that ref is the last one, `FetchTasklet::deinit` ->
`clear_data` -> `clear_sink` -> `heap::take(sink)` frees `*self` inside
the call. That is the fallback path for a pump that never settled; it is
reachable at least on worker teardown: phase B of `VirtualMachine`
teardown releases the aborted fetch's other refs on the tasklet, and
phase C then destroys the heap, sweeping the controller with `m_sinkPtr`
still set because `JSSinkController__onClose` does not run the detaching
JS callback once termination is pending.
* `HTTPServerWritable`, `NetworkSink` and `RewriterPipe` do not free
anything here (their allocations are owned by the `RequestContext`, the
S3 wrapper and the pipe's own refcount respectively).
A reference passed as an argument has to stay dereferenceable until the
call returns. Freeing it from inside the call is undefined behaviour
under both aliasing models whether or not the reference is used again
(Stacked Borrows: `deallocating while item is strongly protected`; Tree
Borrows, which `bun run rust:miri` uses, rejects it the same way), and
that protector is the model behind the `dereferenceable` attribute rustc
puts on every `&`/`&mut` argument, so the optimizer may legitimately
move a load through any of the three frames past the free. No crash is
known from this; ASAN only has something to catch if the optimizer
actually takes that liberty, which the unoptimized debug build never
does, so it is not observable as a runtime test. Same family as #37672,
#37681, #37685, #37693, #37705 and #37551; #37705's description leaves
this chain out explicitly because it needs a change to the generated
thunk.
### Fix
The whole chain takes the raw pointer, which is what the C++ side has
anyway (`void* m_sinkPtr`):
* generate-jssink.ts emits `pub unsafe extern "C" fn
${name}__finalize(this: *mut ${name})` forwarding to `js_finalize`; the
ABI is unchanged, so JSSink.cpp is untouched.
* `JSSink::js_finalize(this: *mut T)` forwards to the trait.
* `JsSinkType::finalize` becomes `unsafe fn finalize(this: *mut Self)`,
documented as "the cell is giving up its claim; this may free the sink",
the same shape as `HTTPServerWritable::abort(this: *mut Self)` and the
FileSink PipeWriter callbacks.
* The three freeing impls release through the pointer without forming a
reference to the allocation: `ArrayBufferSink` calls `destroy` directly
(the inherent `finalize` wrapper, whose only caller was the trait impl,
is deleted); `FileSink::finalize(this: *mut FileSink)` keeps the same
body with per-statement `(*this).field` access, like `on_close` in the
same file (the file header no longer claims the `&mut` version was
sound; the rationale lives once, on the trait method);
`FetchRequestBodySink::finalize(this: *mut Self)` takes `task` out
through the pointer and does not touch it after the deref.
* `HTTPServerWritable` and `NetworkSink` reborrow inside their own impl
to call the unchanged inherent `finalize(&mut self)`; that borrow ends
before the impl returns and nothing under it frees, which the SAFETY
comments state. `RewriterPipe`'s impl stays empty.
Every impl performs the same operations in the same order as before; the
only thing that moves is the type the pointer travels as.
`js_controller_detached`, `js_close` and `js_end_with_sink` still take
`&mut`: nothing frees under them (the `controller_detached` contract on
the trait already requires deferring a last-owner free for that reason).
`FileSink::assign_to_stream`'s `FileSinkRef` guard also derefs from a
`&mut self` frame, but its ref is balanced against one it took itself
and every caller (subprocess stdin setup) holds its own ref across the
call, so it can never be the one that frees; left alone. Sites with the
same shape outside this chain
(`S3UploadStreamWrapper::handle_{resolve,reject}_stream`,
`FetchTasklet::write_end_request`) are not sink frames and are reported
separately.
### Tests
test/internal/source-lints/jssink-finalize-raw-ptr.test.ts scans every
`impl ... JsSinkType for ...` block for a `finalize` item and requires
`unsafe fn finalize(<ident>: *mut Self)`, checks the other frames by
signature (trait declaration, `js_finalize`, the codegen template, and
the three inherent methods that perform the free, which `pub` tells
apart from the trait impls in the same files), and checks its own
patterns against positive and negative spellings. With src/ restored to
`main` it reports:
```
src/runtime/api/html_rewriter.rs:1650: impl JsSinkType for RewriterPipe: fn finalize(&mut self) (line 1661)
src/runtime/webcore/ArrayBufferSink.rs:213: impl JsSinkType for ArrayBufferSink: fn finalize(&mut self) (line 221)
src/runtime/webcore/fetch/FetchRequestBodySink.rs:274: impl JsSinkType for FetchRequestBodySink: fn finalize(&mut self) (line 281)
src/runtime/webcore/FileSink.rs:1283: impl JsSinkType for FileSink: fn finalize(&mut self) (line 1294)
src/runtime/webcore/streams.rs:2104: impl JsSinkType for HTTPServerWritable: fn finalize(&mut self) (line 2119)
src/runtime/webcore/streams.rs:2523: impl JsSinkType for NetworkSink: fn finalize(&mut self) (line 2530)
src/runtime/webcore/Sink.rs: JsSinkType::finalize declaration does not take the sink as `*mut`
src/runtime/webcore/Sink.rs: JSSink::js_finalize does not take the sink as `*mut`
src/codegen/generate-jssink.ts: generated `${name}__finalize` thunk does not take the sink as `*mut`
src/runtime/webcore/FileSink.rs: FileSink::finalize does not take the sink as `*mut`
src/runtime/webcore/fetch/FetchRequestBodySink.rs: FetchRequestBodySink::finalize does not take the sink as `*mut`
```
(`ArrayBufferSink::destroy` already took `*mut` on `main`; its entry is
a ratchet.)
The behaviour itself is the existing coverage of each finalize path; see
below.
### Verification
Debug (ASAN) build on Linux: `cargo clippy -p bun_runtime` and `rustfmt
--check` on the touched files are clean; the generated thunks have the
new signature. Passing: test/internal/source-lints/ (all 18 files),
test/js/bun/util/arraybuffersink.test.ts and filesink.test.ts (wrapper
sweep and prototype `.close()` for the two Box/refcount sinks),
test/js/bun/spawn/spawn.test.ts (stdin `FileSink` via
`assign_to_stream`), test/js/web/fetch/body-stream.test.ts,
fetch-abort-stream-body.test.ts and fetch-stream-cancel-leak.test.ts
(`FetchRequestBodySink`),
test/js/bun/http/serve-response-stream-sink-leak,
serve-direct-readable-stream, serve-stream-reject-flush-leak and
serve-async-stream-client-abort (`HTTPServerWritable` controller
teardown), test/js/web/fetch/server-response-stream-leak.test.ts,
test/js/web/streams/streams.test.js,
test/js/workerd/html-rewriter.test.js and html-rewriter-leak.test.ts
(`RewriterPipe`), test/js/bun/s3/s3-stream-error-gc.test.ts and
s3-argument-validation.test.ts. The S3 upload tests that would drive
`NetworkSink` (s3.test.ts, s3-storage-class.test.ts) cannot connect from
this environment and fail identically on the released binary, so that
impl (a one-line forward to the unchanged inherent method) is left to
CI.
Overlap with the sibling lints, each of which documents these sites as
tracked separately: #37685 / #37693 / #37705 add
`self-receiver-teardown.test.ts` with
`src/runtime/webcore/ArrayBufferSink.rs: 1` allowlisted for the
`Self::finalize(ptr::from_mut(self))` line this PR removes, and #37703
adds `self-receiver-release.test.ts` with
`src/runtime/webcore/FileSink.rs: 2` allowlisted for the two derefs
inside the old `FileSink::finalize(&mut self)` (running that lint
against this branch reports FileSink.rs at 0). Whichever side lands
second deletes the entry; nothing else conflicts (#37703's
FetchRequestBodySink.rs hunk is `end_from_stream`, a different
function). #34999 and #35528 edit the body of `FileSink::finalize`
textually but keep the receiver.
---------
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…fter fail, 204 abort) (#41688) ### Problem - A `S3File.writer()` dropped without `end()` leaks every byte written, leaves its multipart upload open, and keeps the event loop alive forever. - `NetworkSink::finalize` (`src/runtime/webcore/streams.rs:2343`) only drops the sink's ref. The `MultiPartUpload` keeps its owner ref, buffers and `KeepAlive` until `fail()` or `done()`, which nothing can call. - Also: a `fail()` during an in-flight CreateMultipartUpload left that upload open (`multipart.rs:704`). A 204 abort response counted as a failure, so each abort went out `retry` + 1 times (`simple_request.rs:367`). ### Fix - Commit 1: a Create response on a finished upload sends AbortMultipartUpload for the returned id. - Commit 2: the `writer()` finalizer queues a task (tag `S3UploadWriterCollected`) that calls `fail`: buffers freed, abort sent, `KeepAlive` released. - Commit 3: the rollback completes through the `Delete` callback kind. 200, 204 and 404 are final. - Verified: `test/js/bun/s3/s3-upload-abort.test.ts` (6 cases, each fails without its change). ### Background - `MultiPartUpload` is the native object behind one S3 upload. Its refs: the sink, each part in flight, and an owner ref that the final commit or rollback releases. - A finalizer runs inside a GC sweep, where no promise can be settled. So `fail` runs from an event-loop task. ### Downsides - A `flush()` promise pending when its writer is collected now rejects with `S3 writer was garbage collected before end() was called`. Before, it resolved and the process then hung. - Still open: `fail()` sends the abort while part uploads are in flight, and no second one (checked in the code). AWS documents that such a part can outlive the abort. Not verified here. Not tracked. <details><summary>Notes</summary> Rebased onto main. The first version queued a `ManagedTask`, which #43675 removed. The task is now `multipart::WriterCollected`, a `#[repr(transparent)]` wrapper over the upload with its own `Taskable` impl, a `run_task` arm, a `release_task_unrun` arm, and `task_tag::COUNT` 82 to 83. It carries one ref. Its context is the context of the script that made the writer. When that context stops, the upload's `abort_handle` fails it, so `release_unrun` only drops the task's ref. `writer_holders` on `NetworkSink` is 0 when a streaming upload (`S3UploadStreamWrapper`) owns the sink, so the finalizer hook acts only on the `writer()` path. A writer that called `end()` is not touched. The pending-`flush()` rejection only reaches code that can no longer call `end()`: a writer that a suspended async function still refers to is not collected. Commit 3 came from review. Against real S3 every AbortMultipartUpload (the two new callers here, and the existing stream-error rollback) was answered with 204, counted as failed, and sent again 3 more times by default. The later ones got 404 NoSuchUpload, also counted as failures. Isolated check on the debug build with `retry: 3` and a stub that answers 204: 4 aborts without commit 3, 1 with it. Repro from the report (80 dropped writers, 2 parts each, loopback stub): on 1.4.3 the stub sees 80 Create and 160 UploadPart, 0 Complete, 0 Abort, and the process never exits. On this branch (debug build with ASAN) the stub sees 80 Create and 80 Abort, and the process exits on its own in 4.6 s. No ASAN report. RSS was not measured on a release build. A writer with only 1000 buffered bytes pins the loop on 1.4.3 too, and exits here. `Bun.file(path).writer()` dropped the same way closes its fd and exits on both. The test cases: stream error while Create is in flight (no GC), a 204 abort with `retry: 3` (no GC), a dropped writer with parts already uploaded (Abort sent at once), a dropped writer collected while Create is in flight (Abort sent when the response lands), and a dropped writer with only buffered bytes (no request was ever sent, the process just exits). The uploaded-parts writer case keeps its writer reachable until both parts are uploaded, then drops it: a forced GC before that point aborted the upload early (0 parts, 1 abort), so an automatic one could have failed the case. The Create-in-flight writer case holds the Create response until the writer's pending `flush()` rejects. That is the one signal script gets that `fail()` ran, so the part count does not depend on GC or task order. Probes on this branch: 20 dropped writers then `process.exit(0)` in the same tick, a Worker that exits with 20 dropped writers (20 `fail AbortError` from the context stop, 20 frees), and `close()` without `end()` (it completes the upload, same as 1.4.3). All exit clean under ASAN. Suites run on the final build: `s3-upload-abort` (10 runs, 5 of 5 each), `s3-upload-stream-gc`, `s3-stream-error-gc`, `s3-stream-cancel-leak`, `s3-connection-close`, `s3-queueSize-validation`, `s3-storage-class`, `s3-requester-pays`: 38 pass, 0 fail. `s3.leak` skips without S3 credentials. `cargo clippy -p bun_runtime -p bun_event_loop` reports nothing in the touched files. No per-write cost: the finalizer hook runs once per collected writer, nothing per chunk. From review, after the first version: the collected-writer rejection had no `path`, unlike every other error of this writer. A probe showed it: a 403 on the same writer gave `path: "control-key"`, the new rejection gave none. The finalizer drops the sink's ref before the queued task runs, so `sink.path()` was `None`. The completion callback now takes the path from the upload, as it already did for `uploaded_bytes`, and the unused `NetworkSink::path` is removed. The gated writer case asserts `(path key)`. With the change reverted it fails with `(path undefined)`. The 204 case now also runs with a stub that answers 404. With `NotFound` routed to the failure arm the stub sees 4 aborts and that case fails. With the clause it sees 1. CI on f9087f1 (build 119830): 180 of 181 jobs passed and `s3-upload-abort.test.ts` passed on every lane. One red test: `test/js/bun/spawn/spawn.test.ts` ("an idle reader stopped at the highwater mark") on debian 13 x64-asan. What is known: it passed 3 of 3 runs on a local ASAN build of this branch, it is not listed in the last 8 finished builds of main, and its assertion text is not in the CI output that could be read. No link to this diff was found, and it is reported for triage. `s3.test.ts` timed out once on darwin in a large upload to R2 and passed on retry. That upload uses the streaming path, which the finalizer hook skips, and the same suite also flaked in two recent builds of main. About the open item under Downsides, and how commit 3 touches it: on main the rollback read the 204 as a failure and re-sent the abort while `retry > 0` (4 aborts by default, measured with the stub). AWS's advice for a part that is in flight during an abort is to repeat the abort. So main repeated it by accident on the stream-error and part-failure paths, and commit 3 stops that. Whether those repeats ever helped is not known: the later ones were answered 404, they were not timed to the parts, and what S3 keeps cannot be tested here. For a collected writer main sent no abort at all, so there this PR can only reduce what stays on the server. Test placement: the repo rule is to add tests to the module's existing file, and these cases are in a new file, `s3-upload-abort.test.ts`. An earlier version of this note said `s3.test.ts` only runs with real S3 credentials. That was wrong. Its blocks "s3 multipart upload id validation" and "s3 upload stream body error" are not gated, use a local `Bun.serve` stub, and spawn a child with `bunExe() -e`, the same shape as these cases. So these cases could live there. A sibling file is also an existing pattern in this directory (`s3-upload-stream-gc.test.ts` was added after those blocks), which is why the new file was not flagged in review. I did not move them, because that means one more push and CI run for a location change. I will move them into `s3.test.ts` if a maintainer prefers that. Related open PRs: #39692 covers the VM-teardown half of the same leak and rewrites `fail`. This PR changes the "a request still out drops its ref on Finished" rule for the Create response only. #34999 is an older take on freeing the sink box that `writer_holders` replaced. </details> <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 0 · 6 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 6 FAILED $ 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-upload-abort.test.ts" bun test v1.4.3 (367d939) test/js/bun/s3/s3-upload-abort.test.ts: 138 | // S3 answers AbortMultipartUpload with 204. A 404 means the store no longer has the upload. 139 | // Both are final: the abort must not be sent again. 140 | test.concurrent.each([204, 404])("AbortMultipartUpload answered with %d is not retried", async abortStatus => { 141 | expect( 142 | await run({ body: failingStream(`reqs.part === 1`), waitFor: `reqs.abort > 0`, retry: 3, abortStatus }), 143 | ).toEqual({ ^ error: expect(received).toEqual(expected) { "exited": 0, "stderr": "", "stdout": "rejected: source failed - {"create":1,"part":1,"complete":0,"abort":1,"put":0} + {"create":1,"part":1,"complete":0,"abort":4,"put":0} " , } - Expected - 1 + Received + 1 at <anonymous> (/workspace/bun/test/js/bun/s3/s3-upload-abort.test.ts:143:5) (fail) AbortMultipartUpload answered with 204 is not retried [410.92ms] 138 | // S3 answers AbortMultipartUpload with 204. A 404 means the s ... (truncated) release without fix: all passed bun test v1.4.3-canary.1 (f280f55) test/js/bun/s3/s3-upload-abort.test.ts: (pass) stream error while CreateMultipartUpload is in flight aborts the upload [21.17ms] (pass) dropped writer with only buffered bytes lets the process exit [18.01ms] (pass) AbortMultipartUpload answered with 204 is not retried [40.27ms] (pass) AbortMultipartUpload answered with 404 is not retried [41.07ms] (pass) dropped writer collected while CreateMultipartUpload is in flight still aborts it [44.70ms] (pass) dropped writer with uploaded parts aborts the multipart upload and lets the process exit [52.69ms] 6 pass 0 fail 6 expect() calls Ran 6 tests across 1 file. [120.00ms] __F:0:S:0 ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: all passed $ 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-upload-abort.test.ts" bun test v1.4.3 (367d939) test/js/bun/s3/s3-upload-abort.test.ts: (pass) stream error while CreateMultipartUpload is in flight aborts the upload [359.34ms] (pass) AbortMultipartUpload answered with 404 is not retried [345.85ms] (pass) AbortMultipartUpload answered with 204 is not retried [362.73ms] (pass) dropped writer collected while CreateMultipartUpload is in flight still aborts it [386.84ms] (pass) dropped writer with uploaded parts aborts the multipart upload and lets the process exit [409.72ms] (pass) dropped writer with only buffered bytes lets the process exit [306.80ms] 6 pass 0 fail 6 expect() calls Ran 6 tests across 1 file. [2.48s] __F:0:S:0 release with fix: all passed $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 739ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/29] gen generated_host_exports.rs generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited [2/28] rustc bun_event_loop [3/28] rustc bun_spawn [4/28] rustc bun_patch [5/28] rustc bun_http [6/28] rustc bun_bundler [7/28] rustc bun_transpiler [8/28] rustc bun_standalone_graph [9/28] rustc bun_bunfig [10/28] rustc bun_install [11/28] rustc bun_jsc [12/28] rustc bun_sys_jsc [13/28] rustc bun_ast_jsc [14/28] rustc bun_bundler_jsc [15/28] rustc bun_patch_jsc [16/28] rustc bun_semver_jsc [17/28] rustc bun_css_jsc [18/28] rustc bun_js_parser_jsc [19/28] rustc bun_sourcemap_jsc [20/28] rustc bun_install_jsc [21/28] rustc bun_http_jsc [22/28] rustc bun_sql_jsc [23/28] rustc bun_runtime [24/28] link bun-profile ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-564ac2a6cad8da6a-lto/lib/libJavaScriptCore. ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/event_loop/ConcurrentTask.rs | 1 + src/runtime/dispatch.rs | 7 +- src/runtime/webcore/s3/client.rs | 9 +- src/runtime/webcore/s3/multipart.rs | 84 +++++++++++-- src/runtime/webcore/streams.rs | 20 +-- test/js/bun/s3/s3-upload-abort.test.ts | 222 +++++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 24 deletions(-) ``` </details> **gate history** · 5 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/event_loop/ConcurrentTask.rs 1 1 44 src/runtime/dispatch.rs 1 1 43 src/runtime/webcore/s3/client.rs 3 1 44 src/runtime/webcore/s3/multipart.rs 5 10 44 src/runtime/webcore/streams.rs 4 6 44 test/js/bun/s3/s3-upload-abort.test.ts 7 6 43 ``` </details> <!-- robobun:evidence:end -->
|
Closing: main already has both fixes.
The tests of this PR ( The leak tests of this PR continue in #43833, as a test-only change. |
…ter() (#43833) Behaviour change: none ### Problem - No test checks that the heap `NetworkSink` behind `s3file.writer()` is freed. #39690 frees it through `writer_holders` (`src/runtime/webcore/streams.rs:2351`). A regression leaks 152 bytes per writer: LSAN `Direct leak ... NetworkSink`. - The CI runner's LeakSan mode does not see it. A `writer()` call during module evaluation falls under `leak:JSC::JSModuleLoader::evaluateNonVirtual` in `test/leaksan.supp`. ### Fix - Add `test/js/bun/s3/s3-networksink-leak.test.ts`. On ASAN builds it compares the bytes LeakSanitizer reports for 2 and for 22 writers. Five rows: `end()` resolves, `end()` rejects, `close()` with a 200, `close()` with a 403, collection before `end()`. - Each child prints proof of its path (requests seen, how `end()` settled). A run with no leak summary must exit with 0, so a failed scan does not count as 0 bytes. - One test runs on all builds: the process must exit after `end()` resolves while the script holds the writer. - Verified: 6 of 6 pass on main (debug ASAN build, local and CI ASAN lane environment). Three mutations of main fail exactly the expected rows (Notes). ### Background - The sink has two holders: the JS wrapper and the upload's completion callback. `writer_holders` starts at 2 (`src/runtime/webcore/s3/client.rs:547`). The last holder to let go frees the box. The rows cover the three orders. - The children run with `symbolize=0`: fast, and no suppressions. What a process leaks once cancels out in the difference, as in `serve-body-leak.test.ts`. - Considered `s3-upload-abort.test.ts` as the home. Its fixture runs with the suppressions on, which hide this leak. - The tests come from #34999, closed because main has its fixes (#39690, #36785). <details><summary>Notes</summary> Leaked bytes that LeakSanitizer reports on main at 6d504dd (`symbolize=0`, no suppressions, local environment): | row | 2 writers | 22 writers | | --- | --- | --- | | `end()` that resolves | 838 | 838 | | `end()` that rejects | 839 | 839 | | `close()`, 200 | 833 | 833 | | `close()`, 403 | 834 | 834 | | collected before `end()` | 862 | 862 | The fixed part is the source map of the script and the one live `S3Client` with its credentials. No record names `NetworkSink`. With the environment of the CI ASAN lane (`BUN_DESTRUCT_VM_ON_EXIT=1`) the same children report no leak at all and exit with 0. Mutation checks on main, each reverted afterwards: - Remove `NetworkSink::release_writer_holder(sink)` from `wrapper_callback_thunk` (`src/runtime/webcore/s3/client.rs:483`). All five leak rows fail with `Expected: < 400, Received: 3040` (20 x 152 bytes), with the local environment and with the environment of the CI ASAN lane. The exit test still passes. - In `JsSinkType::finalize` of `NetworkSink` (`src/runtime/webcore/streams.rs:2696`), skip `release_writer_holder` when the wrapper is collected before `end()`. Only the row "collected before `end()`" fails (3040 bytes). The other four rows pass, so that row guards an order the others do not reach. - Remove `sink.finalize()` from `wrapper_callback` (`src/runtime/webcore/s3/client.rs:465`). The exit test fails: the child never exits and the test times out after 5000 ms. The leak rows still pass. On success only `Drop for MultiPartUpload` unrefs the event loop (`src/runtime/webcore/s3/multipart.rs:434`). The three orders in which the two holders let go: - `end()`: the upload callback first, the collected wrapper last. - `close()`: it reaches `end(None)` (`src/runtime/webcore/Sink.rs:765`), so the upload is sent. The wrapper lets go at once in `__doClose`, the upload callback last. - Collection before `end()`: the wrapper first. `abort_on_collect` (`src/runtime/webcore/streams.rs:2370`) fails the upload, and that callback lets go last. A buffered write sends nothing, so the mock sees 0 requests. A run that measures nothing: - With the child under ptrace (reproduced with `gdb -batch -ex run`), the child prints `done`, LeakSanitizer prints `LeakSanitizer has encountered a fatal error.` and no summary, and the exit code is 1. - A valid report also exits with 1 (ASAN default `exitcode=1`), so the helper does not require exit code 0 in general. It requires exit code 0 only when there is no leak summary. An ASAN error report or a signal after `done` has no leak summary either, so it also fails the test. - With the guard, the ptrace run fails with `exitCode: 1` and the LeakSanitizer message in the assertion output. Suppression probe on the first mutated build, with `BUN_DESTRUCT_VM_ON_EXIT=1` and `suppressions=test/leaksan.supp` as the CI runner sets them: - A child that calls `writer()` before its first `await` exits 0. LSAN prints `Suppressions used: 1 152 JSC::JSModuleLoader::evaluateNonVirtual`. - The same child with one `setImmediate` hop before `writer()` exits 1 with `SUMMARY: AddressSanitizer: 152 byte(s) leaked in 1 allocation(s)`. Child environment: - The child gets `ASAN_OPTIONS: "detect_leaks=1:symbolize=0"` outright, like `serve-body-leak.test.ts` and `arraybuffersink.test.ts`. The CI ASAN lane exports `abort_on_error=1` and `disable_coredump=0`. With those inherited, a reported leak aborts the child, and the runner fails a test file when a new core file appears (`scripts/runner.node.ts:2033`). - The child env clears `ALL_PROXY` and `all_proxy` next to the four other proxy variables. #43717 explains why. A child that hangs: - `bun test` kills the child of a test that times out only when the test is serial (`kill_dangling_processes_on_timeout`, `src/runtime/test_runner/Execution.rs:308`). Reproduced with two `test.concurrent` rows whose children never exit: both children are still alive after `bun test` exits. - The children of the leak rows get `BUN_FEATURE_FLAG_NO_ORPHANS=1` (`src/io/ParentDeathWatchdog.rs`, `PR_SET_PDEATHSIG` on Linux), so a child that hangs exits with the test process. With five hung children, none is left after the run. The CI runner sets the same flag for every test file on ASAN lanes (`scripts/runner.node.ts:2207`). - No spawn `timeout`: CI passes `--timeout` of 270 s on ASAN lanes, and a fixed limit below that can fail a slow but healthy run. - The loop that waits for the collection has a 10 s deadline. A writer that is not collected keeps the process alive, so `beforeExit` never comes. The child prints `collected only 21 of 22` and exits. Checked with one writer kept reachable and a shorter deadline: the row fails in about 3 s with that line in the diff. - The exit test stays serial, so `bun test` kills its child on timeout (`killed 1 dangling process`, seen with the third mutation). Runs: - `bun bd test test/js/bun/s3/s3-networksink-leak.test.ts`: 6 pass, about 3 s for the file on a debug ASAN build. The final version passes 8 runs in a row (4 with the local environment, 4 with the environment of the CI ASAN lane, where the `bun test` process itself also exits 0). The version before it passed 18 in a row. - Release build: 5 skip, 1 pass. </details> <!-- robobun:evidence:begin --> --- **[auto-merge]** gate passed · iteration 2 · 1 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/bun/s3/s3-networksink-leak.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "test/js/bun/s3/s3-networksink-leak.test.ts" bun test v1.4.3 (367d939) test/js/bun/s3/s3-networksink-leak.test.ts: (pass) S3 writer() frees its NetworkSink > after collection before end() [708.98ms] (pass) S3 writer() frees its NetworkSink > after close() and an upload that succeeds [758.67ms] (pass) S3 writer() frees its NetworkSink > after end() that resolves [813.02ms] (pass) S3 writer() frees its NetworkSink > after close() and an upload that fails [758.93ms] (pass) S3 writer() frees its NetworkSink > after end() that rejects [780.20ms] (pass) S3 writer() lets the process exit once end() resolves, even if the writer is retained [273.05ms] 6 pass 0 fail 11 expect() calls Ran 6 tests across 1 file. [2.89s] Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/bun/s3/s3-networksink-leak.test.ts | 142 +++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) ``` </details> **gate history** · 4 passed · 0 rejected · iteration 2 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/bun/s3/s3-networksink-leak.test.ts 5 7 37 ``` </details> <!-- robobun:evidence:end -->
Problem
NetworkSink(the native struct behinds3.file(k).writer()) is heap-allocated inwritable_streamviabun_core::heap::into_rawand never freed. Two owners hold a raw*mut NetworkSink:JSNetworkSinkwrapper'sm_sinkPtr: on GC,~JSNetworkSinkcallsNetworkSink__finalize→NetworkSink::finalize()MultiPartUpload.callback_context: on upload completion,wrapper_callbackcallssink.finalize()finalize()only randetach_writable().finalize_and_destroy()doesheap::takebut has no callers. Everywriter()leaked one ~80-byteNetworkSinkon both the success and failure completion paths:Separately, the plain-sink
.close()prototype method nullsm_sinkPtrviadetach()before the destructor can run, so~JS${name}skips__finalizeand the wrapper's native payload is never released on that path for anyJSSinktype.This is the Rust-side reappearance of #29883, which fixed the same leak in the Zig implementation before that PR was closed by the migration.
Fix
NetworkSinkgains an intrusiveCellRefCountedrefcount.writable_streaminitialises it to 2 (JS wrapper +callback_context).finalize()is now a barederef(); the sink's counted ref on theMultiPartUploadis released indeinit()once the refcount hits zero.wrapper_callbackreleasescallback_context's +1 viaNetworkSink::derefdirectly.abort()stops atdetach_writable()so the failure path does not double-deref. The unusedfinalize_and_destroyis removed.JsSinkTypegains awrapper_detached()hook (default:finalize()).generate-jssink.tsemits a new${name}__wrapperDetachedextern and__doClosecalls it after__close, so.close()releases the wrapper's ref without routing through the GC-sweepfinalize(). This closes the same pre-existing leak forArrayBufferSink.close()andFileSink.close().FileSink::finalizeis unchanged from main;release_wrapper_ref()factors out the shared cleanup, andwrapper_detached()calls only that so a backpressured write promise survives.close().Verification
test/js/bun/s3/s3-networksink-leak.test.tsspawns subprocesses underdetect_leaks=1with 2 and 22 writers against a mock S3 server (200 and 403 responses, finished via both.end()and.close()) and asserts the extra 20 writers do not add leaked bytes. Before: diff = 1600 (= 20 × 80). After: diff = 0, and a symbolized run with the repo suppressions reports no leaks at all.no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/filesink.test.ts