/
languageClient.ts
374 lines (341 loc) · 12.2 KB
/
languageClient.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
import { ChildProcess, spawn } from "child_process";
import { Disposable, Event, EventEmitter, workspace } from "vscode";
import {
CloseAction,
ErrorAction,
ErrorHandler,
GenericNotificationHandler,
LanguageClient,
RevealOutputChannelOn,
ServerCapabilities,
ServerOptions,
} from "vscode-languageclient/node";
import { stopProcess } from "./connections";
import { Tags } from "./metricsClient";
import { SorbetExtensionContext } from "./sorbetExtensionContext";
import { ServerStatus, RestartReason } from "./types";
import { backwardsCompatibleTrackUntyped } from "./config";
const VALID_STATE_TRANSITIONS: ReadonlyMap<
ServerStatus,
ReadonlySet<ServerStatus>
> = new Map<ServerStatus, Set<ServerStatus>>([
[
ServerStatus.INITIALIZING,
new Set([
ServerStatus.ERROR,
ServerStatus.RESTARTING,
ServerStatus.RUNNING,
]),
],
[
ServerStatus.RUNNING,
new Set([ServerStatus.ERROR, ServerStatus.RESTARTING]),
],
// Restarting is a terminal state. The restart occurs by terminating this LanguageClient and creating a new one.
[ServerStatus.RESTARTING, new Set()],
// Error is a terminal state for this class.
[ServerStatus.ERROR, new Set()],
]);
/**
* Create Sorbet Language Client.
*/
function createClient(
context: SorbetExtensionContext,
serverOptions: ServerOptions,
errorHandler: ErrorHandler,
) {
const initializationOptions = {
// Opt in to sorbet/showOperation notifications.
supportsOperationNotifications: true,
// Let Sorbet know that we can handle sorbet:// URIs for generated files.
supportsSorbetURIs: true,
highlightUntyped: backwardsCompatibleTrackUntyped(
context.log,
context.configuration.highlightUntyped,
),
enableTypedFalseCompletionNudges:
context.configuration.typedFalseCompletionNudges,
};
context.log.debug(
`Initializing with initializationOptions=${JSON.stringify(
initializationOptions,
)}`,
);
const client = new LanguageClient("ruby", "Sorbet", serverOptions, {
documentSelector: [
{ language: "ruby", scheme: "file" },
// Support queries on generated files with sorbet:// URIs that do not exist editor-side.
{ language: "ruby", scheme: "sorbet" },
],
outputChannel: context.logOutputChannel,
initializationOptions,
errorHandler,
revealOutputChannelOn: context.configuration.revealOutputOnError
? RevealOutputChannelOn.Error
: RevealOutputChannelOn.Never,
});
return client;
}
/**
* Shims the language client object so that all requests sent get timed. Exported for tests.
*/
export function shimLanguageClient(
client: LanguageClient,
emitTimingMetric: (metric: string, value: number | Date, tags: Tags) => void,
) {
const originalSendRequest = client.sendRequest;
client.sendRequest = function(
this: LanguageClient,
method: any,
...args: any[]
) {
const now = new Date();
const requestName = typeof method === "string" ? method : method.method;
// Replace some special characters with underscores.
const sanitizedRequestName = requestName.replace(/[/$]/g, "_");
args.unshift(method);
const rv = originalSendRequest.apply(this, args as any);
const metricName = `latency.${sanitizedRequestName}_ms`;
rv.then(
() =>
// NOTE: This callback is only called if the request succeeds and was _not_ canceled.
// If the request is canceled, the promise is rejected.
emitTimingMetric(metricName, now, { success: "true" }),
() =>
// This callback is called if the request failed or was canceled.
emitTimingMetric(metricName, now, { success: "false" }),
);
return rv;
};
return client;
}
export type SorbetServerCapabilities = ServerCapabilities & {
sorbetShowSymbolProvider: boolean;
};
export class SorbetLanguageClient implements Disposable, ErrorHandler {
private readonly context: SorbetExtensionContext;
private readonly disposables: Disposable[];
private readonly languageClient: LanguageClient;
private readonly onStatusChangeEmitter: EventEmitter<ServerStatus>;
private readonly restart: (reason: RestartReason) => void;
private sorbetProcess?: ChildProcess;
// Sometimes this is an errno, not a process exit code. This happens when set
// via the `.on("error")` handler, instead of the `.on("exit")` handler.
private sorbetProcessExitCode?: number;
private wrappedLastError?: string;
private wrappedStatus: ServerStatus;
constructor(
context: SorbetExtensionContext,
restart: (reason: RestartReason) => void,
) {
this.context = context;
this.onStatusChangeEmitter = new EventEmitter<ServerStatus>();
this.restart = restart;
this.wrappedStatus = ServerStatus.INITIALIZING;
this.languageClient = shimLanguageClient(
createClient(context, () => this.startSorbetProcess(), this),
(metric, value, tags) =>
this.context.metrics.emitTimingMetric(metric, value, tags),
);
// It's possible for `onReady` to fire after `stop()` is called on the language client. :(
this.languageClient.onReady().then(() => {
if (this.status !== ServerStatus.ERROR) {
// Language client started successfully.
this.status = ServerStatus.RUNNING;
}
});
this.disposables = [
this.languageClient.start(),
this.onStatusChangeEmitter,
];
}
/**
* Implements the disposable interface so this object can be added to the context's subscriptions
* to keep it alive. Stops the language server and Sorbet processes, and removes UI items.
*/
public dispose() {
Disposable.from(...this.disposables).dispose();
this.disposables.length = 0;
let stopped = false;
/*
* stop() only invokes the then() callback after the language server
* ACKs the stop request.
* Stopping can time out if the language client is repeatedly failing to
* start (e.g. if network is down, or path to Sorbet is incorrect), or if
* Sorbet never ACKs the stop request.
* In the former case (which is the common case), VS code stops retrying
* the connection after we call stop(), but never invokes our callback.
* Thus, our solution is to wait 5 seconds for a callback, and stop the
* process if we haven't heard back.
*/
const stopTimer = setTimeout(() => {
stopped = true;
this.context.metrics.emitCountMetric("stop.timed_out", 1);
if (this.sorbetProcess?.pid) {
stopProcess(this.sorbetProcess, this.context.log);
}
this.sorbetProcess = undefined;
}, 5000);
this.languageClient.stop().then(() => {
if (!stopped) {
clearTimeout(stopTimer);
this.context.metrics.emitCountMetric("stop.success", 1);
this.context.log.info("Sorbet has stopped.");
}
});
}
/**
* Sorbet language server {@link SorbetServerCapabilities capabilities}. Only
* available if the server has been initialized.
*/
public get capabilities(): SorbetServerCapabilities | undefined {
return <SorbetServerCapabilities | undefined>(
this.languageClient.initializeResult?.capabilities
);
}
/**
* Contains error message when {@link status} is {@link ServerStatus.ERROR}.
*/
public get lastError(): string | undefined {
return this.wrappedLastError;
}
/**
* Resolves when client is ready to serve requests.
*/
public onReady(): Promise<void> {
return this.languageClient.onReady();
}
/**
* Register a handler for a language server notification. See {@link LanguageClient.onNotification}.
*/
public onNotification(
method: string,
handler: GenericNotificationHandler,
): Disposable {
return this.languageClient.onNotification(method, handler);
}
/**
* Event fired on {@link status} changes.
*/
public get onStatusChange(): Event<ServerStatus> {
return this.onStatusChangeEmitter.event;
}
/**
* Send a request to language server. See {@link LanguageClient.sendRequest}.
*/
public sendRequest<TResponse>(
method: string,
param: any,
): Promise<TResponse> {
return this.languageClient.sendRequest<TResponse>(method, param);
}
/**
* Send a notification to language server. See {@link LanguageClient.sendNotification}.
*/
public sendNotification(method: string, param: any): void {
this.languageClient.sendNotification(method, param);
}
/**
* Language client status.
*/
public get status(): ServerStatus {
return this.wrappedStatus;
}
private set status(newStatus: ServerStatus) {
if (this.status === newStatus) {
return;
}
const set = VALID_STATE_TRANSITIONS.get(this.status);
if (!set?.has(newStatus)) {
this.context.log.error(
`Invalid Sorbet server transition: ${this.status} => ${newStatus}}`,
);
}
this.wrappedStatus = newStatus;
this.onStatusChangeEmitter.fire(newStatus);
}
/**
* Runs a Sorbet process using the current active configuration. Debounced so that it runs Sorbet at most every 3 seconds.
*/
private startSorbetProcess(): Promise<ChildProcess> {
this.status = ServerStatus.INITIALIZING;
this.context.log.info("Running Sorbet LSP.");
const [command, ...args] =
this.context.configuration.activeLspConfig?.command ?? [];
if (!command) {
const msg = `Missing command-line data to start Sorbet. ConfigId:${this.context.configuration.activeLspConfig?.id}`;
this.context.log.error(msg);
return Promise.reject(new Error(msg));
}
this.context.log.debug(` > ${command} ${args.join(" ")}`);
this.sorbetProcess = spawn(command, args, {
cwd: workspace.rootPath,
});
// N.B.: 'exit' is sometimes not invoked if the process exits with an error/fails to start, as per the Node.js docs.
// So, we need to handle both events. ¯\_(ツ)_/¯
this.sorbetProcess.on(
"exit",
(code: number | null, _signal: string | null) => {
this.sorbetProcessExitCode = code ?? undefined;
},
);
this.sorbetProcess.on("error", (err?: NodeJS.ErrnoException) => {
if (
err &&
this.status === ServerStatus.INITIALIZING &&
err.code === "ENOENT"
) {
this.context.metrics.emitCountMetric("error.enoent", 1);
// We failed to start the process. The path to Sorbet is likely incorrect.
this.wrappedLastError = `Could not start Sorbet with command: '${command} ${args.join(
" ",
)}'. Encountered error '${
err.message
}'. Is the path to Sorbet correct?`;
this.status = ServerStatus.ERROR;
}
this.sorbetProcess = undefined;
this.sorbetProcessExitCode = err?.errno;
});
return Promise.resolve(this.sorbetProcess);
}
/** ErrorHandler interface */
/**
* LanguageClient has built-in restart capabilities but if it's broken:
* * It drops all `onNotification` subscriptions after restarting, so we'll miss ShowNotification updates.
* * It drops all `onReady` subscriptions after restarting, so we won't know when the Sorbet server is running.
* * It doesn't reset `onReady` state, so we can't even reset our `onReady` callback.
*/
public error(): ErrorAction {
if (this.status !== ServerStatus.ERROR) {
this.status = ServerStatus.RESTARTING;
this.restart(RestartReason.CRASH_LC_ERROR);
}
return ErrorAction.Shutdown;
}
/**
* Note: If the VPN is disconnected, then Sorbet will repeatedly fail to start.
*/
public closed(): CloseAction {
if (this.status !== ServerStatus.ERROR) {
let reason: RestartReason;
if (this.sorbetProcessExitCode === 11) {
// 11 number chosen somewhat arbitrarily. Most important is that this doesn't
// clobber the exit code of Sorbet itself (which means Sorbet cannot return 11).
//
// The only thing that matters is that this value is kept in sync with any
// wrapper scripts that people use with Sorbet. If this number has to
// change for some reason, we should announce that.
reason = RestartReason.WRAPPER_REFUSED_SPAWN;
} else if (this.sorbetProcessExitCode === 143) {
// 143 = 128 + 15 and 15 is TERM signal
reason = RestartReason.FORCIBLY_TERMINATED;
} else {
reason = RestartReason.CRASH_LC_CLOSED;
}
this.status = ServerStatus.RESTARTING;
this.restart(reason);
}
return CloseAction.DoNotRestart;
}
}