Skip to content

Commit 15280c6

Browse files
committed
chore: code review comments from src/kernels/deepnote/deepnoteToolkitInstaller.node.ts
1 parent c4e6bb0 commit 15280c6

File tree

10 files changed

+217
-27
lines changed

10 files changed

+217
-27
lines changed

src/kernels/deepnote/deepnoteServerProvider.node.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { IDisposableRegistry } from '../../platform/common/types';
99
import { IJupyterServerProviderRegistry } from '../jupyter/types';
1010
import { JVSC_EXTENSION_ID } from '../../platform/common/constants';
1111
import { logger } from '../../platform/logging';
12+
import { DeepnoteServerNotFoundError } from '../../platform/errors/deepnoteServerNotFoundError';
1213
import { DeepnoteServerInfo } from './types';
1314

1415
/**
@@ -79,7 +80,7 @@ export class DeepnoteServerProvider implements IExtensionSyncActivationService,
7980
const serverInfo = this.servers.get(server.id);
8081

8182
if (!serverInfo) {
82-
throw new Error(`Deepnote server not found: ${server.id}`);
83+
throw new DeepnoteServerNotFoundError(server.id);
8384
}
8485

8586
return {

src/kernels/deepnote/deepnoteServerStarter.node.ts

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
// Licensed under the MIT License.
33

44
import { inject, injectable, named } from 'inversify';
5-
import { Uri } from 'vscode';
5+
import { CancellationToken, Uri } from 'vscode';
66
import { PythonEnvironment } from '../../platform/pythonEnvironments/info';
77
import { IDeepnoteServerStarter, IDeepnoteToolkitInstaller, DeepnoteServerInfo, DEEPNOTE_DEFAULT_PORT } from './types';
88
import { IProcessServiceFactory, ObservableExecutionResult } from '../../platform/common/process/types.node';
99
import { logger } from '../../platform/logging';
10-
import { IOutputChannel, IDisposable } from '../../platform/common/types';
10+
import { IOutputChannel, IDisposable, IHttpClient } from '../../platform/common/types';
1111
import { STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants';
1212
import { sleep } from '../../platform/common/utils/async';
13-
import { HttpClient } from '../../platform/common/net/httpClient';
13+
import { Cancellation, raceCancellationError } from '../../platform/common/cancellation';
1414
import getPort from 'get-port';
1515

1616
/**
@@ -21,31 +21,74 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter {
2121
private readonly serverProcesses: Map<string, ObservableExecutionResult<string>> = new Map();
2222
private readonly serverInfos: Map<string, DeepnoteServerInfo> = new Map();
2323
private readonly disposablesByFile: Map<string, IDisposable[]> = new Map();
24-
private readonly httpClient = new HttpClient();
24+
// Track in-flight operations per file to prevent concurrent start/stop
25+
private readonly pendingOperations: Map<string, Promise<DeepnoteServerInfo | void>> = new Map();
2526

2627
constructor(
2728
@inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory,
2829
@inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller,
29-
@inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel
30+
@inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel,
31+
@inject(IHttpClient) private readonly httpClient: IHttpClient
3032
) {}
3133

32-
public async getOrStartServer(interpreter: PythonEnvironment, deepnoteFileUri: Uri): Promise<DeepnoteServerInfo> {
34+
public async getOrStartServer(
35+
interpreter: PythonEnvironment,
36+
deepnoteFileUri: Uri,
37+
token?: CancellationToken
38+
): Promise<DeepnoteServerInfo> {
3339
const fileKey = deepnoteFileUri.fsPath;
3440

41+
// Wait for any pending operations on this file to complete
42+
const pendingOp = this.pendingOperations.get(fileKey);
43+
if (pendingOp) {
44+
logger.info(`Waiting for pending operation on ${fileKey} to complete...`);
45+
try {
46+
await pendingOp;
47+
} catch {
48+
// Ignore errors from previous operations
49+
}
50+
}
51+
3552
// If server is already running for this file, return existing info
3653
const existingServerInfo = this.serverInfos.get(fileKey);
3754
if (existingServerInfo && (await this.isServerRunning(existingServerInfo))) {
3855
logger.info(`Deepnote server already running at ${existingServerInfo.url} for ${fileKey}`);
3956
return existingServerInfo;
4057
}
4158

59+
// Start the operation and track it
60+
const operation = this.startServerImpl(interpreter, deepnoteFileUri, token);
61+
this.pendingOperations.set(fileKey, operation);
62+
63+
try {
64+
const result = await operation;
65+
return result;
66+
} finally {
67+
// Remove from pending operations when done
68+
if (this.pendingOperations.get(fileKey) === operation) {
69+
this.pendingOperations.delete(fileKey);
70+
}
71+
}
72+
}
73+
74+
private async startServerImpl(
75+
interpreter: PythonEnvironment,
76+
deepnoteFileUri: Uri,
77+
token?: CancellationToken
78+
): Promise<DeepnoteServerInfo> {
79+
const fileKey = deepnoteFileUri.fsPath;
80+
81+
Cancellation.throwIfCanceled(token);
82+
4283
// Ensure toolkit is installed
4384
logger.info(`Ensuring deepnote-toolkit is installed for ${fileKey}...`);
44-
const installed = await this.toolkitInstaller.ensureInstalled(interpreter, deepnoteFileUri);
85+
const installed = await this.toolkitInstaller.ensureInstalled(interpreter, deepnoteFileUri, token);
4586
if (!installed) {
4687
throw new Error('Failed to install deepnote-toolkit. Please check the output for details.');
4788
}
4889

90+
Cancellation.throwIfCanceled(token);
91+
4992
// Find available port
5093
const port = await getPort({ host: 'localhost', port: DEEPNOTE_DEFAULT_PORT });
5194
logger.info(`Starting deepnote-toolkit server on port ${port} for ${fileKey}`);
@@ -88,7 +131,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter {
88131
const serverInfo = { url, port };
89132
this.serverInfos.set(fileKey, serverInfo);
90133

91-
const serverReady = await this.waitForServer(serverInfo, 30000);
134+
const serverReady = await this.waitForServer(serverInfo, 30000, token);
92135
if (!serverReady) {
93136
await this.stopServer(deepnoteFileUri);
94137
throw new Error('Deepnote server failed to start within timeout period');
@@ -102,6 +145,34 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter {
102145

103146
public async stopServer(deepnoteFileUri: Uri): Promise<void> {
104147
const fileKey = deepnoteFileUri.fsPath;
148+
149+
// Wait for any pending operations on this file to complete
150+
const pendingOp = this.pendingOperations.get(fileKey);
151+
if (pendingOp) {
152+
logger.info(`Waiting for pending operation on ${fileKey} before stopping...`);
153+
try {
154+
await pendingOp;
155+
} catch {
156+
// Ignore errors from previous operations
157+
}
158+
}
159+
160+
// Start the stop operation and track it
161+
const operation = this.stopServerImpl(deepnoteFileUri);
162+
this.pendingOperations.set(fileKey, operation);
163+
164+
try {
165+
await operation;
166+
} finally {
167+
// Remove from pending operations when done
168+
if (this.pendingOperations.get(fileKey) === operation) {
169+
this.pendingOperations.delete(fileKey);
170+
}
171+
}
172+
}
173+
174+
private async stopServerImpl(deepnoteFileUri: Uri): Promise<void> {
175+
const fileKey = deepnoteFileUri.fsPath;
105176
const serverProcess = this.serverProcesses.get(fileKey);
106177

107178
if (serverProcess) {
@@ -123,13 +194,18 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter {
123194
}
124195
}
125196

126-
private async waitForServer(serverInfo: DeepnoteServerInfo, timeout: number): Promise<boolean> {
197+
private async waitForServer(
198+
serverInfo: DeepnoteServerInfo,
199+
timeout: number,
200+
token?: CancellationToken
201+
): Promise<boolean> {
127202
const startTime = Date.now();
128203
while (Date.now() - startTime < timeout) {
204+
Cancellation.throwIfCanceled(token);
129205
if (await this.isServerRunning(serverInfo)) {
130206
return true;
131207
}
132-
await sleep(500);
208+
await raceCancellationError(token, sleep(500));
133209
}
134210
return false;
135211
}
@@ -143,4 +219,35 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter {
143219
return false;
144220
}
145221
}
222+
223+
public dispose(): void {
224+
logger.info('Disposing DeepnoteServerStarter - stopping all servers...');
225+
226+
// Stop all server processes
227+
for (const [fileKey, serverProcess] of this.serverProcesses.entries()) {
228+
try {
229+
logger.info(`Stopping Deepnote server for ${fileKey}...`);
230+
serverProcess.proc?.kill();
231+
} catch (ex) {
232+
logger.error(`Error stopping Deepnote server for ${fileKey}: ${ex}`);
233+
}
234+
}
235+
236+
// Dispose all tracked disposables
237+
for (const [fileKey, disposables] of this.disposablesByFile.entries()) {
238+
try {
239+
disposables.forEach((d) => d.dispose());
240+
} catch (ex) {
241+
logger.error(`Error disposing resources for ${fileKey}: ${ex}`);
242+
}
243+
}
244+
245+
// Clear all maps
246+
this.serverProcesses.clear();
247+
this.serverInfos.clear();
248+
this.disposablesByFile.clear();
249+
this.pendingOperations.clear();
250+
251+
logger.info('DeepnoteServerStarter disposed successfully');
252+
}
146253
}

src/kernels/deepnote/deepnoteToolkitInstaller.node.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,24 @@
22
// Licensed under the MIT License.
33

44
import { inject, injectable, named } from 'inversify';
5-
import { Uri } from 'vscode';
5+
import { CancellationToken, Uri } from 'vscode';
66
import { PythonEnvironment } from '../../platform/pythonEnvironments/info';
77
import { IDeepnoteToolkitInstaller, DEEPNOTE_TOOLKIT_WHEEL_URL } from './types';
88
import { IProcessServiceFactory } from '../../platform/common/process/types.node';
99
import { logger } from '../../platform/logging';
1010
import { IOutputChannel, IExtensionContext } from '../../platform/common/types';
1111
import { STANDARD_OUTPUT_CHANNEL } from '../../platform/common/constants';
1212
import { IFileSystem } from '../../platform/common/platform/types';
13+
import { Cancellation } from '../../platform/common/cancellation';
1314

1415
/**
1516
* Handles installation of the deepnote-toolkit Python package.
1617
*/
1718
@injectable()
1819
export class DeepnoteToolkitInstaller implements IDeepnoteToolkitInstaller {
1920
private readonly venvPythonPaths: Map<string, Uri> = new Map();
21+
// Track in-flight installations per venv path to prevent concurrent installs
22+
private readonly pendingInstallations: Map<string, Promise<PythonEnvironment | undefined>> = new Map();
2023

2124
constructor(
2225
@inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory,
@@ -56,17 +59,54 @@ export class DeepnoteToolkitInstaller implements IDeepnoteToolkitInstaller {
5659

5760
public async ensureInstalled(
5861
baseInterpreter: PythonEnvironment,
59-
deepnoteFileUri: Uri
62+
deepnoteFileUri: Uri,
63+
token?: CancellationToken
6064
): Promise<PythonEnvironment | undefined> {
6165
const venvPath = this.getVenvPath(deepnoteFileUri);
66+
const venvKey = venvPath.fsPath;
67+
68+
// Wait for any pending installation for this venv to complete
69+
const pendingInstall = this.pendingInstallations.get(venvKey);
70+
if (pendingInstall) {
71+
logger.info(`Waiting for pending installation for ${venvKey} to complete...`);
72+
try {
73+
return await pendingInstall;
74+
} catch {
75+
// If the previous installation failed, continue to retry
76+
logger.info(`Previous installation for ${venvKey} failed, retrying...`);
77+
}
78+
}
79+
80+
// Check if venv already exists with toolkit installed
81+
const existingVenv = await this.getVenvInterpreter(deepnoteFileUri);
82+
if (existingVenv && (await this.isToolkitInstalled(existingVenv))) {
83+
logger.info(`deepnote-toolkit venv already exists and is ready for ${deepnoteFileUri.fsPath}`);
84+
return existingVenv;
85+
}
86+
87+
// Start the installation and track it
88+
const installation = this.installImpl(baseInterpreter, deepnoteFileUri, venvPath, token);
89+
this.pendingInstallations.set(venvKey, installation);
6290

6391
try {
64-
// Check if venv already exists with toolkit installed
65-
const existingVenv = await this.getVenvInterpreter(deepnoteFileUri);
66-
if (existingVenv && (await this.isToolkitInstalled(existingVenv))) {
67-
logger.info(`deepnote-toolkit venv already exists and is ready for ${deepnoteFileUri.fsPath}`);
68-
return existingVenv;
92+
const result = await installation;
93+
return result;
94+
} finally {
95+
// Remove from pending installations when done
96+
if (this.pendingInstallations.get(venvKey) === installation) {
97+
this.pendingInstallations.delete(venvKey);
6998
}
99+
}
100+
}
101+
102+
private async installImpl(
103+
baseInterpreter: PythonEnvironment,
104+
deepnoteFileUri: Uri,
105+
venvPath: Uri,
106+
token?: CancellationToken
107+
): Promise<PythonEnvironment | undefined> {
108+
try {
109+
Cancellation.throwIfCanceled(token);
70110

71111
logger.info(`Creating virtual environment at ${venvPath.fsPath} for ${deepnoteFileUri.fsPath}`);
72112
this.outputChannel.appendLine(`Setting up Deepnote toolkit environment for ${deepnoteFileUri.fsPath}...`);
@@ -93,6 +133,8 @@ export class DeepnoteToolkitInstaller implements IDeepnoteToolkitInstaller {
93133
return undefined;
94134
}
95135

136+
Cancellation.throwIfCanceled(token);
137+
96138
// Get venv Python interpreter
97139
const venvInterpreter = await this.getVenvInterpreter(deepnoteFileUri);
98140
if (!venvInterpreter) {
@@ -111,6 +153,8 @@ export class DeepnoteToolkitInstaller implements IDeepnoteToolkitInstaller {
111153
{ throwOnStdErr: false }
112154
);
113155

156+
Cancellation.throwIfCanceled(token);
157+
114158
if (installResult.stdout) {
115159
this.outputChannel.appendLine(installResult.stdout);
116160
}

src/kernels/deepnote/types.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,13 @@ export interface IDeepnoteToolkitInstaller {
7171
* Ensures deepnote-toolkit is installed in a dedicated virtual environment.
7272
* @param baseInterpreter The base Python interpreter to use for creating the venv
7373
* @param deepnoteFileUri The URI of the .deepnote file (used to create a unique venv per file)
74+
* @param token Cancellation token to cancel the operation
7475
* @returns The Python interpreter from the venv if installed successfully, undefined otherwise
7576
*/
7677
ensureInstalled(
7778
baseInterpreter: PythonEnvironment,
78-
deepnoteFileUri: vscode.Uri
79+
deepnoteFileUri: vscode.Uri,
80+
token?: vscode.CancellationToken
7981
): Promise<PythonEnvironment | undefined>;
8082

8183
/**
@@ -86,14 +88,19 @@ export interface IDeepnoteToolkitInstaller {
8688
}
8789

8890
export const IDeepnoteServerStarter = Symbol('IDeepnoteServerStarter');
89-
export interface IDeepnoteServerStarter {
91+
export interface IDeepnoteServerStarter extends vscode.Disposable {
9092
/**
9193
* Starts or gets an existing deepnote-toolkit Jupyter server.
9294
* @param interpreter The Python interpreter to use
9395
* @param deepnoteFileUri The URI of the .deepnote file (for server management per file)
96+
* @param token Cancellation token to cancel the operation
9497
* @returns Connection information (URL, port, etc.)
9598
*/
96-
getOrStartServer(interpreter: PythonEnvironment, deepnoteFileUri: vscode.Uri): Promise<DeepnoteServerInfo>;
99+
getOrStartServer(
100+
interpreter: PythonEnvironment,
101+
deepnoteFileUri: vscode.Uri,
102+
token?: vscode.CancellationToken
103+
): Promise<DeepnoteServerInfo>;
97104

98105
/**
99106
* Stops the deepnote-toolkit server if running.
@@ -112,8 +119,10 @@ export const IDeepnoteKernelAutoSelector = Symbol('IDeepnoteKernelAutoSelector')
112119
export interface IDeepnoteKernelAutoSelector {
113120
/**
114121
* Automatically selects and starts a Deepnote kernel for the given notebook.
122+
* @param notebook The notebook document
123+
* @param token Cancellation token to cancel the operation
115124
*/
116-
ensureKernelSelected(notebook: vscode.NotebookDocument): Promise<void>;
125+
ensureKernelSelected(notebook: vscode.NotebookDocument, token?: vscode.CancellationToken): Promise<void>;
117126
}
118127

119128
export const DEEPNOTE_TOOLKIT_WHEEL_URL =

0 commit comments

Comments
 (0)