Skip to content

Commit 9e90676

Browse files
petetronicwesm
authored andcommitted
Merged PR posit-dev/positron-python#108: Safely shutdown LSP on language runtime shutdown or restart
Merge pull request #108 from posit-dev/feature/631 Safely shutdown LSP on language runtime shutdown or restart -------------------- Commit message for posit-dev/positron-python@34eb739: Safely shutdown LSP on language runtime shutdown or restart Also refactor LSP client related code into the recently added pythonLanguageRuntime.ts file. Authored-by: Pete Farland <[email protected]> Signed-off-by: Pete Farland <[email protected]>
1 parent 99bee89 commit 9e90676

File tree

2 files changed

+126
-101
lines changed

2 files changed

+126
-101
lines changed

extensions/positron-python/src/client/activation/jedi/positronLanguageRuntimes.ts

Lines changed: 4 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,26 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
21
/* eslint-disable class-methods-use-this */
32
/* eslint-disable global-require */
43
/*---------------------------------------------------------------------------------------------
54
* Copyright (C) 2023 Posit Software, PBC. All rights reserved.
65
*--------------------------------------------------------------------------------------------*/
76

8-
import { Socket } from 'net';
97
import * as path from 'path';
108
import * as fs from 'fs';
119
// eslint-disable-next-line import/no-unresolved
1210
import * as positron from 'positron';
1311
import * as vscode from 'vscode';
1412
import * as crypto from 'crypto';
15-
import { Disposable, DocumentFilter, LanguageClient, LanguageClientOptions, ServerOptions, StreamInfo } from 'vscode-languageclient/node';
13+
import { Disposable, DocumentFilter, LanguageClientOptions } from 'vscode-languageclient/node';
1614

1715
import { compare } from 'semver';
1816
import { EXTENSION_ROOT_DIR, PYTHON_LANGUAGE } from '../../common/constants';
1917
import { IConfigurationService, IInstaller, InstallerResponse, Product, Resource } from '../../common/types';
2018
import { InstallOptions } from '../../common/installer/types';
2119
import { IInterpreterService } from '../../interpreter/contracts';
2220
import { IServiceContainer } from '../../ioc/types';
23-
import { traceError, traceVerbose } from '../../logging';
21+
import { traceVerbose } from '../../logging';
2422
import { PythonEnvironment } from '../../pythonEnvironments/info';
2523
import { PythonVersion } from '../../pythonEnvironments/info/pythonVersion';
26-
import { ProgressReporting } from '../progress';
2724
import { ILanguageServerProxy } from '../types';
2825
import { PythonLanguageRuntime } from './pythonLanguageRuntime';
2926
import { JupyterAdapterApi } from '../../jupyter-adapter.d'
@@ -42,8 +39,6 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy {
4239

4340
private readonly disposables: Disposable[] = [];
4441

45-
private readonly languageClients: LanguageClient[] = [];
46-
4742
private extensionVersion: string | undefined;
4843

4944
private readonly installer: IInstaller;
@@ -119,16 +114,6 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy {
119114
const r = this.disposables.shift()!;
120115
r.dispose();
121116
}
122-
123-
// Dispose of any language clients
124-
for (const client of this.languageClients) {
125-
try {
126-
await client.stop();
127-
await client.dispose();
128-
} catch (ex) {
129-
traceError('Stopping language client failed', ex);
130-
}
131-
}
132117
}
133118

134119
public dispose(): void {
@@ -242,17 +227,7 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy {
242227

243228
// Create an adapter for the kernel as our language runtime
244229
const runtime = new PythonLanguageRuntime(
245-
kernelSpec, metadata, jupyterAdapterApi);
246-
247-
runtime.onDidChangeRuntimeState((state) => {
248-
if (state === positron.RuntimeState.Ready) {
249-
jupyterAdapterApi.findAvailablePort([], 25).then((port: number) => {
250-
runtime.emitJupyterLog(`Starting Positron LSP server on port ${port}`);
251-
runtime.startPositronLsp(`127.0.0.1:${port}`);
252-
this.startClient(options, port).ignoreErrors();
253-
});
254-
}
255-
});
230+
kernelSpec, metadata, jupyterAdapterApi, options);
256231

257232
// Register our language runtime provider
258233
return positron.runtime.registerLanguageRuntime(runtime);
@@ -295,77 +270,7 @@ export class PositronJediLanguageServerProxy implements ILanguageServerProxy {
295270
return `${info.major}.${info.minor}.${info.patch}`;
296271
}
297272

298-
/**
299-
* Start the language client
300-
*/
301-
private async startClient(clientOptions: LanguageClientOptions, port: number): Promise<void> {
302-
303-
// Configure language client to connect to LSP via TCP on start
304-
const serverOptions: ServerOptions = async () => this.getServerOptions(port);
305-
const client = new LanguageClient(PYTHON_LANGUAGE, 'Positron Python Jedi', serverOptions, clientOptions);
306-
this.registerHandlers(client);
307-
await client.start();
308-
this.languageClients.push(client);
309-
}
310-
311-
/**
312-
* An async function used by the LanguageClient to establish a connection to the LSP on start.
313-
* Several attempts to connect are made given recently spawned servers may not be ready immediately
314-
* for client connections.
315-
* @param port the LSP port
316-
*/
317-
private async getServerOptions(port: number): Promise<StreamInfo> {
318-
319-
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
320-
const maxAttempts = 20;
321-
const baseDelay = 50;
322-
const multiplier = 1.5;
323-
324-
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
325-
// Retry up to five times then start to back-off
326-
const interval = attempt < 6 ? baseDelay : baseDelay * multiplier * attempt;
327-
if (attempt > 0) {
328-
await delay(interval);
329-
}
330-
331-
try {
332-
// Try to connect to LSP port
333-
const socket: Socket = await this.tryToConnect(port);
334-
return { reader: socket, writer: socket };
335-
} catch (error: any) {
336-
if (error?.code === 'ECONNREFUSED') {
337-
traceVerbose(`Error '${error.message}' on connection attempt '${attempt}' to Jedi LSP on port '${port}', will retry`);
338-
} else {
339-
throw error;
340-
}
341-
}
342-
}
343-
344-
throw new Error(`Failed to create TCP connection to Jedi LSP on port ${port} after multiple attempts`);
345-
}
346-
347-
/**
348-
* Attempts to establish a TCP socket connection to the given port
349-
* @param port the server port to connect to
350-
*/
351-
private async tryToConnect(port: number): Promise<Socket> {
352-
return new Promise((resolve, reject) => {
353-
const socket = new Socket();
354-
socket.on('ready', () => {
355-
resolve(socket);
356-
});
357-
socket.on('error', (error) => {
358-
reject(error);
359-
});
360-
socket.connect(port);
361-
});
362-
}
363-
364-
private registerHandlers(client: LanguageClient) {
365-
const progressReporting = new ProgressReporting(client);
366-
this.disposables.push(progressReporting);
367-
}
368-
273+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
369274
private withActiveExtention(ext: vscode.Extension<any>, callback: () => void) {
370275
if (ext.isActive) {
371276
callback();

extensions/positron-python/src/client/activation/jedi/pythonLanguageRuntime.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
1+
/* eslint-disable class-methods-use-this */
12
/*---------------------------------------------------------------------------------------------
23
* Copyright (C) 2023 Posit Software, PBC. All rights reserved.
34
*--------------------------------------------------------------------------------------------*/
45

6+
import { Socket } from 'net';
7+
58
// eslint-disable-next-line import/no-unresolved
69
import * as positron from 'positron';
710
import * as vscode from 'vscode';
11+
import { Disposable, LanguageClient, LanguageClientOptions, ServerOptions, StreamInfo } from 'vscode-languageclient/node';
12+
import { PYTHON_LANGUAGE } from '../../common/constants';
813
import { JupyterAdapterApi, JupyterKernelSpec, JupyterLanguageRuntime } from '../../jupyter-adapter.d';
14+
import { ProgressReporting } from '../progress';
915

1016
/**
1117
* A Positron Python language runtime that wraps a Jupyter kernel runtime.
1218
* Fulfills most Language Runtime API calls by delegating to the wrapped kernel.
1319
*/
14-
export class PythonLanguageRuntime implements JupyterLanguageRuntime {
20+
export class PythonLanguageRuntime implements JupyterLanguageRuntime, Disposable {
1521

1622
/** The Jupyter kernel-based implementation of the Language Runtime API */
1723
private _kernel: JupyterLanguageRuntime;
1824

25+
private _lsp: LanguageClient | undefined;
26+
27+
private readonly disposables: Disposable[] = [];
28+
1929
/**
2030
* Create a new PythonLanguageRuntime object to wrap a Jupyter kernel.
2131
*
@@ -26,12 +36,17 @@ export class PythonLanguageRuntime implements JupyterLanguageRuntime {
2636
constructor(
2737
readonly kernelSpec: JupyterKernelSpec,
2838
readonly metadata: positron.LanguageRuntimeMetadata,
29-
readonly adapterApi: JupyterAdapterApi) {
39+
readonly adapterApi: JupyterAdapterApi,
40+
readonly languageClientOptions: LanguageClientOptions) {
3041

3142
this._kernel = adapterApi.adaptKernel(kernelSpec, metadata);
3243

3344
this.onDidChangeRuntimeState = this._kernel.onDidChangeRuntimeState;
3445
this.onDidReceiveRuntimeMessage = this._kernel.onDidReceiveRuntimeMessage;
46+
47+
this.onDidChangeRuntimeState((state) => {
48+
this.onStateChange(state);
49+
});
3550
}
3651

3752
startPositronLsp(clientAddress: string): void {
@@ -85,10 +100,115 @@ export class PythonLanguageRuntime implements JupyterLanguageRuntime {
85100
}
86101

87102
async restart(): Promise<void> {
103+
104+
// Stop the LSP (it will restart after the kernel restarts)
105+
if (this._lsp) {
106+
await this._lsp.stop();
107+
}
108+
109+
// Restart the kernel
88110
return this._kernel.restart();
89111
}
90112

91113
async shutdown(): Promise<void> {
114+
115+
// Stop the LSP
116+
if (this._lsp) {
117+
await this._lsp.stop();
118+
}
119+
120+
// Shutdown the kernel
92121
return this._kernel.shutdown();
93122
}
123+
124+
dispose(): void {
125+
while (this.disposables.length > 0) {
126+
const d = this.disposables.shift()!;
127+
d.dispose();
128+
}
129+
}
130+
131+
private onStateChange(state: positron.RuntimeState): void {
132+
if (state === positron.RuntimeState.Ready) {
133+
this.adapterApi.findAvailablePort([], 25).then((port: number) => {
134+
this.emitJupyterLog(`Starting Positron LSP server on port ${port}`);
135+
this.startPositronLsp(`127.0.0.1:${port}`);
136+
this.startLSPClient(port).ignoreErrors();
137+
});
138+
} else if (state === positron.RuntimeState.Exiting ||
139+
state === positron.RuntimeState.Exited) {
140+
if (this._lsp) {
141+
this._lsp.stop().ignoreErrors();
142+
}
143+
}
144+
}
145+
146+
// LSP Client Helpers
147+
148+
private async startLSPClient(port: number): Promise<void> {
149+
150+
const serverOptions: ServerOptions = async () => this.getLSPServerOptions(port);
151+
const client = new LanguageClient(PYTHON_LANGUAGE, 'Positron Python Jedi', serverOptions, this.languageClientOptions);
152+
this.disposables.push(client);
153+
154+
const progressReporting = new ProgressReporting(client);
155+
this.disposables.push(progressReporting);
156+
157+
await client.start();
158+
this._lsp = client;
159+
}
160+
161+
/**
162+
* An async function used by the LanguageClient to establish a connection to the LSP on start.
163+
* Several attempts to connect are made given recently spawned servers may not be ready immediately
164+
* for client connections.
165+
* @param port the LSP port
166+
*/
167+
private async getLSPServerOptions(port: number): Promise<StreamInfo> {
168+
169+
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
170+
const maxAttempts = 20;
171+
const baseDelay = 50;
172+
const multiplier = 1.5;
173+
174+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
175+
// Retry up to five times then start to back-off
176+
const interval = attempt < 6 ? baseDelay : baseDelay * multiplier * attempt;
177+
if (attempt > 0) {
178+
await delay(interval);
179+
}
180+
181+
try {
182+
// Try to connect to LSP port
183+
const socket: Socket = await this.tryToConnect(port);
184+
return { reader: socket, writer: socket };
185+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
186+
} catch (error: any) {
187+
if (error?.code === 'ECONNREFUSED') {
188+
this.emitJupyterLog(`Error '${error.message}' on connection attempt '${attempt}' to Jedi LSP on port '${port}', will retry`);
189+
} else {
190+
throw error;
191+
}
192+
}
193+
}
194+
195+
throw new Error(`Failed to create TCP connection to Jedi LSP on port ${port} after multiple attempts`);
196+
}
197+
198+
/**
199+
* Attempts to establish a TCP socket connection to the given port
200+
* @param port the server port to connect to
201+
*/
202+
private async tryToConnect(port: number): Promise<Socket> {
203+
return new Promise((resolve, reject) => {
204+
const socket = new Socket();
205+
socket.on('ready', () => {
206+
resolve(socket);
207+
});
208+
socket.on('error', (error) => {
209+
reject(error);
210+
});
211+
socket.connect(port);
212+
});
213+
}
94214
}

0 commit comments

Comments
 (0)