Conversation
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().
|
Warning Review limit reached
Next review available in: 6 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 (3)
Comment |
|
Status: diff is green. Remaining CI red is unrelated infra:
Ready for review/merge. |
|
Updated 11:36 PM PT - Jul 24th, 2026
❌ @robobun, your commit f595858 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 35528That installs a local version of the PR into your bun-35528 --bun |
…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.
There was a problem hiding this comment.
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 FileSinkparent backref, so no R-2 re-entry whilefinalizeholds&mut self. - Verified Windows
write_internal_u8always reachesprocess_send()(or a prior one is in flight) for non-SyncFilesources, andprocess_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 forFileType::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(_) => 0arm indrain_without_reporting()drops the partially-written offset thattry_writeaccumulated 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_writecompletion landing duringawait 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.
There was a problem hiding this comment.
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.
…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 -->
### 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>
|
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. |
Reproduction
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 (belowCHUNK_SIZE) in theStreamingWriter'soutgoingbuffer and registers anAutoFlusherdeferred 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, andclose_without_reporting()closes the fd with the buffer still unwritten.Fix
Add
StreamingWriter::drain_without_reporting()and call it from the top ofFileSink::finalize():try_write()over theoutgoingbuffer (pure syscalls viaget_fd()/get_file_type(); noParent::on_*dispatch). Stops on the first retry/error, so a backpressured pollable fd does not block the sweep.SyncFilewrite already went throughprocess_send(), which queued auv_fs_write/uv_writeand 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 dispatchParent::on_*via the stored*mut FileSinkbackref whilefinalizeholds&mut self, which is the R-2 noalias re-entry the file's header andon_auto_flushdoc warn against (and the POSIX error arm would reachrun_pending()and touch a JSPromise during the sweep).Verification
New test in
test/js/bun/util/filesink.test.tsspawns 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.mainwith["", "", ""]filesink.test.tssuite (51 tests) passesno 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