Skip to content

Cherry-pick 4 WebCore memory safety fixes from upstream WebKit - #28494

Merged
Jarred-Sumner merged 2 commits into
mainfrom
claude/webcore-safety-fixes
Mar 25, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
claude/webcore-safety-fixes

Conversation

@sosukesuzuki

@sosukesuzuki sosukesuzuki commented Mar 24, 2026 •

Copy link
Copy Markdown
Member

What

Cherry-picks 4 memory safety fixes from upstream WebKit into src/bun.js/bindings/webcore/.

Why

Bun's WebCore code was forked from WebKit ~2022-03 and has since missed several GC/memory safety fixes. These are minimal, surgical backports of confirmed bug fixes.

Changes

1. MessagePortChannel: drop messages to closed ports

Upstream: WebKit 281662
Messages sent to a closed MessagePort were queued indefinitely in m_pendingMessages, never to be delivered.
Measured: 5000 × 64KB postMessage to closed port: RSS growth 332MB → 1.5MB

2. JSAbortController: visit signal.reason in GC

Upstream: WebKit 293319
controller.signal.reason was not marked during GC when only the controller was retained, causing reason to become undefined after collection.

3. BroadcastChannel: use ThreadSafeWeakPtr in global map

Upstream: WebKit 267883
The global channel map held raw BroadcastChannel* pointers. If a worker-owned channel was being destroyed while the main thread looked it up, the resulting RefPtr assignment could race. ThreadSafeWeakPtr::get() atomically checks liveness via the control block.

4. EventListenerMap: per-instance thread affinity assert

Upstream: WebKit 292397 + 304961
Adds releaseAssertOrSetThreadUID() to mutation operations. Unlike a global isMainThread() check, this records the owning thread per-instance, so worker-owned EventTargets work correctly. GC thread sweeps are exempted.

Tests

  • test/js/web/abort/abort-controller-gc-reason.test.ts
  • test/js/web/workers/message-port-closed-leak.test.ts
  • test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts

All 270 tests in test/js/web/{abort,workers,broadcastchannel}/ + test/js/deno/event/ pass.

Not in this PR

JSCallbackDataStrong removal (WebKit 276563) requires restructuring AbortSignal::m_algorithms to allow GC visitation. Deferred to a follow-up.

@robobun

robobun commented Mar 24, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 1:27 AM PT - Mar 24th, 2026

❌ @autofix-ci[bot], your commit 9bfffb1 has 5 failures in Build #41554 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28494

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

bun-28494 --bun

@coderabbitai

coderabbitai Bot commented Mar 24, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

This pull request strengthens memory and thread safety throughout WebCore bindings by converting raw pointers to weak references in broadcast channels, adding thread-safety assertions to event listener operations, improving garbage collection visitation for abort signals, preventing message posting to closed ports, and introducing comprehensive safety test coverage for these scenarios.

Changes

Cohort / File(s) Summary
Message Channel Registry Updates
src/bun.js/bindings/webcore/BroadcastChannel.cpp, src/bun.js/bindings/webcore/MessagePortChannel.cpp
BroadcastChannel registry now uses ThreadSafeWeakPtr instead of raw pointers for stored channels; MessagePortChannel returns early when posting to closed destination ports to prevent message queueing and dispatch processing.
Event Listener Map Thread Safety
src/bun.js/bindings/webcore/EventListenerMap.h, src/bun.js/bindings/webcore/EventListenerMap.cpp
New per-instance m_threadUID tracking and releaseAssertOrSetThreadUID() helper enforce single-thread access for listener operations, asserting thread consistency before acquiring locks in clear, replace, add, remove, and removeFirstEventListenerCreatedFromMarkup methods.
Abort Controller GC Visitation
src/bun.js/bindings/webcore/JSAbortController.cpp
Enhanced garbage collection child visitation by explicitly traversing the abort signal reason to ensure it remains reachable during GC traversals.
Garbage Collection and Memory Safety Tests
test/js/web/abort/abort-controller-gc-reason.test.ts, test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts, test/js/web/workers/message-port-closed-leak.test.ts
New test suites validating memory safety: abort controller reason persistence across GC cycles, broadcast channel stability when workers terminate under GC pressure, and message port closed state to prevent memory leaks.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Cherry-pick 4 WebCore memory safety fixes from upstream WebKit' clearly and accurately summarizes the main change in the changeset.
Description check ✅ Passed The PR description comprehensively covers the template requirements with detailed 'What' section explaining the changes and 'How did you verify' coverage via extensive test information and measured improvements.

✏️ 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.

@sosukesuzuki
sosukesuzuki force-pushed the claude/webcore-safety-fixes branch from b9fdd16 to 01af732 Compare March 24, 2026 05:41

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/webcore-sync-plan.md`:
- Around line 116-135: Update the Phase 1 checklist to reflect what actually
landed: mark the four fixes that have already been merged as checked/removed so
they are not chased again; for BroadcastChannel, change the target from
channelToContextIdentifier to the actual raw-pointer map symbol
allBroadcastChannels() in BroadcastChannel.cpp; for JSAbortController, rename
the checklist hook to the exact implemented hook used in JSAbortController.cpp
(replace the incorrect visitAdditionalChildrenInGCThread with the actual
implemented visit hook name used in the current code); and ensure the
EventListenerMap entry references the correct class/method names
(EventListenerMap::releaseAssertOrSetThreadUID) and only leave entries unchecked
that truly remain outstanding.

In `@src/bun.js/bindings/webcore/EventListenerMap.h`:
- Around line 75-89: The m_threadUID field is racy because
releaseAssertOrSetThreadUID() reads/writes it before acquiring m_lock in
mutators clear(), replace(), add(), remove(), and
removeFirstEventListenerCreatedFromMarkup(); fix by either making m_threadUID an
atomic<uint32_t> and use an atomic compare-and-swap to perform the "first-touch"
set safely inside releaseAssertOrSetThreadUID(), or move the initial
check-and-assign logic so it runs while holding Lock m_lock (i.e., acquire
m_lock before testing/setting m_threadUID) so publication is protected by the
existing mutex.

In `@test/js/web/abort/abort-controller-gc-reason.test.ts`:
- Line 56: The test uses string.repeat to generate the payload; update the abort
call so the data payload is generated with Buffer.alloc(count, fill).toString()
instead of "x".repeat(100) — i.e., in the c.abort({ index: i, data: ... })
invocation replace the "x".repeat(100) expression with Buffer.alloc(100,
'x').toString() to follow test conventions.

In `@test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts`:
- Around line 55-66: Replace the fixed setTimeout waits with a condition-based
barrier: create an awaitable promise (e.g., waitForMessages or waitForDispatch)
that resolves when the test-observable (the BroadcastChannel message handler or
a counter updated by mainChannel.onmessage / worker message handler) reaches the
expected state/count, then await that promise instead of sleeping; specifically,
after sending the initial messages and again after Bun.gc(true) + posting the
100 "after-gc" messages, await the corresponding waitForMessages(expectedCount)
tied to mainChannel.onmessage (or the worker-side handler) so the test proceeds
only when the messages have actually been observed rather than after a fixed
timeout.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7d15d8d0-f962-44d9-88fd-66e29300ffdd

📥 Commits

Reviewing files that changed from the base of the PR and between 639bc43 and b9fdd16b3221b0c807f60a972c13c52dd50de5de.

📒 Files selected for processing (9)
  • docs/webcore-sync-plan.md
  • src/bun.js/bindings/webcore/BroadcastChannel.cpp
  • src/bun.js/bindings/webcore/EventListenerMap.cpp
  • src/bun.js/bindings/webcore/EventListenerMap.h
  • src/bun.js/bindings/webcore/JSAbortController.cpp
  • src/bun.js/bindings/webcore/MessagePortChannel.cpp
  • test/js/web/abort/abort-controller-gc-reason.test.ts
  • test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts
  • test/js/web/workers/message-port-closed-leak.test.ts

Comment thread docs/webcore-sync-plan.md Outdated
Comment on lines +116 to +135
- [ ] **`JSAbortController::visitAdditionalChildrenInGCThread`追加**
- ファイル: `src/bun.js/bindings/webcore/JSAbortController.cpp`(要生成 or 手動実装)
- 理由: `signal().reason()`のJSValueがGCでvisitされていない可能性。メモリ破壊リスク
- 参照: `vendor/WebKit/Source/WebCore/bindings/js/JSAbortControllerCustom.cpp`

- [ ] **`BroadcastChannel`の`ThreadSafeWeakPtr`化**
- ファイル: `src/bun.js/bindings/webcore/BroadcastChannel.cpp:channelToContextIdentifier`
- 理由: 現在raw pointerでマップ格納。Worker終了時のUAF可能性
- 参照: `vendor/WebKit/Source/WebCore/dom/BroadcastChannel.cpp`

- [ ] **`MessagePortChannel`の閉じたポートチェック(メモリリーク修正)**
- ファイル: `src/bun.js/bindings/webcore/MessagePortChannel.cpp:postMessageToRemote`
- 理由: 閉じたMessageChannelへのbuffer転送がメモリリークを引き起こす(WebKit bug 281662)
- WebKit該当行: `if (m_isClosed[i]) return;`
- WebKitコミット: `ae1111af5882` "Transferring buffers to a closed MessageChannel causes memory leaks"

- [ ] **`EventListenerMap::releaseAssertOrSetThreadUID`追加**
- ファイル: `src/bun.js/bindings/webcore/EventListenerMap.{cpp,h}`
- 理由: スレッド違反の早期検出。デバッグビルドでの品質向上

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.

⚠️ Potential issue | 🟡 Minor

Sync the Phase 1 checklist with what this PR already landed.

These entries are already stale in the same PR: the four fixes remain unchecked, the BroadcastChannel row points at channelToContextIdentifier instead of the allBroadcastChannels() raw-pointer map, and the JSAbortController row names a different hook than the implementation now uses. Please update this section so the next sync pass does not chase the wrong follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/webcore-sync-plan.md` around lines 116 - 135, Update the Phase 1
checklist to reflect what actually landed: mark the four fixes that have already
been merged as checked/removed so they are not chased again; for
BroadcastChannel, change the target from channelToContextIdentifier to the
actual raw-pointer map symbol allBroadcastChannels() in BroadcastChannel.cpp;
for JSAbortController, rename the checklist hook to the exact implemented hook
used in JSAbortController.cpp (replace the incorrect
visitAdditionalChildrenInGCThread with the actual implemented visit hook name
used in the current code); and ensure the EventListenerMap entry references the
correct class/method names (EventListenerMap::releaseAssertOrSetThreadUID) and
only leave entries unchecked that truly remain outstanding.

Comment on lines +75 to +89
void releaseAssertOrSetThreadUID()
{
if (!m_threadUID) {
ASSERT(!Thread::mayBeGCThread());
m_threadUID = Thread::currentSingleton().uid();
return;
}
if (m_threadUID == Thread::currentSingleton().uid()) [[likely]]
return;
RELEASE_ASSERT(Thread::mayBeGCThread());
}

Vector<std::pair<AtomString, EventListenerVector>, 0, CrashOnOverflow, 4> m_entries;
Lock m_lock;
uint32_t m_threadUID { 0 };

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== EventListenerMap thread UID state ==\n'
rg -n -C2 'releaseAssertOrSetThreadUID|m_threadUID' src/bun.js/bindings/webcore/EventListenerMap.h

printf '\n== Mutators calling the helper before taking m_lock ==\n'
rg -n -C2 'releaseAssertOrSetThreadUID\(\);|Locker locker \{ m_lock \};' src/bun.js/bindings/webcore/EventListenerMap.cpp

Repository: oven-sh/bun

Length of output: 1934


Make m_threadUID atomic or publish it under m_lock.

releaseAssertOrSetThreadUID() reads and writes m_threadUID before Locker locker { m_lock }; is acquired in all five mutators (clear(), replace(), add(), remove(), removeFirstEventListenerCreatedFromMarkup()). Since m_threadUID is a plain uint32_t, this creates a data race: concurrent threads can race on the initial read and write of m_threadUID, allowing the wrong thread to observe or set 0 before acquiring the mutex. This defeats the guard's purpose and invokes undefined behavior. Use an atomic<uint32_t> with compare-and-swap for safe first-touch publication, or move the assignment inside the existing lock.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/bindings/webcore/EventListenerMap.h` around lines 75 - 89, The
m_threadUID field is racy because releaseAssertOrSetThreadUID() reads/writes it
before acquiring m_lock in mutators clear(), replace(), add(), remove(), and
removeFirstEventListenerCreatedFromMarkup(); fix by either making m_threadUID an
atomic<uint32_t> and use an atomic compare-and-swap to perform the "first-touch"
set safely inside releaseAssertOrSetThreadUID(), or move the initial
check-and-assign logic so it runs while holding Lock m_lock (i.e., acquire
m_lock before testing/setting m_threadUID) so publication is protected by the
existing mutex.

const controllers = [];
for (let i = 0; i < 100; i++) {
const c = new AbortController();
c.abort({ index: i, data: "x".repeat(100) });

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify string repeat usage in this test file
rg -n --type=ts '"[^"]+"\.repeat\(' test/js/web/abort/abort-controller-gc-reason.test.ts

Repository: oven-sh/bun

Length of output: 115


🏁 Script executed:

#!/bin/bash
# Search for all .repeat() usages in the file (not just string literals)
rg -n 'repeat\(' test/js/web/abort/abort-controller-gc-reason.test.ts

Repository: oven-sh/bun

Length of output: 115


Replace string .repeat() test payload generation with Buffer.alloc().

At line 56, use Buffer.alloc(count, fill).toString() instead of "x".repeat(100) to match test conventions.

🔧 Proposed fix
-            c.abort({ index: i, data: "x".repeat(100) });
+            c.abort({ index: i, data: Buffer.alloc(100, "x").toString() });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
c.abort({ index: i, data: "x".repeat(100) });
c.abort({ index: i, data: Buffer.alloc(100, "x").toString() });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/web/abort/abort-controller-gc-reason.test.ts` at line 56, The test
uses string.repeat to generate the payload; update the abort call so the data
payload is generated with Buffer.alloc(count, fill).toString() instead of
"x".repeat(100) — i.e., in the c.abort({ index: i, data: ... }) invocation
replace the "x".repeat(100) expression with Buffer.alloc(100, 'x').toString() to
follow test conventions.

Comment on lines +55 to +66
// Give the event loop time to process any pending dispatches.
await new Promise(resolve => setTimeout(resolve, 100));

// Force GC to clean up any lingering references.
Bun.gc(true);

// Post more messages after GC.
for (let i = 0; i < 100; i++) {
mainChannel.postMessage("after-gc " + i);
}

await new Promise(resolve => setTimeout(resolve, 50));

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.

⚠️ Potential issue | 🟠 Major

Replace the fixed sleeps with a condition-based barrier.

The 100ms/50ms waits make this regression machine-speed dependent, so it can miss the teardown window on slow CI or fail spuriously under load. Please wait on an observable completion signal instead of sleeping.

As per coding guidelines, "Do not use setTimeout in tests; instead await the condition to be met - you are testing the CONDITION, not TIME PASSING".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts` around
lines 55 - 66, Replace the fixed setTimeout waits with a condition-based
barrier: create an awaitable promise (e.g., waitForMessages or waitForDispatch)
that resolves when the test-observable (the BroadcastChannel message handler or
a counter updated by mainChannel.onmessage / worker message handler) reaches the
expected state/count, then await that promise instead of sleeping; specifically,
after sending the initial messages and again after Bun.gc(true) + posting the
100 "after-gc" messages, await the corresponding waitForMessages(expectedCount)
tied to mainChannel.onmessage (or the worker-side handler) so the test proceeds
only when the messages have actually been observed rather than after a fixed
timeout.

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

♻️ Duplicate comments (3)
src/bun.js/bindings/webcore/EventListenerMap.cpp (1)

76-77: ⚠️ Potential issue | 🔴 Critical

Acquire m_lock before releaseAssertOrSetThreadUID() to avoid a data race.

At Line 76, Line 106, Line 120, Line 147, and Line 186, the helper is called before lock acquisition. Since the helper reads/writes m_threadUID (non-atomic), this is UB under concurrent access.

🔧 Minimal fix (serialize helper under existing lock)
 void EventListenerMap::clear()
 {
-    releaseAssertOrSetThreadUID();
     Locker locker { m_lock };
+    releaseAssertOrSetThreadUID();
@@
 void EventListenerMap::replace(const AtomString& eventType, EventListener& oldListener, Ref<EventListener>&& newListener, const RegisteredEventListener::Options& options)
 {
-    releaseAssertOrSetThreadUID();
     Locker locker { m_lock };
+    releaseAssertOrSetThreadUID();
@@
 bool EventListenerMap::add(const AtomString& eventType, Ref<EventListener>&& listener, const RegisteredEventListener::Options& options)
 {
-    releaseAssertOrSetThreadUID();
     Locker locker { m_lock };
+    releaseAssertOrSetThreadUID();
@@
 bool EventListenerMap::remove(const AtomString& eventType, EventListener& listener, bool useCapture)
 {
-    releaseAssertOrSetThreadUID();
     Locker locker { m_lock };
+    releaseAssertOrSetThreadUID();
@@
 void EventListenerMap::removeFirstEventListenerCreatedFromMarkup(const AtomString& eventType)
 {
-    releaseAssertOrSetThreadUID();
     Locker locker { m_lock };
+    releaseAssertOrSetThreadUID();

Verification (read-only):

#!/bin/bash
set -euo pipefail

echo "== Check helper vs lock order in mutators =="
rg -n -C2 'void EventListenerMap::(clear|replace|add|remove|removeFirstEventListenerCreatedFromMarkup)\(|releaseAssertOrSetThreadUID\(\);|Locker locker \{ m_lock \};' src/bun.js/bindings/webcore/EventListenerMap.cpp

echo
echo "== Check m_threadUID declaration =="
rg -n -C1 '\bm_threadUID\b' src/bun.js/bindings/webcore/EventListenerMap.h

Also applies to: 106-107, 120-121, 147-148, 186-187

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/bindings/webcore/EventListenerMap.cpp` around lines 76 - 77, The
helper releaseAssertOrSetThreadUID() accesses non-atomic m_threadUID and must be
called while holding the mutex; move the call to occur after acquiring the lock
(i.e., after Locker locker { m_lock };) in all mutators of EventListenerMap
(clear, replace, add, remove, removeFirstEventListenerCreatedFromMarkup) so the
helper runs under the existing lock protecting m_threadUID and eliminate the
data race.
test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts (1)

55-66: ⚠️ Potential issue | 🟠 Major

Replace the fixed sleeps with a condition-based barrier.

Line 56 and Line 66 make this regression timing-dependent; on slow or contended CI, the teardown window can shift enough that the test either misses the race or fails spuriously. Please gate progress on a test-observable condition instead of elapsed time.

As per coding guidelines, "Do not use setTimeout in tests; instead await the condition to be met - you are testing the CONDITION, not TIME PASSING".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts` around
lines 55 - 66, Replace the fixed setTimeout waits with condition-based awaits
that resolve when the test-observable message counts reach expected values:
add/use the existing message handler on mainChannel (or worker) to collect
received messages and expose a Promise that resolves when the pre-GC dispatches
have been observed, then await that Promise instead of setTimeout before calling
Bun.gc(true); after calling Bun.gc(true) and posting the 100 "after-gc"
messages, await a second Promise that resolves when the post-GC message counter
reaches 100 (or the total expected), ensuring the test proceeds only once the
observable condition (message receipt) is met rather than a fixed timeout while
keeping Bun.gc(true) and mainChannel.postMessage as-is.
test/js/web/abort/abort-controller-gc-reason.test.ts (1)

54-57: ⚠️ Potential issue | 🟡 Minor

Use Buffer.alloc() for the repeated payload.

Line 56 uses "x".repeat(100), which goes against the repo convention for repetitive test strings.

♻️ Proposed fix
-            c.abort({ index: i, data: "x".repeat(100) });
+            c.abort({ index: i, data: Buffer.alloc(100, "x").toString() });

As per coding guidelines, "Use Buffer.alloc(count, fill).toString() instead of 'A'.repeat(count) to create repetitive strings in tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/web/abort/abort-controller-gc-reason.test.ts` around lines 54 - 57,
Replace the repeated string construction in the test loop with the repo-standard
Buffer.alloc approach: when creating the AbortController and calling c.abort({
index: i, data: "x".repeat(100) }) (inside the for loop that pushes into
controllers), use Buffer.alloc(100, "x").toString() for the data payload instead
of "x".repeat(100) so the test follows the project's repetitive-string
convention.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/bun.js/bindings/webcore/EventListenerMap.cpp`:
- Around line 76-77: The helper releaseAssertOrSetThreadUID() accesses
non-atomic m_threadUID and must be called while holding the mutex; move the call
to occur after acquiring the lock (i.e., after Locker locker { m_lock };) in all
mutators of EventListenerMap (clear, replace, add, remove,
removeFirstEventListenerCreatedFromMarkup) so the helper runs under the existing
lock protecting m_threadUID and eliminate the data race.

In `@test/js/web/abort/abort-controller-gc-reason.test.ts`:
- Around line 54-57: Replace the repeated string construction in the test loop
with the repo-standard Buffer.alloc approach: when creating the AbortController
and calling c.abort({ index: i, data: "x".repeat(100) }) (inside the for loop
that pushes into controllers), use Buffer.alloc(100, "x").toString() for the
data payload instead of "x".repeat(100) so the test follows the project's
repetitive-string convention.

In `@test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts`:
- Around line 55-66: Replace the fixed setTimeout waits with condition-based
awaits that resolve when the test-observable message counts reach expected
values: add/use the existing message handler on mainChannel (or worker) to
collect received messages and expose a Promise that resolves when the pre-GC
dispatches have been observed, then await that Promise instead of setTimeout
before calling Bun.gc(true); after calling Bun.gc(true) and posting the 100
"after-gc" messages, await a second Promise that resolves when the post-GC
message counter reaches 100 (or the total expected), ensuring the test proceeds
only once the observable condition (message receipt) is met rather than a fixed
timeout while keeping Bun.gc(true) and mainChannel.postMessage as-is.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4935520c-9027-439e-97b6-a3d78746927f

📥 Commits

Reviewing files that changed from the base of the PR and between b9fdd16b3221b0c807f60a972c13c52dd50de5de and 9bfffb1.

📒 Files selected for processing (8)
  • src/bun.js/bindings/webcore/BroadcastChannel.cpp
  • src/bun.js/bindings/webcore/EventListenerMap.cpp
  • src/bun.js/bindings/webcore/EventListenerMap.h
  • src/bun.js/bindings/webcore/JSAbortController.cpp
  • src/bun.js/bindings/webcore/MessagePortChannel.cpp
  • test/js/web/abort/abort-controller-gc-reason.test.ts
  • test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts
  • test/js/web/workers/message-port-closed-leak.test.ts

@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 fixes are clean surgical backports with good test coverage, but these changes touch critical C++ GC marking and threading paths (JSAbortController::visitChildrenImpl, ThreadSafeWeakPtr map, RELEASE_ASSERT in EventListenerMap) that warrant human eyes before landing.

Extended reasoning...

Overview

This PR cherry-picks 4 memory safety fixes from upstream WebKit: drop messages to closed MessagePort, visit signal.reason in GC for JSAbortController, use ThreadSafeWeakPtr in BroadcastChannel global map, and add per-instance thread affinity assertions to EventListenerMap. A docs file tracking future WebCore sync work is also added.

Security / Correctness Risks

All four changes address real memory safety bugs (UAF, GC premature collection, memory leaks), so they are net improvements. The main correctness risk is in the backport itself. The JSAbortController change calls thisObject->wrapped().signal().reason().visit(visitor) from the GC thread - reason() must be safe to access without JSLock; upstream added this line so it is presumed safe, but worth verifying against Bun's fork divergence. The EventListenerMap change adds RELEASE_ASSERT(Thread::mayBeGCThread()) which will crash in production if any non-GC, non-owning thread calls a mutation method - correct by design but any missed code path would be a hard crash.

Level of Scrutiny

These changes are minimal and directly mirror confirmed upstream WebKit fixes with bug IDs cited. However, GC marking changes and threading assertions in C++ are high-stakes: a wrong assumption causes either memory corruption or a process crash. A quick human verification that the backport context matches Bun's fork correctly is warranted.

Other Factors

The only automated bug found is a nit: the BroadcastChannel test uses setTimeout instead of condition-based synchronization. The report itself acknowledges this is unavoidable since worker.terminate() has no observable completion event from JS, and the test cannot produce false failures - only potentially reduced sensitivity on fast machines. This should not block the PR.

Comment on lines +54 to +68

// Give the event loop time to process any pending dispatches.
await new Promise(resolve => setTimeout(resolve, 100));

// Force GC to clean up any lingering references.
Bun.gc(true);

// Post more messages after GC.
for (let i = 0; i < 100; i++) {
mainChannel.postMessage("after-gc " + i);
}

await new Promise(resolve => setTimeout(resolve, 50));

mainChannel.close();

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 first test in broadcast-channel-worker-gc.test.ts uses setTimeout(resolve, 100) and setTimeout(resolve, 50) (lines 56 and 66) inside the subprocess script, violating the CLAUDE.md guideline: "never wait for time to pass in tests." While there is no existing synchronization primitive to replace these delays (since worker.terminate() is fire-and-forget), the test could be made more robust by increasing the message count rather than relying on time windows.

Extended reasoning...

The test/CLAUDE.md file explicitly states: "Do not write flaky tests. Unless explicitly asked, never wait for time to pass in tests. Always wait for the condition to be met instead of waiting for an arbitrary amount of time." The subprocess script in broadcast-channel-worker-gc.test.ts (the first test) uses await new Promise(resolve => setTimeout(resolve, 100)) after terminating workers and await new Promise(resolve => setTimeout(resolve, 50)) after posting more messages, which is a direct violation of this rule in newly-added test code.

One refutation argues that the setTimeout lives inside a -e script string passed to bunExe() rather than directly in the bun:test framework, and thus the CLAUDE.md rule doesn’t apply. However, this is still test code authored for this PR, and the spirit of the guideline — avoid time-based synchronization in test infrastructure — clearly applies to subprocess scripts used as test harnesses.

A stronger point from the refutation is that there is genuinely no synchronization primitive available to replace the delays. The Worker API provides no “terminated” event observable from JS, and worker.terminate() is fire-and-forget. This means the condition-based alternative recommended by CLAUDE.md literally does not exist for this particular race scenario.

The practical impact is also bounded: the test asserts exitCode == 0, stderr == "", and stdout.trim() == "OK". The console.log("OK") is reached unconditionally at the end of the script (assuming no crash). So the test cannot produce false failures from timing — the worst case is that the race window is not stressed adequately on very fast machines, reducing the test’s ability to detect regressions (false passes), not causing spurious failures.

Step-by-step proof of the violation: (1) Worker threads are terminated via worker.terminate() with no observable completion event. (2) The script then calls await new Promise(resolve => setTimeout(resolve, 100)) — a hardcoded 100 ms pause — hoping workers have torn down. (3) On an extremely fast machine or lightly loaded CI, the GC + race window may complete well within 100 ms, meaning the race is never exercised. Conversely the 100 ms is arbitrary and not tied to any condition. (4) The same pattern repeats with setTimeout(resolve, 50) after the post-GC messages. Both delays are arbitrary time budgets with no fallback condition.

A conformant fix would remove the time-based delays and instead rely on a higher message count (e.g., 500 post/terminate cycles) to increase the statistical probability of catching the race, combined with the existing Bun.gc(true) calls which are synchronous. Alternatively, a comment explaining why no condition-based wait is possible would at minimum document the deliberate exception to the rule.

@Jarred-Sumner
Jarred-Sumner merged commit 9ce5b05 into main Mar 25, 2026
53 of 64 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/webcore-safety-fixes branch March 25, 2026 00:09
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
…sh#28494)

## What

Cherry-picks 4 memory safety fixes from upstream WebKit into
`src/bun.js/bindings/webcore/`.

## Why

Bun's WebCore code was forked from WebKit ~2022-03 and has since missed
several GC/memory safety fixes. These are minimal, surgical backports of
confirmed bug fixes.

## Changes

### 1. `MessagePortChannel`: drop messages to closed ports
**Upstream:** WebKit
[281662](https://bugs.webkit.org/show_bug.cgi?id=281662)
Messages sent to a closed `MessagePort` were queued indefinitely in
`m_pendingMessages`, never to be delivered.
**Measured:** 5000 × 64KB `postMessage` to closed port: RSS growth 332MB
→ 1.5MB

### 2. `JSAbortController`: visit `signal.reason` in GC
**Upstream:** WebKit
[293319](https://bugs.webkit.org/show_bug.cgi?id=293319)
`controller.signal.reason` was not marked during GC when only the
controller was retained, causing `reason` to become `undefined` after
collection.

### 3. `BroadcastChannel`: use `ThreadSafeWeakPtr` in global map
**Upstream:** WebKit
[267883](https://bugs.webkit.org/show_bug.cgi?id=267883)
The global channel map held raw `BroadcastChannel*` pointers. If a
worker-owned channel was being destroyed while the main thread looked it
up, the resulting `RefPtr` assignment could race.
`ThreadSafeWeakPtr::get()` atomically checks liveness via the control
block.

### 4. `EventListenerMap`: per-instance thread affinity assert
**Upstream:** WebKit
[292397](https://bugs.webkit.org/show_bug.cgi?id=292397) +
[304961](https://bugs.webkit.org/show_bug.cgi?id=304961)
Adds `releaseAssertOrSetThreadUID()` to mutation operations. Unlike a
global `isMainThread()` check, this records the owning thread
per-instance, so worker-owned `EventTarget`s work correctly. GC thread
sweeps are exempted.

## Tests

- `test/js/web/abort/abort-controller-gc-reason.test.ts`
- `test/js/web/workers/message-port-closed-leak.test.ts`
- `test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts`

All 270 tests in `test/js/web/{abort,workers,broadcastchannel}/` +
`test/js/deno/event/` pass.

## Not in this PR

`JSCallbackDataStrong` removal (WebKit
[276563](https://bugs.webkit.org/show_bug.cgi?id=276563)) requires
restructuring `AbortSignal::m_algorithms` to allow GC visitation.
Deferred to a follow-up.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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
…sh#28494)

## What

Cherry-picks 4 memory safety fixes from upstream WebKit into
`src/bun.js/bindings/webcore/`.

## Why

Bun's WebCore code was forked from WebKit ~2022-03 and has since missed
several GC/memory safety fixes. These are minimal, surgical backports of
confirmed bug fixes.

## Changes

### 1. `MessagePortChannel`: drop messages to closed ports
**Upstream:** WebKit
[281662](https://bugs.webkit.org/show_bug.cgi?id=281662)
Messages sent to a closed `MessagePort` were queued indefinitely in
`m_pendingMessages`, never to be delivered.
**Measured:** 5000 × 64KB `postMessage` to closed port: RSS growth 332MB
→ 1.5MB

### 2. `JSAbortController`: visit `signal.reason` in GC
**Upstream:** WebKit
[293319](https://bugs.webkit.org/show_bug.cgi?id=293319)
`controller.signal.reason` was not marked during GC when only the
controller was retained, causing `reason` to become `undefined` after
collection.

### 3. `BroadcastChannel`: use `ThreadSafeWeakPtr` in global map
**Upstream:** WebKit
[267883](https://bugs.webkit.org/show_bug.cgi?id=267883)
The global channel map held raw `BroadcastChannel*` pointers. If a
worker-owned channel was being destroyed while the main thread looked it
up, the resulting `RefPtr` assignment could race.
`ThreadSafeWeakPtr::get()` atomically checks liveness via the control
block.

### 4. `EventListenerMap`: per-instance thread affinity assert
**Upstream:** WebKit
[292397](https://bugs.webkit.org/show_bug.cgi?id=292397) +
[304961](https://bugs.webkit.org/show_bug.cgi?id=304961)
Adds `releaseAssertOrSetThreadUID()` to mutation operations. Unlike a
global `isMainThread()` check, this records the owning thread
per-instance, so worker-owned `EventTarget`s work correctly. GC thread
sweeps are exempted.

## Tests

- `test/js/web/abort/abort-controller-gc-reason.test.ts`
- `test/js/web/workers/message-port-closed-leak.test.ts`
- `test/js/web/broadcastchannel/broadcast-channel-worker-gc.test.ts`

All 270 tests in `test/js/web/{abort,workers,broadcastchannel}/` +
`test/js/deno/event/` pass.

## Not in this PR

`JSCallbackDataStrong` removal (WebKit
[276563](https://bugs.webkit.org/show_bug.cgi?id=276563)) requires
restructuring `AbortSignal::m_algorithms` to allow GC visitation.
Deferred to a follow-up.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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