Skip to content

Commit bd504e2

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

4 files changed

Lines changed: 195 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: 27 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 = 10000;
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,17 @@ 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+
onStopError: (error) => logger.warn(`Failed to stop the pipe transport client before falling back to 'stdio': ${error}`),
791+
}).then(result => {
792+
this.languageClient = result.client;
793+
this.serverOptions = result.serverOptions;
794+
});
774795
}
775796
}
776797

src/standardLanguageClientStart.ts

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

0 commit comments

Comments
 (0)