Skip to content

Commit 69ffc0f

Browse files
committed
fix: fall back to stdio when pipe startup fails
1 parent 40e6970 commit 69ffc0f

4 files changed

Lines changed: 185 additions & 10 deletions

File tree

src/fileEventHandler.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as stringInterpolate from 'fmtr';
1313
import { apiManager } from './apiManager';
1414

1515
let serverReady: boolean = false;
16+
type LanguageClientProvider = LanguageClient | (() => LanguageClient);
1617

1718
const BRACE_POSITION_KEY = "org.eclipse.jdt.core.formatter.brace_position_for_type_declaration";
1819
const END_OF_LINE = "end_of_line";
@@ -24,7 +25,7 @@ export function setServerStatus(ready: boolean) {
2425
serverReady = ready;
2526
}
2627

27-
export function registerFileEventHandlers(client: LanguageClient, context: ExtensionContext) {
28+
export function registerFileEventHandlers(client: LanguageClientProvider, context: ExtensionContext) {
2829
if (workspace.onDidCreateFiles) {// Theia doesn't support workspace.onDidCreateFiles yet
2930
context.subscriptions.push(workspace.onDidCreateFiles(handleNewJavaFiles));
3031
}
@@ -188,7 +189,11 @@ async function handleNewJavaFiles(e: FileCreateEvent) {
188189
}, 100);
189190
}
190191

191-
function getWillRenameHandler(client: LanguageClient) {
192+
function getLanguageClient(client: LanguageClientProvider): LanguageClient {
193+
return typeof client === 'function' ? client() : client;
194+
}
195+
196+
function getWillRenameHandler(client: LanguageClientProvider) {
192197
return function handleWillRenameFiles(e: FileWillRenameEvent): void {
193198
if (!serverReady) {
194199
return;
@@ -213,10 +218,11 @@ function getWillRenameHandler(client: LanguageClient) {
213218
return;
214219
}
215220

216-
const edit = await client.sendRequest(WillRenameFiles.type, {
221+
const languageClient = getLanguageClient(client);
222+
const edit = await languageClient.sendRequest(WillRenameFiles.type, {
217223
files: javaRenameEvents
218224
});
219-
resolve(await client.protocol2CodeConverter.asWorkspaceEdit(edit));
225+
resolve(await languageClient.protocol2CodeConverter.asWorkspaceEdit(edit));
220226
} catch (ex) {
221227
reject(ex);
222228
}

src/standardLanguageClient.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as net from 'net';
44
import * as path from 'path';
55
import { CancellationToken, CodeActionKind, commands, ConfigurationTarget, DocumentSelector, EventEmitter, ExtensionContext, extensions, languages, Location, ProgressLocation, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceConfiguration } from "vscode";
66
import { ConfigurationParams, ConfigurationRequest, LanguageClientOptions, Location as LSLocation, MessageType, Position as LSPosition, TextDocumentPositionParams, WorkspaceEdit, StaticFeature, ClientCapabilities, FeatureState, TelemetryEventNotification } from "vscode-languageclient";
7-
import { LanguageClient, StreamInfo } from "vscode-languageclient/node";
7+
import { LanguageClient, ServerOptions, StreamInfo } from "vscode-languageclient/node";
88
import { apiManager } from "./apiManager";
99
import * as buildPath from './buildpath';
1010
import { javaRefactorKinds, RefactorDocumentProvider } from "./codeActionProvider";
@@ -42,6 +42,7 @@ import { listJdks, sortJdksBySource, sortJdksByVersion } from './jdkUtils';
4242
import { ClientCodeActionProvider } from './clientCodeActionProvider';
4343
import { BuildFileSelector } from './buildFilesSelector';
4444
import { extendedOutlineQuickPick } from "./outline/extendedOutlineQuickPick";
45+
import { startWithStdioFallback } from './standardLanguageClientStart';
4546

4647
const extensionName = 'Language Support for Java';
4748
const GRADLE_CHECKSUM = "gradle/checksum/prompt";
@@ -50,6 +51,7 @@ const USE_JAVA = "Use Java ";
5051
const AS_GRADLE_JVM = " as Gradle JVM";
5152
const UPGRADE_GRADLE = "Upgrade Gradle to ";
5253
const GRADLE_IMPORT_JVM = "java.import.gradle.java.home";
54+
const PIPE_START_TIMEOUT_MS = 30000;
5355
export const JAVA_SELECTOR: DocumentSelector = [
5456
{ scheme: "file", language: "java", pattern: "**/*.java" },
5557
{ scheme: "jdt", language: "java", pattern: "**/*.class" },
@@ -59,6 +61,8 @@ export const JAVA_SELECTOR: DocumentSelector = [
5961
export class StandardLanguageClient {
6062

6163
private languageClient: LanguageClient;
64+
private serverOptions: ServerOptions;
65+
private clientOptions: LanguageClientOptions;
6266
private status: ClientStatus = ClientStatus.uninitialized;
6367

6468
public async initialize(context: ExtensionContext, requirements: RequirementsData, clientOptions: LanguageClientOptions, workspacePath: string, jdtEventEmitter: EventEmitter<Uri>): Promise<void> {
@@ -85,7 +89,7 @@ export class StandardLanguageClient {
8589
}
8690
});
8791

88-
let serverOptions;
92+
let serverOptions: ServerOptions;
8993
const port = process.env['JDTLS_SERVER_PORT'];
9094
if (!port) {
9195
const lsPort = process.env['JDTLS_CLIENT_PORT'];
@@ -105,19 +109,26 @@ export class StandardLanguageClient {
105109
// used during development
106110
serverOptions = awaitServerConnection.bind(null, port);
107111
}
112+
this.serverOptions = serverOptions;
113+
this.clientOptions = clientOptions;
108114

109115
// Create the language client and start the client.
110-
this.languageClient = new TracingLanguageClient('java', extensionName, serverOptions, clientOptions, DEBUG);
111-
this.languageClient.registerFeature(new DisableWillRenameFeature());
116+
this.languageClient = this.createLanguageClient(serverOptions, clientOptions);
112117

113118
this.registerCommandsForStandardServer(context, jdtEventEmitter);
114-
fileEventHandler.registerFileEventHandlers(this.languageClient, context);
119+
fileEventHandler.registerFileEventHandlers(() => this.languageClient, context);
115120

116121
collectBuildFilePattern(extensions.all);
117122

118123
this.status = ClientStatus.initialized;
119124
}
120125

126+
private createLanguageClient(serverOptions: ServerOptions, clientOptions: LanguageClientOptions): LanguageClient {
127+
const languageClient = new TracingLanguageClient('java', extensionName, serverOptions, clientOptions, DEBUG);
128+
languageClient.registerFeature(new DisableWillRenameFeature());
129+
return languageClient;
130+
}
131+
121132
public registerLanguageClientActions(context: ExtensionContext, hasImported: boolean, jdtEventEmitter: EventEmitter<Uri>) {
122133
activationProgressNotification.showProgress();
123134
this.languageClient.onNotification(StatusNotification.type, (report) => {
@@ -770,7 +781,16 @@ export class StandardLanguageClient {
770781
public start(): Promise<void> {
771782
if (this.languageClient && this.status === ClientStatus.initialized) {
772783
this.status = ClientStatus.starting;
773-
return this.languageClient.start();
784+
return startWithStdioFallback({
785+
languageClient: this.languageClient,
786+
serverOptions: this.serverOptions,
787+
createLanguageClient: (serverOptions) => this.createLanguageClient(serverOptions, this.clientOptions),
788+
pipeStartTimeout: PIPE_START_TIMEOUT_MS,
789+
onFallback: (error) => logger.warn(`Falling back to 'stdio' (from 'pipe') because starting the pipe transport failed: ${error}`),
790+
}).then(result => {
791+
this.languageClient = result.client;
792+
this.serverOptions = result.serverOptions;
793+
});
774794
}
775795
}
776796

src/standardLanguageClientStart.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Executable, ServerOptions, TransportKind } from "vscode-languageclient/node";
2+
3+
export interface StartableLanguageClient {
4+
start(): Promise<void>;
5+
}
6+
7+
export interface StartWithStdioFallbackOptions<T extends StartableLanguageClient> {
8+
languageClient: T;
9+
serverOptions: ServerOptions;
10+
createLanguageClient(serverOptions: Executable): T;
11+
pipeStartTimeout: number;
12+
onFallback?(error: any): void;
13+
}
14+
15+
class PipeStartTimeoutError extends Error {
16+
}
17+
18+
export async function startWithStdioFallback<T extends StartableLanguageClient>(options: StartWithStdioFallbackOptions<T>): Promise<{ client: T; serverOptions: ServerOptions }> {
19+
if (!isPipeExecutable(options.serverOptions)) {
20+
await options.languageClient.start();
21+
return { client: options.languageClient, serverOptions: options.serverOptions };
22+
}
23+
24+
try {
25+
await startWithTimeout(options.languageClient, options.pipeStartTimeout);
26+
return { client: options.languageClient, serverOptions: options.serverOptions };
27+
} catch (error) {
28+
if (!(error instanceof PipeStartTimeoutError)) {
29+
throw error;
30+
}
31+
options.onFallback?.(error);
32+
const stdioServerOptions = createStdioServerOptions(options.serverOptions);
33+
const stdioClient = options.createLanguageClient(stdioServerOptions);
34+
await stdioClient.start();
35+
return { client: stdioClient, serverOptions: stdioServerOptions };
36+
}
37+
}
38+
39+
async function startWithTimeout(client: StartableLanguageClient, timeout: number): Promise<void> {
40+
let timeoutHandle: NodeJS.Timeout | undefined;
41+
try {
42+
await Promise.race([
43+
client.start(),
44+
new Promise<void>((_resolve, reject) => {
45+
timeoutHandle = setTimeout(() => reject(new PipeStartTimeoutError(`Starting pipe transport timed out after ${timeout}ms.`)), timeout);
46+
})
47+
]);
48+
} finally {
49+
if (timeoutHandle) {
50+
clearTimeout(timeoutHandle);
51+
}
52+
}
53+
}
54+
55+
function isPipeExecutable(serverOptions: ServerOptions): serverOptions is Executable {
56+
return !!serverOptions && typeof (serverOptions as Executable).command === 'string' && (serverOptions as Executable).transport === TransportKind.pipe;
57+
}
58+
59+
function createStdioServerOptions(serverOptions: Executable): Executable {
60+
return {
61+
...serverOptions,
62+
args: serverOptions.args?.slice(),
63+
options: serverOptions.options ? { ...serverOptions.options } : undefined,
64+
transport: TransportKind.stdio,
65+
};
66+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use strict';
2+
3+
import * as assert from 'assert';
4+
import { Executable, TransportKind } from 'vscode-languageclient/node';
5+
import { startWithStdioFallback, StartableLanguageClient } from '../../src/standardLanguageClientStart';
6+
7+
class TestLanguageClient implements StartableLanguageClient {
8+
public startCount = 0;
9+
10+
constructor(private readonly startResult: Promise<void>) {
11+
}
12+
13+
public start(): Promise<void> {
14+
this.startCount++;
15+
return this.startResult;
16+
}
17+
}
18+
19+
suite('Standard Language Client Test', () => {
20+
21+
test('startWithStdioFallback() - does not fall back when pipe start rejects', async () => {
22+
const pipeClient = new TestLanguageClient(Promise.reject(new Error('pipe failed')));
23+
const stdioClient = new TestLanguageClient(Promise.resolve());
24+
const pipeOptions = createServerOptions(TransportKind.pipe);
25+
let fallbackOptions: Executable | undefined;
26+
27+
await assert.rejects(startWithStdioFallback({
28+
languageClient: pipeClient,
29+
serverOptions: pipeOptions,
30+
createLanguageClient: serverOptions => {
31+
fallbackOptions = serverOptions;
32+
return stdioClient;
33+
},
34+
pipeStartTimeout: 1000,
35+
}), /pipe failed/);
36+
37+
assert.equal(pipeClient.startCount, 1);
38+
assert.equal(stdioClient.startCount, 0);
39+
assert.equal(fallbackOptions, undefined);
40+
});
41+
42+
test('startWithStdioFallback() - falls back when pipe start times out', async () => {
43+
const pipeClient = new TestLanguageClient(new Promise<void>(() => { /* never resolves */ }));
44+
const stdioClient = new TestLanguageClient(Promise.resolve());
45+
let fallbackError: any;
46+
47+
const result = await startWithStdioFallback({
48+
languageClient: pipeClient,
49+
serverOptions: createServerOptions(TransportKind.pipe),
50+
createLanguageClient: () => stdioClient,
51+
pipeStartTimeout: 1,
52+
onFallback: error => fallbackError = error,
53+
});
54+
55+
assert.equal(pipeClient.startCount, 1);
56+
assert.equal(stdioClient.startCount, 1);
57+
assert.equal(result.client, stdioClient);
58+
assert.equal((result.serverOptions as Executable).transport, TransportKind.stdio);
59+
assert.ok(String(fallbackError).includes('timed out'));
60+
});
61+
62+
test('startWithStdioFallback() - does not fall back for stdio start failures', async () => {
63+
const stdioClient = new TestLanguageClient(Promise.reject(new Error('stdio failed')));
64+
65+
await assert.rejects(startWithStdioFallback({
66+
languageClient: stdioClient,
67+
serverOptions: createServerOptions(TransportKind.stdio),
68+
createLanguageClient: () => new TestLanguageClient(Promise.resolve()),
69+
pipeStartTimeout: 1,
70+
}), /stdio failed/);
71+
72+
assert.equal(stdioClient.startCount, 1);
73+
});
74+
});
75+
76+
function createServerOptions(transport: TransportKind): Executable {
77+
return {
78+
command: 'java',
79+
args: ['-version'],
80+
options: { env: { test: 'true' } },
81+
transport,
82+
};
83+
}

0 commit comments

Comments
 (0)