Skip to content

JSSink: release native backing in prototype close() - #36785

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/5961b6b8/jssink-close-finalize
Aug 2, 2026
Merged

JSSink: release native backing in prototype close()#36785
Jarred-Sumner merged 3 commits into
mainfrom
farm/5961b6b8/jssink-close-finalize

Conversation

@robobun

@robobun robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

The generated ${name}__doClose (backing ArrayBufferSink.prototype.close(), FileSink.prototype.close(), and the other JSSink wrappers) does this:

sink->detach();                  // m_sinkPtr = nullptr
${name}__close(global, ptr);     // end(None)

${name}__close only runs end(None) and does not free anything. When the wrapper is later collected, ~JS${name} checks if (m_sinkPtr) before calling ${name}__finalize, and m_sinkPtr is already null, so finalize is skipped. Every close() leaked the native backing:

  • ArrayBufferSink: the boxed struct and its Vec<u8> buffer.
  • FileSink (including Bun.file(...).writer() and child.stdin): the wrapper's +1 intrusive ref, so the struct and its IO buffers.
  • NetworkSink / FetchRequestBodySink: the wrapper's task ref.

The HTTP response sinks only go through the controller path and are not affected. filesink.test.ts already carried a comment and a detect_leaks=0 override acknowledging the FileSink case.

Fix

${name}__doClose now mirrors the destructor's teardown order after detaching: fire m_onDestroy (so Subprocess clears its weak_file_sink_stdin_ptr before the sink can be freed), then call ${name}__finalize(ptr). This runs even if __close set an exception, since the wrapper has already given up its pointer.

Because FileSink::finalize is now reachable synchronously from close(), it no longer clears pending (which may hold a backpressured write() promise that run_pending still has to settle) or readable_stream (which may still be driving a spawn stdin). Both are released by deinit() via Box drop once the keep-alive and assignToStream refs are gone. js_sink_ref still gets cleared: it roots the wrapper itself.

#29883 fixed the same bug in the Zig sources but was closed when those files were removed in the Rust migration.

Verification

// before
Direct leak of 600 byte(s) in 5 object(s) allocated from:
    ...
    #16 in ArrayBufferSink__construct
Indirect leak of 20480 byte(s) in 5 object(s) allocated from:
    ...
    #15 in <ArrayBufferSink>::write_latin1
SUMMARY: AddressSanitizer: 21080 byte(s) leaked in 10 allocation(s).
  • arraybuffersink.test.ts: LSAN-gated subprocess test for close() (fails on main with >16 KiB leaked, clean with the fix) and a guard that write()/flush()/end() after close() still throw "already been closed".
  • filesink.test.ts: fileSinkInternals.liveCount() check for close() (8 leaked on main, 0 with the fix); a guard that a backpressured write() promise still settles when close() runs before the drain; and the pre-existing EPIPE test drops its detect_leaks=0 workaround.

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

The generated ${name}__doClose (ArrayBufferSink.close(), FileSink.close(),
etc.) nulls m_sinkPtr via detach() and then calls ${name}__close, which
only runs end(None). Because m_sinkPtr is null, the later ~JS${name}
destructor skips ${name}__finalize, so the wrapper's ownership of the
native backing was never released on this path: ArrayBufferSink leaked
its Box and buffer outright, and FileSink/NetworkSink leaked the
wrapper's +1 intrusive ref.

__doClose now fires the destroy callback (so Subprocess can clear its
weak stdin back-pointer before the sink can be freed) and then calls
__finalize, mirroring the destructor order.

FileSink::finalize no longer clears pending/readable_stream: now that it
is reachable synchronously from close(), tearing those down would strand
a backpressured write() promise or an in-flight ReadableStream stdin.
Both are released by deinit() (Box drop) once the keep-alive and
assignToStream refs are gone.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change finalizes native ArrayBufferSink and FileSink objects during synchronous close, updates finalization documentation, and adds tests for leaks, repeated close calls, closed-state errors, and pending writes.

Changes

Sink lifecycle

Layer / File(s) Summary
Close and finalization flow
src/codegen/generate-jssink.ts
__doClose now invokes the destroy callback and finalizes the native backing object before propagating exceptions.
Native sink finalization contracts
src/runtime/webcore/ArrayBufferSink.rs, src/runtime/webcore/FileSink.rs
Finalization documentation covers lazy sweep and synchronous close. FileSink retains I/O state for deinitialization and releases js_sink_ref during finalization.
Cleanup and close regression coverage
test/js/bun/util/arraybuffersink.test.ts, test/js/bun/util/filesink.test.ts
Tests verify leak-free cleanup, safe repeated close calls, closed-state errors, and settlement of pending writes.

Possibly related PRs

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 primary change: releasing native backing resources during prototype close().
Description check ✅ Passed The description explains the problem, fix, and verification details, although it uses different headings from the repository template.

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

@github-actions github-actions Bot added the claude label Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. s3: free the NetworkSink behind writer() via intrusive refcount #34999 - Also fixes the JSSink close()-skips-finalize memory leak (references the same issue s3: free NetworkSink via ref count instead of leaking it #29883) by making __doClose call finalize after __close in generate-jssink.ts

🤖 Generated with Claude Code

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #34999. That PR is primarily the NetworkSink refcount fix and adds a new ${name}__wrapperDetached ABI hook to __doClose as a secondary change; this PR is the __doClose leak fix on its own, reusing the existing __finalize symbol.

The substantive difference: this PR also fires m_onDestroy from __doClose before __finalize. Without that, close() on a spawn child.stdin writer can free the FileSink while Subprocess.weak_file_sink_stdin_ptr still points at it, and a later on_process_exit dereferences the freed pointer (ASAN heap-use-after-free in the "close() while a write() promise is pending" test here). #34999's wrapperDetached path does not clear m_onDestroy, so it would hit the same UAF.

Whichever lands first, the other will need a small rebase in generate-jssink.ts and 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.

I didn't find any bugs, but this reworks native refcount release for all seven JSSink types and re-times FileSink::finalize / on_stdin_destroyed, so it's worth a human look at the ref-balance across every sink.

What was reviewed:

  • __doClose now mirrors the destructor (Bun__onSinkDestroyed__finalize) after detach(); m_onDestroy is std::exchanged to 0 so the wrapper's dtor won't fire it again.
  • FileSink::finalize dropping the eager pending/readable_stream clears — traced that both are still released via deinit's Box drop once must_be_kept_alive_until_eof / assignToStream refs are gone.
  • on_stdin_destroyed now firing synchronously from child.stdin.close() — nulls weak_file_sink_stdin_ptr before __finalize can drop the last ref, and the Subprocess deref there is safe while child is still JS-reachable.
  • New tests cover the leak (LSAN + liveCount()), use-after-close, and backpressured-promise settlement; the pre-existing EPIPE test drops its detect_leaks=0 workaround.
Extended reasoning...

Overview

This PR fixes a leak in the generated ${name}__doClose (backing .close() on all seven JSSink wrappers): it detached m_sinkPtr and called __close but never __finalize, so the wrapper's destructor skipped the native release and every close() leaked the backing struct/buffers. The fix adds Bun__onSinkDestroyed(m_onDestroy, ptr) + __finalize(ptr) after __close, mirroring the destructor's teardown order. Because FileSink::finalize is now reachable synchronously from close() (not just GC sweep), it no longer eagerly clears pending (backpressured write promise) or readable_stream (spawn-stdin driver); both defer to deinit()'s Box drop.

Security risks

None. This is memory-lifetime bookkeeping — no user input parsing, auth, or externally-facing surface changes.

Level of scrutiny

High. This is squarely in the repo's "most-blocked category" (native memory safety / refcount balancing / GC interaction). The generated change applies to seven sink types (ArrayBufferSink, FileSink, three HTTP response sinks, NetworkSink, FetchRequestBodySink), each with its own finalize semantics that must now be safe when invoked synchronously from user JS rather than only from lazy sweep. The FileSink::finalize change also shifts when Subprocess::on_stdin_destroyed fires (from GC-time to close()-time via Bun__onSinkDestroyed), which nulls weak_file_sink_stdin_ptr and may deref() the Subprocess — a maintainer should confirm that early HAS_STDIN_DESTRUCTOR_CALLED / cleared weak-ptr doesn't break the process-exit path (on_attached_process_exit is skipped once the weak-ptr is null, which looks intended).

Other factors

The PR references #29883 (same fix in the removed Zig sources), and the test suite already carried a detect_leaks=0 workaround acknowledging the FileSink case, so the leak is well-established. The new tests are targeted (LSAN subprocess for ArrayBufferSink, liveCount() for FileSink, backpressured-promise settlement for the pending-clear removal, use-after-close guard). I checked that m_onDestroy is std::exchanged to 0 so the wrapper's later dtor won't double-fire it, and that js_finalize for the other sink types (ArrayBufferSink, NetworkSink, FetchRequestBodySink, HTTP response sinks) doesn't obviously depend on sweep-only invariants — but the ref-balance for each deserves maintainer eyes given how many callers reach .close().

Update the header comments on FileSink::finalize, ArrayBufferSink::finalize
and the generated __finalize thunk to list both call sites (destructor lazy
sweep and the prototype close() path) so the governing constraint reads
correctly up front.
Comment thread src/codegen/generate-jssink.ts Outdated
Comment thread src/codegen/generate-jssink.ts
Comment thread src/runtime/webcore/ArrayBufferSink.rs
Comment thread src/runtime/webcore/FileSink.rs
Comment thread src/runtime/webcore/FileSink.rs Outdated
Comment thread src/codegen/generate-jssink.ts
Comment thread src/runtime/webcore/FileSink.rs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/codegen/generate-jssink.ts (1)

511-542: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Verify exception-scope handling in ${name}__doClose before finalizing.

${name}__close(lexicalGlobalObject, ptr) runs inside a DECLARE_THROW_SCOPE and can set a pending exception. The new code calls Bun__onSinkDestroyed(destroy, ptr) and ${name}__finalize(ptr) immediately afterward, without checking or clearing that exception first. Only then does RETURN_IF_EXCEPTION(scope, {}) run.

This differs from the established pattern in the same file for ${controller}__close and ${controller}__end (Lines 425-433, 472-480), which explicitly stash the pending exception, clear it with scope.tryClearException(), run the JS-entering call, clear again, and rethrow the original exception. That pattern exists specifically because JSC has assertions against entering the VM again while an exception scope is set.

If Bun__onSinkDestroyed or ${name}__finalize never call back into JS for any of the four sink types (ArrayBufferSink, FileSink, NetworkSink, FetchRequestBodySink), this is safe as written. If any of them do (now or after a future change), running them with a pending exception can trip JSC's exception-scope validator (enabled on the ASAN CI shard). Since this is templated code shared by all sink classes, a single defect here affects every sink.

This location was previously flagged by automated review for needing a paragraph-long comment to justify a workaround. Restructuring to mirror ${controller}__close's stash/clear/rethrow removes both the exception-ordering risk and the need for that justification comment.

🔧 Proposed fix mirroring the `${controller}__close` pattern
     sink->detach();
     ${name}__close(lexicalGlobalObject, ptr);
-    // detach() nulled m_sinkPtr, so ~${className} will not run __finalize for
-    // this ptr. Release the wrapper's ownership here (unconditionally: even if
-    // __close set an exception) so the native backing is freed rather than
-    // leaked. Fire the destroy callback first, same as the destructor does:
-    // Subprocess holds a weak back-pointer that must be cleared before
-    // __finalize can drop the last ref on the sink.
-    if (auto destroy = std::exchange(sink->m_onDestroy, 0)) {
-        Bun__onSinkDestroyed(destroy, ptr);
-    }
-    ${name}__finalize(ptr);
-    RETURN_IF_EXCEPTION(scope, {});
+    // detach() nulled m_sinkPtr, so ~${className} will not run __finalize for
+    // this ptr; run the destructor's onDestroy-then-finalize sequence here so
+    // the native backing is freed rather than leaked, even if __close threw.
+    if (JSC::Exception* pending = scope.exception()) [[unlikely]] {
+        if (!scope.tryClearException()) {
+            return {};
+        }
+        if (auto destroy = std::exchange(sink->m_onDestroy, 0)) {
+            Bun__onSinkDestroyed(destroy, ptr);
+        }
+        ${name}__finalize(ptr);
+        (void)scope.tryClearException();
+        scope.throwException(lexicalGlobalObject, pending);
+        return {};
+    }
+    if (auto destroy = std::exchange(sink->m_onDestroy, 0)) {
+        Bun__onSinkDestroyed(destroy, ptr);
+    }
+    ${name}__finalize(ptr);
+    RETURN_IF_EXCEPTION(scope, {});
     return JSC::JSValue::encode(JSC::jsUndefined());

Please confirm with a search whether Bun__onSinkDestroyed or any ${name}__finalize implementation ever invokes JS.

oven-sh bun Bun__onSinkDestroyed implementation Subprocess weak_file_sink_stdin_ptr
🤖 Prompt for 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.

In `@src/codegen/generate-jssink.ts` around lines 511 - 542, Update
`${name}__doClose` to mirror the exception handling used by
`${controller}__close` and `${controller}__end`: stash any exception from
`${name}__close`, clear the scope before invoking `Bun__onSinkDestroyed` and
`${name}__finalize`, clear any newly raised exception, then rethrow the original
exception. Preserve cleanup ordering and remove the workaround comment once the
control flow provides the necessary justification.

Source: Path instructions

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

Outside diff comments:
In `@src/codegen/generate-jssink.ts`:
- Around line 511-542: Update `${name}__doClose` to mirror the exception
handling used by `${controller}__close` and `${controller}__end`: stash any
exception from `${name}__close`, clear the scope before invoking
`Bun__onSinkDestroyed` and `${name}__finalize`, clear any newly raised
exception, then rethrow the original exception. Preserve cleanup ordering and
remove the workaround comment once the control flow provides the necessary
justification.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 47bf9379-76b8-4105-87cf-5b92296ed317

📥 Commits

Reviewing files that changed from the base of the PR and between 1b1efd0 and b704b9f.

📒 Files selected for processing (5)
  • src/codegen/generate-jssink.ts
  • src/runtime/webcore/ArrayBufferSink.rs
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/util/arraybuffersink.test.ts
  • test/js/bun/util/filesink.test.ts

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

On the exception-scope concern: checked whether Bun__onSinkDestroyed or any __finalize that is reachable from __doClose can re-enter JS. They cannot.

  • Bun__onSinkDestroyedSubprocess::on_stdin_destroyed: flag writes, a Cell::set, update_has_pending_activity (Strong slot bookkeeping only), and an intrusive deref whose zero-count path is a plain Box drop. No JS.
  • ArrayBufferSink::finalize: heap::take drop. No JS.
  • FileSink::finalize: Strong slot release + intrusive deref; deinit is AutoFlusher unregister + Box drop. No JS.
  • NetworkSink::finalize / FetchRequestBodySink::finalize: task refcount release. No JS.
  • HTTPServerWritable::finalize can reach render_metadata via flush_no_wait, but no JSHTTPResponseSink wrapper is ever created (HTTPResponseSink__createObject has no callers), so __doClose is unreachable for those sinks; they only go through the controller path.

The ${controller}__close stash/clear/rethrow exists because controller->detach() fires the user-supplied onClose JS callback. __doClose has no JS-entering call between __close and the trailing RETURN_IF_EXCEPTION.

Ran both test files under BUN_JSC_validateExceptionChecks=1 with no unchecked-exception reports.

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

No issues found, but this reworks native finalization/refcount lifecycle across the C++/Rust FFI boundary for all seven JSSink types — worth a human look.

What was reviewed:

  • __doClose teardown order: m_onDestroy fires before __finalize so Subprocess's weak stdin backpointer is cleared before the sink can free; std::exchange prevents the destructor from firing it again.
  • FileSink::finalize no longer clearing pending/readable_stream: traced both the sweep path (deferred to deinit via Box drop, same net effect) and the synchronous-close path (in-flight IO still holds the keep-alive ref, so run_pending can settle the promise).
  • Confirmed none of the reachable __finalize/Bun__onSinkDestroyed paths re-enter JS between __close and the trailing RETURN_IF_EXCEPTION, so no exception-scope stash/clear is needed here (unlike the controller path).
Extended reasoning...

Overview

The PR fixes a native memory leak in the generated ${name}__doClose (backing .close() on ArrayBufferSink, FileSink, NetworkSink, FetchRequestBodySink, and the three HTTP response sinks). Previously detach() nulled m_sinkPtr before __close ran, so the wrapper's destructor skipped __finalize and every .close() leaked the native backing. The fix mirrors the destructor's teardown order inline: fire m_onDestroy, then __finalize(ptr), even if __close set an exception. FileSink::finalize is adjusted to stop tearing down pending/readable_stream (now that it can run synchronously from .close() while a backpressured write is still in flight), leaving them for deinit (Box drop). Four new tests cover the LSAN leak, the liveCount() leak, post-close method behavior, and the pending-write-during-close case.

Security risks

None. This is a native lifecycle/refcount fix; no user input parsing, auth, or trust boundaries are touched.

Level of scrutiny

High. This is squarely in the repo's most-blocked review category (native memory safety): it changes when finalize runs relative to close, adjusts which fields are torn down at which lifecycle stage, and interacts with intrusive refcounting, JSC lazy sweep, Subprocess's weak stdin backpointer, and the keep-alive ref taken on backpressure. The change fans out through generated C++ to seven sink types, only two of which have direct test coverage here (the PR argues the HTTP response sinks never reach __doClose, and NetworkSink/FetchRequestBodySink finalize is a task-ref release). The reasoning is careful and the tests are well-targeted, but the invariant surface ("finalize now runs from two contexts with different constraints") is exactly the shape that benefits from a maintainer's eyes.

Other factors

  • There is a known overlapping PR (#34999) that touches the same __doClose codegen with a different ABI approach; whichever lands first requires the other to rebase.
  • The FileSink::finalize change is a real behavioral shift for the sweep path too: when the keep-alive ref outlives the wrapper, pending/readable_stream now survive until deinit instead of being cleared at sweep time. I traced this and believe it's correct (and arguably fixes a latent bug where sweep could drop a pending promise's Strong before on_write settled it), but it's a non-obvious consequence a maintainer should confirm.
  • Test coverage is solid: LSAN-gated leak repro, native liveCount() check, post-close error guard, and a spawn-stdin backpressure test that specifically exercises the m_onDestroy ordering. The pre-existing EPIPE test dropping its detect_leaks=0 workaround is a nice proof the leak is actually gone.

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI on ebb4bce: arraybuffersink.test.ts and filesink.test.ts pass on every lane. The only non-flaky failure is test/internal/expected-durations.test.ts, a metadata check over test/expected-durations.json that started failing on main at a00c4db ("Update test durations") and is unrelated to this diff. The remaining failures are all tagged flaky and passed on retry.

Ready for review.

@Jarred-Sumner
Jarred-Sumner merged commit 506945e into main Aug 2, 2026
52 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/5961b6b8/jssink-close-finalize branch August 2, 2026 23:28
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