From c3a56f9b062336cb7d3eb3eb69bfe17e21421aae Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Tue, 1 Sep 2026 21:23:19 +0300 Subject: [PATCH 01/12] webview - retry service worker registration and reload webview on transient failures Service worker registration in webviews can transiently fail, e.g. with "InvalidStateError: The document is in an invalid state" when the document is not fully active during registration, which previously surfaced a fatal "Error loading webview" notification that could only be resolved by reloading the whole window. - pre/index.html: retry registration with backoff (1s/1s/2s/3s/5s) and clean up possibly corrupted service worker state (caches / registrations on desktop) between attempts. Fail fast on permanent conditions (user denied permission, InvalidStateError) since retrying cannot recover the current document. - webviewElement: on fatal service worker registration errors, reload the webview into a fresh document (up to 5 times with backoff) before surfacing the error to the user, and offer a Reload Webview action with the final error. --- .../contrib/webview/browser/pre/index.html | 72 ++++++++++++++++++- .../contrib/webview/browser/webviewElement.ts | 61 ++++++++++++++-- 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 42c7c01ade59a..4e0cdcb708554 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-NzhQp9y6/PwqdjTbET5hRTrvFKhwC+4AXQUQzhkJq7s=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { + try { + if (typeof caches !== 'undefined') { + const keys = await caches.keys(); + await Promise.all(keys.map(key => caches.delete(key))); + } + } catch (e) { + console.warn(`Failed to clear webview caches: ${e}`); + } + + // On desktop, each webview is served from its own origin, so it is + // safe to unregister the broken worker. In the browser, service + // workers are shared across webviews, so we must not unregister. + if (onElectron) { + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(registration => registration.unregister())); + } catch (e) { + console.warn(`Failed to unregister webview service workers: ${e}`); + } + } + }; + + /** + * @param {string} swPath + * @return {Promise} + */ + const registerWithRetry = async (swPath) => { + for (let attempt = 0; ; ++attempt) { + try { + return await navigator.serviceWorker.register(swPath, { type: 'module', updateViaCache: 'none' }); + } catch (error) { + if ( + error instanceof Error + && error.message.includes('user denied permission') + ) { + // Permanent condition; retrying will not help + throw error; + } + if ( + error instanceof DOMException + && error.name === 'InvalidStateError' + ) { + // The document is not fully active (e.g. it was re-parented or + // navigated mid-registration). This document can never recover; + // fail fast so that the host can reload us into a fresh document. + throw error; + } + if (attempt >= registerRetryDelays.length) { + throw new Error(`${error} (gave up after ${attempt + 1} attempts)`); + } + const delay = registerRetryDelays[attempt]; + console.warn(`Failed to register service worker (attempt ${attempt + 1} of ${registerRetryDelays.length + 1}): ${error}. Retrying in ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + await cleanupServiceWorkerState(); + } + } + }; + /** @type {Promise} */ const workerReady = new Promise((resolve, reject) => { if (disableServiceWorker) { @@ -253,7 +321,7 @@ } const swPath = encodeURI(`service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}&remoteAuthority=${searchParams.get('remoteAuthority') ?? ''}&platform=${searchParams.get('platform')}`); - navigator.serviceWorker.register(swPath, { type: 'module', updateViaCache: 'none' }) + registerWithRetry(swPath) .then(async registration => { if (navigator.serviceWorker.controller) { // A previous SW is already controlling. Force an update diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 6f91392186367..66c39bd6b8515 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -8,13 +8,14 @@ import { addDisposableListener, EventType, getWindow, getWindowById } from '../. import { parentOriginHash } from '../../../../base/browser/iframe.js'; import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; import { CodeWindow } from '../../../../base/browser/window.js'; -import { promiseWithResolvers, ThrottledDelayer } from '../../../../base/common/async.js'; +import { disposableTimeout, promiseWithResolvers, ThrottledDelayer } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { COI } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; +import Severity from '../../../../base/common/severity.js'; import { listenStream } from '../../../../base/common/stream.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -169,7 +170,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi protected readonly webviewThemeDataProvider: WebviewThemeDataProvider, @IConfigurationService configurationService: IConfigurationService, @IContextMenuService contextMenuService: IContextMenuService, - @INotificationService notificationService: INotificationService, + @INotificationService private readonly _notificationService: INotificationService, @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, @ILogService private readonly _logService: ILogService, @IRemoteAuthorityResolverService private readonly _remoteAuthorityResolverService: IRemoteAuthorityResolverService, @@ -245,8 +246,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi })); this._register(this.on('fatal-error', (e) => { - notificationService.error(localize('fatalErrorMessage', "Error loading webview: {0}", e.message)); - this._onFatalError.fire({ message: e.message }); + this.handleFatalError(e.message); })); this._register(this.on('did-keydown', (data) => { @@ -621,6 +621,57 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } } + private static readonly _serviceWorkerReloadDelays = [1000, 1000, 2000, 3000, 5000]; + private _serviceWorkerReloadAttempt = 0; + private _serviceWorkerLastFailureTime = 0; + private readonly _serviceWorkerReloadTimeout = this._register(new MutableDisposable()); + + /** + * Handles a fatal error reported by the webview. Service worker registration + * failures are often transient (e.g. the webview's document may not have + * been fully active when registration was attempted, a state which can + * never recover without a new document), so before surfacing the error to + * the user we retry by reloading the webview, which creates a fresh + * document and re-attempts registration. + */ + private handleFatalError(message: string): void { + if (/^Could not register service worker:/.test(message)) { + // If the webview has been working since the last failure, start a + // fresh retry cycle so that sporadic failures keep self-healing + if (Date.now() - this._serviceWorkerLastFailureTime > 60_000) { + this._serviceWorkerReloadAttempt = 0; + } + this._serviceWorkerLastFailureTime = Date.now(); + + if (this._serviceWorkerReloadAttempt < WebviewElement._serviceWorkerReloadDelays.length && !this._disposed && this.element?.parentElement) { + const attempt = ++this._serviceWorkerReloadAttempt; + const delay = WebviewElement._serviceWorkerReloadDelays[attempt - 1]; + this._logService.warn(`Webview(${this.id}): service worker registration failed (${message}). Reloading webview in ${delay}ms (retry ${attempt} of ${WebviewElement._serviceWorkerReloadDelays.length})`); + this._serviceWorkerReloadTimeout.value = disposableTimeout(() => { + this._serviceWorkerReloadTimeout.clear(); + this.reinitializeAfterDismount(); + }, delay); + return; + } + + this._logService.error(`Webview(${this.id}): service worker registration failed after ${this._serviceWorkerReloadAttempt} reload retries (${message})`); + this._notificationService.prompt(Severity.Error, + localize('fatalErrorMessage', "Error loading webview: {0}", message), + [{ + label: localize('reloadWebview', "Reload Webview"), + run: () => { + this._serviceWorkerReloadAttempt = 0; + this.reinitializeAfterDismount(); + } + }]); + this._onFatalError.fire({ message }); + return; + } + + this._notificationService.error(localize('fatalErrorMessage', "Error loading webview: {0}", message)); + this._onFatalError.fire({ message }); + } + public reload(): void { this.doUpdateContent(this._content); } From 7b1e4086ef95c40ae84140b041155c30774f0220 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Wed, 2 Sep 2026 11:06:12 +0300 Subject: [PATCH 02/12] webview - address review feedback on service worker retry - Clear webview caches only on desktop, where each webview has an isolated origin. In the browser, webviews share an origin, service worker, and cache storage, so clearing them would evict resources for every other open webview. Update the inline script CSP hash. - Ignore duplicate registration failure reports while a reload is already scheduled, so repeated content events from a failed document cannot exhaust the retry budget before any reload happens. - Track mount-time drag listeners in a per-mount disposable store that is cleared on each mount, so repeated remounts no longer accumulate duplicate listeners. - Guard the Reload Webview notification action against disposed or detached webviews. --- .../contrib/webview/browser/pre/index.html | 26 +++++++++------- .../contrib/webview/browser/webviewElement.ts | 31 +++++++++++++++++-- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 4e0cdcb708554..bcc5b86bbe549 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-NNdJ1w/1OhDB+Yqygljb50rTjilG9d2HbsG3IwgpzK4=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { + // On desktop, each webview is served from its own origin, so it is + // safe to clear caches and unregister the broken worker. In the + // browser, webviews share an origin, service worker, and cache + // storage, so none of them must be touched or we would evict + // resources for every other open webview. + if (!onElectron) { + return; + } try { if (typeof caches !== 'undefined') { const keys = await caches.keys(); @@ -260,17 +268,11 @@ } catch (e) { console.warn(`Failed to clear webview caches: ${e}`); } - - // On desktop, each webview is served from its own origin, so it is - // safe to unregister the broken worker. In the browser, service - // workers are shared across webviews, so we must not unregister. - if (onElectron) { - try { - const registrations = await navigator.serviceWorker.getRegistrations(); - await Promise.all(registrations.map(registration => registration.unregister())); - } catch (e) { - console.warn(`Failed to unregister webview service workers: ${e}`); - } + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(registration => registration.unregister())); + } catch (e) { + console.warn(`Failed to unregister webview service workers: ${e}`); } }; diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 66c39bd6b8515..d60379a01949c 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -12,7 +12,7 @@ import { disposableTimeout, promiseWithResolvers, ThrottledDelayer } from '../.. import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { COI } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; import Severity from '../../../../base/common/severity.js'; @@ -471,11 +471,22 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this.element!.setAttribute('src', `${this.webviewContentEndpoint(encodedWebviewOrigin)}/${fileName}?${queryString}`); } + /** + * Listeners that are scoped to a single mount. `mountTo` can be called + * repeatedly over the lifetime of the webview (e.g. when retrying a + * failed service worker registration), so these are replaced on each + * mount instead of accumulating. + */ + private readonly _mountListeners = this._register(new DisposableStore()); + public mountTo(element: HTMLElement, targetWindow: CodeWindow) { if (!this.element) { return; } + // Drop listeners from any previous mount before re-registering them + this._mountListeners.clear(); + this._windowId = targetWindow.vscodeWindowId; this._encodedWebviewOriginPromise = parentOriginHash(targetWindow.origin, this.origin).then(id => this._encodedWebviewOrigin = id); this._encodedWebviewOriginPromise.then(encodedWebviewOrigin => { @@ -490,13 +501,13 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } for (const eventName of [EventType.MOUSE_DOWN, EventType.MOUSE_MOVE, EventType.DROP]) { - this._register(addDisposableListener(element, eventName, () => { + this._mountListeners.add(addDisposableListener(element, eventName, () => { this._stopBlockingIframeDragEvents(); })); } for (const node of [element, targetWindow]) { - this._register(addDisposableListener(node, EventType.DRAG_END, () => { + this._mountListeners.add(addDisposableListener(node, EventType.DRAG_END, () => { this._stopBlockingIframeDragEvents(); })); } @@ -636,6 +647,14 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi */ private handleFatalError(message: string): void { if (/^Could not register service worker:/.test(message)) { + // A failed document reports the registration failure for every + // content event, so ignore duplicates while a reload is already + // scheduled. Otherwise each report would replace the pending + // reload and exhaust the retry budget without ever reloading. + if (this._serviceWorkerReloadTimeout.value) { + return; + } + // If the webview has been working since the last failure, start a // fresh retry cycle so that sporadic failures keep self-healing if (Date.now() - this._serviceWorkerLastFailureTime > 60_000) { @@ -660,6 +679,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi [{ label: localize('reloadWebview', "Reload Webview"), run: () => { + // The notification can outlive the webview, e.g. when + // its editor gets closed. Do not act on a disposed or + // detached webview. + if (this._disposed || !this.element?.parentElement) { + return; + } this._serviceWorkerReloadAttempt = 0; this.reinitializeAfterDismount(); } From 4e5e8e217e9255f888d7b2ecc54a674bbd3cba66 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 08:19:51 +0300 Subject: [PATCH 03/12] webview - make service worker retry less destructive and lossless - Only clear webview caches and unregister service workers on the final in-document registration attempt. Cache storage and registrations are scoped to the webview origin, which may be shared by several webview instances at once (e.g. notebook webviews of one view type, chat output webviews of one renderer), so cleaning up for a transient failure of one instance must not evict state still in use by the others. - When a service worker reload is scheduled, transition the webview out of Ready and close the failed document's message port right away, so messages sent during the backoff are queued instead of being written to the dead port. Preserve the pending message queue across the reload and replay it once the new document becomes ready. --- .../contrib/webview/browser/pre/index.html | 26 ++++++++++++------- .../contrib/webview/browser/webviewElement.ts | 19 +++++++++++++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index bcc5b86bbe549..a17b9f059a4d1 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-Qewsm1WNbj0je7yaRxyN0ev2k2yRGl4lCU0y8Ub0RHA=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { - // On desktop, each webview is served from its own origin, so it is - // safe to clear caches and unregister the broken worker. In the - // browser, webviews share an origin, service worker, and cache - // storage, so none of them must be touched or we would evict - // resources for every other open webview. - if (!onElectron) { + const cleanupServiceWorkerState = async (isFinalAttempt) => { + if (!onElectron || !isFinalAttempt) { return; } try { @@ -307,7 +315,7 @@ const delay = registerRetryDelays[attempt]; console.warn(`Failed to register service worker (attempt ${attempt + 1} of ${registerRetryDelays.length + 1}): ${error}. Retrying in ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); - await cleanupServiceWorkerState(); + await cleanupServiceWorkerState(attempt >= registerRetryDelays.length - 1); } } }; diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index d60379a01949c..7dc7ef6a94caf 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -666,6 +666,18 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi const attempt = ++this._serviceWorkerReloadAttempt; const delay = WebviewElement._serviceWorkerReloadDelays[attempt - 1]; this._logService.warn(`Webview(${this.id}): service worker registration failed (${message}). Reloading webview in ${delay}ms (retry ${attempt} of ${WebviewElement._serviceWorkerReloadDelays.length})`); + + // Stop delivering messages to the failed document's port right + // away. Otherwise messages sent while the reload is pending + // would be written to the dead port and silently dropped when + // the webview is reinitialized. Queued messages are preserved + // across the reload and replayed once the new document + // becomes ready. + const pendingMessages = this._state.type === WebviewState.Type.Initializing ? this._state.pendingMessages : []; + this._state = new WebviewState.Initializing(pendingMessages); + this._messagePort?.close(); + this._messagePort = undefined; + this._serviceWorkerReloadTimeout.value = disposableTimeout(() => { this._serviceWorkerReloadTimeout.clear(); this.reinitializeAfterDismount(); @@ -702,7 +714,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } public reinitializeAfterDismount(): void { - this._state = new WebviewState.Initializing([]); + // Preserve messages that were queued while the previous document was + // failing to load, so that they are replayed once the new document + // becomes ready instead of being dropped + const pendingMessages = this._state.type === WebviewState.Type.Initializing ? this._state.pendingMessages : []; + this._state = new WebviewState.Initializing(pendingMessages); + this._messagePort?.close(); this._messagePort = undefined; this.mountTo(this.element!.parentElement!, getWindow(this.element)); From 0d7c131175ea4cd289059be541b100d5d2f07cfa Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 08:29:12 +0300 Subject: [PATCH 04/12] webview - suppress duplicate fatal errors after retry budget exhausted After the service worker reload retry budget is exhausted, the failed document keeps reporting the same registration error for every subsequent content update. This would re-log the error, recreate the notification, and refire onFatalError for each update. Track the terminal failure and suppress further registration errors until the webview is reinitialized with a fresh document, which starts a fresh retry cycle. --- .../contrib/webview/browser/webviewElement.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 7dc7ef6a94caf..611a364305d0a 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -636,6 +636,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private _serviceWorkerReloadAttempt = 0; private _serviceWorkerLastFailureTime = 0; private readonly _serviceWorkerReloadTimeout = this._register(new MutableDisposable()); + /** + * Whether service worker registration has terminally failed for the + * current document. Further registration errors are suppressed until + * the webview is reinitialized with a fresh document. + */ + private _serviceWorkerTerminalFailure = false; /** * Handles a fatal error reported by the webview. Service worker registration @@ -655,6 +661,15 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi return; } + // Once the reload retry budget is exhausted, the failed document + // reports the same registration error for every subsequent + // content update. Suppress those duplicates so the error is not + // relogged, the notification is not recreated, and onFatalError + // does not refire, until the webview is reinitialized. + if (this._serviceWorkerTerminalFailure) { + return; + } + // If the webview has been working since the last failure, start a // fresh retry cycle so that sporadic failures keep self-healing if (Date.now() - this._serviceWorkerLastFailureTime > 60_000) { @@ -685,6 +700,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi return; } + this._serviceWorkerTerminalFailure = true; this._logService.error(`Webview(${this.id}): service worker registration failed after ${this._serviceWorkerReloadAttempt} reload retries (${message})`); this._notificationService.prompt(Severity.Error, localize('fatalErrorMessage', "Error loading webview: {0}", message), @@ -714,6 +730,10 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } public reinitializeAfterDismount(): void { + // A fresh document gets a fresh service worker retry cycle, so clear + // any terminal registration failure from the previous document + this._serviceWorkerTerminalFailure = false; + // Preserve messages that were queued while the previous document was // failing to load, so that they are replayed once the new document // becomes ready instead of being dropped From 751fe21aea3c68422e1e2d36defced79a86d4f70 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 08:43:52 +0300 Subject: [PATCH 05/12] webview - cancel pending service worker retry on reinitialize reinitializeAfterDismount can also be invoked by external callers, such as overlay webview remounts, while a service worker retry backoff is still pending. The armed timeout would then fire later and reload the newly initialized document again, interrupting its content and messages. Cancel the pending retry when reinitializing. --- src/vs/workbench/contrib/webview/browser/webviewElement.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 611a364305d0a..f08619e48ca18 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -730,6 +730,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } public reinitializeAfterDismount(): void { + // This can also be called by external callers, e.g. remounting an + // overlay webview, while a service worker retry backoff is still + // pending. Cancel it so it does not fire later and reload the newly + // initialized document again, interrupting its content and messages. + this._serviceWorkerReloadTimeout.clear(); + // A fresh document gets a fresh service worker retry cycle, so clear // any terminal registration failure from the previous document this._serviceWorkerTerminalFailure = false; From ab1591d0de43c0aa6e4246fdf50df6e436ed8dd0 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 09:03:52 +0300 Subject: [PATCH 06/12] webview - match serialized Error prefix in service worker fatal errors The webview document reports the workerReady rejection using e + '', which serializes through Error.prototype.toString as 'Error: Could not register service worker: ...'. The retry matcher was anchored at 'Could', so it never matched and every registration failure took the generic fatal-error path without the reload recovery. Accept the serialized 'Error: ' prefix while keeping the trailing colon so the non-retryable third-party-cookie variant still fails fast. --- .../workbench/contrib/webview/browser/webviewElement.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index f08619e48ca18..6abdcdacc10ab 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -652,7 +652,13 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi * document and re-attempts registration. */ private handleFatalError(message: string): void { - if (/^Could not register service worker:/.test(message)) { + // The webview document reports the rejection of `workerReady` using + // `error + ''`, which serializes through `Error.prototype.toString` as + // "Error: Could not register service worker: ...". Accept that prefix, + // but keep the trailing colon: the non-retryable third-party cookie + // variant ("Could not register service worker. Please make sure...") + // must still fail fast without triggering a reload. + if (/^(?:Error: )?Could not register service worker:/.test(message)) { // A failed document reports the registration failure for every // content event, so ignore duplicates while a reload is already // scheduled. Otherwise each report would replace the pending From 72c962c7cb9fd15f0127c6c0b57e674c85de90aa Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 09:14:54 +0300 Subject: [PATCH 07/12] webview - classify permission-denied service worker failures as permanent The document-side fail-fast for 'user denied permission' errors only skips the in-document registration retries. The catch wrapping the error for the host kept the !onElectron guard, so on Electron the permission error was serialized into the retryable colon form and the host burned all five document reloads on a permanent condition. Report permission denials in the period-terminated non-retryable form on every platform, and have the host side also refuse to retry such messages as defense in depth. --- src/vs/workbench/contrib/webview/browser/pre/index.html | 8 ++++++-- .../workbench/contrib/webview/browser/webviewElement.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index a17b9f059a4d1..c8585a6ab02ee 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-u7VQXHfzdvLSCfctDMQDjq5/9EXC9LfxRSkXqqtQbBU=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { - if (!onElectron && error instanceof Error && error.message.includes('user denied permission')) { + if (error instanceof Error && error.message.includes('user denied permission')) { + // A permission denial is a permanent condition; neither retrying + // here nor the host reloading the document can fix it. Report it + // in the period-terminated form so that the host does not + // classify it as retryable and burn its reload budget. return reject(new Error(`Could not register service worker. Please make sure third party cookies are enabled: ${error}`)); } return reject(new Error(`Could not register service worker: ${error}.`)); diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 6abdcdacc10ab..bd0f422c95bb5 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -658,7 +658,10 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // but keep the trailing colon: the non-retryable third-party cookie // variant ("Could not register service worker. Please make sure...") // must still fail fast without triggering a reload. - if (/^(?:Error: )?Could not register service worker:/.test(message)) { + // A permission denial ("user denied permission") is a permanent condition + // that a document reload cannot fix either, so even if one arrives in the + // retryable form do not burn the reload budget on it. + if (/^(?:Error: )?Could not register service worker:/.test(message) && !message.includes('user denied permission')) { // A failed document reports the registration failure for every // content event, so ignore duplicates while a reload is already // scheduled. Otherwise each report would replace the pending From af547b6cb86c7125d38f313520659042aa26cd06 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 09:25:58 +0300 Subject: [PATCH 08/12] webview - harden service worker retry state and error reporting Reset the reload retry budget only when a new document actually becomes ready (webview-ready), not based on elapsed time between failures: a slow registration that rejects more than 60s after the previous failure would otherwise reset the budget on every attempt and reload forever without surfacing the terminal error. Track the terminal-error notification in a registered MutableDisposable so it is closed when the webview is disposed or reinitialized instead of keeping the disposed webview alive through its action closure. On desktop, report permission-denied service worker failures without the browser-oriented third-party cookie guidance, while keeping the period-terminated non-retryable message form on all platforms. --- .../contrib/webview/browser/pre/index.html | 9 +++- .../contrib/webview/browser/webviewElement.ts | 43 +++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index c8585a6ab02ee..71a7acebbf543 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-8/S4t1vjOzZC2yL50bdHzKxIqHo1Bv7+ofuhTFI4TS8=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> ()); + /** + * The notification shown when service worker registration terminally failed, + * so that it can be closed when the webview is disposed or reinitialized, + * instead of keeping the disposed webview alive through its action closure. + */ + private readonly _serviceWorkerErrorNotification = this._register(new MutableDisposable()); /** * Whether service worker registration has terminally failed for the * current document. Further registration errors are suppressed until @@ -679,12 +690,13 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi return; } - // If the webview has been working since the last failure, start a - // fresh retry cycle so that sporadic failures keep self-healing - if (Date.now() - this._serviceWorkerLastFailureTime > 60_000) { - this._serviceWorkerReloadAttempt = 0; - } - this._serviceWorkerLastFailureTime = Date.now(); + // The retry budget is only reset when a new document successfully + // becomes ready (see the 'webview-ready' handling in + // _registerMessageHandler), never based on elapsed time between + // failures: a registration that rejects only after running longer + // than the gap to the previous failure must not reset the budget, + // or the host could reload forever without ever surfacing the + // terminal error. if (this._serviceWorkerReloadAttempt < WebviewElement._serviceWorkerReloadDelays.length && !this._disposed && this.element?.parentElement) { const attempt = ++this._serviceWorkerReloadAttempt; @@ -711,14 +723,17 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this._serviceWorkerTerminalFailure = true; this._logService.error(`Webview(${this.id}): service worker registration failed after ${this._serviceWorkerReloadAttempt} reload retries (${message})`); - this._notificationService.prompt(Severity.Error, + // Track the notification so it can be closed when the webview is + // disposed or reinitialized; otherwise its action closure would + // keep the disposed webview's object graph alive until the user + // manually dismissed it. + this._serviceWorkerErrorNotification.value = this._notificationService.prompt(Severity.Error, localize('fatalErrorMessage', "Error loading webview: {0}", message), [{ label: localize('reloadWebview', "Reload Webview"), run: () => { - // The notification can outlive the webview, e.g. when - // its editor gets closed. Do not act on a disposed or - // detached webview. + // The notification may still be open while the webview + // has been detached from its parent. Do not act then. if (this._disposed || !this.element?.parentElement) { return; } @@ -745,6 +760,10 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // initialized document again, interrupting its content and messages. this._serviceWorkerReloadTimeout.clear(); + // Any terminal failure notification from the previous document is + // stale now that a fresh document is being created + this._serviceWorkerErrorNotification.clear(); + // A fresh document gets a fresh service worker retry cycle, so clear // any terminal registration failure from the previous document this._serviceWorkerTerminalFailure = false; From 15513ff4090ea0f26b547e815a81e65b0d48f285 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 09:43:48 +0300 Subject: [PATCH 09/12] webview - fix notification handle typing and scope message handler to mount INotificationHandle exposes close() but does not implement IDisposable, so it cannot be stored in MutableDisposable directly. Store an IDisposable wrapper that closes the handle instead. The webview-ready message listener was registered on the webview element's lifetime while mountTo can run repeatedly (service worker retries, overlay transfers). A failed document never sends webview-ready, so each remount retained another window listener; after a successful retry only the first listener disposed itself. Register the listener in the mount-scoped listener store instead. --- .../contrib/webview/browser/webviewElement.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 3b792c58f270e..3d00429bc6e04 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -28,7 +28,7 @@ import { IContextMenuService } from '../../../../platform/contextview/browser/co import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { INotificationHandle, INotificationService } from '../../../../platform/notification/common/notification.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; @@ -519,7 +519,10 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } private _registerMessageHandler(targetWindow: CodeWindow) { - const subscription = this._register(addDisposableListener(targetWindow, 'message', (e: MessageEvent) => { + // Scoped to the current mount, like the other mount listeners: a failed + // document never sends webview-ready, so registering on the element's + // lifetime would retain one listener per remount. + const subscription = this._mountListeners.add(addDisposableListener(targetWindow, 'message', (e: MessageEvent) => { if (!this._encodedWebviewOrigin || e?.data?.target !== this.id) { return; } @@ -645,8 +648,10 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi * The notification shown when service worker registration terminally failed, * so that it can be closed when the webview is disposed or reinitialized, * instead of keeping the disposed webview alive through its action closure. + * Stored as an IDisposable that closes the handle, since the notification + * handle itself only exposes close(), not dispose(). */ - private readonly _serviceWorkerErrorNotification = this._register(new MutableDisposable()); + private readonly _serviceWorkerErrorNotification = this._register(new MutableDisposable()); /** * Whether service worker registration has terminally failed for the * current document. Further registration errors are suppressed until @@ -726,8 +731,9 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // Track the notification so it can be closed when the webview is // disposed or reinitialized; otherwise its action closure would // keep the disposed webview's object graph alive until the user - // manually dismissed it. - this._serviceWorkerErrorNotification.value = this._notificationService.prompt(Severity.Error, + // manually dismissed it. The handle only exposes close() rather + // than dispose(), so wrap it in an IDisposable. + const notificationHandle = this._notificationService.prompt(Severity.Error, localize('fatalErrorMessage', "Error loading webview: {0}", message), [{ label: localize('reloadWebview', "Reload Webview"), @@ -741,6 +747,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this.reinitializeAfterDismount(); } }]); + this._serviceWorkerErrorNotification.value = toDisposable(() => notificationHandle.close()); this._onFatalError.fire({ message }); return; } From f84291e37cdfcc57f2549ca6a8d0600b52a4a43d Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 10:08:05 +0300 Subject: [PATCH 10/12] Reset service worker retry budget on explicit worker-ready signal\n\nwebview-ready is sent by the pre-document as soon as the message port is\nestablished, before service worker registration settles. Resetting the\nretry budget (and flushing queued messages) on it meant every failed\nreplacement document reset the budget before reporting its own failure,\nso a persistent InvalidStateError reloaded the webview every second\nforever, and messages flushed into a doomed document were lost when it\nwas replaced.\n\n- pre/index.html reports the outcome of workerReady directly: success\n posts a new worker-ready channel, failure posts fatal-error. The\n content handler catch remains as a backstop only.\n- webviewElement keeps the state Initializing after webview-ready and\n only flushes pending messages, transitions to Ready, and resets the\n retry budget on worker-ready, so messages queued before or after a\n document failure survive document replacement host-side and are\n replayed once a fresh document succeeds.\n- webviewMessages.d.ts adds the worker-ready channel.\n\nA persistent registration failure now exhausts the five reload retries\n(~12s) and surfaces the terminal notification instead of retrying\nforever. CSP hash updated for the index.html change (double-run\nverified HASH-UNCHANGED). --- .../contrib/webview/browser/pre/index.html | 17 ++++- .../contrib/webview/browser/webviewElement.ts | 73 +++++++++++++------ .../webview/browser/webviewMessages.d.ts | 1 + 3 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 71a7acebbf543..2359a265bc029 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-EzDj/aU+QVZCdeBdI3/J89FrdxdGLov2WwjCh9qp8Jw=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { + hostMessaging.postMessage('worker-ready', undefined); + }, (/** @type {any} */ error) => { + console.error(`Webview fatal error: ${error}`); + hostMessaging.postMessage('fatal-error', { message: error + '' }); + }); + const unloadMonitor = new class { constructor() { @@ -1056,6 +1067,10 @@ await workerReady; perfMark('content/workerReady'); } catch (e) { + // Backstop: the host holds content back until registration + // succeeded ('worker-ready'), so this should not normally + // fire. Report rather than render into a broken document + // if it somehow does. console.error(`Webview fatal error: ${e}`); hostMessaging.postMessage('fatal-error', { message: e + '' }); return; diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 3d00429bc6e04..807e21e00a296 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -249,6 +249,30 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this.handleFatalError(e.message); })); + this._register(this.on('worker-ready', () => { + // 'webview-ready' only hands over the message port; this signal + // arrives once service worker registration actually succeeded. + // Messages are held until then, so they cannot be lost when a + // failing document has to be replaced. + if (!this._messagePort) { + return; + } + + this.perfMark('worker-ready'); + this._logService.trace(`Webview(${this.id}): service worker ready`); + + if (this._state.type === WebviewState.Type.Initializing) { + this._state.pendingMessages.forEach(({ channel, data, resolve }) => resolve(this.doPostMessage(channel, data))); + } + this._state = WebviewState.Ready; + + // The document successfully registered its service worker and is + // serving content: the webview is genuinely working again, so + // future registration failures must start from a fresh retry + // budget. + this._serviceWorkerReloadAttempt = 0; + })); + this._register(this.on('did-keydown', (data) => { this.handleKeyEvent('keydown', data); })); @@ -552,16 +576,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this.element?.classList.add('ready'); - if (this._state.type === WebviewState.Type.Initializing) { - this._state.pendingMessages.forEach(({ channel, data, resolve }) => resolve(this.doPostMessage(channel, data))); - } - this._state = WebviewState.Ready; - - // The document became ready, so service worker registration - // succeeded and content was served for it: the webview is - // genuinely working again. Future registration failures must - // start from a fresh retry budget. - this._serviceWorkerReloadAttempt = 0; + // The state intentionally stays Initializing here: the port + // being handed over says nothing about service worker + // registration. Content and other messages are only flushed + // once the document reports 'worker-ready', so they are not + // lost if this document ends up being replaced because + // registration failed. subscription.dispose(); } @@ -678,10 +698,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // that a document reload cannot fix either, so even if one arrives in the // retryable form do not burn the reload budget on it. if (/^(?:Error: )?Could not register service worker:/.test(message) && !message.includes('user denied permission')) { - // A failed document reports the registration failure for every - // content event, so ignore duplicates while a reload is already - // scheduled. Otherwise each report would replace the pending - // reload and exhaust the retry budget without ever reloading. + // A failed document reports its registration failure once, when + // its workerReady promise rejects. Ignore duplicate reports while + // a reload is already scheduled (e.g. if the content handler + // backstop also fires): each report would otherwise replace the + // pending reload and exhaust the retry budget without ever + // reloading. if (this._serviceWorkerReloadTimeout.value) { return; } @@ -695,13 +717,13 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi return; } - // The retry budget is only reset when a new document successfully - // becomes ready (see the 'webview-ready' handling in - // _registerMessageHandler), never based on elapsed time between - // failures: a registration that rejects only after running longer - // than the gap to the previous failure must not reset the budget, - // or the host could reload forever without ever surfacing the - // terminal error. + // The retry budget is only reset when a new document reports + // that service worker registration succeeded ('worker-ready', + // see the handler registered in the constructor), never based + // on elapsed time between failures: a registration that rejects + // only after running longer than the gap to the previous failure + // must not reset the budget, or the host could reload forever + // without ever surfacing the terminal error. if (this._serviceWorkerReloadAttempt < WebviewElement._serviceWorkerReloadDelays.length && !this._disposed && this.element?.parentElement) { const attempt = ++this._serviceWorkerReloadAttempt; @@ -775,9 +797,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // any terminal registration failure from the previous document this._serviceWorkerTerminalFailure = false; - // Preserve messages that were queued while the previous document was - // failing to load, so that they are replayed once the new document - // becomes ready instead of being dropped + // Messages are held host-side until the document reports that + // service worker registration succeeded ('worker-ready'), so + // everything sent to the previous (failed) document — before or + // after its failure — is still queued in the Initializing state. + // Carry it over so it is replayed once the fresh document becomes + // ready instead of being dropped. const pendingMessages = this._state.type === WebviewState.Type.Initializing ? this._state.pendingMessages : []; this._state = new WebviewState.Initializing(pendingMessages); this._messagePort?.close(); diff --git a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts index 5ae6734308eb9..d93d6ccb6a188 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewMessages.d.ts @@ -36,6 +36,7 @@ export type FromWebviewMessage = { 'load-localhost': { id: string; origin: string }; 'did-scroll-wheel': IMouseWheelEvent; 'fatal-error': { message: string }; + 'worker-ready': void; 'no-csp-found': void; 'did-keydown': KeyEvent; 'did-keyup': KeyEvent; From b7e6be6ddce8b8ae9f77d933824f67403275a9c2 Mon Sep 17 00:00:00 2001 From: dnch13 Date: Sat, 5 Sep 2026 10:24:12 +0300 Subject: [PATCH 11/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/contrib/webview/browser/webviewElement.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 807e21e00a296..486525723bcd7 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -697,7 +697,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // A permission denial ("user denied permission") is a permanent condition // that a document reload cannot fix either, so even if one arrives in the // retryable form do not burn the reload budget on it. - if (/^(?:Error: )?Could not register service worker:/.test(message) && !message.includes('user denied permission')) { + if ( + /^(?:Error: )?Could not register service worker:/.test(message) + && !message.includes('user denied permission') + && !message.includes('Service Workers are not enabled') + ) { // A failed document reports its registration failure once, when // its workerReady promise rejects. Ignore duplicate reports while // a reload is already scheduled (e.g. if the content handler From 8ba1c5b2fc17bc296380fa8764a28de048ef55e5 Mon Sep 17 00:00:00 2001 From: Danila Fominykh Date: Sat, 5 Sep 2026 10:41:31 +0300 Subject: [PATCH 12/12] =?UTF-8?q?Settle=20pending=20sends=20on=20terminal?= =?UTF-8?q?=20failure=20and=20add=20recovery=20contract\n\nTerminal=20fail?= =?UTF-8?q?ure=20paths=20left=20the=20webview=20in=20the=20Initializing=20?= =?UTF-8?q?state,=20so\nevery=20queued=20=5Fsend=20promise=20and=20every?= =?UTF-8?q?=20later=20postMessage=20stayed=20pending\nforever=20(only=20wo?= =?UTF-8?q?rker-ready=20drains=20the=20queue=20and=20dispose()=20settles?= =?UTF-8?q?=20it).\nThis=20affected=20both=20the=20exhausted-retry=20branc?= =?UTF-8?q?h=20and=20the=20non-retryable\nfatal-error=20branches=20now=20t?= =?UTF-8?q?hat=20webview-ready=20no=20longer=20transitions=20to\nReady.=20?= =?UTF-8?q?Add=20an=20explicit=20WebviewState.Failed:=20entering=20it=20se?= =?UTF-8?q?ttles=20queued\nmessages=20as=20not=20delivered,=20further=20se?= =?UTF-8?q?nds=20resolve=20as=20false,=20and=20the=20dead\ndocument's=20po?= =?UTF-8?q?rt=20is=20closed.=20reinitializeAfterDismount=20returns=20the?= =?UTF-8?q?=20webview\nto=20Initializing=20(with=20an=20empty=20queue;=20r?= =?UTF-8?q?eload()=20requeues=20the=20content).\n\nThe=20terminal=20Reload?= =?UTF-8?q?=20Webview=20action=20could=20revive=20the=20webview=20element,?= =?UTF-8?q?=20but\nconsumers=20that=20gave=20up=20on=20onFatalError=20stay?= =?UTF-8?q?ed=20broken:=20BackLayerWebView\nrejects=20its=20one-shot=20ini?= =?UTF-8?q?tialization=20promise=20and=20NotebookEditorWidget\ncaches=20th?= =?UTF-8?q?at=20rejection,=20so=20a=20recovered=20notebook=20webview=20was?= =?UTF-8?q?=20never=20used\nagain.=20Add=20a=20recovery=20contract:=20Webv?= =?UTF-8?q?iewElement=20fires=20a=20new\nonFatalErrorResolved=20when=20a?= =?UTF-8?q?=20document=20reports=20worker-ready=20after=20a\nprevious=20fa?= =?UTF-8?q?tal=20error=20(tracked=20by=20a=20flag=20that=20survives=20rein?= =?UTF-8?q?itialization\nand=20is=20only=20cleared=20on=20actual=20recover?= =?UTF-8?q?y,=20so=20the=20event=20fires=20exactly\nonce).=20The=20event?= =?UTF-8?q?=20is=20declared=20on=20IWebview,=20forwarded=20by=20OverlayWeb?= =?UTF-8?q?view,\nre-exposed=20by=20BackLayerWebView,=20and=20NotebookEdit?= =?UTF-8?q?orWidget=20replaces=20the\ncached=20rejected=20=5FwebviewResolv?= =?UTF-8?q?ePromise=20with=20a=20resolved=20one=20=E2=80=94=20no\nre-runni?= =?UTF-8?q?ng=20of=20createWebview,=20since=20the=20recovered=20document?= =?UTF-8?q?=20re-posts\n'initialized'=20which=20resynchronizes=20the=20bac?= =?UTF-8?q?k=20layer's=20state.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notebook/browser/notebookEditorWidget.ts | 13 ++++ .../view/renderers/backLayerWebView.ts | 12 ++++ .../contrib/webview/browser/overlayWebview.ts | 4 ++ .../contrib/webview/browser/webview.ts | 8 +++ .../contrib/webview/browser/webviewElement.ts | 70 ++++++++++++++++++- 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 6b8d6b229684a..1e8fc6b40838f 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -1460,6 +1460,19 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD fontFamily: this._generateFontFamily() }, this.notebookRendererMessaging.getScoped(this._uuid)); + // When the webview terminally fails, `createWebview` rejects and + // `_resolveWebview` caches the rejection. The webview can still + // recover afterwards (e.g. via its Reload Webview action). Once it + // has, serve future `_resolveWebview` lookups from the recovered + // webview instead of the stale rejection; initialization state is + // resynchronized by the 'initialized' message that the recovered + // webview's content posts. + this._localStore.add(this._webview.onFatalErrorResolved(() => { + if (this._webview) { + this._webviewResolvePromise = Promise.resolve(this._webview); + } + })); + this._webview.element.style.width = '100%'; // attach the webview container to the DOM tree first diff --git a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts index fee8fdc6b46f5..a72211b2bc5c1 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/renderers/backLayerWebView.ts @@ -150,6 +150,9 @@ export class BackLayerWebView extends Themable { private readonly _onMessage = this._register(new Emitter()); private readonly _preloadsCache = new Set(); public readonly onMessage: Event = this._onMessage.event; + + private readonly _onFatalErrorResolved = this._register(new Emitter()); + public readonly onFatalErrorResolved: Event = this._onFatalErrorResolved.event; private _disposed = false; private _currentKernel?: INotebookKernel; @@ -604,6 +607,15 @@ export class BackLayerWebView extends Themable { initializePromise.error(new Error(`Could not initialize webview: ${e.message}}`)); })); + // The webview element can recover from a fatal error later (e.g. the + // user triggers its Reload Webview action), but the initialize + // promise above has already been rejected by then. Surface the + // recovery so that owners caching the rejection can retry against + // the same webview. + this._register(this.webview.onFatalErrorResolved(() => { + this._onFatalErrorResolved.fire(); + })); + this._register(this.webview.onMessage(async (message) => { const data: FromWebviewMessage | { readonly __vscode_notebook_message: undefined } = message.message; if (this._disposed) { diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index ad4e4e14cf361..1ffc4a3ba4a84 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -270,6 +270,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._webviewEvents.add(webview.onMissingCsp(x => { this._onMissingCsp.fire(x); })); this._webviewEvents.add(webview.onDidWheel(x => { this._onDidWheel.fire(x); })); this._webviewEvents.add(webview.onFatalError(x => { this._onFatalError.fire(x); })); + this._webviewEvents.add(webview.onFatalErrorResolved(() => { this._onFatalErrorResolved.fire(); })); this._webviewEvents.add(autorun(reader => { this.intrinsicContentSize.set(reader.readObservable(webview.intrinsicContentSize), undefined, undefined); })); @@ -373,6 +374,9 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private readonly _onFatalError = this._register(new Emitter<{ readonly message: string }>()); public onFatalError = this._onFatalError.event; + private readonly _onFatalErrorResolved = this._register(new Emitter()); + public onFatalErrorResolved = this._onFatalErrorResolved.event; + public readonly intrinsicContentSize = observableValue<{ readonly width: number; readonly height: number } | undefined>('WebviewIntrinsicContentSize', undefined); public async postMessage(message: unknown, transfer?: readonly ArrayBuffer[]): Promise { diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index f950b7b5a64e6..f3a0e5ec6e640 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -259,6 +259,14 @@ export interface IWebview extends IDisposable { * Fired when the webview cannot be loaded or is now in a non-functional state. */ readonly onFatalError: Event<{ readonly message: string }>; + + /** + * Fired when the webview has recovered after a previous `onFatalError`, + * e.g. because it was reinitialized and its new document is serving + * content again. Consumers that gave up on the webview when the fatal + * error was fired can retry their initialization. + */ + readonly onFatalErrorResolved: Event; readonly onMissingCsp: Event; readonly onMessage: Event; diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 486525723bcd7..f9c0b7331b8a1 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -48,7 +48,7 @@ interface WebviewContent { } namespace WebviewState { - export const enum Type { Initializing, Ready } + export const enum Type { Initializing, Ready, Failed } export class Initializing { readonly type = Type.Initializing; @@ -65,7 +65,16 @@ namespace WebviewState { export const Ready = { type: Type.Ready } as const; - export type State = typeof Ready | Initializing; + /** + * Terminal state entered when a fatal error has given up on the + * webview (e.g. a service worker registration failure that exhausted + * its reload budget). Queued messages are settled when entering this + * state and further sends resolve as not delivered. Reinitializing + * the webview returns it to the Initializing state. + */ + export const Failed = { type: Type.Failed } as const; + + export type State = typeof Ready | typeof Failed | Initializing; } interface WebviewActionContext { @@ -271,6 +280,15 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // future registration failures must start from a fresh retry // budget. this._serviceWorkerReloadAttempt = 0; + + // If a fatal error was previously fired for this webview (e.g. + // the retry budget was exhausted before this document + // succeeded), let consumers that gave up on it retry their + // initialization. + if (this._fatalErrorFired) { + this._fatalErrorFired = false; + this._onFatalErrorResolved.fire(); + } })); this._register(this.on('did-keydown', (data) => { @@ -419,6 +437,15 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private readonly _onFatalError = this._register(new Emitter<{ readonly message: string }>()); public readonly onFatalError = this._onFatalError.event; + /** + * Fired when the webview has recovered after a previous `onFatalError`: + * it was reinitialized and the fresh document successfully registered + * its service worker. Consumers that gave up on the webview when the + * fatal error fired can retry their initialization. + */ + private readonly _onFatalErrorResolved = this._register(new Emitter()); + public readonly onFatalErrorResolved = this._onFatalErrorResolved.event; + private readonly _onDidDispose = this._register(new Emitter()); public readonly onDidDispose = this._onDidDispose.event; @@ -431,6 +458,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi const { promise, resolve } = promiseWithResolvers(); this._state.pendingMessages.push({ channel, data, transferable: _createElement, resolve }); return promise; + } else if (this._state.type === WebviewState.Type.Failed) { + // The webview terminally failed and is not expected to recover + // on its own. Settle the send as not delivered instead of + // queueing it indefinitely. + return false; } else { return this.doPostMessage(channel, data, _createElement); } @@ -678,6 +710,28 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi * the webview is reinitialized with a fresh document. */ private _serviceWorkerTerminalFailure = false; + /** + * Whether `onFatalError` has fired for this webview without it + * recovering since. Used to fire `onFatalErrorResolved` exactly once + * when a reinitialized document reports that it is working again. + */ + private _fatalErrorFired = false; + + /** + * Marks the webview as terminally failed: settles every queued message + * as not delivered, makes further sends resolve as not delivered, and + * cuts the connection to the failed document. Reinitializing the + * webview returns it to the Initializing state. + */ + private enterTerminalFailureState(): void { + if (this._state.type === WebviewState.Type.Initializing) { + this._state.pendingMessages.forEach(({ resolve }) => resolve(false)); + this._state.pendingMessages = []; + } + this._state = WebviewState.Failed; + this._messagePort?.close(); + this._messagePort = undefined; + } /** * Handles a fatal error reported by the webview. Service worker registration @@ -754,6 +808,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this._serviceWorkerTerminalFailure = true; this._logService.error(`Webview(${this.id}): service worker registration failed after ${this._serviceWorkerReloadAttempt} reload retries (${message})`); + // Settle queued sends as not delivered and stop queueing new + // ones: the webview is not expected to recover on its own. + // Reinitializing (e.g. through the reload action below) + // returns it to the Initializing state. + this.enterTerminalFailureState(); // Track the notification so it can be closed when the webview is // disposed or reinitialized; otherwise its action closure would // keep the disposed webview's object graph alive until the user @@ -774,10 +833,13 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } }]); this._serviceWorkerErrorNotification.value = toDisposable(() => notificationHandle.close()); + this._fatalErrorFired = true; this._onFatalError.fire({ message }); return; } + this.enterTerminalFailureState(); + this._fatalErrorFired = true; this._notificationService.error(localize('fatalErrorMessage', "Error loading webview: {0}", message)); this._onFatalError.fire({ message }); } @@ -806,7 +868,9 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi // everything sent to the previous (failed) document — before or // after its failure — is still queued in the Initializing state. // Carry it over so it is replayed once the fresh document becomes - // ready instead of being dropped. + // ready instead of being dropped. (After a terminal failure the + // queue was already settled as not delivered, so the fresh + // document starts empty; the content posted below is requeued.) const pendingMessages = this._state.type === WebviewState.Type.Initializing ? this._state.pendingMessages : []; this._state = new WebviewState.Initializing(pendingMessages); this._messagePort?.close();