Skip to content

FileSink: flush buffered bytes in finalize before closing - #35528

Closed
robobun wants to merge 4 commits into
mainfrom
claude/ef870c93/filesink-gc-flush
Closed

robobun wants to merge 4 commits into
mainfrom
claude/ef870c93/filesink-gc-flush

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Reproduction

import { readFileSync } from "node:fs";
for (let i = 0; i < 3; i++) {
  const w = Bun.file("/tmp/sink-" + i).writer();
  w.write("payload-" + i);
  // abandoned: no flush(), no end(), no reference kept
}
Bun.gc(true); await Bun.sleep(20); Bun.gc(true);
process.on("exit", () =>
  console.log([0,1,2].map(i => JSON.stringify(readFileSync("/tmp/sink-" + i, "utf8"))).join(" ")));
// before:  "" "" ""
// after:   "payload-0" "payload-1" "payload-2"

Without the Bun.gc() calls the same program flushes correctly at exit, so this is GC-timing-dependent data loss. The real-world shape is per-request or per-job log/append sinks created and dropped in a long-lived process: the last unflushed chunk vanishes whenever a collection lands before the deferred-microtask drain.

Cause

FileSink::write() buffers small writes (below CHUNK_SIZE) in the StreamingWriter's outgoing buffer and registers an AutoFlusher deferred microtask to drain it after the current microtask queue. If a GC sweep collects the JS wrapper before that deferred task runs, FileSink::finalize() drops the wrapper's ref, deinit() unregisters the deferred task and drops the writer, and close_without_reporting() closes the fd with the buffer still unwritten.

Fix

Add StreamingWriter::drain_without_reporting() and call it from the top of FileSink::finalize():

  • POSIX: loops try_write() over the outgoing buffer (pure syscalls via get_fd()/get_file_type(); no Parent::on_* dispatch). Stops on the first retry/error, so a backpressured pollable fd does not block the sweep.
  • Windows: no-op. Every non-SyncFile write already went through process_send(), which queued a uv_fs_write/uv_write and took a parent ref, so the native sink already outlives its JS wrapper until the completion fires.

This does not go through IOWriter::flush(): flush() can dispatch Parent::on_* via the stored *mut FileSink backref while finalize holds &mut self, which is the R-2 noalias re-entry the file's header and on_auto_flush doc warn against (and the POSIX error arm would reach run_pending() and touch a JSPromise during the sweep).

Verification

New test in test/js/bun/util/filesink.test.ts spawns a subprocess that creates three abandoned sinks with small buffered writes, forces two full GCs around a sleep, and asserts the file contents match the payloads.

  • fails on main with ["", "", ""]
  • passes with this change
  • full filesink.test.ts suite (51 tests) passes

no test proof · iteration 1 · 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

A Bun.file(p).writer() that is written to with fewer than CHUNK_SIZE
bytes and then abandoned (no flush/end, no reference kept) buffers
those bytes in the StreamingWriter's outgoing buffer and registers a
deferred microtask to flush them. If a GC sweep runs before that
deferred task drains, the JS wrapper is collected, finalize() derefs
the native sink to zero, deinit() unregisters the deferred task and
drops the writer, and close_without_reporting() closes the fd with the
buffered bytes still unwritten: the file is created empty.

Flush the writer's pending buffer at the top of finalize(), so the
bytes reach the fd before teardown. On POSIX regular files this is a
synchronous write; on Windows the uv_fs_write queued by process_send()
refs the parent, so the native sink naturally outlives finalize until
the write completes. Matches the existing pattern in
HTTPServerWritable::finalize().
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 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: 26aed97b-4ede-4885-af61-6b3284d4d536

📥 Commits

Reviewing files that changed from the base of the PR and between ae4b17d and f595858.

📒 Files selected for processing (3)
  • src/io/PipeWriter.rs
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/util/filesink.test.ts

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green. filesink.test.ts passed on every test lane across builds #80124 and #80205; the new test fails on stock 1.4.0 with empty files.

Remaining CI red is unrelated infra:

  • build #80205: darwin-aarch64-build-bun and linux-x64-asan-build-bun timed out (state: timed_out, exit -1); both passed in #80124 so this code compiles fine on those targets
  • linux-x64-musl-build-bun and linux-aarch64-android-build-bun are "step failed outside runner", pre-existing on main
  • test-repl-close.js EPIPE on Windows, process.memoryUsage.arrayBuffers on Windows aarch64, bun-security-scanner-matrix on Alpine: all flaky, passed on retry

Ready for review/merge.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:36 PM PT - Jul 24th, 2026

❌ @robobun, your commit f595858 has 1 failures in Build #80205 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35528

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

bun-35528 --bun

Comment thread src/runtime/webcore/FileSink.rs Outdated
Jarred-Sumner pushed a commit that referenced this pull request Jul 25, 2026
…35534)

Adds a self-contained GitHub Action that scans `claude`-labeled PRs for
multi-line comment blocks added under `src/` and leaves a review line
comment on each one asking for it to be deleted (and the thread resolved
once done).

### Behavior

- Triggers on `pull_request_target` (`opened` / `synchronize` /
`reopened` / `labeled`), gated on `github.repository == 'oven-sh/bun'`
and the `claude` label being present. The `labeled` trigger covers the
normal case where `auto-label-claude-prs.yml` applies the label after
open.
- Fetches PR files via `pulls.listFiles` and parses the unified diff in
`actions/github-script`. No repo checkout.
- A group is **2+ consecutive added lines** that are pure comment lines
(`//`, `///`, `//!`, `/* ... */`, `* ` continuation) in `.rs` / `.c` /
`.cpp` / `.h` / `.ts` / `.js` and friends. Groups containing `SAFETY:`
are skipped.
- Posts one standalone review line comment per consecutive group via
`pulls.createReviewComment` (no review summary).
- Each comment carries `<!-- comment-cop:<path>:<sha12> -->` keyed on
the group's content hash, so reruns (new pushes, relabels) skip groups
that already have a marker.
- On each run, any existing comment-cop thread whose flagged block is no
longer present in the diff is auto-resolved via `resolveReviewThread`.

### Verification

Dry-ran the parser against the five most recent open `claude` PRs via
`gh api repos/oven-sh/bun/pulls/<n>/files`:

```
#35531  src/runtime/webcore/Blob.rs:5245-5246
#35529  src/http_jsc/websocket_client.rs:907-910
#35528  src/runtime/webcore/FileSink.rs:917-920
#35526  src/http_jsc/websocket_client.rs:2179-2182
#35532  (clean)
```

Cross-checked `#35531` line numbers against the fetched head ref;
`Blob.rs:5245` is the first line of the flagged block. `actionlint` and
prettier both pass.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · build/CI scripts only; test-proof not
applicable

<!-- robobun:evidence:end -->
IOWriter::flush() can dispatch Parent::on_* via the stored *mut FileSink
backref (Windows process_send refs the parent on the Source::File
success path; POSIX drain_buffered_data calls on_error on partial-write-
then-error), which is the R-2 noalias re-entry hazard finalize's own
header and the on_auto_flush doc warn against, and lets on_error reach
run_pending() (JSPromise) during the GC sweep.

Add StreamingWriter::drain_without_reporting(): on POSIX it loops
try_write() over the outgoing buffer (pure syscalls via get_fd/
get_file_type, no parent dispatch); on Windows it is a no-op because
every non-SyncFile write already queued a uv_fs_write via process_send
and holds a parent ref.
Comment thread src/io/PipeWriter.rs Outdated
Comment thread src/io/PipeWriter.rs Outdated
Comment thread src/runtime/webcore/FileSink.rs Outdated
Comment thread src/io/PipeWriter.rs
Comment thread src/io/PipeWriter.rs
Comment thread src/runtime/webcore/FileSink.rs

@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 revised fix addresses my earlier R-2 concern — drain_without_reporting() on POSIX only touches try_write() (reads get_fd()/get_file_type() and does syscalls; no Parent::on_* dispatch), and the Windows no-op is justified since process_send() already holds a parent ref past the wrapper's finalize. I didn't find further issues, but this is a semantic change to GC-finalize behaviour in a file whose header is dense with aliasing/refcount invariants, so a maintainer sign-off is worth having.

What was reviewed:

  • Traced POSIX drain_without_reporting() → try_write() → try_write_with_write_fn: no path reaches the *mut FileSink parent backref, so no R-2 re-entry while finalize holds &mut self.
  • Verified Windows write_internal_u8 always reaches process_send() (or a prior one is in flight) for non-SyncFile sources, and process_send() refs the parent on the queue-success path (line ~2325), so the sink already outlives the wrapper — the no-op is sound.
  • Checked write_to_blocking_pipe (used for FileType::Pipe) uses non-blocking write / 0-timeout poll, so the drain cannot stall the sweep.
  • Confirmed the outgoing.wrote(drained) cursor advance means a surviving sink (keep-alive ref held) won't double-write when its poll later fires.
Extended reasoning...

Overview

Adds StreamingWriter::drain_without_reporting() (POSIX: best-effort try_write loop over outgoing; Windows: no-op) and calls it from the top of FileSink::finalize(), so buffered small writes are pushed to the fd when the JS wrapper is collected before the deferred auto-flush runs. Adds a subprocess-spawning regression test in test/js/bun/util/filesink.test.ts.

Prior review resolution

My earlier review flagged that the first iteration called IOWriter::flush() from finalize(&mut self), which re-enters FileSink via the writer's stored *mut FileSink backref (Windows process_send() → Parent::ref_/loop_; POSIX partial-write-then-error → Parent::on_error) — the exact R-2 noalias hazard the file documents. The revised approach avoids this: drain_without_reporting() reads only writer-local state (is_done, closed_without_reporting, force_sync, outgoing) and try_write() reads only get_fd()/get_file_type() from self.handle before doing raw syscalls. No Parent::on_* dispatch is reachable, so no backref access occurs while &mut self is live and no JSPromise can be resolved during the sweep.

Security risks

None — pure I/O path, no untrusted input parsing, no auth/crypto.

Level of scrutiny

High. FileSink.rs is one of the most delicate files in the runtime for aliasing/provenance (the R-2 header, the borrow = ptr macro note, and on_auto_flush's doc all exist because prior iterations here were UB). finalize runs during the GC lazy sweep and its own header forbids touching live JS cells. Adding I/O to a finalizer is a semantic change (best-effort flush-on-GC) that a maintainer should confirm is the intended contract — though the fact that Windows already exhibits this behaviour (via the parent ref) and process-exit already flushes both point to yes.

Other factors

  • The test follows harness conventions (tempDir, bunEnv, concurrent pipe drain, stderr asserted before exitCode) and mirrors the existing "under GC pressure" subprocess pattern in the same file.
  • The Err(_) => 0 arm in drain_without_reporting() drops the partially-written offset that try_write accumulated internally before the error, but since the error path leaves the fd broken (subsequent writes will also fail) and finalize is best-effort, no double-write can reach the fd.
  • On Windows the test is not gated; it depends on the uv_fs_write completion landing during await Bun.sleep(20) — should be fine, but worth watching in CI.
  • comment-cop threads are resolved with the author's justification for keeping the trimmed "why not flush()" comments; I agree those are load-bearing.

@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 R-2 backref re-entry from the first revision is fixed — drain_without_reporting() on POSIX only reads get_fd()/get_file_type() and loops sys::write, with no Parent::on_* dispatch. Deferring for a human to confirm the Windows no-op invariant (that every non-SyncFile write leaves a process_send() ref outstanding whenever has_pending_data() is true), since this is GC-finalize + cross-platform refcounting.

Checked: try_write → try_write_with_write_fn touches only handle and the fd (no parent backref); write_to_blocking_pipe polls first / uses RWF_NOWAIT so a blocking-pipe fd won't stall the sweep; WindowsStreamingWriter::write_internal_u8 calls process_send() on the first buffered write, and the completion chain re-queues while outgoing is non-empty. Test drains pipes concurrently, uses tempDir, and asserts stderr/exitCode.

Extended reasoning...

Overview

Adds PosixStreamingWriter::drain_without_reporting() (best-effort try_write loop over outgoing, no parent-callback dispatch) and a no-op Windows counterpart, called from the top of FileSink::finalize() so buffered bytes reach the fd when the JS wrapper is GC'd before the deferred auto-flush runs. One new subprocess test in filesink.test.ts.

Prior review resolved

The first revision called IOWriter::flush() from finalize(&mut self), which I flagged as R-2 noalias UB (backref re-entry into Parent::on_* while &mut self is live, plus run_pending() touching a JSPromise during lazy sweep). Commit 642ea68 replaced it with drain_without_reporting(). I traced the POSIX call graph: try_write → get_file_type() (poll-flag read) / get_fd() → try_write_with_write_fn → sys::write/send_non_block/write_to_blocking_pipe. None of these touch self.parent. outgoing.{slice,wrote,is_empty,reset} are pure StreamBuffer ops. So no re-entry into FileSink while &mut self is held — the concern is addressed.

Also checked that write_to_blocking_pipe polls with is_writable() (or uses RWF_NOWAIT on Linux) rather than blocking, so a backpressured blocking-pipe fd won't stall the GC sweep; it maps to Pending(n) and drain_without_reporting stops there.

Security risks

None. Best-effort draining of already-buffered user bytes to an already-open fd; errors are dropped (nothing to report to — the wrapper is dead).

Level of scrutiny

High. This runs during JSC lazy sweep and interacts with the FileSink intrusive-refcount / *mut FileSink backref machinery that this file's R-2 header and the impl_streaming_writer_parent!(borrow = ptr) note document at length. The Windows no-op rests on the invariant that whenever has_pending_data() is true, a process_send()-taken parent ref is already outstanding — I read write_internal_u8 and it holds for the first-write case (empty outgoing → process_send()) and the chained case (had_buffered_data → prior process_send() still in flight), but a maintainer familiar with the Windows completion chain should confirm.

Other factors

All prior threads are resolved. Bug-hunting pass found nothing on the current revision. Test follows harness conventions (tempDir, bunEnv, concurrent pipe drain, stderr-before-exitCode). CI build #80124 is in progress.

Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
…35534)

Adds a self-contained GitHub Action that scans `claude`-labeled PRs for
multi-line comment blocks added under `src/` and leaves a review line
comment on each one asking for it to be deleted (and the thread resolved
once done).

### Behavior

- Triggers on `pull_request_target` (`opened` / `synchronize` /
`reopened` / `labeled`), gated on `github.repository == 'oven-sh/bun'`
and the `claude` label being present. The `labeled` trigger covers the
normal case where `auto-label-claude-prs.yml` applies the label after
open.
- Fetches PR files via `pulls.listFiles` and parses the unified diff in
`actions/github-script`. No repo checkout.
- A group is **2+ consecutive added lines** that are pure comment lines
(`//`, `///`, `//!`, `/* ... */`, `* ` continuation) in `.rs` / `.c` /
`.cpp` / `.h` / `.ts` / `.js` and friends. Groups containing `SAFETY:`
are skipped.
- Posts one standalone review line comment per consecutive group via
`pulls.createReviewComment` (no review summary).
- Each comment carries `<!-- comment-cop:<path>:<sha12> -->` keyed on
the group's content hash, so reruns (new pushes, relabels) skip groups
that already have a marker.
- On each run, any existing comment-cop thread whose flagged block is no
longer present in the diff is auto-resolved via `resolveReviewThread`.

### Verification

Dry-ran the parser against the five most recent open `claude` PRs via
`gh api repos/oven-sh/bun/pulls/<n>/files`:

```
#35531  src/runtime/webcore/Blob.rs:5245-5246
#35529  src/http_jsc/websocket_client.rs:907-910
#35528  src/runtime/webcore/FileSink.rs:917-920
#35526  src/http_jsc/websocket_client.rs:2179-2182
#35532  (clean)
```

Cross-checked `#35531` line numbers against the fetched head ref;
`Blob.rs:5245` is the first line of the flagged block. `actionlint` and
prettier both pass.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · build/CI scripts only; test-proof not
applicable

<!-- robobun:evidence:end -->
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>
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-25, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants