Skip to content

Commit 9bcb1b1

Browse files
committed
New approach to persistent Jupyter kernel using Jupyter server
1 parent 259f623 commit 9bcb1b1

File tree

4 files changed

+1110
-0
lines changed

4 files changed

+1110
-0
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { inject, injectable, optional } from 'inversify';
5+
import { Uri } from 'vscode';
6+
import type { ServerConnection } from '@jupyterlab/services';
7+
import { logger } from '../../../platform/logging';
8+
import { DataScience } from '../../../platform/common/utils/localize';
9+
import { IInterpreterService } from '../../../platform/interpreter/contracts';
10+
import { JupyterInstallError } from '../../../platform/errors/jupyterInstallError';
11+
import { GetServerOptions, IJupyterConnection } from '../../types';
12+
import { IJupyterServerHelper, IJupyterServerProvider } from '../types';
13+
import { NotSupportedInWebError } from '../../../platform/errors/notSupportedInWebError';
14+
import { getFilePath } from '../../../platform/common/platform/fs-paths';
15+
import { Cancellation, isCancellationError } from '../../../platform/common/cancellation';
16+
import { getPythonEnvDisplayName } from '../../../platform/interpreter/helpers';
17+
import { IPersistentServerStorage, IPersistentServerInfo } from './persistentServerStorage';
18+
import { generateUuid } from '../../../platform/common/uuid';
19+
import { DisposableBase } from '../../../platform/common/utils/lifecycle';
20+
21+
/**
22+
* Jupyter server provider that launches persistent servers that outlast VS Code sessions.
23+
* These servers can be reconnected to after the extension restarts.
24+
*/
25+
@injectable()
26+
export class PersistentJupyterServerProvider extends DisposableBase implements IJupyterServerProvider {
27+
private serverConnections = new Map<string, Promise<IJupyterConnection>>();
28+
29+
constructor(
30+
@inject(IJupyterServerHelper)
31+
@optional()
32+
private readonly jupyterServerHelper: IJupyterServerHelper | undefined,
33+
@inject(IInterpreterService) private readonly interpreterService: IInterpreterService,
34+
@inject(IPersistentServerStorage) private readonly persistentServerStorage: IPersistentServerStorage
35+
) {
36+
super();
37+
}
38+
39+
public async getOrStartServer(options: GetServerOptions): Promise<IJupyterConnection> {
40+
const workingDirectory = options.resource || Uri.file(process.cwd());
41+
const serverId = this.getServerIdForWorkspace(workingDirectory);
42+
43+
// Check if we already have a connection promise for this server
44+
if (this.serverConnections.has(serverId)) {
45+
const existingConnection = this.serverConnections.get(serverId)!;
46+
try {
47+
return await existingConnection;
48+
} catch (error) {
49+
// Connection failed, remove from cache and try again
50+
this.serverConnections.delete(serverId);
51+
}
52+
}
53+
54+
// Try to reconnect to an existing persistent server first
55+
const existingServer = this.findExistingServerForWorkspace(workingDirectory);
56+
if (existingServer) {
57+
logger.debug(`Attempting to reconnect to persistent server: ${existingServer.serverId}`);
58+
const connectionPromise = this.reconnectToServer(existingServer);
59+
this.serverConnections.set(serverId, connectionPromise);
60+
61+
try {
62+
return await connectionPromise;
63+
} catch (error) {
64+
logger.warn(`Failed to reconnect to persistent server ${existingServer.serverId}:`, error);
65+
// Remove the failed server from storage and fall through to create a new one
66+
await this.persistentServerStorage.remove(existingServer.serverId);
67+
this.serverConnections.delete(serverId);
68+
}
69+
}
70+
71+
// No existing server or reconnection failed, start a new persistent server
72+
logger.debug(`Starting new persistent server for workspace: ${workingDirectory.fsPath}`);
73+
const connectionPromise = this.startNewPersistentServer(workingDirectory, options);
74+
this.serverConnections.set(serverId, connectionPromise);
75+
76+
return connectionPromise;
77+
}
78+
79+
private getServerIdForWorkspace(workingDirectory: Uri): string {
80+
// Use the workspace path as the key for identifying servers
81+
return `persistent-${workingDirectory.fsPath}`;
82+
}
83+
84+
private findExistingServerForWorkspace(workingDirectory: Uri): IPersistentServerInfo | undefined {
85+
const servers = this.persistentServerStorage.all;
86+
return servers.find(
87+
(server) => server.workingDirectory === workingDirectory.fsPath && server.launchedByExtension
88+
);
89+
}
90+
91+
private async reconnectToServer(serverInfo: IPersistentServerInfo): Promise<IJupyterConnection> {
92+
// Validate that the server is still running by trying to connect
93+
const baseUrl = serverInfo.url.split('?')[0]; // Remove token from URL
94+
const serverProviderHandle = {
95+
id: 'persistent-server-provider',
96+
handle: serverInfo.serverId,
97+
extensionId: 'ms-toolsai.jupyter'
98+
};
99+
100+
const connection: IJupyterConnection = {
101+
baseUrl,
102+
token: serverInfo.token,
103+
hostName: new URL(serverInfo.url).hostname,
104+
displayName: serverInfo.displayName,
105+
providerId: 'persistent-server-provider',
106+
serverProviderHandle,
107+
dispose: () => {
108+
// Don't dispose the server - it should persist
109+
logger.debug(
110+
`Connection to persistent server ${serverInfo.serverId} disposed, but server continues running`
111+
);
112+
},
113+
rootDirectory: Uri.file(serverInfo.workingDirectory),
114+
getAuthHeader: () => ({ Authorization: `token ${serverInfo.token}` }),
115+
settings: {
116+
baseUrl,
117+
token: serverInfo.token,
118+
websocket: null,
119+
init: {},
120+
fetch: global.fetch?.bind(global) || require('node-fetch')
121+
} as unknown as ServerConnection.ISettings
122+
};
123+
124+
// TODO: Add validation that the server is actually accessible
125+
// For now, we'll trust that the stored server info is valid
126+
127+
// Update the last used time
128+
await this.persistentServerStorage.update(serverInfo.serverId, { time: Date.now() });
129+
130+
return connection;
131+
}
132+
133+
private async startNewPersistentServer(
134+
workingDirectory: Uri,
135+
options: GetServerOptions
136+
): Promise<IJupyterConnection> {
137+
const jupyterServerHelper = this.jupyterServerHelper;
138+
if (!jupyterServerHelper) {
139+
throw new NotSupportedInWebError();
140+
}
141+
142+
// Check if Jupyter is usable
143+
const usable = await this.checkUsable();
144+
if (!usable) {
145+
logger.trace('Server not usable (should ask for install now)');
146+
throw new JupyterInstallError(
147+
DataScience.jupyterNotSupported(await jupyterServerHelper.getJupyterServerError())
148+
);
149+
}
150+
151+
try {
152+
logger.debug(`Starting new persistent Jupyter server for: ${workingDirectory.fsPath}`);
153+
154+
// Start the server with persistent arguments
155+
const connection = await this.startPersistentJupyterServer(workingDirectory, options);
156+
157+
// Store the server information for future reconnection
158+
const serverId = generateUuid();
159+
const serverInfo: IPersistentServerInfo = {
160+
serverId,
161+
displayName: `Persistent Server (${workingDirectory.fsPath})`,
162+
url: `${connection.baseUrl}/?token=${connection.token}`,
163+
token: connection.token,
164+
workingDirectory: workingDirectory.fsPath,
165+
launchedByExtension: true,
166+
time: Date.now()
167+
};
168+
169+
await this.persistentServerStorage.add(serverInfo);
170+
171+
// Wrap the connection to prevent disposal of the persistent server
172+
const persistentConnection: IJupyterConnection = {
173+
...connection,
174+
dispose: () => {
175+
logger.debug(`Connection to persistent server ${serverId} disposed, but server continues running`);
176+
// Don't actually dispose the server
177+
}
178+
};
179+
180+
logger.info(`Successfully started persistent Jupyter server: ${serverId}`);
181+
return persistentConnection;
182+
} catch (error) {
183+
if (options.token?.isCancellationRequested && isCancellationError(error)) {
184+
throw error;
185+
}
186+
187+
await jupyterServerHelper.refreshCommands();
188+
throw error;
189+
}
190+
}
191+
192+
private async startPersistentJupyterServer(
193+
workingDirectory: Uri,
194+
options: GetServerOptions
195+
): Promise<IJupyterConnection> {
196+
const jupyterServerHelper = this.jupyterServerHelper!;
197+
198+
// Start the server with custom options for persistence
199+
const connection = await jupyterServerHelper.startServer(workingDirectory, options.token);
200+
Cancellation.throwIfCanceled(options.token);
201+
202+
return connection;
203+
}
204+
205+
private async checkUsable(): Promise<boolean> {
206+
try {
207+
if (this.jupyterServerHelper) {
208+
const usableInterpreter = await this.jupyterServerHelper.getUsableJupyterPython();
209+
return usableInterpreter ? true : false;
210+
} else {
211+
return true;
212+
}
213+
} catch (e) {
214+
const activeInterpreter = await this.interpreterService.getActiveInterpreter(undefined);
215+
// Can't find a usable interpreter, show the error.
216+
if (activeInterpreter) {
217+
const displayName = getPythonEnvDisplayName(activeInterpreter) || getFilePath(activeInterpreter.uri);
218+
throw new Error(DataScience.jupyterNotSupportedBecauseOfEnvironment(displayName, e.toString()));
219+
} else {
220+
throw new JupyterInstallError(
221+
DataScience.jupyterNotSupported(
222+
this.jupyterServerHelper ? await this.jupyterServerHelper.getJupyterServerError() : 'Error'
223+
)
224+
);
225+
}
226+
}
227+
}
228+
229+
/**
230+
* Get all persistent servers managed by this provider.
231+
*/
232+
public getAllPersistentServers(): IPersistentServerInfo[] {
233+
return this.persistentServerStorage.all.filter((server) => server.launchedByExtension);
234+
}
235+
236+
/**
237+
* Stop and remove a persistent server.
238+
*/
239+
public async stopPersistentServer(serverId: string): Promise<void> {
240+
const serverInfo = this.persistentServerStorage.get(serverId);
241+
if (!serverInfo) {
242+
logger.warn(`Persistent server ${serverId} not found in storage`);
243+
return;
244+
}
245+
246+
// TODO: Add logic to actually stop the server process if we have the PID
247+
// For now, just remove from storage
248+
await this.persistentServerStorage.remove(serverId);
249+
250+
// Remove from connection cache
251+
const workspaceServerId = this.getServerIdForWorkspace(Uri.file(serverInfo.workingDirectory));
252+
this.serverConnections.delete(workspaceServerId);
253+
254+
logger.info(`Persistent server ${serverId} stopped and removed`);
255+
}
256+
}

0 commit comments

Comments
 (0)