420 lines
16 KiB
TypeScript
420 lines
16 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { DefaultWorkerFactory } from 'vs/base/worker/defaultWorkerFactory';
|
|
import { Emitter, Event } from 'vs/base/common/event';
|
|
import { toDisposable, Disposable } from 'vs/base/common/lifecycle';
|
|
import { IMessagePassingProtocol } from 'vs/base/parts/ipc/common/ipc';
|
|
import { VSBuffer } from 'vs/base/common/buffer';
|
|
import { createMessageOfType, MessageType, isMessageOfType, ExtensionHostExitCode } from 'vs/workbench/services/extensions/common/extensionHostProtocol';
|
|
import { IInitData, UIKind } from 'vs/workbench/api/common/extHost.protocol';
|
|
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
|
import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace';
|
|
import { ILabelService } from 'vs/platform/label/common/label';
|
|
import { ILogService } from 'vs/platform/log/common/log';
|
|
import { IExtensionDescription } from 'vs/platform/extensions/common/extensions';
|
|
import * as platform from 'vs/base/common/platform';
|
|
import * as dom from 'vs/base/browser/dom';
|
|
import { URI } from 'vs/base/common/uri';
|
|
import { IExtensionHost, ExtensionHostLogFileName, ExtensionHostKind } from 'vs/workbench/services/extensions/common/extensions';
|
|
import { IProductService } from 'vs/platform/product/common/productService';
|
|
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
|
|
import { joinPath } from 'vs/base/common/resources';
|
|
import { Registry } from 'vs/platform/registry/common/platform';
|
|
import { IOutputChannelRegistry, Extensions } from 'vs/workbench/services/output/common/output';
|
|
import { localize } from 'vs/nls';
|
|
import { generateUuid } from 'vs/base/common/uuid';
|
|
import { canceled, onUnexpectedError } from 'vs/base/common/errors';
|
|
import { Barrier } from 'vs/base/common/async';
|
|
import { ILayoutService } from 'vs/platform/layout/browser/layoutService';
|
|
import { NewWorkerMessage, TerminateWorkerMessage } from 'vs/workbench/services/extensions/common/polyfillNestedWorker.protocol';
|
|
|
|
export interface IWebWorkerExtensionHostInitData {
|
|
readonly autoStart: boolean;
|
|
readonly extensions: IExtensionDescription[];
|
|
}
|
|
|
|
export interface IWebWorkerExtensionHostDataProvider {
|
|
getInitData(): Promise<IWebWorkerExtensionHostInitData>;
|
|
}
|
|
|
|
const ttPolicyNestedWorker = window.trustedTypes?.createPolicy('webNestedWorkerExtensionHost', {
|
|
createScriptURL(value) {
|
|
if (value.startsWith('blob:')) {
|
|
return value;
|
|
}
|
|
throw new Error(value + ' is NOT allowed');
|
|
}
|
|
});
|
|
|
|
export class WebWorkerExtensionHost extends Disposable implements IExtensionHost {
|
|
|
|
public readonly kind = ExtensionHostKind.LocalWebWorker;
|
|
public readonly remoteAuthority = null;
|
|
public readonly lazyStart: boolean;
|
|
|
|
private readonly _onDidExit = this._register(new Emitter<[number, string | null]>());
|
|
public readonly onExit: Event<[number, string | null]> = this._onDidExit.event;
|
|
|
|
private _isTerminating: boolean;
|
|
private _protocolPromise: Promise<IMessagePassingProtocol> | null;
|
|
private _protocol: IMessagePassingProtocol | null;
|
|
|
|
private readonly _extensionHostLogsLocation: URI;
|
|
private readonly _extensionHostLogFile: URI;
|
|
|
|
constructor(
|
|
lazyStart: boolean,
|
|
private readonly _initDataProvider: IWebWorkerExtensionHostDataProvider,
|
|
@ITelemetryService private readonly _telemetryService: ITelemetryService,
|
|
@IWorkspaceContextService private readonly _contextService: IWorkspaceContextService,
|
|
@ILabelService private readonly _labelService: ILabelService,
|
|
@ILogService private readonly _logService: ILogService,
|
|
@IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService,
|
|
@IProductService private readonly _productService: IProductService,
|
|
@ILayoutService private readonly _layoutService: ILayoutService,
|
|
) {
|
|
super();
|
|
this.lazyStart = lazyStart;
|
|
this._isTerminating = false;
|
|
this._protocolPromise = null;
|
|
this._protocol = null;
|
|
this._extensionHostLogsLocation = joinPath(this._environmentService.extHostLogsPath, 'webWorker');
|
|
this._extensionHostLogFile = joinPath(this._extensionHostLogsLocation, `${ExtensionHostLogFileName}.log`);
|
|
}
|
|
|
|
private _webWorkerExtensionHostIframeSrc(): string | null {
|
|
const suffix = this._environmentService.debugExtensionHost && this._environmentService.debugRenderer ? '?debugged=1' : '?';
|
|
if (this._environmentService.options && this._environmentService.options.webWorkerExtensionHostIframeSrc) {
|
|
return this._environmentService.options.webWorkerExtensionHostIframeSrc + suffix;
|
|
}
|
|
|
|
const forceHTTPS = (location.protocol === 'https:');
|
|
const webEndpointUrlTemplate = this._productService.webEndpointUrlTemplate;
|
|
const commit = this._productService.commit;
|
|
const quality = this._productService.quality;
|
|
if (webEndpointUrlTemplate && commit && quality) {
|
|
const baseUrl = (
|
|
webEndpointUrlTemplate
|
|
.replace('{{uuid}}', generateUuid())
|
|
.replace('{{commit}}', commit)
|
|
.replace('{{quality}}', quality)
|
|
);
|
|
const base = (
|
|
forceHTTPS
|
|
? `${baseUrl}/out/vs/workbench/services/extensions/worker/httpsWebWorkerExtensionHostIframe.html`
|
|
: `${baseUrl}/out/vs/workbench/services/extensions/worker/httpWebWorkerExtensionHostIframe.html`
|
|
);
|
|
|
|
return base + suffix;
|
|
}
|
|
|
|
if (this._productService.webEndpointUrl) {
|
|
let baseUrl = this._productService.webEndpointUrl;
|
|
if (this._productService.quality) {
|
|
baseUrl += `/${this._productService.quality}`;
|
|
}
|
|
if (this._productService.commit) {
|
|
baseUrl += `/${this._productService.commit}`;
|
|
}
|
|
const base = (
|
|
forceHTTPS
|
|
? `${baseUrl}/out/vs/workbench/services/extensions/worker/httpsWebWorkerExtensionHostIframe.html`
|
|
: `${baseUrl}/out/vs/workbench/services/extensions/worker/httpWebWorkerExtensionHostIframe.html`
|
|
);
|
|
|
|
return base + suffix;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public async start(): Promise<IMessagePassingProtocol> {
|
|
if (!this._protocolPromise) {
|
|
if (platform.isWeb) {
|
|
const webWorkerExtensionHostIframeSrc = this._webWorkerExtensionHostIframeSrc();
|
|
if (webWorkerExtensionHostIframeSrc) {
|
|
this._protocolPromise = this._startInsideIframe(webWorkerExtensionHostIframeSrc);
|
|
} else {
|
|
console.warn(`The web worker extension host is started without an iframe sandbox!`);
|
|
this._protocolPromise = this._startOutsideIframe();
|
|
}
|
|
} else {
|
|
this._protocolPromise = this._startOutsideIframe();
|
|
}
|
|
this._protocolPromise.then(protocol => this._protocol = protocol);
|
|
}
|
|
return this._protocolPromise;
|
|
}
|
|
|
|
private async _startInsideIframe(webWorkerExtensionHostIframeSrc: string): Promise<IMessagePassingProtocol> {
|
|
const emitter = this._register(new Emitter<VSBuffer>());
|
|
|
|
const iframe = document.createElement('iframe');
|
|
iframe.setAttribute('class', 'web-worker-ext-host-iframe');
|
|
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
|
|
iframe.style.display = 'none';
|
|
|
|
const vscodeWebWorkerExtHostId = generateUuid();
|
|
iframe.setAttribute('src', `${webWorkerExtensionHostIframeSrc}&vscodeWebWorkerExtHostId=${vscodeWebWorkerExtHostId}`);
|
|
|
|
const barrier = new Barrier();
|
|
let port!: MessagePort;
|
|
let barrierError: Error | null = null;
|
|
let barrierHasError = false;
|
|
let startTimeout: any = null;
|
|
|
|
const rejectBarrier = (exitCode: number, error: Error) => {
|
|
barrierError = error;
|
|
barrierHasError = true;
|
|
onUnexpectedError(barrierError);
|
|
clearTimeout(startTimeout);
|
|
this._onDidExit.fire([ExtensionHostExitCode.UnexpectedError, barrierError.message]);
|
|
barrier.open();
|
|
};
|
|
|
|
const resolveBarrier = (messagePort: MessagePort) => {
|
|
port = messagePort;
|
|
clearTimeout(startTimeout);
|
|
barrier.open();
|
|
};
|
|
|
|
startTimeout = setTimeout(() => {
|
|
console.warn(`The Web Worker Extension Host did not start in 60s, that might be a problem.`);
|
|
}, 60000);
|
|
|
|
this._register(dom.addDisposableListener(window, 'message', (event) => {
|
|
if (event.source !== iframe.contentWindow) {
|
|
return;
|
|
}
|
|
if (event.data.vscodeWebWorkerExtHostId !== vscodeWebWorkerExtHostId) {
|
|
return;
|
|
}
|
|
if (event.data.error) {
|
|
const { name, message, stack } = event.data.error;
|
|
const err = new Error();
|
|
err.message = message;
|
|
err.name = name;
|
|
err.stack = stack;
|
|
return rejectBarrier(ExtensionHostExitCode.UnexpectedError, err);
|
|
}
|
|
const { data } = event.data;
|
|
if (barrier.isOpen() || !(data instanceof MessagePort)) {
|
|
console.warn('UNEXPECTED message', event);
|
|
const err = new Error('UNEXPECTED message');
|
|
return rejectBarrier(ExtensionHostExitCode.UnexpectedError, err);
|
|
}
|
|
resolveBarrier(data);
|
|
}));
|
|
|
|
this._layoutService.container.appendChild(iframe);
|
|
this._register(toDisposable(() => iframe.remove()));
|
|
|
|
// await MessagePort and use it to directly communicate
|
|
// with the worker extension host
|
|
await barrier.wait();
|
|
|
|
if (barrierHasError) {
|
|
throw barrierError;
|
|
}
|
|
|
|
port.onmessage = (event) => {
|
|
const { data } = event;
|
|
if (!(data instanceof ArrayBuffer)) {
|
|
console.warn('UNKNOWN data received', data);
|
|
this._onDidExit.fire([77, 'UNKNOWN data received']);
|
|
return;
|
|
}
|
|
emitter.fire(VSBuffer.wrap(new Uint8Array(data, 0, data.byteLength)));
|
|
};
|
|
|
|
const protocol: IMessagePassingProtocol = {
|
|
onMessage: emitter.event,
|
|
send: vsbuf => {
|
|
const data = vsbuf.buffer.buffer.slice(vsbuf.buffer.byteOffset, vsbuf.buffer.byteOffset + vsbuf.buffer.byteLength);
|
|
port.postMessage(data, [data]);
|
|
}
|
|
};
|
|
|
|
return this._performHandshake(protocol);
|
|
}
|
|
|
|
private async _startOutsideIframe(): Promise<IMessagePassingProtocol> {
|
|
const emitter = new Emitter<VSBuffer>();
|
|
const barrier = new Barrier();
|
|
let port!: MessagePort;
|
|
|
|
const nestedWorker = new Map<string, Worker>();
|
|
|
|
const name = this._environmentService.debugRenderer && this._environmentService.debugExtensionHost ? 'DebugWorkerExtensionHost' : 'WorkerExtensionHost';
|
|
const worker = new DefaultWorkerFactory(name).create(
|
|
'vs/workbench/services/extensions/worker/extensionHostWorker',
|
|
(data: MessagePort | NewWorkerMessage | TerminateWorkerMessage | any) => {
|
|
|
|
if (data instanceof MessagePort) {
|
|
// receiving a message port which is used to communicate
|
|
// with the web worker extension host
|
|
if (barrier.isOpen()) {
|
|
console.warn('UNEXPECTED message', data);
|
|
this._onDidExit.fire([ExtensionHostExitCode.UnexpectedError, 'received a message port AFTER opening the barrier']);
|
|
return;
|
|
}
|
|
port = data;
|
|
barrier.open();
|
|
|
|
|
|
} else if (data?.type === '_newWorker') {
|
|
// receiving a message to create a new nested/child worker
|
|
const worker = new Worker((ttPolicyNestedWorker?.createScriptURL(data.url) ?? data.url) as string, data.options);
|
|
worker.postMessage(data.port, [data.port]);
|
|
worker.onerror = console.error.bind(console);
|
|
nestedWorker.set(data.id, worker);
|
|
|
|
} else if (data?.type === '_terminateWorker') {
|
|
// receiving a message to terminate nested/child worker
|
|
if (nestedWorker.has(data.id)) {
|
|
nestedWorker.get(data.id)!.terminate();
|
|
nestedWorker.delete(data.id);
|
|
}
|
|
|
|
} else {
|
|
// all other messages are an error
|
|
console.warn('UNEXPECTED message', data);
|
|
this._onDidExit.fire([ExtensionHostExitCode.UnexpectedError, 'UNEXPECTED message']);
|
|
}
|
|
},
|
|
(event: any) => {
|
|
console.error(event.message, event.error);
|
|
|
|
if (!barrier.isOpen()) {
|
|
// Only terminate the web worker extension host when an error occurs during handshake
|
|
// and setup. All other errors can be normal uncaught exceptions
|
|
this._onDidExit.fire([ExtensionHostExitCode.UnexpectedError, event.message || event.error]);
|
|
}
|
|
}
|
|
);
|
|
|
|
// TODO@joao: let's use this mechanism to invert the regular MessagePort sending.
|
|
// it is currently being sent from the extension host to the outside. we could
|
|
// send it in this collection instead
|
|
const messagePorts = this._environmentService.options?.messagePorts ?? new Map();
|
|
worker.postMessage(messagePorts as any, [...messagePorts.values()]);
|
|
|
|
// await MessagePort and use it to directly communicate
|
|
// with the worker extension host
|
|
await barrier.wait();
|
|
|
|
port.onmessage = (event) => {
|
|
const { data } = event;
|
|
if (!(data instanceof ArrayBuffer)) {
|
|
console.warn('UNKNOWN data received', data);
|
|
this._onDidExit.fire([77, 'UNKNOWN data received']);
|
|
return;
|
|
}
|
|
|
|
emitter.fire(VSBuffer.wrap(new Uint8Array(data, 0, data.byteLength)));
|
|
};
|
|
|
|
|
|
// keep for cleanup
|
|
this._register(emitter);
|
|
this._register(worker);
|
|
|
|
const protocol: IMessagePassingProtocol = {
|
|
onMessage: emitter.event,
|
|
send: vsbuf => {
|
|
const data = vsbuf.buffer.buffer.slice(vsbuf.buffer.byteOffset, vsbuf.buffer.byteOffset + vsbuf.buffer.byteLength);
|
|
port.postMessage(data, [data]);
|
|
}
|
|
};
|
|
|
|
return this._performHandshake(protocol);
|
|
}
|
|
|
|
private async _performHandshake(protocol: IMessagePassingProtocol): Promise<IMessagePassingProtocol> {
|
|
// extension host handshake happens below
|
|
// (1) <== wait for: Ready
|
|
// (2) ==> send: init data
|
|
// (3) <== wait for: Initialized
|
|
|
|
await Event.toPromise(Event.filter(protocol.onMessage, msg => isMessageOfType(msg, MessageType.Ready)));
|
|
if (this._isTerminating) {
|
|
throw canceled();
|
|
}
|
|
protocol.send(VSBuffer.fromString(JSON.stringify(await this._createExtHostInitData())));
|
|
if (this._isTerminating) {
|
|
throw canceled();
|
|
}
|
|
await Event.toPromise(Event.filter(protocol.onMessage, msg => isMessageOfType(msg, MessageType.Initialized)));
|
|
if (this._isTerminating) {
|
|
throw canceled();
|
|
}
|
|
|
|
// Register log channel for web worker exthost log
|
|
Registry.as<IOutputChannelRegistry>(Extensions.OutputChannels).registerChannel({ id: 'webWorkerExtHostLog', label: localize('name', "Worker Extension Host"), file: this._extensionHostLogFile, log: true });
|
|
|
|
return protocol;
|
|
}
|
|
|
|
public override dispose(): void {
|
|
if (this._isTerminating) {
|
|
return;
|
|
}
|
|
this._isTerminating = true;
|
|
if (this._protocol) {
|
|
this._protocol.send(createMessageOfType(MessageType.Terminate));
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
getInspectPort(): number | undefined {
|
|
return undefined;
|
|
}
|
|
|
|
enableInspectPort(): Promise<boolean> {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
private async _createExtHostInitData(): Promise<IInitData> {
|
|
const [telemetryInfo, initData] = await Promise.all([this._telemetryService.getTelemetryInfo(), this._initDataProvider.getInitData()]);
|
|
const workspace = this._contextService.getWorkspace();
|
|
return {
|
|
commit: this._productService.commit,
|
|
version: this._productService.version,
|
|
parentPid: -1,
|
|
environment: {
|
|
isExtensionDevelopmentDebug: this._environmentService.debugRenderer,
|
|
appName: this._productService.nameLong,
|
|
appHost: this._productService.embedderIdentifier ?? (platform.isWeb ? 'web' : 'desktop'),
|
|
appUriScheme: this._productService.urlProtocol,
|
|
appLanguage: platform.language,
|
|
extensionDevelopmentLocationURI: this._environmentService.extensionDevelopmentLocationURI,
|
|
extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI,
|
|
globalStorageHome: this._environmentService.globalStorageHome,
|
|
workspaceStorageHome: this._environmentService.workspaceStorageHome,
|
|
},
|
|
workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? undefined : {
|
|
configuration: workspace.configuration || undefined,
|
|
id: workspace.id,
|
|
name: this._labelService.getWorkspaceLabel(workspace),
|
|
transient: workspace.transient
|
|
},
|
|
resolvedExtensions: [],
|
|
hostExtensions: [],
|
|
extensions: initData.extensions,
|
|
telemetryInfo,
|
|
logLevel: this._logService.getLevel(),
|
|
logsLocation: this._extensionHostLogsLocation,
|
|
logFile: this._extensionHostLogFile,
|
|
autoStart: initData.autoStart,
|
|
remote: {
|
|
authority: this._environmentService.remoteAuthority,
|
|
connectionData: null,
|
|
isRemote: false
|
|
},
|
|
uiKind: platform.isWeb ? UIKind.Web : UIKind.Desktop
|
|
};
|
|
}
|
|
}
|