diff --git a/x-pack/plugins/code/server/lsp/abstract_launcher.test.ts b/x-pack/plugins/code/server/lsp/abstract_launcher.test.ts new file mode 100644 index 0000000000000..f3b465a002982 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/abstract_launcher.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line max-classes-per-file +import { fork, ChildProcess } from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +import { ServerOptions } from '../server_options'; +import { createTestServerOption } from '../test_utils'; +import { AbstractLauncher } from './abstract_launcher'; +import { RequestExpander } from './request_expander'; +import { LanguageServerProxy } from './proxy'; +import { ConsoleLoggerFactory } from '../utils/console_logger_factory'; +import { Logger } from '../log'; + +jest.setTimeout(10000); + +// @ts-ignore +const options: ServerOptions = createTestServerOption(); + +// a mock function being called when then forked sub process status changes +// @ts-ignore +const mockMonitor = jest.fn(); + +class MockLauncher extends AbstractLauncher { + public childProcess?: ChildProcess; + + constructor(name: string, targetHost: string, opt: ServerOptions) { + super(name, targetHost, opt, new ConsoleLoggerFactory()); + } + + createExpander( + proxy: LanguageServerProxy, + builtinWorkspace: boolean, + maxWorkspace: number + ): RequestExpander { + return new RequestExpander(proxy, builtinWorkspace, maxWorkspace, this.options); + } + + async getPort() { + return 19999; + } + + async spawnProcess(installationPath: string, port: number, log: Logger): Promise { + const childProcess = fork(path.join(__dirname, 'mock_lang_server.js')); + this.childProcess = childProcess; + childProcess.on('message', msg => { + // eslint-disable-next-line no-console + console.log(msg); + mockMonitor(msg); + }); + childProcess.send(`port ${await this.getPort()}`); + childProcess.send(`host ${this.targetHost}`); + childProcess.send('listen'); + return childProcess; + } +} + +class PassiveMockLauncher extends MockLauncher { + constructor( + name: string, + targetHost: string, + opt: ServerOptions, + private dieFirstTime: boolean = false + ) { + super(name, targetHost, opt); + } + + startConnect(proxy: LanguageServerProxy) { + proxy.awaitServerConnection(); + } + + async getPort() { + return 19998; + } + + async spawnProcess(installationPath: string, port: number, log: Logger): Promise { + this.childProcess = fork(path.join(__dirname, 'mock_lang_server.js')); + this.childProcess.on('message', msg => { + // eslint-disable-next-line no-console + console.log(msg); + mockMonitor(msg); + }); + this.childProcess.send(`port ${await this.getPort()}`); + this.childProcess.send(`host ${this.targetHost}`); + if (this.dieFirstTime) { + this.childProcess!.send('quit'); + this.dieFirstTime = false; + } else { + this.childProcess!.send('connect'); + } + return this.childProcess!; + } +} + +beforeAll(async () => { + if (!fs.existsSync(options.workspacePath)) { + fs.mkdirSync(options.workspacePath, { recursive: true }); + fs.mkdirSync(options.jdtWorkspacePath, { recursive: true }); + } +}); + +beforeEach(() => { + mockMonitor.mockClear(); +}); + +function delay(millis: number) { + return new Promise(resolve => { + setTimeout(() => resolve(), millis); + }); +} + +test('launcher can start and end a process', async () => { + const launcher = new MockLauncher('mock', 'localhost', options); + const proxy = await launcher.launch(false, 1, ''); + await delay(100); + expect(mockMonitor.mock.calls[0][0]).toBe('process started'); + expect(mockMonitor.mock.calls[1][0]).toBe('start listening'); + expect(mockMonitor.mock.calls[2][0]).toBe('socket connected'); + await proxy.exit(); + await delay(100); + expect(mockMonitor.mock.calls[3][0]).toMatchObject({ method: 'shutdown' }); + expect(mockMonitor.mock.calls[4][0]).toMatchObject({ method: 'exit' }); + expect(mockMonitor.mock.calls[5][0]).toBe('exit process with code 0'); +}); + +test('launcher can force kill the process if langServer can not exit', async () => { + const launcher = new MockLauncher('mock', 'localhost', options); + const proxy = await launcher.launch(false, 1, ''); + await delay(100); + // set mock lang server to noExist mode + launcher.childProcess!.send('noExit'); + mockMonitor.mockClear(); + await proxy.exit(); + await delay(2000); + expect(mockMonitor.mock.calls[0][0]).toMatchObject({ method: 'shutdown' }); + expect(mockMonitor.mock.calls[1][0]).toMatchObject({ method: 'exit' }); + expect(mockMonitor.mock.calls[2][0]).toBe('noExit'); + expect(launcher.childProcess!.killed).toBe(true); +}); + +test('launcher can reconnect if process died', async () => { + const launcher = new MockLauncher('mock', 'localhost', options); + const proxy = await launcher.launch(false, 1, ''); + await delay(1000); + mockMonitor.mockClear(); + // let the process quit + launcher.childProcess!.send('quit'); + await delay(5000); + // launcher should respawn a new process and connect + expect(mockMonitor.mock.calls[0][0]).toBe('process started'); + expect(mockMonitor.mock.calls[1][0]).toBe('start listening'); + expect(mockMonitor.mock.calls[2][0]).toBe('socket connected'); + await proxy.exit(); + await delay(2000); +}); + +test('passive launcher can start and end a process', async () => { + const launcher = new PassiveMockLauncher('mock', 'localhost', options); + const proxy = await launcher.launch(false, 1, ''); + await delay(100); + expect(mockMonitor.mock.calls[0][0]).toBe('process started'); + expect(mockMonitor.mock.calls[1][0]).toBe('start connecting'); + expect(mockMonitor.mock.calls[2][0]).toBe('socket connected'); + await proxy.exit(); + await delay(100); + expect(mockMonitor.mock.calls[3][0]).toMatchObject({ method: 'shutdown' }); + expect(mockMonitor.mock.calls[4][0]).toMatchObject({ method: 'exit' }); + expect(mockMonitor.mock.calls[5][0]).toBe('exit process with code 0'); +}); + +test('passive launcher should restart a process if a process died before connected', async () => { + const launcher = new PassiveMockLauncher('mock', 'localhost', options, true); + const proxy = await launcher.launch(false, 1, ''); + await delay(100); + expect(mockMonitor.mock.calls[0][0]).toBe('process started'); + expect(mockMonitor.mock.calls[1][0]).toBe('process started'); + expect(mockMonitor.mock.calls[2][0]).toBe('start connecting'); + expect(mockMonitor.mock.calls[3][0]).toBe('socket connected'); + await proxy.exit(); + await delay(1000); +}); diff --git a/x-pack/plugins/code/server/lsp/abstract_launcher.ts b/x-pack/plugins/code/server/lsp/abstract_launcher.ts new file mode 100644 index 0000000000000..5a946f8126229 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/abstract_launcher.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ChildProcess } from 'child_process'; +import { ILanguageServerLauncher } from './language_server_launcher'; +import { ServerOptions } from '../server_options'; +import { LoggerFactory } from '../utils/log_factory'; +import { Logger } from '../log'; +import { LanguageServerProxy } from './proxy'; +import { RequestExpander } from './request_expander'; + +export abstract class AbstractLauncher implements ILanguageServerLauncher { + running: boolean = false; + private _currentPid: number = -1; + private child: ChildProcess | null = null; + private _startTime: number = -1; + private _proxyConnected: boolean = false; + protected constructor( + readonly name: string, + readonly targetHost: string, + readonly options: ServerOptions, + readonly loggerFactory: LoggerFactory + ) {} + + public async launch(builtinWorkspace: boolean, maxWorkspace: number, installationPath: string) { + const port = await this.getPort(); + const log: Logger = this.loggerFactory.getLogger([ + 'code', + `${this.name}@${this.targetHost}:${port}`, + ]); + let child: ChildProcess; + const proxy = new LanguageServerProxy(port, this.targetHost, log, this.options.lsp); + if (this.options.lsp.detach) { + log.debug('Detach mode, expected language server launch externally'); + proxy.onConnected(() => { + this.running = true; + }); + proxy.onDisconnected(() => { + this.running = false; + if (!proxy.isClosed) { + log.debug(`${this.name} language server disconnected, reconnecting`); + setTimeout(() => this.reconnect(proxy, installationPath, port, log), 1000); + } + }); + } else { + child = await this.spawnProcess(installationPath, port, log); + this.child = child; + log.debug('spawned a child process ' + child.pid); + this._currentPid = child.pid; + this._startTime = Date.now(); + this.running = true; + this.onProcessExit(child, () => this.reconnect(proxy, installationPath, port, log)); + proxy.onDisconnected(async () => { + this._proxyConnected = true; + if (!proxy.isClosed) { + log.debug('proxy disconnected, reconnecting'); + setTimeout(async () => { + await this.reconnect(proxy, installationPath, port, log, child); + }, 1000); + } else if (this.child) { + log.info('proxy closed, kill process'); + await this.killProcess(this.child, log); + } + }); + } + proxy.onExit(() => { + log.debug('proxy exited, is the process running? ' + this.running); + if (this.child && this.running) { + const p = this.child!; + setTimeout(async () => { + if (!p.killed) { + log.debug('killing the process after 1s'); + await this.killProcess(p, log); + } + }, 1000); + } + }); + proxy.listen(); + this.startConnect(proxy); + await new Promise(resolve => { + proxy.onConnected(() => { + this._proxyConnected = true; + resolve(); + }); + }); + return this.createExpander(proxy, builtinWorkspace, maxWorkspace); + } + + private onProcessExit(child: ChildProcess, reconnectFn: () => void) { + const pid = child.pid; + child.on('exit', () => { + if (this._currentPid === pid) { + this.running = false; + // if the process exited before proxy connected, then we reconnect + if (!this._proxyConnected) { + reconnectFn(); + } + } + }); + } + + /** + * proxy should be connected within this timeout, otherwise we reconnect. + */ + protected startupTimeout = 3000; + + /** + * try reconnect the proxy when disconnected + */ + public async reconnect( + proxy: LanguageServerProxy, + installationPath: string, + port: number, + log: Logger, + child?: ChildProcess + ) { + log.debug('reconnecting'); + if (this.options.lsp.detach) { + this.startConnect(proxy); + } else { + const processExpired = () => Date.now() - this._startTime > this.startupTimeout; + if (child && !child.killed && !processExpired()) { + this.startConnect(proxy); + } else { + if (child && this.running) { + log.debug('killing the old process.'); + await this.killProcess(child, log); + } + this.child = await this.spawnProcess(installationPath, port, log); + log.debug('spawned a child process ' + this.child.pid); + this._currentPid = this.child.pid; + this._startTime = Date.now(); + this.running = true; + this.onProcessExit(this.child, () => + this.reconnect(proxy, installationPath, port, log, child) + ); + this.startConnect(proxy); + } + } + } + + abstract async getPort(): Promise; + + startConnect(proxy: LanguageServerProxy) { + proxy.connect(); + } + + /** + * await for proxy connected, create a request expander + * @param proxy + */ + abstract createExpander( + proxy: LanguageServerProxy, + builtinWorkspace: boolean, + maxWorkspace: number + ): RequestExpander; + + abstract async spawnProcess( + installationPath: string, + port: number, + log: Logger + ): Promise; + + private killProcess(child: ChildProcess, log: Logger) { + if (!child.killed) { + return new Promise((resolve, reject) => { + // if not killed within 1s + const t = setTimeout(reject, 1000); + child.on('exit', () => { + clearTimeout(t); + resolve(true); + }); + child.kill(); + log.info('killed process ' + child.pid); + }) + .catch(() => { + // force kill + child.kill('SIGKILL'); + log.info('force killed process ' + child.pid); + return child.killed; + }) + .finally(() => { + if (this._currentPid === child.pid) this.running = false; + }); + } + } +} diff --git a/x-pack/plugins/code/server/lsp/java_launcher.ts b/x-pack/plugins/code/server/lsp/java_launcher.ts index a1ef38c485374..6b094451719df 100644 --- a/x-pack/plugins/code/server/lsp/java_launcher.ts +++ b/x-pack/plugins/code/server/lsp/java_launcher.ts @@ -13,74 +13,41 @@ import path from 'path'; import { Logger } from '../log'; import { ServerOptions } from '../server_options'; import { LoggerFactory } from '../utils/log_factory'; -import { ILanguageServerLauncher } from './language_server_launcher'; import { LanguageServerProxy } from './proxy'; import { RequestExpander } from './request_expander'; +import { AbstractLauncher } from './abstract_launcher'; -export class JavaLauncher implements ILanguageServerLauncher { - private isRunning: boolean = false; +const JAVA_LANG_DETACH_PORT = 2090; + +export class JavaLauncher extends AbstractLauncher { private needModuleArguments: boolean = true; - constructor( + public constructor( readonly targetHost: string, readonly options: ServerOptions, readonly loggerFactory: LoggerFactory - ) {} - public get running(): boolean { - return this.isRunning; + ) { + super('java', targetHost, options, loggerFactory); } - public async launch(builtinWorkspace: boolean, maxWorkspace: number, installationPath: string) { - let port = 2090; + createExpander(proxy: LanguageServerProxy, builtinWorkspace: boolean, maxWorkspace: number) { + return new RequestExpander(proxy, builtinWorkspace, maxWorkspace, this.options, { + settings: { + 'java.import.gradle.enabled': this.options.security.enableGradleImport, + 'java.import.maven.enabled': this.options.security.enableMavenImport, + 'java.autobuild.enabled': false, + }, + }); + } - if (!this.options.lsp.detach) { - port = await getPort(); - } - const log = this.loggerFactory.getLogger(['code', `java@${this.targetHost}:${port}`]); - const proxy = new LanguageServerProxy(port, this.targetHost, log, this.options.lsp); + startConnect(proxy: LanguageServerProxy) { proxy.awaitServerConnection(); - if (this.options.lsp.detach) { - // detach mode - proxy.onConnected(() => { - this.isRunning = true; - }); - proxy.onDisconnected(() => { - this.isRunning = false; - if (!proxy.isClosed) { - proxy.awaitServerConnection(); - } - }); - } else { - let child = await this.spawnJava(installationPath, port, log); - proxy.onDisconnected(async () => { - if (!proxy.isClosed) { - child.kill(); - proxy.awaitServerConnection(); - log.warn('language server disconnected, restarting it'); - child = await this.spawnJava(installationPath, port, log); - } else { - child.kill(); - } - }); - proxy.onExit(() => { - if (child) { - child.kill(); - } - }); + } + + async getPort(): Promise { + if (!this.options.lsp.detach) { + return await getPort(); } - proxy.listen(); - return new Promise(resolve => { - proxy.onConnected(() => { - resolve( - new RequestExpander(proxy, builtinWorkspace, maxWorkspace, this.options, { - settings: { - 'java.import.gradle.enabled': this.options.security.enableGradleImport, - 'java.import.maven.enabled': this.options.security.enableMavenImport, - 'java.autobuild.enabled': false, - }, - }) - ); - }); - }); + return JAVA_LANG_DETACH_PORT; } private async getJavaHome(installationPath: string, log: Logger) { @@ -132,7 +99,7 @@ export class JavaLauncher implements ILanguageServerLauncher { return bundledJavaHome; } - private async spawnJava(installationPath: string, port: number, log: Logger) { + async spawnProcess(installationPath: string, port: number, log: Logger) { const launchersFound = glob.sync('**/plugins/org.eclipse.equinox.launcher_*.jar', { cwd: installationPath, }); @@ -192,8 +159,6 @@ export class JavaLauncher implements ILanguageServerLauncher { p.stderr.on('data', data => { log.stderr(data.toString()); }); - this.isRunning = true; - p.on('exit', () => (this.isRunning = false)); log.info( `Launch Java Language Server at port ${port.toString()}, pid:${ p.pid diff --git a/x-pack/plugins/code/server/lsp/mock_lang_server.js b/x-pack/plugins/code/server/lsp/mock_lang_server.js new file mode 100644 index 0000000000000..656f2face70b0 --- /dev/null +++ b/x-pack/plugins/code/server/lsp/mock_lang_server.js @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable */ +// This file is used in the test, using subprocess.fork to load a module directly, so this module can not be typescript +const net = require('net'); +const jsonrpc = require('vscode-jsonrpc'); +const createMessageConnection = jsonrpc.createMessageConnection; +const SocketMessageReader = jsonrpc.SocketMessageReader; +const SocketMessageWriter = jsonrpc.SocketMessageWriter; + +function log(msg) { + if (process.send) { + process.send(msg); + } + else { + // eslint-disable-next-line no-console + console.log(msg); + } +} +class MockLangServer { + constructor(host, port) { + this.host = host; + this.port = port; + this.socket = null; + this.connection = null; + this.shutdown = false; + } + /** + * connect remote server as a client + */ + connect() { + this.socket = new net.Socket(); + this.socket.on('connect', () => { + const reader = new SocketMessageReader(this.socket); + const writer = new SocketMessageWriter(this.socket); + this.connection = createMessageConnection(reader, writer); + this.connection.listen(); + this.connection.onNotification(this.onNotification.bind(this)); + this.connection.onRequest(this.onRequest.bind(this)); + log('socket connected'); + }); + this.socket.on('close', () => this.onSocketClosed()); + log('start connecting'); + this.socket.connect(this.port, this.host); + } + listen() { + const server = net.createServer(socket => { + server.close(); + socket.on('close', () => this.onSocketClosed()); + const reader = new SocketMessageReader(socket); + const writer = new SocketMessageWriter(socket); + this.connection = createMessageConnection(reader, writer); + this.connection.onNotification(this.onNotification.bind(this)); + this.connection.onRequest(this.onRequest.bind(this)); + this.connection.listen(); + log('socket connected'); + }); + server.on('error', err => { + log(err); + }); + log('start listening'); + server.listen(this.port); + } + onNotification(method, ...params) { + log({ method, params }); + // notify parent process what happened + if (method === 'exit') { + // https://microsoft.github.io/language-server-protocol/specification#exit + if (options.noExit) { + log('noExit'); + } + else { + const code = this.shutdown ? 0 : 1; + log(`exit process with code ${code}`); + process.exit(code); + } + } + } + onRequest(method, ...params) { + // notify parent process what requested + log({ method, params }); + if (method === 'shutdown') { + this.shutdown = true; + } + return { result: 'ok' }; + } + onSocketClosed() { + // notify parent process that socket closed + log('socket closed'); + } +} +log('process started'); +let port = 9999; +let host = ' localhost'; +const options = { noExit: false }; +let langServer; +process.on('message', (msg) => { + const [cmd, value] = msg.split(' '); + switch (cmd) { + case 'port': + port = parseInt(value, 10); + break; + case 'host': + host = value; + break; + case 'noExit': + options.noExit = true; + break; + case 'listen': + langServer = new MockLangServer(host, port); + langServer.listen(); + break; + case 'connect': + langServer = new MockLangServer(host, port); + langServer.connect(); + break; + case 'quit': + process.exit(0); + break; + default: + // nothing to do + } +}); diff --git a/x-pack/plugins/code/server/lsp/proxy.ts b/x-pack/plugins/code/server/lsp/proxy.ts index d3e94f1e0e7e4..7b26bc350f50f 100644 --- a/x-pack/plugins/code/server/lsp/proxy.ts +++ b/x-pack/plugins/code/server/lsp/proxy.ts @@ -58,8 +58,9 @@ export class LanguageServerProxy implements ILanguageServerHandler { private readonly logger: Logger; private readonly lspOptions: LspOptions; private eventEmitter = new EventEmitter(); + private passiveConnection: boolean = false; - private connectingPromise?: Promise; + private connectingPromise: Promise | null = null; constructor(targetPort: number, targetHost: string, logger: Logger, lspOptions: LspOptions) { this.targetHost = targetHost; @@ -103,7 +104,7 @@ export class LanguageServerProxy implements ILanguageServerHandler { workspaceFolders: [WorkspaceFolder], initOptions?: object ): Promise { - const clientConn = await this.connect(); + const clientConn = await this.tryConnect(); const rootUri = workspaceFolders[0].uri; const params = { processId: null, @@ -135,7 +136,7 @@ export class LanguageServerProxy implements ILanguageServerHandler { this.logger.debug('received request method: ' + method); } - return this.connect().then(clientConn => { + return this.tryConnect().then(clientConn => { if (this.lspOptions.verbose) { this.logger.info(`proxy method:${method} to Language Server `); } else { @@ -149,7 +150,7 @@ export class LanguageServerProxy implements ILanguageServerHandler { } public async shutdown() { - const clientConn = await this.connect(); + const clientConn = await this.tryConnect(); this.logger.info(`sending shutdown request`); return await clientConn.sendRequest('shutdown'); } @@ -175,28 +176,33 @@ export class LanguageServerProxy implements ILanguageServerHandler { } public awaitServerConnection() { - return new Promise((res, rej) => { - const server = net.createServer(socket => { - this.initialized = false; - server.close(); - this.eventEmitter.emit('connect'); - socket.on('close', () => this.onSocketClosed()); + // prevent calling this method multiple times which may cause 'port already in use' error + if (!this.connectingPromise) { + this.passiveConnection = true; + this.connectingPromise = new Promise((res, rej) => { + const server = net.createServer(socket => { + this.initialized = false; + server.close(); + this.eventEmitter.emit('connect'); + socket.on('close', () => this.onSocketClosed()); - this.logger.info('Java langserver connection established on port ' + this.targetPort); + this.logger.info('langserver connection established on port ' + this.targetPort); - const reader = new SocketMessageReader(socket); - const writer = new SocketMessageWriter(socket); - this.clientConnection = createMessageConnection(reader, writer, this.logger); - this.registerOnNotificationHandler(this.clientConnection); - this.clientConnection.listen(); - res(this.clientConnection); - }); - server.on('error', rej); - server.listen(this.targetPort, () => { - server.removeListener('error', rej); - this.logger.info('Wait Java langserver connection on port ' + this.targetPort); + const reader = new SocketMessageReader(socket); + const writer = new SocketMessageWriter(socket); + this.clientConnection = createMessageConnection(reader, writer, this.logger); + this.registerOnNotificationHandler(this.clientConnection); + this.clientConnection.listen(); + res(this.clientConnection); + }); + server.on('error', rej); + server.listen(this.targetPort, () => { + server.removeListener('error', rej); + this.logger.info('Wait langserver connection on port ' + this.targetPort); + }); }); - }); + } + return this.connectingPromise; } /** @@ -225,7 +231,7 @@ export class LanguageServerProxy implements ILanguageServerHandler { } this.closed = false; if (!this.connectingPromise) { - this.connectingPromise = new Promise((resolve, reject) => { + this.connectingPromise = new Promise(resolve => { this.socket = new net.Socket(); this.socket.on('connect', () => { @@ -247,7 +253,6 @@ export class LanguageServerProxy implements ILanguageServerHandler { this.targetPort, this.targetHost ); - this.onDisconnected(() => setTimeout(() => this.reconnect(), 1000)); }); } return this.connectingPromise; @@ -257,20 +262,12 @@ export class LanguageServerProxy implements ILanguageServerHandler { return Promise.reject('should not hit here'); } - private reconnect() { - if (!this.isClosed) { - this.socket.connect( - this.targetPort, - this.targetHost - ); - } - } - private onSocketClosed() { if (this.clientConnection) { this.clientConnection.dispose(); } this.clientConnection = null; + this.connectingPromise = null; this.eventEmitter.emit('close'); } @@ -305,4 +302,8 @@ export class LanguageServerProxy implements ILanguageServerHandler { } }); } + + private tryConnect() { + return this.passiveConnection ? this.awaitServerConnection() : this.connect(); + } } diff --git a/x-pack/plugins/code/server/lsp/ts_launcher.ts b/x-pack/plugins/code/server/lsp/ts_launcher.ts index e8195ce856694..c6453cf7441f8 100644 --- a/x-pack/plugins/code/server/lsp/ts_launcher.ts +++ b/x-pack/plugins/code/server/lsp/ts_launcher.ts @@ -4,109 +4,60 @@ * you may not use this file except in compliance with the Elastic License. */ -import { spawn } from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import getPort from 'get-port'; import { resolve } from 'path'; import { Logger } from '../log'; import { ServerOptions } from '../server_options'; import { LoggerFactory } from '../utils/log_factory'; -import { ILanguageServerLauncher } from './language_server_launcher'; import { LanguageServerProxy } from './proxy'; import { RequestExpander } from './request_expander'; +import { AbstractLauncher } from './abstract_launcher'; -export class TypescriptServerLauncher implements ILanguageServerLauncher { - private isRunning: boolean = false; - constructor( +const TS_LANG_DETACH_PORT = 2089; + +export class TypescriptServerLauncher extends AbstractLauncher { + public constructor( readonly targetHost: string, readonly options: ServerOptions, readonly loggerFactory: LoggerFactory - ) {} - - public get running(): boolean { - return this.isRunning; + ) { + super('typescript', targetHost, options, loggerFactory); } - public async launch(builtinWorkspace: boolean, maxWorkspace: number, installationPath: string) { - let port = 2089; - + async getPort() { if (!this.options.lsp.detach) { - port = await getPort(); + return await getPort(); } - const log: Logger = this.loggerFactory.getLogger(['code', `ts@${this.targetHost}:${port}`]); - const proxy = new LanguageServerProxy(port, this.targetHost, log, this.options.lsp); + return TS_LANG_DETACH_PORT; + } - if (this.options.lsp.detach) { - log.info('Detach mode, expected langserver launch externally'); - proxy.onConnected(() => { - this.isRunning = true; - }); - proxy.onDisconnected(() => { - this.isRunning = false; - if (!proxy.isClosed) { - log.warn('language server disconnected, reconnecting'); - setTimeout(() => proxy.connect(), 1000); - } - }); - } else { - const spawnTs = () => { - const p = spawn( - 'node', - ['--max_old_space_size=4096', installationPath, '-p', port.toString(), '-c', '1'], - { - detached: false, - stdio: 'pipe', - cwd: resolve(installationPath, '../..'), - } - ); - p.stdout.on('data', data => { - log.stdout(data.toString()); - }); - p.stderr.on('data', data => { - log.stderr(data.toString()); - }); - this.isRunning = true; - p.on('exit', () => (this.isRunning = false)); - return p; - }; - let child = spawnTs(); - log.info(`Launch Typescript Language Server at port ${port}, pid:${child.pid}`); - // TODO: how to properly implement timeout socket connection? maybe config during socket connection - // const reconnect = () => { - // log.debug('reconnecting'); - // promiseTimeout(3000, proxy.connect()).then( - // () => { - // log.info('connected'); - // }, - // () => { - // log.error('unable to connect within 3s, respawn ts server.'); - // child.kill(); - // child = spawnTs(); - // setTimeout(reconnect, 1000); - // } - // ); - // }; - proxy.onDisconnected(() => { - if (!proxy.isClosed) { - log.info('waiting language server to be connected'); - if (!this.isRunning) { - log.error('detect language server killed, respawn ts server.'); - child = spawnTs(); - } - } else { - child.kill(); - } - }); - proxy.onExit(() => { - if (child) { - child.kill(); - } - }); - } - proxy.listen(); - await proxy.connect(); + createExpander( + proxy: LanguageServerProxy, + builtinWorkspace: boolean, + maxWorkspace: number + ): RequestExpander { return new RequestExpander(proxy, builtinWorkspace, maxWorkspace, this.options, { installNodeDependency: this.options.security.installNodeDependency, gitHostWhitelist: this.options.security.gitHostWhitelist, }); } + async spawnProcess(installationPath: string, port: number, log: Logger): Promise { + const p = spawn( + 'node', + ['--max_old_space_size=4096', installationPath, '-p', port.toString(), '-c', '1'], + { + detached: false, + stdio: 'pipe', + cwd: resolve(installationPath, '../..'), + } + ); + p.stdout.on('data', data => { + log.stdout(data.toString()); + }); + p.stderr.on('data', data => { + log.stderr(data.toString()); + }); + return p; + } }