You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The main-process BrowserViewMainService throws Browser view <id> not found when a renderer subscribes to a per-view "dynamic" event (onDynamicDidChangePermissions in the reported stack) over IPC after that view has already been destroyed. Event subscription is resolved asynchronously across the process boundary, so the view can disappear in the window between the renderer subscribing and the main process handling the listen request. The throw crosses the IPC boundary as an unhandled error and is reported to telemetry (~177 users/bucket across 5 sibling buckets that differ only by the random view GUID). This is a post-fix recurrence of the same class fixed for #318995 in 1.124.0, now surfacing through the newer permissions event path.
This commit added onDynamicDidChangePermissions(id) (and the renderer subscribes to it in BrowserViewModel's constructor), following the existing onDynamic* pattern that calls _getBrowserView(id) — which throws when the view is gone. The permissions event is the specific accessor in the reported stack; all sibling onDynamic* accessors share the same race.
Code Flow
sequenceDiagram
participant Renderer as BrowserViewModel (renderer)
participant IPC as IPC channel
participant Main as BrowserViewMainService
participant Map as browserViews (DisposableMap)
Renderer->>IPC: listen onDynamicDidChangePermissions(id)
Note over Map: ⚠️ Root cause:<br/>view destroyed before<br/>listen request handled
IPC->>Main: onEventListen -> onDynamicDidChangePermissions(id)
Main->>Map: _getBrowserView(id) -> get(id) === undefined
Note over Main: 💥 throw new Error(<br/>`Browser view ${id} not found`)
Main-->>IPC: error crosses process boundary
L539-L540: this._register(this.browserViewService.onDynamicDidChangePermissions(this.id)(snapshot => ...)) subscribes over IPC
Repro Steps
Non-deterministic timing race across the renderer↔main IPC boundary:
Open an integrated browser view so a BrowserViewModel is created and subscribes to onDynamicDidChangePermissions(id) (and the other onDynamic* events) over IPC.
Destroy/close the browser view (e.g. close the editor) so browserViews.deleteAndDispose(id) removes it from the map in the main process.
If the renderer's listen request for any onDynamic* event is handled by the main process after the view is removed, _getBrowserView(id) throws and the error is reported to telemetry.
Likelihood increases when views are opened and closed rapidly, or during window teardown when many subscriptions and disposals interleave.
How the Fix Works
Chosen approach (browserViewMainService.ts): Added a small producer-side helper _getBrowserViewEvent<T>(id, getEvent) that resolves the requested event only when the view still exists and returns Event.None otherwise. All 20 onDynamic* event accessors now route through it instead of this._getBrowserView(id).<event>. This fixes the problem at the producer of the invalid state (the main-process event accessor), not at an unrelated crash site or by silencing telemetry.
A missing view at event-subscription time is an expected outcome of asynchronous IPC subscription, not a real error: the view is gone, so there are no further events to deliver and an empty event stream is the semantically correct result. This is the external/untrusted-boundary case in the lifecycle-race guidance — the consumer lives in a separate process and cannot synchronously coordinate its subscription with main-process disposal, so the producer must tolerate the race for these event getters. Non-event methods (getState, layout, loadURL, etc.) intentionally keep throwing via _getBrowserView, because calling those against a destroyed view is a genuine caller error rather than a benign subscription race.
Alternatives considered:
Wrapping the IPC onEventListen in try/catch — hides the error from telemetry for all channels and swallows genuine bugs, rather than fixing the specific event getters that are legitimately racy.
Guarding at the renderer consumer (BrowserViewModel) — the renderer cannot know the view was destroyed in the main process at subscription time, so it cannot avoid the race; the fix belongs at the producer.
Making _getBrowserView itself return undefined/Event.None for everything — would weaken the invariant for the many state-mutating methods where a missing view really is a bug.
Lifecycle pattern: external/untrusted boundary (cross-process IPC event subscription). Producer site: src/vs/platform/browserView/electron-main/browserViewMainService.ts → onDynamicDidChangePermissions() and sibling onDynamic* accessors. Consumer-side fix justification: the consumer (BrowserViewModel) runs in the renderer process and subscribes over IPC; it has no synchronous view of main-process disposal, so it cannot prevent the race. The fix is applied at the producer (main-process event accessors), which is the correct side and keeps the throwing contract intact for genuine misuse of state-mutating methods.
Recommended Owner
@kycutler — author of the culprit commit (#322639, "Support browser permissions") and of the surrounding onDynamic* browser-view accessors; actively committing to microsoft/vscode within the last 90 days.
Original error: ERR_API: [2026-09-01T15:37:12.277Z] create pull request in microsoft/vscode failed (attempt 1)
Original error: Validation Failed: {"resource":"PullRequest","code":"custom","field":"fork_collab","message":"fork_collab Fork collab can't be granted by someone without permission"} - https://docs.github.com/rest/pulls/pulls#create-a-pull-request
Retryable: false
Suggestion: This error cannot be resolved by retrying. Please check the error details and fix the underlying issue.
From dd3d3739906122745b16cb4ec56dce06b6b8ac2f Mon Sep 17 00:00:00 2001
X-GH-AW-Base-Commit: 4735247b4a22a921d588e224f347a2186f11ba2f
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Tue, 1 Sep 2026 15:27:41 +0000
Subject: [PATCH] fix: don't throw resolving browser view dynamic events after
view destroyed (fixes #333783)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../electron-main/browserViewMainService.ts | 60 ++++++++++++-------
1 file changed, 40 insertions(+), 20 deletions(-)
diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts
index 7de2bb5cd3e..5b78efbf7ec 100644
--- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts+++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts@@ -124,6 +124,26 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa
return view;
}
+ /**+ * Resolve a dynamic event for a browser view, returning {@link Event.None}+ * when the view no longer exists.+ *+ * Event subscriptions are established asynchronously over IPC: a renderer+ * issues a `listen` request and the main process resolves the event getter+ * when the message arrives. The view can be destroyed in the window between+ * the renderer subscribing and the request being handled, so a missing view+ * here is an expected race rather than a bug. In that case there are no+ * further events to deliver, so an empty event is the correct result — the+ * getter must not throw across the process boundary.+ */+ private _getBrowserViewEvent<T>(id: string, getEvent: (view: BrowserView) => Event<T>): Event<T> {+ const view = this.browserViews.get(id);+ if (!view) {+ return Event.None;+ }+ return getEvent(view);+ }+
private _getViewInfo(view: BrowserView): IBrowserViewInfo {
return {
id: view.id,
@@ -146,83 +166,83 @@
... (truncated)
Summary
The main-process
BrowserViewMainServicethrowsBrowser view <id> not foundwhen a renderer subscribes to a per-view "dynamic" event (onDynamicDidChangePermissionsin the reported stack) over IPC after that view has already been destroyed. Event subscription is resolved asynchronously across the process boundary, so the view can disappear in the window between the renderer subscribing and the main process handling thelistenrequest. The throw crosses the IPC boundary as an unhandled error and is reported to telemetry (~177 users/bucket across 5 sibling buckets that differ only by the random view GUID). This is a post-fix recurrence of the same class fixed for #318995 in 1.124.0, now surfacing through the newer permissions event path.Fixes #333783
Recommended reviewer:
@kycutlerCulprit Commit
67aea9c1@kycutleronDynamicDidChangePermissions(id)(and the renderer subscribes to it inBrowserViewModel's constructor), following the existingonDynamic*pattern that calls_getBrowserView(id)— which throws when the view is gone. The permissions event is the specific accessor in the reported stack; all siblingonDynamic*accessors share the same race.Code Flow
sequenceDiagram participant Renderer as BrowserViewModel (renderer) participant IPC as IPC channel participant Main as BrowserViewMainService participant Map as browserViews (DisposableMap) Renderer->>IPC: listen onDynamicDidChangePermissions(id) Note over Map: ⚠️ Root cause:<br/>view destroyed before<br/>listen request handled IPC->>Main: onEventListen -> onDynamicDidChangePermissions(id) Main->>Map: _getBrowserView(id) -> get(id) === undefined Note over Main: 💥 throw new Error(<br/>`Browser view ${id} not found`) Main-->>IPC: error crosses process boundaryAffected Files
src/vs/platform/browserView/electron-main/browserViewMainService.tsthrow new Error(\Browser view ${id} not found`)`src/vs/platform/browserView/electron-main/browserViewMainService.tsonDynamicDidChangePermissions(id)returnsthis._getBrowserView(id).onDidChangePermissions, throwing when the view is already gonesrc/vs/workbench/contrib/browserView/common/browserView.tsthis._register(this.browserViewService.onDynamicDidChangePermissions(this.id)(snapshot => ...))subscribes over IPCRepro Steps
Non-deterministic timing race across the renderer↔main IPC boundary:
BrowserViewModelis created and subscribes toonDynamicDidChangePermissions(id)(and the otheronDynamic*events) over IPC.browserViews.deleteAndDispose(id)removes it from the map in the main process.listenrequest for anyonDynamic*event is handled by the main process after the view is removed,_getBrowserView(id)throws and the error is reported to telemetry.How the Fix Works
Chosen approach (
browserViewMainService.ts): Added a small producer-side helper_getBrowserViewEvent<T>(id, getEvent)that resolves the requested event only when the view still exists and returnsEvent.Noneotherwise. All 20onDynamic*event accessors now route through it instead ofthis._getBrowserView(id).<event>. This fixes the problem at the producer of the invalid state (the main-process event accessor), not at an unrelated crash site or by silencing telemetry.A missing view at event-subscription time is an expected outcome of asynchronous IPC subscription, not a real error: the view is gone, so there are no further events to deliver and an empty event stream is the semantically correct result. This is the external/untrusted-boundary case in the lifecycle-race guidance — the consumer lives in a separate process and cannot synchronously coordinate its subscription with main-process disposal, so the producer must tolerate the race for these event getters. Non-event methods (
getState,layout,loadURL, etc.) intentionally keep throwing via_getBrowserView, because calling those against a destroyed view is a genuine caller error rather than a benign subscription race.Alternatives considered:
onEventListenin try/catch — hides the error from telemetry for all channels and swallows genuine bugs, rather than fixing the specific event getters that are legitimately racy.BrowserViewModel) — the renderer cannot know the view was destroyed in the main process at subscription time, so it cannot avoid the race; the fix belongs at the producer._getBrowserViewitself returnundefined/Event.Nonefor everything — would weaken the invariant for the many state-mutating methods where a missing view really is a bug.Lifecycle pattern: external/untrusted boundary (cross-process IPC event subscription).
Producer site:
src/vs/platform/browserView/electron-main/browserViewMainService.ts→onDynamicDidChangePermissions()and siblingonDynamic*accessors.Consumer-side fix justification: the consumer (
BrowserViewModel) runs in the renderer process and subscribes over IPC; it has no synchronous view of main-process disposal, so it cannot prevent the race. The fix is applied at the producer (main-process event accessors), which is the correct side and keeps the throwing contract intact for genuine misuse of state-mutating methods.Recommended Owner
@kycutler— author of the culprit commit (#322639, "Support browser permissions") and of the surroundingonDynamic*browser-view accessors; actively committing tomicrosoft/vscodewithin the last 90 days.Note
This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch
fix/browser-view-dynamic-event-race-c58ec365481fc82e.Original error: ERR_API: [2026-09-01T15:37:12.277Z] create pull request in microsoft/vscode failed (attempt 1)
Original error: Validation Failed: {"resource":"PullRequest","code":"custom","field":"fork_collab","message":"fork_collab Fork collab can't be granted by someone without permission"} - https://docs.github.com/rest/pulls/pulls#create-a-pull-request
Retryable: false
Suggestion: This error cannot be resolved by retrying. Please check the error details and fix the underlying issue.
To create the pull request manually:
gh pr create --title "t" --base main --head vscodebot-pr:fix/browser-view-dynamic-event-race-c58ec365481fc82e --repo microsoft/vscodeShow patch preview (151 of 151 lines)