diff --git a/extensions/positron-python/src/client/positron-supervisor.d.ts b/extensions/positron-python/src/client/positron-supervisor.d.ts index 7955deb3213e..380b2ab8b354 100644 --- a/extensions/positron-python/src/client/positron-supervisor.d.ts +++ b/extensions/positron-python/src/client/positron-supervisor.d.ts @@ -91,17 +91,6 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS */ startPositronLsp(clientId: string, ipAddress: string): Promise; - /** - * Convenience method for starting the Positron DAP server, if the - * language runtime supports it. - * - * @param clientId The ID of the client comm, created with - * `createPositronDapClientId()`. - * @param debugType Passed as `vscode.DebugConfiguration.type`. - * @param debugName Passed as `vscode.DebugConfiguration.name`. - */ - startPositronDap(clientId: string, debugType: string, debugName: string): Promise; - /** * Convenience method for creating a client id to pass to * `startPositronLsp()`. The caller can later remove the client using this @@ -110,11 +99,64 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS createPositronLspClientId(): string; /** - * Convenience method for creating a client id to pass to - * `startPositronDap()`. The caller can later remove the client using this - * id as well. + * Start a comm for communication between frontend and backend. + * + * Unlike Positron clients, this kind of comm is private to the calling + * extension and its kernel. They open a direct line of communication that + * lives entirely on the extension host. + * + * The messages sent over these comms are expected to conform to JSON-RPC: + * - The message type is encoded in the `method` field. + * - Parameters are optional and encoded as object (named list of + * parameters) in the `params` field. + * - If a response is expected, add an `id` field to indicate that this is a + * request. This ID field is redundant with the one used in the Jupyter + * layer but allows applications to make a distinction between notifications + * and requests. + * + * Responses to requests follow this format: + * - `result` or `error` field. + * - `id` field corresponding to the request's `id`. + * + * @param target_name Comm type, also used to generate comm identifier. + * @param params Optionally, additional parameters included in `comm_open`. + */ + createComm(target_name: string, params?: Record): Promise; + + /** + * Create a server comm. + * + * Server comms are a special type of comms (see `createComm()`) that + * wrap a TCP server (e.g. an LSP or DAP server). The backend is expected to + * handle `comm_open` messages for `targetName` comms in the following way: + * + * - The `comm_open` messages includes an `ip_address` field. The server + * must be started on this addess. The server, and not the client, picks + * the port to prevent race conditions where a port becomes used between + * the time it was picked by the frontend and handled by the backend. + * + * - Once the server is started at `ip_address` on a port, the backend sends + * back a notification message of type (method) `server_started` that + * includes a field `port`. + * + * @param targetName The name of the comm target + * @param ip_address The IP address to which the server should bind to. + * @returns A promise that resolves to a tuple of [Comm, port number] + * once the server has been started on the backend side. + */ + createServerComm(targetName: string, ip_address: string): Promise<[Comm, number]>; + + /** + * Constructs a new DapComm instance. + * Must be disposed. See `DapComm` documentation. + * + * @param session The Jupyter language runtime session. + * @param targetName The name of the comm target. + * @param debugType The type of debugger, as required by `vscode.DebugConfiguration.type`. + * @param debugName The name of the debugger, as required by `vscode.DebugConfiguration.name`. + * @returns A new `DapComm` instance. */ - createPositronDapClientId(): string; + createDapComm(targetName: string, debugType: string, debugName: string): Promise; /** * Method for emitting a message to the language server's Jupyter output @@ -129,7 +171,8 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS * A Jupyter kernel is guaranteed to have a `showOutput()` * method, so we declare it non-optional. * - * @param channel The channel to show the output of. + * @param channel The name of the output channel to show. + * If not provided, the default channel is shown. */ showOutput(channel?: positron.LanguageRuntimeSessionChannel): void; @@ -210,3 +253,170 @@ export interface JupyterKernelExtra { init: (args: Array, delay: number) => void; }; } + +/** + * Comm between an extension and its kernel. + * + * This type of comm is not mapped to a Positron client. It lives entirely in + * the extension space and allows a direct line of communication between an + * extension and its kernel. + * + * It's a disposable. Dispose of it once it's closed or you're no longer using + * it. If the comm has not already been closed by the kernel, a client-initiated + * `comm_close` message is emitted to clean the comm on the backend side. + */ +export interface Comm { + /** The comm ID. */ + id: string; + + /** + * Async-iterable for messages sent from backend. + * + * - This receiver channel _must_ be awaited and handled to exhaustion. + * - When exhausted, you _must_ dispose of the comm. + * + * Yields `CommBackendMessage` messages which are a tagged union of + * notifications and requests. If a request, the `handle` method _must_ be + * called (see `CommBackendMessage` documentation). + */ + receiver: ReceiverChannel; + + /** + * Send a notification to the backend comm. + * Throws `CommClosedError` if comm was closed. + */ + notify: (method: string, params?: Record) => void; + + /** + * Make a request to the backend comm. + * + * Resolves when backend responds with the result. + * Throws: + * - `CommClosedError` if comm was closed + * - `CommRpcError` for RPC errors. + */ + request: (method: string, params?: Record) => Promise; + + /** Clear resources and sends `comm_close` to backend comm (unless the channel + * was closed by the backend already). */ + dispose: () => Promise; +} + +/** + * Async-iterable receiver channel for comm messages from the backend. + * The messages are buffered and must be received as long as the channel is open. + * Dispose to close. + */ +export interface ReceiverChannel extends AsyncIterable, vscode.Disposable { + next(): Promise>; +} + +/** + * Base class for communication errors. + */ +export interface CommError extends Error { + readonly name: 'CommError' | 'CommClosedError' | 'CommRpcError'; + readonly method?: string; +} + +/** + * Error thrown when attempting to communicate through a closed channel. + */ +export interface CommClosedError extends CommError { + readonly name: 'CommClosedError'; +} + +/** + * Error thrown for RPC-specific errors with error codes. + */ +export interface CommRpcError extends CommError { + readonly name: 'CommRpcError'; + readonly code: number; +} + +/** + * Message from the backend. + * + * If a request, the `handle` method _must_ be called. + * Throw an error from `handle` to reject the request (e.g. if `method` is unknown). + * + * Note: Requests are currently not possible, see + * + */ +export type CommBackendMessage = + | { + kind: 'request'; + method: string; + params?: Record; + handle: (handler: () => any) => void; + } + | { + kind: 'notification'; + method: string; + params?: Record; + }; + +/** + * A Debug Adapter Protocol (DAP) comm. + * + * This wraps a `Comm` that: + * + * - Implements the server protocol (see `createComm()` and + * `JupyterLanguageRuntimeSession::createServerComm()`). + * + * - Optionally handles a standard set of DAP comm messages. + * + * Must be disposed when no longer in use or if `comm.receiver` is exhausted. + * Disposing the `DapComm` automatically disposes of the nested `Comm`. + */ +export interface DapComm { + /** The `targetName` passed to the constructor. */ + readonly targetName: string; + + /** The `debugType` passed to the constructor. */ + readonly debugType: string; + + /** The `debugName` passed to the constructor. */ + readonly debugName: string; + + /** + * The comm for the DAP. + * Use it to receive messages or make notifications and requests. + * Defined after `createServerComm()` has been called. + */ + readonly comm?: Comm; + + /** + * The port on which the DAP server is listening. + * Defined after `createServerComm()` has been called. + */ + readonly serverPort?: number; + + /** + * Handle a message received via `this.comm.receiver`. + * + * This is optional. If called, these message types are handled: + * + * - `start_debug`: A debugging session is started from the frontend side, + * connecting to `this.serverPort`. + * + * - `execute`: A command is visibly executed in the console. Can be used to + * handle DAP requests like "step" via the console, delegating to the + * interpreter's own debugging infrastructure. + * + * - `restart`: The console session is restarted. Can be used to handle a + * restart DAP request on the backend side. + * + * Returns whether the message was handled. Note that if the message was not + * handled, you _must_ check whether the message is a request, and either + * handle or reject it in that case. + */ + handleMessage(msg: any): Promise; + + /** + * Dispose of the underlying comm. + * Must be called if the DAP comm is no longer in use. + * Closes the comm if not done already. + */ + dispose(): void; +} diff --git a/extensions/positron-r/package.json b/extensions/positron-r/package.json index 9a4ed8aec605..b82cf90a46f3 100644 --- a/extensions/positron-r/package.json +++ b/extensions/positron-r/package.json @@ -974,7 +974,7 @@ }, "positron": { "binaryDependencies": { - "ark": "0.1.209" + "ark": "0.1.210" }, "minimumRVersion": "4.2.0", "minimumRenvVersion": "1.0.9" diff --git a/extensions/positron-r/src/ark-comm.ts b/extensions/positron-r/src/ark-comm.ts new file mode 100644 index 000000000000..faa3d5f233b1 --- /dev/null +++ b/extensions/positron-r/src/ark-comm.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { JupyterLanguageRuntimeSession, Comm } from './positron-supervisor'; +import { LOGGER } from './extension'; + +/** + * Communication channel with the bakend. + * Currently only used for testing. + */ +export class ArkComm implements vscode.Disposable { + readonly targetName: string = 'ark'; + + public get comm(): Comm | undefined { + return this._comm; + } + + private _comm?: Comm; + + constructor( + private session: JupyterLanguageRuntimeSession, + ) { } + + async createComm(): Promise { + this._comm = await this.session.createComm(this.targetName); + LOGGER.info(`Created Ark comm with ID: ${this._comm.id}`); + } + + async dispose(): Promise { + await this._comm?.dispose(); + } +} diff --git a/extensions/positron-r/src/extension.ts b/extensions/positron-r/src/extension.ts index eea91c437802..a07a9138a8a5 100644 --- a/extensions/positron-r/src/extension.ts +++ b/extensions/positron-r/src/extension.ts @@ -14,6 +14,7 @@ import { RRuntimeManager } from './runtime-manager'; import { registerUriHandler } from './uri-handler'; import { registerRLanguageModelTools } from './llm-tools.js'; import { registerFileAssociations } from './file-associations.js'; +import { PositronSupervisorApi } from './positron-supervisor'; export const LOGGER = vscode.window.createOutputChannel('R Language Pack', { log: true }); @@ -53,3 +54,16 @@ export function activate(context: vscode.ExtensionContext) { } }); } + +export async function supervisorApi(): Promise { + const ext = vscode.extensions.getExtension('positron.positron-supervisor'); + if (!ext) { + throw new Error('Positron Supervisor extension not found'); + } + + if (!ext.isActive) { + await ext.activate(); + } + + return ext?.exports as PositronSupervisorApi; +} diff --git a/extensions/positron-r/src/positron-supervisor.d.ts b/extensions/positron-r/src/positron-supervisor.d.ts index 142e801685fb..5a36ebbb69d4 100644 --- a/extensions/positron-r/src/positron-supervisor.d.ts +++ b/extensions/positron-r/src/positron-supervisor.d.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; +// eslint-disable-next-line import/no-unresolved import * as positron from 'positron'; export interface JupyterSessionState { @@ -90,17 +91,6 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS */ startPositronLsp(clientId: string, ipAddress: string): Promise; - /** - * Convenience method for starting the Positron DAP server, if the - * language runtime supports it. - * - * @param clientId The ID of the client comm, created with - * `createPositronDapClientId()`. - * @param debugType Passed as `vscode.DebugConfiguration.type`. - * @param debugName Passed as `vscode.DebugConfiguration.name`. - */ - startPositronDap(clientId: string, debugType: string, debugName: string): Promise; - /** * Convenience method for creating a client id to pass to * `startPositronLsp()`. The caller can later remove the client using this @@ -109,11 +99,71 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS createPositronLspClientId(): string; /** - * Convenience method for creating a client id to pass to - * `startPositronDap()`. The caller can later remove the client using this - * id as well. + * Start a comm for communication between frontend and backend. + * + * Unlike Positron clients, this kind of comm is private to the calling + * extension and its kernel. They open a direct line of communication that + * lives entirely on the extension host. + * + * The messages sent over these comms are expected to conform to JSON-RPC: + * - The message type is encoded in the `method` field. + * - Parameters are optional and encoded as object (named list of + * parameters) in the `params` field. + * - If a response is expected, add an `id` field to indicate that this is a + * request. This ID field is redundant with the one used in the Jupyter + * layer but allows applications to make a distinction between notifications + * and requests. + * + * Responses to requests follow this format: + * - `result` or `error` field. + * - `id` field corresponding to the request's `id`. + * + * @param target_name Comm type, also used to generate comm identifier. + * @param params Optionally, additional parameters included in `comm_open`. + */ + createComm( + target_name: string, + params?: Record, + ): Promise; + + /** + * Create a server comm. + * + * Server comms are a special type of comms (see `createComm()`) that + * wrap a TCP server (e.g. an LSP or DAP server). The backend is expected to + * handle `comm_open` messages for `targetName` comms in the following way: + * + * - The `comm_open` messages includes an `ip_address` field. The server + * must be started on this addess. The server, and not the client, picks + * the port to prevent race conditions where a port becomes used between + * the time it was picked by the frontend and handled by the backend. + * + * - Once the server is started at `ip_address` on a port, the backend sends + * back a notification message of type (method) `server_started` that + * includes a field `port`. + * + * @param targetName The name of the comm target + * @param ip_address The IP address to which the server should bind to. + * @returns A promise that resolves to a tuple of [Comm, port number] + * once the server has been started on the backend side. + */ + createServerComm(targetName: string, ip_address: string): Promise<[Comm, number]>; + + /** + * Constructs a new DapComm instance. + * Must be disposed. See `DapComm` documentation. + * + * @param session The Jupyter language runtime session. + * @param targetName The name of the comm target. + * @param debugType The type of debugger, as required by `vscode.DebugConfiguration.type`. + * @param debugName The name of the debugger, as required by `vscode.DebugConfiguration.name`. + * @returns A new `DapComm` instance. */ - createPositronDapClientId(): string; + createDapComm( + targetName: string, + debugType: string, + debugName: string, + ): Promise; /** * Method for emitting a message to the language server's Jupyter output @@ -128,7 +178,8 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS * A Jupyter kernel is guaranteed to have a `showOutput()` * method, so we declare it non-optional. * - * @param channel The channel to show the output of. + * @param channel The name of the output channel to show. + * If not provided, the default channel is shown. */ showOutput(channel?: positron.LanguageRuntimeSessionChannel): void; @@ -210,3 +261,170 @@ export interface JupyterKernelExtra { init: (args: Array, delay: number) => void; }; } + +/** + * Comm between an extension and its kernel. + * + * This type of comm is not mapped to a Positron client. It lives entirely in + * the extension space and allows a direct line of communication between an + * extension and its kernel. + * + * It's a disposable. Dispose of it once it's closed or you're no longer using + * it. If the comm has not already been closed by the kernel, a client-initiated + * `comm_close` message is emitted to clean the comm on the backend side. + */ +export interface Comm { + /** The comm ID. */ + id: string; + + /** + * Async-iterable for messages sent from backend. + * + * - This receiver channel _must_ be awaited and handled to exhaustion. + * - When exhausted, you _must_ dispose of the comm. + * + * Yields `CommBackendMessage` messages which are a tagged union of + * notifications and requests. If a request, the `handle` method _must_ be + * called (see `CommBackendMessage` documentation). + */ + receiver: ReceiverChannel; + + /** + * Send a notification to the backend comm. + * Throws `CommClosedError` if comm was closed. + */ + notify: (method: string, params?: Record) => void; + + /** + * Make a request to the backend comm. + * + * Resolves when backend responds with the result. + * Throws: + * - `CommClosedError` if comm was closed + * - `CommRpcError` for RPC errors. + */ + request: (method: string, params?: Record) => Promise; + + /** Clear resources and sends `comm_close` to backend comm (unless the channel + * was closed by the backend already). */ + dispose: () => Promise; +} + +/** + * Async-iterable receiver channel for comm messages from the backend. + * The messages are buffered and must be received as long as the channel is open. + * Dispose to close. + */ +export interface ReceiverChannel extends AsyncIterable, vscode.Disposable { + next(): Promise>; +} + +/** + * Base class for communication errors. + */ +export interface CommError extends Error { + readonly name: 'CommError' | 'CommClosedError' | 'CommRpcError'; + readonly method?: string; +} + +/** + * Error thrown when attempting to communicate through a closed channel. + */ +export interface CommClosedError extends CommError { + readonly name: 'CommClosedError'; +} + +/** + * Error thrown for RPC-specific errors with error codes. + */ +export interface CommRpcError extends CommError { + readonly name: 'CommRpcError'; + readonly code: number; +} + +/** + * Message from the backend. + * + * If a request, the `handle` method _must_ be called. + * Throw an error from `handle` to reject the request (e.g. if `method` is unknown). + * + * Note: Requests are currently not possible, see + * + */ +export type CommBackendMessage = + | { + kind: 'request'; + method: string; + params?: Record; + handle: (handler: () => any) => void; + } + | { + kind: 'notification'; + method: string; + params?: Record; + }; + +/** + * A Debug Adapter Protocol (DAP) comm. + * + * This wraps a `Comm` that: + * + * - Implements the server protocol (see `createComm()` and + * `JupyterLanguageRuntimeSession::createServerComm()`). + * + * - Optionally handles a standard set of DAP comm messages. + * + * Must be disposed when no longer in use or if `comm.receiver` is exhausted. + * Disposing the `DapComm` automatically disposes of the nested `Comm`. + */ +export interface DapComm { + /** The `targetName` passed to the constructor. */ + readonly targetName: string; + + /** The `debugType` passed to the constructor. */ + readonly debugType: string; + + /** The `debugName` passed to the constructor. */ + readonly debugName: string; + + /** + * The comm for the DAP. + * Use it to receive messages or make notifications and requests. + * Defined after `createServerComm()` has been called. + */ + readonly comm?: Comm; + + /** + * The port on which the DAP server is listening. + * Defined after `createServerComm()` has been called. + */ + readonly serverPort?: number; + + /** + * Handle a message received via `this.comm.receiver`. + * + * This is optional. If called, these message types are handled: + * + * - `start_debug`: A debugging session is started from the frontend side, + * connecting to `this.serverPort`. + * + * - `execute`: A command is visibly executed in the console. Can be used to + * handle DAP requests like "step" via the console, delegating to the + * interpreter's own debugging infrastructure. + * + * - `restart`: The console session is restarted. Can be used to handle a + * restart DAP request on the backend side. + * + * Returns whether the message was handled. Note that if the message was not + * handled, you _must_ check whether the message is a request, and either + * handle or reject it in that case. + */ + handleMessage(msg: any): Promise; + + /** + * Dispose of the underlying comm. + * Must be called if the DAP comm is no longer in use. + * Closes the comm if not done already. + */ + dispose(): void; +} diff --git a/extensions/positron-r/src/runtime-manager.ts b/extensions/positron-r/src/runtime-manager.ts index a73f1c86fb37..44bf1d0e48a0 100644 --- a/extensions/positron-r/src/runtime-manager.ts +++ b/extensions/positron-r/src/runtime-manager.ts @@ -10,7 +10,7 @@ import { currentRBinary, makeMetadata, rRuntimeDiscoverer } from './provider'; import { RInstallation, RMetadataExtra, ReasonDiscovered, friendlyReason } from './r-installation'; import { RSession, createJupyterKernelExtra } from './session'; import { createJupyterKernelSpec } from './kernel-spec'; -import { LOGGER } from './extension'; +import { LOGGER, supervisorApi } from './extension'; import { POSITRON_R_INTERPRETERS_DEFAULT_SETTING_KEY } from './constants'; import { getDefaultInterpreterPath } from './interpreter-settings.js'; import { dirname } from 'path'; @@ -160,14 +160,8 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { * @returns True if the session is valid, false otherwise */ async validateSession(sessionId: string): Promise { - const ext = vscode.extensions.getExtension('positron.positron-supervisor'); - if (!ext) { - throw new Error('Positron Supervisor extension not found'); - } - if (!ext.isActive) { - await ext.activate(); - } - return ext.exports.validateSession(sessionId); + const api = await supervisorApi(); + return await api.validateSession(sessionId); } restoreSession( diff --git a/extensions/positron-r/src/session.ts b/extensions/positron-r/src/session.ts index 6205329a6fc9..4852c5e86b8c 100644 --- a/extensions/positron-r/src/session.ts +++ b/extensions/positron-r/src/session.ts @@ -7,15 +7,16 @@ import * as positron from 'positron'; import * as vscode from 'vscode'; import PQueue from 'p-queue'; -import { PositronSupervisorApi, JupyterKernelSpec, JupyterLanguageRuntimeSession, JupyterKernelExtra } from './positron-supervisor'; +import { PositronSupervisorApi, JupyterKernelSpec, JupyterLanguageRuntimeSession, JupyterKernelExtra, DapComm } from './positron-supervisor'; import { ArkLsp, ArkLspState } from './lsp'; -import { delay, whenTimeout, timeout } from './util'; +import { delay, whenTimeout, timeout, PromiseHandles } from './util'; import { ArkAttachOnStartup, ArkDelayStartup } from './startup'; import { RHtmlWidget, getResourceRoots } from './htmlwidgets'; import { randomUUID } from 'crypto'; import { handleRCode } from './hyperlink'; import { RSessionManager } from './session-manager'; -import { LOGGER } from './extension.js'; +import { LOGGER, supervisorApi } from './extension.js'; +import { ArkComm } from './ark-comm'; interface RPackageInstallation { packageName: string; @@ -42,10 +43,16 @@ interface Locale { * Protocol client. */ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposable { - /** The Language Server Protocol client wrapper */ private _lsp: ArkLsp; + /** The Ark Comm for direct communication with the kernel */ + private _arkComm?: ArkComm; + + get arkComm(): ArkComm | undefined { + return this._arkComm; + } + /** Queue for LSP events */ private _lspQueue: PQueue; @@ -62,6 +69,9 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa /** The Jupyter kernel-based session implementing the Language Runtime API */ private _kernel?: JupyterLanguageRuntimeSession; + /** The DAP communication channel */ + private _dapComm?: DapComm; + /** The emitter for language runtime messages */ private _messageEmitter = new vscode.EventEmitter(); @@ -290,7 +300,8 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa this.onConsoleWidthChange(newWidth); }); } - return this._kernel.start(); + + return await this._kernel.start(); } private async onConsoleWidthChange(newWidth: number): Promise { @@ -391,6 +402,9 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa this._consoleWidthDisposable = undefined; await this._lsp.dispose(); + if (this._arkComm) { + await this._arkComm.dispose(); + } if (this._kernel) { await this._kernel.dispose(); } @@ -625,15 +639,7 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa } private async createKernel(): Promise { - // Get the Positron Supervisor extension and activate it if necessary - const ext = vscode.extensions.getExtension('positron.positron-supervisor'); - if (!ext) { - throw new Error('Positron Supervisor extension not found'); - } - if (!ext.isActive) { - await ext.activate(); - } - this.adapterApi = ext?.exports as PositronSupervisorApi; + this.adapterApi = await supervisorApi(); // Create the Jupyter session const kernel = this.kernelSpec ? @@ -830,23 +836,73 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa * sessions by coming online. */ private async startDap(): Promise { - if (this._kernel) { - try { - let clientId = this._kernel.createPositronDapClientId(); - await this._kernel.startPositronDap(clientId, 'ark', 'Ark Positron R'); - } catch (err) { - this._kernel.emitJupyterLog(`Error starting DAP: ${err}`, vscode.LogLevel.Error); + try { + if (!this._kernel) { + throw new Error('Kernel not started'); + } + + this._dapComm = await this._kernel.createDapComm('ark_dap', 'ark', 'Ark Positron R'); + + // Not awaited: we're spawning an infinite async loop + this.startDapMessageLoop(); + } catch (err) { + LOGGER.error(`Error starting DAP: ${err}`); + } + } + + // Only called from tests for now + async startArkComm(): Promise { + try { + if (!this._kernel) { + throw new Error('Kernel not started'); + } + + this._arkComm = new ArkComm(this._kernel); + await this._arkComm!.createComm(); + } catch (err) { + LOGGER.error(`Error starting DAP: ${err}`); + } + } + + /** + * Handle DAP messages in an infinite loop. + * Should typically not be awaited. + */ + private async startDapMessageLoop(): Promise { + LOGGER.info('Starting DAP loop'); + + if (!this._dapComm?.comm) { + throw new Error('Must create comm before use'); + } + + for await (const message of this._dapComm.comm.receiver) { + LOGGER.trace('Received DAP message:', JSON.stringify(message)); + + if (!await this._dapComm.handleMessage(message)) { + LOGGER.info(`Unknown DAP message: ${message.method}`); + + if (message.kind === 'request') { + message.handle(() => { throw new Error(`Unknown request '${message.method}' for DAP comm`) }); + } } } + + LOGGER.info('Exiting DAP loop'); + this._dapComm?.dispose(); } private async onStateChange(state: positron.RuntimeState): Promise { this._state = state; if (state === positron.RuntimeState.Ready) { - await this.startDap(); - await this.setConsoleWidth(); + await Promise.all([ + this.startDap(), + this.setConsoleWidth() + ]); } else if (state === positron.RuntimeState.Exited) { - await this.deactivateLsp('session exited'); + await Promise.all([ + this._dapComm?.dispose(), + this.deactivateLsp('session exited'), + ]); } } diff --git a/extensions/positron-r/src/test/ark-comm.test.ts b/extensions/positron-r/src/test/ark-comm.test.ts new file mode 100644 index 000000000000..8e1646c1af5e --- /dev/null +++ b/extensions/positron-r/src/test/ark-comm.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as testKit from './kit'; +import { RSession } from '../session'; +import { Comm, CommBackendMessage } from '../positron-supervisor'; +import { whenTimeout } from '../util'; + +suite('ArkComm', () => { + let session: RSession; + let sesDisposable: vscode.Disposable; + let comm: Comm; + + suiteSetup(async () => { + const [ses, disposable] = await testKit.startR(); + session = ses; + sesDisposable = disposable; + + await session.startArkComm(); + assert.notStrictEqual(session.arkComm, undefined); + assert.notStrictEqual(session.arkComm!.comm, undefined); + + comm = session.arkComm!.comm!; + }); + + suiteTeardown(async () => { + if (sesDisposable) { + await sesDisposable.dispose(); + } + }); + + test('Can send notification', async () => { + comm.notify("test_notification", { i: 10 }); + + // Backend should echo back + const notifReply = await assertNextMessage(comm); + assert.deepStrictEqual( + notifReply, + { + kind: 'notification', + method: 'test_notification', + params: { + i: -10 + } + } + ) + }); + + test('Can send request', async () => { + const requestReply = await assertRequest(comm, 'test_request', { i: 11 }); + assert.deepStrictEqual(requestReply, { i: -11 }) + }); + + test('Invalid method sends error', async () => { + await assert.rejects( + async () => { + await assertRequest(comm, 'invalid_request', {}); + }, + (error: any) => { + return error.name === 'CommRpcError'; + } + ); + }); + + test('Request can error', async () => { + await assert.rejects( + async () => { + await assertRequest(comm, 'test_request_error', {}); + }, + (error: any) => { + return error.name === 'CommRpcError' && /this-is-an-error/.test(error.message); + } + ); + }); +}); + +async function assertNextMessage(comm: Comm): Promise { + const result = await Promise.race([ + comm.receiver.next(), + whenTimeout(5000, () => assert.fail(`Timeout while expecting comm message on ${comm.id}`)), + ]) as any; + + assert.strictEqual(result.done, false) + return result.value; +} + +async function assertRequest(comm: Comm, method: string, params?: Record): Promise { + return await Promise.race([ + comm.request(method, params), + whenTimeout(5000, () => assert.fail(`Timeout while expecting comm reply on ${comm.id}`)), + ]); +} diff --git a/extensions/positron-r/src/test/kit-session.ts b/extensions/positron-r/src/test/kit-session.ts index 0250d3297968..0baa66779d1f 100644 --- a/extensions/positron-r/src/test/kit-session.ts +++ b/extensions/positron-r/src/test/kit-session.ts @@ -39,8 +39,7 @@ export async function startR(): Promise<[RSession, vscode.Disposable, ArkLsp]> { const lspReady = session.waitLsp(); const lspTimeout = (async () => { - await delay(2000); - undefined + await delay(5000); })(); const lsp = await Promise.race([lspReady, lspTimeout]); diff --git a/extensions/positron-reticulate/src/positron-supervisor.d.ts b/extensions/positron-reticulate/src/positron-supervisor.d.ts index ff95ec33d0c8..5a36ebbb69d4 100644 --- a/extensions/positron-reticulate/src/positron-supervisor.d.ts +++ b/extensions/positron-reticulate/src/positron-supervisor.d.ts @@ -36,7 +36,7 @@ export interface JupyterKernel { /** * This set of type definitions defines the interfaces used by the Positron - * Positron Supervisor extension. + * Supervisor extension. */ /** @@ -84,19 +84,86 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS * Convenience method for starting the Positron LSP server, if the * language runtime supports it. * + * @param clientId The ID of the client comm, created with + * `createPositronLspClientId()`. * @param ipAddress The address of the client that will connect to the * language server. */ - startPositronLsp(ipAddress: string): Promise; + startPositronLsp(clientId: string, ipAddress: string): Promise; /** - * Convenience method for starting the Positron DAP server, if the - * language runtime supports it. + * Convenience method for creating a client id to pass to + * `startPositronLsp()`. The caller can later remove the client using this + * id as well. + */ + createPositronLspClientId(): string; + + /** + * Start a comm for communication between frontend and backend. + * + * Unlike Positron clients, this kind of comm is private to the calling + * extension and its kernel. They open a direct line of communication that + * lives entirely on the extension host. + * + * The messages sent over these comms are expected to conform to JSON-RPC: + * - The message type is encoded in the `method` field. + * - Parameters are optional and encoded as object (named list of + * parameters) in the `params` field. + * - If a response is expected, add an `id` field to indicate that this is a + * request. This ID field is redundant with the one used in the Jupyter + * layer but allows applications to make a distinction between notifications + * and requests. + * + * Responses to requests follow this format: + * - `result` or `error` field. + * - `id` field corresponding to the request's `id`. * - * @param debugType Passed as `vscode.DebugConfiguration.type`. - * @param debugName Passed as `vscode.DebugConfiguration.name`. + * @param target_name Comm type, also used to generate comm identifier. + * @param params Optionally, additional parameters included in `comm_open`. */ - startPositronDap(debugType: string, debugName: string): Promise; + createComm( + target_name: string, + params?: Record, + ): Promise; + + /** + * Create a server comm. + * + * Server comms are a special type of comms (see `createComm()`) that + * wrap a TCP server (e.g. an LSP or DAP server). The backend is expected to + * handle `comm_open` messages for `targetName` comms in the following way: + * + * - The `comm_open` messages includes an `ip_address` field. The server + * must be started on this addess. The server, and not the client, picks + * the port to prevent race conditions where a port becomes used between + * the time it was picked by the frontend and handled by the backend. + * + * - Once the server is started at `ip_address` on a port, the backend sends + * back a notification message of type (method) `server_started` that + * includes a field `port`. + * + * @param targetName The name of the comm target + * @param ip_address The IP address to which the server should bind to. + * @returns A promise that resolves to a tuple of [Comm, port number] + * once the server has been started on the backend side. + */ + createServerComm(targetName: string, ip_address: string): Promise<[Comm, number]>; + + /** + * Constructs a new DapComm instance. + * Must be disposed. See `DapComm` documentation. + * + * @param session The Jupyter language runtime session. + * @param targetName The name of the comm target. + * @param debugType The type of debugger, as required by `vscode.DebugConfiguration.type`. + * @param debugName The name of the debugger, as required by `vscode.DebugConfiguration.name`. + * @returns A new `DapComm` instance. + */ + createDapComm( + targetName: string, + debugType: string, + debugName: string, + ): Promise; /** * Method for emitting a message to the language server's Jupyter output @@ -110,8 +177,18 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS /** * A Jupyter kernel is guaranteed to have a `showOutput()` * method, so we declare it non-optional. + * + * @param channel The name of the output channel to show. + * If not provided, the default channel is shown. */ - showOutput(): void; + showOutput(channel?: positron.LanguageRuntimeSessionChannel): void; + + /** + * Return a list of output channels + * + * @returns A list of output channels available on this runtime + */ + listOutputChannels(): positron.LanguageRuntimeSessionChannel[]; /** * A Jupyter kernel is guaranteed to have a `callMethod()` method; it uses @@ -152,18 +229,25 @@ export interface PositronSupervisorApi extends vscode.Disposable { extra?: JupyterKernelExtra | undefined, ): Promise; + /** + * Validate an existing session for a Jupyter-compatible kernel. + */ + validateSession(sessionId: string): Promise; + /** * Restore a session for a Jupyter-compatible kernel. * * @param runtimeMetadata The metadata for the language runtime to be * wrapped by the adapter. * @param sessionMetadata The metadata for the session to be reconnected. + * @param dynState The initial dynamic state of the session. * * @returns A JupyterLanguageRuntimeSession that wraps the kernel. */ restoreSession( runtimeMetadata: positron.LanguageRuntimeMetadata, - sessionMetadata: positron.RuntimeSessionMetadata + sessionMetadata: positron.RuntimeSessionMetadata, + dynState: positron.LanguageRuntimeDynState, ): Promise; } @@ -177,3 +261,170 @@ export interface JupyterKernelExtra { init: (args: Array, delay: number) => void; }; } + +/** + * Comm between an extension and its kernel. + * + * This type of comm is not mapped to a Positron client. It lives entirely in + * the extension space and allows a direct line of communication between an + * extension and its kernel. + * + * It's a disposable. Dispose of it once it's closed or you're no longer using + * it. If the comm has not already been closed by the kernel, a client-initiated + * `comm_close` message is emitted to clean the comm on the backend side. + */ +export interface Comm { + /** The comm ID. */ + id: string; + + /** + * Async-iterable for messages sent from backend. + * + * - This receiver channel _must_ be awaited and handled to exhaustion. + * - When exhausted, you _must_ dispose of the comm. + * + * Yields `CommBackendMessage` messages which are a tagged union of + * notifications and requests. If a request, the `handle` method _must_ be + * called (see `CommBackendMessage` documentation). + */ + receiver: ReceiverChannel; + + /** + * Send a notification to the backend comm. + * Throws `CommClosedError` if comm was closed. + */ + notify: (method: string, params?: Record) => void; + + /** + * Make a request to the backend comm. + * + * Resolves when backend responds with the result. + * Throws: + * - `CommClosedError` if comm was closed + * - `CommRpcError` for RPC errors. + */ + request: (method: string, params?: Record) => Promise; + + /** Clear resources and sends `comm_close` to backend comm (unless the channel + * was closed by the backend already). */ + dispose: () => Promise; +} + +/** + * Async-iterable receiver channel for comm messages from the backend. + * The messages are buffered and must be received as long as the channel is open. + * Dispose to close. + */ +export interface ReceiverChannel extends AsyncIterable, vscode.Disposable { + next(): Promise>; +} + +/** + * Base class for communication errors. + */ +export interface CommError extends Error { + readonly name: 'CommError' | 'CommClosedError' | 'CommRpcError'; + readonly method?: string; +} + +/** + * Error thrown when attempting to communicate through a closed channel. + */ +export interface CommClosedError extends CommError { + readonly name: 'CommClosedError'; +} + +/** + * Error thrown for RPC-specific errors with error codes. + */ +export interface CommRpcError extends CommError { + readonly name: 'CommRpcError'; + readonly code: number; +} + +/** + * Message from the backend. + * + * If a request, the `handle` method _must_ be called. + * Throw an error from `handle` to reject the request (e.g. if `method` is unknown). + * + * Note: Requests are currently not possible, see + * + */ +export type CommBackendMessage = + | { + kind: 'request'; + method: string; + params?: Record; + handle: (handler: () => any) => void; + } + | { + kind: 'notification'; + method: string; + params?: Record; + }; + +/** + * A Debug Adapter Protocol (DAP) comm. + * + * This wraps a `Comm` that: + * + * - Implements the server protocol (see `createComm()` and + * `JupyterLanguageRuntimeSession::createServerComm()`). + * + * - Optionally handles a standard set of DAP comm messages. + * + * Must be disposed when no longer in use or if `comm.receiver` is exhausted. + * Disposing the `DapComm` automatically disposes of the nested `Comm`. + */ +export interface DapComm { + /** The `targetName` passed to the constructor. */ + readonly targetName: string; + + /** The `debugType` passed to the constructor. */ + readonly debugType: string; + + /** The `debugName` passed to the constructor. */ + readonly debugName: string; + + /** + * The comm for the DAP. + * Use it to receive messages or make notifications and requests. + * Defined after `createServerComm()` has been called. + */ + readonly comm?: Comm; + + /** + * The port on which the DAP server is listening. + * Defined after `createServerComm()` has been called. + */ + readonly serverPort?: number; + + /** + * Handle a message received via `this.comm.receiver`. + * + * This is optional. If called, these message types are handled: + * + * - `start_debug`: A debugging session is started from the frontend side, + * connecting to `this.serverPort`. + * + * - `execute`: A command is visibly executed in the console. Can be used to + * handle DAP requests like "step" via the console, delegating to the + * interpreter's own debugging infrastructure. + * + * - `restart`: The console session is restarted. Can be used to handle a + * restart DAP request on the backend side. + * + * Returns whether the message was handled. Note that if the message was not + * handled, you _must_ check whether the message is a request, and either + * handle or reject it in that case. + */ + handleMessage(msg: any): Promise; + + /** + * Dispose of the underlying comm. + * Must be called if the DAP comm is no longer in use. + * Closes the comm if not done already. + */ + dispose(): void; +} diff --git a/extensions/positron-supervisor/justfile b/extensions/positron-supervisor/justfile new file mode 100644 index 000000000000..d714c92c43d2 --- /dev/null +++ b/extensions/positron-supervisor/justfile @@ -0,0 +1,5 @@ +d-ts: + cp src/positron-supervisor.d.ts ../positron-r/src/positron-supervisor.d.ts + cp src/positron-supervisor.d.ts ../positron-reticulate/src/positron-supervisor.d.ts + cp src/positron-supervisor.d.ts ../positron-python/src/client/positron-supervisor.d.ts + npx prettier --config ../positron-python/.prettierrc.js --write ../positron-python/src/client/positron-supervisor.d.ts diff --git a/extensions/positron-supervisor/src/Channel.ts b/extensions/positron-supervisor/src/Channel.ts new file mode 100644 index 000000000000..641b6894c7eb --- /dev/null +++ b/extensions/positron-supervisor/src/Channel.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { delay } from './util'; + +// Used to yield periodically to the event loop +const YIELD_THRESHOLD = 100; + +/** + * Creates a new channel and returns both sender and receiver. + * Either can be used to close the channel by calling `dispose()`. + */ +export function channel(): [Sender, Receiver] { + const state = new ChannelState(); + const sender = new Sender(state); + const receiver = new Receiver(state); + return [sender, receiver]; +} + +/** + * Channel sender (tx). synchronously sends values to the channel. + */ +export class Sender implements vscode.Disposable { + private disposables: vscode.Disposable[] = []; + + constructor(private state: ChannelState) { } + + /** + * Sends a value to the channel. + * @param value A value to send + * @returns `true` if the value was sent successfully, `false` if the channel is closed. + */ + send(value: T): boolean { + if (this.state.closed) { + return false; + } + + if (this.state.pending_consumers.length > 0) { + // There is a consumer waiting, resolve it immediately + const consumer = this.state.pending_consumers.shift(); + consumer!({ value, done: false }); + } else { + // No consumer waiting, queue up the value + this.state.queue.push(value); + } + + return true; + } + + dispose() { + this.state.dispose(); + for (const disposable of this.disposables) { + disposable.dispose(); + } + } + + register(disposable: vscode.Disposable) { + this.disposables.push(disposable); + } +} + +/** + * Channel receiver (rx). Async-iterable to receive values from the channel. + */ +export class Receiver implements AsyncIterable, AsyncIterator, vscode.Disposable { + private yieldCount = 0; + private disposables: vscode.Disposable[] = []; + + constructor(private state: ChannelState) { } + + [Symbol.asyncIterator]() { + return this; + } + + async next(): Promise> { + if (this.state.queue.length > 0) { + ++this.yieldCount; + + // Get the value from queue first, before any potential await + const value = this.state.queue.shift()!; + + // Yield regularly to event loop to avoid starvation. Sends are + // synchronous and handlers might be synchronous as well. + if (this.yieldCount > YIELD_THRESHOLD) { + this.yieldCount = 0; + await delay(0); + } + + return { value, done: false }; + } + + // If nothing in the queue and the channel is closed, we're done + if (this.state.closed) { + return { value: undefined, done: true }; + } + + // Nothing in the queue, wait for a value to be sent + return new Promise>((resolve) => { + this.state.pending_consumers.push(resolve); + }); + } + + dispose() { + this.state.dispose(); + for (const disposable of this.disposables) { + disposable.dispose(); + } + } + + register(disposable: vscode.Disposable) { + this.disposables.push(disposable); + } +} + +/** + * Shared state between sender and receiver + */ +class ChannelState { + closed = false; + queue: T[] = []; + pending_consumers: ((value: IteratorResult) => void)[] = []; + + dispose() { + // Since channel is owned by multiple endpoints we need to be careful about + // `dispose()` being idempotent + if (this.closed) { + return; + } + this.closed = true; + + // Resolve all pending consumers as done + while (this.pending_consumers.length > 0) { + this.pending_consumers.shift()!({ value: undefined, done: true }); + } + } +} diff --git a/extensions/positron-supervisor/src/Client.ts b/extensions/positron-supervisor/src/Client.ts new file mode 100644 index 000000000000..be427964366e --- /dev/null +++ b/extensions/positron-supervisor/src/Client.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Simple representation of a client (and its underlying comm) between Positron and the kernel + */ +export class Client { + /** + * Create a new client representation + * + * @param id The unique ID of the comm/client instance. + * @param target The comm/client's target name (also known as its type); can be any + * string. Positron-specific comms are listed in its `RuntimeClientType` + * enum. + */ + constructor( + public readonly id: string, + public readonly target: string) { + } +} diff --git a/extensions/positron-supervisor/src/Comm.ts b/extensions/positron-supervisor/src/Comm.ts index ec61e81a94c6..bb8bccae8e50 100644 --- a/extensions/positron-supervisor/src/Comm.ts +++ b/extensions/positron-supervisor/src/Comm.ts @@ -1,23 +1,227 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -/** - * Simple representation of a comm (communications channel) between the client - * and the kernel - */ -export class Comm { - /** - * Create a new comm representation - * - * @param id The unique ID of the comm instance @param target The comm - * @param target The comm's target name (also known as its type); can be any - * string. Positron-specific comms are listed in its `RuntimeClientType` - * enum. - */ +import * as vscode from 'vscode'; +import { KallichoreSession } from './KallichoreSession'; +import { createUniqueId } from './util'; +import { JupyterCommMsg } from './jupyter/JupyterCommMsg'; +import { CommMsgRequest } from './jupyter/CommMsgRequest'; +import { Receiver } from './Channel'; +import { CommBackendMessage } from './positron-supervisor'; +import { CommCloseCommand } from './jupyter/CommCloseCommand'; + +export class CommError extends Error { + constructor( + message: string, + public readonly method?: string + ) { + super(message); + this.name = 'CommError'; + } +} + +export class CommClosedError extends CommError { + constructor(commId: string, method?: string) { + super(`Communication channel ${commId} is closed`, method); + this.name = 'CommClosedError'; + } +} + +export class CommRpcError extends CommError { + constructor( + message: string, + public readonly code: number = -32000, + method?: string + ) { + super(message, method); + this.name = 'CommRpcError'; + } +} + +export class CommImpl implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + private closed = false; + constructor( public readonly id: string, - public readonly target: string) { + private readonly session: KallichoreSession, + public readonly receiver: Receiver, + ) { } + + notify(method: string, params?: Record): void { + if (this.closed) { + throw new CommClosedError(this.id, method); + } + + const msg: CommRpcMessage = { + jsonrpc: '2.0', + method, + params, + }; + + // We don't expect a response here, so `id` can be created and forgotten + const id = createUniqueId(); + this.session.sendClientMessage(this.id, id, msg); + } + + async request(method: string, params?: Record): Promise { + if (this.closed) { + throw new CommClosedError(this.id, method); + } + + const id = createUniqueId(); + + const msg: CommRpcMessage = { + jsonrpc: '2.0', + id, + method, + params, + }; + + const commMsg: JupyterCommMsg = { + comm_id: this.id, + data: msg + }; + + const request = new CommMsgRequest(id, commMsg); + const reply = await this.session.sendRequest(request); + + if (reply.data.error !== undefined) { + const payload = reply.data as CommRpcMessageError; + throw new CommRpcError( + payload.error.message, + payload.error.code, + method + ); + } + + if (reply.data.result === undefined) { + throw new Error(`Internal error in ${this.id}: undefined result for request ${msg}`); + } + + return reply.data.result; + } + + close() { + this.closed = true; + } + + closeAndNotify() { + if (this.closed) { + return; + } + + this.close(); + const commClose = new CommCloseCommand(this.id); + this.session.sendCommand(commClose); + } + + // Make sure not to call `dispose()` from Kallichore, only the owner of the + // comm should dispose of it. Kallichore calls the `close()` method instead. + async dispose(): Promise { + this.close(); + for (const disposable of this.disposables) { + disposable.dispose(); + } + + // Clear array to make method idempotent + this.disposables.length = 0; + } + + register(disposable: vscode.Disposable) { + this.disposables.push(disposable); + } +} + +export class CommBackendRequest { + kind: 'request' = 'request'; + readonly method: string; + readonly params?: Record; + + private readonly id: string; + + constructor( + private readonly session: KallichoreSession, + private readonly commId: string, + private readonly message: CommRpcMessage, + ) { + this.method = message.method; + this.params = message.params; + + if (!this.message.id) { + throw new Error('Expected `id` field in request'); + } + this.id = this.message.id; } + + // Handle request. Takes a callback and responds with return value or rejects + // with error if one is thrown. + handle(handler: () => any) { + try { + this.reply(handler()); + } catch (err) { + this.reject(err); + } + } + + reply(result: any) { + const msg: CommRpcMessageResult = { + jsonrpc: '2.0', + id: this.id, + method: this.method, + result, + }; + this.send(msg); + } + + reject(error: Error, code = -32000) { + const msg: CommRpcMessageError = { + jsonrpc: '2.0', + id: this.id, + error: { + method: this.method, + message: `${error}`, + code, + } + }; + this.send(msg); + } + + private send(data: Record) { + const commMsg: JupyterCommMsg = { + comm_id: this.commId, + data, + }; + this.session.sendClientMessage(this.commId, this.id, commMsg); + } +} + +export interface CommRpcMessage { + jsonrpc: '2.0'; + method: string; + // If present, this indicates a request, otherwise a notification. + // This `id` is otherwise redundant with Jupyter's own `id` field. + id?: string; + params?: Record; + [key: string]: unknown; +} + +interface CommRpcMessageResult { + jsonrpc: '2.0'; + result: any; + id: string; + [key: string]: unknown; +} + +interface CommRpcMessageError { + jsonrpc: '2.0'; + error: { + message: string; + code: number; + [key: string]: unknown; + }; + id: string; + [key: string]: unknown; } diff --git a/extensions/positron-supervisor/src/DapClient.ts b/extensions/positron-supervisor/src/DapClient.ts deleted file mode 100644 index 731b16119947..000000000000 --- a/extensions/positron-supervisor/src/DapClient.ts +++ /dev/null @@ -1,72 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. - * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import * as positron from 'positron'; -import { JupyterLanguageRuntimeSession } from './positron-supervisor'; - -/** - * A Debug Adapter Protocol (DAP) client instance; handles messages from the - * kernel side of the DAP and forwards them to the debug adapter. - */ -export class DapClient { - /** Message counter; used for creating unique message IDs */ - private static _counter = 0; - - private _msgStem: string; - - constructor(readonly clientId: string, - readonly serverPort: number, - readonly debugType: string, - readonly debugName: string, - readonly session: JupyterLanguageRuntimeSession) { - - // Generate 8 random hex characters for the message stem - this._msgStem = Math.random().toString(16).slice(2, 10); - } - - handleDapMessage(msg: any) { - switch (msg.msg_type) { - // The runtime is in control of when to start a debug session. - // When this happens, we attach automatically to the runtime - // with a synthetic configuration. - case 'start_debug': { - this.session.emitJupyterLog(`Starting debug session for DAP server ${this.clientId}`); - const config: vscode.DebugConfiguration = { - type: this.debugType, - name: this.debugName, - request: 'attach', - debugServer: this.serverPort, - internalConsoleOptions: 'neverOpen', - }; - vscode.debug.startDebugging(undefined, config); - break; - } - - // If the DAP has commands to execute, such as "n", "f", or "Q", - // it sends events to let us do it from here. - case 'execute': { - this.session.execute( - msg.content.command, - this._msgStem + '-dap-' + DapClient._counter++, - positron.RuntimeCodeExecutionMode.Interactive, - positron.RuntimeErrorBehavior.Stop - ); - break; - } - - // We use the restart button as a shortcut for restarting the runtime - case 'restart': { - this.session.restart(); - break; - } - - default: { - this.session.emitJupyterLog(`Unknown DAP command: ${msg.msg_type}`); - break; - } - } - } -} diff --git a/extensions/positron-supervisor/src/DapComm.ts b/extensions/positron-supervisor/src/DapComm.ts new file mode 100644 index 000000000000..8834e18031e0 --- /dev/null +++ b/extensions/positron-supervisor/src/DapComm.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as positron from 'positron'; +import { JupyterLanguageRuntimeSession, Comm } from './positron-supervisor'; + +/** + * A Debug Adapter Protocol (DAP) comm. + * See `positron-supervisor.d.ts` for documentation. + */ +export class DapComm { + public get comm(): Comm | undefined { + return this._comm; + } + public get port(): number | undefined { + return this._port; + } + + private _comm?: Comm; + private _port?: number; + + // Message counter used for creating unique message IDs + private messageCounter = 0; + + // Random stem for messages + private msgStem: string; + + constructor( + private session: JupyterLanguageRuntimeSession, + readonly targetName: string, + readonly debugType: string, + readonly debugName: string, + ) { + + // Generate 8 random hex characters for the message stem + this.msgStem = Math.random().toString(16).slice(2, 10); + } + + async createComm(): Promise { + // NOTE: Ideally we'd allow connecting to any network interface but the + // `debugServer` property passed in the configuration below needs to be + // localhost. + const host = '127.0.0.1'; + + const [comm, serverPort] = await this.session.createServerComm(this.targetName, host); + + this._comm = comm; + this._port = serverPort; + } + + async handleMessage(msg: any): Promise { + if (msg.kind === 'request') { + return false; + } + + switch (msg.method) { + // The runtime is in control of when to start a debug session. + // When this happens, we attach automatically to the runtime + // with a synthetic configuration. + case 'start_debug': { + this.session.emitJupyterLog(`Starting debug session for DAP server ${this.comm!.id}`); + const config: vscode.DebugConfiguration = { + type: this.debugType, + name: this.debugName, + request: 'attach', + debugServer: this.port, + internalConsoleOptions: 'neverOpen', + }; + + // Log errors because this sometimes fail at + // https://github.com/posit-dev/positron/blob/71686862/src/vs/workbench/contrib/debug/browser/debugService.ts#L361 + // because `hasDebugged` is undefined. + try { + await vscode.debug.startDebugging(undefined, config); + } catch (err) { + this.session.emitJupyterLog( + `Can't start debug session for DAP server ${this.comm!.id}: ${err}`, + vscode.LogLevel.Warning + ); + } + + return true; + } + + // If the DAP has commands to execute, such as "n", "f", or "Q", + // it sends events to let us do it from here. + case 'execute': { + const command = msg.params?.command; + if (command) { + this.session.execute( + command, + this.msgStem + '-dap-' + this.messageCounter++, + positron.RuntimeCodeExecutionMode.Interactive, + positron.RuntimeErrorBehavior.Stop + ); + } + + return true; + } + + // We use the restart button as a shortcut for restarting the runtime + case 'restart': { + await this.session.restart(); + return true; + } + + default: { + return false; + } + } + } + + dispose(): void { + this._comm?.dispose(); + } +} diff --git a/extensions/positron-supervisor/src/KallichoreAdapterApi.ts b/extensions/positron-supervisor/src/KallichoreAdapterApi.ts index 4301907b9850..1a620efee10c 100644 --- a/extensions/positron-supervisor/src/KallichoreAdapterApi.ts +++ b/extensions/positron-supervisor/src/KallichoreAdapterApi.ts @@ -15,6 +15,8 @@ import { Barrier, PromiseHandles, withTimeout } from './async'; import { LogStreamer } from './LogStreamer'; import { createUniqueId, summarizeError, summarizeHttpError } from './util'; import { namedPipeInterceptor } from './NamedPipeHttpAgent'; +import { DapComm } from './DapComm'; + const KALLICHORE_STATE_KEY = 'positron-supervisor.v2'; @@ -120,6 +122,8 @@ function constructWebSocketUri(apiBasePath: string, sessionId: string): string { } export class KCApi implements PositronSupervisorApi { + /** The DAP comm class */ + readonly DapComm = DapComm; /** The instance of the API; the API is code-generated from the Kallichore * OpenAPI spec */ diff --git a/extensions/positron-supervisor/src/KallichoreSession.ts b/extensions/positron-supervisor/src/KallichoreSession.ts index 716fba842bce..54abc4cf6d55 100644 --- a/extensions/positron-supervisor/src/KallichoreSession.ts +++ b/extensions/positron-supervisor/src/KallichoreSession.ts @@ -8,7 +8,7 @@ import * as positron from 'positron'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; -import { JupyterKernelExtra, JupyterKernelSpec, JupyterLanguageRuntimeSession, JupyterSession } from './positron-supervisor'; +import { CommBackendMessage, JupyterKernelExtra, JupyterKernelSpec, JupyterLanguageRuntimeSession, JupyterSession, Comm } from './positron-supervisor'; import { ActiveSession, ConnectionInfo, DefaultApi, HttpError, InterruptMode, NewSession, RestartSession, Status, VarAction, VarActionType } from './kcclient/api'; import { JupyterMessage } from './jupyter/JupyterMessage'; import { JupyterRequest } from './jupyter/JupyterRequest'; @@ -31,9 +31,8 @@ import { JupyterChannel } from './jupyter/JupyterChannel'; import { InputReplyCommand } from './jupyter/InputReplyCommand'; import { RpcReplyCommand } from './jupyter/RpcReplyCommand'; import { JupyterCommRequest } from './jupyter/JupyterCommRequest'; -import { Comm } from './Comm'; +import { Client } from './Client'; import { CommMsgRequest } from './jupyter/CommMsgRequest'; -import { DapClient } from './DapClient'; import { SocketSession } from './ws/SocketSession'; import { KernelOutputMessage } from './ws/KernelMessage'; import { UICommRequest } from './UICommRequest'; @@ -41,6 +40,10 @@ import { createUniqueId, summarizeError, summarizeHttpError } from './util'; import { AdoptedSession } from './AdoptedSession'; import { DebugRequest } from './jupyter/DebugRequest'; import { JupyterMessageType } from './jupyter/JupyterMessageType.js'; +import { JupyterCommClose } from './jupyter/JupyterCommClose'; +import { CommBackendRequest, CommRpcMessage, CommImpl } from './Comm'; +import { channel, Sender } from './Channel'; +import { DapComm } from './DapComm'; /** * The reason for a disconnection event. @@ -123,9 +126,6 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { /** Whether it is possible to connect to the session's websocket */ private _canConnect = true; - /** The Debug Adapter Protocol client, if any */ - private _dapClient: DapClient | undefined; - /** A map of pending comm startups */ private _startingComms: Map> = new Map(); @@ -147,8 +147,11 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { */ private _profileChannel: vscode.OutputChannel | undefined; - /** A map of active comm channels */ - private readonly _comms: Map = new Map(); + /** A map of active comms connected to Positron clients */ + private readonly _clients: Map = new Map(); + + /** A map of active comms unmanaged by Positron */ + private readonly _comms: Map]> = new Map(); /** The kernel's log file, if any. */ private _kernelLogFile: string | undefined; @@ -456,58 +459,39 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { return startPromise.promise; } - /** - * Requests that the kernel start a Debug Adapter Protocol server, and - * connect it to the client locally on the given TCP address. - * - * @param clientId The ID of the client comm, created with - * `createPositronDapClientId()`. - * @param debugType Passed as `vscode.DebugConfiguration.type`. - * @param debugName Passed as `vscode.DebugConfiguration.name`. - */ - async startPositronDap(clientId: string, debugType: string, debugName: string) { - // NOTE: Ideally we'd connect to any address but the - // `debugServer` property passed in the configuration below - // needs to be localhost. - const ipAddress = '127.0.0.1'; - - // TODO: Should we query the kernel to see if it can create a DAP - // (QueryInterface style) instead of just demanding it? - // - // The Jupyter kernel spec does not provide a way to query for - // supported comms; the only way to know is to try to create one. + createPositronLspClientId(): string { + return `positron-lsp-${this.runtimeMetadata.languageId}-${createUniqueId()}`; + } - this.log(`Starting DAP server ${clientId} for ${ipAddress}`, vscode.LogLevel.Debug); + /** Create a raw server comm. See `positron-supervisor.d.ts` for documentation. */ + async createServerComm(target_name: string, ip_address: string): Promise<[Comm, number]> { + this.log(`Starting server comm '${target_name}' for ${ip_address}`); + const comm = await this.createComm(target_name, { ip_address }); - // Notify Positron that we're handling messages from this client - this._disposables.push(positron.runtime.registerClientInstance(clientId)); + const result = await comm.receiver.next(); - await this.createClient( - clientId, - positron.RuntimeClientType.Dap, - { ip_address: ipAddress } - ); + if (result.done) { + comm.dispose(); + throw new Error('Comm was closed before sending a `server_started` message'); + } - // Create a promise that will resolve when the DAP starts on the server - // side. When the promise resolves we obtain the port the client should - // connect on. - const startPromise = new PromiseHandles(); - this._startingComms.set(clientId, startPromise); + const message = result.value; - // Immediately await that promise because `startPositronDap()` handles the full - // DAP setup, unlike the LSP where the extension finishes the setup. - const port = await startPromise.promise; + if (message.method !== 'server_started') { + comm.dispose(); + throw new Error('Comm was closed before sending a `server_started` message'); + } - // Create the DAP client message handler - this._dapClient = new DapClient(clientId, port, debugType, debugName, this); - } + const serverStarted = message.params as any; + const port = serverStarted.port; - createPositronLspClientId(): string { - return `positron-lsp-${this.runtimeMetadata.languageId}-${createUniqueId()}`; - } + if (typeof port !== 'number') { + comm.dispose(); + throw new Error('`server_started` message doesn\'t include a port'); + } - createPositronDapClientId(): string { - return `positron-dap-${this.runtimeMetadata.languageId}-${createUniqueId()}`; + this.log(`Started server comm '${target_name}' for ${ip_address} on port ${port}`); + return [comm, port]; } /** @@ -564,7 +548,7 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { const request = new UICommRequest(method, args, promise); // Find the UI comm - const uiComm = Array.from(this._comms.values()) + const uiComm = Array.from(this._clients.values()) .find(c => c.target === positron.RuntimeClientType.Ui); if (!uiComm) { @@ -736,6 +720,50 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { } } + /** Create raw comm. See `positron-supervisor.d.ts` for documentation. */ + async createComm( + target_name: string, + params: Record = {}, + ): Promise { + const id = `extension-comm-${target_name}-${this.runtimeMetadata.languageId}-${createUniqueId()}`; + + const [tx, rx] = channel(); + const comm = new CommImpl(id, this, rx); + this._comms.set(id, [comm, tx]); + + // Disposal handler that allows extension to initiate close comm + comm.register({ + dispose: () => { + // If already deleted, it means a `comm_close` from the backend was + // received and we don't need to send one. + if (this._comms.delete(id)) { + comm.closeAndNotify(); + } + } + }); + + const msg: JupyterCommOpen = { + target_name, + comm_id: id, + data: params, + }; + const commOpen = new CommOpenCommand(msg); + await this.sendCommand(commOpen); + + return comm as Comm; + } + + /** Create DAP comm. See `positron-supervisor.d.ts` for documentation. */ + async createDapComm( + targetName: string, + debugType: string, + debugName: string, + ): Promise { + const comm = new DapComm(this, targetName, debugType, debugName); + await comm.createComm(); + return comm; + } + /** * Create a new client comm. * @@ -755,7 +783,6 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { // client-initiated creation if (type === positron.RuntimeClientType.Variables || type === positron.RuntimeClientType.Lsp || - type === positron.RuntimeClientType.Dap || type === positron.RuntimeClientType.Ui || type === positron.RuntimeClientType.Help || type === positron.RuntimeClientType.IPyWidgetControl) { @@ -767,7 +794,7 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { }; const commOpen = new CommOpenCommand(msg, metadata); await this.sendCommand(commOpen); - this._comms.set(id, new Comm(id, type)); + this._clients.set(id, new Client(id, type)); // If we have any pending UI comm requests and we just created the // UI comm, send them now @@ -794,12 +821,17 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { const comms = reply.comms; // Unwrap the comm info and add it to the result for (const key in comms) { + // Don't list as client if this is an unmanaged comm + if (this._comms.has(key)) { + continue; + } + if (comms.hasOwnProperty(key)) { const target = comms[key].target_name; result[key] = target; // If we don't have a comm object for this comm, create one - if (!this._comms.has(key)) { - this._comms.set(key, new Comm(key, target)); + if (!this._clients.has(key)) { + this._clients.set(key, new Client(key, target)); } // If we just discovered a UI comm, send any pending UI comm @@ -815,6 +847,8 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { } removeClient(id: string): void { + this._clients.delete(id); + // Ignore this if the session is already exited; an exited session has // no clients if (this._runtimeState === positron.RuntimeState.Exited) { @@ -1751,7 +1785,15 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { this._connected = new Barrier(); } - // All comms are now closed + // All clients are now closed + this._clients.clear(); + + // Close all raw comms + for (const [comm, tx] of this._comms.values()) { + // Don't dispose of comm, this resource is owned by caller of `createComm()`. + comm.close(); + tx.dispose(); + } this._comms.clear(); // Clear any starting comms @@ -1866,6 +1908,16 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { if (request.replyType === msg.header.msg_type) { request.resolve(msg.content); this._pendingRequests.delete(msg.parent_header.msg_id); + + // If this is a reply for an unmanaged comm, return early. + // The comm socket gets the response via the now resolved request + // promise. + if (msg.header.msg_type === 'comm_msg') { + const commMsg = msg.content as JupyterCommMsg; + if (this._comms.has(commMsg.comm_id)) { + return; + } + } } } } @@ -1890,18 +1942,49 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { } } - if (msg.header.msg_type === 'comm_msg') { - const commMsg = msg.content as JupyterCommMsg; + // Handle comms that are not managed by Positron first + switch (msg.header.msg_type) { + case 'comm_close': { + const closeMsg = msg.content as JupyterCommClose; + const commHandle = this._comms.get(closeMsg.comm_id); - // If we have a DAP client active and this is a comm message intended - // for that client, forward the message. - if (this._dapClient) { - const comm = this._comms.get(commMsg.comm_id); - if (comm && comm.id === this._dapClient.clientId) { - this._dapClient.handleDapMessage(commMsg.data); + if (commHandle) { + // Delete first, this prevents the channel disposable from sending a + // `comm_close` back + this._comms.delete(closeMsg.comm_id); + + const [comm, _] = commHandle; + comm.close(); + return; } + + break; } + case 'comm_msg': { + const commMsg = msg.content as JupyterCommMsg; + const commHandle = this._comms.get(commMsg.comm_id); + + if (commHandle) { + const [_, tx] = commHandle; + const rpcMsg = commMsg.data as CommRpcMessage; + + if (rpcMsg.id) { + tx.send(new CommBackendRequest(this, commMsg.comm_id, rpcMsg)); + } else { + tx.send({ kind: 'notification', method: rpcMsg.method, params: rpcMsg.params }); + } + + return; + } + + break; + } + } + + // TODO: Make LSP comms unmanaged and remove this branch + if (msg.header.msg_type === 'comm_msg') { + const commMsg = msg.content as JupyterCommMsg; // If this is a `server_started` message, resolve the promise that // was created when the comm was started. if (commMsg.data.msg_type === 'server_started') { diff --git a/extensions/positron-supervisor/src/positron-supervisor.d.ts b/extensions/positron-supervisor/src/positron-supervisor.d.ts index ee5797600381..5a36ebbb69d4 100644 --- a/extensions/positron-supervisor/src/positron-supervisor.d.ts +++ b/extensions/positron-supervisor/src/positron-supervisor.d.ts @@ -91,17 +91,6 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS */ startPositronLsp(clientId: string, ipAddress: string): Promise; - /** - * Convenience method for starting the Positron DAP server, if the - * language runtime supports it. - * - * @param clientId The ID of the client comm, created with - * `createPositronDapClientId()`. - * @param debugType Passed as `vscode.DebugConfiguration.type`. - * @param debugName Passed as `vscode.DebugConfiguration.name`. - */ - startPositronDap(clientId: string, debugType: string, debugName: string): Promise; - /** * Convenience method for creating a client id to pass to * `startPositronLsp()`. The caller can later remove the client using this @@ -110,11 +99,71 @@ export interface JupyterLanguageRuntimeSession extends positron.LanguageRuntimeS createPositronLspClientId(): string; /** - * Convenience method for creating a client id to pass to - * `startPositronDap()`. The caller can later remove the client using this - * id as well. + * Start a comm for communication between frontend and backend. + * + * Unlike Positron clients, this kind of comm is private to the calling + * extension and its kernel. They open a direct line of communication that + * lives entirely on the extension host. + * + * The messages sent over these comms are expected to conform to JSON-RPC: + * - The message type is encoded in the `method` field. + * - Parameters are optional and encoded as object (named list of + * parameters) in the `params` field. + * - If a response is expected, add an `id` field to indicate that this is a + * request. This ID field is redundant with the one used in the Jupyter + * layer but allows applications to make a distinction between notifications + * and requests. + * + * Responses to requests follow this format: + * - `result` or `error` field. + * - `id` field corresponding to the request's `id`. + * + * @param target_name Comm type, also used to generate comm identifier. + * @param params Optionally, additional parameters included in `comm_open`. + */ + createComm( + target_name: string, + params?: Record, + ): Promise; + + /** + * Create a server comm. + * + * Server comms are a special type of comms (see `createComm()`) that + * wrap a TCP server (e.g. an LSP or DAP server). The backend is expected to + * handle `comm_open` messages for `targetName` comms in the following way: + * + * - The `comm_open` messages includes an `ip_address` field. The server + * must be started on this addess. The server, and not the client, picks + * the port to prevent race conditions where a port becomes used between + * the time it was picked by the frontend and handled by the backend. + * + * - Once the server is started at `ip_address` on a port, the backend sends + * back a notification message of type (method) `server_started` that + * includes a field `port`. + * + * @param targetName The name of the comm target + * @param ip_address The IP address to which the server should bind to. + * @returns A promise that resolves to a tuple of [Comm, port number] + * once the server has been started on the backend side. + */ + createServerComm(targetName: string, ip_address: string): Promise<[Comm, number]>; + + /** + * Constructs a new DapComm instance. + * Must be disposed. See `DapComm` documentation. + * + * @param session The Jupyter language runtime session. + * @param targetName The name of the comm target. + * @param debugType The type of debugger, as required by `vscode.DebugConfiguration.type`. + * @param debugName The name of the debugger, as required by `vscode.DebugConfiguration.name`. + * @returns A new `DapComm` instance. */ - createPositronDapClientId(): string; + createDapComm( + targetName: string, + debugType: string, + debugName: string, + ): Promise; /** * Method for emitting a message to the language server's Jupyter output @@ -212,3 +261,170 @@ export interface JupyterKernelExtra { init: (args: Array, delay: number) => void; }; } + +/** + * Comm between an extension and its kernel. + * + * This type of comm is not mapped to a Positron client. It lives entirely in + * the extension space and allows a direct line of communication between an + * extension and its kernel. + * + * It's a disposable. Dispose of it once it's closed or you're no longer using + * it. If the comm has not already been closed by the kernel, a client-initiated + * `comm_close` message is emitted to clean the comm on the backend side. + */ +export interface Comm { + /** The comm ID. */ + id: string; + + /** + * Async-iterable for messages sent from backend. + * + * - This receiver channel _must_ be awaited and handled to exhaustion. + * - When exhausted, you _must_ dispose of the comm. + * + * Yields `CommBackendMessage` messages which are a tagged union of + * notifications and requests. If a request, the `handle` method _must_ be + * called (see `CommBackendMessage` documentation). + */ + receiver: ReceiverChannel; + + /** + * Send a notification to the backend comm. + * Throws `CommClosedError` if comm was closed. + */ + notify: (method: string, params?: Record) => void; + + /** + * Make a request to the backend comm. + * + * Resolves when backend responds with the result. + * Throws: + * - `CommClosedError` if comm was closed + * - `CommRpcError` for RPC errors. + */ + request: (method: string, params?: Record) => Promise; + + /** Clear resources and sends `comm_close` to backend comm (unless the channel + * was closed by the backend already). */ + dispose: () => Promise; +} + +/** + * Async-iterable receiver channel for comm messages from the backend. + * The messages are buffered and must be received as long as the channel is open. + * Dispose to close. + */ +export interface ReceiverChannel extends AsyncIterable, vscode.Disposable { + next(): Promise>; +} + +/** + * Base class for communication errors. + */ +export interface CommError extends Error { + readonly name: 'CommError' | 'CommClosedError' | 'CommRpcError'; + readonly method?: string; +} + +/** + * Error thrown when attempting to communicate through a closed channel. + */ +export interface CommClosedError extends CommError { + readonly name: 'CommClosedError'; +} + +/** + * Error thrown for RPC-specific errors with error codes. + */ +export interface CommRpcError extends CommError { + readonly name: 'CommRpcError'; + readonly code: number; +} + +/** + * Message from the backend. + * + * If a request, the `handle` method _must_ be called. + * Throw an error from `handle` to reject the request (e.g. if `method` is unknown). + * + * Note: Requests are currently not possible, see + * + */ +export type CommBackendMessage = + | { + kind: 'request'; + method: string; + params?: Record; + handle: (handler: () => any) => void; + } + | { + kind: 'notification'; + method: string; + params?: Record; + }; + +/** + * A Debug Adapter Protocol (DAP) comm. + * + * This wraps a `Comm` that: + * + * - Implements the server protocol (see `createComm()` and + * `JupyterLanguageRuntimeSession::createServerComm()`). + * + * - Optionally handles a standard set of DAP comm messages. + * + * Must be disposed when no longer in use or if `comm.receiver` is exhausted. + * Disposing the `DapComm` automatically disposes of the nested `Comm`. + */ +export interface DapComm { + /** The `targetName` passed to the constructor. */ + readonly targetName: string; + + /** The `debugType` passed to the constructor. */ + readonly debugType: string; + + /** The `debugName` passed to the constructor. */ + readonly debugName: string; + + /** + * The comm for the DAP. + * Use it to receive messages or make notifications and requests. + * Defined after `createServerComm()` has been called. + */ + readonly comm?: Comm; + + /** + * The port on which the DAP server is listening. + * Defined after `createServerComm()` has been called. + */ + readonly serverPort?: number; + + /** + * Handle a message received via `this.comm.receiver`. + * + * This is optional. If called, these message types are handled: + * + * - `start_debug`: A debugging session is started from the frontend side, + * connecting to `this.serverPort`. + * + * - `execute`: A command is visibly executed in the console. Can be used to + * handle DAP requests like "step" via the console, delegating to the + * interpreter's own debugging infrastructure. + * + * - `restart`: The console session is restarted. Can be used to handle a + * restart DAP request on the backend side. + * + * Returns whether the message was handled. Note that if the message was not + * handled, you _must_ check whether the message is a request, and either + * handle or reject it in that case. + */ + handleMessage(msg: any): Promise; + + /** + * Dispose of the underlying comm. + * Must be called if the DAP comm is no longer in use. + * Closes the comm if not done already. + */ + dispose(): void; +} diff --git a/extensions/positron-supervisor/src/util.ts b/extensions/positron-supervisor/src/util.ts index 880deed76dcd..6da30ddb5162 100644 --- a/extensions/positron-supervisor/src/util.ts +++ b/extensions/positron-supervisor/src/util.ts @@ -123,8 +123,8 @@ type PayloadStructure = { /** * @description Type predicate to check if an object is VSBufferLike ({ buffer: Buffer }). - * @param {unknown} item - The item to check. - * @returns {boolean} True if the item is VSBufferLike, false otherwise. + * @param item - The item to check. + * @returns True if the item is VSBufferLike, false otherwise. */ function isVSBufferLike(item: unknown): item is VSBufferLike { return ( @@ -144,8 +144,8 @@ type PayloadWithDataValue = PayloadStructure & { /** * @description Type predicate to check if the payload has the required nested data.value structure. - * @param {unknown} payload - The payload to check. - * @returns {boolean} True if the payload has the expected structure, false otherwise. + * @param payload - The payload to check. + * @returns True if the payload has the expected structure, false otherwise. */ function isPayloadWithDataValue(payload: unknown): payload is PayloadWithDataValue { return ( @@ -164,9 +164,9 @@ function isPayloadWithDataValue(payload: unknown): payload is PayloadWithDataVal /** * @description Validates if an item is a Buffer or VSBufferLike and within the size limit. - * @param {unknown} item - The item to validate. - * @param {number} maxSize - The maximum allowed buffer size in bytes. - * @returns {Buffer | undefined} The Buffer instance if valid, otherwise undefined. + * @param item - The item to validate. + * @param maxSize - The maximum allowed buffer size in bytes. + * @returns The Buffer instance if valid, otherwise undefined. */ function validateAndGetBufferInstance(item: unknown, maxSize: number): Buffer | undefined { let bufferInstance: Buffer | undefined; @@ -195,8 +195,8 @@ function validateAndGetBufferInstance(item: unknown, maxSize: number): Buffer | * found in `payload.data.value.buffers`, converts them to base64 strings, * and restructures the content payload. If the expected structure isn't found, * the original payload is returned as content with empty buffers. - * @param {unknown} payload - The input payload, potentially containing serialized data and buffers. - * @returns {UnpackedResult} An object containing the processed content and an array of base64 buffer strings. + * @param payload - The input payload, potentially containing serialized data and buffers. + * @returns An object containing the processed content and an array of base64 buffer strings. * @export */ export function unpackSerializedObjectWithBuffers(payload: unknown): { @@ -251,3 +251,7 @@ export function unpackSerializedObjectWithBuffers(payload: unknown): { export function isEnumMember>(value: unknown, enumObj: T): value is T[keyof T] { return Object.values(enumObj).includes(value as T[keyof T]); } + +export function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/extensions/positron-zed/src/positronZedLanguageRuntime.ts b/extensions/positron-zed/src/positronZedLanguageRuntime.ts index eb69a8f84d62..0249e6344da1 100644 --- a/extensions/positron-zed/src/positronZedLanguageRuntime.ts +++ b/extensions/positron-zed/src/positronZedLanguageRuntime.ts @@ -957,7 +957,6 @@ export class PositronZedRuntimeSession implements positron.LanguageRuntimeSessio case positron.RuntimeClientType.Help: case positron.RuntimeClientType.Lsp: - case positron.RuntimeClientType.Dap: // These types aren't currently supported by Zed, so close the // comm immediately to signal this to the client. this._onDidReceiveRuntimeMessage.fire({ diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts index f5d74ed5747c..efae57d0d977 100644 --- a/src/positron-dts/positron.d.ts +++ b/src/positron-dts/positron.d.ts @@ -686,7 +686,6 @@ declare module 'positron' { export enum RuntimeClientType { Variables = 'positron.variables', Lsp = 'positron.lsp', - Dap = 'positron.dap', Plot = 'positron.plot', DataExplorer = 'positron.dataExplorer', Ui = 'positron.ui', diff --git a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts index 876d33a4e2f8..07025d140c6c 100644 --- a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts +++ b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts @@ -1174,7 +1174,14 @@ class ExtHostRuntimeClientInstance */ performRpcWithBuffers(request: Input, timeout: number | undefined, responseKeys: Array = []): Promise> { // Generate a unique ID for this message. - const messageId = generateUuid(); + let messageId; + if ((request as any)?.id) { + // If the request already has an id field, use it as id. This is typically + // the case with nested JSON-RPC messages. + messageId = (request as any).id; + } else { + messageId = generateUuid(); + } // Add the promise to the list of pending RPCs. const pending = new PendingRpc(responseKeys); diff --git a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts index 8ccb4c46135e..2236831cda9e 100644 --- a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts +++ b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts @@ -185,7 +185,6 @@ export enum PositronChatMode { export enum RuntimeClientType { Variables = 'positron.variables', Lsp = 'positron.lsp', - Dap = 'positron.dap', Plot = 'positron.plot', DataExplorer = 'positron.dataExplorer', Ui = 'positron.ui', diff --git a/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts b/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts index 83f27f1b0631..64f607af58b8 100644 --- a/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts +++ b/src/vs/workbench/services/languageRuntime/common/positronBaseComm.ts @@ -7,6 +7,7 @@ import { IRuntimeClientInstance, RuntimeClientState, RuntimeClientStatus } from import { Event, Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { ISettableObservable } from '../../../../base/common/observableInternal/base.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; /** * An enum representing the set of JSON-RPC error codes. @@ -227,10 +228,18 @@ export class PositronBaseComm extends Disposable { rpcArgs[paramNames[i]] = paramValues[i]; } - // Form the request object + // Generate a unique ID for this message. + const id = generateUuid(); + + // Form the JSON-RPC message nested in our Jupyter `comm_msg`. Note that the + // `id` field is not used for matching requests at the Jupyter transport + // level. It only expresses that this is a request, not a notification, as + // required by the JSON-RPC spec. This allows comms at the other end to + // determine whether they should respond to the message. const request: any = { jsonrpc: '2.0', method: rpcName, + id }; // Amend params if we have any (methods which take no parameters