Skip to content

Commit 647a8ce

Browse files
jmcphersclaude
andauthored
Improve runtime state views in Positron API (#14825)
Closes #14181 ### Summary Extends the runtime session API on `LanguageRuntimeSession` handles with three capabilities discussed in #14181. - **`evaluateCode(..., whenBusy?)`** -- a new optional `RuntimeBusyBehavior` parameter (`Queue` | `Reject`). `Queue` (default) preserves today's behavior; `Reject` rejects the evaluation with an error when the target session is busy instead of queueing it. - **`getRuntimeState()`** -- a synchronous accessor on `BaseLanguageRuntimeSession` returning the session's current `RuntimeState`. Unlike `onDidChangeRuntimeState` (change-only, no initial value), it can be read at any time to gate work on idleness. - **`onDidDisconnect` / `onDidReconnect`** -- events on `BaseLanguageRuntimeSession` that fire when the session's connection to the underlying runtime drops and is re-established, closing the "silent reconnect" blind window described in the issue. The new members are optional on `BaseLanguageRuntimeSession`. They're implemented on the cross-extension-host session proxy (state and events forwarded from the main thread via new subscribe/notify RPCs) and on the real R/Python sessions (`KallichoreSession` is the connection source; `RSession`/`PythonRuntimeSession` delegate to it). ### Release Notes #### New Features - Added a `whenBusy` option to `positron.runtime.evaluateCode`, a synchronous `getRuntimeState()` accessor, and `onDidDisconnect`/`onDidReconnect` events on language runtime session handles (#14181) #### Bug Fixes - N/A ### Validation Steps @:console @:sessions @:reticulate These are API-surface changes with no UI. Automated coverage: - **Ext-host API tests** -- `extensions/vscode-api-tests/src/singlefolder-tests/positron/runtime.test.ts`: `whenBusy=Reject`/`Queue`, `getRuntimeState`, and `onDidDisconnect`/`onDidReconnect` exposure + firing (fake session). Run: `./scripts/test-positron-api.sh` - **Real R (ark) session tests** -- `extensions/positron-r/src/test/runtime-state.test.ts`: live `getRuntimeState()` Busy->Idle transitions, event exposure, and `evaluateCode` `whenBusy` Reject/Queue against a real kernel. Run: `npm run test-extension -- -l positron-r --grep "Runtime state API"` - **Proxy unit tests** -- `src/vs/workbench/api/test/common/positron/extHostLanguageRuntime.vitest.ts`: cross-host proxy state seeding, state/disconnect/reconnect forwarding, and subscribe/unsubscribe lifecycle. Run: `npx vitest run src/vs/workbench/api/test/common/positron/extHostLanguageRuntime.vitest.ts` --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e6f7af5 commit 647a8ce

13 files changed

Lines changed: 984 additions & 30 deletions

File tree

extensions/positron-python/src/client/positron/session.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
8282
/** The emitter for resource usage updates */
8383
private _resourceUsageEmitter = new vscode.EventEmitter<positron.RuntimeResourceUsage>();
8484

85+
/** The emitter fired when the connection to the underlying runtime is lost */
86+
private _onDidDisconnect = new vscode.EventEmitter<void>();
87+
88+
/** The emitter fired when the connection to the underlying runtime is re-established */
89+
private _onDidReconnect = new vscode.EventEmitter<void>();
90+
91+
/** Disposables owned by this session (emitters, kernel subscriptions, etc.) */
92+
private _disposables: vscode.Disposable[] = [];
93+
8594
/** The Positron Supervisor extension API */
8695
private adapterApi?: PositronSupervisorApi;
8796

@@ -138,6 +147,10 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
138147

139148
onDidUpdateResourceUsage = this._resourceUsageEmitter.event;
140149

150+
onDidDisconnect = this._onDidDisconnect.event;
151+
152+
onDidReconnect = this._onDidReconnect.event;
153+
141154
constructor(
142155
readonly runtimeMetadata: positron.LanguageRuntimeMetadata,
143156
readonly metadata: positron.RuntimeSessionMetadata,
@@ -170,6 +183,10 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
170183
await this.onStateChange(state);
171184
});
172185

186+
// Ensure the connection state emitters are disposed with the session
187+
this._disposables.push(this._onDidDisconnect);
188+
this._disposables.push(this._onDidReconnect);
189+
173190
this._installer = this.serviceContainer.get<IInstaller>(IInstaller);
174191
this._interpreterService = serviceContainer.get<IInterpreterService>(IInterpreterService);
175192
this._interpreterPathService = serviceContainer.get<IInterpreterPathService>(IInterpreterPathService);
@@ -180,6 +197,15 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
180197
return this._runtimeInfo;
181198
}
182199

200+
/**
201+
* Synchronously gets the current runtime state of the session.
202+
*
203+
* @returns The session's current runtime state.
204+
*/
205+
getRuntimeState(): positron.RuntimeState {
206+
return this._state;
207+
}
208+
183209
getDynState(): Thenable<positron.LanguageRuntimeDynState> {
184210
return Promise.resolve(this.dynState);
185211
}
@@ -819,6 +845,10 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
819845
if (this._kernel) {
820846
await this._kernel.dispose();
821847
}
848+
849+
// Dispose the connection state emitters and kernel subscriptions
850+
this._disposables.forEach((disposable) => disposable.dispose());
851+
this._disposables = [];
822852
}
823853

824854
updateSessionName(sessionName: string): void {
@@ -904,6 +934,21 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
904934
kernel.onDidUpdateResourceUsage((usage) => {
905935
this._resourceUsageEmitter.fire(usage);
906936
});
937+
938+
// Forward the kernel's connection state changes, if it supports them.
939+
const disconnectListener = kernel.onDidDisconnect?.(() => {
940+
this._onDidDisconnect.fire();
941+
});
942+
if (disconnectListener) {
943+
this._disposables.push(disconnectListener);
944+
}
945+
const reconnectListener = kernel.onDidReconnect?.(() => {
946+
this._onDidReconnect.fire();
947+
});
948+
if (reconnectListener) {
949+
this._disposables.push(reconnectListener);
950+
}
951+
907952
return kernel;
908953
}
909954

extensions/positron-r/src/session.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
112112
private _resourceUsageEmitter =
113113
new vscode.EventEmitter<positron.RuntimeResourceUsage>();
114114

115+
/** The emitter fired when the connection to the underlying runtime is lost */
116+
private _onDidDisconnect = new vscode.EventEmitter<void>();
117+
118+
/** The emitter fired when the connection to the underlying runtime is re-established */
119+
private _onDidReconnect = new vscode.EventEmitter<void>();
120+
115121
/** The Positron Supervisor extension API */
116122
private adapterApi?: PositronSupervisorApi;
117123

@@ -162,6 +168,12 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
162168
this.onDidChangeRuntimeState = this._stateEmitter.event;
163169
this.onDidEndSession = this._exitEmitter.event;
164170
this.onDidUpdateResourceUsage = this._resourceUsageEmitter.event;
171+
this.onDidDisconnect = this._onDidDisconnect.event;
172+
this.onDidReconnect = this._onDidReconnect.event;
173+
174+
// Ensure the emitters are disposed when the session is disposed
175+
this._disposables.push(this._onDidDisconnect);
176+
this._disposables.push(this._onDidReconnect);
165177

166178
// Timestamp the session creation
167179
this._created = Date.now();
@@ -178,6 +190,8 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
178190
onDidReceiveRuntimeMessage: vscode.Event<positron.LanguageRuntimeMessage>;
179191
onDidChangeRuntimeState: vscode.Event<positron.RuntimeState>;
180192
onDidUpdateResourceUsage: vscode.Event<positron.RuntimeResourceUsage>;
193+
onDidDisconnect: vscode.Event<void>;
194+
onDidReconnect: vscode.Event<void>;
181195

182196
/**
183197
* Accessor for the current state of the runtime.
@@ -186,6 +200,15 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
186200
return this._state;
187201
}
188202

203+
/**
204+
* Synchronously gets the current runtime state of the session.
205+
*
206+
* @returns The session's current runtime state.
207+
*/
208+
getRuntimeState(): positron.RuntimeState {
209+
return this._state;
210+
}
211+
189212
/**
190213
* Accessor for the creation time of the runtime.
191214
*/
@@ -836,6 +859,20 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
836859
this._resourceUsageEmitter.fire(usage);
837860
});
838861

862+
// Forward the kernel's connection state changes, if it supports them.
863+
const disconnectListener = kernel.onDidDisconnect?.(() => {
864+
this._onDidDisconnect.fire();
865+
});
866+
if (disconnectListener) {
867+
this._disposables.push(disconnectListener);
868+
}
869+
const reconnectListener = kernel.onDidReconnect?.(() => {
870+
this._onDidReconnect.fire();
871+
});
872+
if (reconnectListener) {
873+
this._disposables.push(reconnectListener);
874+
}
875+
839876
return kernel;
840877
}
841878

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import './mocha-setup';
7+
8+
import * as positron from 'positron';
9+
import * as vscode from 'vscode';
10+
import * as assert from 'assert';
11+
import * as testKit from './kit';
12+
import { RSession } from '../session';
13+
import { delay } from '../util';
14+
15+
/**
16+
* Exercises the improved runtime-state API surface against a real R (ark)
17+
* session: the synchronous `getRuntimeState()` accessor, the
18+
* `onDidDisconnect`/`onDidReconnect` events, and the `evaluateCode` `whenBusy`
19+
* behavior.
20+
*/
21+
suite('Runtime state API', () => {
22+
let session: RSession;
23+
let sesDisposable: vscode.Disposable;
24+
25+
suiteSetup(async () => {
26+
const [ses, disposable] = await testKit.startR('Suite: Runtime state API');
27+
session = ses;
28+
sesDisposable = disposable;
29+
});
30+
31+
suiteTeardown(async () => {
32+
if (sesDisposable) {
33+
await sesDisposable.dispose();
34+
}
35+
});
36+
37+
test('getRuntimeState is exposed and reports the live state', async () => {
38+
// Fetch the session through the public API, the same way an external
39+
// extension would.
40+
const handle = await positron.runtime.getSession(session.metadata.sessionId);
41+
assert.ok(handle, 'the session should be retrievable via getSession');
42+
assert.strictEqual(typeof handle.getRuntimeState, 'function',
43+
'getRuntimeState should be exposed on the session handle');
44+
45+
// After startup the session should settle into an idle-ish state.
46+
await testKit.pollForSuccess(() => {
47+
const state = handle.getRuntimeState!();
48+
assert.ok(
49+
state === positron.RuntimeState.Idle || state === positron.RuntimeState.Ready,
50+
`expected idle/ready after startup, got '${state}'`);
51+
});
52+
53+
// The public handle and the underlying RSession should agree.
54+
assert.strictEqual(handle.getRuntimeState!(), session.getRuntimeState(),
55+
'the API handle and the underlying session should report the same state');
56+
});
57+
58+
test('getRuntimeState transitions to Busy during execution and back to Idle', async () => {
59+
const handle = await positron.runtime.getSession(session.metadata.sessionId);
60+
assert.ok(handle);
61+
62+
// Ensure we start idle.
63+
await testKit.pollForSuccess(() => {
64+
assert.strictEqual(handle.getRuntimeState!(), positron.RuntimeState.Idle);
65+
});
66+
67+
// Kick off a long-running computation without awaiting it.
68+
positron.runtime.executeCode('r', 'Sys.sleep(2)', false, false).then(() => { }, () => { });
69+
70+
// The state should become Busy.
71+
await testKit.pollForSuccess(() => {
72+
assert.strictEqual(handle.getRuntimeState!(), positron.RuntimeState.Busy,
73+
'session should report Busy while executing');
74+
});
75+
76+
// And return to Idle once the computation completes.
77+
await testKit.pollForSuccess(() => {
78+
assert.strictEqual(handle.getRuntimeState!(), positron.RuntimeState.Idle,
79+
'session should return to Idle after executing');
80+
}, 50, 10000);
81+
});
82+
83+
test('onDidDisconnect and onDidReconnect are exposed on the handle', async () => {
84+
const handle = await positron.runtime.getSession(session.metadata.sessionId);
85+
assert.ok(handle);
86+
87+
assert.strictEqual(typeof handle.onDidDisconnect, 'function',
88+
'onDidDisconnect should be exposed on the session handle');
89+
assert.strictEqual(typeof handle.onDidReconnect, 'function',
90+
'onDidReconnect should be exposed on the session handle');
91+
92+
// Subscribing must not throw, and the events must not fire spuriously
93+
// while the session is healthy.
94+
let disconnected = false;
95+
const sub = handle.onDidDisconnect!(() => { disconnected = true; });
96+
await delay(200);
97+
sub.dispose();
98+
assert.strictEqual(disconnected, false,
99+
'onDidDisconnect should not fire while the session is connected');
100+
});
101+
102+
test('evaluateCode with whenBusy=Reject rejects while the session is busy', async () => {
103+
const sessionId = session.metadata.sessionId;
104+
105+
// Ensure the session is idle before we begin.
106+
await testKit.pollForSuccess(() => {
107+
assert.strictEqual(session.getRuntimeState(), positron.RuntimeState.Idle);
108+
});
109+
110+
// Make the session busy with a long-running computation.
111+
positron.runtime.executeCode('r', 'Sys.sleep(3)', false, false).then(() => { }, () => { });
112+
await testKit.pollForSuccess(() => {
113+
assert.strictEqual(session.getRuntimeState(), positron.RuntimeState.Busy);
114+
});
115+
116+
// Reject behavior should throw while busy.
117+
let rejected = false;
118+
let message = '';
119+
try {
120+
await positron.runtime.evaluateCode('r', '1 + 1', undefined, sessionId,
121+
positron.RuntimeBusyBehavior.Reject);
122+
} catch (err: any) {
123+
rejected = true;
124+
message = err?.message ?? String(err);
125+
}
126+
assert.ok(rejected, 'evaluateCode should reject when the session is busy and whenBusy is Reject');
127+
assert.ok(/busy/.test(message), `the rejection should mention busy, got: ${message}`);
128+
129+
// Wait for the session to become idle again before finishing.
130+
await testKit.pollForSuccess(() => {
131+
assert.strictEqual(session.getRuntimeState(), positron.RuntimeState.Idle);
132+
}, 50, 10000);
133+
});
134+
135+
test('evaluateCode with whenBusy=Queue resolves once the session is idle', async () => {
136+
const sessionId = session.metadata.sessionId;
137+
138+
await testKit.pollForSuccess(() => {
139+
assert.strictEqual(session.getRuntimeState(), positron.RuntimeState.Idle);
140+
});
141+
142+
// Make the session busy briefly.
143+
positron.runtime.executeCode('r', 'Sys.sleep(1)', false, false).then(() => { }, () => { });
144+
await testKit.pollForSuccess(() => {
145+
assert.strictEqual(session.getRuntimeState(), positron.RuntimeState.Busy);
146+
});
147+
148+
// Queue an evaluation while busy; it should not reject, and should
149+
// eventually resolve with the result once the session is idle again.
150+
const result = await positron.runtime.evaluateCode('r', '2 + 3', undefined, sessionId,
151+
positron.RuntimeBusyBehavior.Queue);
152+
153+
// The queued evaluation should have run and produced 5.
154+
const serialized = JSON.stringify(result);
155+
assert.ok(/5/.test(serialized), `queued evaluation should return 5, got: ${serialized}`);
156+
});
157+
});

0 commit comments

Comments
 (0)