From 69ffc0fe03e5f91a7d0cfec2a4a719795a05cc5d Mon Sep 17 00:00:00 2001 From: Changyong Gong Date: Tue, 30 Jun 2026 11:01:25 +0800 Subject: [PATCH] fix: fall back to stdio when pipe startup fails --- src/fileEventHandler.ts | 14 +++- src/standardLanguageClient.ts | 32 +++++-- src/standardLanguageClientStart.ts | 66 +++++++++++++++ .../standardLanguageClient.test.ts | 83 +++++++++++++++++++ 4 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 src/standardLanguageClientStart.ts create mode 100644 test/standard-mode-suite/standardLanguageClient.test.ts diff --git a/src/fileEventHandler.ts b/src/fileEventHandler.ts index 03e950b5a..e2268c5f8 100644 --- a/src/fileEventHandler.ts +++ b/src/fileEventHandler.ts @@ -13,6 +13,7 @@ import * as stringInterpolate from 'fmtr'; import { apiManager } from './apiManager'; let serverReady: boolean = false; +type LanguageClientProvider = LanguageClient | (() => LanguageClient); const BRACE_POSITION_KEY = "org.eclipse.jdt.core.formatter.brace_position_for_type_declaration"; const END_OF_LINE = "end_of_line"; @@ -24,7 +25,7 @@ export function setServerStatus(ready: boolean) { serverReady = ready; } -export function registerFileEventHandlers(client: LanguageClient, context: ExtensionContext) { +export function registerFileEventHandlers(client: LanguageClientProvider, context: ExtensionContext) { if (workspace.onDidCreateFiles) {// Theia doesn't support workspace.onDidCreateFiles yet context.subscriptions.push(workspace.onDidCreateFiles(handleNewJavaFiles)); } @@ -188,7 +189,11 @@ async function handleNewJavaFiles(e: FileCreateEvent) { }, 100); } -function getWillRenameHandler(client: LanguageClient) { +function getLanguageClient(client: LanguageClientProvider): LanguageClient { + return typeof client === 'function' ? client() : client; +} + +function getWillRenameHandler(client: LanguageClientProvider) { return function handleWillRenameFiles(e: FileWillRenameEvent): void { if (!serverReady) { return; @@ -213,10 +218,11 @@ function getWillRenameHandler(client: LanguageClient) { return; } - const edit = await client.sendRequest(WillRenameFiles.type, { + const languageClient = getLanguageClient(client); + const edit = await languageClient.sendRequest(WillRenameFiles.type, { files: javaRenameEvents }); - resolve(await client.protocol2CodeConverter.asWorkspaceEdit(edit)); + resolve(await languageClient.protocol2CodeConverter.asWorkspaceEdit(edit)); } catch (ex) { reject(ex); } diff --git a/src/standardLanguageClient.ts b/src/standardLanguageClient.ts index 7a0aae0eb..f5902766c 100644 --- a/src/standardLanguageClient.ts +++ b/src/standardLanguageClient.ts @@ -4,7 +4,7 @@ import * as net from 'net'; import * as path from 'path'; import { CancellationToken, CodeActionKind, commands, ConfigurationTarget, DocumentSelector, EventEmitter, ExtensionContext, extensions, languages, Location, ProgressLocation, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceConfiguration } from "vscode"; import { ConfigurationParams, ConfigurationRequest, LanguageClientOptions, Location as LSLocation, MessageType, Position as LSPosition, TextDocumentPositionParams, WorkspaceEdit, StaticFeature, ClientCapabilities, FeatureState, TelemetryEventNotification } from "vscode-languageclient"; -import { LanguageClient, StreamInfo } from "vscode-languageclient/node"; +import { LanguageClient, ServerOptions, StreamInfo } from "vscode-languageclient/node"; import { apiManager } from "./apiManager"; import * as buildPath from './buildpath'; import { javaRefactorKinds, RefactorDocumentProvider } from "./codeActionProvider"; @@ -42,6 +42,7 @@ import { listJdks, sortJdksBySource, sortJdksByVersion } from './jdkUtils'; import { ClientCodeActionProvider } from './clientCodeActionProvider'; import { BuildFileSelector } from './buildFilesSelector'; import { extendedOutlineQuickPick } from "./outline/extendedOutlineQuickPick"; +import { startWithStdioFallback } from './standardLanguageClientStart'; const extensionName = 'Language Support for Java'; const GRADLE_CHECKSUM = "gradle/checksum/prompt"; @@ -50,6 +51,7 @@ const USE_JAVA = "Use Java "; const AS_GRADLE_JVM = " as Gradle JVM"; const UPGRADE_GRADLE = "Upgrade Gradle to "; const GRADLE_IMPORT_JVM = "java.import.gradle.java.home"; +const PIPE_START_TIMEOUT_MS = 30000; export const JAVA_SELECTOR: DocumentSelector = [ { scheme: "file", language: "java", pattern: "**/*.java" }, { scheme: "jdt", language: "java", pattern: "**/*.class" }, @@ -59,6 +61,8 @@ export const JAVA_SELECTOR: DocumentSelector = [ export class StandardLanguageClient { private languageClient: LanguageClient; + private serverOptions: ServerOptions; + private clientOptions: LanguageClientOptions; private status: ClientStatus = ClientStatus.uninitialized; public async initialize(context: ExtensionContext, requirements: RequirementsData, clientOptions: LanguageClientOptions, workspacePath: string, jdtEventEmitter: EventEmitter): Promise { @@ -85,7 +89,7 @@ export class StandardLanguageClient { } }); - let serverOptions; + let serverOptions: ServerOptions; const port = process.env['JDTLS_SERVER_PORT']; if (!port) { const lsPort = process.env['JDTLS_CLIENT_PORT']; @@ -105,19 +109,26 @@ export class StandardLanguageClient { // used during development serverOptions = awaitServerConnection.bind(null, port); } + this.serverOptions = serverOptions; + this.clientOptions = clientOptions; // Create the language client and start the client. - this.languageClient = new TracingLanguageClient('java', extensionName, serverOptions, clientOptions, DEBUG); - this.languageClient.registerFeature(new DisableWillRenameFeature()); + this.languageClient = this.createLanguageClient(serverOptions, clientOptions); this.registerCommandsForStandardServer(context, jdtEventEmitter); - fileEventHandler.registerFileEventHandlers(this.languageClient, context); + fileEventHandler.registerFileEventHandlers(() => this.languageClient, context); collectBuildFilePattern(extensions.all); this.status = ClientStatus.initialized; } + private createLanguageClient(serverOptions: ServerOptions, clientOptions: LanguageClientOptions): LanguageClient { + const languageClient = new TracingLanguageClient('java', extensionName, serverOptions, clientOptions, DEBUG); + languageClient.registerFeature(new DisableWillRenameFeature()); + return languageClient; + } + public registerLanguageClientActions(context: ExtensionContext, hasImported: boolean, jdtEventEmitter: EventEmitter) { activationProgressNotification.showProgress(); this.languageClient.onNotification(StatusNotification.type, (report) => { @@ -770,7 +781,16 @@ export class StandardLanguageClient { public start(): Promise { if (this.languageClient && this.status === ClientStatus.initialized) { this.status = ClientStatus.starting; - return this.languageClient.start(); + return startWithStdioFallback({ + languageClient: this.languageClient, + serverOptions: this.serverOptions, + createLanguageClient: (serverOptions) => this.createLanguageClient(serverOptions, this.clientOptions), + pipeStartTimeout: PIPE_START_TIMEOUT_MS, + onFallback: (error) => logger.warn(`Falling back to 'stdio' (from 'pipe') because starting the pipe transport failed: ${error}`), + }).then(result => { + this.languageClient = result.client; + this.serverOptions = result.serverOptions; + }); } } diff --git a/src/standardLanguageClientStart.ts b/src/standardLanguageClientStart.ts new file mode 100644 index 000000000..5ba496859 --- /dev/null +++ b/src/standardLanguageClientStart.ts @@ -0,0 +1,66 @@ +import { Executable, ServerOptions, TransportKind } from "vscode-languageclient/node"; + +export interface StartableLanguageClient { + start(): Promise; +} + +export interface StartWithStdioFallbackOptions { + languageClient: T; + serverOptions: ServerOptions; + createLanguageClient(serverOptions: Executable): T; + pipeStartTimeout: number; + onFallback?(error: any): void; +} + +class PipeStartTimeoutError extends Error { +} + +export async function startWithStdioFallback(options: StartWithStdioFallbackOptions): Promise<{ client: T; serverOptions: ServerOptions }> { + if (!isPipeExecutable(options.serverOptions)) { + await options.languageClient.start(); + return { client: options.languageClient, serverOptions: options.serverOptions }; + } + + try { + await startWithTimeout(options.languageClient, options.pipeStartTimeout); + return { client: options.languageClient, serverOptions: options.serverOptions }; + } catch (error) { + if (!(error instanceof PipeStartTimeoutError)) { + throw error; + } + options.onFallback?.(error); + const stdioServerOptions = createStdioServerOptions(options.serverOptions); + const stdioClient = options.createLanguageClient(stdioServerOptions); + await stdioClient.start(); + return { client: stdioClient, serverOptions: stdioServerOptions }; + } +} + +async function startWithTimeout(client: StartableLanguageClient, timeout: number): Promise { + let timeoutHandle: NodeJS.Timeout | undefined; + try { + await Promise.race([ + client.start(), + new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => reject(new PipeStartTimeoutError(`Starting pipe transport timed out after ${timeout}ms.`)), timeout); + }) + ]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +} + +function isPipeExecutable(serverOptions: ServerOptions): serverOptions is Executable { + return !!serverOptions && typeof (serverOptions as Executable).command === 'string' && (serverOptions as Executable).transport === TransportKind.pipe; +} + +function createStdioServerOptions(serverOptions: Executable): Executable { + return { + ...serverOptions, + args: serverOptions.args?.slice(), + options: serverOptions.options ? { ...serverOptions.options } : undefined, + transport: TransportKind.stdio, + }; +} diff --git a/test/standard-mode-suite/standardLanguageClient.test.ts b/test/standard-mode-suite/standardLanguageClient.test.ts new file mode 100644 index 000000000..f30564da3 --- /dev/null +++ b/test/standard-mode-suite/standardLanguageClient.test.ts @@ -0,0 +1,83 @@ +'use strict'; + +import * as assert from 'assert'; +import { Executable, TransportKind } from 'vscode-languageclient/node'; +import { startWithStdioFallback, StartableLanguageClient } from '../../src/standardLanguageClientStart'; + +class TestLanguageClient implements StartableLanguageClient { + public startCount = 0; + + constructor(private readonly startResult: Promise) { + } + + public start(): Promise { + this.startCount++; + return this.startResult; + } +} + +suite('Standard Language Client Test', () => { + + test('startWithStdioFallback() - does not fall back when pipe start rejects', async () => { + const pipeClient = new TestLanguageClient(Promise.reject(new Error('pipe failed'))); + const stdioClient = new TestLanguageClient(Promise.resolve()); + const pipeOptions = createServerOptions(TransportKind.pipe); + let fallbackOptions: Executable | undefined; + + await assert.rejects(startWithStdioFallback({ + languageClient: pipeClient, + serverOptions: pipeOptions, + createLanguageClient: serverOptions => { + fallbackOptions = serverOptions; + return stdioClient; + }, + pipeStartTimeout: 1000, + }), /pipe failed/); + + assert.equal(pipeClient.startCount, 1); + assert.equal(stdioClient.startCount, 0); + assert.equal(fallbackOptions, undefined); + }); + + test('startWithStdioFallback() - falls back when pipe start times out', async () => { + const pipeClient = new TestLanguageClient(new Promise(() => { /* never resolves */ })); + const stdioClient = new TestLanguageClient(Promise.resolve()); + let fallbackError: any; + + const result = await startWithStdioFallback({ + languageClient: pipeClient, + serverOptions: createServerOptions(TransportKind.pipe), + createLanguageClient: () => stdioClient, + pipeStartTimeout: 1, + onFallback: error => fallbackError = error, + }); + + assert.equal(pipeClient.startCount, 1); + assert.equal(stdioClient.startCount, 1); + assert.equal(result.client, stdioClient); + assert.equal((result.serverOptions as Executable).transport, TransportKind.stdio); + assert.ok(String(fallbackError).includes('timed out')); + }); + + test('startWithStdioFallback() - does not fall back for stdio start failures', async () => { + const stdioClient = new TestLanguageClient(Promise.reject(new Error('stdio failed'))); + + await assert.rejects(startWithStdioFallback({ + languageClient: stdioClient, + serverOptions: createServerOptions(TransportKind.stdio), + createLanguageClient: () => new TestLanguageClient(Promise.resolve()), + pipeStartTimeout: 1, + }), /stdio failed/); + + assert.equal(stdioClient.startCount, 1); + }); +}); + +function createServerOptions(transport: TransportKind): Executable { + return { + command: 'java', + args: ['-version'], + options: { env: { test: 'true' } }, + transport, + }; +}