// ***************************************************************************** // Copyright (C) 2018 Red Hat, Inc. and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { RequestOptions, RequestService } from '@theia/core/shared/@theia/request'; import { inject, injectable, named } from '@theia/core/shared/inversify'; import * as cp from 'child_process'; import * as fs from '@theia/core/shared/fs-extra'; import * as net from 'net'; import * as path from 'path'; import URI from '@theia/core/lib/common/uri'; import { ContributionProvider } from '@theia/core/lib/common/contribution-provider'; import { HostedPluginUriPostProcessor, HostedPluginUriPostProcessorSymbolName } from './hosted-plugin-uri-postprocessor'; import { environment, isWindows } from '@theia/core'; import { FileUri } from '@theia/core/lib/common/file-uri'; import { LogType } from '@theia/plugin-ext/lib/common/types'; import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin'; import { MetadataScanner } from '@theia/plugin-ext/lib/hosted/node/metadata-scanner'; import { PluginDebugConfiguration } from '../common/plugin-dev-protocol'; import { HostedPluginProcess } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin-process'; import { isENOENT } from '@theia/plugin-ext/lib/common/errors'; const DEFAULT_HOSTED_PLUGIN_PORT = 3030; export const HostedInstanceManager = Symbol('HostedInstanceManager'); /** * Is responsible for running and handling separate Theia instance with given plugin. */ export interface HostedInstanceManager { /** * Checks whether hosted instance is run. */ isRunning(): boolean; /** * Runs specified by the given uri plugin in separate Theia instance. * * @param pluginUri uri to the plugin source location * @param port port on which new instance of Theia should be run. Optional. * @returns uri where new Theia instance is run */ run(pluginUri: URI, port?: number): Promise; /** * Runs specified by the given uri plugin with debug in separate Theia instance. * @param pluginUri uri to the plugin source location * @param debugConfig debug configuration * @returns uri where new Theia instance is run */ debug(pluginUri: URI, debugConfig: PluginDebugConfiguration): Promise; /** * Terminates hosted plugin instance. * Throws error if instance is not running. */ terminate(): void; /** * Returns uri where hosted instance is run. * Throws error if instance is not running. */ getInstanceURI(): URI; /** * Returns uri where plugin loaded into hosted instance is located. * Throws error if instance is not running. */ getPluginURI(): URI; /** * Checks whether given uri points to a valid plugin. * * @param uri uri to the plugin source location */ isPluginValid(uri: URI): Promise; } const HOSTED_INSTANCE_START_TIMEOUT_MS = 30000; const THEIA_INSTANCE_REGEX = /.*Theia app listening on (.*).*\./; const PROCESS_OPTIONS = { cwd: process.cwd(), env: { ...process.env } }; @injectable() export abstract class AbstractHostedInstanceManager implements HostedInstanceManager { protected hostedInstanceProcess: cp.ChildProcess; protected isPluginRunning: boolean = false; protected instanceUri: URI; protected pluginUri: URI; protected instanceOptions: Omit; @inject(HostedPluginSupport) protected readonly hostedPluginSupport: HostedPluginSupport; @inject(MetadataScanner) protected readonly metadata: MetadataScanner; @inject(HostedPluginProcess) protected readonly hostedPluginProcess: HostedPluginProcess; @inject(RequestService) protected readonly request: RequestService; isRunning(): boolean { return this.isPluginRunning; } async run(pluginUri: URI, port?: number): Promise { return this.doRun(pluginUri, port); } async debug(pluginUri: URI, debugConfig: PluginDebugConfiguration): Promise { return this.doRun(pluginUri, undefined, debugConfig); } private async doRun(pluginUri: URI, port?: number, debugConfig?: PluginDebugConfiguration): Promise { if (this.isPluginRunning) { this.hostedPluginSupport.sendLog({ data: 'Hosted plugin instance is already running.', type: LogType.Info }); throw new Error('Hosted instance is already running.'); } let command: string[]; let processOptions: cp.SpawnOptions; if (pluginUri.scheme === 'file') { processOptions = { ...PROCESS_OPTIONS }; // get filesystem path that work cross operating systems processOptions.env!.HOSTED_PLUGIN = FileUri.fsPath(pluginUri.toString()); // Disable all the other plugins on this instance processOptions.env!.THEIA_PLUGINS = ''; command = await this.getStartCommand(port, debugConfig); } else { throw new Error('Not supported plugin location: ' + pluginUri.toString()); } this.instanceUri = await this.postProcessInstanceUri(await this.runHostedPluginTheiaInstance(command, processOptions)); this.pluginUri = pluginUri; // disable redirect to grab the release this.instanceOptions = { followRedirects: 0 }; this.instanceOptions = await this.postProcessInstanceOptions(this.instanceOptions); await this.checkInstanceUriReady(); return this.instanceUri; } terminate(): void { if (this.isPluginRunning && !!this.hostedInstanceProcess.pid) { this.hostedPluginProcess.killProcessTree(this.hostedInstanceProcess.pid); this.hostedPluginSupport.sendLog({ data: 'Hosted instance has been terminated', type: LogType.Info }); this.isPluginRunning = false; } else { throw new Error('Hosted plugin instance is not running.'); } } getInstanceURI(): URI { if (this.isPluginRunning) { return this.instanceUri; } throw new Error('Hosted plugin instance is not running.'); } getPluginURI(): URI { if (this.isPluginRunning) { return this.pluginUri; } throw new Error('Hosted plugin instance is not running.'); } /** * Checks that the `instanceUri` is responding before exiting method */ public async checkInstanceUriReady(): Promise { return new Promise((resolve, reject) => this.pingLoop(60, resolve, reject)); } /** * Start a loop to ping, if ping is OK return immediately, else start a new ping after 1second. We iterate for the given amount of loops provided in remainingCount * @param remainingCount the number of occurrence to check * @param resolve resolve function if ok * @param reject reject function if error */ private async pingLoop(remainingCount: number, resolve: (value?: void | PromiseLike | undefined | Error) => void, reject: (value?: void | PromiseLike | undefined | Error) => void): Promise { const isOK = await this.ping(); if (isOK) { resolve(); } else { if (remainingCount > 0) { setTimeout(() => this.pingLoop(--remainingCount, resolve, reject), 1000); } else { reject(new Error('Unable to ping the remote server')); } } } /** * Ping the plugin URI (checking status of the head) */ private async ping(): Promise { try { const url = this.instanceUri.toString(); // Wait that the status is OK const response = await this.request.request({ url, type: 'HEAD', ...this.instanceOptions }); return response.res.statusCode === 200; } catch { return false; } } async isPluginValid(uri: URI): Promise { const pckPath = path.join(FileUri.fsPath(uri), 'package.json'); try { const pck = await fs.readJSON(pckPath); this.metadata.getScanner(pck); return true; } catch (err) { if (!isENOENT(err)) { console.error(err); } return false; } } protected async getStartCommand(port?: number, debugConfig?: PluginDebugConfiguration): Promise { const processArguments = process.argv; let command: string[]; if (environment.electron.is()) { command = ['npm', 'run', 'theia', 'start']; } else { command = processArguments.filter((arg, index, args) => { // remove --port=X and --port X arguments if set // remove --plugins arguments if (arg.startsWith('--port') || args[index - 1] === '--port') { return; } else { return arg; } }); } if (process.env.HOSTED_PLUGIN_HOSTNAME) { command.push('--hostname=' + process.env.HOSTED_PLUGIN_HOSTNAME); } if (port) { await this.validatePort(port); command.push('--port=' + port); } if (debugConfig) { if (debugConfig.debugPort === undefined) { command.push(`--hosted-plugin-${debugConfig.debugMode || 'inspect'}=0.0.0.0`); } else if (typeof debugConfig.debugPort === 'string') { command.push(`--hosted-plugin-${debugConfig.debugMode || 'inspect'}=0.0.0.0:${debugConfig.debugPort}`); } else if (Array.isArray(debugConfig.debugPort)) { if (debugConfig.debugPort.length === 0) { // treat empty array just like undefined command.push(`--hosted-plugin-${debugConfig.debugMode || 'inspect'}=0.0.0.0`); } else { for (const serverToPort of debugConfig.debugPort) { command.push(`--${serverToPort.serverName}-${debugConfig.debugMode || 'inspect'}=0.0.0.0:${serverToPort.debugPort}`); } } } } return command; } protected async postProcessInstanceUri(uri: URI): Promise { return uri; } protected async postProcessInstanceOptions(options: Omit): Promise> { return options; } protected runHostedPluginTheiaInstance(command: string[], options: cp.SpawnOptions): Promise { this.isPluginRunning = true; return new Promise((resolve, reject) => { let started = false; const outputListener = (data: string | Buffer) => { const line = data.toString(); const match = THEIA_INSTANCE_REGEX.exec(line); if (match) { this.hostedInstanceProcess.stdout!.removeListener('data', outputListener); started = true; resolve(new URI(match[1])); } }; if (isWindows) { // Has to be set for running on windows (electron). // See also: https://github.com/nodejs/node/issues/3675 options.shell = true; } this.hostedInstanceProcess = cp.spawn(command.shift()!, command, options); this.hostedInstanceProcess.on('error', () => { this.isPluginRunning = false; }); this.hostedInstanceProcess.on('exit', () => { this.isPluginRunning = false; }); this.hostedInstanceProcess.stdout!.addListener('data', outputListener); this.hostedInstanceProcess.stdout!.addListener('data', data => { this.hostedPluginSupport.sendLog({ data: data.toString(), type: LogType.Info }); }); this.hostedInstanceProcess.stderr!.addListener('data', data => { this.hostedPluginSupport.sendLog({ data: data.toString(), type: LogType.Error }); }); setTimeout(() => { if (!started) { this.terminate(); this.isPluginRunning = false; reject(new Error('Timeout.')); } }, HOSTED_INSTANCE_START_TIMEOUT_MS); }); } protected async validatePort(port: number): Promise { if (port < 1 || port > 65535) { throw new Error('Port value is incorrect.'); } if (! await this.isPortFree(port)) { throw new Error('Port ' + port + ' is already in use.'); } } protected isPortFree(port: number): Promise { return new Promise(resolve => { const server = net.createServer(); server.listen(port, '0.0.0.0'); server.on('error', () => { resolve(false); }); server.on('listening', () => { server.close(); resolve(true); }); }); } } @injectable() export class NodeHostedPluginRunner extends AbstractHostedInstanceManager { @inject(ContributionProvider) @named(Symbol.for(HostedPluginUriPostProcessorSymbolName)) protected readonly uriPostProcessors: ContributionProvider; protected override async postProcessInstanceUri(uri: URI): Promise { for (const uriPostProcessor of this.uriPostProcessors.getContributions()) { uri = await uriPostProcessor.processUri(uri); } return uri; } protected override async postProcessInstanceOptions(options: object): Promise { for (const uriPostProcessor of this.uriPostProcessors.getContributions()) { options = await uriPostProcessor.processOptions(options); } return options; } protected override async getStartCommand(port?: number, debugConfig?: PluginDebugConfiguration): Promise { if (!port) { port = process.env.HOSTED_PLUGIN_PORT ? Number(process.env.HOSTED_PLUGIN_PORT) : (debugConfig?.debugPort ? Number(debugConfig.debugPort) : DEFAULT_HOSTED_PLUGIN_PORT); } return super.getStartCommand(port, debugConfig); } } @injectable() export class ElectronNodeHostedPluginRunner extends AbstractHostedInstanceManager { }