Skip to content

Fix AbortSignal leak in ReadableStream.pipeTo with signal option - #28491

Merged
Jarred-Sumner merged 1 commit into
mainfrom
claude/fix-pipeTo-signal-abort-algorithm-leak
Mar 24, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
claude/fix-pipeTo-signal-abort-algorithm-leak

Conversation

@sosukesuzuki

Copy link
Copy Markdown
Member

What does this PR do?

Fixes a 100% leak of AbortSignal when ReadableStream.prototype.pipeTo is called with a signal option and the pipe never completes.

The cycle

AbortSignal (RefCounted)
  → m_algorithms[i] (lambda capturing Ref<JSAbortAlgorithm>)
  → JSCallbackDataStrong::m_callback (JSC::Strong = GC root)
  → callback closure
  → pipeState.signal (JSAbortSignal wrapper)
  → Ref<AbortSignal>  ← cycle

JSAbortAlgorithm held its JS callback via JSCallbackDataStrong, which creates a JSC::Strong handle (a GC root). The abort algorithm closure registered by pipeTo captures pipeState, which in turn holds pipeState.signal (the JSAbortSignal wrapper). Since the wrapper holds a Ref<AbortSignal>, the cycle is complete and nothing can be collected even after all user-side references are dropped.

Before / After

Bun (before) Bun (after) Node.js
200 iterations, pipe never completes 201 AbortSignals retained 1 ~0

The fix

Switch JSAbortAlgorithm from JSCallbackDataStrong to JSCallbackDataWeak, and keep the callback alive by visiting it from JSAbortSignal::visitAdditionalChildrenInGCThread while the signal wrapper is reachable.

This follows two existing patterns in the codebase:

  • JSCallbackDataWeak + visitJSFunction override — same as JSPerformanceObserverCallback (src/bun.js/bindings/webcore/JSPerformanceObserverCallback.{h,cpp})
  • Lock + Vector + GC-thread visit — same as EventListenerMap::visitJSEventListeners (src/bun.js/bindings/webcore/EventListenerMap.h:77-85), which already protects every addEventListener/removeEventListener against concurrent GC iteration

Why a separate m_abortAlgorithms vector?

The existing m_algorithms stores type-erased Function<void(JSValue)> lambdas. Erasing Ref<AbortAlgorithm> into a lambda would hide it from the GC thread — there would be no way to call visitJSFunction on it. Storing Ref<AbortAlgorithm> directly in a separate vector lets visitAbortAlgorithms iterate and mark the weak callbacks.

The Locker in runAbortSteps moves the vector out under the lock and releases before calling handleEvent, so we never hold the lock during JS re-entry.

How did you verify your code works?

  • New regression test test/js/web/streams/pipeTo-signal-leak.test.ts fails on main (201 leaked) and passes with this fix
  • Existing test/js/web/abort/abort.test.ts, test/js/deno/abort/abort-controller.test.ts, test/js/node/util/test-aborted.test.ts, test/js/web/streams/streams.test.js all pass
  • Concurrent GC stress test (BUN_JSC_useConcurrentGC=1, 500 controllers × 10 pipes) — no crash, no deadlock
  • 5000-iteration heap stress — AbortSignal count stable (growth: -2)
  • JSC exception scope validation (BUN_JSC_validateExceptionChecks=1) — clean

JSAbortAlgorithm held its callback via JSCallbackDataStrong, creating a
GC root. When the callback closure captured the signal (via pipeState),
this formed an uncollectable cycle: AbortSignal -> m_algorithms ->
Strong<callback> -> closure -> JSAbortSignal -> Ref<AbortSignal>.

Switch to JSCallbackDataWeak and keep the callback alive by visiting it
from JSAbortSignal::visitAdditionalChildrenInGCThread while the signal
wrapper is reachable. Store JS abort algorithms in a separate vector so
the GC thread can iterate them (erasing Ref<AbortAlgorithm> into a
lambda would hide it from the GC).
@robobun

robobun commented Mar 24, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 11:50 PM PT - Mar 23rd, 2026

❌ @sosukesuzuki, your commit c2fbbf8 has 4 failures in Build #41534 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28491

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

bun-28491 --bun

@coderabbitai

coderabbitai Bot commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e3ee2962-90ff-4094-8a54-84c8ea845486

📥 Commits

Reviewing files that changed from the base of the PR and between fe4a66e and c2fbbf8.

📒 Files selected for processing (6)
  • src/bun.js/bindings/webcore/AbortSignal.cpp
  • src/bun.js/bindings/webcore/AbortSignal.h
  • src/bun.js/bindings/webcore/JSAbortAlgorithm.cpp
  • src/bun.js/bindings/webcore/JSAbortAlgorithm.h
  • src/bun.js/bindings/webcore/JSAbortSignalCustom.cpp
  • test/js/web/streams/pipeTo-signal-leak.test.ts

Walkthrough

The changes refactor AbortSignal to maintain abort algorithms in a separate, lock-protected container instead of the generic algorithm queue. Callback references are changed to weak references with GC visitation support. A new regression test verifies memory handling during stream pipeTo operations.

Changes

Cohort / File(s) Summary
Abort Signal Refactoring
src/bun.js/bindings/webcore/AbortSignal.h, src/bun.js/bindings/webcore/AbortSignal.cpp
Separated abort algorithms into a dedicated m_abortAlgorithms container protected by m_abortAlgorithmsLock. Added template method visitAbortAlgorithms() for GC tracing. Modified runAbortSteps() to atomically swap and invoke algorithms from the new container before firing the abort event. Introduced monotonically increasing algorithm identifiers for tracking.
Callback Lifecycle & GC Integration
src/bun.js/bindings/webcore/JSAbortAlgorithm.h, src/bun.js/bindings/webcore/JSAbortAlgorithm.cpp, src/bun.js/bindings/webcore/JSAbortSignalCustom.cpp
Changed JSAbortAlgorithm callback storage from strong to weak references (JSCallbackDataWeak). Added null-check in handleEvent() to handle collected callbacks. Implemented visitJSFunction() overloads for both AbstractSlotVisitor and SlotVisitor. Extended JSAbortSignal GC visitation to include abort algorithms.
Memory Leak Regression Testing
test/js/web/streams/pipeTo-signal-leak.test.ts
Added regression test suite for ReadableStream.pipeTo() with AbortSignal. Two tests verify that AbortSignal object counts remain bounded during repeated iterations with pending streams and deferred/immediate signal aborts, using heap statistics assertions.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a memory leak in AbortSignal when ReadableStream.pipeTo is used with a signal option.
Description check ✅ Passed The description thoroughly covers both required sections: what the PR does (memory leak fix with detailed cycle explanation and before/after metrics) and how it was verified (regression test, existing tests, stress tests).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Unable to abort pipes with an AbortSignal #26392 - Unable to abort pipes with an AbortSignal - this PR fixes the memory leak preventing proper AbortSignal functionality in ReadableStream.pipeTo

If this is helpful, consider adding 'Fixes #26392' to the PR description to auto-close the issue on merge.

🤖 Generated with Claude Code

Comment on lines 310 to 313
size_t AbortSignal::memoryCost() const
{
return sizeof(AbortSignal) + m_native_callbacks.sizeInBytes() + m_algorithms.sizeInBytes() + m_sourceSignals.capacity() + m_dependentSignals.capacity();
return sizeof(AbortSignal) + m_native_callbacks.sizeInBytes() + m_algorithms.sizeInBytes() + m_abortAlgorithms.sizeInBytes() + m_sourceSignals.capacity() + m_dependentSignals.capacity();
}

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.

🟡 AbortSignal::memoryCost() reads m_abortAlgorithms.sizeInBytes() without holding m_abortAlgorithmsLock, violating the WTF_GUARDED_BY_LOCK(m_abortAlgorithmsLock) annotation that this PR itself introduces. Since memoryCost() is called via JSAbortSignal::estimatedSize() during GC heap accounting (potentially from a concurrent GC thread), this is a C++ data race with main-thread calls to addAbortAlgorithmToSignal() or removeAbortAlgorithmFromSignal(). Fix: acquire m_abortAlgorithmsLock before the sizeInBytes() call.

Extended reasoning...

Bug: memoryCost() violates its own lock annotation

What the bug is

AbortSignal::memoryCost() is a const method computing heap size for JSC memory accounting. This PR adds a new field m_abortAlgorithms annotated with WTF_GUARDED_BY_LOCK(m_abortAlgorithmsLock). Every other accessor of this field properly acquires the lock: addAbortAlgorithmToSignal() (before append()), removeAbortAlgorithmFromSignal() (before removeFirstMatching()), runAbortSteps() (before std::exchange()), and visitAbortAlgorithms() (before iterating). memoryCost() is the sole exception, calling m_abortAlgorithms.sizeInBytes() without holding the lock.

Concrete code path

JSAbortSignal::estimatedSize() calls wrapped().memoryCost(). With concurrent GC enabled (BUN_JSC_useConcurrentGC=1, explicitly mentioned in the PR description's stress tests), JSC invokes estimatedSize() from a GC thread. If the main thread is simultaneously inside addAbortAlgorithmToSignal() holding m_abortAlgorithmsLock while appending to the vector (updating internal m_size and potentially reallocating), the GC thread reading m_size via sizeInBytes() is an unsynchronized read-write data race.

Why existing code does not prevent it

The const qualifier on memoryCost() provides no thread-safety guarantee. WTF_GUARDED_BY_LOCK is 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

  1. Main thread calls addAbortAlgorithmToSignal(), acquires m_abortAlgorithmsLock, appends to m_abortAlgorithms (updates m_size, may reallocate buffer).
  2. Simultaneously, GC thread calls JSAbortSignal::estimatedSize() -> wrapped().memoryCost() -> m_abortAlgorithms.sizeInBytes() with no lock held.
  3. Step 2 races on m_size while step 1 writes it -- undefined behavior per the C++ memory model.

Note that visitAbortAlgorithms() correctly holds the lock during its iteration, but memoryCost() 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_LOCK contract introduced by this very PR. Concurrent GC is explicitly exercised in the PR stress test.

Fix

Acquire m_abortAlgorithmsLock inside memoryCost() before calling sizeInBytes():

size_t AbortSignal::memoryCost() const
{
    size_t abortAlgorithmsCost;
    {
        Locker locker { m_abortAlgorithmsLock };
        abortAlgorithmsCost = m_abortAlgorithms.sizeInBytes();
    }
    return sizeof(AbortSignal) + m_native_callbacks.sizeInBytes() + m_algorithms.sizeInBytes()
        + abortAlgorithmsCost + m_sourceSignals.capacity() + m_dependentSignals.capacity();
}

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

Interestingly, this does not fix #26392

@Jarred-Sumner
Jarred-Sumner merged commit f6528b5 into main Mar 24, 2026
59 of 66 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/fix-pipeTo-signal-abort-algorithm-leak branch March 24, 2026 05:31
sosukesuzuki added a commit that referenced this pull request Mar 25, 2026
Follow-up to #28494. Backports WebKit 276563 which removed the
Strong/Weak split in favor of a single Weak-only JSCallbackData class,
making it structurally impossible to introduce Strong callback handles
(a recurring source of GC reference cycles).

The prerequisite AbortSignal::m_algorithms restructuring that #28494
deferred this work for was already completed in #28491, so this is now
a pure rename + dead code removal with no behavior change.

Also deletes ErrorCallback/JSErrorCallback (FileSystemEntry API
callback, never used in Bun).
Jarred-Sumner added a commit that referenced this pull request Mar 25, 2026
…28539)

## What

Follow-up to #28494. Backports WebKit
[276563](https://bugs.webkit.org/show_bug.cgi?id=276563) ("Make all
callbacks Weak handles"), which removed
`JSCallbackDataStrong`/`JSCallbackDataWeak` in favor of a single
Weak-only `JSCallbackData` class.

## Why

`JSC::Strong` handles in callback wrappers have been a recurring source
of GC reference cycles (see #28491 for the `pipeTo + signal` leak).
Upstream WebKit solved this structurally in 2024-07 by deleting the
Strong variant entirely, making such leaks impossible to introduce by
construction.

\#28494 deferred this backport because it "requires restructuring
`AbortSignal::m_algorithms` to allow GC visitation." That restructuring
was already done in #28491 (`m_abortAlgorithms` +
`visitAbortAlgorithms()` + Lock), so the prerequisite is satisfied and
this is now a pure rename + dead code removal.

## Changes

### `JSCallbackData.h` / `.cpp`
- Merged `JSCallbackDataWeak` into the base `JSCallbackData` class
- Deleted `JSCallbackDataStrong`
- Replaced `Strong.h`/`StrongInlines.h` includes with
`Weak.h`/`WeakInlines.h`
- Kept Bun's existing `invokeCallback(VM&, ...)` signature and
`visitJSFunction` method name (not upstream's
`visitJSFunctionInGCThread`) to avoid touching base class overrides —
this is a structural sync, not a full API sync

### `JSAbortAlgorithm`, `JSPerformanceObserverCallback`
- Type rename only: `JSCallbackDataWeak*` → `JSCallbackData*`
- No semantic change (both were already Weak)

### Deleted: `ErrorCallback.{h,cpp,idl}`, `JSErrorCallback.{h,cpp}`
- FileSystemEntry API callback (`webkitGetAsEntry()` etc.), never
implemented in Bun
- Zero `create()` call sites, zero `#include` references outside itself
- Was the only remaining `JSCallbackDataStrong` user
- Upstream still has `ErrorCallback` under `Modules/entriesapi/`, but
with `[GenerateIsReachable]` which generates a Weak callback — our copy
predated that and was a stale Strong version

## Verification

- `nm build/debug/bun-debug | grep -cE "JSCallbackData(Strong|Weak)"` →
`0`
- `test/js/web/abort/` — pass
- `test/js/web/streams/pipeTo-signal-leak.test.ts` — pass (regression
check for #28491)
- `test/js/web/timers/performance-entries.test.ts` — pass
- `BUN_JSC_validateExceptionChecks=1` — pass
- GC stress: 100 AbortSignal callbacks + 50× `Bun.gc(true)` → all 100
fire (opaque-root reachability preserved)
- GC stress: 50 PerformanceObserver callbacks + 30× `Bun.gc(true)` → all
50 fire

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
…n-sh#28491)

## What does this PR do?

Fixes a 100% leak of `AbortSignal` when
`ReadableStream.prototype.pipeTo` is called with a `signal` option and
the pipe never completes.

## The cycle

```
AbortSignal (RefCounted)
  → m_algorithms[i] (lambda capturing Ref<JSAbortAlgorithm>)
  → JSCallbackDataStrong::m_callback (JSC::Strong = GC root)
  → callback closure
  → pipeState.signal (JSAbortSignal wrapper)
  → Ref<AbortSignal>  ← cycle
```

`JSAbortAlgorithm` held its JS callback via `JSCallbackDataStrong`,
which creates a `JSC::Strong` handle (a GC root). The abort algorithm
closure registered by `pipeTo` captures `pipeState`, which in turn holds
`pipeState.signal` (the `JSAbortSignal` wrapper). Since the wrapper
holds a `Ref<AbortSignal>`, the cycle is complete and nothing can be
collected even after all user-side references are dropped.

### Before / After

| | Bun (before) | Bun (after) | Node.js |
|---|---|---|---|
| 200 iterations, pipe never completes | 201 AbortSignals retained | 1 |
~0 |

## The fix

Switch `JSAbortAlgorithm` from `JSCallbackDataStrong` to
`JSCallbackDataWeak`, and keep the callback alive by visiting it from
`JSAbortSignal::visitAdditionalChildrenInGCThread` while the signal
wrapper is reachable.

This follows two existing patterns in the codebase:

- **`JSCallbackDataWeak` + `visitJSFunction` override** — same as
`JSPerformanceObserverCallback`
(`src/bun.js/bindings/webcore/JSPerformanceObserverCallback.{h,cpp}`)
- **Lock + Vector + GC-thread visit** — same as
`EventListenerMap::visitJSEventListeners`
(`src/bun.js/bindings/webcore/EventListenerMap.h:77-85`), which already
protects every `addEventListener`/`removeEventListener` against
concurrent GC iteration

### Why a separate `m_abortAlgorithms` vector?

The existing `m_algorithms` stores type-erased `Function<void(JSValue)>`
lambdas. Erasing `Ref<AbortAlgorithm>` into a lambda would hide it from
the GC thread — there would be no way to call `visitJSFunction` on it.
Storing `Ref<AbortAlgorithm>` directly in a separate vector lets
`visitAbortAlgorithms` iterate and mark the weak callbacks.

The `Locker` in `runAbortSteps` moves the vector out under the lock and
releases before calling `handleEvent`, so we never hold the lock during
JS re-entry.

## How did you verify your code works?

- New regression test `test/js/web/streams/pipeTo-signal-leak.test.ts`
fails on main (201 leaked) and passes with this fix
- Existing `test/js/web/abort/abort.test.ts`,
`test/js/deno/abort/abort-controller.test.ts`,
`test/js/node/util/test-aborted.test.ts`,
`test/js/web/streams/streams.test.js` all pass
- Concurrent GC stress test (`BUN_JSC_useConcurrentGC=1`, 500
controllers × 10 pipes) — no crash, no deadlock
- 5000-iteration heap stress — AbortSignal count stable (growth: -2)
- JSC exception scope validation (`BUN_JSC_validateExceptionChecks=1`) —
clean
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
…ven-sh#28539)

## What

Follow-up to oven-sh#28494. Backports WebKit
[276563](https://bugs.webkit.org/show_bug.cgi?id=276563) ("Make all
callbacks Weak handles"), which removed
`JSCallbackDataStrong`/`JSCallbackDataWeak` in favor of a single
Weak-only `JSCallbackData` class.

## Why

`JSC::Strong` handles in callback wrappers have been a recurring source
of GC reference cycles (see oven-sh#28491 for the `pipeTo + signal` leak).
Upstream WebKit solved this structurally in 2024-07 by deleting the
Strong variant entirely, making such leaks impossible to introduce by
construction.

\oven-sh#28494 deferred this backport because it "requires restructuring
`AbortSignal::m_algorithms` to allow GC visitation." That restructuring
was already done in oven-sh#28491 (`m_abortAlgorithms` +
`visitAbortAlgorithms()` + Lock), so the prerequisite is satisfied and
this is now a pure rename + dead code removal.

## Changes

### `JSCallbackData.h` / `.cpp`
- Merged `JSCallbackDataWeak` into the base `JSCallbackData` class
- Deleted `JSCallbackDataStrong`
- Replaced `Strong.h`/`StrongInlines.h` includes with
`Weak.h`/`WeakInlines.h`
- Kept Bun's existing `invokeCallback(VM&, ...)` signature and
`visitJSFunction` method name (not upstream's
`visitJSFunctionInGCThread`) to avoid touching base class overrides —
this is a structural sync, not a full API sync

### `JSAbortAlgorithm`, `JSPerformanceObserverCallback`
- Type rename only: `JSCallbackDataWeak*` → `JSCallbackData*`
- No semantic change (both were already Weak)

### Deleted: `ErrorCallback.{h,cpp,idl}`, `JSErrorCallback.{h,cpp}`
- FileSystemEntry API callback (`webkitGetAsEntry()` etc.), never
implemented in Bun
- Zero `create()` call sites, zero `#include` references outside itself
- Was the only remaining `JSCallbackDataStrong` user
- Upstream still has `ErrorCallback` under `Modules/entriesapi/`, but
with `[GenerateIsReachable]` which generates a Weak callback — our copy
predated that and was a stale Strong version

## Verification

- `nm build/debug/bun-debug | grep -cE "JSCallbackData(Strong|Weak)"` →
`0`
- `test/js/web/abort/` — pass
- `test/js/web/streams/pipeTo-signal-leak.test.ts` — pass (regression
check for oven-sh#28491)
- `test/js/web/timers/performance-entries.test.ts` — pass
- `BUN_JSC_validateExceptionChecks=1` — pass
- GC stress: 100 AbortSignal callbacks + 50× `Bun.gc(true)` → all 100
fire (opaque-root reachability preserved)
- GC stress: 50 PerformanceObserver callbacks + 30× `Bun.gc(true)` → all
50 fire

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…n-sh#28491)

## What does this PR do?

Fixes a 100% leak of `AbortSignal` when
`ReadableStream.prototype.pipeTo` is called with a `signal` option and
the pipe never completes.

## The cycle

```
AbortSignal (RefCounted)
  → m_algorithms[i] (lambda capturing Ref<JSAbortAlgorithm>)
  → JSCallbackDataStrong::m_callback (JSC::Strong = GC root)
  → callback closure
  → pipeState.signal (JSAbortSignal wrapper)
  → Ref<AbortSignal>  ← cycle
```

`JSAbortAlgorithm` held its JS callback via `JSCallbackDataStrong`,
which creates a `JSC::Strong` handle (a GC root). The abort algorithm
closure registered by `pipeTo` captures `pipeState`, which in turn holds
`pipeState.signal` (the `JSAbortSignal` wrapper). Since the wrapper
holds a `Ref<AbortSignal>`, the cycle is complete and nothing can be
collected even after all user-side references are dropped.

### Before / After

| | Bun (before) | Bun (after) | Node.js |
|---|---|---|---|
| 200 iterations, pipe never completes | 201 AbortSignals retained | 1 |
~0 |

## The fix

Switch `JSAbortAlgorithm` from `JSCallbackDataStrong` to
`JSCallbackDataWeak`, and keep the callback alive by visiting it from
`JSAbortSignal::visitAdditionalChildrenInGCThread` while the signal
wrapper is reachable.

This follows two existing patterns in the codebase:

- **`JSCallbackDataWeak` + `visitJSFunction` override** — same as
`JSPerformanceObserverCallback`
(`src/bun.js/bindings/webcore/JSPerformanceObserverCallback.{h,cpp}`)
- **Lock + Vector + GC-thread visit** — same as
`EventListenerMap::visitJSEventListeners`
(`src/bun.js/bindings/webcore/EventListenerMap.h:77-85`), which already
protects every `addEventListener`/`removeEventListener` against
concurrent GC iteration

### Why a separate `m_abortAlgorithms` vector?

The existing `m_algorithms` stores type-erased `Function<void(JSValue)>`
lambdas. Erasing `Ref<AbortAlgorithm>` into a lambda would hide it from
the GC thread — there would be no way to call `visitJSFunction` on it.
Storing `Ref<AbortAlgorithm>` directly in a separate vector lets
`visitAbortAlgorithms` iterate and mark the weak callbacks.

The `Locker` in `runAbortSteps` moves the vector out under the lock and
releases before calling `handleEvent`, so we never hold the lock during
JS re-entry.

## How did you verify your code works?

- New regression test `test/js/web/streams/pipeTo-signal-leak.test.ts`
fails on main (201 leaked) and passes with this fix
- Existing `test/js/web/abort/abort.test.ts`,
`test/js/deno/abort/abort-controller.test.ts`,
`test/js/node/util/test-aborted.test.ts`,
`test/js/web/streams/streams.test.js` all pass
- Concurrent GC stress test (`BUN_JSC_useConcurrentGC=1`, 500
controllers × 10 pipes) — no crash, no deadlock
- 5000-iteration heap stress — AbortSignal count stable (growth: -2)
- JSC exception scope validation (`BUN_JSC_validateExceptionChecks=1`) —
clean
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…ven-sh#28539)

## What

Follow-up to oven-sh#28494. Backports WebKit
[276563](https://bugs.webkit.org/show_bug.cgi?id=276563) ("Make all
callbacks Weak handles"), which removed
`JSCallbackDataStrong`/`JSCallbackDataWeak` in favor of a single
Weak-only `JSCallbackData` class.

## Why

`JSC::Strong` handles in callback wrappers have been a recurring source
of GC reference cycles (see oven-sh#28491 for the `pipeTo + signal` leak).
Upstream WebKit solved this structurally in 2024-07 by deleting the
Strong variant entirely, making such leaks impossible to introduce by
construction.

\oven-sh#28494 deferred this backport because it "requires restructuring
`AbortSignal::m_algorithms` to allow GC visitation." That restructuring
was already done in oven-sh#28491 (`m_abortAlgorithms` +
`visitAbortAlgorithms()` + Lock), so the prerequisite is satisfied and
this is now a pure rename + dead code removal.

## Changes

### `JSCallbackData.h` / `.cpp`
- Merged `JSCallbackDataWeak` into the base `JSCallbackData` class
- Deleted `JSCallbackDataStrong`
- Replaced `Strong.h`/`StrongInlines.h` includes with
`Weak.h`/`WeakInlines.h`
- Kept Bun's existing `invokeCallback(VM&, ...)` signature and
`visitJSFunction` method name (not upstream's
`visitJSFunctionInGCThread`) to avoid touching base class overrides —
this is a structural sync, not a full API sync

### `JSAbortAlgorithm`, `JSPerformanceObserverCallback`
- Type rename only: `JSCallbackDataWeak*` → `JSCallbackData*`
- No semantic change (both were already Weak)

### Deleted: `ErrorCallback.{h,cpp,idl}`, `JSErrorCallback.{h,cpp}`
- FileSystemEntry API callback (`webkitGetAsEntry()` etc.), never
implemented in Bun
- Zero `create()` call sites, zero `#include` references outside itself
- Was the only remaining `JSCallbackDataStrong` user
- Upstream still has `ErrorCallback` under `Modules/entriesapi/`, but
with `[GenerateIsReachable]` which generates a Weak callback — our copy
predated that and was a stale Strong version

## Verification

- `nm build/debug/bun-debug | grep -cE "JSCallbackData(Strong|Weak)"` →
`0`
- `test/js/web/abort/` — pass
- `test/js/web/streams/pipeTo-signal-leak.test.ts` — pass (regression
check for oven-sh#28491)
- `test/js/web/timers/performance-entries.test.ts` — pass
- `BUN_JSC_validateExceptionChecks=1` — pass
- GC stress: 100 AbortSignal callbacks + 50× `Bun.gc(true)` → all 100
fire (opaque-root reachability preserved)
- GC stress: 50 PerformanceObserver callbacks + 30× `Bun.gc(true)` → all
50 fire

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants