Skip to content
Open
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
14 changes: 11 additions & 3 deletions extensions/positron-python/src/client/positron/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,22 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
errorBehavior: positron.RuntimeErrorBehavior,
codeLocation?: positron.Utf8Location,
executionMetadata?: Record<string, any>,
): void {
): Promise<void> {
if (this._kernel) {
if (this._isUninstallBundledPackageCommand(code, id)) {
// It's an attempt to uninstall a bundled package, don't execute.
return;
return Promise.resolve();
}

this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata);
// Return the kernel's execution promise so a rejection propagates
// back to the caller. This matters for the `Unprocessed` mode: the
// supervisor checks completeness and rejects (with a
// `CodeIncompleteError`) when the code is incomplete, and the
// console relies on that rejection to show a continuation prompt.
// Dropping it here would leave the console waiting forever.
return Promise.resolve(
this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata),
);
} else {
throw new Error(`Cannot execute '${code}'; kernel not started`);
}
Expand Down
6 changes: 6 additions & 0 deletions extensions/positron-python/src/test/mocks/pst/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export enum RuntimeCodeExecutionMode {
* Stored in history: No
*/
Silent = 'silent',

/**
* Unprocessed code execution: behaves like `Interactive`, but the code has
* not been checked for completeness; the session checks it before executing.
*/
Unprocessed = 'unprocessed',
}

export enum RuntimeErrorBehavior {
Expand Down
27 changes: 27 additions & 0 deletions extensions/positron-python/src/test/positron/session.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,4 +298,31 @@ suite('Python Runtime Session', () => {
const state = messages[1] as positron.LanguageRuntimeState;
assert.strictEqual(state.state, positron.RuntimeOnlineState.Idle);
});

test('Execute: propagates the kernel execute promise (e.g. incomplete code)', async () => {
sinon.stub(fs, 'readdirSync').returns(['ipykernel']);

const session = createSession(positron.LanguageRuntimeSessionMode.Console);
await session.start();

// For Unprocessed code the supervisor checks completeness itself and
// rejects with a CodeIncompleteError when the code is incomplete. That
// rejection must propagate back through execute() so the console can
// show a continuation prompt instead of hanging forever.
const incompleteError = new Error('Code fragment is incomplete');
incompleteError.name = 'CodeIncompleteError';
sinon.stub(kernel, 'execute').rejects(incompleteError);

await assert.rejects(
Promise.resolve(
session.execute(
'def f():',
'execute-id',
positron.RuntimeCodeExecutionMode.Unprocessed,
positron.RuntimeErrorBehavior.Continue,
),
),
(err: Error) => err.name === 'CodeIncompleteError',
);
});
});
8 changes: 6 additions & 2 deletions extensions/positron-r/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,13 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
errorBehavior: positron.RuntimeErrorBehavior,
codeLocation?: positron.Utf8Location,
executionMetadata?: Record<string, any>
): void {
): Promise<void> {
if (this._kernel) {
this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata);
// Return the kernel's execution promise so a rejection (e.g. an
// Unprocessed submission found to be incomplete) propagates back to
// the caller instead of surfacing as an unhandled rejection.
return Promise.resolve(
this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata));
} else {
throw new Error(`Cannot execute '${code}'; kernel not started`);
}
Expand Down
101 changes: 95 additions & 6 deletions extensions/positron-supervisor/src/KallichoreSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { KernelInfoReply, KernelInfoRequest } from './jupyter/KernelInfoRequest'
import { Barrier, PromiseHandles, withTimeout } from './async';
import { ExecuteRequest, JupyterExecuteRequest } from './jupyter/ExecuteRequest';
import { IsCompleteRequest, JupyterIsCompleteRequest } from './jupyter/IsCompleteRequest';
import { CODE_INCOMPLETE_ERROR, EXECUTION_CANCELLED_ERROR, shouldExecuteAfterCompletenessCheck, shouldSendKernelInterrupt } from './completenessHelpers';
import { CommInfoRequest } from './jupyter/CommInfoRequest';
import { JupyterCommOpen } from './jupyter/JupyterCommOpen';
import { CommOpenCommand } from './jupyter/CommOpenCommand';
Expand Down Expand Up @@ -134,6 +135,14 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession {
/** The current runtime state of this session */
private _runtimeState: positron.RuntimeState = positron.RuntimeState.Uninitialized;

/**
* Pending unprocessed (Flow 2) completeness checks, keyed by execution ID.
* The value aborts the pending check, causing the corresponding `execute()`
* call to reject with an `ExecutionCancelledError`. See `execute()` and
* `interrupt()`.
*/
private _pendingUnprocessedExecutions: Map<string, () => void> = new Map();

/** A map of pending RPCs, used to pair up requests and replies */
private _pendingRequests: Map<string, JupyterRequest<any, any>> = new Map();

Expand Down Expand Up @@ -807,20 +816,35 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession {
* @param mode The execution mode
* @param errorBehavior What to do if an error occurs
*/
execute(
async execute(
code: string,
id: string,
mode: positron.RuntimeCodeExecutionMode,
errorBehavior: positron.RuntimeErrorBehavior,
codeLocation?: positron.Utf8Location,
executionMetadata?: Record<string, unknown>
): void {

// Translate the parameters into a Jupyter execute request
): Promise<void> {

// For unprocessed code, check completeness ourselves before executing.
// This eliminates a client-to-(remote-)supervisor roundtrip: the
// console does not have to send a separate is_complete_request and wait
// for its reply before sending the execute_request. If the code is
// incomplete, throw so the console can show the continuation prompt; if
// the submission is cancelled while we wait, throw a cancellation error.
if (mode === positron.RuntimeCodeExecutionMode.Unprocessed) {
await this.checkUnprocessedCompleteness(code, id);
}

// Translate the parameters into a Jupyter execute request. `Unprocessed`
// behaves exactly like `Interactive` on the wire (displayed to the user,
// stored in history).
const interactiveLike =
mode === positron.RuntimeCodeExecutionMode.Interactive ||
mode === positron.RuntimeCodeExecutionMode.Unprocessed;
const request: JupyterExecuteRequest = {
code,
silent: mode === positron.RuntimeCodeExecutionMode.Silent,
store_history: mode === positron.RuntimeCodeExecutionMode.Interactive,
store_history: interactiveLike,
user_expressions: {},
allow_stdin: true,
stop_on_error: errorBehavior === positron.RuntimeErrorBehavior.Stop,
Expand All @@ -845,7 +869,15 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession {
};
}

// Create and send the execute request.
// Create and send the execute request. The promise returned by
// `execute()` signals ACCEPTANCE of the code (the request has been
// dispatched to the kernel), not completion, so we do not await the
// reply here. The reply paired with `ExecuteRequest` is `execute_result`,
// which the kernel only emits for code that produces a result value;
// awaiting it would hang forever on assignments, `print(...)`, and any
// other statement without a value. For `Unprocessed` code the
// completeness check above already established acceptance (rejecting on
// incomplete/cancelled). The reply is logged out of band.
const execute = new ExecuteRequest(id, request, cellId as string);
this.sendRequest(execute).then((reply) => {
this.log(`Execution result: ${JSON.stringify(reply)}`, vscode.LogLevel.Debug);
Expand All @@ -856,6 +888,43 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession {
});
}

/**
* Checks whether an unprocessed (Flow 2) code fragment is complete before
* executing it. Sends an `is_complete_request` and awaits the reply. Rejects
* (without sending the execute request) if the code is incomplete or if the
* submission is cancelled via `interrupt()` while waiting.
*
* @param code The code to check.
* @param id The execution ID, used to key the cancellation entry.
*/
private async checkUnprocessedCompleteness(code: string, id: string): Promise<void> {
// Register a cancellation entry so interrupt() can abort this check.
const abortPromise = new Promise<never>((_resolve, reject) => {
this._pendingUnprocessedExecutions.set(id, () => {
const err = new Error(
`Execution ${id} was cancelled before it was submitted`);
err.name = EXECUTION_CANCELLED_ERROR;
reject(err);
});
});

try {
// Send the is_complete_request and race it against cancellation.
const request: JupyterIsCompleteRequest = { code };
const isComplete = new IsCompleteRequest(request);
const reply = await Promise.race([this.sendRequest(isComplete), abortPromise]);

if (!shouldExecuteAfterCompletenessCheck(reply.status)) {
const err = new Error(
`Code fragment of length ${code.length} is incomplete`);
err.name = CODE_INCOMPLETE_ERROR;
throw err;
}
} finally {
this._pendingUnprocessedExecutions.delete(id);
}
}

/**
* Tests whether a code fragment is complete.
* @param code The code to test
Expand Down Expand Up @@ -1741,6 +1810,26 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession {
* been sent. Note that the kernel may not be interrupted immediately.
*/
async interrupt(): Promise<void> {
// Abort any pending unprocessed (Flow 2) completeness checks first; each
// abort makes the corresponding execute() reject with a cancellation
// error. Capture the count before clearing so we can decide whether the
// HTTP interrupt is still needed.
const pendingUnprocessedCount = this._pendingUnprocessedExecutions.size;
if (pendingUnprocessedCount > 0) {
for (const abort of Array.from(this._pendingUnprocessedExecutions.values())) {
abort();
}
this._pendingUnprocessedExecutions.clear();
}

// If we only had pending completeness checks to abort and the kernel is
// not currently busy, there is nothing running to interrupt; skip the
// HTTP interrupt (and the Interrupting state transition).
const kernelBusy = this._runtimeState === positron.RuntimeState.Busy;
if (!shouldSendKernelInterrupt(kernelBusy, pendingUnprocessedCount)) {
return;
}

// Mark the session as interrupting
this.onStateChange(positron.RuntimeState.Interrupting, 'interrupting kernel');

Expand Down
47 changes: 47 additions & 0 deletions extensions/positron-supervisor/src/completenessHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

/** The status returned by a Jupyter `is_complete_request`. */
export type IsCompleteStatus = 'complete' | 'incomplete' | 'invalid' | 'unknown';

/** The error name thrown when unprocessed code is found to be incomplete. */
export const CODE_INCOMPLETE_ERROR = 'CodeIncompleteError';

/** The error name thrown when an unprocessed submission is cancelled. */
export const EXECUTION_CANCELLED_ERROR = 'ExecutionCancelledError';

/**
* Decides whether code should execute after an `is_complete_request` check.
*
* Only `incomplete` blocks execution; `complete`, `invalid`, and `unknown` all
* proceed so the interpreter can surface any syntax error itself (matching the
* front-end's historical behavior for invalid/unknown fragments).
*
* @param status The status from the is_complete reply.
* @returns True if the code should be executed, false if it is incomplete.
*/
export function shouldExecuteAfterCompletenessCheck(status: IsCompleteStatus): boolean {
return status !== 'incomplete';
}

/**
* Decides whether a kernel interrupt (the HTTP interrupt call) should be sent.
*
* When there are pending unprocessed completeness checks, those are aborted
* locally; the HTTP interrupt is only needed when the kernel is actually busy
* executing something. When there are no pending checks, the normal interrupt
* behavior is preserved.
*
* @param kernelBusy Whether the kernel is currently busy executing code.
* @param pendingUnprocessedCount The number of pending unprocessed checks that
* were aborted.
* @returns True if the HTTP interrupt should be sent.
*/
export function shouldSendKernelInterrupt(kernelBusy: boolean, pendingUnprocessedCount: number): boolean {
if (pendingUnprocessedCount > 0) {
return kernelBusy;
}
return true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 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 { shouldExecuteAfterCompletenessCheck, shouldSendKernelInterrupt } from '../completenessHelpers';

suite('completenessHelpers', () => {
suite('shouldExecuteAfterCompletenessCheck', () => {
test('executes complete code', () => {
assert.strictEqual(shouldExecuteAfterCompletenessCheck('complete'), true);
});

test('does not execute incomplete code', () => {
assert.strictEqual(shouldExecuteAfterCompletenessCheck('incomplete'), false);
});

test('executes invalid code (so the interpreter surfaces the error)', () => {
assert.strictEqual(shouldExecuteAfterCompletenessCheck('invalid'), true);
});

test('executes unknown code (so the interpreter surfaces any error)', () => {
assert.strictEqual(shouldExecuteAfterCompletenessCheck('unknown'), true);
});
});

suite('shouldSendKernelInterrupt', () => {
test('sends the interrupt when there are no pending checks (normal interrupt)', () => {
assert.strictEqual(shouldSendKernelInterrupt(true, 0), true);
assert.strictEqual(shouldSendKernelInterrupt(false, 0), true);
});

test('sends the interrupt when there are pending checks and the kernel is busy', () => {
assert.strictEqual(shouldSendKernelInterrupt(true, 1), true);
});

test('skips the interrupt when there are only pending checks to abort and the kernel is idle', () => {
assert.strictEqual(shouldSendKernelInterrupt(false, 1), false);
assert.strictEqual(shouldSendKernelInterrupt(false, 3), false);
});
});
});
28 changes: 26 additions & 2 deletions src/positron-dts/positron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,24 @@ declare module 'positron' {
* Combined with pending code: No
* Stored in history: No
*/
Silent = 'silent'
Silent = 'silent',

/**
* Unprocessed code execution: behaves exactly like `Interactive`
* (displayed to the user, stored in history), except the code has NOT
* been checked for completeness. A session receiving this mode must
* check completeness itself before executing; if the code is
* incomplete it must not execute it and must reject the `execute()`
* call with an error whose `name` is `'CodeIncompleteError'`. Sessions
* that cannot check completeness should treat `Unprocessed` exactly as
* `Interactive` (the interpreter will surface a syntax error for
* incomplete code).
*
* Displayed to user: Yes
* Combined with pending code: Yes
* Stored in history: Yes
*/
Unprocessed = 'unprocessed'
}

/**
Expand Down Expand Up @@ -1527,6 +1544,13 @@ declare module 'positron' {
* @param codeLocation Optionally, the location of `code` in the source editor.
* @param executionMetadata Optionally, a record of additional metadata to associate with this execution.
* Note: The errorBehavior parameter is currently ignored by kernels
*
* The returned Thenable (if any) signals ACCEPTANCE of the code for
* execution, not completion of the execution. When `mode` is
* `RuntimeCodeExecutionMode.Unprocessed`, the session must check the
* code for completeness first; if it is incomplete, reject with an
* error whose `name` is `'CodeIncompleteError'`. Returning `void`
* (the historical behavior) is treated as immediate acceptance.
*/
execute(
code: string,
Expand All @@ -1535,7 +1559,7 @@ declare module 'positron' {
errorBehavior: RuntimeErrorBehavior,
codeLocation?: Utf8Location,
executionMetadata?: Record<string, any>,
): void;
): Thenable<void> | void;

/**
* Shut down the runtime; returns a Thenable that resolves when the
Expand Down
Loading
Loading