Skip to content

Commit 18bdedf

Browse files
authored
feat: delegate manual attachment to external terminals (#1871)
#1868 and #1870 provide managed sess preparation, connection references, and explicit terminal binding, but manual attachment still has a gap. When an external terminal has no live session binding, the status-bar attach action can send the native command to vscode-R's global endpoint. In vscode-R-console, that bypasses SessProxy and replaces the proxy-backed transport. PID discovery can also wait indefinitely on an extension-owned pseudoterminal before its frontend gets a chance to recover. This adds `session.registerTerminalIntegration(terminal, { attach(token) })` so the frontend can restore its own transport and bind the accepted connection using the existing APIs: - While a registered handler is pending, repeated attach requests remain busy even if a live binding already exists. Otherwise a live terminal owner is focused first, or the registered handler runs before PID discovery. Registered terminals are excluded from native PID association and use explicit binding. - Registration survives disconnects and connection replacement. Disposal, terminal close, and watcher shutdown revoke it and request cancellation; moving focus away also cancels a pending attempt. - Registering an integration releases native PID waits that already started for that terminal: scans continue to other terminals, and the old manual attach stops without fallback. Discovery updates use the same eligibility rules, and invalidated operations remain invalid after registration disposal. Terminal and watcher lifetimes, and the lifetime of the requesting handshake or manual operation, also bound native PID waits. - Concurrent attach requests do not submit duplicate work. Success requires a current live terminal binding, and delayed completion preserves a newer choice of another session. A decline, exception, or cancellation never falls back to the native command. Consumers can prepare through their proxy, observe the matching connection, and call `activate(sessionId, { terminal, connectionId, select: false })` before returning `true`. vscode-R then verifies the binding and selects/focuses the terminal. Consumers retain responsibility for prompt readiness, submission queues, failure explanations, and R code already submitted when cancellation occurs. This completes the third part of the external session API for the intended 3.2.0 release alongside #1868 and #1870, so consumers can adopt managed sess with a supported manual recovery path. This change is on the TypeScript side and does not change the bundled sess revision or introduce another reinstall/restart transition. External frontends must adopt the hook; pasting a copied native attach command remains outside this API and can still bypass a proxy.
1 parent 376709b commit 18bdedf

12 files changed

Lines changed: 1482 additions & 57 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- 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.
66
- Added connection change events and live connection references for sess attachments, with activation controls for consumer-owned terminals.
7+
- Added `session.registerTerminalIntegration()` so external terminals can recover sess connections without falling back to a native attach command.
78
- 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)
89

910
## 3.0.1 - 2026-09-28

‎src/api.d.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export interface RSessionActivationOptions {
4040
select?: boolean;
4141
}
4242

43+
/** Consumer-owned terminal hook for restoring a sess connection without bypassing its proxy. */
44+
export interface RExternalTerminalIntegration {
45+
/**
46+
* Reconnect through the consumer's transport and bind this terminal before resolving true.
47+
* A resolved false is a consumer-reported decline/busy result; a rejection is shown by
48+
* vscode-R. Cancellation can stop pending work, but cannot retract R code already submitted.
49+
*/
50+
attach(token: vscode.CancellationToken): Promise<boolean>;
51+
}
52+
4353
export interface RSessionPreparationOptions {
4454
/**
4555
* A local sess IPC endpoint on the current R host, such as a proxy socket.
@@ -123,6 +133,28 @@ export interface RSessionApi {
123133
* is still current after activation completes; a later disconnect can still occur.
124134
*/
125135
activate(sessionId: string, options?: RSessionActivationOptions): Promise<boolean>;
136+
/**
137+
* Register one attach hook for an open terminal. Duplicate or closed-terminal registrations
138+
* throw. The hook remains registered across session disconnects and replacements until its
139+
* Disposable is disposed, the terminal closes, or the extension host shuts down. It is
140+
* invoked before native PID discovery; false, rejection, busy, or cancellation never falls
141+
* back to a native attach command. On true, vscode-R requires a current live session binding
142+
* for this terminal, then focuses that session. Consumers should bind with
143+
* `activate(..., { terminal, connectionId, select: false })` before resolving true. When no
144+
* hook is in flight, a current live owner is focused and the hook is skipped. While a hook is
145+
* in flight, repeated activations report busy without selecting or focusing its bound owner,
146+
* and do not invoke another handler. The terminal remains busy until the handler promise
147+
* settles, even after cancellation is requested.
148+
* Registered terminals are excluded from native PID association, so consumers must bind
149+
* their session explicitly. Registration during host shutdown throws.
150+
* If the user selects another session while the hook is pending, vscode-R leaves that selection
151+
* in place and retains the terminal binding. Cancellation is requested when focus leaves the
152+
* terminal, it closes, registration is disposed, or the host shuts down. Consumers should
153+
* stop cancellable waits and settle the handler on cancellation. Submitted R code cannot be
154+
* recalled; the consumer must retain its preparation while the runtime keeps its submission
155+
* queue occupied until execution finishes.
156+
*/
157+
registerTerminalIntegration(terminal: vscode.Terminal, integration: RExternalTerminalIntegration): vscode.Disposable;
126158
}
127159

128160
export type HelpSubMenu = 'doc' | 'pkgList' | 'refresh' | '?' | '??';

‎src/extension.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp
6666
activate: session.activateSessionById,
6767
onDidChangeSessionConnection: session.onDidChangeSessionConnection,
6868
getSessionReference: session.getSessionReference,
69+
registerTerminalIntegration: session.registerTerminalIntegration,
6970
};
7071

7172
// assign extension context to global variable

‎src/nativeTerminalPidWait.ts‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
export type NativeTerminalPidWaitResult<Value> =
2+
| { readonly kind: 'resolved'; readonly value: Value; readonly isCurrent: () => boolean }
3+
| { readonly kind: 'invalidated'; readonly reason: 'terminal' | 'lifecycle' | 'operation' };
4+
5+
export interface NativeTerminalPidWaitOptions {
6+
readonly signal?: AbortSignal;
7+
readonly isEligible?: () => boolean;
8+
}
9+
10+
interface NativeTerminalPidWaiter<T extends object> {
11+
readonly terminal: T;
12+
invalidate(reason: 'terminal' | 'lifecycle'): void;
13+
}
14+
15+
/** Interruptible reads of VS Code's otherwise uncancellable terminal processId promise. */
16+
export class NativeTerminalPidWaitRegistry<T extends object> {
17+
private lifecycleRevision = 0;
18+
private readonly terminalRevisions = new WeakMap<T, number>();
19+
private readonly waiters = new Set<NativeTerminalPidWaiter<T>>();
20+
21+
public invalidate(terminal: T): void {
22+
this.terminalRevisions.set(terminal, this.revisionOf(terminal) + 1);
23+
for (const waiter of this.waiters) {
24+
if (waiter.terminal === terminal) { waiter.invalidate('terminal'); }
25+
}
26+
}
27+
28+
public invalidateAll(): void {
29+
this.lifecycleRevision++;
30+
for (const waiter of this.waiters) { waiter.invalidate('lifecycle'); }
31+
}
32+
33+
public waitFor<Value>(
34+
terminal: T,
35+
read: () => PromiseLike<Value> | Value,
36+
options: NativeTerminalPidWaitOptions = {},
37+
): Promise<NativeTerminalPidWaitResult<Value>> {
38+
const terminalRevision = this.revisionOf(terminal);
39+
const lifecycleRevision = this.lifecycleRevision;
40+
41+
return new Promise((resolve, reject) => {
42+
let settled = false;
43+
const isCurrent = (): boolean => !options.signal?.aborted
44+
&& lifecycleRevision === this.lifecycleRevision
45+
&& terminalRevision === this.revisionOf(terminal)
46+
&& (options.isEligible?.() ?? true);
47+
const checkCurrent = (): boolean => {
48+
try { return isCurrent(); }
49+
catch (error) { finishError(error); return false; }
50+
};
51+
const finish = (result: NativeTerminalPidWaitResult<Value>): void => {
52+
if (settled) { return; }
53+
settled = true;
54+
cleanup();
55+
resolve(result);
56+
};
57+
const finishError = (error: unknown): void => {
58+
if (settled) { return; }
59+
settled = true;
60+
cleanup();
61+
reject(error);
62+
};
63+
const waiter: NativeTerminalPidWaiter<T> = {
64+
terminal,
65+
invalidate: reason => finish({ kind: 'invalidated', reason }),
66+
};
67+
const onAbort = () => finish({ kind: 'invalidated', reason: 'operation' });
68+
const cleanup = (): void => {
69+
this.waiters.delete(waiter);
70+
options.signal?.removeEventListener('abort', onAbort);
71+
};
72+
73+
// Install invalidation listeners before reading processId: its getter may
74+
// synchronously trigger registration, close, or shutdown callbacks.
75+
this.waiters.add(waiter);
76+
options.signal?.addEventListener('abort', onAbort, { once: true });
77+
if (options.signal?.aborted) {
78+
onAbort();
79+
return;
80+
}
81+
if (lifecycleRevision !== this.lifecycleRevision) {
82+
finish({ kind: 'invalidated', reason: 'lifecycle' });
83+
return;
84+
}
85+
let eligible: boolean;
86+
try { eligible = options.isEligible?.() ?? true; }
87+
catch (error) { finishError(error); return; }
88+
if (terminalRevision !== this.revisionOf(terminal) || !eligible) {
89+
finish({ kind: 'invalidated', reason: 'terminal' });
90+
return;
91+
}
92+
93+
let pending: Promise<Value>;
94+
try { pending = Promise.resolve(read()); }
95+
catch (error) { finishError(error); return; }
96+
// Keep a rejection observer after invalidation so the underlying VS Code
97+
// promise cannot become an unhandled rejection if it settles late.
98+
void pending.then(value => {
99+
if (settled) { return; }
100+
if (!checkCurrent()) {
101+
if (settled) { return; }
102+
const reason = options.signal?.aborted ? 'operation'
103+
: lifecycleRevision !== this.lifecycleRevision ? 'lifecycle' : 'terminal';
104+
finish({ kind: 'invalidated', reason });
105+
return;
106+
}
107+
finish({ kind: 'resolved', value, isCurrent });
108+
}, error => {
109+
if (settled) { return; }
110+
if (!checkCurrent()) {
111+
if (settled) { return; }
112+
const reason = options.signal?.aborted ? 'operation'
113+
: lifecycleRevision !== this.lifecycleRevision ? 'lifecycle' : 'terminal';
114+
finish({ kind: 'invalidated', reason });
115+
return;
116+
}
117+
finishError(error);
118+
});
119+
});
120+
}
121+
122+
private revisionOf(terminal: T): number { return this.terminalRevisions.get(terminal) ?? 0; }
123+
}

0 commit comments

Comments
 (0)