Skip to content

Commit fdc7d7b

Browse files
authored
Keep jupyter connections open and allow for expirations (#13267)
* Idea for refresh -expiration date * Add expiration functional test * Fixup after merge * Add news entry * Fix sonar problem. * Fix unit test compilation * Fix other unit test compile failure * Dispose timeouts * Try to get test to pass
1 parent 30af20a commit fdc7d7b

File tree

12 files changed

+242
-31
lines changed

12 files changed

+242
-31
lines changed

news/2 Fixes/12987.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow custom Jupyter server URI providers to have an expiration on their authorization headers.

src/client/datascience/jupyter/jupyterExecution.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,20 @@ import { IServiceContainer } from '../../ioc/types';
1717
import { PythonInterpreter } from '../../pythonEnvironments/info';
1818
import { captureTelemetry, sendTelemetryEvent } from '../../telemetry';
1919
import { JupyterSessionStartError } from '../baseJupyterSession';
20-
import { Commands, Telemetry } from '../constants';
20+
import { Commands, Identifiers, Telemetry } from '../constants';
2121
import { reportAction } from '../progress/decorator';
2222
import { ReportableAction } from '../progress/types';
2323
import {
2424
IJupyterConnection,
2525
IJupyterExecution,
26+
IJupyterServerUri,
2627
IJupyterSessionManagerFactory,
2728
IJupyterSubCommandExecutionService,
2829
IJupyterUriProviderRegistration,
2930
INotebookServer,
3031
INotebookServerLaunchInfo,
31-
INotebookServerOptions
32+
INotebookServerOptions,
33+
JupyterServerUriHandle
3234
} from '../types';
3335
import { JupyterSelfCertsError } from './jupyterSelfCertsError';
3436
import { createRemoteConnectionInfo } from './jupyterUtils';
@@ -44,6 +46,8 @@ export class JupyterExecutionBase implements IJupyterExecution {
4446
private disposed: boolean = false;
4547
private readonly jupyterInterpreterService: IJupyterSubCommandExecutionService;
4648
private readonly jupyterPickerRegistration: IJupyterUriProviderRegistration;
49+
private uriToJupyterServerUri = new Map<string, IJupyterServerUri>();
50+
private pendingTimeouts: (NodeJS.Timeout | number)[] = [];
4751

4852
constructor(
4953
_liveShare: ILiveShareApi,
@@ -72,6 +76,10 @@ export class JupyterExecutionBase implements IJupyterExecution {
7276
// When config changes happen, recreate our commands.
7377
this.onSettingsChanged();
7478
}
79+
if (e.affectsConfiguration('python.dataScience.jupyterServerURI', undefined)) {
80+
// When server URI changes, clear our pending URI timeouts
81+
this.clearTimeouts();
82+
}
7583
});
7684
this.disposableRegistry.push(disposable);
7785
}
@@ -83,6 +91,7 @@ export class JupyterExecutionBase implements IJupyterExecution {
8391

8492
public dispose(): Promise<void> {
8593
this.disposed = true;
94+
this.clearTimeouts();
8695
return Promise.resolve();
8796
}
8897

@@ -367,8 +376,11 @@ export class JupyterExecutionBase implements IJupyterExecution {
367376
throw new Error(localize.DataScience.jupyterNotebookFailure().format(''));
368377
}
369378
} else {
379+
// Prepare our map of server URIs
380+
await this.updateServerUri(options.uri);
381+
370382
// If we have a URI spec up a connection info for it
371-
return createRemoteConnectionInfo(options.uri, this.jupyterPickerRegistration);
383+
return createRemoteConnectionInfo(options.uri, this.getServerUri.bind(this));
372384
}
373385
}
374386

@@ -385,4 +397,45 @@ export class JupyterExecutionBase implements IJupyterExecution {
385397
// Clear our usableJupyterInterpreter so that we recompute our values
386398
this.usablePythonInterpreter = undefined;
387399
}
400+
401+
private extractJupyterServerHandleAndId(uri: string): { handle: JupyterServerUriHandle; id: string } | undefined {
402+
const url: URL = new URL(uri);
403+
404+
// Id has to be there too.
405+
const id = url.searchParams.get(Identifiers.REMOTE_URI_ID_PARAM);
406+
const uriHandle = url.searchParams.get(Identifiers.REMOTE_URI_HANDLE_PARAM);
407+
return id && uriHandle ? { handle: uriHandle, id } : undefined;
408+
}
409+
410+
private clearTimeouts() {
411+
// tslint:disable-next-line: no-any
412+
this.pendingTimeouts.forEach((t) => clearTimeout(t as any));
413+
this.pendingTimeouts = [];
414+
}
415+
416+
private getServerUri(uri: string): IJupyterServerUri | undefined {
417+
const idAndHandle = this.extractJupyterServerHandleAndId(uri);
418+
if (idAndHandle) {
419+
return this.uriToJupyterServerUri.get(uri);
420+
}
421+
}
422+
423+
private async updateServerUri(uri: string): Promise<void> {
424+
const idAndHandle = this.extractJupyterServerHandleAndId(uri);
425+
if (idAndHandle) {
426+
const serverUri = await this.jupyterPickerRegistration.getJupyterServerUri(
427+
idAndHandle.id,
428+
idAndHandle.handle
429+
);
430+
this.uriToJupyterServerUri.set(uri, serverUri);
431+
// See if there's an expiration date
432+
if (serverUri.expiration) {
433+
const timeoutInMS = serverUri.expiration.getTime() - Date.now();
434+
// Week seems long enough (in case the expiration is ridiculous)
435+
if (timeoutInMS > 0 && timeoutInMS < 604800000) {
436+
this.pendingTimeouts.push(setTimeout(() => this.updateServerUri(uri).ignoreErrors(), timeoutInMS));
437+
}
438+
}
439+
}
440+
}
388441
}

src/client/datascience/jupyter/jupyterRequest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import * as nodeFetch from 'node-fetch';
55
// Function for creating node Request object that prevents jupyterlab services from writing its own
66
// authorization header.
77
// tslint:disable: no-any
8-
export function createAuthorizingRequest(authorizationHeader: any) {
8+
export function createAuthorizingRequest(getAuthHeader: () => any) {
99
class AuthorizingRequest extends nodeFetch.Request {
1010
constructor(input: nodeFetch.RequestInfo, init?: nodeFetch.RequestInit) {
1111
super(input, init);
1212

1313
// Add all of the authorization parts onto the headers.
1414
const origHeaders = this.headers;
15+
const authorizationHeader = getAuthHeader();
1516
const keys = Object.keys(authorizationHeader);
1617
keys.forEach((k) => origHeaders.append(k, authorizationHeader[k].toString()));
1718
origHeaders.append('Content-Type', 'application/json');

src/client/datascience/jupyter/jupyterSessionManager.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,12 @@ export class JupyterSessionManager implements IJupyterSessionManager {
263263

264264
// If authorization header is provided, then we need to prevent jupyterlab services from
265265
// writing the authorization header.
266-
if (connInfo.authorizationHeader) {
267-
requestCtor = createAuthorizingRequest(connInfo.authorizationHeader);
266+
if (connInfo.getAuthHeader) {
267+
requestCtor = createAuthorizingRequest(connInfo.getAuthHeader);
268268
}
269269

270270
// If no token is specified prompt for a password
271-
if ((connInfo.token === '' || connInfo.token === 'null') && !connInfo.authorizationHeader) {
271+
if ((connInfo.token === '' || connInfo.token === 'null') && !connInfo.getAuthHeader) {
272272
if (this.failOnPassword) {
273273
throw new Error('Password request not allowed.');
274274
}
@@ -314,7 +314,7 @@ export class JupyterSessionManager implements IJupyterSessionManager {
314314
WebSocket: createJupyterWebSocket(
315315
cookieString,
316316
allowUnauthorized,
317-
connInfo.authorizationHeader
317+
connInfo.getAuthHeader
318318
// tslint:disable-next-line:no-any
319319
) as any,
320320
// Redefine fetch to our node-modules so it picks up the correct version.

src/client/datascience/jupyter/jupyterUtils.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import { Uri } from 'vscode';
99
import { IWorkspaceService } from '../../common/application/types';
1010
import { noop } from '../../common/utils/misc';
1111
import { SystemVariables } from '../../common/variables/systemVariables';
12-
import { Identifiers } from '../constants';
1312
import { getJupyterConnectionDisplayName } from '../jupyter/jupyterConnection';
14-
import { IJupyterConnection, IJupyterUriProviderRegistration } from '../types';
13+
import { IJupyterConnection, IJupyterServerUri } from '../types';
1514

1615
export function expandWorkingDir(
1716
workingDir: string | undefined,
@@ -27,10 +26,10 @@ export function expandWorkingDir(
2726
return path.dirname(launchingFile);
2827
}
2928

30-
export async function createRemoteConnectionInfo(
29+
export function createRemoteConnectionInfo(
3130
uri: string,
32-
providerRegistration: IJupyterUriProviderRegistration
33-
): Promise<IJupyterConnection> {
31+
getJupyterServerUri: (uri: string) => IJupyterServerUri | undefined
32+
): IJupyterConnection {
3433
let url: URL;
3534
try {
3635
url = new URL(uri);
@@ -39,14 +38,10 @@ export async function createRemoteConnectionInfo(
3938
throw err;
4039
}
4140

42-
const id = url.searchParams.get(Identifiers.REMOTE_URI_ID_PARAM);
43-
const uriHandle = url.searchParams.get(Identifiers.REMOTE_URI_HANDLE_PARAM);
44-
const serverUri = id && uriHandle ? await providerRegistration.getJupyterServerUri(id, uriHandle) : undefined;
45-
const baseUrl = serverUri && serverUri.baseUrl ? serverUri.baseUrl : `${url.protocol}//${url.host}${url.pathname}`;
46-
const token = serverUri && serverUri.token ? serverUri.token : `${url.searchParams.get('token')}`;
47-
const hostName = serverUri && serverUri.baseUrl ? new URL(serverUri.baseUrl).hostname : url.hostname;
48-
const displayName =
49-
serverUri && serverUri.displayName ? serverUri.displayName : getJupyterConnectionDisplayName(token, baseUrl);
41+
const serverUri = getJupyterServerUri(uri);
42+
const baseUrl = serverUri ? serverUri.baseUrl : `${url.protocol}//${url.host}${url.pathname}`;
43+
const token = serverUri ? serverUri.token : `${url.searchParams.get('token')}`;
44+
const hostName = serverUri ? new URL(serverUri.baseUrl).hostname : url.hostname;
5045

5146
return {
5247
type: 'jupyter',
@@ -56,11 +51,14 @@ export async function createRemoteConnectionInfo(
5651
localLaunch: false,
5752
localProcExitCode: undefined,
5853
valid: true,
59-
displayName,
54+
displayName:
55+
serverUri && serverUri.displayName
56+
? serverUri.displayName
57+
: getJupyterConnectionDisplayName(token, baseUrl),
6058
disconnected: (_l) => {
6159
return { dispose: noop };
6260
},
6361
dispose: noop,
64-
authorizationHeader: serverUri ? serverUri.authorizationHeader : undefined
62+
getAuthHeader: serverUri ? () => getJupyterServerUri(uri)?.authorizationHeader : undefined
6563
};
6664
}

src/client/datascience/jupyter/jupyterWebSocket.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
'use strict';
44
import * as WebSocketWS from 'ws';
55
import { traceError } from '../../common/logger';
6+
import { noop } from '../../common/utils/misc';
67
import { KernelSocketWrapper } from '../kernelSocketWrapper';
78
import { IKernelSocket } from '../types';
89

@@ -11,9 +12,10 @@ export const JupyterWebSockets = new Map<string, WebSocketWS & IKernelSocket>();
1112

1213
// We need to override the websocket that jupyter lab services uses to put in our cookie information
1314
// Do this as a function so that we can pass in variables the the socket will have local access to
14-
export function createJupyterWebSocket(cookieString?: string, allowUnauthorized?: boolean, authorizationHeader?: any) {
15+
export function createJupyterWebSocket(cookieString?: string, allowUnauthorized?: boolean, getAuthHeaders?: () => any) {
1516
class JupyterWebSocket extends KernelSocketWrapper(WebSocketWS) {
1617
private kernelId: string | undefined;
18+
private timer: NodeJS.Timeout | number;
1719

1820
constructor(url: string, protocols?: string | string[] | undefined) {
1921
let co: WebSocketWS.ClientOptions = {};
@@ -26,7 +28,11 @@ export function createJupyterWebSocket(cookieString?: string, allowUnauthorized?
2628
if (cookieString) {
2729
co_headers = { Cookie: cookieString };
2830
}
29-
if (authorizationHeader) {
31+
32+
// Auth headers have to be refetched every time we create a connection. They may have expired
33+
// since the last connection.
34+
if (getAuthHeaders) {
35+
const authorizationHeader = getAuthHeaders();
3036
co_headers = co_headers ? { ...co_headers, ...authorizationHeader } : authorizationHeader;
3137
}
3238
if (co_headers) {
@@ -43,11 +49,15 @@ export function createJupyterWebSocket(cookieString?: string, allowUnauthorized?
4349
if (this.kernelId) {
4450
JupyterWebSockets.set(this.kernelId, this);
4551
this.on('close', () => {
52+
clearInterval(this.timer as any);
4653
JupyterWebSockets.delete(this.kernelId!);
4754
});
4855
} else {
4956
traceError('KernelId not extracted from Kernel WebSocket URL');
5057
}
58+
59+
// Ping the websocket connection every 30 seconds to make sure it stays alive
60+
this.timer = setInterval(() => this.ping(noop), 30_000);
5161
}
5262
}
5363
return JupyterWebSocket;

src/client/datascience/jupyterUriProviderRegistration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class JupyterUriProviderRegistration implements IJupyterUriProviderRegist
7777
const frames = stack.split('\n').map((f) => {
7878
const result = /\((.*)\)/.exec(f);
7979
if (result) {
80-
return result[1];
80+
return result[1].toLowerCase();
8181
}
8282
});
8383
for (const frame of frames) {

src/client/datascience/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export interface IJupyterConnection extends Disposable {
7575
readonly hostName: string;
7676
localProcExitCode: number | undefined;
7777
// tslint:disable-next-line: no-any
78-
authorizationHeader?: any; // Snould be a json object
78+
getAuthHeader?(): any; // Snould be a json object
7979
}
8080

8181
export type INotebookProviderConnection = IRawConnection | IJupyterConnection;
@@ -1333,13 +1333,14 @@ export interface IJupyterServerUri {
13331333
token: string;
13341334
// tslint:disable-next-line: no-any
13351335
authorizationHeader: any; // JSON object for authorization header.
1336+
expiration?: Date; // Date/time when header expires and should be refreshed.
13361337
displayName: string;
13371338
}
13381339

13391340
export type JupyterServerUriHandle = string;
13401341

13411342
export interface IJupyterUriProvider {
1342-
id: string; // Should be a unique string (like a guid)
1343+
readonly id: string; // Should be a unique string (like a guid)
13431344
getQuickPickEntryItems(): QuickPickItem[];
13441345
handleQuickPick(item: QuickPickItem, backEnabled: boolean): Promise<JupyterServerUriHandle | 'back' | undefined>;
13451346
getServerUri(handle: JupyterServerUriHandle): Promise<IJupyterServerUri>;

src/test/datascience/extensionapi/exampleextension/ms-ai-tools-test/src/serverPicker.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,18 @@ export class RemoteServerPickerExample implements IJupyterUriProvider {
7676
// some other stuff
7777
if (stdout) {
7878
const output = JSON.parse(stdout.toString());
79+
const currentDate = new Date();
7980
resolve({
8081
baseUrl: Compute_ServerUri,
8182
token: '', //output.accessToken,
82-
authorizationHeader: { Authorization: `Bearer ${output.accessToken}` }
83+
authorizationHeader: { Authorization: `Bearer ${output.accessToken}` },
84+
expiration: new Date(
85+
currentDate.getFullYear(),
86+
currentDate.getMonth(),
87+
undefined,
88+
currentDate.getHours(),
89+
currentDate.getMinutes() + 1 // Expire after one minute
90+
)
8391
});
8492
} else {
8593
reject('Unable to get az token');

src/test/datascience/extensionapi/exampleextension/ms-ai-tools-test/src/typings/python.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface IJupyterServerUri {
5252
token: string;
5353
// tslint:disable-next-line: no-any
5454
authorizationHeader: any; // JSON object for authorization header.
55+
expiration?: Date; // Date/time when header expires and should be refreshed.
5556
}
5657

5758
export type JupyterServerUriHandle = string;

0 commit comments

Comments
 (0)