Skip to content

webcore: sweep worker-owned blob URLs on Worker shutdown - #35827

Open
robobun wants to merge 4 commits into
mainfrom
farm/3590bb11/worker-blob-url-sweep
Open

webcore: sweep worker-owned blob URLs on Worker shutdown#35827
robobun wants to merge 4 commits into
mainfrom
farm/3590bb11/worker-blob-url-sweep

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

const src = URL.createObjectURL(new Blob([`
  const urls = [];
  for (let i = 0; i < 4; i++) {
    const u8 = new Uint8Array(1 << 20); u8.fill(65);
    urls.push(URL.createObjectURL(new Blob([u8])));
  }
  postMessage(urls);`], { type: "application/javascript" }));
const rss0 = process.memoryUsage.rss(); let first;
for (let i = 0; i < 200; i++) {
  const w = new Worker(src);
  const urls = await new Promise(r => (w.onmessage = e => r(e.data)));
  await new Promise(r => w.addEventListener("close", r));
  first ??= urls[0];
}
Bun.gc(true);
console.log("rss growth MB:", (process.memoryUsage.rss() - rss0) / 2**20);          // ~808
console.log((await (await fetch(first)).arrayBuffer()).byteLength);                  // 1048576

200 workers each mint 4x1 MB blob URLs and exit without revoking; post-GC RSS grows ~806 MB (~1 MB retained per MB minted per dead worker). The first dead worker's URL still serves its payload.

Cause

ObjectURLRegistry (src/runtime/webcore/ObjectURLRegistry.rs) is a process-global Guarded<HashMap<[u8;16], Box<Entry>>>. register() receives the creating VirtualMachine but records no owner on the Entry; the only removal path is revoke() from URL.revokeObjectURL. WebWorker::shutdown, RareData::deinit, and global_exit never touch the map, so a worker's entries (each a strong dupe_with_content_type of the blob store) outlive the worker indefinitely.

Fix

  • Entry gains owner: i32 = the registering VM's initial_script_execution_context_identifier.
  • ObjectURLRegistry::sweep_owner(id) removes every entry with that owner.
  • A new RuntimeHooks::sweep_object_urls_for_owner slot lets bun_jsc reach the registry in bun_runtime.
  • WebWorker::shutdown calls the sweep in step 2, after vm.on_exit() and alongside rare.release_js_handles(), before WebWorker__teardownJSCVM.

The process-global map stays, so cross-thread fetch/revokeObjectURL of a worker's URL while that worker is alive keeps working (a Bun extension over Node's per-Environment registry). Main-context entries are unaffected.

Semantic change

After a worker exits or is terminated, fetch() of a blob URL minted inside that worker fails like a revoked URL instead of serving the payload. No in-tree test relied on cross-worker blob-URL survival; every worker test mints the URL on the parent. Node drops worker blob URLs at environment teardown as well.

Verification

test/js/web/workers/worker_blob.test.ts spawns a subprocess that cycles 30 workers (each minting 4x1 MB), then asserts post-GC RSS growth < 40 MB (unfixed: ~120 MB), the dead worker's URL no longer resolves, and the parent-minted worker-source URL still does.

RSS growth (30x4 MB) dead-worker URL
before ~121 MB serves 1048576
after ~11 MB throws TypeError

no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker_blob.test.ts

ObjectURLRegistry is a process-global map keyed by UUID. register()
receives the creating VM but records no owner on the entry, and the only
removal path is URL.revokeObjectURL. A Worker that calls
URL.createObjectURL and exits without revoking therefore pins the duped
blob store for the lifetime of the process: roughly 1 MB retained per MB
minted per dead worker, linearly.

Record the registering context's initial_script_execution_context_identifier
on each Entry and add ObjectURLRegistry::sweep_owner, reached from
WebWorker::shutdown via a new RuntimeHooks slot (the registry lives in
bun_runtime, the shutdown path in bun_jsc). The sweep runs in step 2 of
shutdown, after vm.on_exit() and alongside rare.release_js_handles(), so
user exit handlers can still revoke and the JSC VM is still live when the
entries drop.

The process-global map is unchanged, so cross-thread fetch/revoke of a
worker's blob URL while the worker is alive keeps working. After the
worker exits, fetching one of its blob URLs fails the same way as a
revoked URL. Node's per-Environment registry drops worker entries at env
teardown as well; Bun continues to allow cross-thread resolution while
the owning context is alive.
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced locally: 40 workers x 4 MB grows RSS by ~161 MB on release bun and the dead worker's URL still serves its payload. With the sweep, growth is ~11 MB and the URL throws. The existing five worker_blob.test.ts cases and buffer-resolveObjectURL/url.test.ts pass unchanged.

CI status: the new worker_blob.test.ts case passed on every lane in both build 81732 and build 81812. Remaining reds are pre-existing flakes unrelated to this diff (webview-chrome animation timing, fastutf8stream-reopen, message-port-closed-leak Windows RSS, security-scanner-matrix TTY, no-orphans perl, 20144 SIGKILL/SIGINT, double-connect), none of which touch ObjectURLRegistry, WebWorker::shutdown, or blob URLs.

The binary-size check in build 81812 is comparing against canary main #79916 (a stale baseline); this diff adds one i32 field, one method, one RuntimeHooks slot, and one call site (~30 lines of Rust), so the reported +500 KB is drift accumulated on main between that baseline and this PR's base, not from this change. Ready for review.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Worker object URL cleanup

Layer / File(s) Summary
Track object URL ownership
src/runtime/webcore/ObjectURLRegistry.rs
Registry entries record the VM context owner, and sweep_owner removes entries associated with that owner.
Wire the runtime hook
src/jsc/VirtualMachine.rs, src/runtime/jsc_hooks.rs
The runtime hook contract and implementation delegate owner-based cleanup to ObjectURLRegistry.
Clean up during worker shutdown
src/jsc/web_worker.rs, test/js/web/workers/worker_blob.test.ts
Worker shutdown invokes the sweep hook, and the test verifies worker-created URLs are released while the parent URL remains valid.

Possibly related PRs

  • oven-sh/bun#31833: Extends the runtime hook teardown path for JSC handle cleanup.
  • oven-sh/bun#34154: Changes the worker shutdown sequence with an additional teardown operation.
  • oven-sh/bun#34455: Extends runtime hooks and worker shutdown for DNS channel cleanup.

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 matches the main change: sweeping worker-owned blob URLs on Worker shutdown.
Description check ✅ Passed The description is detailed and covers purpose, fix, and verification, though it uses custom headings instead of the template.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:07 PM PT - Jul 25th, 2026

@robobun, your commit 0376626 has 1 failures in Build #81812 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.47 MB71.95 MB+528.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+571.0 KB
    bun-windows-aarch6470.86 MB70.34 MB+533.5 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35827

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

bun-35827 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. macOS Apple Silicon: memory invisible to RSS — bmalloc slabs, worker cleanup gaps, GC safety bugs #28318 - This issue documents incomplete worker thread cleanup (VirtualMachine.deinit() is a stub, worker-owned resources never freed on exit); the PR's sweep_owner() call during WebWorker::shutdown directly addresses the worker-resource-cleanup gap for blob URL registry entries.

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

Fixes #28318

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. webcore: revoke a Worker's blob URLs when the worker exits #35828 - Fixes the same blob URL memory leak on worker shutdown using the same approach: per-owner tracking in ObjectURLRegistry and sweeping entries during WebWorker::shutdown via RuntimeHooks

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

#35828 landed five minutes after this one with the same fix shape (owner id on Entry + per-context sweep via RuntimeHooks from WebWorker::shutdown). Differences:

Either fix closes the leak; happy to fold the in-process resolveObjectURL checks from #35828 in here if preferred.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

I opened #35828 independently with the same core fix (context-id tag on Entry + sweep via RuntimeHooks from WebWorker::shutdown); closing that one in favor of this PR.

Branch for reference: farm/9aafbbd2/revoke-worker-blob-urls. The only notable difference is the test: mine asserts resolveObjectURL(url) === undefined and fetch(url) rejecting after the worker's exit event, which avoids RSS thresholds and runs on Windows too. Feel free to lift it if useful.

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

Beyond the inline test nits: I checked that ScriptExecutionContextIdentifier is a process-global monotonic counter (++lastUniqueIdentifier in ScriptExecutionContext.cpp) and never recycled, so sweep_owner cannot hit a live context's entries; and that Entry::drop under the map lock only runs blob.deinit() (refcount decrement on a Send + Sync store — no JS, no registry re-entry). The hook plumbing mirrors close_dns_for_terminate just above it. Deferring to a human because this sits in worker-shutdown ordering and introduces a user-visible semantic change (dead-worker blob URLs stop resolving).

Extended reasoning...

Verified the owner key is safe: context identifiers come from a process-global atomic increment and are never reused, so sweeping a dead worker's id cannot collide with the main context or another worker. Also confirmed the sweep runs after vm.on_exit() (user exit handlers see their URLs intact) and before JSC teardown, and that dropping Box<Entry> while holding the Guarded lock only decrements a blob-store refcount — no path back into JS or the registry. The change itself is small and patterned on the adjacent close_dns_for_terminate hook, but worker teardown is a high-scrutiny path and the PR deliberately changes observable behavior, so a human should sign off.

Comment thread test/js/web/workers/worker_blob.test.ts Outdated
Comment thread test/js/web/workers/worker_blob.test.ts Outdated
… reject

The deadUrlServed / parentUrlStillResolves checks observe registry state
via fetch() and are platform-independent; only the RSS bound is skipped
on Windows. Also give runWorker() a rejection path so a worker that
errors before postMessage surfaces the error instead of hanging the
subprocess until the outer timeout.
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/runtime/jsc_hooks.rs
Comment thread src/runtime/webcore/ObjectURLRegistry.rs Outdated
Comment thread src/runtime/webcore/ObjectURLRegistry.rs
Comment thread src/runtime/webcore/ObjectURLRegistry.rs
Comment thread src/jsc/web_worker.rs
Comment thread src/runtime/webcore/ObjectURLRegistry.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.

Both of my earlier notes (Windows gating only the RSS assertion, wiring w.onerror to reject) are addressed in b33aaab, and the doc-comment trims in c65c1d5 look fine. I didn't find anything further, but this touches WebWorker::shutdown teardown ordering and introduces a user-visible semantic change (dead-worker blob URLs now fail to resolve), so it's worth a human sign-off.

What was reviewed:

  • sweep_owner holds the Guarded lock across collect+remove; Box<Entry> drop runs blob.deinit(), which is already thread-agnostic (same path as revoke()).
  • Sweep placement is after on_exit/close_all_socket_groups/release_js_handles (nothing left to re-register) and before teardownJSCVM, so worker VM state is still live.
  • Owner key is initial_script_execution_context_identifier — set to worker.execution_context_id() in init_worker, distinct from the main context; test's parentUrlStillResolves confirms no over-deletion.
Extended reasoning...

Overview

The PR tags each ObjectURLRegistry entry with the registering VM's initial_script_execution_context_identifier, adds sweep_owner(id) to drop all entries with that owner, and calls it from WebWorker::shutdown step 2 via a new RuntimeHooks slot. Five files touched: the registry (Entry gains a field, register records it, new sweep_owner), RuntimeHooks struct, the hook impl in jsc_hooks.rs, the call site in web_worker.rs, and a subprocess-based leak+semantics test.

Security risks

None identified. No untrusted input parsing; the owner id is a VM-internal integer. The sweep runs under the existing Guarded mutex, so it composes with concurrent register/revoke/resolve_and_dupe from other threads.

Level of scrutiny

High. WebWorker::shutdown is a memory-safety-critical teardown sequence with documented step-by-step ordering constraints (VM unpublish, exit handlers, JSC teardown, dispatchExit, arena free). Inserting a new call requires confidence it doesn't touch state freed earlier or run JS after termination is armed. The placement here — after release_js_handles(), before exit_code snapshot — is correct: JSC is still live, no JS runs (sweep_owner is pure Rust map manipulation + Blob::deinit), and it follows the exact wiring pattern of close_dns_for_terminate directly above. But this is the kind of ordering a maintainer should confirm.

Other factors

  • Semantic change: blob URLs minted inside a worker now fail like revoked URLs after that worker exits/terminates. The PR argues this matches Node's per-Environment teardown and no in-tree test relied on cross-worker survival, but it's still a behavioral change a human should ratify.
  • The hook indirection (bun_jscbun_runtime) mirrors every other RuntimeHooks slot; nothing novel there.
  • sweep_owner uses collect-keys-then-remove rather than retain; correct if slightly less direct, and the map is small.
  • Test now runs on all platforms with only the RSS check gated on !isWindows; runWorker wires onerror to reject. Both of my prior comments are resolved.
  • A duplicate PR (#35828) with the same shape was closed in favor of this one.

@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: 2

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

Inline comments:
In `@src/runtime/webcore/ObjectURLRegistry.rs`:
- Around line 105-120: Update ObjectURLRegistry::sweep_owner to use the backing
map’s retain method, removing entries whose owner matches the supplied owner.
First verify that the map type supports retain with the expected key/value
predicate signature; preserve the existing lock and leave entries belonging to
other owners untouched.

In `@test/js/web/workers/worker_blob.test.ts`:
- Around line 134-216: Update the test declaration for “blob URLs created inside
a Worker are released when the Worker exits” to use test.concurrent, preserving
its existing subprocess logic, assertions, and timeout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 380aa898-79e9-4878-b34a-2f2d0ac32bbc

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and 0376626.

📒 Files selected for processing (5)
  • src/jsc/VirtualMachine.rs
  • src/jsc/web_worker.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/webcore/ObjectURLRegistry.rs
  • test/js/web/workers/worker_blob.test.ts

Comment thread src/runtime/webcore/ObjectURLRegistry.rs
Comment thread test/js/web/workers/worker_blob.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.

No new issues found — both prior nits (Windows gating, onerror wiring) are addressed in b33aaab, and c65c1d5 trimmed the flagged doc comments. Deferring to a maintainer for the user-visible semantic change (blob URLs minted inside a worker now fail to resolve after the worker exits) and the placement in WebWorker::shutdown's teardown ordering.

What was reviewed:

  • sweep_owner holds the registry lock across collect+remove; Entry::dropBlob::deinitdetach() is a thread-safe refcount decrement with no re-entry into the registry.
  • Owner id sourced from initial_script_execution_context_identifier (i32), set per-worker in init_worker from execution_context_id(); main-context entries carry a distinct id and are never swept.
  • Sweep placement after release_js_handles() — every earlier step that can run JS (on_exit, socket on_close, DNS destruction) has already run, so nothing re-registers post-sweep.
Extended reasoning...

Overview

The PR tags each ObjectURLRegistry entry with the registering VM's initial_script_execution_context_identifier, adds sweep_owner(id) to remove all entries for a given context, and wires it into WebWorker::shutdown via a new RuntimeHooks::sweep_object_urls_for_owner slot. Five files touched: the registry (~25 lines), the hook declaration/impl (~10 lines), the shutdown call site (~6 lines), and a subprocess-spawning leak+semantics test.

Security risks

None identified. The registry is process-global and mutex-guarded; the new sweep only removes entries and does so under the existing lock. No new user-controlled input reaches native code.

Level of scrutiny

Medium-high. WebWorker::shutdown is a teardown-ordering-critical path with a documented step barrier (each numbered step is a barrier for the next), and the file header enumerates several UAF hazards around it. The new call is placed inside step 2, after every hook that can re-enter JS and before teardownJSCVM, which is the correct window — but shutdown ordering in this file has historically been delicate (see the surrounding comments on markTerminating, close_dns_for_terminate, release_queued_tasks_for_shutdown). The semantic change — a dead worker's blob URL now fails to resolve rather than serving its payload — is user-observable and worth a maintainer's explicit ack, even though it aligns with Node's per-Environment registry behavior.

Other factors

  • The RuntimeHooks pattern is copied exactly from the adjacent close_dns_for_terminate / cancel_all_timers slots; no novel abstraction.
  • Entry remains auto-Send (i32 + Blob); the compile-time assert_send still covers it.
  • The test now runs on all platforms with only the RSS threshold gated on !isWindows, and runWorker() wires onerror to reject — both prior review points are resolved.
  • A duplicate PR (#35828) with the same fix shape was closed in favor of this one; the author of that PR noted the sweep placement here (after release_js_handles()) is preferable.
  • No bugs were surfaced by the bug-hunting pass on the current revision.

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