Skip to content

webview: close the tab created by a Target.createTarget that was in flight at close() - #39075

Open
robobun wants to merge 1 commit into
mainfrom
farm/b2e48722/webview-close-mid-createtarget-tab-leak
Open

robobun wants to merge 1 commit into
mainfrom
farm/b2e48722/webview-close-mid-createtarget-tab-leak

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.WebView (chrome backend): calling view.close() right after the first view.navigate() rejects the navigate promise with WebView closed as intended, but the tab Chrome creates for that navigate is never closed. It stays in Target.getTargets as { type: "page", url: "about:blank", attached: false } for as long as the browser lives (the spawned Chrome is shared by every view in the process; in connect mode it is the user's own Chrome). Each such close leaks one tab and its renderer process.
  • Cause, src/runtime/webview/ChromeBackend.cpp: Ops::close removes every m_pending entry of the view, including the Target.createTarget that Chrome has already received. view->m_targetId is only filled in from that command's reply, so close() has nothing to send Target.closeTarget for, and when the reply arrives Transport::handleResponse finds no entry and drops it together with the targetId it carries. Every later step of the attach chain already has m_targetId when close() runs, so this first step was the only gap.
  • Same thing over the WebSocket transport once the handshake is open. Before the handshake, the command is still queued inside the transport and close() already cancels it correctly.

Fix

  • Ops::close keeps the in-flight TargetCreateTarget entry and retags it Method::TargetCreateTargetOrphaned instead of erasing it. handleResponse handles that tag before any view lookup: it sends Target.closeTarget for the returned targetId (fire-and-forget, same as close() does when the id is already known), calls updateKeepAlive(), and returns. An error reply has no result and therefore no targetId, so nothing is sent.
  • A createTarget still parked behind the WebSocket handshake (Transport::isQueuedUnsent, new) is erased exactly as before, so it is never sent and no tab is created in the first place.
  • Why this is the right shape:
    • Once Chrome has read the command the tab gets created no matter what we do; the reply is the only place its id ever appears, so the only way to not leak it is to act on that reply.
    • The retagged handler touches no view and no promise slot, and none of the later chain steps (attachToTarget, Page.enable, Page.navigate) are issued, so the property the prune was added for (nothing runs on a closed view) still holds.
    • The entry stays in m_pending until the reply, which keeps the event loop (and, in connect mode, the WebSocket to the user's Chrome) alive just long enough for closeTarget to go out; updateKeepAlive() in the handler drops it afterwards. This is the role updateKeepAlive's m_pending clause already documents. If Chrome dies instead, rejectAllAndMarkDead clears the entry as it does every other one.
    • Closing an orphan reply is strictly worse than never sending the command, hence the separate pre-handshake branch.
    • The WebKit backend has no equivalent gap: its Close is keyed by our own view id, which exists before anything is sent.
  • Related: webview: navigate/reload/goBack/goForward accept { waitUntil, timeout } #30645 adds the same close-on-late-reply for its navigate timeout path (where the view is still alive) and leaves close() as is, so it does not cover this.
  • Verified:
    • test/js/bun/webview/webview-chrome.test.ts, new tests: close() while createTarget is in flight (pipe mode) now observes Target.targetDestroyed for the created tab and no new page targets remain; close() after the chain completed still closes the tab; a process that does navigate+close exits on its own once the reply has been handled; connect mode (Chrome spawned with --remote-debugging-port=0, fixture webview-chrome-ws-close-fixture.ts in a child process, tab creation counted on an independent connection) cancels the pre-handshake createTarget outright and closes the post-handshake one. The existing attach-chain test is renamed to what it actually checks; its assertions are unchanged.
    • USE_SYSTEM_BUN=1: the pipe-mode test times out waiting for targetDestroyed and the connect-mode test fails with late: tab still open; both pass with bun bd test. The whole test/js/bun/webview/ directory passes with the debug build, also under BUN_JSC_validateExceptionChecks=1.
    • Removing the isQueuedUnsent condition makes the connect-mode test fail (three pages created instead of two); removing the updateKeepAlive() call makes the exit test hang.

Background

  • CDP (Chrome DevTools Protocol): JSON commands with an id, answered by a reply with the same id; events carry a method and, for page-scoped events, a sessionId. The backend talks to one Chrome per process, over a pipe it spawned or over a WebSocket to an existing Chrome.
  • Attach chain: the first navigate() on a view sends Target.createTarget (makes the tab; the reply returns its targetId), then Target.attachToTarget (returns the sessionId later commands are scoped to), Page.enable, Page.navigate. Each reply triggers the next command; Transport::m_pending maps each outstanding command id to the method tag and view it belongs to, and handleResponse dispatches on that tag.
  • Target.closeTarget is the browser-level command that closes a tab given its targetId; close() sends it without tracking the reply.
  • Keep-alive: Transport::updateKeepAlive holds a ref on the event loop while any view exists or any command is outstanding, and in connect mode closes the WebSocket when neither is true.
  • Tests watch tabs through a second view's session with Target.setDiscoverTargets, because browser-level events (no sessionId) are not routed to any view; Chrome then emits Target.targetCreated / Target.targetDestroyed on that session and they reach the view's EventTarget.
Repro against the released build
const chrome = { type: "chrome", url: false };
const probe = new Bun.WebView({ backend: chrome, width: 100, height: 100 });
await probe.navigate("data:text/html,<body>probe</body>");
const pages = async () => (await probe.cdp("Target.getTargets")).targetInfos.filter(t => t.type === "page");
const before = await pages();
const v = new Bun.WebView({ backend: chrome, width: 100, height: 100 });
const p = v.navigate("data:text/html,<body>x</body>");
v.close();
await p.catch(e => console.log(e.message)); // WebView closed
for (let i = 0; i < 10; i++) await probe.cdp("Target.getTargets"); // let the createTarget reply land
const after = await pages();
console.log(after.filter(t => !before.some(b => b.targetId === t.targetId)));

Release bun: [ { type: "page", url: "about:blank", attached: false, ... } ], still present later. With this change: [].

…ates

Ops::close pruned every m_pending entry of the view, including a
Target.createTarget that Chrome had already received. Its reply is the
only message carrying the new tab's targetId, so handleResponse dropped
it and the about:blank tab stayed open, unattached, for the life of the
browser. Retag that entry instead of erasing it; when the reply arrives
the transport sends Target.closeTarget for the returned targetId and
releases the keep-alive ref. A createTarget still queued behind the
WebSocket handshake is erased as before, so it is never sent.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the released build (google-chrome-stable 151, Linux) with the script in the PR description: after navigate() + immediate close(), an unattached about:blank page target stays in Target.getTargets indefinitely. With this branch it is closed as soon as the Target.createTarget reply arrives. New tests in test/js/bun/webview/webview-chrome.test.ts fail on the released build and pass with this branch; waiting on CI.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 51b6a68b-a592-494a-8204-0a8073d2117e

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and 741722f.

📒 Files selected for processing (4)
  • src/runtime/webview/ChromeBackend.cpp
  • src/runtime/webview/ChromeBackend.h
  • test/js/bun/webview/webview-chrome-ws-close-fixture.ts
  • test/js/bun/webview/webview-chrome.test.ts

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

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

Automated review ran and found no bugs. The C++ change is small and the reasoning in the PR description checks out end to end, but because it touches event-loop keep-alive refcounting and the WebSocket-transport close path, a human look from someone familiar with the WebView backend would still be worthwhile.

What was reviewed:

  • The retag-in-removeIf + early TargetCreateTargetOrphaned branch in handleResponse: runs before viewFor, touches no view/slot, and updateKeepAlive() ordering matches the existing Ops::close fire-and-forget path.
  • isQueuedUnsent gating: m_wsPending is empty in pipe mode so retag always applies there; in WS pre-handshake the entry is dropped and the wsOnOpen/wsOnClose drains already skip it via m_pending.contains(id).
  • WTF::Vector::containsIf and mutating a value inside HashMap::removeIf while returning false — both have precedent in-tree.
  • Tests await real CDP events (no sleep-for-condition), cover both transport modes plus the keep-alive release, and the fixture's 2 s bound is a fail-open on the leak case rather than a timing assumption.
Extended reasoning...

Overview

Fixes a Chrome-tab leak in Bun.WebView: when close() runs while the first navigate's Target.createTarget is in flight, the reply carrying the new tab's targetId was dropped and the tab was never closed. The fix retags the pending entry as TargetCreateTargetOrphaned so handleResponse can send Target.closeTarget from the late reply, and adds isQueuedUnsent so a command still parked behind the WS handshake is cancelled outright instead. ~35 net C++ lines across ChromeBackend.{h,cpp} plus ~200 lines of tests and a WS-mode fixture.

Security risks

None. No user-controlled input reaches new parsing; the only new parse is jsonField(result, "targetId") on Chrome's own reply, using the existing scanner. No auth, crypto, or filesystem paths are touched.

Level of scrutiny

Medium-high. The core logic is small and well-argued, but it sits in the intersection of C++ lifecycle management, event-loop keep-alive refs (updateKeepAlive deciding when to drop the loop ref and, in connect mode, close the user's Chrome session), and WebSocket-transport ordering. Per the repo's review guidance, ref-count / lifecycle exits are the most-blocked category, so a maintainer sanity check on the keep-alive interaction is warranted even though I found no defect.

Other factors

  • Test coverage is unusually thorough: pipe-mode in-flight close, post-chain close (guards the untouched branch), a subprocess exit test that pins down the updateKeepAlive() call, and a WS-mode test that spawns its own Chrome and verifies both the pre-handshake cancel and the post-handshake close on an independent CDP connection. The PR description documents that each load-bearing clause breaks a specific test when removed and that USE_SYSTEM_BUN=1 fails the new tests.
  • I checked that mutating pair.value.method inside HashMap::removeIf and returning false is sound (value mutation, entry kept), that containsIf is an existing WTF::Vector method used elsewhere in src/jsc/bindings/webcore/, and that the orphaned handler's send(0, closeTarget) → updateKeepAlive() sequence is the same ordering Ops::close already uses when m_targetId is known, so the WS-close-after-send behaviour is unchanged.
  • The one nit I noticed (the DevToolsActivePort read loop can briefly busy-spin if the file exists but is partial) is a sub-millisecond window and not worth blocking on.
  • No prior human review comments; CI status is pending per the robobun comment.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. webview: navigate/reload/goBack/goForward accept { waitUntil, timeout } #30645 - Adds a near-identical "on a stale Target.createTarget reply, extract targetId and fire Target.closeTarget" block in the same spot in Transport::handleResponse, targeting the same leaked-tab-from-abandoned-createTarget problem (different trigger: navGen mismatch vs. close()), so the two will textually conflict.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 PM PT - Aug 15th, 2026

❌ @robobun, your commit 741722f has 1 failures in Build #98158 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 39075

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

bun-39075 --bun

This branch has not been deployed

No deployments
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.

1 participant