Repository navigation
Fix AbortSignal leak in ReadableStream.pipeTo with signal option #28491
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Jarred-Sumner
merged 1 commit into
main
from
claude/fix-pipeTo-signal-abort-algorithm-leak
Mar 24, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { heapStats } from "bun:jsc"; | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { expectMaxObjectTypeCount } from "harness"; | ||
|
|
||
| describe("ReadableStream.pipeTo with AbortSignal", () => { | ||
| // Regression: when pipeTo() is passed a signal and the pipe never completes, | ||
| // dropping all JS references to the signal should allow it to be collected. | ||
| // Previously, a Strong ref cycle (AbortSignal -> AbortAlgorithm -> | ||
| // Strong<callback> -> closure -> pipeState.signal -> JSAbortSignal -> | ||
| // Ref<AbortSignal>) caused a 100% leak. | ||
| test("dropping signal references should not leak AbortSignal when pipe never completes", async () => { | ||
| const baseline = heapStats().objectTypeCounts.AbortSignal || 0; | ||
| const iterations = 200; | ||
|
|
||
| function iteration() { | ||
| const controller = new AbortController(); | ||
| const rs = new ReadableStream({ | ||
| pull() { | ||
| // Never enqueue, never close: pipe stays pending forever. | ||
| return new Promise(() => {}); | ||
| }, | ||
| }); | ||
| const ws = new WritableStream({}); | ||
| rs.pipeTo(ws, { signal: controller.signal }).catch(() => {}); | ||
| // All locals go out of scope here. If there is no Strong ref cycle, | ||
| // GC should be able to reclaim the AbortSignal. | ||
| } | ||
|
|
||
| for (let i = 0; i < iterations; i++) { | ||
| iteration(); | ||
| } | ||
|
|
||
| // Allow microtasks to settle before GC. | ||
| await Bun.sleep(0); | ||
|
|
||
| // Allow some slack for GC timing, but nowhere near `iterations`. | ||
| await expectMaxObjectTypeCount(expect, "AbortSignal", baseline + 20); | ||
| }); | ||
|
|
||
| test("aborting signal still works and cleans up after pipe completes via abort", async () => { | ||
| const baseline = heapStats().objectTypeCounts.AbortSignal || 0; | ||
| const iterations = 200; | ||
|
|
||
| async function iteration() { | ||
| const controller = new AbortController(); | ||
| const rs = new ReadableStream({ | ||
| pull() { | ||
| return new Promise(() => {}); | ||
| }, | ||
| }); | ||
| const ws = new WritableStream({}); | ||
| const p = rs.pipeTo(ws, { signal: controller.signal }); | ||
| controller.abort(); | ||
| await p.catch(() => {}); | ||
| } | ||
|
|
||
| for (let i = 0; i < iterations; i++) { | ||
| await iteration(); | ||
| } | ||
|
|
||
| await Bun.sleep(0); | ||
| await expectMaxObjectTypeCount(expect, "AbortSignal", baseline + 20); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡
AbortSignal::memoryCost()readsm_abortAlgorithms.sizeInBytes()without holdingm_abortAlgorithmsLock, violating theWTF_GUARDED_BY_LOCK(m_abortAlgorithmsLock)annotation that this PR itself introduces. SincememoryCost()is called viaJSAbortSignal::estimatedSize()during GC heap accounting (potentially from a concurrent GC thread), this is a C++ data race with main-thread calls toaddAbortAlgorithmToSignal()orremoveAbortAlgorithmFromSignal(). Fix: acquirem_abortAlgorithmsLockbefore thesizeInBytes()call.Extended reasoning...
Bug: memoryCost() violates its own lock annotation
What the bug is
AbortSignal::memoryCost()is aconstmethod computing heap size for JSC memory accounting. This PR adds a new fieldm_abortAlgorithmsannotated withWTF_GUARDED_BY_LOCK(m_abortAlgorithmsLock). Every other accessor of this field properly acquires the lock:addAbortAlgorithmToSignal()(beforeappend()),removeAbortAlgorithmFromSignal()(beforeremoveFirstMatching()),runAbortSteps()(beforestd::exchange()), andvisitAbortAlgorithms()(before iterating).memoryCost()is the sole exception, callingm_abortAlgorithms.sizeInBytes()without holding the lock.Concrete code path
JSAbortSignal::estimatedSize()callswrapped().memoryCost(). With concurrent GC enabled (BUN_JSC_useConcurrentGC=1, explicitly mentioned in the PR description's stress tests), JSC invokesestimatedSize()from a GC thread. If the main thread is simultaneously insideaddAbortAlgorithmToSignal()holdingm_abortAlgorithmsLockwhile appending to the vector (updating internalm_sizeand potentially reallocating), the GC thread readingm_sizeviasizeInBytes()is an unsynchronized read-write data race.Why existing code does not prevent it
The
constqualifier onmemoryCost()provides no thread-safety guarantee.WTF_GUARDED_BY_LOCKis a static-analysis annotation (Clang thread-safety analysis), not a runtime enforcement mechanism. There is no other synchronization primitive guarding this read path.Step-by-step proof
addAbortAlgorithmToSignal(), acquiresm_abortAlgorithmsLock, appends tom_abortAlgorithms(updatesm_size, may reallocate buffer).JSAbortSignal::estimatedSize()->wrapped().memoryCost()->m_abortAlgorithms.sizeInBytes()with no lock held.m_sizewhile step 1 writes it -- undefined behavior per the C++ memory model.Note that
visitAbortAlgorithms()correctly holds the lock during its iteration, butmemoryCost()does not follow the same discipline.Impact
The practical consequence is an inaccurate memory cost estimate rather than a crash or incorrect abort logic. However, it is technically undefined behavior in C++ and violates the explicit
WTF_GUARDED_BY_LOCKcontract introduced by this very PR. Concurrent GC is explicitly exercised in the PR stress test.Fix
Acquire
m_abortAlgorithmsLockinsidememoryCost()before callingsizeInBytes():