Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export class BackLayerWebView<T extends ICommonCellInfo> extends Themable {
private readonly _onMessage = this._register(new Emitter<INotebookWebviewMessage>());
private readonly _preloadsCache = new Set<string>();
public readonly onMessage: Event<INotebookWebviewMessage> = this._onMessage.event;

private readonly _onFatalErrorResolved = this._register(new Emitter<void>());
public readonly onFatalErrorResolved: Event<void> = this._onFatalErrorResolved.event;
private _disposed = false;
private _currentKernel?: INotebookKernel;

Expand Down Expand Up @@ -604,6 +607,15 @@ export class BackLayerWebView<T extends ICommonCellInfo> 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) {
Expand Down
4 changes: 4 additions & 0 deletions src/vs/workbench/contrib/webview/browser/overlayWebview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}));
Expand Down Expand Up @@ -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<void>());
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<boolean> {
Expand Down
110 changes: 106 additions & 4 deletions src/vs/workbench/contrib/webview/browser/pre/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta charset="UTF-8">

<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'sha256-FFQoOVVa2tOE3uqUvirwaMNT20TZmrHcL2aaOjJ8BUo=' 'self'; frame-src 'self'; style-src 'unsafe-inline';">
content="default-src 'none'; script-src 'sha256-EzDj/aU+QVZCdeBdI3/J89FrdxdGLov2WwjCh9qp8Jw=' 'self'; frame-src 'self'; style-src 'unsafe-inline';">

<!-- Disable pinch zooming -->
<meta name="viewport"
Expand Down Expand Up @@ -242,6 +242,84 @@
`;
}

// Registration of the webview service worker can transiently fail
// (e.g. due to temporary browser storage errors), so retry a few
// times before giving up and reporting a fatal error.
const registerRetryDelays = [1000, 1000, 2000, 3000, 5000];

/**
* Best effort cleanup of potentially corrupted service worker state
* before retrying the registration.
*
* This is only a last resort: cache storage and service worker
* registrations are scoped to the webview's origin, and a single
* origin can be shared by many webview instances at once (e.g. all
* notebook webviews of one view type, or all chat output webviews
* of one renderer). Clearing them evicts resources for those other
* webviews too, so it must only happen once the registration is
* about to give up entirely, never for a transient first failure.
* In the browser, service workers and cache storage are shared
* across all webviews on the main origin, so they are never
* cleared here.
*
* @param {boolean} isFinalAttempt
*/
const cleanupServiceWorkerState = async (isFinalAttempt) => {
if (!onElectron || !isFinalAttempt) {
return;
}
try {
if (typeof caches !== 'undefined') {
const keys = await caches.keys();
await Promise.all(keys.map(key => caches.delete(key)));
Comment thread
dnch13 marked this conversation as resolved.
}
} catch (e) {
console.warn(`Failed to clear webview caches: ${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}`);
}
};

/**
* @param {string} swPath
* @return {Promise<ServiceWorkerRegistration>}
*/
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;
Comment thread
dnch13 marked this conversation as resolved.
}
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(attempt >= registerRetryDelays.length - 1);
}
}
};

/** @type {Promise<void>} */
const workerReady = new Promise((resolve, reject) => {
if (disableServiceWorker) {
Expand All @@ -253,7 +331,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
Expand All @@ -278,8 +356,17 @@

return resolve();
}).catch(error => {
if (!onElectron && error instanceof Error && error.message.includes('user denied permission')) {
return reject(new Error(`Could not register service worker. Please make sure third party cookies are enabled: ${error}`));
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.
if (!onElectron) {
return reject(new Error(`Could not register service worker. Please make sure third party cookies are enabled: ${error}`));
}
// The third-party cookie guidance only applies to browsers;
// report the denial without it on desktop.
return reject(new Error(`Could not register service worker. Permission to register service workers was denied: ${error}`));
}
return reject(new Error(`Could not register service worker: ${error}.`));
});
Expand Down Expand Up @@ -362,6 +449,17 @@
}
}();

// Report the outcome of service worker registration to the host
// directly. The host only flushes queued messages (including the
// initial content) once registration has succeeded, so the content
// handler cannot be relied on to surface registration failures.
workerReady.then(() => {
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() {
Expand Down Expand Up @@ -969,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;
Expand Down
8 changes: 8 additions & 0 deletions src/vs/workbench/contrib/webview/browser/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
readonly onMissingCsp: Event<ExtensionIdentifier>;

readonly onMessage: Event<WebviewMessageReceivedEvent>;
Expand Down
Loading