Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Reticulate support to the kernel supervisor #5854

Merged
merged 15 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/positron-python/src/client/positron/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs

private async createKernel(): Promise<JupyterLanguageRuntimeSession> {
const config = vscode.workspace.getConfiguration('kernelSupervisor');
if (config.get<boolean>('enable', true) && this.runtimeMetadata.runtimeId !== 'reticulate') {
if (config.get<boolean>('enable', true)) {
// Use the Positron kernel supervisor if enabled
const ext = vscode.extensions.getExtension('positron.positron-supervisor');
if (!ext) {
Expand Down
74 changes: 74 additions & 0 deletions extensions/positron-reticulate/src/async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2024 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

/**
* PromiseHandles is a class that represents a promise that can be resolved or
* rejected externally.
*/
export class PromiseHandles<T> {
resolve!: (value: T | Promise<T>) => void;

reject!: (error: unknown) => void;

promise: Promise<T>;

constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}

/**
* A barrier that is initially closed and then becomes opened permanently.
* Ported from VS Code's async.ts.
*/

export class Barrier {
private _isOpen: boolean;
private _promise: Promise<boolean>;
private _completePromise!: (v: boolean) => void;

constructor() {
this._isOpen = false;
this._promise = new Promise<boolean>((c, _e) => {
this._completePromise = c;
});
}

isOpen(): boolean {
return this._isOpen;
}

open(): void {
this._isOpen = true;
this._completePromise(true);
}

wait(): Promise<boolean> {
return this._promise;
}
}

/**
* Wraps a promise in a timeout that rejects the promise if it does not resolve
* within the given time.
*
* @param promise The promise to wrap
* @param timeout The timeout interval in milliseconds
* @param message The error message to use if the promise times out
*
* @returns The wrapped promise
*/
export function withTimeout<T>(promise: Promise<T>,
timeout: number,
message: string): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))
]);
}

181 changes: 127 additions & 54 deletions extensions/positron-reticulate/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as positron from 'positron';
import path = require('path');
import fs = require('fs');
import { JupyterKernelSpec, JupyterSession, JupyterKernel } from './jupyter-adapter.d';
import { Barrier, PromiseHandles } from './async';

export class ReticulateRuntimeManager implements positron.LanguageRuntimeManager {

Expand Down Expand Up @@ -166,53 +167,121 @@ class InitializationError extends Error {
class ReticulateRuntimeSession implements positron.LanguageRuntimeSession {

private kernel: JupyterKernel | undefined;
public started = new Barrier();
private pythonSession: positron.LanguageRuntimeSession;

// To create a reticulate runtime session we need to first create a python
// runtime session using the exported interface from the positron-python
// extension.

// The PythonRuntimeSession object in the positron-python extensions, is created
// by passing 'runtimeMetadata', 'sessionMetadata' and something called 'kernelSpec'
// that's further passed to the JupyterAdapter extension in order to actually initialize
// the session.

// ReticulateRuntimeSession are only different from Python runtime sessions in the
// way the kernel spec is provided. In general, the kernel spec contains a runtime
// path and some arguments that are used start the kernel process. (The kernel is started
// by the Jupyter Adapter in a vscode terminal). In the reticulate case, the kernel isn't
// started that way. Instead, we need to call into the R console to start the python jupyter
// kernel (that's actually running in the same process as R), and only then, ask JupyterAdapter
// to connect to that kernel.
// The PythonRuntimeSession object in the positron-python extensions, is
// created by passing 'runtimeMetadata', 'sessionMetadata' and something
// called 'kernelSpec' that's further passed to the JupyterAdapter
// extension in order to actually initialize the session.

// ReticulateRuntimeSession are only different from Python runtime sessions
// in the way the kernel spec is provided. In general, the kernel spec
// contains a runtime path and some arguments that are used start the
// kernel process. (The kernel is started by the Jupyter Adapter in a
// vscode terminal). In the reticulate case, the kernel isn't started that
// way. Instead, we need to call into the R console to start the python
// jupyter kernel (that's actually running in the same process as R), and
// only then, ask JupyterAdapter to connect to that kernel.
static async create(
runtimeMetadata: positron.LanguageRuntimeMetadata,
sessionMetadata: positron.RuntimeSessionMetadata,
): Promise<ReticulateRuntimeSession> {
const rSession = await getRSession();
const config = await ReticulateRuntimeSession.checkRSession(rSession);
const metadata = await ReticulateRuntimeSession.fixInterpreterPath(runtimeMetadata, config.python);

return new ReticulateRuntimeSession(
rSession,
metadata,
sessionMetadata,
ReticulateRuntimeSessionType.Create
);
// A deferred promise that will resolve when the session is created.
const sessionPromise = new PromiseHandles<ReticulateRuntimeSession>();

// Show a progress notification while we create the session.
vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: 'Creating the Reticulate Python session',
cancellable: false
}, async (progress, _token) => {
let session: ReticulateRuntimeSession | undefined;
try {
// Get the R session that we'll use to start the reticulate session.
progress.report({ increment: 10, message: 'Initializing the host R session' });
const rSession = await getRSession(progress);

// Make sure the R session has the necessary packages installed.
progress.report({ increment: 10, message: 'Checking prerequisites' });
const config = await ReticulateRuntimeSession.checkRSession(rSession);
const metadata = await ReticulateRuntimeSession.fixInterpreterPath(runtimeMetadata, config.python);

// Create the session itself.
session = new ReticulateRuntimeSession(
rSession,
metadata,
sessionMetadata,
ReticulateRuntimeSessionType.Create,
progress
);
sessionPromise.resolve(session);
} catch (err) {
sessionPromise.reject(err);
}

// Wait for the session to start (or fail to start) before
// returning from this callback, so that the progress bar stays up
// while we wait.
if (session) {
progress.report({ increment: 10, message: 'Waiting to connect' });
await session.started.wait();
}
});

return sessionPromise.promise;
}

static async restore(
runtimeMetadata: positron.LanguageRuntimeMetadata,
sessionMetadata: positron.RuntimeSessionMetadata,
): Promise<ReticulateRuntimeSession> {
const rSession = await getRSession();
const config = await ReticulateRuntimeSession.checkRSession(rSession);
const metadata = await ReticulateRuntimeSession.fixInterpreterPath(runtimeMetadata, config.python);
return new ReticulateRuntimeSession(
rSession,
metadata,
sessionMetadata,
ReticulateRuntimeSessionType.Restore
);

const sessionPromise = new PromiseHandles<ReticulateRuntimeSession>();

vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: 'Restoring the Reticulate Python session',
cancellable: false
}, async (progress, _token) => {
let session: ReticulateRuntimeSession | undefined;
try {
// Find the R session that we'll use to restore the reticulate session.
progress.report({ increment: 10, message: 'Initializing the host R session' });
const rSession = await getRSession(progress);

// Make sure the R session has the necessary packages installed.
progress.report({ increment: 10, message: 'Checking prerequisites' });
const config = await ReticulateRuntimeSession.checkRSession(rSession);
const metadata = await ReticulateRuntimeSession.fixInterpreterPath(runtimeMetadata, config.python);

// Create the session itself.
session = new ReticulateRuntimeSession(
rSession,
metadata,
sessionMetadata,
ReticulateRuntimeSessionType.Restore,
progress
);
sessionPromise.resolve(session);
} catch (err) {
sessionPromise.reject(err);
}

// Wait for the session to resume (or fail to resume) before
// returning
if (session) {
progress.report({ increment: 10, message: 'Waiting to reconnect' });
await session.started.wait();
}
});

return sessionPromise.promise;
}

static async checkRSession(rSession: positron.LanguageRuntimeSession): Promise<{ python: string }> {
Expand Down Expand Up @@ -330,6 +399,7 @@ class ReticulateRuntimeSession implements positron.LanguageRuntimeSession {
runtimeMetadata: positron.LanguageRuntimeMetadata,
sessionMetadata: positron.RuntimeSessionMetadata,
sessionType: ReticulateRuntimeSessionType,
readonly progress: vscode.Progress<{ message?: string; increment?: number }>
) {
// When the kernelSpec is undefined, the PythonRuntimeSession
// will perform a restore session.
Expand Down Expand Up @@ -360,20 +430,29 @@ class ReticulateRuntimeSession implements positron.LanguageRuntimeSession {
if (!api) {
throw new Error('Failed to find the Positron Python extension API.');
}
this.progress.report({ increment: 10, message: 'Creating the Python session' });
this.pythonSession = api.exports.positron.createPythonRuntimeSession(
runtimeMetadata,
sessionMetadata,
kernelSpec
);

// Open the start barrier once the session is ready.
this.pythonSession.onDidChangeRuntimeState((state) => {
if (state === positron.RuntimeState.Ready || state === positron.RuntimeState.Idle) {
this.started.open();
}
});

this.onDidReceiveRuntimeMessage = this.pythonSession.onDidReceiveRuntimeMessage;
this.onDidChangeRuntimeState = this.pythonSession.onDidChangeRuntimeState;
this.onDidEndSession = this.pythonSession.onDidEndSession;
}

// A function that starts a kernel and then connects to it.
async startKernel(session: JupyterSession, kernel: JupyterKernel) {
kernel.log('Starting the reticulate session!');
kernel.log('Starting the Reticulate session!');
this.progress.report({ increment: 10, message: 'Starting the Reticulate session in R' });

// Store a reference to the kernel, so the session can log, reconnect, etc.
this.kernel = kernel;
Expand Down Expand Up @@ -407,11 +486,15 @@ class ReticulateRuntimeSession implements positron.LanguageRuntimeSession {
throw new Error(`Reticulate initialization failed: ${init_err}`);
}

this.progress.report({ increment: 10, message: 'Connecting to the Reticulate session' });

try {
await kernel.connectToSession(session);
} catch (err: any) {
kernel.log('Failed connecting to the Reticulate Python session');
throw err;
} finally {
this.started.open();
}
}

Expand Down Expand Up @@ -533,15 +616,15 @@ class ReticulateRuntimeSession implements positron.LanguageRuntimeSession {
}
}

async function getRSession(): Promise<positron.LanguageRuntimeSession> {
async function getRSession(progress: vscode.Progress<{ message?: string; increment?: number }>): Promise<positron.LanguageRuntimeSession> {

// Retry logic to start an R session.
const maxRetries = 5;
let session;
let error;
for (let i = 0; i < maxRetries; i++) {
try {
session = await getRSession_();
session = await getRSession_(progress);
}
catch (err: any) {
error = err; // Keep the last error so we can display it
Expand All @@ -566,12 +649,12 @@ class RSessionError extends Error {
}
}

async function getRSession_(): Promise<positron.LanguageRuntimeSession> {
async function getRSession_(progress: vscode.Progress<{ message?: string; increment?: number }>): Promise<positron.LanguageRuntimeSession> {
let session = await positron.runtime.getForegroundSession();

if (session) {
// Get foreground session will return a runtime session even if it has
// already exitted. We check that it's still there before proceeding.
// already exited. We check that it's still there before proceeding.
// TODO: it would be nice to have an API to check for the session state.
try {
await session.callMethod?.('is_installed', 'reticulate', '1.39');
Expand All @@ -581,25 +664,15 @@ async function getRSession_(): Promise<positron.LanguageRuntimeSession> {
}

if (!session || session.runtimeMetadata.languageId !== 'r') {
session = await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: 'Starting R session for reticulate',
cancellable: true
}, async (progress, token) => {
token.onCancellationRequested(() => {
throw new RSessionError('User requested cancellation', true);
});
progress.report({ increment: 0, message: 'Looking for prefered runtime...' });
const runtime = await positron.runtime.getPreferredRuntime('r');

progress.report({ increment: 20, message: 'Starting R runtime...' });
await positron.runtime.selectLanguageRuntime(runtime.runtimeId);

progress.report({ increment: 70, message: 'Getting R session...' });
session = await positron.runtime.getForegroundSession();

return session;
});
progress.report({ increment: 10, message: 'Looking for prefered runtime...' });

const runtime = await positron.runtime.getPreferredRuntime('r');

progress.report({ increment: 10, message: 'Starting R runtime...' });
await positron.runtime.selectLanguageRuntime(runtime.runtimeId);

progress.report({ increment: 10, message: 'Getting R session...' });
session = await positron.runtime.getForegroundSession();
}

if (!session) {
Expand Down
8 changes: 7 additions & 1 deletion extensions/positron-supervisor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@
"title": "%command.reconnectSession.title%",
"shortTitle": "%command.reconnectSession.title%",
"enablement": "isDevelopment"
},
{
"command": "positron.supervisor.restartSupervisor",
"category": "%command.positron.supervisor.category%",
"title": "%command.restartSupervisor.title%",
"shortTitle": "%command.restartSupervisor.title%"
}
]
},
Expand All @@ -109,7 +115,7 @@
},
"positron": {
"binaryDependencies": {
"kallichore": "0.1.22"
"kallichore": "0.1.26"
}
},
"dependencies": {
Expand Down
3 changes: 2 additions & 1 deletion extensions/positron-supervisor/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
"configuration.sleepOnStartup.description": "Sleep for n seconds before starting up Jupyter kernel (when supported)",
"command.positron.supervisor.category": "Kernel Supervisor",
"command.showKernelSupervisorLog.title": "Show the Kernel Supervisor Log",
"command.reconnectSession.title": "Reconnect the current session"
"command.reconnectSession.title": "Reconnect the Current Session",
"command.restartSupervisor.title": "Restart the Kernel Supervisor"
}
Loading
Loading