Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Added the extension API's `session.prepareAttachment()` for extensions that own an R process and need vscode-R's managed `sess` setup. R errors and interrupts during manual attachment are now reported as messages while leaving the R prompt usable.
- Added connection change events and live connection references for sess attachments, with activation controls for consumer-owned terminals.
- Added `session.registerTerminalIntegration()` so external terminals can recover sess connections without falling back to a native attach command.
- The httpgd plot backend now requires the `httpgd` R package version 2.0.1 or later. Older versions are treated as unavailable when choosing the plot backend. (#1855)

## 3.0.1 - 2026-09-28
Expand Down
32 changes: 32 additions & 0 deletions src/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export interface RSessionActivationOptions {
select?: boolean;
}

/** Consumer-owned terminal hook for restoring a sess connection without bypassing its proxy. */
export interface RExternalTerminalIntegration {
/**
* Reconnect through the consumer's transport and bind this terminal before resolving true.
* A resolved false is a consumer-reported decline/busy result; a rejection is shown by
* vscode-R. Cancellation can stop pending work, but cannot retract R code already submitted.
*/
attach(token: vscode.CancellationToken): Promise<boolean>;
}

export interface RSessionPreparationOptions {
/**
* A local sess IPC endpoint on the current R host, such as a proxy socket.
Expand Down Expand Up @@ -123,6 +133,28 @@ export interface RSessionApi {
* is still current after activation completes; a later disconnect can still occur.
*/
activate(sessionId: string, options?: RSessionActivationOptions): Promise<boolean>;
/**
* Register one attach hook for an open terminal. Duplicate or closed-terminal registrations
* throw. The hook remains registered across session disconnects and replacements until its
* Disposable is disposed, the terminal closes, or the extension host shuts down. It is
* invoked before native PID discovery; false, rejection, busy, or cancellation never falls
* back to a native attach command. On true, vscode-R requires a current live session binding
* for this terminal, then focuses that session. Consumers should bind with
* `activate(..., { terminal, connectionId, select: false })` before resolving true. When no
* hook is in flight, a current live owner is focused and the hook is skipped. While a hook is
* in flight, repeated activations report busy without selecting or focusing its bound owner,
* and do not invoke another handler. The terminal remains busy until the handler promise
* settles, even after cancellation is requested.
* Registered terminals are excluded from native PID association, so consumers must bind
* their session explicitly. Registration during host shutdown throws.
* If the user selects another session while the hook is pending, vscode-R leaves that selection
* in place and retains the terminal binding. Cancellation is requested when focus leaves the
* terminal, it closes, registration is disposed, or the host shuts down. Consumers should
* stop cancellable waits and settle the handler on cancellation. Submitted R code cannot be
* recalled; the consumer must retain its preparation while the runtime keeps its submission
* queue occupied until execution finishes.
*/
registerTerminalIntegration(terminal: vscode.Terminal, integration: RExternalTerminalIntegration): vscode.Disposable;
}

export type HelpSubMenu = 'doc' | 'pkgList' | 'refresh' | '?' | '??';
Expand Down
1 change: 1 addition & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp
activate: session.activateSessionById,
onDidChangeSessionConnection: session.onDidChangeSessionConnection,
getSessionReference: session.getSessionReference,
registerTerminalIntegration: session.registerTerminalIntegration,
};

// assign extension context to global variable
Expand Down
123 changes: 123 additions & 0 deletions src/nativeTerminalPidWait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
export type NativeTerminalPidWaitResult<Value> =
| { readonly kind: 'resolved'; readonly value: Value; readonly isCurrent: () => boolean }
| { readonly kind: 'invalidated'; readonly reason: 'terminal' | 'lifecycle' | 'operation' };

export interface NativeTerminalPidWaitOptions {
readonly signal?: AbortSignal;
readonly isEligible?: () => boolean;
}

interface NativeTerminalPidWaiter<T extends object> {
readonly terminal: T;
invalidate(reason: 'terminal' | 'lifecycle'): void;
}

/** Interruptible reads of VS Code's otherwise uncancellable terminal processId promise. */
export class NativeTerminalPidWaitRegistry<T extends object> {
private lifecycleRevision = 0;
private readonly terminalRevisions = new WeakMap<T, number>();
private readonly waiters = new Set<NativeTerminalPidWaiter<T>>();

public invalidate(terminal: T): void {
this.terminalRevisions.set(terminal, this.revisionOf(terminal) + 1);
for (const waiter of this.waiters) {
if (waiter.terminal === terminal) { waiter.invalidate('terminal'); }
}
}

public invalidateAll(): void {
this.lifecycleRevision++;
for (const waiter of this.waiters) { waiter.invalidate('lifecycle'); }
}

public waitFor<Value>(
terminal: T,
read: () => PromiseLike<Value> | Value,
options: NativeTerminalPidWaitOptions = {},
): Promise<NativeTerminalPidWaitResult<Value>> {
const terminalRevision = this.revisionOf(terminal);
const lifecycleRevision = this.lifecycleRevision;

return new Promise((resolve, reject) => {
let settled = false;
const isCurrent = (): boolean => !options.signal?.aborted
&& lifecycleRevision === this.lifecycleRevision
&& terminalRevision === this.revisionOf(terminal)
&& (options.isEligible?.() ?? true);
const checkCurrent = (): boolean => {
try { return isCurrent(); }
catch (error) { finishError(error); return false; }
};
const finish = (result: NativeTerminalPidWaitResult<Value>): void => {
if (settled) { return; }
settled = true;
cleanup();
resolve(result);
};
const finishError = (error: unknown): void => {
if (settled) { return; }
settled = true;
cleanup();
reject(error);
};
const waiter: NativeTerminalPidWaiter<T> = {
terminal,
invalidate: reason => finish({ kind: 'invalidated', reason }),
};
const onAbort = () => finish({ kind: 'invalidated', reason: 'operation' });
const cleanup = (): void => {
this.waiters.delete(waiter);
options.signal?.removeEventListener('abort', onAbort);
};

// Install invalidation listeners before reading processId: its getter may
// synchronously trigger registration, close, or shutdown callbacks.
this.waiters.add(waiter);
options.signal?.addEventListener('abort', onAbort, { once: true });
if (options.signal?.aborted) {
onAbort();
return;
}
if (lifecycleRevision !== this.lifecycleRevision) {
finish({ kind: 'invalidated', reason: 'lifecycle' });
return;
}
let eligible: boolean;
try { eligible = options.isEligible?.() ?? true; }
catch (error) { finishError(error); return; }
if (terminalRevision !== this.revisionOf(terminal) || !eligible) {
finish({ kind: 'invalidated', reason: 'terminal' });
return;
}

let pending: Promise<Value>;
try { pending = Promise.resolve(read()); }
catch (error) { finishError(error); return; }
// Keep a rejection observer after invalidation so the underlying VS Code
// promise cannot become an unhandled rejection if it settles late.
void pending.then(value => {
if (settled) { return; }
if (!checkCurrent()) {
if (settled) { return; }
const reason = options.signal?.aborted ? 'operation'
: lifecycleRevision !== this.lifecycleRevision ? 'lifecycle' : 'terminal';
finish({ kind: 'invalidated', reason });
return;
}
finish({ kind: 'resolved', value, isCurrent });
}, error => {
if (settled) { return; }
if (!checkCurrent()) {
if (settled) { return; }
const reason = options.signal?.aborted ? 'operation'
: lifecycleRevision !== this.lifecycleRevision ? 'lifecycle' : 'terminal';
finish({ kind: 'invalidated', reason });
return;
}
finishError(error);
});
});
}

private revisionOf(terminal: T): number { return this.terminalRevisions.get(terminal) ?? 0; }
}
Loading
Loading