diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index 8d05f291a..83db34fc1 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -10,7 +10,9 @@ import * as vscode from 'vscode'; import * as lsclient from 'vscode-languageclient/node'; import * as proto from 'vscode-languageserver-protocol'; import { MemoryFileSystemProvider } from './memoryFileSystemProvider'; -import { vsdiag, DiagnosticProviderMiddleware } from 'vscode-languageclient'; +import { vsdiag, DiagnosticProviderMiddleware, LanguageClient } from 'vscode-languageclient'; +import { IsDetachedRequest } from './servers/types'; +import { afterEach } from 'mocha'; namespace GotNotifiedRequest { export const method: 'testing/gotNotified' = 'testing/gotNotified'; @@ -1961,6 +1963,60 @@ class CrashClient extends lsclient.LanguageClient { } suite('Server tests', () => { + suite('detached', async function () { + const detachedServerModule = path.join(__dirname, 'servers', 'detachedServer.js'); + + let client: LanguageClient | undefined; + + afterEach(async function () { + await client?.stop(); + }); + + test('servers are NOT detached by default', async () => { + const serverOptions: lsclient.ServerOptions = { + module: detachedServerModule, + transport: lsclient.TransportKind.ipc, + }; + const client = new lsclient.LanguageClient('test svr', serverOptions, {}); + const res = await client.sendRequest(IsDetachedRequest); + assert.deepStrictEqual(res, { detached: false }); + }); + + [lsclient.TransportKind.stdio, lsclient.TransportKind.ipc, lsclient.TransportKind.pipe].forEach((transport) => { + test(`server detects it is detached using Node ServerOptions when transport: ${transport}`, async () => { + const serverOptions: lsclient.ServerOptions = { + module: detachedServerModule, + transport, + options: { + detached: true, + detachedTimeout: 1000 + } + }; + const client = new lsclient.LanguageClient('test svr', serverOptions, {}); + const res = await client.sendRequest(IsDetachedRequest); + assert.deepStrictEqual(res, { detached: true, timeout: 1000 }); + }); + }); + + [lsclient.TransportKind.stdio, lsclient.TransportKind.pipe].forEach((transport) => { + test(`server detects it is detached using Executable ServerOptions when transport: ${transport}`, async () => { + const serverOptions: lsclient.ServerOptions = { + command: 'node', // making assumption this exists in the PATH of the test environment + args: [detachedServerModule], + transport, + options: { + detached: true, + detachedTimeout: 1001 + } + }; + const client = new lsclient.LanguageClient('test svr', serverOptions, {}); + const res = await client.sendRequest(IsDetachedRequest); + assert.deepStrictEqual(res, { detached: true, timeout: 1001 }); + }); + }); + + }); + test('Stop fails if server crashes after shutdown request', async () => { const serverOptions: lsclient.ServerOptions = { module: path.join(__dirname, './servers/crashOnShutdownServer.js'), diff --git a/client-node-tests/src/servers/detachedServer.ts b/client-node-tests/src/servers/detachedServer.ts new file mode 100644 index 000000000..a3fd4452b --- /dev/null +++ b/client-node-tests/src/servers/detachedServer.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Server that allows the client to request detached status of the server + */ + +import { createConnection, ProposedFeatures } from 'vscode-languageserver/node'; +import { IsDetachedRequest } from './types'; +import { parseCliOpts } from 'vscode-languageserver/utils'; + +const connection = createConnection(ProposedFeatures.all); + +connection.onRequest(IsDetachedRequest, () => { + const args = parseCliOpts(process.argv); + const detached = Object.keys(args).includes('detached'); + const timeout = args['detached']; + return { detached, timeout }; +}); + +// Initialize the language server connection +connection.onInitialize(() => { + return { + capabilities: {} + }; +}); + +connection.listen(); \ No newline at end of file diff --git a/client-node-tests/src/servers/types.ts b/client-node-tests/src/servers/types.ts new file mode 100644 index 000000000..5bd7af1be --- /dev/null +++ b/client-node-tests/src/servers/types.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RequestType0 } from 'vscode-languageserver'; + +export const IsDetachedRequest = new RequestType0<{detached: boolean; timeout: number }, void>('isDetached'); diff --git a/client/src/node/main.ts b/client/src/node/main.ts index de004cab3..f92ecbe15 100644 --- a/client/src/node/main.ts +++ b/client/src/node/main.ts @@ -57,10 +57,18 @@ namespace Transport { } } -export interface ExecutableOptions { +export interface DetachedOptions { + detached?: boolean; + /** + * Maximum milliseconds to keep the server alive after the parent/client terminates. + * If undefined it will never terminate. + */ + detachedTimeout?: number; +} + +export interface ExecutableOptions extends DetachedOptions { cwd?: string; env?: any; - detached?: boolean; shell?: boolean; } @@ -77,7 +85,7 @@ namespace Executable { } } -export interface ForkOptions { +export interface ForkOptions extends DetachedOptions { cwd?: string; env?: any; encoding?: string; @@ -130,6 +138,7 @@ export class LanguageClient extends BaseLanguageClient { private readonly _serverOptions: ServerOptions; private readonly _forceDebug: boolean; private _serverProcess: ChildProcess | undefined; + /** Use {@link _setDetached} to set the value unless necessary */ private _isDetached: boolean | undefined; private _isInDebugMode: boolean; @@ -285,20 +294,22 @@ export class LanguageClient extends BaseLanguageClient { if (Is.func(server)) { return server().then((result) => { if (MessageTransports.is(result)) { - this._isDetached = !!result.detached; + this._setDetached(!!result.detached); return result; } else if (StreamInfo.is(result)) { - this._isDetached = !!result.detached; + this._setDetached(!!result.detached); return { reader: new StreamMessageReader(result.reader), writer: new StreamMessageWriter(result.writer) }; } else { let cp: ChildProcess; + let isDetached; if (ChildProcessInfo.is(result)) { cp = result.process; - this._isDetached = result.detached; + isDetached = result.detached; } else { cp = result; - this._isDetached = false; + isDetached = false; } + this._setDetached(isDetached, cp); cp.stderr!.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); return { reader: new StreamMessageReader(cp.stdout!), writer: new StreamMessageWriter(cp.stdin!) }; } @@ -348,6 +359,12 @@ export class LanguageClient extends BaseLanguageClient { } else if (Transport.isSocket(transport)) { args.push(`--socket=${transport.port}`); } + if (node.options?.detached) { + args.push('--detached'); + if (node.options.detachedTimeout) { + args.push(node.options.detachedTimeout.toString()); + } + } args.push(`--clientProcessId=${process.pid.toString()}`); if (transport === TransportKind.ipc || transport === TransportKind.stdio) { const serverProcess = cp.spawn(runtime, args, execOptions); @@ -355,6 +372,7 @@ export class LanguageClient extends BaseLanguageClient { return handleChildProcessStartError(serverProcess, `Launching server using runtime ${runtime} failed.`); } this._serverProcess = serverProcess; + this._setDetached(!!node.options?.detached, serverProcess); serverProcess.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); if (transport === TransportKind.ipc) { serverProcess.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); @@ -369,6 +387,7 @@ export class LanguageClient extends BaseLanguageClient { return handleChildProcessStartError(process, `Launching server using runtime ${runtime} failed.`); } this._serverProcess = process; + this._setDetached(!!node.options?.detached, process); process.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); process.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); return transport.onConnected().then((protocol) => { @@ -382,6 +401,7 @@ export class LanguageClient extends BaseLanguageClient { return handleChildProcessStartError(process, `Launching server using runtime ${runtime} failed.`); } this._serverProcess = process; + this._setDetached(!!node.options?.detached, process); process.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); process.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); return transport.onConnected().then((protocol) => { @@ -404,6 +424,12 @@ export class LanguageClient extends BaseLanguageClient { args.push(`--socket=${transport.port}`); } args.push(`--clientProcessId=${process.pid.toString()}`); + if (node.options?.detached) { + args.push('--detached'); + if (node.options.detachedTimeout) { + args.push(node.options.detachedTimeout.toString()); + } + } const options: cp.ForkOptions = node.options ?? Object.create(null); options.env = getEnvironment(options.env, true); options.execArgv = options.execArgv || []; @@ -413,6 +439,7 @@ export class LanguageClient extends BaseLanguageClient { const sp = cp.fork(node.module, args || [], options); assertStdio(sp); this._serverProcess = sp; + this._setDetached(!!options.detached, sp); sp.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); if (transport === TransportKind.ipc) { sp.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); @@ -425,6 +452,7 @@ export class LanguageClient extends BaseLanguageClient { const sp = cp.fork(node.module, args || [], options); assertStdio(sp); this._serverProcess = sp; + this._setDetached(!!options.detached, sp); sp.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); sp.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); transport.onConnected().then((protocol) => { @@ -436,6 +464,7 @@ export class LanguageClient extends BaseLanguageClient { const sp = cp.fork(node.module, args || [], options); assertStdio(sp); this._serverProcess = sp; + this._setDetached(!!options.detached, sp); sp.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); sp.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); transport.onConnected().then((protocol) => { @@ -460,6 +489,12 @@ export class LanguageClient extends BaseLanguageClient { } else if (transport === TransportKind.ipc) { throw new Error(`Transport kind ipc is not support for command executable`); } + if (command.options?.detached) { + args.push('--detached'); + if (command.options.detachedTimeout) { + args.push(command.options.detachedTimeout.toString()); + } + } const options = Object.assign({}, command.options); options.cwd = options.cwd || serverWorkingDir; if (transport === undefined || transport === TransportKind.stdio) { @@ -469,7 +504,7 @@ export class LanguageClient extends BaseLanguageClient { } serverProcess.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); this._serverProcess = serverProcess; - this._isDetached = !!options.detached; + this._setDetached(!!options.detached, serverProcess); return Promise.resolve({ reader: new StreamMessageReader(serverProcess.stdout), writer: new StreamMessageWriter(serverProcess.stdin) }); } else if (transport === TransportKind.pipe) { return createClientPipeTransport(pipeName!).then((transport) => { @@ -478,7 +513,7 @@ export class LanguageClient extends BaseLanguageClient { return handleChildProcessStartError(serverProcess, `Launching server using command ${command.command} failed.`); } this._serverProcess = serverProcess; - this._isDetached = !!options.detached; + this._setDetached(!!options.detached, serverProcess); serverProcess.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); serverProcess.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); return transport.onConnected().then((protocol) => { @@ -492,7 +527,7 @@ export class LanguageClient extends BaseLanguageClient { return handleChildProcessStartError(serverProcess, `Launching server using command ${command.command} failed.`); } this._serverProcess = serverProcess; - this._isDetached = !!options.detached; + this._setDetached(!!options.detached, serverProcess); serverProcess.stderr.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); serverProcess.stdout.on('data', data => this.outputChannel.append(Is.string(data) ? data : data.toString(encoding))); return transport.onConnected().then((protocol) => { @@ -568,6 +603,21 @@ export class LanguageClient extends BaseLanguageClient { return Promise.resolve(undefined); } + /** + * Setter that should be used for {@link _isDetached}. It performs additional + * actions based on the value set for detached. + */ + private _setDetached(isDetached: boolean, serverProcess?: cp.ChildProcess) { + if (!isDetached) { + this._isDetached = false; + return; + } + + this._isDetached = true; + // Ensures the parent can exit even if the child (server) is still running + serverProcess?.unref(); + } + } export class SettingMonitor { diff --git a/server/package.json b/server/package.json index 1d9e2f8c0..ab4370082 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,10 @@ "./browser": { "types": "./lib/browser/main.d.ts", "browser": "./lib/browser/main.js" + }, + "./utils": { + "types": "./lib/common/utils/main.d.ts", + "default": "./lib/common/utils/main.js" } }, "bin": { diff --git a/server/src/common/utils/main.ts b/server/src/common/utils/main.ts new file mode 100644 index 000000000..287c4157d --- /dev/null +++ b/server/src/common/utils/main.ts @@ -0,0 +1,6 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +export * from './process'; \ No newline at end of file diff --git a/server/src/common/utils/process.ts b/server/src/common/utils/process.ts new file mode 100644 index 000000000..755605828 --- /dev/null +++ b/server/src/common/utils/process.ts @@ -0,0 +1,73 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +type CliOptValue = string | number | boolean | undefined; +/** + * Parses an array of command-line opts into a map of key-value pairs. + * + * This currently supports args in the formats: + * + * ``` + * --key=value + * --key value + * --key + * ``` + * + * Example: + * ``` + * parseCliOpts(['positionalArg', '--enable-logging', '--log-level=info']); + * // Result: { 'enable-logging': undefined, 'log-level': 'info' } + * ``` + * + * @param opts - An array of command-line arguments. + * @returns A map of key-value pairs parsed from the arguments. Leading '--' are removed from the + * keys of the final result. + */ +export function parseCliOpts(opts: string[]): { [key: string]: CliOptValue } { + const result: { [key: string]: CliOptValue } = {}; + + for (let i = 0; i < opts.length; i++) { + const arg = opts[i]; + + // Check if argument starts with -- + if (arg.startsWith('--')) { + const key = arg.slice(2); // Remove -- + + // Handle '--key=value' format + if (key.includes('=')) { + const [actualKey, value] = key.split('='); + result[actualKey] = tryParseValue(value); + continue; + } + + // Handle '--key value' format or '--key' (flag without value) + const nextArg = opts[i + 1]; + if (nextArg && !nextArg.startsWith('--')) { + result[key] = tryParseValue(nextArg); + i++; // Skip the next argument since we used it as a value + } else { + result[key] = undefined; + } + } + } + + return result; + + /** Attempts to parse the string value in to its primitive */ + function tryParseValue(value: string): Exclude { + const valueNormalized = value.toLowerCase(); + if (valueNormalized === 'true') { + return true; + } + if (valueNormalized === 'false') { + return false; + } + const num = Number(value); + if (!isNaN(num)) { + return num; + } + return value; + } +} diff --git a/server/src/node/main.ts b/server/src/node/main.ts index 72d4b2425..d7c480d7c 100644 --- a/server/src/node/main.ts +++ b/server/src/node/main.ts @@ -7,6 +7,7 @@ import { inspect } from 'node:util'; import * as Is from '../common/utils/is'; +import { parseCliOpts } from '../common/utils/process'; import { Connection, _, _Connection, Features, WatchDog, createConnection as createCommonConnection } from '../common/server'; import * as fm from './files'; @@ -26,8 +27,60 @@ export namespace Files { export const resolveModulePath = fm.resolveModulePath; } +let _isDetached: boolean | undefined = undefined; + +/** + * @returns {boolean} True if the process is detached from its parent, false otherwise. + * + * @description + * When a process is detached, it will continue running even if it's parent process + * terminates. **But EXIT notifications from the client will be honored regardless.** + * + * @remarks + * This function assumes the parent process has been properly configured with + * necessary settings (e.g., using `child_process.unref()`). + */ +function isDetached() { + // cached result + if (_isDetached !== undefined) { + return _isDetached; + } + + const args = parseCliOpts(process.argv); + + if (Object.keys(args).includes('detached')) { + _isDetached = true; + + // Override the default behavior: when then parent/child communication + // channel (e.g. IPC) disconnects, the child terminates. + process.on('disconnect', () => { + endProtocolConnection(); + }); + + const timeoutValue = args['detached']; + const timeoutMillis = Number(timeoutValue); + if (timeoutValue !== undefined && isNaN(timeoutMillis)) { + throw Error(`Cli value for flag '--detached' was not a number: ${timeoutMillis}`); + } + // Keeps the server alive since node may terminate this process if + // the parent terminates + this process has nothing in the event loop. + setInterval(() => { + // Check if the detached server has reached the user-specified lifetime + if (_protocolConnectionEndTime && timeoutMillis && Date.now() - _protocolConnectionEndTime >= timeoutMillis) { + process.exit(7); + } + }, 60_000); + } else { + _isDetached = false; + } + return _isDetached; +} + let _protocolConnection: ProtocolConnection | undefined; +let _protocolConnectionEndTime: number | undefined = undefined; function endProtocolConnection(): void { + _protocolConnectionEndTime = Date.now(); + if (_protocolConnection === undefined) { return; } @@ -38,11 +91,23 @@ function endProtocolConnection(): void { // did and we can't send an end into the connection. } } + let _shutdownReceived: boolean = false; let exitTimer: NodeJS.Timer | undefined = undefined; + +/** + * May auto-exit the server if the parent process does not exist. + * + * We need this due to different behaviors between OS's when the parent terminates: + * - Windows terminates the child + * - Unix-like may not terminate the child + */ function setupExitTimer(): void { - const argName = '--clientProcessId'; + if (isDetached()) { + return; + } + function runTimer(value: string): void { try { const processId = parseInt(value); @@ -62,23 +127,21 @@ function setupExitTimer(): void { } } - for (let i = 2; i < process.argv.length; i++) { - const arg = process.argv[i]; - if (arg === argName && i + 1 < process.argv.length) { - runTimer(process.argv[i + 1]); - return; - } else { - const args = arg.split('='); - if (args[0] === argName) { - runTimer(args[1]); - } - } + const argName = 'clientProcessId'; + const args = parseCliOpts(process.argv); + if (Object.keys(args).includes(argName) && Is.string(args[argName])) { + runTimer(args[argName]); + return; } } setupExitTimer(); const watchDog: WatchDog = { initialize: (params: InitializeParams): void => { + if (isDetached()) { + return; + } + const processId = params.processId; if (Is.number(processId) && exitTimer === undefined) { // We received a parent process id. Set up a timer to periodically check @@ -105,7 +168,6 @@ const watchDog: WatchDog = { } }; - /** * Creates a new connection based on the processes command line arguments: * @@ -249,11 +311,15 @@ function _createConnectioninput; inputStream.on('end', () => { endProtocolConnection(); - process.exit(_shutdownReceived ? 0 : 1); + if (!isDetached()) { + process.exit(_shutdownReceived ? 0 : 1); + } }); inputStream.on('close', () => { endProtocolConnection(); - process.exit(_shutdownReceived ? 0 : 1); + if (!isDetached()) { + process.exit(_shutdownReceived ? 0 : 1); + } }); } diff --git a/server/src/node/test/process.test.ts b/server/src/node/test/process.test.ts new file mode 100644 index 000000000..004963433 --- /dev/null +++ b/server/src/node/test/process.test.ts @@ -0,0 +1,98 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import {parseCliOpts} from '../../common/utils/process'; +import assert from 'assert'; + +suite('parseCliArgs', () => { + test('should parse key-value pairs with equals sign', () => { + const args = ['--name=value', '--port=3000']; + const result = parseCliOpts(args); + + assert.deepStrictEqual(result, { + name: 'value', + port: 3000 + }); + }); + + test('should parse key-value pairs with space separator', () => { + const args = ['--name', 'value', '--port', '3000']; + const result = parseCliOpts(args); + + assert.deepStrictEqual(result, { + name: 'value', + port: 3000 + }); + }); + + test('should handle flags without values', () => { + const args = ['--verbose', '--name', 'value', '--debug']; + const result = parseCliOpts(args); + + assert.deepStrictEqual(result, { + verbose: undefined, + name: 'value', + debug: undefined + }); + }); + + test('should handle mixed format arguments', () => { + const args = ['--name=john', '--age', '25', '--debug', '--port=8080']; + const result = parseCliOpts(args); + + assert.deepStrictEqual(result, { + name: 'john', + age: 25, + debug: undefined, + port: 8080 + }); + }); + + test('should handle empty array', () => { + const args: string[] = []; + const result = parseCliOpts(args); + + assert.deepStrictEqual(result, {}); + }); + + test('converts values to their appropriate primitive', () => { + let args = ['--string', 'hello', '--num', '12345', '--bool', 'false', '--undefined']; + let result = parseCliOpts(args); + assert.deepStrictEqual(result, { + string: 'hello', + num: 12345, + bool: false, + undefined: undefined + }); + + args = ['--string=hello', '--num=12345', '--bool=true', '--undefined']; + result = parseCliOpts(args); + assert.deepStrictEqual(result, { + string: 'hello', + num: 12345, + bool: true, + undefined: undefined + }); + }); + + test('should ignore non-flag arguments', () => { + const args = ['node', 'myModule.js', '--name', 'value', 'another']; + const result = parseCliOpts(args); + + assert.deepStrictEqual(result, { + name: 'value' + }); + }); + + test('should handle last argument as flag if no value follows', () => { + const args = ['--name', 'value', '--flag']; + const result = parseCliOpts(args); + + assert.deepStrictEqual(result, { + name: 'value', + flag: undefined + }); + }); +});