Skip to content

s3: free the NetworkSink behind writer() via intrusive refcount - #34999

Closed
robobun wants to merge 7 commits into
mainfrom
farm/b6bf5ab7/s3-networksink-leak
Closed

robobun wants to merge 7 commits into
mainfrom
farm/b6bf5ab7/s3-networksink-leak

Conversation

@robobun

@robobun robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

NetworkSink (the native struct behind s3.file(k).writer()) is heap-allocated in writable_stream via bun_core::heap::into_raw and never freed. Two owners hold a raw *mut NetworkSink:

  • the JSNetworkSink wrapper's m_sinkPtr: on GC, ~JSNetworkSink calls NetworkSink__finalize → NetworkSink::finalize()
  • MultiPartUpload.callback_context: on upload completion, wrapper_callback calls sink.finalize()

finalize() only ran detach_writable(). finalize_and_destroy() does heap::take but has no callers. Every writer() leaked one ~80-byte NetworkSink on both the success and failure completion paths:

Direct leak of 80 byte(s) in 1 object(s) allocated from:
    ...
    #11 new<bun_runtime::webcore::streams::NetworkSink> boxed.rs:288
    #12 new src/runtime/webcore/streams.rs:2204
    #13 bun_runtime::webcore::__s3_client::writable_stream src/runtime/webcore/s3/client.rs

Separately, the plain-sink .close() prototype method nulls m_sinkPtr via detach() before the destructor can run, so ~JS${name} skips __finalize and the wrapper's native payload is never released on that path for any JSSink type.

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

  • NetworkSink gains an intrusive CellRefCounted refcount. writable_stream initialises it to 2 (JS wrapper + callback_context). finalize() is now a bare deref(); the sink's counted ref on the MultiPartUpload is released in deinit() once the refcount hits zero. wrapper_callback releases callback_context's +1 via NetworkSink::deref directly. abort() stops at detach_writable() so the failure path does not double-deref. The unused finalize_and_destroy is removed.
  • JsSinkType gains a wrapper_detached() hook (default: finalize()). generate-jssink.ts emits a new ${name}__wrapperDetached extern and __doClose calls it after __close, so .close() releases the wrapper's ref without routing through the GC-sweep finalize(). This closes the same pre-existing leak for ArrayBufferSink.close() and FileSink.close().
  • FileSink::finalize is unchanged from main; release_wrapper_ref() factors out the shared cleanup, and wrapper_detached() calls only that so a backpressured write promise survives .close().

Verification

test/js/bun/s3/s3-networksink-leak.test.ts spawns subprocesses under detect_leaks=1 with 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.

bun bd test test/js/bun/s3/s3-networksink-leak.test.ts              # 4 pass
bun bd test test/js/bun/util/{filesink,arraybuffersink}.test.ts     # 57 pass
bun bd test test/js/bun/s3/                                         # same pass/fail set as main + the 4 above
bun bd test test/js/bun/http/serve.test.ts                          # same as main
bun run rust:check-all                                              # 10 ok

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

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().
@coderabbitai

coderabbitai Bot commented Jul 21, 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: 4 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: 5fd439dd-1352-4099-b2f1-bbc79cabd816

📥 Commits

Reviewing files that changed from the base of the PR and between 92969a0 and 4c1d8cf.

📒 Files selected for processing (4)
  • src/runtime/webcore/FileSink.rs
  • src/runtime/webcore/s3/client.rs
  • test/js/bun/s3/s3-networksink-leak.test.ts
  • test/js/bun/util/filesink.test.ts

Walkthrough

This change adds a wrapper-detachment callback path, preserves pending FileSink state during wrapper cleanup, introduces intrusive NetworkSink reference counting, updates S3 callback teardown, and adds leak and event-loop regression tests.

Changes

Sink lifecycle and leak handling

Layer / File(s) Summary
Wrapper detachment flow
src/codegen/generate-jssink.ts, src/runtime/webcore/Sink.rs, src/runtime/webcore/FileSink.rs
Generated doClose invokes wrapperDetached after detaching, the host layer forwards it to JsSinkType, and FileSink releases its wrapper reference without clearing pending writes.
NetworkSink ownership and teardown
src/runtime/webcore/streams.rs, src/runtime/webcore/s3/client.rs
NetworkSink uses intrusive reference counting, callback contexts hold an additional reference, completion dereferences that ownership, and abort detaches the writable task.
S3 writer leak and event-loop coverage
test/js/bun/s3/s3-networksink-leak.test.ts
Adds ASAN leak scenarios for successful and failed responses using end() and close(), plus an event-loop exit test after end().

Possibly related PRs

  • oven-sh/bun#34223: Modifies the S3 upload callback and related NetworkSink lifecycle and cleanup paths.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ 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 summarizes the main change: freeing NetworkSink via intrusive refcount in S3 writer().
Description check ✅ Passed The description covers the problem, fix, and verification, though it uses custom headings instead of the template.

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

@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:15 PM PT - Jul 21st, 2026

✅ @robobun, your commit 4c1d8cf025752e4d27942a4fc6a0099e1ecc97fa passed in Build #77279! 🎉


🧪   To try this PR locally:

bunx bun-pr 34999

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

bun-34999 --bun

Comment thread src/runtime/webcore/streams.rs
__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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 98fb0ac and abccf8d.

📒 Files selected for processing (5)
  • src/codegen/generate-jssink.ts
  • src/runtime/webcore/FileSink.rs
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/streams.rs
  • test/js/bun/s3/s3-networksink-leak.test.ts

Comment thread src/runtime/webcore/FileSink.rs Outdated
Comment thread src/runtime/webcore/streams.rs Outdated
Comment thread test/js/bun/s3/s3-networksink-leak.test.ts Outdated
@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI on 4d2f779 (build #77266): the diff is green. The only hard failure is test-http2-reset-flood.js SIGABRT on debian-13 x64-asan, which is the known main break tracked in #34846 (quarantine in #34848); HTTP/2 does not touch any JSSink type and the test passes locally under this branch's ASAN build. The other failures (complex-workspace, bun-upgrade, test-gc-http-client-timeout) passed on retry. s3-networksink-leak.test.ts and filesink.test.ts passed on every lane that ran them.

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

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 two finalize()→deref() calls; abort() no longer double-derefs on the failure branch of wrapper_callback.
  • __doClose→__finalize fanout: ArrayBufferSink (destroy — was leaking, now freed once), FileSink (rc-based, keep-alive ref survives), HTTPServerWritable×3 (finalize() is documented idempotent, no rc).
  • Controller __close/__end intentionally still skip __finalize — assign_to_stream takes no per-wrapper +1 for the controller, so nothing to release there.
  • FileSink not clearing pending: rc>1 via must_be_kept_alive_until_eof while a write is outstanding, so run_pending still 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 is wrapper_callback's failure arm, which follows with finalize() — so abort() dropping its own deref() 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 through finalize()'s deref(), and run_pending later settles the (now-preserved) pending promise.
  • ArrayBufferSink: finalize()→destroy() frees directly; end(None) does not free, so the new __finalize call 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 __doClose sees done=true and the buffer-pool cleanup is idempotent.
  • Controller paths (${controller}__close/__end): intentionally unchanged — assign_to_stream takes no per-controller +1 (see the comment in FileSink::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 Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed both in 3451a9f:

  • JsSinkType now has a wrapper_detached() hook (default: finalize()), and __doClose calls a new ${name}__wrapperDetached extern instead of __finalize. FileSink::finalize is unchanged from main (still clears pending during GC sweep); its wrapper_detached calls only the factored-out release_wrapper_ref(), so a backpressured write promise survives .close(). Nothing in finalize() or the .close() path enters JS.
  • wrapper_callback now calls NetworkSink::deref(sink) directly to release callback_context's +1. NetworkSink::finalize is just a deref(); the sink's counted ref on the MultiPartUpload is released in deinit() once rc hits zero (Drop for MultiPartUpload does not enter JS).

Comment thread src/runtime/webcore/s3/client.rs Outdated
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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between abccf8d and 92969a0.

📒 Files selected for processing (6)
  • src/codegen/generate-jssink.ts
  • src/runtime/webcore/FileSink.rs
  • src/runtime/webcore/Sink.rs
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/streams.rs
  • test/js/bun/s3/s3-networksink-leak.test.ts

Comment thread src/runtime/webcore/FileSink.rs Outdated
Comment thread src/runtime/webcore/FileSink.rs
Comment thread src/runtime/webcore/s3/client.rs Outdated
…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.
Comment thread test/js/bun/s3/s3-networksink-leak.test.ts Outdated
robobun added a commit that referenced this pull request Jul 25, 2026
…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.
Jarred-Sumner added a commit that referenced this pull request Aug 12, 2026
### 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>
Jarred-Sumner pushed a commit that referenced this pull request Sep 22, 2026
…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 -->
@robobun

robobun commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Closing: main already has both fixes.

The tests of this PR (s3-networksink-leak.test.ts and the new filesink.test.ts case) pass on main at 6d504dd with a debug ASAN build, without this diff. LeakSanitizer reports the same leaked byte count for 2 writers and for 22 writers in all four cases (.end() and .close(), upload success and failure), so no sink box leaks per writer.

The leak tests of this PR continue in #43833, as a test-only change.

@robobun robobun closed this Sep 23, 2026
alii pushed a commit that referenced this pull request Sep 23, 2026
…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 -->
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.

2 participants