Skip to content

Commit 56174ec

Browse files
authored
Restore ggsql sessions after an extension host restart or window reload (#507)
1 parent f3971bb commit 56174ec

11 files changed

Lines changed: 192 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
plot sizes itself from its container, so a zero-width first measurement drew
2727
it at zero size with nothing left to correct it. It now recovers once the
2828
container has a real width.
29+
- ggsql interpreter sessions in Positron now come back after an extension host
30+
restart as well as after a window reload. A session the user renamed also
31+
keeps its name across the restore, and ggsql runtimes are rediscovered on
32+
every window open rather than risking a stale cache hit.
2933

3034
## 0.4.1 - 2026-06-22
3135

ggsql-vscode/CLAUDE.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ggsql-vscode/
1717
├── src/
1818
│ ├── extension.ts activate(): registers commands, manager, code lenses
1919
│ ├── manager.ts Kernel discovery + Positron language-runtime registration
20+
│ ├── positronApi.ts Acquires the Positron API so Positron can attribute it to this extension
2021
│ ├── connections.ts Connection-string handling for the Connections pane
2122
│ ├── cellParser.ts Splits .ggsql files into cells for Run-Cell commands
2223
│ ├── codelens.ts "▶ Run cell" lens above each cell
@@ -96,7 +97,13 @@ Outside Positron there is no way to execute a query: `activate()` returns early,
9697
- the three keybindings
9798
- the five run commands, hidden from the Command Palette via a `commandPalette` menu entry
9899

99-
`isPositron` is declared with a default of `true`, so it is correct in Positron from startup with no code and no activation-order window. In VS Code the key is never declared, so it evaluates falsy. Prefer it over a hand-rolled `setContext` key for anything purely declarative; the Positron API (`tryAcquirePositronApi`) is the right check when the code needs the API object itself.
100+
`isPositron` is declared with a default of `true`, so it is correct in Positron from startup with no code and no activation-order window. In VS Code the key is never declared, so it evaluates falsy. Prefer it over a hand-rolled `setContext` key for anything purely declarative; `getPositronApi()` from [`src/positronApi.ts`](src/positronApi.ts) is the right check when the code needs the API object itself.
101+
102+
**Acquiring the Positron API.** `src/positronApi.ts` calls `require('positron')` directly, and `esbuild.js` lists `positron` in `external` so the call is still there in `out/extension.js`. This is load bearing, not a style choice. Positron's require interceptor works out which extension owns an API object from the filesystem path of the requiring file, and that identity becomes the `extensionId` on every runtime the extension registers. Reaching the API through the global accessor that `tryAcquirePositronApi()` uses puts the requiring path inside Positron's own bootstrap, which the interceptor cannot place, so the runtime is filed under `nullExtensionDescription` and Positron cannot activate this extension when it restores sessions after a window reload. `src/test/bundle.test.ts` guards the esbuild half of this.
103+
104+
The Positron Supervisor is a soft dependency, reached through `getSupervisorApi()` in `manager.ts`. It deliberately is not in `extensionDependencies`: that field is static, and an entry for `positron.positron-supervisor` would stop the extension activating at all in VS Code, where the supervisor does not exist.
105+
106+
`GgsqlRuntimeManager.alwaysRediscover` is `true` because ggsql runtimes are never marked `cacheable`, so Positron must run discovery on every window open rather than trusting its cross-window cache. The typings declare it as an optional member, so `tsc` checks the value's type but not the name: a misspelling would compile as a harmless extra property and silently disable the flag. `src/test/manager.test.ts` is the guard, because the property access there fails to compile if the name changes.
100107

101108
Anything that does *not* need the runtime (`ggsql.createNewFile`, `ggsql.resetSqlAssociationPrompt`, syntax highlighting) is registered before the early return and works in plain VS Code. Add new commands on the correct side of that line, and gate them if they execute code.
102109

@@ -121,6 +128,8 @@ code --install-extension ggsql-<version>.vsix
121128

122129
Watch mode for development: `npm run watch` (runs esbuild + tsc in parallel).
123130

131+
For an interactive session, open the **repo root** in Positron and press <kbd>F5</kbd> ("Run Extension"). [`/.vscode/launch.json`](../.vscode/launch.json) runs the `build-ggsql-vscode` task, which is `npm run watch` in this folder, then opens an Extension Development Host with `--extensionDevelopmentPath`, so the extension loads from source with no VSIX. Launch from Positron rather than VS Code, or the dev host has no Positron API and the runtime manager never registers. The watcher rebuilds `out/extension.js` on save, but the host does not hot-reload: run _Developer: Reload Window_ in the Extension Development Host to pick up a change.
132+
124133
## Testing
125134

126135
```sh
@@ -134,7 +143,7 @@ Tests live in `src/test/` and compile to `out-test/` via `tsconfig.test.json`, d
134143

135144
Note that `tsc` does not prune output for deleted sources: if you delete or rename a test, remove its `.js` and `.js.map` from `out-test/test/` or the runner keeps executing the stale copy. `npm run test:extension` on its own does not recompile, so run `npm test` (or `npm run compile-tests` first) after editing any `.ts`.
136145

137-
The suites cover the extension as stock VS Code sees it: activation, language resolution, cell parsing, `.sql` gating, CodeLens placement and TextMate scopes. The Positron surface (runtime manager, connection drivers, cell execution) is not covered, since it needs a Positron host. `sqlAssociation.ts`, `manager.ts` and `connections.ts` are also untested.
146+
The suites cover the extension as stock VS Code sees it: activation, language resolution, cell parsing, `.sql` gating, CodeLens placement, TextMate scopes, and the parts of `manager.ts` and `positronApi.ts` that are reachable without a Positron host. `bundle.test.ts` additionally asserts against the built `out/extension.js`. The rest of the Positron surface (session creation, connection drivers, cell execution) is not covered, since it needs a Positron host, and `sqlAssociation.ts` and `connections.ts` are untested.
138147

139148
Add new tests as `src/test/<name>.test.ts`; no config change is needed.
140149

ggsql-vscode/esbuild.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ async function main() {
1313
sourcesContent: false,
1414
platform: 'node',
1515
outfile: 'out/extension.js',
16-
external: ['vscode'],
16+
external: ['vscode', 'positron'],
1717
logLevel: 'info',
1818
});
1919

ggsql-vscode/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ggsql-vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@
191191
"toml": "^3.0.0"
192192
},
193193
"devDependencies": {
194-
"@posit-dev/positron": "^0.2.2",
194+
"@posit-dev/positron": "^0.2.7",
195195
"@types/mocha": "^10.0.10",
196196
"@types/node": "^18.x",
197197
"@types/vscode": "^1.75.0",

ggsql-vscode/src/extension.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import * as vscode from 'vscode';
9-
import { tryAcquirePositronApi } from '@posit-dev/positron';
9+
import { getPositronApi } from './positronApi';
1010
import { GgsqlRuntimeManager } from './manager';
1111
import { createConnectionDrivers } from './connections';
1212
import { GgsqlCodeLensProvider, registerCellCommands } from './codelens';
@@ -43,7 +43,7 @@ export function activate(context: vscode.ExtensionContext): void {
4343
activateSqlAssociationPrompt(context);
4444

4545
// Try to acquire the Positron API
46-
const positronApi = tryAcquirePositronApi();
46+
const positronApi = getPositronApi();
4747

4848
if (!positronApi) {
4949
// Running in VS Code (not Positron) - syntax highlighting still works

ggsql-vscode/src/manager.ts

Lines changed: 43 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -307,23 +307,57 @@ function ensureKernelSpecInstalled(kernelPath: string): void {
307307

308308
/**
309309
* Create the dynamic state for a ggsql runtime session.
310+
*
311+
* @param sessionName The name Positron holds for the session, when restoring
312+
* one. New sessions have no name yet and get the default.
310313
*/
311-
function createDynState(): positron.LanguageRuntimeDynState {
314+
export function createDynState(sessionName?: string): positron.LanguageRuntimeDynState {
312315
return {
313316
inputPrompt: 'ggsql> ',
314317
continuationPrompt: '... ',
315-
sessionName: 'ggsql',
318+
sessionName: sessionName || 'ggsql',
316319
};
317320
}
318321

322+
/**
323+
* Get the Positron Supervisor API, activating the extension if needed.
324+
*
325+
* The supervisor is a soft dependency: it is declared nowhere in
326+
* package.json, because an extensionDependencies entry would stop this
327+
* extension activating at all in VS Code, where the supervisor does not
328+
* exist. Awaiting activate() here gives the same ordering guarantee that a
329+
* declared dependency would.
330+
*/
331+
export async function getSupervisorApi(): Promise<PositronSupervisorApi> {
332+
const supervisorExt = vscode.extensions.getExtension<PositronSupervisorApi>(
333+
'positron.positron-supervisor'
334+
);
335+
336+
if (!supervisorExt) {
337+
throw new Error('Positron Supervisor extension not found');
338+
}
339+
340+
return supervisorExt.activate();
341+
}
342+
319343
/**
320344
* ggsql Language Runtime Manager
321345
*
322346
* Manages the lifecycle of ggsql runtime sessions in Positron.
323347
*/
324348
export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
349+
/**
350+
* Run discovery on every window open rather than trusting Positron's
351+
* cross-window cache.
352+
*
353+
* ggsql runtimes are not marked cacheable: the ggsql.kernelPath setting is
354+
* workspace scoped, and the PATH fallback is not guaranteed to resolve to
355+
* a real file. A cache hit would therefore register only some of the
356+
* candidates and silently hide the rest on warm starts.
357+
*/
358+
public readonly alwaysRediscover = true;
359+
325360
private _context: vscode.ExtensionContext;
326-
private _sessions: Map<string, positron.LanguageRuntimeSession> = new Map();
327361

328362
constructor(context: vscode.ExtensionContext) {
329363
this._context = context;
@@ -383,17 +417,7 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
383417
runtimeMetadata: positron.LanguageRuntimeMetadata,
384418
sessionMetadata: positron.RuntimeSessionMetadata
385419
): Promise<positron.LanguageRuntimeSession> {
386-
// Get the Positron Supervisor extension
387-
const supervisorExt = vscode.extensions.getExtension<PositronSupervisorApi>(
388-
'positron.positron-supervisor'
389-
);
390-
391-
if (!supervisorExt) {
392-
throw new Error('Positron Supervisor extension not found');
393-
}
394-
395-
// Ensure the extension is activated
396-
const supervisorApi = await supervisorExt.activate();
420+
const supervisorApi = await getSupervisorApi();
397421

398422
// Create the kernel spec using the runtime's kernel path
399423
const kernelSpec = createKernelSpec(runtimeMetadata.runtimePath);
@@ -411,14 +435,6 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
411435
dynState
412436
);
413437

414-
// Track the session
415-
this._sessions.set(sessionMetadata.sessionId, session);
416-
417-
// Remove from tracking when session ends
418-
session.onDidEndSession(() => {
419-
this._sessions.delete(sessionMetadata.sessionId);
420-
});
421-
422438
return session;
423439
}
424440

@@ -427,20 +443,12 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
427443
*/
428444
async restoreSession(
429445
runtimeMetadata: positron.LanguageRuntimeMetadata,
430-
sessionMetadata: positron.RuntimeSessionMetadata
446+
sessionMetadata: positron.RuntimeSessionMetadata,
447+
sessionName: string
431448
): Promise<positron.LanguageRuntimeSession> {
432-
// Get the Positron Supervisor extension
433-
const supervisorExt = vscode.extensions.getExtension<PositronSupervisorApi>(
434-
'positron.positron-supervisor'
435-
);
436-
437-
if (!supervisorExt) {
438-
throw new Error('Positron Supervisor extension not found');
439-
}
440-
441-
const supervisorApi = await supervisorExt.activate();
449+
const supervisorApi = await getSupervisorApi();
442450

443-
const dynState = createDynState();
451+
const dynState = createDynState(sessionName);
444452

445453
// Re-advertise this kernel on restore
446454
ensureKernelSpecInstalled(runtimeMetadata.runtimePath);
@@ -451,28 +459,14 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager {
451459
dynState
452460
);
453461

454-
this._sessions.set(sessionMetadata.sessionId, session);
455-
456-
session.onDidEndSession(() => {
457-
this._sessions.delete(sessionMetadata.sessionId);
458-
});
459-
460462
return session;
461463
}
462464

463465
/**
464466
* Validate an existing session.
465467
*/
466468
async validateSession(sessionId: string): Promise<boolean> {
467-
const supervisorExt = vscode.extensions.getExtension<PositronSupervisorApi>(
468-
'positron.positron-supervisor'
469-
);
470-
471-
if (!supervisorExt) {
472-
return false;
473-
}
474-
475-
const supervisorApi = await supervisorExt.activate();
469+
const supervisorApi = await getSupervisorApi();
476470
return supervisorApi.validateSession(sessionId);
477471
}
478472
}

ggsql-vscode/src/positronApi.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Positron API access.
3+
*
4+
* Positron's require interceptor decides which extension owns an API object
5+
* by matching the filesystem path of the file that called require('positron')
6+
* against its map of extension folders. That identity becomes the extensionId
7+
* on every runtime this extension registers, and Positron uses it to activate
8+
* the owning extension when it restores sessions after a window reload.
9+
*
10+
* The require therefore has to happen in a ggsql source file, and 'positron'
11+
* is marked external in esbuild.js so the call is still in out/extension.js
12+
* rather than inlined. Reaching the API through a global accessor instead
13+
* attributes it to Positron's own bootstrap file, which the interceptor
14+
* cannot place, and the runtime is recorded under nullExtensionDescription.
15+
*
16+
* In VS Code the module does not exist, so the require throws and the
17+
* extension runs without the Positron surface.
18+
*/
19+
20+
import type { PositronApi } from '@posit-dev/positron';
21+
22+
let api: PositronApi | undefined;
23+
let attempted = false;
24+
25+
/**
26+
* Get the Positron API, or undefined when not running in Positron.
27+
*/
28+
export function getPositronApi(): PositronApi | undefined {
29+
if (!attempted) {
30+
attempted = true;
31+
try {
32+
api = require('positron') as PositronApi;
33+
} catch {
34+
// Not running in Positron.
35+
}
36+
}
37+
return api;
38+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as assert from 'assert';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
5+
// out-test/test/bundle.test.js -> the extension root
6+
const bundlePath = path.resolve(__dirname, '..', '..', 'out', 'extension.js');
7+
8+
suite('bundle', () => {
9+
test('keeps positron as an external require', () => {
10+
// Positron's require interceptor attributes the API object by the path
11+
// of the requiring file. The call has to survive bundling and stay in
12+
// out/extension.js, which lives inside the extension folder. If esbuild
13+
// inlines the module instead, every registered runtime is recorded
14+
// under nullExtensionDescription and session restore breaks.
15+
assert.ok(fs.existsSync(bundlePath), 'out/extension.js missing; run npm run package first');
16+
const bundle = fs.readFileSync(bundlePath, 'utf8');
17+
assert.match(bundle, /require\(["']positron["']\)/);
18+
});
19+
20+
test('does not bundle the positron API helper package', () => {
21+
assert.ok(fs.existsSync(bundlePath), 'out/extension.js missing; run npm run package first');
22+
const bundle = fs.readFileSync(bundlePath, 'utf8');
23+
assert.doesNotMatch(bundle, /acquirePositronApi/);
24+
});
25+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as assert from 'assert';
2+
import * as vscode from 'vscode';
3+
import { GgsqlRuntimeManager, createDynState, getSupervisorApi } from '../manager';
4+
5+
suite('manager', () => {
6+
// The suites run in stock VS Code, where positron.positron-supervisor is
7+
// not installed. All three call sites depend on this rejecting rather than
8+
// resolving with a partially usable object.
9+
test('getSupervisorApi rejects when the supervisor is absent', async () => {
10+
await assert.rejects(
11+
() => getSupervisorApi(),
12+
/Positron Supervisor extension not found/,
13+
);
14+
});
15+
16+
test('createDynState falls back to the default session name', () => {
17+
const state = createDynState();
18+
assert.strictEqual(state.sessionName, 'ggsql');
19+
assert.strictEqual(state.inputPrompt, 'ggsql> ');
20+
assert.strictEqual(state.continuationPrompt, '... ');
21+
});
22+
23+
test('createDynState keeps a name Positron supplies', () => {
24+
// Positron passes the current session name to restoreSession. Dropping
25+
// it renames the console back to 'ggsql' on every window reload.
26+
const state = createDynState('Sales analysis');
27+
assert.strictEqual(state.sessionName, 'Sales analysis');
28+
});
29+
30+
test('createDynState falls back when the supplied name is empty', () => {
31+
// A blank name would otherwise leave the restored console with no title.
32+
assert.strictEqual(createDynState('').sessionName, 'ggsql');
33+
});
34+
35+
test('the manager opts out of the discovery cache fast path', () => {
36+
// ggsql runtimes are never marked cacheable, because the kernel path
37+
// can come from a workspace setting or from PATH. Without this flag
38+
// Positron would be free to skip discovery on a warm start and leave
39+
// ggsql unregistered.
40+
const manager = new GgsqlRuntimeManager({} as vscode.ExtensionContext);
41+
assert.strictEqual(manager.alwaysRediscover, true);
42+
});
43+
44+
test('restoreSession propagates a missing supervisor', async () => {
45+
// getSupervisorApi() is awaited first, before any kernel spec is
46+
// written, so the rejection arrives with nothing done on disk.
47+
const manager = new GgsqlRuntimeManager({} as vscode.ExtensionContext);
48+
await assert.rejects(
49+
() => manager.restoreSession({} as never, {} as never, 'Sales analysis'),
50+
/Positron Supervisor extension not found/,
51+
);
52+
});
53+
});

0 commit comments

Comments
 (0)