Skip to content

webcore: break Performance ↔ PerformanceObserver ref cycle on global teardown#29921

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/dbc89287/performance-observer-cycle
Apr 29, 2026
Merged

webcore: break Performance ↔ PerformanceObserver ref cycle on global teardown#29921
Jarred-Sumner merged 1 commit into
mainfrom
farm/dbc89287/performance-observer-cycle

Conversation

@robobun

@robobun robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

Performance holds RefPtr<PerformanceObserver> in m_observers (its registered-observer list), and each PerformanceObserver holds RefPtr<Performance> in m_performance. When a Worker terminates with an observer still registered — i.e. the user never called .disconnect() — neither object is released, leaking the Performance instance along with everything it owns (user-timing buffer, etc).

Performance::removeAllObservers() exists specifically to break this cycle, but nothing in Bun ever called it.

Repro

// worker.js
new PerformanceObserver(() => {}).observe({ entryTypes: ["mark"] });
postMessage("ready");
// never calls .disconnect()

// main.js
for (let i = 0; i < 5; i++) {
  const w = new Worker("./worker.js");
  await new Promise(r => w.onmessage = r);
  await w.terminate();
}

On a release build this segfaults on the second iteration (the leaked objects outlive their VM and are accessed during the next teardown). On debug, each worker's Performance instance is simply never freed.

Fix

Call m_performance->removeAllObservers() in ~GlobalObject() before the ScriptExecutionContext is deref'd. This mirrors WebKit, where WorkerGlobalScope / LocalDOMWindow call removeAllObservers() during removeAllEventListeners(); Bun has no equivalent hook, so the global destructor is the last point where the context is still fully alive.

Calling it from Performance::contextDestroyed() instead is too late: dropping the last observer ref there cascades into ~JSPerformanceObserverCallback()~ContextDestructionObserver(), which tries to unregister from the ScriptExecutionContext while that context is already inside its own destructor iterating observers, tripping ASSERT(!m_inScriptExecutionContextDestructor).

Verification

New test spawns 28 workers that each register an observer, store a 4 MB mark in performance's user-timing buffer, and get terminated without .disconnect(). RSS of the second batch of 20 is compared against a warm baseline.

RSS delta (20 workers) ~Performance() runs
before ~110 MB 0 / N
after ~28 MB N / N
  • USE_SYSTEM_BUN=1 bun test → segfault (fail)
  • bun bd test without src/ change → RSS grew by 109.3 MB (fail)
  • bun bd test with fix → pass (×3)

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minute and 2 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 980c139a-fb33-454a-abd0-d089368f5c3e

📥 Commits

Reviewing files that changed from the base of the PR and between 9079d5b and adc61ca.

📒 Files selected for processing (2)
  • src/bun.js/bindings/ZigGlobalObject.cpp
  • test/js/web/workers/performance-observer-leak.test.ts

Review rate limit: 0/5 reviews remaining, refill in 1 minute and 2 seconds.

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

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:04 AM PT - Apr 29th, 2026

@robobun, your commit adc61ca has 7 failures in Build #48919 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29921

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

bun-29921 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun leaks memory in Workers #5709 - Breaks the Performance ↔ PerformanceObserver ref cycle that leaks memory on Worker teardown, directly addressing one component of the reported Worker memory leak

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #5709

🤖 Generated with Claude Code

Comment thread test/js/web/workers/performance-observer-leak.test.ts
…l teardown

Performance holds RefPtr<PerformanceObserver> in m_observers and each
PerformanceObserver holds RefPtr<Performance> back. When a Worker
terminates (or any global is destroyed) with an observer still registered
-- i.e. the user never called .disconnect() -- neither object is released.
Performance::removeAllObservers() exists to break this but was dead code.

In WebKit the owning scope (WorkerGlobalScope / LocalDOMWindow) calls
removeAllObservers() during removeAllEventListeners(). Bun has no such
hook, so call it from ~GlobalObject() just before the ScriptExecutionContext
is deref'd. Calling it from Performance::contextDestroyed() is too late:
dropping the last observer ref there destroys the observer's callback,
whose ~ContextDestructionObserver() then tries to unregister from the
context that is already mid-destruction, tripping an assertion.
@robobun robobun force-pushed the farm/dbc89287/performance-observer-cycle branch from a43b363 to adc61ca Compare April 29, 2026 02:36
@Jarred-Sumner Jarred-Sumner merged commit 85e866c into main Apr 29, 2026
67 of 69 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/dbc89287/performance-observer-cycle branch April 29, 2026 04:44
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…teardown (oven-sh#29921)

## Problem

`Performance` holds `RefPtr<PerformanceObserver>` in `m_observers` (its
registered-observer list), and each `PerformanceObserver` holds
`RefPtr<Performance>` in `m_performance`. When a Worker terminates with
an observer still registered — i.e. the user never called
`.disconnect()` — neither object is released, leaking the `Performance`
instance along with everything it owns (user-timing buffer, etc).

`Performance::removeAllObservers()` exists specifically to break this
cycle, but nothing in Bun ever called it.

## Repro

```js
// worker.js
new PerformanceObserver(() => {}).observe({ entryTypes: ["mark"] });
postMessage("ready");
// never calls .disconnect()

// main.js
for (let i = 0; i < 5; i++) {
  const w = new Worker("./worker.js");
  await new Promise(r => w.onmessage = r);
  await w.terminate();
}
```

On a release build this segfaults on the second iteration (the leaked
objects outlive their VM and are accessed during the next teardown). On
debug, each worker's `Performance` instance is simply never freed.

## Fix

Call `m_performance->removeAllObservers()` in `~GlobalObject()` before
the `ScriptExecutionContext` is deref'd. This mirrors WebKit, where
`WorkerGlobalScope` / `LocalDOMWindow` call `removeAllObservers()`
during `removeAllEventListeners()`; Bun has no equivalent hook, so the
global destructor is the last point where the context is still fully
alive.

Calling it from `Performance::contextDestroyed()` instead is too late:
dropping the last observer ref there cascades into
`~JSPerformanceObserverCallback()` → `~ContextDestructionObserver()`,
which tries to unregister from the `ScriptExecutionContext` while that
context is already inside its own destructor iterating observers,
tripping `ASSERT(!m_inScriptExecutionContextDestructor)`.

## Verification

New test spawns 28 workers that each register an observer, store a 4 MB
mark in `performance`'s user-timing buffer, and get terminated without
`.disconnect()`. RSS of the second batch of 20 is compared against a
warm baseline.

| | RSS delta (20 workers) | `~Performance()` runs |
|---|---|---|
| before | ~110 MB | 0 / N |
| after | ~28 MB | N / N |

- `USE_SYSTEM_BUN=1 bun test` → segfault (fail)
- `bun bd test` without src/ change → `RSS grew by 109.3 MB` (fail)
- `bun bd test` with fix → pass (×3)

Co-authored-by: robobun <robobun@users.noreply.github.com>
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