From 33b7f4c394876b2d6bde9a7b5500641ab4d9f37c Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:44:53 +1000 Subject: [PATCH 01/23] Use active R session packages for language completion --- R/languageServer.R | 42 ++++++++++++++++++++++++++++++++++- src/extension.ts | 4 +++- src/languageService.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ src/session.ts | 4 +++- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index b24c1eab5..5499a9abe 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -42,4 +42,44 @@ if (identical(Sys.getenv("VSCR_LSP_SYNTHETIC_WORKSPACE"), "TRUE")) { options(languageserver.index_mode = "off") } -languageserver::run(port = port, debug = debug) +tools::Rd2txt_options(underline_titles = FALSE) +tools::Rd2txt_options(itemBullet = "* ") +languageserver:::lsp_settings$update_from_options() +if (isTRUE(debug)) { + languageserver:::lsp_settings$set("debug", TRUE) + languageserver:::lsp_settings$set("log_file", NULL) +} + +normalize_character <- function(value) { + if (is.list(value)) { + value <- unlist(value, use.names = FALSE) + } + if (!is.character(value)) { + return(character()) + } + unique(value[nzchar(value)]) +} + +server <- languageserver:::LanguageServer$new("localhost", port) +server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { + attached_packages <- normalize_character(params$attachedPackages) + loaded_namespaces <- normalize_character(params$loadedNamespaces) + + for (workspace in self$workspaces$values()) { + workspace$startup_packages <- if (length(attached_packages)) { + # languageserver resolves package conflicts from the end of this list. + rev(attached_packages) + } else { + languageserver:::workspace_startup_packages() + } + workspace$update_loaded_packages() + + for (pkg in unique(c(attached_packages, loaded_namespaces))) { + try(workspace$get_namespace(pkg), silent = TRUE) + } + } + + self$deliver(languageserver:::Response$new(id, result = TRUE)) +} + +server$run() diff --git a/src/extension.ts b/src/extension.ts index b511094a1..e4ebc41e3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -40,6 +40,7 @@ export let globalPlotManager: PlotManager | undefined = undefined; export let rmdPreviewManager: rmarkdown.RMarkdownPreviewManager | undefined = undefined; export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefined; export let sessionStatusBarItem: vscode.StatusBarItem | undefined = undefined; +export let rLanguageService: languageService.LanguageService | undefined = undefined; // Called (once) when the extension is activated export async function activate(context: vscode.ExtensionContext): Promise { @@ -187,7 +188,8 @@ export async function activate(context: vscode.ExtensionContext): Promise = new Map(); private readonly initSet: Set = new Set(); @@ -22,6 +32,8 @@ export class LanguageService implements Disposable { private readonly clientUpdates = new Map>(); private readonly listeners: Disposable[] = []; private disposed = false; + private sessionState: SessionState | undefined; + private sessionStateKey: string | undefined; constructor() { this.outputChannel = window.createOutputChannel('R Language Server'); @@ -35,6 +47,43 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } + syncSessionState(data?: SessionWorkspaceData): void { + const state: SessionState = { + attachedPackages: data?.search + .filter(value => value.startsWith('package:')) + .map(value => value.substring(8)) ?? [], + loadedNamespaces: data?.loaded_namespaces ?? [], + }; + const stateKey = this.getSessionStateKey(state); + if (this.sessionStateKey === stateKey) { + return; + } + + this.sessionState = state; + this.sessionStateKey = stateKey; + for (const client of this.clients.values()) { + void this.applySessionState(client); + } + } + + private async applySessionState(client: LanguageClient): Promise { + if (!this.sessionState) { + return; + } + try { + await client.sendRequest('r/syncSessionState', this.sessionState); + } catch { + // Keep language-service features available if session synchronization fails. + } + } + + private getSessionStateKey(state: SessionState): string { + return [ + state.attachedPackages.join('\u0000'), + state.loadedNamespaces.join('\u0000'), + ].join('\u0001'); + } + private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }): DisposableProcess { const childProcess = spawn(rPath, args, options); const pid = childProcess.pid || -1; @@ -228,6 +277,7 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); + await this.applySessionState(client); return client; } diff --git a/src/session.ts b/src/session.ts index 35403d329..8936451e4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -14,7 +14,7 @@ import * as rTerminal from './rTerminal'; import { getProcessAncestors } from './processTree'; import { purgeAddinPickerItems, RSEditOperation, RSRange } from './rstudioapi'; -import { extensionContext, rWorkspace, globalRHelp, globalPlotManager, sessionStatusBarItem, enableSessionWatcher } from './extension'; +import { extensionContext, rWorkspace, globalRHelp, globalPlotManager, sessionStatusBarItem, enableSessionWatcher, rLanguageService } from './extension'; import { resolveBackend, jgdEnabled, CommonPlotManager } from './plotViewer'; import type { RSessionConnectionInfo } from './api'; @@ -2063,6 +2063,7 @@ async function refreshActiveSession(session: Session): Promise { sessionDir = session.sessionDir; workingDir = session.workingDir; workspaceData = session.workspaceData; + rLanguageService?.syncSessionState(workspaceData); if (sessionStatusBarItem) { const version = rVer.replace(/^R (?:version )?/, '').replace(/\s+\(.*/, ''); @@ -2465,6 +2466,7 @@ export async function cleanupSession(sessionId: string, closingSocket?: IpcSocke session.socket.destroy(); } if (activeSession === session) { + rLanguageService?.syncSessionState(); await clearActiveSession(); } } From acb6b89d6d9e33f887ee4325a392c3aaabb1837e Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:30:13 +1000 Subject: [PATCH 02/23] Route session packages to the active language server --- src/languageService.ts | 75 ++++++++++++++++++++++++++++-------------- src/rTerminal.ts | 2 +- src/session.ts | 19 +++++++---- 3 files changed, 64 insertions(+), 32 deletions(-) diff --git a/src/languageService.ts b/src/languageService.ts index 3e76df401..3727aae88 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -32,8 +32,7 @@ export class LanguageService implements Disposable { private readonly clientUpdates = new Map>(); private readonly listeners: Disposable[] = []; private disposed = false; - private sessionState: SessionState | undefined; - private sessionStateKey: string | undefined; + private readonly sessionStates: Map = new Map(); constructor() { this.outputChannel = window.createOutputChannel('R Language Server'); @@ -47,31 +46,51 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } - syncSessionState(data?: SessionWorkspaceData): void { + syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = ''): void { + const clientKey = this.config.get('lsp.multiServer') === true + ? resource ? this.getClientKey(resource) : undefined + : 'global'; + if (!clientKey) { + return; + } + + const current = this.sessionStates.get(clientKey); + if (!data) { + if (!current || (sessionId && current.sessionId !== sessionId)) { + return; + } + this.sessionStates.delete(clientKey); + const client = this.clients.get(clientKey); + if (client) { + void this.applySessionState(client, { + attachedPackages: [], + loadedNamespaces: [], + }); + } + return; + } + const state: SessionState = { - attachedPackages: data?.search + attachedPackages: data.search .filter(value => value.startsWith('package:')) - .map(value => value.substring(8)) ?? [], - loadedNamespaces: data?.loaded_namespaces ?? [], + .map(value => value.substring(8)), + loadedNamespaces: data.loaded_namespaces, }; const stateKey = this.getSessionStateKey(state); - if (this.sessionStateKey === stateKey) { + if (current?.stateKey === stateKey && current.sessionId === sessionId) { return; } - this.sessionState = state; - this.sessionStateKey = stateKey; - for (const client of this.clients.values()) { - void this.applySessionState(client); + this.sessionStates.set(clientKey, { state, stateKey, sessionId }); + const client = this.clients.get(clientKey); + if (client) { + void this.applySessionState(client, state); } } - private async applySessionState(client: LanguageClient): Promise { - if (!this.sessionState) { - return; - } + private async applySessionState(client: LanguageClient, state: SessionState): Promise { try { - await client.sendRequest('r/syncSessionState', this.sessionState); + await client.sendRequest('r/syncSessionState', state); } catch { // Keep language-service features available if session synchronization fails. } @@ -115,7 +134,7 @@ export class LanguageService implements Disposable { private async createClient(selector: DocumentFilter[], cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, - resource?: Uri, target?: Session): Promise { + resource?: Uri, clientKey: string = 'global', target?: Session): Promise { let client: LanguageClient; const virtualOnly = selector.every(filter => 'scheme' in filter @@ -277,7 +296,10 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); - await this.applySessionState(client); + const sessionState = this.sessionStates.get(clientKey)?.state; + if (sessionState) { + await this.applySessionState(client, sessionState); + } return client; } @@ -294,6 +316,11 @@ export class LanguageService implements Disposable { return false; } + private getClientKey(uri: Uri): string { + const folder = workspace.getWorkspaceFolder(uri); + return this.getKey(folder?.uri ?? uri); + } + private getKey(uri: Uri): string { switch (uri.scheme) { case 'untitled': @@ -324,7 +351,7 @@ export class LanguageService implements Disposable { const key = document.uri.toString(); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; - const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, target); + const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, key, target); this.clients.set(key, client); this.initSet.delete(key); } return; @@ -339,7 +366,7 @@ export class LanguageService implements Disposable { { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, ]; const client = await this.createClient(documentSelector, - target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, target); + target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, key, target); this.clients.set(key, client); this.initSet.delete(key); } @@ -357,7 +384,7 @@ export class LanguageService implements Disposable { { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, ]; - const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri); + const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); this.clients.set(key, client); this.initSet.delete(key); } @@ -373,7 +400,7 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri); + const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, key); this.clients.set(key, client); this.initSet.delete(key); } @@ -389,7 +416,7 @@ export class LanguageService implements Disposable { { scheme: 'file', pattern: document.uri.fsPath }, ]; const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri); + dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, key); this.clients.set(key, client); this.initSet.delete(key); } @@ -494,7 +521,7 @@ export class LanguageService implements Disposable { const workspaceFolder = workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); - const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri); + const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); this.clients.set('global', client); this.startMultiLanguageService(true); } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index d014c6371..0afeb859e 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -366,7 +366,7 @@ export function deleteTerminal(term: vscode.Terminal): void { } } -function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { +export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { if (term === rTerm && rTermResource) { return rTermResource; } diff --git a/src/session.ts b/src/session.ts index 8936451e4..496c1e2b2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -72,6 +72,7 @@ export class Session { public info: SessionInfo; public sessionDir: string; public workingDir: string; + public resource: Uri | undefined; public workspaceData: WorkspaceData; constructor(sessionId: string, host: string, sessVersion: string, pipePath: string, socket: IpcSocket) { @@ -85,6 +86,7 @@ export class Session { this.info = { version: '', command: '', start_time: '' }; this.sessionDir = ''; this.workingDir = ''; + this.resource = undefined; this.workspaceData = { search: [], loaded_namespaces: [], globalenv: {} }; } } @@ -2063,7 +2065,7 @@ async function refreshActiveSession(session: Session): Promise { sessionDir = session.sessionDir; workingDir = session.workingDir; workspaceData = session.workspaceData; - rLanguageService?.syncSessionState(workspaceData); + rLanguageService?.syncSessionState(workspaceData, session.resource, session.sessionId); if (sessionStatusBarItem) { const version = rVer.replace(/^R (?:version )?/, '').replace(/\s+\(.*/, ''); @@ -2100,13 +2102,13 @@ function isLocalHost(host: string): boolean { return host.length > 0 && host.toLocaleLowerCase() === os.hostname().toLocaleLowerCase(); } -async function findLocalTerminalPid(rPid: string): Promise { +async function findLocalTerminal(rPid: string): Promise { const candidates = new Map(); for (const terminal of window.terminals) { const terminalPid = await terminal.processId; if (terminalPid === undefined || isTerminalClosed(terminal) || terminal.exitStatus) { continue; } if (String(terminalPid) === rPid) { - return String(terminalPid); + return terminal; } candidates.set(terminalPid, terminal); } @@ -2122,7 +2124,7 @@ async function findLocalTerminalPid(rPid: string): Promise { if (attachedRPids.has(String(pid))) { return undefined; } const terminal = candidates.get(pid); if (terminal && !isTerminalClosed(terminal) && !terminal.exitStatus && window.terminals.includes(terminal)) { - return String(pid); + return terminal; } } return undefined; @@ -2185,9 +2187,11 @@ async function handleNotification(message: Record, socket: IpcS } const rPid = typeof params.pid === 'string' || typeof params.pid === 'number' ? String(params.pid) : ''; - const terminalPid = rPid && isLocalHost(host) - ? await findLocalTerminalPid(rPid) + const terminal = rPid && isLocalHost(host) + ? await findLocalTerminal(rPid) : undefined; + const matchedTerminalPid = await terminal?.processId; + const terminalPid = matchedTerminalPid === undefined ? undefined : String(matchedTerminalPid); const selectedTerminal = window.activeTerminal; const selectedTerminalPid = terminalPid ? await selectedTerminal?.processId : undefined; if (socket.destroyed) { @@ -2232,6 +2236,7 @@ async function handleNotification(message: Record, socket: IpcS session.info = (params.info as SessionInfo | undefined) ?? { version: session.rVer, command: '', start_time: '' }; session.sessionDir = params.tempdir; session.workingDir = params.wd; + session.resource = terminal ? rTerminal.getTerminalResource(terminal) : undefined; if (terminalPid) { terminalSessionAttached.fire(terminalPid); @@ -2465,8 +2470,8 @@ export async function cleanupSession(sessionId: string, closingSocket?: IpcSocke if (!session.socket.destroyed && session.socket !== closingSocket) { session.socket.destroy(); } + rLanguageService?.syncSessionState(undefined, session.resource, session.sessionId); if (activeSession === session) { - rLanguageService?.syncSessionState(); await clearActiveSession(); } } From b33b25575e84062fb5bea20c5698b221d6744f08 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:04:43 +1000 Subject: [PATCH 03/23] Handle unscoped session package completion --- src/languageService.ts | 71 ++++++++++++++++++++++++------------------ src/rTerminal.ts | 6 ++-- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/languageService.ts b/src/languageService.ts index 3727aae88..cda54c75c 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -32,6 +32,7 @@ export class LanguageService implements Disposable { private readonly clientUpdates = new Map>(); private readonly listeners: Disposable[] = []; private disposed = false; + private readonly clientScopes: Map = new Map(); private readonly sessionStates: Map = new Map(); constructor() { @@ -47,26 +48,17 @@ export class LanguageService implements Disposable { } syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = ''): void { - const clientKey = this.config.get('lsp.multiServer') === true - ? resource ? this.getClientKey(resource) : undefined - : 'global'; - if (!clientKey) { - return; - } - - const current = this.sessionStates.get(clientKey); + const scope = this.getSessionScope(resource); + const current = this.sessionStates.get(scope); if (!data) { if (!current || (sessionId && current.sessionId !== sessionId)) { return; } - this.sessionStates.delete(clientKey); - const client = this.clients.get(clientKey); - if (client) { - void this.applySessionState(client, { - attachedPackages: [], - loadedNamespaces: [], - }); - } + this.sessionStates.delete(scope); + this.applySessionStateToScope(scope, { + attachedPackages: [], + loadedNamespaces: [], + }); return; } @@ -81,10 +73,15 @@ export class LanguageService implements Disposable { return; } - this.sessionStates.set(clientKey, { state, stateKey, sessionId }); - const client = this.clients.get(clientKey); - if (client) { - void this.applySessionState(client, state); + this.sessionStates.set(scope, { state, stateKey, sessionId }); + this.applySessionStateToScope(scope, state); + } + + private applySessionStateToScope(scope: string, state: SessionState): void { + for (const [key, client] of this.clients) { + if (this.clientScopes.get(key) === scope) { + void this.applySessionState(client, state); + } } } @@ -134,7 +131,7 @@ export class LanguageService implements Disposable { private async createClient(selector: DocumentFilter[], cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, - resource?: Uri, clientKey: string = 'global', target?: Session): Promise { + resource?: Uri, sessionScope: string = 'global', target?: Session): Promise { let client: LanguageClient; const virtualOnly = selector.every(filter => 'scheme' in filter @@ -296,7 +293,7 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); - const sessionState = this.sessionStates.get(clientKey)?.state; + const sessionState = this.sessionStates.get(sessionScope)?.state; if (sessionState) { await this.applySessionState(client, sessionState); } @@ -316,9 +313,12 @@ export class LanguageService implements Disposable { return false; } - private getClientKey(uri: Uri): string { - const folder = workspace.getWorkspaceFolder(uri); - return this.getKey(folder?.uri ?? uri); + private getSessionScope(resource?: Uri): string { + if (this.config.get('lsp.multiServer') !== true) { + return 'global'; + } + const folder = resource ? workspace.getWorkspaceFolder(resource) : undefined; + return folder ? this.getKey(folder.uri) : 'unscoped'; } private getKey(uri: Uri): string { @@ -349,10 +349,11 @@ export class LanguageService implements Disposable { if (document.uri.scheme === 'vscode-interactive-input') { const key = document.uri.toString(); + const scope = this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; - const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, key, target); - this.clients.set(key, client); this.initSet.delete(key); + const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); + this.clients.set(key, client); this.clientScopes.set(key, scope); this.initSet.delete(key); } return; } @@ -360,14 +361,16 @@ export class LanguageService implements Disposable { // Each notebook uses a server started from parent folder if (document.uri.scheme === 'vscode-notebook-cell') { const key = this.getKey(document.uri); + const scope = this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { console.log(`Start language server for ${document.uri.toString(true)}`); const documentSelector: DocumentFilter[] = [ { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, ]; const client = await this.createClient(documentSelector, - target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, key, target); + target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, scope, target); this.clients.set(key, client); + this.clientScopes.set(key, scope); this.initSet.delete(key); } return; @@ -386,6 +389,7 @@ export class LanguageService implements Disposable { ]; const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); this.clients.set(key, client); + this.clientScopes.set(key, key); this.initSet.delete(key); } @@ -400,8 +404,9 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, key); + const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped'); this.clients.set(key, client); + this.clientScopes.set(key, 'unscoped'); this.initSet.delete(key); } return; @@ -416,8 +421,9 @@ export class LanguageService implements Disposable { { scheme: 'file', pattern: document.uri.fsPath }, ]; const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, key); + dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, 'unscoped'); this.clients.set(key, client); + this.clientScopes.set(key, 'unscoped'); this.initSet.delete(key); } return; @@ -449,6 +455,7 @@ export class LanguageService implements Disposable { const client = this.clients.get(key); if (client) { this.clients.delete(key); + this.clientScopes.delete(key); this.initSet.delete(key); void client.stop(); } @@ -466,7 +473,7 @@ export class LanguageService implements Disposable { // repeatedly. Restart its shared server only once per owner. if (restart && clientTargets.get(key) !== target) { const client = this.clients.get(key); - this.clients.delete(key); this.initSet.delete(key); + this.clients.delete(key); this.clientScopes.delete(key); this.initSet.delete(key); await client?.stop(); } await didOpenTextDocument(document); @@ -494,6 +501,7 @@ export class LanguageService implements Disposable { const client = this.clients.get(key); if (client) { this.clients.delete(key); + this.clientScopes.delete(key); this.initSet.delete(key); void client.stop(); } @@ -524,6 +532,7 @@ export class LanguageService implements Disposable { const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); this.clients.set('global', client); this.startMultiLanguageService(true); + this.clientScopes.set('global', 'global'); } } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 0afeb859e..eb9cad063 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -368,7 +368,7 @@ export function deleteTerminal(term: vscode.Terminal): void { export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { if (term === rTerm && rTermResource) { - return rTermResource; + return vscode.workspace.getWorkspaceFolder(rTermResource)?.uri; } const creationOptions = term.creationOptions; @@ -377,9 +377,7 @@ export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefin const cwdResource = typeof cwd === 'string' ? vscode.workspace.workspaceFolders?.find(folder => folder.uri.fsPath === cwd)?.uri ?? vscode.Uri.file(cwd) : cwd; - return cwdResource - ? getCurrentWorkspaceFolder(cwdResource)?.uri ?? cwdResource - : getCurrentWorkspaceFolder()?.uri; + return cwdResource ? vscode.workspace.getWorkspaceFolder(cwdResource)?.uri : undefined; } export function findTerminal(): vscode.Terminal | undefined { From 0a5cf6f5e4dd937e4d3de9b7ab58845ffe7898d6 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:55:02 +1100 Subject: [PATCH 04/23] fix(lsp): preserve bound session packages after master rebase --- src/languageService.ts | 20 ++++-- src/session.ts | 4 ++ src/test/suite/languageService.test.ts | 96 ++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/test/suite/languageService.test.ts diff --git a/src/languageService.ts b/src/languageService.ts index cda54c75c..53493ce0c 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -47,8 +47,17 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } - syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = ''): void { - const scope = this.getSessionScope(resource); + syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = '', active = true): void { + // Bound virtual documents keep their owner's packages when focus moves. + if (sessionId) { + this.syncSessionScope(`session:${sessionId}`, data, sessionId); + } + if (active || !data) { + this.syncSessionScope(this.getSessionScope(resource), data, sessionId); + } + } + + private syncSessionScope(scope: string, data: SessionWorkspaceData | undefined, sessionId: string): void { const current = this.sessionStates.get(scope); if (!data) { if (!current || (sessionId && current.sessionId !== sessionId)) { @@ -293,6 +302,9 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); + if (target) { + this.syncSessionScope(sessionScope, target.workspaceData, target.sessionId); + } const sessionState = this.sessionStates.get(sessionScope)?.state; if (sessionState) { await this.applySessionState(client, sessionState); @@ -349,7 +361,7 @@ export class LanguageService implements Disposable { if (document.uri.scheme === 'vscode-interactive-input') { const key = document.uri.toString(); - const scope = this.getSessionScope(folder?.uri); + const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); @@ -361,7 +373,7 @@ export class LanguageService implements Disposable { // Each notebook uses a server started from parent folder if (document.uri.scheme === 'vscode-notebook-cell') { const key = this.getKey(document.uri); - const scope = this.getSessionScope(folder?.uri); + const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { console.log(`Start language server for ${document.uri.toString(true)}`); const documentSelector: DocumentFilter[] = [ diff --git a/src/session.ts b/src/session.ts index 496c1e2b2..4b8500441 100644 --- a/src/session.ts +++ b/src/session.ts @@ -128,6 +128,7 @@ export function bindSessionDocument(uri: Uri, session: Session): void { export function unbindSessionDocument(uri: Uri): void { documentSessions.delete(uri.toString()); } export function unregisterSessionTransport(target: Session): void { + rLanguageService?.syncSessionState(undefined, target.resource, target.sessionId); for (const [uri, owner] of documentSessions) { if (owner === target) { documentSessions.delete(uri); } } sessions.delete(target.sessionId); if (activeSession === target) { void clearActiveSession(); } @@ -162,6 +163,7 @@ export async function executeSessionCode(target: Session, code: string): Promise export function updateSessionWorkspace(target: Session, data: WorkspaceData): void { target.workspaceData = data; + rLanguageService?.syncSessionState(data, target.resource, target.sessionId, false); if (activeSession === target) { void refreshActiveSession(target); } @@ -169,6 +171,7 @@ export function updateSessionWorkspace(target: Session, data: WorkspaceData): vo /** Move document routing to a new process; existing data viewers keep their old owner. */ export function replaceSessionTransport(previous: Session, next: Session): void { + rLanguageService?.syncSessionState(undefined, previous.resource, previous.sessionId); for (const [uri, owner] of documentSessions) { if (owner === previous) { documentSessions.set(uri, next); } } sessions.delete(previous.sessionId); if (activeSession === previous) { activeSession = next; } @@ -179,6 +182,7 @@ export function registerSessionTransport(id: string, host: string, directory: st const target = sessions.get(id) ?? new Session(id, host, '', '', new net.Socket()); target.requester = requester; target.workingDir = directory; + target.resource = vscode.workspace.getWorkspaceFolder(Uri.file(directory))?.uri; sessions.set(id, target); return target; } diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts new file mode 100644 index 000000000..5e53e4a1e --- /dev/null +++ b/src/test/suite/languageService.test.ts @@ -0,0 +1,96 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { LanguageService } from '../../languageService'; +import * as session from '../../session'; +import * as extension from '../../extension'; + +suite('Session package completion', () => { + let sandbox: sinon.SinonSandbox; + let service: LanguageService; + let multiServer: boolean; + const folder = { uri: vscode.Uri.file('/project'), name: 'project', index: 0 }; + const packages = (name: string) => ({ search: ['.GlobalEnv', `package:${name}`, 'package:base'], loaded_namespaces: [name, 'base'], globalenv: {} }); + + // Keep the tests independent of installed R packages and server processes. + type ServiceInternals = { + startLanguageService(): Promise; + clients: Map; + clientScopes: Map; + }; + function client(key: string, scope: string): sinon.SinonStub { + const sendRequest = sandbox.stub().resolves(true); + const internals = service as unknown as ServiceInternals; + internals.clients.set(key, { sendRequest, stop: sandbox.stub().resolves() }); + internals.clientScopes.set(key, scope); + return sendRequest; + } + + setup(() => { + sandbox = sinon.createSandbox(); + multiServer = false; + sandbox.stub(LanguageService.prototype as unknown as ServiceInternals, 'startLanguageService').resolves(); + sandbox.stub(vscode.workspace, 'getConfiguration').returns({ + get: (key: string) => key === 'lsp.multiServer' ? multiServer : undefined + } as vscode.WorkspaceConfiguration); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => uri.fsPath.startsWith('/project') ? folder : undefined); + service = new LanguageService(); + sandbox.stub(extension, 'rLanguageService').value(service); + }); + + teardown(async () => { + await service.dispose(); + sandbox.restore(); + }); + + test('global server follows active packages and ignores duplicate updates', () => { + const request = client('global', 'global'); + service.syncSessionState(packages('stats'), folder.uri, 'first'); + service.syncSessionState(packages('stats'), folder.uri, 'first'); + sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['stats', 'base'], loadedNamespaces: ['stats', 'base'] + }); + service.syncSessionState(packages('utils'), undefined, 'second'); + service.syncSessionState(undefined, folder.uri, 'first'); + assert.strictEqual(request.callCount, 2, 'closing an older session must not clear the active packages'); + service.syncSessionState(undefined, undefined, 'second'); + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + }); + + test('multi-server mode separates workspace and unscoped documents', () => { + multiServer = true; + const project = client('project', folder.uri.toString(true)); + const untitled = client('untitled', 'unscoped'); + const externalFile = client('external', 'unscoped'); + service.syncSessionState(packages('stats'), folder.uri, 'project-session'); + sinon.assert.calledOnce(project); + sinon.assert.notCalled(untitled); + service.syncSessionState(packages('utils'), undefined, 'external-session'); + sinon.assert.calledOnce(project); + sinon.assert.calledOnce(untitled); + sinon.assert.calledOnce(externalFile); + }); + + test('inactive Interactive workspace updates reach only its bound language servers', () => { + const global = client('global', 'global'); + const input = client('input', 'session:interactive-packages'); + const notebook = client('notebook', 'session:interactive-packages'); + const target = session.registerSessionTransport('interactive-packages', 'host', '/project', () => Promise.resolve(undefined)); + try { + assert.strictEqual(target.resource?.toString(), folder.uri.toString()); + session.updateSessionWorkspace(target, packages('stats')); + sinon.assert.notCalled(global); + sinon.assert.calledOnce(input); + sinon.assert.calledOnce(notebook); + service.syncSessionState(packages('utils'), undefined, 'terminal'); + sinon.assert.calledOnce(input); + sinon.assert.calledOnce(notebook); + session.unregisterSessionTransport(target); + sinon.assert.calledWithExactly(input, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + sinon.assert.calledWithExactly(notebook, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + sinon.assert.calledOnce(global); + } finally { + session.unregisterSessionTransport(target); + } + }); +}); From 6e361b6d2b904c503d35f3ca5cb8723a4f1fea85 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:08:48 +1100 Subject: [PATCH 05/23] fix(lsp): resolve session workspace from working directory --- src/session.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index 4b8500441..5a3879204 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2240,7 +2240,8 @@ async function handleNotification(message: Record, socket: IpcS session.info = (params.info as SessionInfo | undefined) ?? { version: session.rVer, command: '', start_time: '' }; session.sessionDir = params.tempdir; session.workingDir = params.wd; - session.resource = terminal ? rTerminal.getTerminalResource(terminal) : undefined; + session.resource = (terminal ? rTerminal.getTerminalResource(terminal) : undefined) + ?? (isLocalHost(host) ? vscode.workspace.getWorkspaceFolder(Uri.file(params.wd))?.uri : undefined); if (terminalPid) { terminalSessionAttached.fire(terminalPid); From b2fbee22cb3626d9260ef5b45f4b5baa0d9b7398 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:39:12 +1100 Subject: [PATCH 06/23] fix(lsp): register clients before session state sync --- src/languageService.ts | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/languageService.ts b/src/languageService.ts index 53493ce0c..8cbc24880 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -138,7 +138,7 @@ export class LanguageService implements Disposable { return childProcess; } - private async createClient(selector: DocumentFilter[], + private async createClient(key: string, selector: DocumentFilter[], cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, resource?: Uri, sessionScope: string = 'global', target?: Session): Promise { @@ -301,18 +301,24 @@ export class LanguageService implements Disposable { } extensionContext.subscriptions.push(client); - await client.start(); if (target) { this.syncSessionScope(sessionScope, target.workspaceData, target.sessionId); } + await client.start(); + await this.registerClient(key, client, sessionScope); + return client; + } + + + private async registerClient(key: string, client: LanguageClient, sessionScope: string): Promise { + this.clients.set(key, client); + this.clientScopes.set(key, sessionScope); const sessionState = this.sessionStates.get(sessionScope)?.state; if (sessionState) { await this.applySessionState(client, sessionState); } - return client; } - private checkClient(name: string): boolean { if (this.initSet.has(name)) { return true; @@ -364,8 +370,8 @@ export class LanguageService implements Disposable { const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; - const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); - this.clients.set(key, client); this.clientScopes.set(key, scope); this.initSet.delete(key); + await this.createClient(key, selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); + this.initSet.delete(key); } return; } @@ -379,10 +385,8 @@ export class LanguageService implements Disposable { const documentSelector: DocumentFilter[] = [ { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, ]; - const client = await this.createClient(documentSelector, + await this.createClient(key, documentSelector, target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, scope, target); - this.clients.set(key, client); - this.clientScopes.set(key, scope); this.initSet.delete(key); } return; @@ -399,9 +403,7 @@ export class LanguageService implements Disposable { { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, ]; - const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); - this.clients.set(key, client); - this.clientScopes.set(key, key); + await this.createClient(key, documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); this.initSet.delete(key); } @@ -416,9 +418,7 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped'); - this.clients.set(key, client); - this.clientScopes.set(key, 'unscoped'); + await this.createClient(key, documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped'); this.initSet.delete(key); } return; @@ -432,10 +432,8 @@ export class LanguageService implements Disposable { const documentSelector: DocumentFilter[] = [ { scheme: 'file', pattern: document.uri.fsPath }, ]; - const client = await this.createClient(documentSelector, + await this.createClient(key, documentSelector, dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, 'unscoped'); - this.clients.set(key, client); - this.clientScopes.set(key, 'unscoped'); this.initSet.delete(key); } return; @@ -541,10 +539,8 @@ export class LanguageService implements Disposable { const workspaceFolder = workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); - const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); - this.clients.set('global', client); + await this.createClient('global', documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); this.startMultiLanguageService(true); - this.clientScopes.set('global', 'global'); } } From cc26c50294f4ad69d8f6825086235efbbdbe3b52 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:39:50 +1100 Subject: [PATCH 07/23] test(lsp): cover session synchronization races --- src/test/suite/languageService.test.ts | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 5e53e4a1e..6add6d920 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -15,6 +15,7 @@ suite('Session package completion', () => { // Keep the tests independent of installed R packages and server processes. type ServiceInternals = { startLanguageService(): Promise; + registerClient(key: string, client: { sendRequest: sinon.SinonStub; stop: sinon.SinonStub }, scope: string): Promise; clients: Map; clientScopes: Map; }; @@ -57,6 +58,41 @@ suite('Session package completion', () => { sinon.assert.calledWithExactly(request, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); }); + test('client receives session updates while initial synchronization is pending', async () => { + let resolveInitial!: () => void; + const initial = new Promise(resolve => { resolveInitial = resolve; }); + const request = sandbox.stub(); + request.onFirstCall().returns(initial); + request.resolves(true); + const fakeClient = { sendRequest: request, stop: sandbox.stub().resolves() }; + const internals = service as unknown as ServiceInternals; + + service.syncSessionState({ + search: ['.GlobalEnv', 'package:base'], + loaded_namespaces: ['base'], + globalenv: {}, + }, folder.uri, 'session'); + + const registering = internals.registerClient('global', fakeClient, 'global'); + await Promise.resolve(); + + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + service.syncSessionState(undefined, folder.uri, 'session'); + + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['base'], loadedNamespaces: ['base'] + }); + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: [], loadedNamespaces: [] + }); + + resolveInitial(); + await registering; + }); + test('multi-server mode separates workspace and unscoped documents', () => { multiServer = true; const project = client('project', folder.uri.toString(true)); From d53815dbdca529b4fba60a4cf39b69fb0bccf3d5 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:40:18 +1100 Subject: [PATCH 08/23] test(session): cover workspace fallback without terminal association --- src/test/suite/session.test.ts | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 396f3b4fb..d6afcfc63 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -745,6 +745,45 @@ suite('Session Communication', () => { }); } + test('local session working directory resolves workspace without terminal association', async () => { + const endpoint = await session.getGlobalPipePath(); + const workspaceUri = vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-session-workspace')); + const workspaceFolder = { uri: workspaceUri, name: 'session-workspace', index: 0 }; + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => + uri.fsPath === workspaceUri.fsPath || uri.fsPath.startsWith(`${workspaceUri.fsPath}${path.sep}`) + ? workspaceFolder + : undefined); + const terminal = { processId: Promise.resolve(41000) } as unknown as vscode.Terminal; + sandbox.stub(vscode.window, 'terminals').value([terminal]); + sandbox.stub(vscode.window, 'activeTerminal').value(terminal); + ancestors.withArgs(41001).resolves([]); + + const existing = new Set(session.activeConnections); + const client = net.createConnection(endpoint); + try { + await new Promise((resolve, reject) => { + client.once('connect', resolve); + client.once('error', reject); + }); + const socket = await waitFor(() => [...session.activeConnections].find(candidate => !existing.has(candidate))); + assert.ok(socket); + client.write(`${JSON.stringify({ + jsonrpc: '2.0', method: 'attach', params: { + protocol_version: 2, session_id: 'workspace-fallback', host: os.hostname(), + pid: 41001, version: '4.4.0', tempdir: os.tmpdir(), + wd: path.join(workspaceUri.fsPath, 'project'), + }, + })}\n`); + const attached = await waitFor(() => session.activeSession?.sessionId === 'workspace-fallback' + ? session.activeSession : undefined); + assert.strictEqual(attached.resource?.toString(), workspaceUri.toString()); + assert.strictEqual(socket._terminalPid, undefined); + } finally { + client.destroy(); + await session.cleanupSession('workspace-fallback'); + } + }); + test('terminal readiness aborts on close even before processId resolves', async () => { const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; const close = new vscode.EventEmitter(); From 089f636ad6f57c347e409d6877e2f08c85e97b34 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:15:41 +1100 Subject: [PATCH 09/23] fix(lsp): replay session state for added workspaces --- src/languageService.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/languageService.ts b/src/languageService.ts index 8cbc24880..053285278 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -244,6 +244,17 @@ export class LanguageService implements Disposable { fileEvents: workspace.createFileSystemWatcher('**/*.{R,r}'), }, middleware: { + workspace: { + didChangeWorkspaceFolders: async (event, next) => { + await next(event); + if (sessionScope === 'global') { + const sessionState = this.sessionStates.get(sessionScope)?.state; + if (sessionState) { + await this.applySessionState(client, sessionState); + } + } + }, + }, provideSignatureHelp: async (document, position, context, token, next) => { const result = await next(document, position, context, token); // An empty LSP result still suppresses other VS Code providers. From bc96ca0c8ce03dd50a6f081ee87641e7c8db739e Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:17:59 +1100 Subject: [PATCH 10/23] test(lsp): cover session workspace synchronization --- src/test/suite/languageService.test.ts | 33 +++++++++++++++++++++++++- src/test/suite/session.test.ts | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 6add6d920..4141ff814 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -1,9 +1,11 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { LanguageClient } from 'vscode-languageclient/node'; import { LanguageService } from '../../languageService'; import * as session from '../../session'; import * as extension from '../../extension'; +import * as util from '../../util'; suite('Session package completion', () => { let sandbox: sinon.SinonSandbox; @@ -15,9 +17,13 @@ suite('Session package completion', () => { // Keep the tests independent of installed R packages and server processes. type ServiceInternals = { startLanguageService(): Promise; + createClient(key: string, selector: vscode.DocumentFilter[], cwd: string, + workspaceFolder: vscode.WorkspaceFolder | undefined, outputChannel: vscode.OutputChannel, + resource?: vscode.Uri, sessionScope?: string): Promise; registerClient(key: string, client: { sendRequest: sinon.SinonStub; stop: sinon.SinonStub }, scope: string): Promise; clients: Map; clientScopes: Map; + outputChannel: vscode.OutputChannel; }; function client(key: string, scope: string): sinon.SinonStub { const sendRequest = sandbox.stub().resolves(true); @@ -70,7 +76,6 @@ suite('Session package completion', () => { service.syncSessionState({ search: ['.GlobalEnv', 'package:base'], loaded_namespaces: ['base'], - globalenv: {}, }, folder.uri, 'session'); const registering = internals.registerClient('global', fakeClient, 'global'); @@ -93,6 +98,32 @@ suite('Session package completion', () => { await registering; }); + test('global server replays current packages after workspace folders change', async () => { + sandbox.stub(util, 'getRpath').resolves('/usr/bin/R'); + sandbox.stub(LanguageClient.prototype, 'start').resolves(); + const request = sandbox.stub(LanguageClient.prototype, 'sendRequest').resolves(true); + const internals = service as unknown as ServiceInternals; + const client = await internals.createClient( + 'global', [{ scheme: 'file', language: 'r' }], '/project', + undefined, internals.outputChannel, folder.uri, 'global' + ); + + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + request.resetHistory(); + + const middleware = client.middleware.workspace?.didChangeWorkspaceFolders; + assert.ok(middleware); + const event = { added: [folder], removed: [] }; + const next = sandbox.stub().resolves(); + await middleware(event, next); + + sinon.assert.calledOnceWithExactly(next, event); + sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + sinon.assert.callOrder(next, request); + }); + test('multi-server mode separates workspace and unscoped documents', () => { multiServer = true; const project = client('project', folder.uri.toString(true)); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index d6afcfc63..f42e95c94 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -776,6 +776,7 @@ suite('Session Communication', () => { })}\n`); const attached = await waitFor(() => session.activeSession?.sessionId === 'workspace-fallback' ? session.activeSession : undefined); + assert.ok(attached); assert.strictEqual(attached.resource?.toString(), workspaceUri.toString()); assert.strictEqual(socket._terminalPid, undefined); } finally { From 27fb9f566a1ba029a91355fec9c9eeb4c2f76d88 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:12:44 +1100 Subject: [PATCH 11/23] test(lsp): fix session package completion fixtures --- src/test/suite/languageService.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 4141ff814..8193d030e 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -1,4 +1,6 @@ import * as assert from 'assert'; +import * as os from 'os'; +import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/node'; @@ -6,12 +8,13 @@ import { LanguageService } from '../../languageService'; import * as session from '../../session'; import * as extension from '../../extension'; import * as util from '../../util'; +import { mockExtensionContext } from '../common/mockvscode'; suite('Session package completion', () => { let sandbox: sinon.SinonSandbox; let service: LanguageService; let multiServer: boolean; - const folder = { uri: vscode.Uri.file('/project'), name: 'project', index: 0 }; + const folder = { uri: vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-language-service', 'project')), name: 'project', index: 0 }; const packages = (name: string) => ({ search: ['.GlobalEnv', `package:${name}`, 'package:base'], loaded_namespaces: [name, 'base'], globalenv: {} }); // Keep the tests independent of installed R packages and server processes. @@ -35,12 +38,16 @@ suite('Session package completion', () => { setup(() => { sandbox = sinon.createSandbox(); + mockExtensionContext(path.join(__dirname, '..', '..', '..'), sandbox); multiServer = false; sandbox.stub(LanguageService.prototype as unknown as ServiceInternals, 'startLanguageService').resolves(); sandbox.stub(vscode.workspace, 'getConfiguration').returns({ get: (key: string) => key === 'lsp.multiServer' ? multiServer : undefined } as vscode.WorkspaceConfiguration); - sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => uri.fsPath.startsWith('/project') ? folder : undefined); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => + uri.fsPath === folder.uri.fsPath || uri.fsPath.startsWith(`${folder.uri.fsPath}${path.sep}`) + ? folder + : undefined); service = new LanguageService(); sandbox.stub(extension, 'rLanguageService').value(service); }); @@ -104,7 +111,7 @@ suite('Session package completion', () => { const request = sandbox.stub(LanguageClient.prototype, 'sendRequest').resolves(true); const internals = service as unknown as ServiceInternals; const client = await internals.createClient( - 'global', [{ scheme: 'file', language: 'r' }], '/project', + 'global', [{ scheme: 'file', language: 'r' }], folder.uri.fsPath, undefined, internals.outputChannel, folder.uri, 'global' ); @@ -142,7 +149,7 @@ suite('Session package completion', () => { const global = client('global', 'global'); const input = client('input', 'session:interactive-packages'); const notebook = client('notebook', 'session:interactive-packages'); - const target = session.registerSessionTransport('interactive-packages', 'host', '/project', () => Promise.resolve(undefined)); + const target = session.registerSessionTransport('interactive-packages', 'host', folder.uri.fsPath, () => Promise.resolve(undefined)); try { assert.strictEqual(target.resource?.toString(), folder.uri.toString()); session.updateSessionWorkspace(target, packages('stats')); From 2470fe192756b5c8b305d5aee6e19e503a10a05c Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:11:26 +1100 Subject: [PATCH 12/23] fix(lsp): retry failed session package synchronisation - Track successful delivery per client and serialise updates to preserve the latest state. Retry failures on refresh or completion, retain disconnect clears, and guard against replaced clients. - Add regression tests and real R server coverage for workspace replay and disconnect cleanup. --- src/languageService.ts | 109 +++++++--- .../integration/sessionCompletion.test.ts | 108 ++++++++++ src/test/suite/languageService.test.ts | 196 ++++++++++++++++-- 3 files changed, 374 insertions(+), 39 deletions(-) create mode 100644 src/test/integration/sessionCompletion.test.ts diff --git a/src/languageService.ts b/src/languageService.ts index dffe3a73a..2c5e07b1f 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -23,6 +23,15 @@ interface SessionWorkspaceData { loaded_namespaces: string[]; } +interface ClientSessionSync { + state: SessionState; + stateKey: string; + syncedStateKey?: string; + replay: number; + syncedReplay?: number; + pending?: Promise; +} + export class LanguageService implements Disposable { private readonly clients: Map = new Map(); private readonly initSet: Set = new Set(); @@ -34,6 +43,7 @@ export class LanguageService implements Disposable { private disposed = false; private readonly clientScopes: Map = new Map(); private readonly sessionStates: Map = new Map(); + private readonly clientSessionSync = new WeakMap(); constructor() { this.outputChannel = window.createOutputChannel('R Language Server', { log: true }); @@ -60,14 +70,11 @@ export class LanguageService implements Disposable { private syncSessionScope(scope: string, data: SessionWorkspaceData | undefined, sessionId: string): void { const current = this.sessionStates.get(scope); if (!data) { - if (!current || (sessionId && current.sessionId !== sessionId)) { + if (current && sessionId && current.sessionId !== sessionId) { return; } this.sessionStates.delete(scope); - this.applySessionStateToScope(scope, { - attachedPackages: [], - loadedNamespaces: [], - }); + this.applySessionStateToScope(scope); return; } @@ -75,30 +82,80 @@ export class LanguageService implements Disposable { attachedPackages: data.search .filter(value => value.startsWith('package:')) .map(value => value.substring(8)), - loadedNamespaces: data.loaded_namespaces, + loadedNamespaces: [...data.loaded_namespaces], }; const stateKey = this.getSessionStateKey(state); - if (current?.stateKey === stateKey && current.sessionId === sessionId) { - return; + if (current?.stateKey !== stateKey || current.sessionId !== sessionId) { + this.sessionStates.set(scope, { state, stateKey, sessionId }); } - - this.sessionStates.set(scope, { state, stateKey, sessionId }); - this.applySessionStateToScope(scope, state); + // An unchanged refresh can retry delivery to a client that failed. + this.applySessionStateToScope(scope); } - private applySessionStateToScope(scope: string, state: SessionState): void { - for (const [key, client] of this.clients) { + private applySessionStateToScope(scope: string): void { + for (const key of this.clients.keys()) { if (this.clientScopes.get(key) === scope) { - void this.applySessionState(client, state); + void this.syncClientSessionState(key); } } } - private async applySessionState(client: LanguageClient, state: SessionState): Promise { - try { - await client.sendRequest('r/syncSessionState', state); - } catch { - // Keep language-service features available if session synchronization fails. + private syncClientSessionState(key: string, force = false): Promise { + const client = this.clients.get(key); + const scope = this.clientScopes.get(key); + if (!client || scope === undefined || this.disposed) { + return Promise.resolve(); + } + let sync = this.clientSessionSync.get(client); + const state = this.sessionStates.get(scope)?.state + // Retain a disconnect clear on the client until it is acknowledged. + ?? (sync ? { attachedPackages: [], loadedNamespaces: [] } : undefined); + if (!state) { return Promise.resolve(); } + const stateKey = this.getSessionStateKey(state); + if (!sync) { + sync = { state, stateKey, replay: 0 }; + this.clientSessionSync.set(client, sync); + } else { + sync.state = state; + sync.stateKey = stateKey; + } + if (force) { sync.replay++; } + // Check in-flight work before acknowledged state: A -> B -> A must + // restore A after B completes, even if A was previously acknowledged. + if (sync.pending) { return sync.pending; } + if (sync.syncedStateKey === stateKey && sync.syncedReplay === sync.replay) { + return Promise.resolve(); + } + const current = sync; + const pending: Promise = this.deliverSessionState(key, client, current).then(failed => { + if (current.pending === pending) { current.pending = undefined; } + // A refresh can arrive after the delivery loop finishes but before + // this promise settles. Reconcile it without retrying a failure loop. + if (!this.disposed && this.clients.get(key) === client + && (current.syncedStateKey !== current.stateKey || current.syncedReplay !== current.replay) + && (!failed || failed.stateKey !== current.stateKey || failed.replay !== current.replay)) { + return this.syncClientSessionState(key); + } + }); + current.pending = pending; + return pending; + } + + private async deliverSessionState(key: string, client: LanguageClient, sync: ClientSessionSync): + Promise<{ stateKey: string; replay: number } | undefined> { + while (!this.disposed && this.clients.get(key) === client) { + if (sync.syncedStateKey === sync.stateKey && sync.syncedReplay === sync.replay) { return; } + const { state, stateKey, replay } = sync; + try { + await client.sendRequest('r/syncSessionState', state); + sync.syncedStateKey = stateKey; + sync.syncedReplay = replay; + } catch { + // A failed response may still have changed the server. Reconcile + // newer state, but leave an unchanged failure for the next event. + sync.syncedStateKey = undefined; + if (sync.stateKey === stateKey && sync.replay === replay) { return { stateKey, replay }; } + } } } @@ -248,13 +305,14 @@ export class LanguageService implements Disposable { didChangeWorkspaceFolders: async (event, next) => { await next(event); if (sessionScope === 'global') { - const sessionState = this.sessionStates.get(sessionScope)?.state; - if (sessionState) { - await this.applySessionState(client, sessionState); - } + await this.syncClientSessionState(key, true); } }, }, + provideCompletionItem: async (document, position, context, token, next) => { + await this.syncClientSessionState(key); + return next(document, position, context, token); + }, provideSignatureHelp: async (document, position, context, token, next) => { const result = await next(document, position, context, token); // An empty LSP result still suppresses other VS Code providers. @@ -324,10 +382,7 @@ export class LanguageService implements Disposable { private async registerClient(key: string, client: LanguageClient, sessionScope: string): Promise { this.clients.set(key, client); this.clientScopes.set(key, sessionScope); - const sessionState = this.sessionStates.get(sessionScope)?.state; - if (sessionState) { - await this.applySessionState(client, sessionState); - } + await this.syncClientSessionState(key); } private checkClient(name: string): boolean { diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts new file mode 100644 index 000000000..fc7b54401 --- /dev/null +++ b/src/test/integration/sessionCompletion.test.ts @@ -0,0 +1,108 @@ +import * as assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { LanguageClient } from 'vscode-languageclient/node'; +import { LanguageService } from '../../languageService'; +import * as util from '../../util'; +import { mockExtensionContext } from '../common/mockvscode'; + +suite('Session completion with real R language server', () => { + test('new workspace folders receive packages and disconnect restores default completions', async () => { + const sandbox = sinon.createSandbox(); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-r session completion ')); + let service: LanguageService | undefined; + let client: LanguageClient | undefined; + try { + const rPath = await util.getRpathFromSystem(); + assert.ok(rPath, 'R must be installed to run the language server integration test'); + mockExtensionContext(path.join(__dirname, '..', '..', '..'), sandbox); + type ServiceInternals = { + startLanguageService(): Promise; + createClient(key: string, selector: vscode.DocumentFilter[], cwd: string, + workspaceFolder: vscode.WorkspaceFolder | undefined, outputChannel: vscode.LogOutputChannel, + resource?: vscode.Uri, sessionScope?: string): Promise; + syncClientSessionState(key: string): Promise; + outputChannel: vscode.LogOutputChannel; + }; + sandbox.stub(LanguageService.prototype as unknown as ServiceInternals, 'startLanguageService').resolves(); + sandbox.stub(util, 'getRpath').resolves(rPath); + const settings = { get: (key: string) => { + if (key === 'lsp.args') { return ['--vanilla']; } + if (key === 'lsp.lang') { return ''; } + return undefined; + } } as vscode.WorkspaceConfiguration; + const getConfiguration = vscode.workspace.getConfiguration; + sandbox.stub(vscode.workspace, 'getConfiguration').callsFake((section, resource) => + section === 'r' ? settings : getConfiguration(section, resource)); + service = new LanguageService(); + const internals = service as unknown as ServiceInternals; + client = await internals.createClient('global', [{ scheme: 'file', language: 'r' }], + root, undefined, internals.outputChannel, undefined, 'global'); + const languageClient = client; + const middleware = client.middleware.workspace?.didChangeWorkspaceFolders; + assert.ok(middleware); + const protocolUri = (uri: vscode.Uri) => languageClient.code2ProtocolConverter.asUri(uri); + const documentUri = (folder: vscode.WorkspaceFolder) => protocolUri(vscode.Uri.joinPath(folder.uri, 'completion.R')); + + const projects: vscode.WorkspaceFolder[] = []; + for (const name of ['initial project', 'added project']) { + const directory = path.join(root, name); + await fs.ensureDir(directory); + await fs.writeFile(path.join(directory, 'completion.R'), 'bs'); + projects.push({ uri: vscode.Uri.file(directory), name, index: projects.length }); + } + const addFolder = (folder: vscode.WorkspaceFolder) => middleware({ added: [folder], removed: [] }, + event => languageClient.sendNotification('workspace/didChangeWorkspaceFolders', { + event: { + added: event.added.map(folder => ({ uri: protocolUri(folder.uri), name: folder.name })), + removed: [] + } + })); + const openDocument = (folder: vscode.WorkspaceFolder) => languageClient.sendNotification('textDocument/didOpen', { + textDocument: { + uri: documentUri(folder), + languageId: 'r', version: 1, text: 'bs' + } + }); + type Completion = { label: string; data?: { package?: string } }; + const hasSplineCompletion = async (folder: vscode.WorkspaceFolder) => { + const response = await languageClient.sendRequest( + 'textDocument/completion', { + textDocument: { uri: documentUri(folder) }, + position: { line: 0, character: 2 } + }); + return (Array.isArray(response) ? response : response?.items ?? []) + .some(item => item.label === 'bs' && item.data?.package === 'splines'); + }; + + await addFolder(projects[0]); + await openDocument(projects[0]); + assert.strictEqual(await hasSplineCompletion(projects[0]), false, 'splines is not attached by default'); + service.syncSessionState({ search: ['package:splines', 'package:base'], loaded_namespaces: ['splines', 'base'] }, + projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + assert.strictEqual(await hasSplineCompletion(projects[0]), true, 'initial workspace receives session packages'); + + await addFolder(projects[1]); + await openDocument(projects[1]); + assert.strictEqual(await hasSplineCompletion(projects[1]), true, 'new workspace receives replayed session packages'); + + service.syncSessionState(undefined, projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + for (const project of projects) { + assert.strictEqual(await hasSplineCompletion(project), false, 'disconnect removes session-only completions'); + } + } finally { + try { + await service?.dispose(); + await client?.dispose(); + } finally { + sandbox.restore(); + await fs.remove(root); + } + } + }).timeout(60000); +}); diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 0a74ed32a..bf5ce31ba 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -24,6 +24,7 @@ suite('Session package completion', () => { workspaceFolder: vscode.WorkspaceFolder | undefined, outputChannel: vscode.LogOutputChannel, resource?: vscode.Uri, sessionScope?: string): Promise; registerClient(key: string, client: { sendRequest: sinon.SinonStub; stop: sinon.SinonStub }, scope: string): Promise; + syncClientSessionState(key: string, force?: boolean): Promise; clients: Map; clientScopes: Map; outputChannel: vscode.LogOutputChannel; @@ -36,6 +37,14 @@ suite('Session package completion', () => { return sendRequest; } + const settle = () => new Promise(resolve => setImmediate(resolve)); + function deferred() { + let resolve!: (value: boolean) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; + } + setup(() => { sandbox = sinon.createSandbox(); mockExtensionContext(path.join(__dirname, '..', '..', '..'), sandbox); @@ -57,17 +66,20 @@ suite('Session package completion', () => { sandbox.restore(); }); - test('global server follows active packages and ignores duplicate updates', () => { + test('global server follows active packages and ignores duplicate updates', async () => { const request = client('global', 'global'); service.syncSessionState(packages('stats'), folder.uri, 'first'); service.syncSessionState(packages('stats'), folder.uri, 'first'); + await settle(); sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { attachedPackages: ['stats', 'base'], loadedNamespaces: ['stats', 'base'] }); service.syncSessionState(packages('utils'), undefined, 'second'); service.syncSessionState(undefined, folder.uri, 'first'); + await settle(); assert.strictEqual(request.callCount, 2, 'closing an older session must not clear the active packages'); service.syncSessionState(undefined, undefined, 'second'); + await settle(); sinon.assert.calledWithExactly(request, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); }); @@ -89,20 +101,16 @@ suite('Session package completion', () => { await Promise.resolve(); service.syncSessionState(packages('dplyr'), folder.uri, 'session'); - service.syncSessionState(undefined, folder.uri, 'session'); - - sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { attachedPackages: ['base'], loadedNamespaces: ['base'] }); - sinon.assert.calledWithExactly(request, 'r/syncSessionState', { - attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] - }); - sinon.assert.calledWithExactly(request, 'r/syncSessionState', { - attachedPackages: [], loadedNamespaces: [] - }); resolveInitial(); await registering; + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + sinon.assert.calledTwice(request); }); test('global server replays current packages after workspace folders change', async () => { @@ -116,6 +124,7 @@ suite('Session package completion', () => { ); service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + await settle(); request.resetHistory(); const middleware = client.middleware.workspace?.didChangeWorkspaceFolders; @@ -131,21 +140,61 @@ suite('Session package completion', () => { sinon.assert.callOrder(next, request); }); - test('multi-server mode separates workspace and unscoped documents', () => { + test('completion retries failed session synchronization before requesting suggestions', async () => { + sandbox.stub(util, 'getRpath').resolves('/usr/bin/R'); + sandbox.stub(LanguageClient.prototype, 'start').resolves(); + const request = sandbox.stub(LanguageClient.prototype, 'sendRequest').resolves(true); + request.onFirstCall().rejects(new Error('transient failure')); + const retry = deferred(); + request.onSecondCall().returns(retry.promise); + const internals = service as unknown as ServiceInternals; + const client = await internals.createClient( + 'global', [{ scheme: 'file', language: 'r' }], folder.uri.fsPath, + undefined, internals.outputChannel, folder.uri, 'global' + ); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + await settle(); + sinon.assert.calledOnce(request); + + const middleware = client.middleware.provideCompletionItem; + assert.ok(middleware); + const document = { uri: folder.uri } as vscode.TextDocument; + const position = new vscode.Position(0, 0); + const context = { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: undefined }; + const next = sandbox.stub().resolves([]); + const cancellation = new vscode.CancellationTokenSource(); + try { + const completing = middleware(document, position, context, cancellation.token, next); + sinon.assert.calledTwice(request); + await Promise.resolve(); + sinon.assert.notCalled(next); + retry.resolve(true); + await completing; + sinon.assert.calledOnceWithExactly(next, document, position, context, cancellation.token); + await middleware(document, position, context, cancellation.token, next); + sinon.assert.calledTwice(request); + } finally { + cancellation.dispose(); + } + }); + + test('multi-server mode separates workspace and unscoped documents', async () => { multiServer = true; const project = client('project', folder.uri.toString(true)); const untitled = client('untitled', 'unscoped'); const externalFile = client('external', 'unscoped'); service.syncSessionState(packages('stats'), folder.uri, 'project-session'); + await settle(); sinon.assert.calledOnce(project); sinon.assert.notCalled(untitled); service.syncSessionState(packages('utils'), undefined, 'external-session'); + await settle(); sinon.assert.calledOnce(project); sinon.assert.calledOnce(untitled); sinon.assert.calledOnce(externalFile); }); - test('inactive Interactive workspace updates reach only its bound language servers', () => { + test('inactive Interactive workspace updates reach only its bound language servers', async () => { const global = client('global', 'global'); const input = client('input', 'session:interactive-packages'); const notebook = client('notebook', 'session:interactive-packages'); @@ -153,13 +202,16 @@ suite('Session package completion', () => { try { assert.strictEqual(target.resource?.toString(), folder.uri.toString()); session.updateSessionWorkspace(target, packages('stats')); + await settle(); sinon.assert.notCalled(global); sinon.assert.calledOnce(input); sinon.assert.calledOnce(notebook); service.syncSessionState(packages('utils'), undefined, 'terminal'); + await settle(); sinon.assert.calledOnce(input); sinon.assert.calledOnce(notebook); session.unregisterSessionTransport(target); + await settle(); sinon.assert.calledWithExactly(input, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); sinon.assert.calledWithExactly(notebook, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); sinon.assert.calledOnce(global); @@ -167,4 +219,124 @@ suite('Session package completion', () => { session.unregisterSessionTransport(target); } }); + + test('unchanged refresh retries only the client whose synchronization failed', async () => { + const healthy = client('healthy', 'global'); + const failed = client('failed', 'global'); + failed.onFirstCall().rejects(new Error('transient failure')); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + await settle(); + sinon.assert.calledOnce(failed); + + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + await settle(); + sinon.assert.calledOnce(healthy); + sinon.assert.calledTwice(failed); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + await settle(); + sinon.assert.calledTwice(failed); + }); + + test('disconnect during initial synchronization replaces queued packages with a clear', async () => { + const initial = deferred(); + const request = client('global', 'global'); + request.onFirstCall().returns(initial.promise); + service.syncSessionState(packages('stats'), folder.uri, 'session'); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + service.syncSessionState(undefined, folder.uri, 'session'); + sinon.assert.calledOnce(request); + initial.resolve(true); + await settle(); + sinon.assert.calledTwice(request); + assert.deepStrictEqual(request.lastCall.args, ['r/syncSessionState', { + attachedPackages: [], loadedNamespaces: [] + }]); + }); + + test('failed disconnect clear is retained for the next synchronization attempt', async () => { + const request = client('global', 'global'); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + await settle(); + request.onSecondCall().rejects(new Error('clear failed')); + service.syncSessionState(undefined, folder.uri, 'session'); + await settle(); + sinon.assert.calledTwice(request); + await (service as unknown as ServiceInternals).syncClientSessionState('global'); + sinon.assert.calledThrice(request); + assert.deepStrictEqual(request.lastCall.args, ['r/syncSessionState', { + attachedPackages: [], loadedNamespaces: [] + }]); + }); + + test('failed synchronization still delivers newer pending state', async () => { + const initial = deferred(); + const request = client('global', 'global'); + request.onFirstCall().returns(initial.promise); + service.syncSessionState(packages('stats'), folder.uri, 'session'); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + initial.reject(new Error('initial sync failed')); + await settle(); + sinon.assert.calledTwice(request); + assert.deepStrictEqual(request.lastCall.args[1], { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + }); + + test('returning to previously acknowledged packages restores them after an in-flight change', async () => { + const request = client('global', 'global'); + service.syncSessionState(packages('stats'), folder.uri, 'session'); + await settle(); + const changing = deferred(); + request.onSecondCall().returns(changing.promise); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + service.syncSessionState(packages('stats'), folder.uri, 'session'); + changing.resolve(true); + await settle(); + sinon.assert.calledThrice(request); + assert.deepStrictEqual(request.lastCall.args[1], { + attachedPackages: ['stats', 'base'], loadedNamespaces: ['stats', 'base'] + }); + }); + + test('updates arriving as a request settles are not lost', async () => { + const initial = deferred(); + const request = client('global', 'global'); + request.onFirstCall().returns(initial.promise); + service.syncSessionState(packages('stats'), folder.uri, 'session'); + void initial.promise.then(() => service.syncSessionState(packages('dplyr'), folder.uri, 'session')); + initial.resolve(true); + await settle(); + sinon.assert.calledTwice(request); + assert.deepStrictEqual(request.lastCall.args[1], { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + }); + + test('replacement client receives current state without replaying updates to the old client', async () => { + const initial = deferred(); + const old = client('global', 'global'); + old.onFirstCall().returns(initial.promise); + service.syncSessionState(packages('stats'), folder.uri, 'session'); + const replacement = client('global', 'global'); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + initial.resolve(true); + await settle(); + sinon.assert.calledOnce(old); + sinon.assert.calledOnce(replacement); + assert.deepStrictEqual(replacement.firstCall.args[1], { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + }); + + test('workspace replay during an in-flight sync sends the state again after it completes', async () => { + const initial = deferred(); + const request = client('global', 'global'); + request.onFirstCall().returns(initial.promise); + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + const replay = (service as unknown as ServiceInternals).syncClientSessionState('global', true); + initial.resolve(true); + await replay; + sinon.assert.calledTwice(request); + assert.deepStrictEqual(request.firstCall.args, request.lastCall.args); + }); }); From 00fd9f38aac24b9e762ae89d73677d785562f1cb Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:56:57 +1100 Subject: [PATCH 13/23] fix(interactive): restore R input binding when reopening native windows --- .github/workflows/main.yml | 1 + src/interactive/manager.ts | 19 +++++++++++-- .../integration/sessionCompletion.test.ts | 5 +++- src/test/suite/interactiveEditor.test.ts | 28 +++++++++++++++++-- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d4673978c..476f16802 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -96,6 +96,7 @@ jobs: path: | .vscode-test/user-data/logs/**/exthost/exthost.log .vscode-test/user-data/logs/**/exthost/output_logging_*/*.log + .vscode-test/user-data/logs/**/exthost/REditorSupport.r/*.log retention-days: 7 build: runs-on: ubuntu-latest diff --git a/src/interactive/manager.ts b/src/interactive/manager.ts index 1f204b350..d67b84fa4 100644 --- a/src/interactive/manager.ts +++ b/src/interactive/manager.ts @@ -661,7 +661,9 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro const previous = this.views.get(key); if (previous && !previous.notebook.isClosed && (previous.client.connected || previous.restarting)) { if (previous.notebook.notebookType === 'interactive') { - await vscode.commands.executeCommand('interactive.open', { preserveFocus: true }, previous.notebook.uri); + const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', { preserveFocus: true }, previous.notebook.uri); + if (result?.inputUri) { await this.bindInput(previous, result.inputUri); } } else { const editor = vscode.window.visibleNotebookEditors.find(item => item.notebook === previous.notebook); await vscode.window.showNotebookDocument(previous.notebook, { preserveFocus: true, viewColumn: editor?.viewColumn }); @@ -743,8 +745,7 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro }); } if (inputUri) { - session.bindSessionDocument(inputUri, target); - await vscode.languages.setTextDocumentLanguage(await vscode.workspace.openTextDocument(inputUri), 'r'); + await this.bindInput(view, inputUri); } const restoring = notebook === existing || !!savedUri && notebook.uri.toString() === savedUri; if (!restoring && inputUri) { @@ -776,6 +777,18 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro } } + private async bindInput(view: InteractiveView, inputUri: vscode.Uri): Promise { + // Native Interactive can recreate its input model when a tab is restored. + // Reapply its owner and R language on reopen as well as initial creation. + if (view.inputUri && view.inputUri.toString() !== inputUri.toString() + && session.boundSessionForDocument(view.inputUri) === view.target) { + session.unbindSessionDocument(view.inputUri); + } + view.inputUri = inputUri; + session.bindSessionDocument(inputUri, view.target); + await vscode.languages.setTextDocumentLanguage(await vscode.workspace.openTextDocument(inputUri), 'r'); + } + private registerTarget(client: AgentClient): session.Session { const manifest = client.manifest; const target = session.registerSessionTransport(`${manifest.id}:${manifest.generation}`, manifest.host, manifest.directory, data => diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index fc7b54401..2b89e1d5c 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -1,5 +1,6 @@ import * as assert from 'assert'; import * as fs from 'fs-extra'; +import { rm } from 'node:fs/promises'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; @@ -101,7 +102,9 @@ suite('Session completion with real R language server', () => { await client?.dispose(); } finally { sandbox.restore(); - await fs.remove(root); + // TCP shutdown can finish before R releases its working directory + // on Windows. Retry transient locks instead of failing cleanup. + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } } }).timeout(60000); diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index e0fece9d3..e7e11267d 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -82,10 +82,10 @@ function treeTooltip(item: vscode.TreeItem): string { fs.rmSync(root, { recursive: true, force: true }); sourceDirectories.forEach(directory => fs.rmSync(directory, { recursive: true, force: true })); }); - const until = async (predicate: () => boolean): Promise => { + const until = async (predicate: () => boolean, description = () => 'notebook execution'): Promise => { const deadline = Date.now() + 20000; while (!predicate()) { - if (Date.now() > deadline) { throw new Error('Timed out waiting for notebook execution'); } + if (Date.now() > deadline) { throw new Error(`Timed out waiting for ${description()}`); } await new Promise(resolve => setTimeout(resolve, 50)); } }; @@ -756,6 +756,21 @@ function treeTooltip(item: vscode.TreeItem): string { await document.save(); } finally { sourceDirectories.push(directory); } }); + test('reopening a native input restores R language after its document is recreated', async () => { + await vscode.commands.executeCommand('r.interactive.open', manifests[0]); + const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + assert.ok(notebook); + const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); + const input = await vscode.workspace.openTextDocument(result.inputUri); + await vscode.languages.setTextDocumentLanguage(input, 'plaintext'); + try { + await vscode.commands.executeCommand('r.interactive.open', manifests[0]); + const reopened = await vscode.workspace.openTextDocument(result.inputUri); + assert.strictEqual(reopened.languageId, 'r', 'Reopening must restore the R input language'); + } finally { + await vscode.languages.setTextDocumentLanguage(await vscode.workspace.openTextDocument(result.inputUri), 'r'); + } + }); test('starts each Interactive input language server in its owning session directory', async () => { const service = bundleContext().subscriptions.find(item => (item as { clients?: unknown }).clients instanceof Map) as { clients: Map } | undefined; @@ -766,9 +781,18 @@ function treeTooltip(item: vscode.TreeItem): string { const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifest.id); assert.ok(notebook); const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); + const input = await vscode.workspace.openTextDocument(result.inputUri); + assert.strictEqual(input.languageId, 'r', 'An owning session must restore its input language'); await until(() => { const client = service.clients.get(result.inputUri.toString()); return client?.isRunning() === true && client.clientOptions.workspaceFolder?.uri.fsPath === manifest.directory; + }, () => { + const client = service.clients.get(result.inputUri.toString()); + return `Interactive input language server ${result.inputUri.toString()}: ${JSON.stringify({ + running: client?.isRunning() ?? false, + expectedDirectory: manifest.directory, + actualDirectory: client?.clientOptions.workspaceFolder?.uri.fsPath, + })}`; }); } } finally { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } From 96afb8ef9bddcf11feaadd7a6ee7821a4204cde8 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Mon, 12 Oct 2026 23:11:57 +1100 Subject: [PATCH 14/23] fix(test): replace removed fs-extra dependency --- src/test/integration/sessionCompletion.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index 2b89e1d5c..0ad56b6c6 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -1,6 +1,5 @@ import * as assert from 'assert'; -import * as fs from 'fs-extra'; -import { rm } from 'node:fs/promises'; +import * as fs from 'node:fs/promises'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; @@ -51,7 +50,7 @@ suite('Session completion with real R language server', () => { const projects: vscode.WorkspaceFolder[] = []; for (const name of ['initial project', 'added project']) { const directory = path.join(root, name); - await fs.ensureDir(directory); + await fs.mkdir(directory, { recursive: true }); await fs.writeFile(path.join(directory, 'completion.R'), 'bs'); projects.push({ uri: vscode.Uri.file(directory), name, index: projects.length }); } @@ -104,7 +103,7 @@ suite('Session completion with real R language server', () => { sandbox.restore(); // TCP shutdown can finish before R releases its working directory // on Windows. Retry transient locks instead of failing cleanup. - await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } } }).timeout(60000); From a92d321333cf0f64d78cc9b2e25f215b566156e3 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:04:05 +0900 Subject: [PATCH 15/23] fix: retain session workspace ownership for package completion Update owned workspace language servers even when their session is not globally selected, and clear recorded scopes on disconnect after folders are removed. Add regression tests for session handoff, competing updates and folder removal. Apply oxfmt to the PR's changed TypeScript sources. --- src/interactive/manager.ts | 3 +- src/languageService.ts | 150 +++++++--- src/session.ts | 7 +- .../integration/sessionCompletion.test.ts | 150 +++++++--- src/test/suite/interactiveEditor.test.ts | 60 ++-- src/test/suite/languageService.test.ts | 260 ++++++++++++++---- src/test/suite/session.test.ts | 45 ++- 7 files changed, 515 insertions(+), 160 deletions(-) diff --git a/src/interactive/manager.ts b/src/interactive/manager.ts index b21156eb5..45be6ce92 100644 --- a/src/interactive/manager.ts +++ b/src/interactive/manager.ts @@ -1643,7 +1643,8 @@ export class InteractiveManager // Native Interactive can recreate its input model when a tab is restored. // Reapply its owner and R language on reopen as well as initial creation. if ( - view.inputUri && view.inputUri.toString() !== inputUri.toString() && + view.inputUri && + view.inputUri.toString() !== inputUri.toString() && session.boundSessionForDocument(view.inputUri) === view.target ) { session.unbindSessionDocument(view.inputUri); diff --git a/src/languageService.ts b/src/languageService.ts index e76153c66..4c788f619 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -68,7 +68,11 @@ export class LanguageService implements Disposable { private readonly listeners: Disposable[] = []; private disposed = false; private readonly clientScopes: Map = new Map(); - private readonly sessionStates: Map = new Map(); + private readonly sessionStates: Map< + string, + { state: SessionState; stateKey: string; sessionId: string } + > = new Map(); + private readonly sessionScopes = new Map(); private readonly clientSessionSync = new WeakMap(); constructor() { @@ -85,17 +89,53 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } - syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = '', active = true): void { - // Bound virtual documents keep their owner's packages when focus moves. + syncSessionState( + data?: SessionWorkspaceData, + resource?: Uri, + sessionId = '', + active = true, + ): void { + // Virtual documents always follow their bound session. if (sessionId) { this.syncSessionScope(`session:${sessionId}`, data, sessionId); } - if (active || !data) { - this.syncSessionScope(this.getSessionScope(resource), data, sessionId); + + // Store the scope when the session becomes its owner. Workspace folders + // may be removed before disconnect, so cleanup must not reclassify it. + const ownedScope = sessionId ? this.sessionScopes.get(sessionId) : undefined; + if (!data) { + if (sessionId) { + this.sessionScopes.delete(sessionId); + } + this.syncSessionScope( + ownedScope ?? this.getSessionScope(resource), + undefined, + sessionId, + ); + return; + } + + if (active) { + const scope = this.getSessionScope(resource); + if (ownedScope && ownedScope !== scope) { + this.syncSessionScope(ownedScope, undefined, sessionId); + } + if (sessionId) { + this.sessionScopes.set(sessionId, scope); + } + this.syncSessionScope(scope, data, sessionId); + } else if (ownedScope && this.sessionStates.get(ownedScope)?.sessionId === sessionId) { + // An unselected session may refresh its own workspace, but never + // replace the state now owned by another session. + this.syncSessionScope(ownedScope, data, sessionId); } } - private syncSessionScope(scope: string, data: SessionWorkspaceData | undefined, sessionId: string): void { + private syncSessionScope( + scope: string, + data: SessionWorkspaceData | undefined, + sessionId: string, + ): void { const current = this.sessionStates.get(scope); if (!data) { if (current && sessionId && current.sessionId !== sessionId) { @@ -108,8 +148,8 @@ export class LanguageService implements Disposable { const state: SessionState = { attachedPackages: data.search - .filter(value => value.startsWith('package:')) - .map(value => value.substring(8)), + .filter((value) => value.startsWith('package:')) + .map((value) => value.substring(8)), loadedNamespaces: [...data.loaded_namespaces], }; const stateKey = this.getSessionStateKey(state); @@ -135,10 +175,13 @@ export class LanguageService implements Disposable { return Promise.resolve(); } let sync = this.clientSessionSync.get(client); - const state = this.sessionStates.get(scope)?.state + const state = + this.sessionStates.get(scope)?.state ?? // Retain a disconnect clear on the client until it is acknowledged. - ?? (sync ? { attachedPackages: [], loadedNamespaces: [] } : undefined); - if (!state) { return Promise.resolve(); } + (sync ? { attachedPackages: [], loadedNamespaces: [] } : undefined); + if (!state) { + return Promise.resolve(); + } const stateKey = this.getSessionStateKey(state); if (!sync) { sync = { state, stateKey, replay: 0 }; @@ -147,32 +190,51 @@ export class LanguageService implements Disposable { sync.state = state; sync.stateKey = stateKey; } - if (force) { sync.replay++; } + if (force) { + sync.replay++; + } // Check in-flight work before acknowledged state: A -> B -> A must // restore A after B completes, even if A was previously acknowledged. - if (sync.pending) { return sync.pending; } + if (sync.pending) { + return sync.pending; + } if (sync.syncedStateKey === stateKey && sync.syncedReplay === sync.replay) { return Promise.resolve(); } const current = sync; - const pending: Promise = this.deliverSessionState(key, client, current).then(failed => { - if (current.pending === pending) { current.pending = undefined; } - // A refresh can arrive after the delivery loop finishes but before - // this promise settles. Reconcile it without retrying a failure loop. - if (!this.disposed && this.clients.get(key) === client - && (current.syncedStateKey !== current.stateKey || current.syncedReplay !== current.replay) - && (!failed || failed.stateKey !== current.stateKey || failed.replay !== current.replay)) { - return this.syncClientSessionState(key); - } - }); + const pending: Promise = this.deliverSessionState(key, client, current).then( + (failed) => { + if (current.pending === pending) { + current.pending = undefined; + } + // A refresh can arrive after the delivery loop finishes but before + // this promise settles. Reconcile it without retrying a failure loop. + if ( + !this.disposed && + this.clients.get(key) === client && + (current.syncedStateKey !== current.stateKey || + current.syncedReplay !== current.replay) && + (!failed || + failed.stateKey !== current.stateKey || + failed.replay !== current.replay) + ) { + return this.syncClientSessionState(key); + } + }, + ); current.pending = pending; return pending; } - private async deliverSessionState(key: string, client: LanguageClient, sync: ClientSessionSync): - Promise<{ stateKey: string; replay: number } | undefined> { + private async deliverSessionState( + key: string, + client: LanguageClient, + sync: ClientSessionSync, + ): Promise<{ stateKey: string; replay: number } | undefined> { while (!this.disposed && this.clients.get(key) === client) { - if (sync.syncedStateKey === sync.stateKey && sync.syncedReplay === sync.replay) { return; } + if (sync.syncedStateKey === sync.stateKey && sync.syncedReplay === sync.replay) { + return; + } const { state, stateKey, replay } = sync; try { await client.sendRequest('r/syncSessionState', state); @@ -182,16 +244,17 @@ export class LanguageService implements Disposable { // A failed response may still have changed the server. Reconcile // newer state, but leave an unchanged failure for the next event. sync.syncedStateKey = undefined; - if (sync.stateKey === stateKey && sync.replay === replay) { return { stateKey, replay }; } + if (sync.stateKey === stateKey && sync.replay === replay) { + return { stateKey, replay }; + } } } } private getSessionStateKey(state: SessionState): string { - return [ - state.attachedPackages.join('\u0000'), - state.loadedNamespaces.join('\u0000'), - ].join('\u0001'); + return [state.attachedPackages.join('\u0000'), state.loadedNamespaces.join('\u0000')].join( + '\u0001', + ); } private spawnServer( @@ -376,8 +439,15 @@ export class LanguageService implements Disposable { }, provideCompletionItem: async (document, position, context, token, next) => { await this.syncClientSessionState(key); - return completionMiddleware.provideCompletionItem?.(document, position, context, token, next) - ?? next(document, position, context, token); + return ( + completionMiddleware.provideCompletionItem?.( + document, + position, + context, + token, + next, + ) ?? next(document, position, context, token) + ); }, provideSignatureHelp: async (document, position, context, token, next) => { const result = await next(document, position, context, token); @@ -464,7 +534,11 @@ export class LanguageService implements Disposable { return client; } - private async registerClient(key: string, client: LanguageClient, sessionScope: string): Promise { + private async registerClient( + key: string, + client: LanguageClient, + sessionScope: string, + ): Promise { this.clients.set(key, client); this.clientScopes.set(key, sessionScope); await this.syncClientSessionState(key); @@ -529,7 +603,9 @@ export class LanguageService implements Disposable { if (document.uri.scheme === 'vscode-interactive-input') { const key = document.uri.toString(); - const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); + const scope = target + ? `session:${target.sessionId}` + : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { const selector = [ { @@ -556,7 +632,9 @@ export class LanguageService implements Disposable { // Each notebook uses a server started from parent folder if (document.uri.scheme === 'vscode-notebook-cell') { const key = this.getKey(document.uri); - const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); + const scope = target + ? `session:${target.sessionId}` + : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { console.log(`Start language server for ${document.uri.toString(true)}`); const documentSelector: DocumentFilter[] = [ diff --git a/src/session.ts b/src/session.ts index 2b2ae9310..28a20dc8b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3867,8 +3867,11 @@ async function handleNotification(message: Record, socket: IpcS }; session.sessionDir = params.tempdir; session.workingDir = params.wd; - session.resource = (native ? rTerminal.getTerminalResource(native.terminal) : undefined) - ?? (isLocalHost(host) ? vscode.workspace.getWorkspaceFolder(Uri.file(params.wd))?.uri : undefined); + session.resource = + (native ? rTerminal.getTerminalResource(native.terminal) : undefined) ?? + (isLocalHost(host) + ? vscode.workspace.getWorkspaceFolder(Uri.file(params.wd))?.uri + : undefined); const terminalPid = native && terminalRegistry.attachNative(native.terminal, session) diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index 0ad56b6c6..ceb3550f6 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -21,31 +21,60 @@ suite('Session completion with real R language server', () => { mockExtensionContext(path.join(__dirname, '..', '..', '..'), sandbox); type ServiceInternals = { startLanguageService(): Promise; - createClient(key: string, selector: vscode.DocumentFilter[], cwd: string, - workspaceFolder: vscode.WorkspaceFolder | undefined, outputChannel: vscode.LogOutputChannel, - resource?: vscode.Uri, sessionScope?: string): Promise; + createClient( + key: string, + selector: vscode.DocumentFilter[], + cwd: string, + workspaceFolder: vscode.WorkspaceFolder | undefined, + outputChannel: vscode.LogOutputChannel, + resource?: vscode.Uri, + sessionScope?: string, + ): Promise; syncClientSessionState(key: string): Promise; outputChannel: vscode.LogOutputChannel; }; - sandbox.stub(LanguageService.prototype as unknown as ServiceInternals, 'startLanguageService').resolves(); + sandbox + .stub( + LanguageService.prototype as unknown as ServiceInternals, + 'startLanguageService', + ) + .resolves(); sandbox.stub(util, 'getRpath').resolves(rPath); - const settings = { get: (key: string) => { - if (key === 'lsp.args') { return ['--vanilla']; } - if (key === 'lsp.lang') { return ''; } - return undefined; - } } as vscode.WorkspaceConfiguration; + const settings = { + get: (key: string) => { + if (key === 'lsp.args') { + return ['--vanilla']; + } + if (key === 'lsp.lang') { + return ''; + } + return undefined; + }, + } as vscode.WorkspaceConfiguration; const getConfiguration = vscode.workspace.getConfiguration; - sandbox.stub(vscode.workspace, 'getConfiguration').callsFake((section, resource) => - section === 'r' ? settings : getConfiguration(section, resource)); + sandbox + .stub(vscode.workspace, 'getConfiguration') + .callsFake((section, resource) => + section === 'r' ? settings : getConfiguration(section, resource), + ); service = new LanguageService(); const internals = service as unknown as ServiceInternals; - client = await internals.createClient('global', [{ scheme: 'file', language: 'r' }], - root, undefined, internals.outputChannel, undefined, 'global'); + client = await internals.createClient( + 'global', + [{ scheme: 'file', language: 'r' }], + root, + undefined, + internals.outputChannel, + undefined, + 'global', + ); const languageClient = client; const middleware = client.middleware.workspace?.didChangeWorkspaceFolders; assert.ok(middleware); - const protocolUri = (uri: vscode.Uri) => languageClient.code2ProtocolConverter.asUri(uri); - const documentUri = (folder: vscode.WorkspaceFolder) => protocolUri(vscode.Uri.joinPath(folder.uri, 'completion.R')); + const protocolUri = (uri: vscode.Uri) => + languageClient.code2ProtocolConverter.asUri(uri); + const documentUri = (folder: vscode.WorkspaceFolder) => + protocolUri(vscode.Uri.joinPath(folder.uri, 'completion.R')); const projects: vscode.WorkspaceFolder[] = []; for (const name of ['initial project', 'added project']) { @@ -54,46 +83,78 @@ suite('Session completion with real R language server', () => { await fs.writeFile(path.join(directory, 'completion.R'), 'bs'); projects.push({ uri: vscode.Uri.file(directory), name, index: projects.length }); } - const addFolder = (folder: vscode.WorkspaceFolder) => middleware({ added: [folder], removed: [] }, - event => languageClient.sendNotification('workspace/didChangeWorkspaceFolders', { - event: { - added: event.added.map(folder => ({ uri: protocolUri(folder.uri), name: folder.name })), - removed: [] - } - })); - const openDocument = (folder: vscode.WorkspaceFolder) => languageClient.sendNotification('textDocument/didOpen', { - textDocument: { - uri: documentUri(folder), - languageId: 'r', version: 1, text: 'bs' - } - }); + const addFolder = (folder: vscode.WorkspaceFolder) => + middleware({ added: [folder], removed: [] }, (event) => + languageClient.sendNotification('workspace/didChangeWorkspaceFolders', { + event: { + added: event.added.map((folder) => ({ + uri: protocolUri(folder.uri), + name: folder.name, + })), + removed: [], + }, + }), + ); + const openDocument = (folder: vscode.WorkspaceFolder) => + languageClient.sendNotification('textDocument/didOpen', { + textDocument: { + uri: documentUri(folder), + languageId: 'r', + version: 1, + text: 'bs', + }, + }); type Completion = { label: string; data?: { package?: string } }; const hasSplineCompletion = async (folder: vscode.WorkspaceFolder) => { - const response = await languageClient.sendRequest( - 'textDocument/completion', { - textDocument: { uri: documentUri(folder) }, - position: { line: 0, character: 2 } - }); - return (Array.isArray(response) ? response : response?.items ?? []) - .some(item => item.label === 'bs' && item.data?.package === 'splines'); + const response = await languageClient.sendRequest< + Completion[] | { items: Completion[] } | null + >('textDocument/completion', { + textDocument: { uri: documentUri(folder) }, + position: { line: 0, character: 2 }, + }); + return (Array.isArray(response) ? response : (response?.items ?? [])).some( + (item) => item.label === 'bs' && item.data?.package === 'splines', + ); }; await addFolder(projects[0]); await openDocument(projects[0]); - assert.strictEqual(await hasSplineCompletion(projects[0]), false, 'splines is not attached by default'); - service.syncSessionState({ search: ['package:splines', 'package:base'], loaded_namespaces: ['splines', 'base'] }, - projects[0].uri, 'session'); + assert.strictEqual( + await hasSplineCompletion(projects[0]), + false, + 'splines is not attached by default', + ); + service.syncSessionState( + { + search: ['package:splines', 'package:base'], + loaded_namespaces: ['splines', 'base'], + }, + projects[0].uri, + 'session', + ); await internals.syncClientSessionState('global'); - assert.strictEqual(await hasSplineCompletion(projects[0]), true, 'initial workspace receives session packages'); + assert.strictEqual( + await hasSplineCompletion(projects[0]), + true, + 'initial workspace receives session packages', + ); await addFolder(projects[1]); await openDocument(projects[1]); - assert.strictEqual(await hasSplineCompletion(projects[1]), true, 'new workspace receives replayed session packages'); + assert.strictEqual( + await hasSplineCompletion(projects[1]), + true, + 'new workspace receives replayed session packages', + ); service.syncSessionState(undefined, projects[0].uri, 'session'); await internals.syncClientSessionState('global'); for (const project of projects) { - assert.strictEqual(await hasSplineCompletion(project), false, 'disconnect removes session-only completions'); + assert.strictEqual( + await hasSplineCompletion(project), + false, + 'disconnect removes session-only completions', + ); } } finally { try { @@ -103,7 +164,12 @@ suite('Session completion with real R language server', () => { sandbox.restore(); // TCP shutdown can finish before R releases its working directory // on Windows. Retry transient locks instead of failing cleanup. - await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + await fs.rm(root, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); } } }).timeout(60000); diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 6bc61aa8b..7559d4b82 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -1685,17 +1685,30 @@ function treeTooltip(item: vscode.TreeItem): string { }); test('reopening a native input restores R language after its document is recreated', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); - const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); + const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebook.uri, + ); const input = await vscode.workspace.openTextDocument(result.inputUri); await vscode.languages.setTextDocumentLanguage(input, 'plaintext'); try { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); const reopened = await vscode.workspace.openTextDocument(result.inputUri); - assert.strictEqual(reopened.languageId, 'r', 'Reopening must restore the R input language'); + assert.strictEqual( + reopened.languageId, + 'r', + 'Reopening must restore the R input language', + ); } finally { - await vscode.languages.setTextDocumentLanguage(await vscode.workspace.openTextDocument(result.inputUri), 'r'); + await vscode.languages.setTextDocumentLanguage( + await vscode.workspace.openTextDocument(result.inputUri), + 'r', + ); } }); test('starts each Interactive input language server in its owning session directory', async () => { @@ -1713,21 +1726,30 @@ function treeTooltip(item: vscode.TreeItem): string { notebook.uri, ); const input = await vscode.workspace.openTextDocument(result.inputUri); - assert.strictEqual(input.languageId, 'r', 'An owning session must restore its input language'); - await until(() => { - const client = service.clients.get(result.inputUri.toString()); - return ( - client?.isRunning() === true && - client.clientOptions.workspaceFolder?.uri.fsPath === manifest.directory - ); - }, () => { - const client = service.clients.get(result.inputUri.toString()); - return `Interactive input language server ${result.inputUri.toString()}: ${JSON.stringify({ - running: client?.isRunning() ?? false, - expectedDirectory: manifest.directory, - actualDirectory: client?.clientOptions.workspaceFolder?.uri.fsPath, - })}`; - }); + assert.strictEqual( + input.languageId, + 'r', + 'An owning session must restore its input language', + ); + await until( + () => { + const client = service.clients.get(result.inputUri.toString()); + return ( + client?.isRunning() === true && + client.clientOptions.workspaceFolder?.uri.fsPath === manifest.directory + ); + }, + () => { + const client = service.clients.get(result.inputUri.toString()); + return `Interactive input language server ${result.inputUri.toString()}: ${JSON.stringify( + { + running: client?.isRunning() ?? false, + expectedDirectory: manifest.directory, + actualDirectory: client?.clientOptions.workspaceFolder?.uri.fsPath, + }, + )}`; + }, + ); } } finally { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index bf5ce31ba..725adb32e 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -14,16 +14,34 @@ suite('Session package completion', () => { let sandbox: sinon.SinonSandbox; let service: LanguageService; let multiServer: boolean; - const folder = { uri: vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-language-service', 'project')), name: 'project', index: 0 }; - const packages = (name: string) => ({ search: ['.GlobalEnv', `package:${name}`, 'package:base'], loaded_namespaces: [name, 'base'], globalenv: {} }); + const folder = { + uri: vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-language-service', 'project')), + name: 'project', + index: 0, + }; + const packages = (name: string) => ({ + search: ['.GlobalEnv', `package:${name}`, 'package:base'], + loaded_namespaces: [name, 'base'], + globalenv: {}, + }); // Keep the tests independent of installed R packages and server processes. type ServiceInternals = { startLanguageService(): Promise; - createClient(key: string, selector: vscode.DocumentFilter[], cwd: string, - workspaceFolder: vscode.WorkspaceFolder | undefined, outputChannel: vscode.LogOutputChannel, - resource?: vscode.Uri, sessionScope?: string): Promise; - registerClient(key: string, client: { sendRequest: sinon.SinonStub; stop: sinon.SinonStub }, scope: string): Promise; + createClient( + key: string, + selector: vscode.DocumentFilter[], + cwd: string, + workspaceFolder: vscode.WorkspaceFolder | undefined, + outputChannel: vscode.LogOutputChannel, + resource?: vscode.Uri, + sessionScope?: string, + ): Promise; + registerClient( + key: string, + client: { sendRequest: sinon.SinonStub; stop: sinon.SinonStub }, + scope: string, + ): Promise; syncClientSessionState(key: string, force?: boolean): Promise; clients: Map; clientScopes: Map; @@ -37,11 +55,14 @@ suite('Session package completion', () => { return sendRequest; } - const settle = () => new Promise(resolve => setImmediate(resolve)); + const settle = () => new Promise((resolve) => setImmediate(resolve)); function deferred() { let resolve!: (value: boolean) => void; let reject!: (error: Error) => void; - const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); return { promise, resolve, reject }; } @@ -49,14 +70,20 @@ suite('Session package completion', () => { sandbox = sinon.createSandbox(); mockExtensionContext(path.join(__dirname, '..', '..', '..'), sandbox); multiServer = false; - sandbox.stub(LanguageService.prototype as unknown as ServiceInternals, 'startLanguageService').resolves(); + sandbox + .stub(LanguageService.prototype as unknown as ServiceInternals, 'startLanguageService') + .resolves(); sandbox.stub(vscode.workspace, 'getConfiguration').returns({ - get: (key: string) => key === 'lsp.multiServer' ? multiServer : undefined + get: (key: string) => (key === 'lsp.multiServer' ? multiServer : undefined), } as vscode.WorkspaceConfiguration); - sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => - uri.fsPath === folder.uri.fsPath || uri.fsPath.startsWith(`${folder.uri.fsPath}${path.sep}`) - ? folder - : undefined); + sandbox + .stub(vscode.workspace, 'getWorkspaceFolder') + .callsFake((uri) => + uri.fsPath === folder.uri.fsPath || + uri.fsPath.startsWith(`${folder.uri.fsPath}${path.sep}`) + ? folder + : undefined, + ); service = new LanguageService(); sandbox.stub(extension, 'rLanguageService').value(service); }); @@ -72,43 +99,59 @@ suite('Session package completion', () => { service.syncSessionState(packages('stats'), folder.uri, 'first'); await settle(); sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { - attachedPackages: ['stats', 'base'], loadedNamespaces: ['stats', 'base'] + attachedPackages: ['stats', 'base'], + loadedNamespaces: ['stats', 'base'], }); service.syncSessionState(packages('utils'), undefined, 'second'); service.syncSessionState(undefined, folder.uri, 'first'); await settle(); - assert.strictEqual(request.callCount, 2, 'closing an older session must not clear the active packages'); + assert.strictEqual( + request.callCount, + 2, + 'closing an older session must not clear the active packages', + ); service.syncSessionState(undefined, undefined, 'second'); await settle(); - sinon.assert.calledWithExactly(request, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: [], + loadedNamespaces: [], + }); }); test('client receives session updates while initial synchronization is pending', async () => { let resolveInitial!: () => void; - const initial = new Promise(resolve => { resolveInitial = resolve; }); + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); const request = sandbox.stub(); request.onFirstCall().returns(initial); request.resolves(true); const fakeClient = { sendRequest: request, stop: sandbox.stub().resolves() }; const internals = service as unknown as ServiceInternals; - service.syncSessionState({ - search: ['.GlobalEnv', 'package:base'], - loaded_namespaces: ['base'], - }, folder.uri, 'session'); + service.syncSessionState( + { + search: ['.GlobalEnv', 'package:base'], + loaded_namespaces: ['base'], + }, + folder.uri, + 'session', + ); const registering = internals.registerClient('global', fakeClient, 'global'); await Promise.resolve(); service.syncSessionState(packages('dplyr'), folder.uri, 'session'); sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { - attachedPackages: ['base'], loadedNamespaces: ['base'] + attachedPackages: ['base'], + loadedNamespaces: ['base'], }); resolveInitial(); await registering; sinon.assert.calledWithExactly(request, 'r/syncSessionState', { - attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + attachedPackages: ['dplyr', 'base'], + loadedNamespaces: ['dplyr', 'base'], }); sinon.assert.calledTwice(request); }); @@ -119,8 +162,13 @@ suite('Session package completion', () => { const request = sandbox.stub(LanguageClient.prototype, 'sendRequest').resolves(true); const internals = service as unknown as ServiceInternals; const client = await internals.createClient( - 'global', [{ scheme: 'file', language: 'r' }], folder.uri.fsPath, - undefined, internals.outputChannel, folder.uri, 'global' + 'global', + [{ scheme: 'file', language: 'r' }], + folder.uri.fsPath, + undefined, + internals.outputChannel, + folder.uri, + 'global', ); service.syncSessionState(packages('dplyr'), folder.uri, 'session'); @@ -135,7 +183,8 @@ suite('Session package completion', () => { sinon.assert.calledOnceWithExactly(next, event); sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { - attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + attachedPackages: ['dplyr', 'base'], + loadedNamespaces: ['dplyr', 'base'], }); sinon.assert.callOrder(next, request); }); @@ -149,8 +198,13 @@ suite('Session package completion', () => { request.onSecondCall().returns(retry.promise); const internals = service as unknown as ServiceInternals; const client = await internals.createClient( - 'global', [{ scheme: 'file', language: 'r' }], folder.uri.fsPath, - undefined, internals.outputChannel, folder.uri, 'global' + 'global', + [{ scheme: 'file', language: 'r' }], + folder.uri.fsPath, + undefined, + internals.outputChannel, + folder.uri, + 'global', ); service.syncSessionState(packages('dplyr'), folder.uri, 'session'); await settle(); @@ -160,7 +214,10 @@ suite('Session package completion', () => { assert.ok(middleware); const document = { uri: folder.uri } as vscode.TextDocument; const position = new vscode.Position(0, 0); - const context = { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: undefined }; + const context = { + triggerKind: vscode.CompletionTriggerKind.Invoke, + triggerCharacter: undefined, + }; const next = sandbox.stub().resolves([]); const cancellation = new vscode.CancellationTokenSource(); try { @@ -170,7 +227,13 @@ suite('Session package completion', () => { sinon.assert.notCalled(next); retry.resolve(true); await completing; - sinon.assert.calledOnceWithExactly(next, document, position, context, cancellation.token); + sinon.assert.calledOnceWithExactly( + next, + document, + position, + context, + cancellation.token, + ); await middleware(document, position, context, cancellation.token, next); sinon.assert.calledTwice(request); } finally { @@ -194,11 +257,93 @@ suite('Session package completion', () => { sinon.assert.calledOnce(externalFile); }); + test('an unselected session refreshes its owned workspace without replacing another owner', async () => { + multiServer = true; + const secondFolder = { + uri: vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-language-service', 'other')), + name: 'other', + index: 1, + }; + (vscode.workspace.getWorkspaceFolder as sinon.SinonStub).callsFake((uri: vscode.Uri) => + uri.fsPath === folder.uri.fsPath || + uri.fsPath.startsWith(`${folder.uri.fsPath}${path.sep}`) + ? folder + : uri.fsPath === secondFolder.uri.fsPath || + uri.fsPath.startsWith(`${secondFolder.uri.fsPath}${path.sep}`) + ? secondFolder + : undefined, + ); + const first = client('first', folder.uri.toString(true)); + const second = client('second', secondFolder.uri.toString(true)); + service.syncSessionState(packages('stats'), folder.uri, 'first-session'); + service.syncSessionState(packages('utils'), secondFolder.uri, 'second-session'); + await settle(); + + service.syncSessionState(packages('dplyr'), folder.uri, 'first-session', false); + await settle(); + sinon.assert.calledTwice(first); + sinon.assert.calledOnce(second); + assert.deepStrictEqual(first.lastCall.args[1], { + attachedPackages: ['dplyr', 'base'], + loadedNamespaces: ['dplyr', 'base'], + }); + + // A third session must not overwrite the recorded workspace owner. + service.syncSessionState(packages('splines'), folder.uri, 'third-session', false); + await settle(); + sinon.assert.calledTwice(first); + + // Once another session is selected in the same workspace, the old + // session must not overwrite that new owner on a subsequent refresh. + service.syncSessionState(packages('tools'), folder.uri, 'third-session'); + await settle(); + service.syncSessionState(packages('stats'), folder.uri, 'first-session', false); + await settle(); + assert.deepStrictEqual(first.lastCall.args[1], { + attachedPackages: ['tools', 'base'], + loadedNamespaces: ['tools', 'base'], + }); + sinon.assert.calledOnce(second); + }); + + test('disconnect clears the recorded workspace after its folder is removed', async () => { + multiServer = true; + const originalScope = folder.uri.toString(true); + const project = client('project', originalScope); + const unscoped = client('unscoped', 'unscoped'); + service.syncSessionState(packages('dplyr'), folder.uri, 'removed-session'); + await settle(); + sinon.assert.calledOnce(project); + + // VS Code no longer recognizes the folder while the R session is + // still running. Cleanup must not be redirected into unscoped. + (vscode.workspace.getWorkspaceFolder as sinon.SinonStub).returns(undefined); + service.syncSessionState(undefined, folder.uri, 'removed-session'); + await settle(); + sinon.assert.calledTwice(project); + sinon.assert.notCalled(unscoped); + assert.deepStrictEqual(project.lastCall.args[1], { + attachedPackages: [], + loadedNamespaces: [], + }); + + // Reopening the folder must not replay packages from the old session. + (vscode.workspace.getWorkspaceFolder as sinon.SinonStub).returns(folder); + const replacement = client('reopened', originalScope); + await (service as unknown as ServiceInternals).syncClientSessionState('reopened'); + sinon.assert.notCalled(replacement); + }); + test('inactive Interactive workspace updates reach only its bound language servers', async () => { const global = client('global', 'global'); const input = client('input', 'session:interactive-packages'); const notebook = client('notebook', 'session:interactive-packages'); - const target = session.registerSessionTransport('interactive-packages', 'host', folder.uri.fsPath, () => Promise.resolve(undefined)); + const target = session.registerSessionTransport( + 'interactive-packages', + 'host', + folder.uri.fsPath, + () => Promise.resolve(undefined), + ); try { assert.strictEqual(target.resource?.toString(), folder.uri.toString()); session.updateSessionWorkspace(target, packages('stats')); @@ -212,8 +357,14 @@ suite('Session package completion', () => { sinon.assert.calledOnce(notebook); session.unregisterSessionTransport(target); await settle(); - sinon.assert.calledWithExactly(input, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); - sinon.assert.calledWithExactly(notebook, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + sinon.assert.calledWithExactly(input, 'r/syncSessionState', { + attachedPackages: [], + loadedNamespaces: [], + }); + sinon.assert.calledWithExactly(notebook, 'r/syncSessionState', { + attachedPackages: [], + loadedNamespaces: [], + }); sinon.assert.calledOnce(global); } finally { session.unregisterSessionTransport(target); @@ -248,9 +399,13 @@ suite('Session package completion', () => { initial.resolve(true); await settle(); sinon.assert.calledTwice(request); - assert.deepStrictEqual(request.lastCall.args, ['r/syncSessionState', { - attachedPackages: [], loadedNamespaces: [] - }]); + assert.deepStrictEqual(request.lastCall.args, [ + 'r/syncSessionState', + { + attachedPackages: [], + loadedNamespaces: [], + }, + ]); }); test('failed disconnect clear is retained for the next synchronization attempt', async () => { @@ -263,9 +418,13 @@ suite('Session package completion', () => { sinon.assert.calledTwice(request); await (service as unknown as ServiceInternals).syncClientSessionState('global'); sinon.assert.calledThrice(request); - assert.deepStrictEqual(request.lastCall.args, ['r/syncSessionState', { - attachedPackages: [], loadedNamespaces: [] - }]); + assert.deepStrictEqual(request.lastCall.args, [ + 'r/syncSessionState', + { + attachedPackages: [], + loadedNamespaces: [], + }, + ]); }); test('failed synchronization still delivers newer pending state', async () => { @@ -278,7 +437,8 @@ suite('Session package completion', () => { await settle(); sinon.assert.calledTwice(request); assert.deepStrictEqual(request.lastCall.args[1], { - attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + attachedPackages: ['dplyr', 'base'], + loadedNamespaces: ['dplyr', 'base'], }); }); @@ -294,7 +454,8 @@ suite('Session package completion', () => { await settle(); sinon.assert.calledThrice(request); assert.deepStrictEqual(request.lastCall.args[1], { - attachedPackages: ['stats', 'base'], loadedNamespaces: ['stats', 'base'] + attachedPackages: ['stats', 'base'], + loadedNamespaces: ['stats', 'base'], }); }); @@ -303,12 +464,15 @@ suite('Session package completion', () => { const request = client('global', 'global'); request.onFirstCall().returns(initial.promise); service.syncSessionState(packages('stats'), folder.uri, 'session'); - void initial.promise.then(() => service.syncSessionState(packages('dplyr'), folder.uri, 'session')); + void initial.promise.then(() => + service.syncSessionState(packages('dplyr'), folder.uri, 'session'), + ); initial.resolve(true); await settle(); sinon.assert.calledTwice(request); assert.deepStrictEqual(request.lastCall.args[1], { - attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + attachedPackages: ['dplyr', 'base'], + loadedNamespaces: ['dplyr', 'base'], }); }); @@ -324,7 +488,8 @@ suite('Session package completion', () => { sinon.assert.calledOnce(old); sinon.assert.calledOnce(replacement); assert.deepStrictEqual(replacement.firstCall.args[1], { - attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + attachedPackages: ['dplyr', 'base'], + loadedNamespaces: ['dplyr', 'base'], }); }); @@ -333,7 +498,10 @@ suite('Session package completion', () => { const request = client('global', 'global'); request.onFirstCall().returns(initial.promise); service.syncSessionState(packages('dplyr'), folder.uri, 'session'); - const replay = (service as unknown as ServiceInternals).syncClientSessionState('global', true); + const replay = (service as unknown as ServiceInternals).syncClientSessionState( + 'global', + true, + ); initial.resolve(true); await replay; sinon.assert.calledTwice(request); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 8a1a417c2..6dfb18640 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -910,10 +910,14 @@ suite('Session Communication', () => { const endpoint = await session.getGlobalPipePath(); const workspaceUri = vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-session-workspace')); const workspaceFolder = { uri: workspaceUri, name: 'session-workspace', index: 0 }; - sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => - uri.fsPath === workspaceUri.fsPath || uri.fsPath.startsWith(`${workspaceUri.fsPath}${path.sep}`) - ? workspaceFolder - : undefined); + sandbox + .stub(vscode.workspace, 'getWorkspaceFolder') + .callsFake((uri) => + uri.fsPath === workspaceUri.fsPath || + uri.fsPath.startsWith(`${workspaceUri.fsPath}${path.sep}`) + ? workspaceFolder + : undefined, + ); const terminal = { processId: Promise.resolve(41000) } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); @@ -925,17 +929,30 @@ suite('Session Communication', () => { client.once('connect', resolve); client.once('error', reject); }); - const socket = await waitFor(() => [...session.activeConnections].find(candidate => !existing.has(candidate))); + const socket = await waitFor(() => + [...session.activeConnections].find((candidate) => !existing.has(candidate)), + ); assert.ok(socket); - client.write(`${JSON.stringify({ - jsonrpc: '2.0', method: 'attach', params: { - protocol_version: 2, session_id: 'workspace-fallback', host: os.hostname(), - pid: 41001, version: '4.4.0', tempdir: os.tmpdir(), - wd: path.join(workspaceUri.fsPath, 'project'), - }, - })}\n`); - const attached = await waitFor(() => session.activeSession?.sessionId === 'workspace-fallback' - ? session.activeSession : undefined); + client.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: 'workspace-fallback', + host: os.hostname(), + pid: 41001, + version: '4.4.0', + tempdir: os.tmpdir(), + wd: path.join(workspaceUri.fsPath, 'project'), + }, + })}\n`, + ); + const attached = await waitFor(() => + session.activeSession?.sessionId === 'workspace-fallback' + ? session.activeSession + : undefined, + ); assert.ok(attached); assert.strictEqual(attached.resource?.toString(), workspaceUri.toString()); assert.strictEqual(socket._terminalPid, undefined); From 4bb69c956c2716810a19d8a89340cedc9d1edac6 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:09:58 +0900 Subject: [PATCH 16/23] fix: synchronize session package completion across library and runtime lifecycles Use actual R session paths when routing workspaces, reconcile ownership as folders change, and refresh background owners. Clear exited and replaced process state, rebind retained Interactive clients, and propagate process-specific package library paths. Prepare member and S3 metadata in languageserver workers and fall back cleanly when its integration internals are unavailable. Cover scope changes, stop/restart/reconnect, custom libraries, background refresh, and provider resolution. --- R/languageServer.R | 158 ++++++++++++++++-- sess/R/handlers.R | 10 +- src/interactive/manager.ts | 3 + src/languageService.ts | 123 +++++++++++--- src/rTerminal.ts | 19 +++ src/session.ts | 45 +++-- .../integration/sessionCompletion.test.ts | 90 +++++++++- src/test/suite/languageService.test.ts | 115 +++++++++++++ src/test/suite/session.test.ts | 48 +++++- .../suite/sessionWorkspaceRefresh.test.ts | 42 +++++ 10 files changed, 598 insertions(+), 55 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index 5499a9abe..a5db26005 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -60,26 +60,156 @@ normalize_character <- function(value) { unique(value[nzchar(value)]) } +# This is intentionally an optional integration. Older languageserver +# installations still provide ordinary static LSP features; don't mutate +# unsupported private structures or leave attached packages behind on reset. +namespace <- asNamespace("languageserver") +startup_fn <- get0("workspace_startup_packages", namespace, inherits = FALSE) server <- languageserver:::LanguageServer$new("localhost", port) +default_library_paths <- .libPaths() +default_startup <- if (is.function(startup_fn)) startup_fn() else character() +# Keep the project NAMESPACE imports separate from the temporary packages +# attached in the interactive R process; S3 resolution consults imports as +# well as the workspace startup list. +session_imports <- list() +previous_session_packages <- character() + server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { - attached_packages <- normalize_character(params$attachedPackages) - loaded_namespaces <- normalize_character(params$loadedNamespaces) - - for (workspace in self$workspaces$values()) { - workspace$startup_packages <- if (length(attached_packages)) { - # languageserver resolves package conflicts from the end of this list. - rev(attached_packages) - } else { - languageserver:::workspace_startup_packages() + reply <- function(applied) { + self$deliver(languageserver:::Response$new(id, result = applied)) + } + if (!is.function(startup_fn) || is.null(tryCatch(self$workspaces, error = function(e) NULL))) { + # Versions before the workspace startup API cannot safely reset state. + reply(FALSE) + return(invisible(NULL)) + } + + tryCatch({ + attached_packages <- normalize_character(params$attachedPackages) + loaded_namespaces <- normalize_character(params$loadedNamespaces) + library_paths <- normalize_character(params$libraryPaths) + locations <- params$packagePaths + if (!is.list(locations)) locations <- list() + package_paths <- unlist(locations, use.names = TRUE) + if (!is.character(package_paths)) package_paths <- character() + package_paths <- package_paths[nzchar(package_paths)] + extra_paths <- dirname(package_paths) + candidate_paths <- unique(c(extra_paths, library_paths, default_library_paths)) + candidate_paths <- candidate_paths[dir.exists(candidate_paths)] + .libPaths(candidate_paths) + + # A server can outlive an R session. Removing the previous package + # metadata avoids stale members and S3 signatures after detach. + for (workspace in self$workspaces$values()) { + index <- which(vapply(session_imports, + function(entry) identical(entry$workspace, workspace), logical(1L))) + if (!length(index)) { + base_imports <- workspace$imported_packages + session_imports[[length(session_imports) + 1L]] <- + list(workspace = workspace, imports = base_imports) + } else { + base_imports <- session_imports[[index[[1L]]]]$imports + actual <- workspace$imported_packages + if (!setequal(actual, union(base_imports, previous_session_packages))) { + base_imports <- union(setdiff(actual, previous_session_packages), + intersect(base_imports, previous_session_packages)) + session_imports[[index[[1L]]]]$imports <- base_imports + } + } + previous <- workspace$startup_packages + new_startup <- if (length(attached_packages)) rev(attached_packages) else default_startup + removed <- setdiff(previous, unique(c(new_startup, workspace$imported_packages))) + workspace$startup_packages <- new_startup + workspace$imported_packages <- union(base_imports, attached_packages) + workspace$update_loaded_packages() + for (pkg in unique(c(attached_packages, loaded_namespaces))) { + try(workspace$get_namespace(pkg), silent = TRUE) + } + if (length(removed) && !is.null(workspace$member_metadata)) { + for (pkg in removed) { + if (is.function(workspace$member_metadata$remove)) { + workspace$member_metadata$remove(pkg) + } + } + } + if (length(removed) && !is.null(workspace$s3_metadata) && + is.function(workspace$s3_metadata$clear)) { + keep <- setdiff(workspace$s3_metadata$keys(), removed) + snapshots <- lapply(keep, workspace$s3_metadata$get) + names(snapshots) <- keep + workspace$s3_metadata$clear() + for (pkg in names(snapshots)) { + workspace$s3_metadata$set(pkg, snapshots[[pkg]]) + } + } } - workspace$update_loaded_packages() - for (pkg in unique(c(attached_packages, loaded_namespaces))) { - try(workspace$get_namespace(pkg), silent = TRUE) + previous_session_packages <- attached_packages + missing <- attached_packages[!vapply(attached_packages, + function(pkg) length(find.package(pkg, quiet = TRUE)) > 0L, logical(1L))] + if (length(missing)) { + message("Session packages cannot be resolved by languageserver: ", + paste(missing, collapse = ", ")) + reply(FALSE) + return(invisible(NULL)) } - } - self$deliver(languageserver:::Response$new(id, result = TRUE)) + # Prepare the independent member and S3 metadata providers in a worker. + # Only prepare on state updates, never in ordinary completion requests. + # Keep the request pending until metadata is available to the providers. + prepare <- get0("member_resolve_packages", namespace, inherits = FALSE) + if (!length(attached_packages) || !is.function(prepare) || + is.null(self$resolve_task_manager)) { + reply(TRUE) + return(invisible(NULL)) + } + create_task <- get0("create_task", namespace, inherits = FALSE) + package_call <- get0("package_call", namespace, inherits = FALSE) + if (!is.function(create_task) || !is.function(package_call)) { + reply(FALSE) + return(invisible(NULL)) + } + task <- create_task( + target = package_call(prepare), + args = list( + pkgs = attached_packages, + lib_paths = .libPaths(), + namespace_packages = character(), + prepare = isTRUE(languageserver:::lsp_settings$get("member_completion")), + prepare_s3 = isTRUE(languageserver:::lsp_settings$get("s3_dispatch")) + ), + callback = function(result) { + tryCatch({ + for (workspace in self$workspaces$values()) { + if (!is.null(result$members) && !is.null(workspace$member_metadata)) { + for (pkg in names(result$members)) { + workspace$member_metadata$set(pkg, result$members[[pkg]]) + } + } + if (!is.null(result$s3) && !is.null(workspace$s3_metadata)) { + for (pkg in names(result$s3)) { + workspace$s3_metadata$set(pkg, result$s3[[pkg]]) + } + } + } + reply(TRUE) + }, error = function(e) { + message("Session package metadata could not be applied: ", conditionMessage(e)) + reply(FALSE) + }) + }, + error = function(e) { + message("Session package metadata could not be prepared: ", conditionMessage(e)) + reply(FALSE) + }, + profiles = FALSE + ) + self$resolve_task_manager$add_task("r/syncSessionState", task) + }, error = function(e) { + message("Session package synchronization failed: ", conditionMessage(e)) + reply(FALSE) + }) + invisible(NULL) } server$run() diff --git a/sess/R/handlers.R b/sess/R/handlers.R index 4f3f615f0..fbb311856 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -135,7 +135,15 @@ get_workspace_data <- function(revision = NULL) { snapshot <- list( globalenv = objs, search = search()[-1], - loaded_namespaces = loadedNamespaces() + loaded_namespaces = loadedNamespaces(), + library_paths = .libPaths(), + package_paths = { + attached <- sub("^package:", "", grep("^package:", search(), value = TRUE)) + paths <- vapply(attached, function(package) { + tryCatch(getNamespaceInfo(asNamespace(package), "path"), error = function(e) "") + }, character(1L)) + as.list(paths[nzchar(paths)]) + } ) # Compare metadata after inspecting every binding: reference objects can mutate # in place, and R task callbacks do not cover every embedding's evaluation. diff --git a/src/interactive/manager.ts b/src/interactive/manager.ts index 45be6ce92..28e7e7416 100644 --- a/src/interactive/manager.ts +++ b/src/interactive/manager.ts @@ -3302,6 +3302,9 @@ export class InteractiveManager if (snapshot.workspace) { target.workspaceData = snapshot.workspace as unknown as session.WorkspaceData; } + // Restarts can finish in an unselected window. Publish the + // replacement process's packages even without activating it. + session.updateSessionWorkspace(target, target.workspaceData); view.base = snapshot.manifest.assetBase ? ( await vscode.env.asExternalUri(vscode.Uri.parse(snapshot.manifest.assetBase)) diff --git a/src/languageService.ts b/src/languageService.ts index 4c788f619..56ceeecbd 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -42,11 +42,15 @@ import { completionMiddleware } from './completionMiddleware'; interface SessionState { attachedPackages: string[]; loadedNamespaces: string[]; + libraryPaths?: string[]; + packagePaths?: Record; } interface SessionWorkspaceData { search: string[]; loaded_namespaces: string[]; + library_paths?: string[]; + package_paths?: Record; } interface ClientSessionSync { @@ -73,11 +77,14 @@ export class LanguageService implements Disposable { { state: SessionState; stateKey: string; sessionId: string } > = new Map(); private readonly sessionScopes = new Map(); + private readonly sessionResources = new Map(); + private activeSessionId = ''; private readonly clientSessionSync = new WeakMap(); constructor() { this.outputChannel = window.createOutputChannel('R Language Server', { log: true }); this.config = workspace.getConfiguration('r'); + this.listeners.push(workspace.onDidChangeWorkspaceFolders(() => this.reconcileSessionScopes())); void this.startLanguageService(); } @@ -89,52 +96,108 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } + /** The workspace that a live session owns is independent of editor focus. */ + ownsWorkspaceScope(sessionId: string): boolean { + const scope = this.sessionScopes.get(sessionId); + return scope !== undefined && this.sessionStates.get(scope)?.sessionId === sessionId; + } + syncSessionState( data?: SessionWorkspaceData, resource?: Uri, sessionId = '', active = true, + libraryPaths?: string[], ): void { - // Virtual documents always follow their bound session. + // Keep bound Interactive clients isolated from editor-focused sessions. if (sessionId) { - this.syncSessionScope(`session:${sessionId}`, data, sessionId); + this.syncSessionScope(`session:${sessionId}`, data, sessionId, libraryPaths); } - // Store the scope when the session becomes its owner. Workspace folders - // may be removed before disconnect, so cleanup must not reclassify it. const ownedScope = sessionId ? this.sessionScopes.get(sessionId) : undefined; if (!data) { if (sessionId) { this.sessionScopes.delete(sessionId); + this.sessionResources.delete(sessionId); + if (this.activeSessionId === sessionId) { + this.activeSessionId = ''; + } } - this.syncSessionScope( - ownedScope ?? this.getSessionScope(resource), - undefined, - sessionId, - ); + this.syncSessionScope(ownedScope ?? this.getSessionScope(resource), undefined, sessionId); return; } - + if (sessionId) { + this.sessionResources.set(sessionId, resource); + } if (active) { - const scope = this.getSessionScope(resource); + this.activeSessionId = sessionId; + } + const scope = this.getSessionScope(resource); + const current = this.sessionStates.get(scope); + if (active || ownedScope === scope && current?.sessionId === sessionId || + // An unselected session can own a distinct workspace when it has + // no existing owner. Never claim the shared unscoped/global scope + // merely because an Interactive view publishes a snapshot. + (scope !== 'unscoped' && scope !== 'global' && !current)) { if (ownedScope && ownedScope !== scope) { this.syncSessionScope(ownedScope, undefined, sessionId); } if (sessionId) { this.sessionScopes.set(sessionId, scope); } - this.syncSessionScope(scope, data, sessionId); + this.syncSessionScope(scope, data, sessionId, libraryPaths); } else if (ownedScope && this.sessionStates.get(ownedScope)?.sessionId === sessionId) { - // An unselected session may refresh its own workspace, but never - // replace the state now owned by another session. - this.syncSessionScope(ownedScope, data, sessionId); + // Preserve updates for an existing owner until workspace membership + // changes, even if another workspace's session is selected. + this.syncSessionScope(ownedScope, data, sessionId, libraryPaths); + } + } + + private reconcileSessionScopes(): void { + if (this.config.get('lsp.multiServer') !== true) { + return; + } + // Workspace membership can change without a new R snapshot. Reuse the + // session-specific state and the original directory, not a stale folder + // association captured during attach. + for (const [sessionId, resource] of this.sessionResources) { + const state = this.sessionStates.get(`session:${sessionId}`)?.state; + if (!state) { + continue; + } + const previous = this.sessionScopes.get(sessionId); + const scope = this.getSessionScope(resource); + if (previous === scope) { + continue; + } + const owned = previous && this.sessionStates.get(previous)?.sessionId === sessionId; + if (owned) { + this.syncSessionScope(previous, undefined, sessionId); + this.sessionScopes.delete(sessionId); + } + const current = this.sessionStates.get(scope); + if (!current && scope !== 'unscoped' || + owned && (!current || current.sessionId === sessionId || this.activeSessionId === sessionId)) { + this.sessionScopes.set(sessionId, scope); + this.setSessionScopeState(scope, state, sessionId); + } } } + private setSessionScopeState(scope: string, state: SessionState, sessionId: string): void { + const stateKey = this.getSessionStateKey(state); + const current = this.sessionStates.get(scope); + if (current?.stateKey !== stateKey || current.sessionId !== sessionId) { + this.sessionStates.set(scope, { state, stateKey, sessionId }); + } + this.applySessionStateToScope(scope); + } + private syncSessionScope( scope: string, data: SessionWorkspaceData | undefined, sessionId: string, + libraryPaths?: string[], ): void { const current = this.sessionStates.get(scope); if (!data) { @@ -152,12 +215,14 @@ export class LanguageService implements Disposable { .map((value) => value.substring(8)), loadedNamespaces: [...data.loaded_namespaces], }; - const stateKey = this.getSessionStateKey(state); - if (current?.stateKey !== stateKey || current.sessionId !== sessionId) { - this.sessionStates.set(scope, { state, stateKey, sessionId }); + const paths = data.library_paths ?? libraryPaths; + if (paths?.length) { + state.libraryPaths = [...paths]; } - // An unchanged refresh can retry delivery to a client that failed. - this.applySessionStateToScope(scope); + if (data.package_paths && Object.keys(data.package_paths).length) { + state.packagePaths = { ...data.package_paths }; + } + this.setSessionScopeState(scope, state, sessionId); } private applySessionStateToScope(scope: string): void { @@ -237,7 +302,12 @@ export class LanguageService implements Disposable { } const { state, stateKey, replay } = sync; try { - await client.sendRequest('r/syncSessionState', state); + const applied = await client.sendRequest('r/syncSessionState', state); + if (applied === false) { + this.outputChannel.appendLine( + 'Session package completion is unavailable with this languageserver version.', + ); + } sync.syncedStateKey = stateKey; sync.syncedReplay = replay; } catch { @@ -252,9 +322,9 @@ export class LanguageService implements Disposable { } private getSessionStateKey(state: SessionState): string { - return [state.attachedPackages.join('\u0000'), state.loadedNamespaces.join('\u0000')].join( - '\u0001', - ); + return JSON.stringify([ + state.attachedPackages, state.loadedNamespaces, state.libraryPaths, state.packagePaths, + ]); } private spawnServer( @@ -527,7 +597,10 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); if (target) { - this.syncSessionScope(sessionScope, target.workspaceData, target.sessionId); + this.syncSessionState( + target.processExited ? undefined : target.workspaceData, + target.resource, target.sessionId, false, target.libraryPaths, + ); } await client.start(); await this.registerClient(key, client, sessionScope); diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 21e0e88ad..3b3e9007a 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -495,6 +495,25 @@ export function deleteTerminal(term: vscode.Terminal): void { } } +/** A routing hint only when the terminal really belongs to a workspace. + * Do not use getTerminalResource() here: its active-folder fallback is for + * configuring terminal sends, not identifying an R process. */ +export function getTerminalWorkspaceResource(term: vscode.Terminal): vscode.Uri | undefined { + if (term === rTerm && rTermResource) { + return vscode.workspace.getWorkspaceFolder(rTermResource)?.uri; + } + const options = term.creationOptions; + const cwd = options && 'cwd' in options ? options.cwd : undefined; + if (!cwd) { + return undefined; + } + const uri = typeof cwd === 'string' + ? (vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === cwd)?.uri + ?? vscode.Uri.file(cwd)) + : cwd; + return vscode.workspace.getWorkspaceFolder(uri)?.uri; +} + export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { if (term === rTerm && rTermResource) { return rTermResource; diff --git a/src/session.ts b/src/session.ts index 28a20dc8b..e70224f09 100644 --- a/src/session.ts +++ b/src/session.ts @@ -95,6 +95,8 @@ export interface GlobalEnv { export interface WorkspaceData { search: string[]; loaded_namespaces: string[]; + library_paths?: string[]; + package_paths?: Record; globalenv: GlobalEnv; revision?: number; } @@ -379,7 +381,10 @@ export async function executeSessionCode(target: Session, code: string): Promise export function updateSessionWorkspace(target: Session, data: WorkspaceData): void { target.workspaceData = data; - rLanguageService?.syncSessionState(data, target.resource, target.sessionId, false); + rLanguageService?.syncSessionState( + target.processExited ? undefined : data, target.resource, target.sessionId, false, + target.libraryPaths, + ); if (activeSession === target) { void refreshActiveSession(target); } @@ -402,6 +407,9 @@ export function replaceSessionTransport(previous: Session, next: Session): void for (const [uri, owner] of documentSessions) { if (owner === previous) { documentSessions.set(uri, next); + // Reinitialize retained notebook and input clients for the new + // process, even though their document URIs did not change. + sessionDocumentBound.fire(Uri.parse(uri)); } } terminalRegistry.releaseSession(previous); @@ -431,7 +439,9 @@ export function registerSessionTransport( : (previous ?? new Session(id, host, '', '', new net.Socket())); target.requester = requester; target.workingDir = directory; - target.resource = vscode.workspace.getWorkspaceFolder(Uri.file(directory))?.uri; + // Preserve the directory even before it belongs to an open workspace. + // A folder added later can then acquire this session without reattaching. + target.resource = Uri.file(directory); sessions.set(id, target); if (previous && previous !== target) { for (const [uri, owner] of documentSessions) { @@ -3533,7 +3543,7 @@ async function refreshActiveSession(session: Session): Promise { sessionDir = session.sessionDir; workingDir = session.workingDir; workspaceData = session.workspaceData; - rLanguageService?.syncSessionState(workspaceData, session.resource, session.sessionId); + rLanguageService?.syncSessionState(session.processExited ? undefined : workspaceData, session.resource, session.sessionId, true, session.libraryPaths); if (sessionStatusBarItem) { sessionStatusBarItem.text = formatSessionLabel(rVer, pid); @@ -3851,6 +3861,11 @@ async function handleNotification(message: Record, socket: IpcS socket._sessionId = sessionId; sessions.set(sessionId, session); const changes: RSessionConnectionChange[] = []; + // A replacement process may reuse the protocol session ID but has + // different loaded packages. Do not retain its predecessor's state. + if (previous) { + rLanguageService?.syncSessionState(undefined, previous.resource, previous.sessionId); + } const disconnected = previous ? invalidateSessionConnection(previous) : undefined; if (disconnected) { changes.push(disconnected); @@ -3867,11 +3882,13 @@ async function handleNotification(message: Record, socket: IpcS }; session.sessionDir = params.tempdir; session.workingDir = params.wd; - session.resource = - (native ? rTerminal.getTerminalResource(native.terminal) : undefined) ?? - (isLocalHost(host) - ? vscode.workspace.getWorkspaceFolder(Uri.file(params.wd))?.uri - : undefined); + // Use the actual R working directory for local sessions, never + // getTerminalResource's active-editor/first-folder config fallback. + // For remote sessions only an explicitly associated terminal can + // supply a usable remote workspace URI. + session.resource = isLocalHost(host) + ? Uri.file(params.wd) + : (native ? rTerminal.getTerminalWorkspaceResource(native.terminal) : undefined); const terminalPid = native && terminalRegistry.attachNative(native.terminal, session) @@ -3929,11 +3946,13 @@ async function handleNotification(message: Record, socket: IpcS const target = socket._sessionId && sessions.get(socket._sessionId); if (target && target.socket === socket) { const refresh = workspaceRefreshFor(target); - if (target === activeSession) { - refresh.invalidate(500); - } else { - refresh.invalidate(); - } + // Background workspaces can own language servers even when + // their R session is not selected. Coalesce their package + // refreshes just like the foreground session. + refresh.invalidate( + target === activeSession || rLanguageService?.ownsWorkspaceScope(target.sessionId) + ? 500 : undefined, + ); } break; } diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index ceb3550f6..f692ddd2f 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -147,6 +148,93 @@ suite('Session completion with real R language server', () => { 'new workspace receives replayed session packages', ); + // A package installed outside the language server's original + // library paths must be resolved from its session-provided location. + const library = path.join(root, 'private-library'); + const packageRoot = path.join(root, 'sessionfixture'); + await fs.mkdir(path.join(packageRoot, 'R'), { recursive: true }); + await fs.mkdir(library, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'DESCRIPTION'), [ + 'Package: sessionfixture', 'Title: Session Completion Fixture', + 'Version: 0.0.1', 'Description: Exercises session-specific library resolution.', + 'Authors@R: person("Test", "Runner", email="test@example.org", role=c("aut", "cre"))', + 'License: MIT', '', + ].join('\n')); + await fs.writeFile(path.join(packageRoot, 'NAMESPACE'), + 'export(sessionOnlyFixture)\nexport(sessionGeneric)\nS3method(sessionGeneric, data.frame)\n'); + await fs.writeFile(path.join(packageRoot, 'R', 'fixture.R'), [ + 'sessionOnlyFixture <- function() 1L', + 'sessionGeneric <- function(x, ...) UseMethod("sessionGeneric")', + 'sessionGeneric.data.frame <- function(x, sessionParameter = TRUE, ...) x', + '', + ].join('\n')); + const install = spawnSync(rPath, ['CMD', 'INSTALL', '--no-multiarch', '-l', library, packageRoot], { + encoding: 'utf8', + }); + assert.strictEqual(install.status, 0, + `Could not install session completion fixture: ${install.stderr || install.error}`); + const packagePath = path.join(library, 'sessionfixture'); + const source = 'sessionOnlyFi'; + const fixtureUri = protocolUri(vscode.Uri.joinPath(projects[0].uri, 'session-fixture.R')); + await languageClient.sendNotification('textDocument/didOpen', { + textDocument: { uri: fixtureUri, languageId: 'r', version: 1, text: source }, + }); + service.syncSessionState({ + search: ['package:sessionfixture', 'package:base'], + loaded_namespaces: ['sessionfixture', 'base'], + library_paths: [library], + package_paths: { sessionfixture: packagePath }, + }, projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + const fixtureCompletions = await languageClient.sendRequest< + Completion[] | { items: Completion[] } | null + >('textDocument/completion', { + textDocument: { uri: fixtureUri }, + position: { line: 0, character: source.length }, + }); + assert.ok((Array.isArray(fixtureCompletions) + ? fixtureCompletions : fixtureCompletions?.items ?? []) + .some(item => item.label === 'sessionOnlyFixture' && item.data?.package === 'sessionfixture'), + 'a package in a session-specific library must offer editor completion'); + + const providerProbe = spawnSync(rPath, [ + '--vanilla', '--slave', '-e', + 'cat(exists("member_resolve_packages", asNamespace("languageserver"), inherits=FALSE))', + ], { encoding: 'utf8' }); + if (providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE') { + const call = 'sessionGeneric(data.frame(x = 1), )'; + await languageClient.sendNotification('textDocument/didChange', { + textDocument: { uri: fixtureUri, version: 2 }, + contentChanges: [{ text: call }], + }); + const signature = await languageClient.sendRequest< + { signatures?: Array<{ label: string }> } | null + >('textDocument/signatureHelp', { + textDocument: { uri: fixtureUri }, + position: { line: 0, character: call.length - 1 }, + context: { triggerKind: 1, isRetrigger: false }, + }); + assert.ok(signature?.signatures?.some(item => item.label.includes('sessionParameter')), + 'S3 resolution must use session-attached package metadata'); + } + + service.syncSessionState(undefined, projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + await languageClient.sendNotification('textDocument/didChange', { + textDocument: { uri: fixtureUri, version: 3 }, + contentChanges: [{ text: source }], + }); + const afterDetach = await languageClient.sendRequest< + Completion[] | { items: Completion[] } | null + >('textDocument/completion', { + textDocument: { uri: fixtureUri }, + position: { line: 0, character: source.length }, + }); + assert.ok(!(Array.isArray(afterDetach) ? afterDetach : afterDetach?.items ?? []) + .some(item => item.label === 'sessionOnlyFixture' && + item.data?.package === 'sessionfixture'), + 'a detached session must not leave private-library completions'); + service.syncSessionState(undefined, projects[0].uri, 'session'); await internals.syncClientSessionState('global'); for (const project of projects) { @@ -172,5 +260,5 @@ suite('Session completion with real R language server', () => { }); } } - }).timeout(60000); + }).timeout(120000); }); diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 725adb32e..b55a17650 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -493,6 +493,121 @@ suite('Session package completion', () => { }); }); + test('session package locations are delivered to the language server', async () => { + const request = client('global', 'global'); + service.syncSessionState({ + ...packages('custompkg'), + library_paths: ['/tmp/custom-library'], + package_paths: { custompkg: '/tmp/custom-library/custompkg' }, + }, folder.uri, 'library-session'); + await settle(); + sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['custompkg', 'base'], + loadedNamespaces: ['custompkg', 'base'], + libraryPaths: ['/tmp/custom-library'], + packagePaths: { custompkg: '/tmp/custom-library/custompkg' }, + }); + + service.syncSessionState({ + ...packages('custompkg'), library_paths: ['/tmp/other-library'], + }, folder.uri, 'library-session'); + await settle(); + sinon.assert.calledTwice(request); + }); + + test('an unselected session acquires a newly added workspace', async () => { + multiServer = true; + const outside = vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-language-service', 'added')); + const request = client('added', outside.toString(true)); + const folderLookup = vscode.workspace.getWorkspaceFolder as sinon.SinonStub; + service.syncSessionState(packages('splines'), outside, 'added-session', false); + await settle(); + sinon.assert.notCalled(request); + + folderLookup.callsFake(uri => uri.fsPath === outside.fsPath + ? { uri: outside, name: 'added', index: 1 } : undefined); + (service as unknown as ServiceInternals & { reconcileSessionScopes(): void }) + .reconcileSessionScopes(); + await settle(); + sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['splines', 'base'], + loadedNamespaces: ['splines', 'base'], + }); + }); + + test('a nested workspace added later acquires the session from its parent', async () => { + multiServer = true; + const nested = vscode.Uri.joinPath(folder.uri, 'nested'); + const outer = client('outer', folder.uri.toString(true)); + const inner = client('inner', nested.toString(true)); + const lookup = vscode.workspace.getWorkspaceFolder as sinon.SinonStub; + service.syncSessionState(packages('splines'), nested, 'nested-session'); + await settle(); + sinon.assert.calledOnce(outer); + sinon.assert.notCalled(inner); + + lookup.callsFake(uri => uri.fsPath === nested.fsPath || + uri.fsPath.startsWith(`${nested.fsPath}${path.sep}`) + ? { uri: nested, name: 'nested', index: 1 } + : uri.fsPath.startsWith(folder.uri.fsPath) ? folder : undefined); + (service as unknown as ServiceInternals & { reconcileSessionScopes(): void }) + .reconcileSessionScopes(); + await settle(); + assert.deepStrictEqual(outer.lastCall.args[1], { + attachedPackages: [], loadedNamespaces: [], + }); + assert.deepStrictEqual(inner.lastCall.args[1], { + attachedPackages: ['splines', 'base'], loadedNamespaces: ['splines', 'base'], + }); + }); + + test('removing a workspace migrates its live owner to unscoped completion', async () => { + multiServer = true; + const project = client('project', folder.uri.toString(true)); + const unscoped = client('external', 'unscoped'); + service.syncSessionState(packages('splines'), folder.uri, 'owner'); + await settle(); + (vscode.workspace.getWorkspaceFolder as sinon.SinonStub).returns(undefined); + (service as unknown as ServiceInternals & { reconcileSessionScopes(): void }) + .reconcileSessionScopes(); + await settle(); + sinon.assert.calledTwice(project); + sinon.assert.calledOnce(unscoped); + assert.deepStrictEqual(project.lastCall.args[1], { + attachedPackages: [], loadedNamespaces: [], + }); + assert.deepStrictEqual(unscoped.firstCall.args[1], { + attachedPackages: ['splines', 'base'], + loadedNamespaces: ['splines', 'base'], + }); + }); + + test('a definitive process exit clears package completion without closing the view', async () => { + multiServer = true; + const input = client('input', 'session:stopped-runtime'); + const project = client('project', folder.uri.toString(true)); + const target = session.registerSessionTransport( + 'stopped-runtime', 'host', folder.uri.fsPath, () => Promise.resolve(undefined), + ); + try { + session.updateSessionWorkspace(target, packages('stats')); + await settle(); + sinon.assert.calledOnce(input); + sinon.assert.calledOnce(project); + target.processExited = true; + session.updateSessionWorkspace(target, target.workspaceData); + await settle(); + assert.deepStrictEqual(input.lastCall.args[1], { + attachedPackages: [], loadedNamespaces: [], + }); + assert.deepStrictEqual(project.lastCall.args[1], { + attachedPackages: [], loadedNamespaces: [], + }); + } finally { + session.unregisterSessionTransport(target); + } + }); + test('workspace replay during an in-flight sync sends the state again after it completes', async () => { const initial = deferred(); const request = client('global', 'global'); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 6dfb18640..30c6e6af9 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -124,10 +124,14 @@ suite('Session Communication', () => { ); const source = vscode.Uri.file('/project/restart.R'); const input = vscode.Uri.from({ scheme: 'vscode-interactive-input', path: '/restart' }); + const rebound: string[] = []; + const listener = session.onDidBindSessionDocument((uri) => rebound.push(uri.toString())); try { session.bindSessionDocument(source, previous); session.bindSessionDocument(input, previous); + rebound.length = 0; session.replaceSessionTransport(previous, next); + assert.deepStrictEqual(new Set(rebound), new Set([source.toString(), input.toString()])); assert.strictEqual(session.boundSessionForDocument(source), next); assert.strictEqual(session.boundSessionForDocument(input), next); assert.strictEqual(await previous.requester?.({}), 'old'); @@ -135,11 +139,46 @@ suite('Session Communication', () => { session.unregisterSessionTransport(previous); assert.strictEqual(session.boundSessionForDocument(source), next); } finally { + listener.dispose(); session.unregisterSessionTransport(previous); session.unregisterSessionTransport(next); } }); + test('terminal routing does not fall back to an unrelated active workspace', () => { + const folderUri = vscode.Uri.file(path.join(os.tmpdir(), 'workspace-only')); + const folder = { uri: folderUri, name: 'only', index: 0 }; + sandbox.stub(vscode.workspace, 'workspaceFolders').value([folder]); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath === folderUri.fsPath ? folder : undefined); + const external = { + creationOptions: { cwd: path.join(os.tmpdir(), 'outside-folder') }, + } as unknown as vscode.Terminal; + const unknown = { creationOptions: {} } as unknown as vscode.Terminal; + const associated = { + creationOptions: { cwd: folderUri.fsPath }, + } as unknown as vscode.Terminal; + assert.strictEqual(rTerminal.getTerminalWorkspaceResource(external), undefined); + assert.strictEqual(rTerminal.getTerminalWorkspaceResource(unknown), undefined); + assert.strictEqual(rTerminal.getTerminalWorkspaceResource(associated)?.toString(), + folderUri.toString()); + }); + + test('an Interactive session retains its unscoped directory for later workspace routing', () => { + const directory = path.join(os.tmpdir(), 'vscode-r-added-workspace'); + const lookup = sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(undefined); + const target = session.registerSessionTransport( + 'late-workspace', 'host', directory, () => Promise.resolve(undefined), + ); + try { + assert.strictEqual(target.resource?.fsPath, directory); + lookup.returns({ uri: vscode.Uri.file(directory), name: 'added', index: 0 }); + assert.strictEqual(vscode.workspace.getWorkspaceFolder(target.resource!)?.name, 'added'); + } finally { + session.unregisterSessionTransport(target); + } + }); + test('help notification does not open when help panel is disabled', async () => { const showHelpForPath = await showHelpWith({ helpPanel: 'Disable' }); @@ -954,7 +993,9 @@ suite('Session Communication', () => { : undefined, ); assert.ok(attached); - assert.strictEqual(attached.resource?.toString(), workspaceUri.toString()); + assert.strictEqual(attached.resource?.fsPath, path.join(workspaceUri.fsPath, 'project')); + assert.strictEqual(vscode.workspace.getWorkspaceFolder(attached.resource!)?.uri.toString(), + workspaceUri.toString()); assert.strictEqual(socket._terminalPid, undefined); } finally { client.destroy(); @@ -1765,6 +1806,8 @@ suite('Session Communication', () => { test('IPC protocol keys sessions by session_id and ignores close from a replaced socket', async () => { sandbox.stub(extension, 'enableSessionWatcher').value(true); + const syncState = sandbox.stub(); + sandbox.stub(extension, 'rLanguageService').value({ syncSessionState: syncState }); const sendText = sandbox.stub(); const terminal = { processId: Promise.resolve(undefined), @@ -1816,6 +1859,7 @@ suite('Session Communication', () => { true, ); + syncState.resetHistory(); const reconnect = net.createConnection(endpoint); second = reconnect; await sendAttach(reconnect, 9876); @@ -1824,6 +1868,8 @@ suite('Session Communication', () => { assert.strictEqual(session.activeSession?.pid, '9876'); const replacementSession = session.activeSession; assert.ok(replacementSession); + sinon.assert.calledWithExactly(syncState, undefined, originalSession.resource, + originalSession.sessionId); await assert.rejects( session.executeSessionCode(replacementSession, 'View(iris)'), /no attached terminal/, diff --git a/src/test/suite/sessionWorkspaceRefresh.test.ts b/src/test/suite/sessionWorkspaceRefresh.test.ts index fb9235b4b..db926ce76 100644 --- a/src/test/suite/sessionWorkspaceRefresh.test.ts +++ b/src/test/suite/sessionWorkspaceRefresh.test.ts @@ -109,6 +109,48 @@ suite('Session workspace refresh demand', () => { } }); + test('an owned background workspace refreshes packages without explicit demand', async () => { + const syncSessionState = sandbox.stub(); + sandbox.stub(extension, 'rLanguageService').value({ + syncSessionState, + ownsWorkspaceScope: () => true, + }); + const socket = await connections.attach('workspace-lsp-owner', 55104, 'external-host', { + autoActivate: false, + attachmentId: 'workspace-lsp-owner-fixture', + }); + await session.activateSessionById('workspace-lsp-owner'); + const background = session.activeSession!; + foreground = session.registerSessionTransport('workspace-selected', 'host', '/tmp', () => + Promise.resolve({ globalenv: {}, search: [], loaded_namespaces: [] }), + ); + await session.activateSession(foreground); + const requests: Array<{ id: number; method: string }> = []; + sandbox.stub(socket, 'write').callsFake((data: string | Uint8Array) => { + const message = JSON.parse(data.toString()) as { id: number; method: string }; + if (message.method === 'workspace') { + requests.push(message); + } + return true; + }); + + socket.emit('data', Buffer.from('{"jsonrpc":"2.0","method":"workspace_updated"}\n')); + const request = await waitForValue(() => requests[0]); + socket.emit('data', Buffer.from(`${JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + revision: 1, globalenv: {}, search: ['package:splines'], + loaded_namespaces: ['splines'], + }, + })}\n`)); + await session.ensureSessionWorkspace(background); + assert.deepStrictEqual(background.workspaceData.search, ['package:splines']); + sinon.assert.calledWith(syncSessionState, background.workspaceData, + background.resource, background.sessionId, false); + assert.strictEqual(session.activeSession, foreground); + }); + test('native unchanged replies invalidate child values once after a notification burst', async () => { const socket = await connections.attach( 'workspace-native-unchanged', From 1b2d7a0281dc9c08b86eaf46e2056b97a8179ebb Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:20:35 +0900 Subject: [PATCH 17/23] fix: preserve session package cleanup and resolve CI findings Persist the R language server's session-owned package baseline across synchronization requests so detaching a private-library package restores the actual workspace imports. Apply repository oxfmt output, narrow test callback types for oxlint, and compare Windows resource URIs without drive-letter casing assumptions. --- R/languageServer.R | 21 +-- src/languageService.ts | 36 +++-- src/rTerminal.ts | 9 +- src/session.ts | 29 +++- .../integration/sessionCompletion.test.ts | 129 ++++++++++++------ src/test/suite/languageService.test.ts | 80 +++++++---- src/test/suite/session.test.ts | 46 +++++-- .../suite/sessionWorkspaceRefresh.test.ts | 32 +++-- 8 files changed, 260 insertions(+), 122 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index a5db26005..eb10f9f63 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -71,8 +71,9 @@ default_startup <- if (is.function(startup_fn)) startup_fn() else character() # Keep the project NAMESPACE imports separate from the temporary packages # attached in the interactive R process; S3 resolution consults imports as # well as the workspace startup list. -session_imports <- list() -previous_session_packages <- character() +session_state <- new.env(parent = emptyenv()) +session_state$imports <- list() +session_state$previous_packages <- character() server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { reply <- function(applied) { @@ -101,19 +102,19 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { # A server can outlive an R session. Removing the previous package # metadata avoids stale members and S3 signatures after detach. for (workspace in self$workspaces$values()) { - index <- which(vapply(session_imports, + index <- which(vapply(session_state$imports, function(entry) identical(entry$workspace, workspace), logical(1L))) if (!length(index)) { base_imports <- workspace$imported_packages - session_imports[[length(session_imports) + 1L]] <- + session_state$imports[[length(session_state$imports) + 1L]] <- list(workspace = workspace, imports = base_imports) } else { - base_imports <- session_imports[[index[[1L]]]]$imports + base_imports <- session_state$imports[[index[[1L]]]]$imports actual <- workspace$imported_packages - if (!setequal(actual, union(base_imports, previous_session_packages))) { - base_imports <- union(setdiff(actual, previous_session_packages), - intersect(base_imports, previous_session_packages)) - session_imports[[index[[1L]]]]$imports <- base_imports + if (!setequal(actual, union(base_imports, session_state$previous_packages))) { + base_imports <- union(setdiff(actual, session_state$previous_packages), + intersect(base_imports, session_state$previous_packages)) + session_state$imports[[index[[1L]]]]$imports <- base_imports } } previous <- workspace$startup_packages @@ -144,7 +145,7 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { } } - previous_session_packages <- attached_packages + session_state$previous_packages <- attached_packages missing <- attached_packages[!vapply(attached_packages, function(pkg) length(find.package(pkg, quiet = TRUE)) > 0L, logical(1L))] if (length(missing)) { diff --git a/src/languageService.ts b/src/languageService.ts index 56ceeecbd..c61843a30 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -84,7 +84,9 @@ export class LanguageService implements Disposable { constructor() { this.outputChannel = window.createOutputChannel('R Language Server', { log: true }); this.config = workspace.getConfiguration('r'); - this.listeners.push(workspace.onDidChangeWorkspaceFolders(() => this.reconcileSessionScopes())); + this.listeners.push( + workspace.onDidChangeWorkspaceFolders(() => this.reconcileSessionScopes()), + ); void this.startLanguageService(); } @@ -123,7 +125,11 @@ export class LanguageService implements Disposable { this.activeSessionId = ''; } } - this.syncSessionScope(ownedScope ?? this.getSessionScope(resource), undefined, sessionId); + this.syncSessionScope( + ownedScope ?? this.getSessionScope(resource), + undefined, + sessionId, + ); return; } if (sessionId) { @@ -134,11 +140,14 @@ export class LanguageService implements Disposable { } const scope = this.getSessionScope(resource); const current = this.sessionStates.get(scope); - if (active || ownedScope === scope && current?.sessionId === sessionId || + if ( + active || + (ownedScope === scope && current?.sessionId === sessionId) || // An unselected session can own a distinct workspace when it has // no existing owner. Never claim the shared unscoped/global scope // merely because an Interactive view publishes a snapshot. - (scope !== 'unscoped' && scope !== 'global' && !current)) { + (scope !== 'unscoped' && scope !== 'global' && !current) + ) { if (ownedScope && ownedScope !== scope) { this.syncSessionScope(ownedScope, undefined, sessionId); } @@ -176,8 +185,13 @@ export class LanguageService implements Disposable { this.sessionScopes.delete(sessionId); } const current = this.sessionStates.get(scope); - if (!current && scope !== 'unscoped' || - owned && (!current || current.sessionId === sessionId || this.activeSessionId === sessionId)) { + if ( + (!current && scope !== 'unscoped') || + (owned && + (!current || + current.sessionId === sessionId || + this.activeSessionId === sessionId)) + ) { this.sessionScopes.set(sessionId, scope); this.setSessionScopeState(scope, state, sessionId); } @@ -323,7 +337,10 @@ export class LanguageService implements Disposable { private getSessionStateKey(state: SessionState): string { return JSON.stringify([ - state.attachedPackages, state.loadedNamespaces, state.libraryPaths, state.packagePaths, + state.attachedPackages, + state.loadedNamespaces, + state.libraryPaths, + state.packagePaths, ]); } @@ -599,7 +616,10 @@ export class LanguageService implements Disposable { if (target) { this.syncSessionState( target.processExited ? undefined : target.workspaceData, - target.resource, target.sessionId, false, target.libraryPaths, + target.resource, + target.sessionId, + false, + target.libraryPaths, ); } await client.start(); diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 3b3e9007a..6b2adc503 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -507,10 +507,11 @@ export function getTerminalWorkspaceResource(term: vscode.Terminal): vscode.Uri if (!cwd) { return undefined; } - const uri = typeof cwd === 'string' - ? (vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === cwd)?.uri - ?? vscode.Uri.file(cwd)) - : cwd; + const uri = + typeof cwd === 'string' + ? (vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === cwd) + ?.uri ?? vscode.Uri.file(cwd)) + : cwd; return vscode.workspace.getWorkspaceFolder(uri)?.uri; } diff --git a/src/session.ts b/src/session.ts index e70224f09..ccd4b48ed 100644 --- a/src/session.ts +++ b/src/session.ts @@ -382,7 +382,10 @@ export async function executeSessionCode(target: Session, code: string): Promise export function updateSessionWorkspace(target: Session, data: WorkspaceData): void { target.workspaceData = data; rLanguageService?.syncSessionState( - target.processExited ? undefined : data, target.resource, target.sessionId, false, + target.processExited ? undefined : data, + target.resource, + target.sessionId, + false, target.libraryPaths, ); if (activeSession === target) { @@ -3543,7 +3546,13 @@ async function refreshActiveSession(session: Session): Promise { sessionDir = session.sessionDir; workingDir = session.workingDir; workspaceData = session.workspaceData; - rLanguageService?.syncSessionState(session.processExited ? undefined : workspaceData, session.resource, session.sessionId, true, session.libraryPaths); + rLanguageService?.syncSessionState( + session.processExited ? undefined : workspaceData, + session.resource, + session.sessionId, + true, + session.libraryPaths, + ); if (sessionStatusBarItem) { sessionStatusBarItem.text = formatSessionLabel(rVer, pid); @@ -3864,7 +3873,11 @@ async function handleNotification(message: Record, socket: IpcS // A replacement process may reuse the protocol session ID but has // different loaded packages. Do not retain its predecessor's state. if (previous) { - rLanguageService?.syncSessionState(undefined, previous.resource, previous.sessionId); + rLanguageService?.syncSessionState( + undefined, + previous.resource, + previous.sessionId, + ); } const disconnected = previous ? invalidateSessionConnection(previous) : undefined; if (disconnected) { @@ -3888,7 +3901,9 @@ async function handleNotification(message: Record, socket: IpcS // supply a usable remote workspace URI. session.resource = isLocalHost(host) ? Uri.file(params.wd) - : (native ? rTerminal.getTerminalWorkspaceResource(native.terminal) : undefined); + : native + ? rTerminal.getTerminalWorkspaceResource(native.terminal) + : undefined; const terminalPid = native && terminalRegistry.attachNative(native.terminal, session) @@ -3950,8 +3965,10 @@ async function handleNotification(message: Record, socket: IpcS // their R session is not selected. Coalesce their package // refreshes just like the foreground session. refresh.invalidate( - target === activeSession || rLanguageService?.ownsWorkspaceScope(target.sessionId) - ? 500 : undefined, + target === activeSession || + rLanguageService?.ownsWorkspaceScope(target.sessionId) + ? 500 + : undefined, ); } break; diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index f692ddd2f..1541cc60e 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -154,37 +154,61 @@ suite('Session completion with real R language server', () => { const packageRoot = path.join(root, 'sessionfixture'); await fs.mkdir(path.join(packageRoot, 'R'), { recursive: true }); await fs.mkdir(library, { recursive: true }); - await fs.writeFile(path.join(packageRoot, 'DESCRIPTION'), [ - 'Package: sessionfixture', 'Title: Session Completion Fixture', - 'Version: 0.0.1', 'Description: Exercises session-specific library resolution.', - 'Authors@R: person("Test", "Runner", email="test@example.org", role=c("aut", "cre"))', - 'License: MIT', '', - ].join('\n')); - await fs.writeFile(path.join(packageRoot, 'NAMESPACE'), - 'export(sessionOnlyFixture)\nexport(sessionGeneric)\nS3method(sessionGeneric, data.frame)\n'); - await fs.writeFile(path.join(packageRoot, 'R', 'fixture.R'), [ - 'sessionOnlyFixture <- function() 1L', - 'sessionGeneric <- function(x, ...) UseMethod("sessionGeneric")', - 'sessionGeneric.data.frame <- function(x, sessionParameter = TRUE, ...) x', - '', - ].join('\n')); - const install = spawnSync(rPath, ['CMD', 'INSTALL', '--no-multiarch', '-l', library, packageRoot], { - encoding: 'utf8', - }); - assert.strictEqual(install.status, 0, - `Could not install session completion fixture: ${install.stderr || install.error}`); + await fs.writeFile( + path.join(packageRoot, 'DESCRIPTION'), + [ + 'Package: sessionfixture', + 'Title: Session Completion Fixture', + 'Version: 0.0.1', + 'Description: Exercises session-specific library resolution.', + 'Authors@R: person("Test", "Runner", email="test@example.org", role=c("aut", "cre"))', + 'License: MIT', + '', + ].join('\n'), + ); + await fs.writeFile( + path.join(packageRoot, 'NAMESPACE'), + 'export(sessionOnlyFixture)\nexport(sessionGeneric)\nS3method(sessionGeneric, data.frame)\n', + ); + await fs.writeFile( + path.join(packageRoot, 'R', 'fixture.R'), + [ + 'sessionOnlyFixture <- function() 1L', + 'sessionGeneric <- function(x, ...) UseMethod("sessionGeneric")', + 'sessionGeneric.data.frame <- function(x, sessionParameter = TRUE, ...) x', + '', + ].join('\n'), + ); + const install = spawnSync( + rPath, + ['CMD', 'INSTALL', '--no-multiarch', '-l', library, packageRoot], + { + encoding: 'utf8', + }, + ); + assert.strictEqual( + install.status, + 0, + `Could not install session completion fixture: ${install.stderr || install.error}`, + ); const packagePath = path.join(library, 'sessionfixture'); const source = 'sessionOnlyFi'; - const fixtureUri = protocolUri(vscode.Uri.joinPath(projects[0].uri, 'session-fixture.R')); + const fixtureUri = protocolUri( + vscode.Uri.joinPath(projects[0].uri, 'session-fixture.R'), + ); await languageClient.sendNotification('textDocument/didOpen', { textDocument: { uri: fixtureUri, languageId: 'r', version: 1, text: source }, }); - service.syncSessionState({ - search: ['package:sessionfixture', 'package:base'], - loaded_namespaces: ['sessionfixture', 'base'], - library_paths: [library], - package_paths: { sessionfixture: packagePath }, - }, projects[0].uri, 'session'); + service.syncSessionState( + { + search: ['package:sessionfixture', 'package:base'], + loaded_namespaces: ['sessionfixture', 'base'], + library_paths: [library], + package_paths: { sessionfixture: packagePath }, + }, + projects[0].uri, + 'session', + ); await internals.syncClientSessionState('global'); const fixtureCompletions = await languageClient.sendRequest< Completion[] | { items: Completion[] } | null @@ -192,30 +216,45 @@ suite('Session completion with real R language server', () => { textDocument: { uri: fixtureUri }, position: { line: 0, character: source.length }, }); - assert.ok((Array.isArray(fixtureCompletions) - ? fixtureCompletions : fixtureCompletions?.items ?? []) - .some(item => item.label === 'sessionOnlyFixture' && item.data?.package === 'sessionfixture'), - 'a package in a session-specific library must offer editor completion'); + assert.ok( + (Array.isArray(fixtureCompletions) + ? fixtureCompletions + : (fixtureCompletions?.items ?? []) + ).some( + (item) => + item.label === 'sessionOnlyFixture' && + item.data?.package === 'sessionfixture', + ), + 'a package in a session-specific library must offer editor completion', + ); - const providerProbe = spawnSync(rPath, [ - '--vanilla', '--slave', '-e', - 'cat(exists("member_resolve_packages", asNamespace("languageserver"), inherits=FALSE))', - ], { encoding: 'utf8' }); + const providerProbe = spawnSync( + rPath, + [ + '--vanilla', + '--slave', + '-e', + 'cat(exists("member_resolve_packages", asNamespace("languageserver"), inherits=FALSE))', + ], + { encoding: 'utf8' }, + ); if (providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE') { const call = 'sessionGeneric(data.frame(x = 1), )'; await languageClient.sendNotification('textDocument/didChange', { textDocument: { uri: fixtureUri, version: 2 }, contentChanges: [{ text: call }], }); - const signature = await languageClient.sendRequest< - { signatures?: Array<{ label: string }> } | null - >('textDocument/signatureHelp', { + const signature = await languageClient.sendRequest<{ + signatures?: Array<{ label: string }>; + } | null>('textDocument/signatureHelp', { textDocument: { uri: fixtureUri }, position: { line: 0, character: call.length - 1 }, context: { triggerKind: 1, isRetrigger: false }, }); - assert.ok(signature?.signatures?.some(item => item.label.includes('sessionParameter')), - 'S3 resolution must use session-attached package metadata'); + assert.ok( + signature?.signatures?.some((item) => item.label.includes('sessionParameter')), + 'S3 resolution must use session-attached package metadata', + ); } service.syncSessionState(undefined, projects[0].uri, 'session'); @@ -230,10 +269,14 @@ suite('Session completion with real R language server', () => { textDocument: { uri: fixtureUri }, position: { line: 0, character: source.length }, }); - assert.ok(!(Array.isArray(afterDetach) ? afterDetach : afterDetach?.items ?? []) - .some(item => item.label === 'sessionOnlyFixture' && - item.data?.package === 'sessionfixture'), - 'a detached session must not leave private-library completions'); + assert.ok( + !(Array.isArray(afterDetach) ? afterDetach : (afterDetach?.items ?? [])).some( + (item) => + item.label === 'sessionOnlyFixture' && + item.data?.package === 'sessionfixture', + ), + 'a detached session must not leave private-library completions', + ); service.syncSessionState(undefined, projects[0].uri, 'session'); await internals.syncClientSessionState('global'); diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index b55a17650..5b9184c8f 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -495,11 +495,15 @@ suite('Session package completion', () => { test('session package locations are delivered to the language server', async () => { const request = client('global', 'global'); - service.syncSessionState({ - ...packages('custompkg'), - library_paths: ['/tmp/custom-library'], - package_paths: { custompkg: '/tmp/custom-library/custompkg' }, - }, folder.uri, 'library-session'); + service.syncSessionState( + { + ...packages('custompkg'), + library_paths: ['/tmp/custom-library'], + package_paths: { custompkg: '/tmp/custom-library/custompkg' }, + }, + folder.uri, + 'library-session', + ); await settle(); sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { attachedPackages: ['custompkg', 'base'], @@ -508,26 +512,35 @@ suite('Session package completion', () => { packagePaths: { custompkg: '/tmp/custom-library/custompkg' }, }); - service.syncSessionState({ - ...packages('custompkg'), library_paths: ['/tmp/other-library'], - }, folder.uri, 'library-session'); + service.syncSessionState( + { + ...packages('custompkg'), + library_paths: ['/tmp/other-library'], + }, + folder.uri, + 'library-session', + ); await settle(); sinon.assert.calledTwice(request); }); test('an unselected session acquires a newly added workspace', async () => { multiServer = true; - const outside = vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-language-service', 'added')); + const outside = vscode.Uri.file( + path.join(os.tmpdir(), 'vscode-r-language-service', 'added'), + ); const request = client('added', outside.toString(true)); const folderLookup = vscode.workspace.getWorkspaceFolder as sinon.SinonStub; service.syncSessionState(packages('splines'), outside, 'added-session', false); await settle(); sinon.assert.notCalled(request); - folderLookup.callsFake(uri => uri.fsPath === outside.fsPath - ? { uri: outside, name: 'added', index: 1 } : undefined); - (service as unknown as ServiceInternals & { reconcileSessionScopes(): void }) - .reconcileSessionScopes(); + folderLookup.callsFake((uri: vscode.Uri) => + uri.fsPath === outside.fsPath ? { uri: outside, name: 'added', index: 1 } : undefined, + ); + ( + service as unknown as ServiceInternals & { reconcileSessionScopes(): void } + ).reconcileSessionScopes(); await settle(); sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { attachedPackages: ['splines', 'base'], @@ -546,18 +559,24 @@ suite('Session package completion', () => { sinon.assert.calledOnce(outer); sinon.assert.notCalled(inner); - lookup.callsFake(uri => uri.fsPath === nested.fsPath || - uri.fsPath.startsWith(`${nested.fsPath}${path.sep}`) - ? { uri: nested, name: 'nested', index: 1 } - : uri.fsPath.startsWith(folder.uri.fsPath) ? folder : undefined); - (service as unknown as ServiceInternals & { reconcileSessionScopes(): void }) - .reconcileSessionScopes(); + lookup.callsFake((uri: vscode.Uri) => + uri.fsPath === nested.fsPath || uri.fsPath.startsWith(`${nested.fsPath}${path.sep}`) + ? { uri: nested, name: 'nested', index: 1 } + : uri.fsPath.startsWith(folder.uri.fsPath) + ? folder + : undefined, + ); + ( + service as unknown as ServiceInternals & { reconcileSessionScopes(): void } + ).reconcileSessionScopes(); await settle(); assert.deepStrictEqual(outer.lastCall.args[1], { - attachedPackages: [], loadedNamespaces: [], + attachedPackages: [], + loadedNamespaces: [], }); assert.deepStrictEqual(inner.lastCall.args[1], { - attachedPackages: ['splines', 'base'], loadedNamespaces: ['splines', 'base'], + attachedPackages: ['splines', 'base'], + loadedNamespaces: ['splines', 'base'], }); }); @@ -568,13 +587,15 @@ suite('Session package completion', () => { service.syncSessionState(packages('splines'), folder.uri, 'owner'); await settle(); (vscode.workspace.getWorkspaceFolder as sinon.SinonStub).returns(undefined); - (service as unknown as ServiceInternals & { reconcileSessionScopes(): void }) - .reconcileSessionScopes(); + ( + service as unknown as ServiceInternals & { reconcileSessionScopes(): void } + ).reconcileSessionScopes(); await settle(); sinon.assert.calledTwice(project); sinon.assert.calledOnce(unscoped); assert.deepStrictEqual(project.lastCall.args[1], { - attachedPackages: [], loadedNamespaces: [], + attachedPackages: [], + loadedNamespaces: [], }); assert.deepStrictEqual(unscoped.firstCall.args[1], { attachedPackages: ['splines', 'base'], @@ -587,7 +608,10 @@ suite('Session package completion', () => { const input = client('input', 'session:stopped-runtime'); const project = client('project', folder.uri.toString(true)); const target = session.registerSessionTransport( - 'stopped-runtime', 'host', folder.uri.fsPath, () => Promise.resolve(undefined), + 'stopped-runtime', + 'host', + folder.uri.fsPath, + () => Promise.resolve(undefined), ); try { session.updateSessionWorkspace(target, packages('stats')); @@ -598,10 +622,12 @@ suite('Session package completion', () => { session.updateSessionWorkspace(target, target.workspaceData); await settle(); assert.deepStrictEqual(input.lastCall.args[1], { - attachedPackages: [], loadedNamespaces: [], + attachedPackages: [], + loadedNamespaces: [], }); assert.deepStrictEqual(project.lastCall.args[1], { - attachedPackages: [], loadedNamespaces: [], + attachedPackages: [], + loadedNamespaces: [], }); } finally { session.unregisterSessionTransport(target); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 30c6e6af9..bcf3aac67 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -131,7 +131,10 @@ suite('Session Communication', () => { session.bindSessionDocument(input, previous); rebound.length = 0; session.replaceSessionTransport(previous, next); - assert.deepStrictEqual(new Set(rebound), new Set([source.toString(), input.toString()])); + assert.deepStrictEqual( + new Set(rebound), + new Set([source.toString(), input.toString()]), + ); assert.strictEqual(session.boundSessionForDocument(source), next); assert.strictEqual(session.boundSessionForDocument(input), next); assert.strictEqual(await previous.requester?.({}), 'old'); @@ -149,8 +152,9 @@ suite('Session Communication', () => { const folderUri = vscode.Uri.file(path.join(os.tmpdir(), 'workspace-only')); const folder = { uri: folderUri, name: 'only', index: 0 }; sandbox.stub(vscode.workspace, 'workspaceFolders').value([folder]); - sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((uri) => - uri.fsPath === folderUri.fsPath ? folder : undefined); + sandbox + .stub(vscode.workspace, 'getWorkspaceFolder') + .callsFake((uri) => (uri.fsPath === folderUri.fsPath ? folder : undefined)); const external = { creationOptions: { cwd: path.join(os.tmpdir(), 'outside-folder') }, } as unknown as vscode.Terminal; @@ -160,20 +164,25 @@ suite('Session Communication', () => { } as unknown as vscode.Terminal; assert.strictEqual(rTerminal.getTerminalWorkspaceResource(external), undefined); assert.strictEqual(rTerminal.getTerminalWorkspaceResource(unknown), undefined); - assert.strictEqual(rTerminal.getTerminalWorkspaceResource(associated)?.toString(), - folderUri.toString()); + assert.strictEqual( + rTerminal.getTerminalWorkspaceResource(associated)?.toString(), + folderUri.toString(), + ); }); test('an Interactive session retains its unscoped directory for later workspace routing', () => { const directory = path.join(os.tmpdir(), 'vscode-r-added-workspace'); const lookup = sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(undefined); - const target = session.registerSessionTransport( - 'late-workspace', 'host', directory, () => Promise.resolve(undefined), + const target = session.registerSessionTransport('late-workspace', 'host', directory, () => + Promise.resolve(undefined), ); try { - assert.strictEqual(target.resource?.fsPath, directory); + assert.strictEqual(target.resource?.toString(), vscode.Uri.file(directory).toString()); lookup.returns({ uri: vscode.Uri.file(directory), name: 'added', index: 0 }); - assert.strictEqual(vscode.workspace.getWorkspaceFolder(target.resource!)?.name, 'added'); + assert.strictEqual( + vscode.workspace.getWorkspaceFolder(target.resource!)?.name, + 'added', + ); } finally { session.unregisterSessionTransport(target); } @@ -993,9 +1002,14 @@ suite('Session Communication', () => { : undefined, ); assert.ok(attached); - assert.strictEqual(attached.resource?.fsPath, path.join(workspaceUri.fsPath, 'project')); - assert.strictEqual(vscode.workspace.getWorkspaceFolder(attached.resource!)?.uri.toString(), - workspaceUri.toString()); + assert.strictEqual( + attached.resource?.fsPath, + path.join(workspaceUri.fsPath, 'project'), + ); + assert.strictEqual( + vscode.workspace.getWorkspaceFolder(attached.resource!)?.uri.toString(), + workspaceUri.toString(), + ); assert.strictEqual(socket._terminalPid, undefined); } finally { client.destroy(); @@ -1868,8 +1882,12 @@ suite('Session Communication', () => { assert.strictEqual(session.activeSession?.pid, '9876'); const replacementSession = session.activeSession; assert.ok(replacementSession); - sinon.assert.calledWithExactly(syncState, undefined, originalSession.resource, - originalSession.sessionId); + sinon.assert.calledWithExactly( + syncState, + undefined, + originalSession.resource, + originalSession.sessionId, + ); await assert.rejects( session.executeSessionCode(replacementSession, 'View(iris)'), /no attached terminal/, diff --git a/src/test/suite/sessionWorkspaceRefresh.test.ts b/src/test/suite/sessionWorkspaceRefresh.test.ts index db926ce76..673571fed 100644 --- a/src/test/suite/sessionWorkspaceRefresh.test.ts +++ b/src/test/suite/sessionWorkspaceRefresh.test.ts @@ -136,18 +136,30 @@ suite('Session workspace refresh demand', () => { socket.emit('data', Buffer.from('{"jsonrpc":"2.0","method":"workspace_updated"}\n')); const request = await waitForValue(() => requests[0]); - socket.emit('data', Buffer.from(`${JSON.stringify({ - jsonrpc: '2.0', - id: request.id, - result: { - revision: 1, globalenv: {}, search: ['package:splines'], - loaded_namespaces: ['splines'], - }, - })}\n`)); + socket.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + revision: 1, + globalenv: {}, + search: ['package:splines'], + loaded_namespaces: ['splines'], + }, + })}\n`, + ), + ); await session.ensureSessionWorkspace(background); assert.deepStrictEqual(background.workspaceData.search, ['package:splines']); - sinon.assert.calledWith(syncSessionState, background.workspaceData, - background.resource, background.sessionId, false); + sinon.assert.calledWith( + syncSessionState, + background.workspaceData, + background.resource, + background.sessionId, + false, + ); assert.strictEqual(session.activeSession, foreground); }); From 941cce9682a07843ff882bafee767a4e360281ee Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:33:04 +0900 Subject: [PATCH 18/23] fix: complete session synchronization reset and handle superseded metadata Restore configured language-server library paths on detach and evict session-only namespace caches. Settle canceled metadata RPCs and prevent stale worker results from restoring old packages. Retain failed synchronization for retry and test server rejection. Resolve R lint indentation findings. TypeScript sources and regression test were checked using oxfmt, typecheck and oxlint on the validation candidate. --- R/languageServer.R | 72 +++++++++++++++++++++----- src/languageService.ts | 3 +- src/test/suite/languageService.test.ts | 14 +++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index eb10f9f63..525b21022 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -74,10 +74,30 @@ default_startup <- if (is.function(startup_fn)) startup_fn() else character() session_state <- new.env(parent = emptyenv()) session_state$imports <- list() session_state$previous_packages <- character() +session_state$revision <- 0L +session_state$pending_reply <- NULL server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { + # A superseded worker discards its callback. Acknowledge its request so + # TypeScript can deliver the next state, including a disconnect clear. + session_state$revision <- session_state$revision + 1L + revision <- session_state$revision + if (is.function(session_state$pending_reply)) session_state$pending_reply(FALSE) + session_state$pending_reply <- NULL + if (!is.null(self$resolve_task_manager) && + is.function(self$resolve_task_manager$cancel)) { + self$resolve_task_manager$cancel("r/syncSessionState") + } + + replied <- FALSE reply <- function(applied) { + if (replied) return(invisible(NULL)) + replied <<- TRUE + if (identical(session_state$pending_reply, reply)) { + session_state$pending_reply <- NULL + } self$deliver(languageserver:::Response$new(id, result = applied)) + invisible(NULL) } if (!is.function(startup_fn) || is.null(tryCatch(self$workspaces, error = function(e) NULL))) { # Versions before the workspace startup API cannot safely reset state. @@ -95,15 +115,23 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { if (!is.character(package_paths)) package_paths <- character() package_paths <- package_paths[nzchar(package_paths)] extra_paths <- dirname(package_paths) - candidate_paths <- unique(c(extra_paths, library_paths, default_library_paths)) + candidate_paths <- if (length(attached_packages)) { + unique(c(extra_paths, library_paths, default_library_paths)) + } else { + default_library_paths + } candidate_paths <- candidate_paths[dir.exists(candidate_paths)] .libPaths(candidate_paths) # A server can outlive an R session. Removing the previous package # metadata avoids stale members and S3 signatures after detach. for (workspace in self$workspaces$values()) { - index <- which(vapply(session_state$imports, - function(entry) identical(entry$workspace, workspace), logical(1L))) + matches <- vapply( + session_state$imports, + function(entry) identical(entry$workspace, workspace), + logical(1L) + ) + index <- which(matches) if (!length(index)) { base_imports <- workspace$imported_packages session_state$imports[[length(session_state$imports) + 1L]] <- @@ -112,20 +140,29 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { base_imports <- session_state$imports[[index[[1L]]]]$imports actual <- workspace$imported_packages if (!setequal(actual, union(base_imports, session_state$previous_packages))) { - base_imports <- union(setdiff(actual, session_state$previous_packages), - intersect(base_imports, session_state$previous_packages)) + base_imports <- union( + setdiff(actual, session_state$previous_packages), + intersect(base_imports, session_state$previous_packages) + ) session_state$imports[[index[[1L]]]]$imports <- base_imports } } previous <- workspace$startup_packages new_startup <- if (length(attached_packages)) rev(attached_packages) else default_startup - removed <- setdiff(previous, unique(c(new_startup, workspace$imported_packages))) + removed <- setdiff(previous, unique(c(new_startup, base_imports))) workspace$startup_packages <- new_startup workspace$imported_packages <- union(base_imports, attached_packages) workspace$update_loaded_packages() for (pkg in unique(c(attached_packages, loaded_namespaces))) { try(workspace$get_namespace(pkg), silent = TRUE) } + if (length(removed) && !is.null(workspace$namespaces)) { + for (pkg in removed) { + if (workspace$namespaces$has(pkg)) { + workspace$namespaces$remove(pkg) + } + } + } if (length(removed) && !is.null(workspace$member_metadata)) { for (pkg in removed) { if (is.function(workspace$member_metadata$remove)) { @@ -146,11 +183,18 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { } session_state$previous_packages <- attached_packages - missing <- attached_packages[!vapply(attached_packages, - function(pkg) length(find.package(pkg, quiet = TRUE)) > 0L, logical(1L))] + missing <- attached_packages[ + !vapply( + attached_packages, + function(pkg) length(find.package(pkg, quiet = TRUE)) > 0L, + logical(1L) + ) + ] if (length(missing)) { - message("Session packages cannot be resolved by languageserver: ", - paste(missing, collapse = ", ")) + message( + "Session packages cannot be resolved by languageserver: ", + paste(missing, collapse = ", ") + ) reply(FALSE) return(invisible(NULL)) } @@ -180,6 +224,7 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { prepare_s3 = isTRUE(languageserver:::lsp_settings$get("s3_dispatch")) ), callback = function(result) { + if (!identical(revision, session_state$revision)) return(invisible(NULL)) tryCatch({ for (workspace in self$workspaces$values()) { if (!is.null(result$members) && !is.null(workspace$member_metadata)) { @@ -200,11 +245,14 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { }) }, error = function(e) { - message("Session package metadata could not be prepared: ", conditionMessage(e)) - reply(FALSE) + if (identical(revision, session_state$revision)) { + message("Session package metadata could not be prepared: ", conditionMessage(e)) + reply(FALSE) + } }, profiles = FALSE ) + session_state$pending_reply <- reply self$resolve_task_manager$add_task("r/syncSessionState", task) }, error = function(e) { message("Session package synchronization failed: ", conditionMessage(e)) diff --git a/src/languageService.ts b/src/languageService.ts index c61843a30..3ae1895be 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -319,8 +319,9 @@ export class LanguageService implements Disposable { const applied = await client.sendRequest('r/syncSessionState', state); if (applied === false) { this.outputChannel.appendLine( - 'Session package completion is unavailable with this languageserver version.', + 'R language server rejected the session package state; completion will retry.', ); + return { stateKey, replay }; } sync.syncedStateKey = stateKey; sync.syncedReplay = replay; diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 5b9184c8f..18de67e80 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -524,6 +524,20 @@ suite('Session package completion', () => { sinon.assert.calledTwice(request); }); + test('an explicit rejection does not acknowledge the session state', async () => { + const request = client('global', 'global'); + request.onFirstCall().resolves(false); + service.syncSessionState(packages('splines'), folder.uri, 'rejected-session'); + await settle(); + sinon.assert.calledOnce(request); + await (service as unknown as ServiceInternals).syncClientSessionState('global'); + sinon.assert.calledTwice(request); + assert.deepStrictEqual(request.secondCall.args[1], { + attachedPackages: ['splines', 'base'], + loadedNamespaces: ['splines', 'base'], + }); + }); + test('an unselected session acquires a newly added workspace', async () => { multiServer = true; const outside = vscode.Uri.file( From 2f6077a7dbb329d9b8dfc25ddd1499c503b73a2b Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:43:00 +0900 Subject: [PATCH 19/23] fix: satisfy R lint and tolerate Windows startup status writer lock Track asynchronous sync reply completion in an explicit environment instead of superassignment. In the managed jgd preparation test, retry transient startup.status read failures while the child process writes the file, using the existing bounded startup deadline. Keep production jgd preparation behavior unchanged. --- R/languageServer.R | 7 ++++--- R/tests/prepare-jgd.R | 15 +++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index 525b21022..d3255fe83 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -89,10 +89,11 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { self$resolve_task_manager$cancel("r/syncSessionState") } - replied <- FALSE + response_state <- new.env(parent = emptyenv()) + response_state$replied <- FALSE reply <- function(applied) { - if (replied) return(invisible(NULL)) - replied <<- TRUE + if (response_state$replied) return(invisible(NULL)) + response_state$replied <- TRUE if (identical(session_state$pending_reply, reply)) { session_state$pending_reply <- NULL } diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index 5381c366b..0d64e67c5 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -498,12 +498,19 @@ local({ while (!file.exists(profile_log) && Sys.time() < deadline) Sys.sleep(0.01) if (!file.exists(profile_log)) stop("Declined startup profile did not connect sess.") stopifnot(identical(readLines(profile_log), c("fixture-socket", "FALSE", "FALSE"))) - startup_status <- readLines(startup_file, warn = FALSE) + # On Windows, the child may briefly hold the newly created status file + # while writing it. Treat an incomplete or unreadable snapshot as pending + # rather than failing before the existing startup deadline. deadline <- Sys.time() + 10 - while (length(startup_status) == 6L && startup_status[[6L]] == "pending" && - Sys.time() < deadline) { + startup_status <- character() + repeat { + startup_status <- tryCatch( + if (file.exists(startup_file)) readLines(startup_file, warn = FALSE) else character(), + error = function(e) character() + ) + if (length(startup_status) == 6L && startup_status[[6L]] != "pending") break + if (Sys.time() >= deadline) break Sys.sleep(0.01) - startup_status <- readLines(startup_file, warn = FALSE) } if (length(startup_status) != 6L || startup_status[[6L]] != "ready") { logs <- unlist(lapply(c("stdout", "stderr"), function(name) { From d37a0df2fdacc1c32fd857e70b3b225b3e1683cf Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:03:16 +1100 Subject: [PATCH 20/23] fix: avoid unsupported session sync retries and cover nested members Distinguish permanently unsupported language servers from transient sync failures, preserving ordinary completion while avoiding repeated requests. Test nested member completion before attachment, after attachment, and after detachment using a private-library package fixture. --- R/languageServer.R | 8 +- src/languageService.ts | 17 +++- .../integration/sessionCompletion.test.ts | 77 +++++++++++++++---- src/test/suite/languageService.test.ts | 21 +++++ 4 files changed, 107 insertions(+), 16 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index d3255fe83..6acc198b7 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -100,8 +100,12 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { self$deliver(languageserver:::Response$new(id, result = applied)) invisible(NULL) } - if (!is.function(startup_fn) || is.null(tryCatch(self$workspaces, error = function(e) NULL))) { - # Versions before the workspace startup API cannot safely reset state. + if (!is.function(startup_fn)) { + # Older languageserver versions cannot support this feature. + reply("unsupported") + return(invisible(NULL)) + } + if (is.null(tryCatch(self$workspaces, error = function(e) NULL))) { reply(FALSE) return(invisible(NULL)) } diff --git a/src/languageService.ts b/src/languageService.ts index 3ae1895be..ef149dfb0 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -59,6 +59,7 @@ interface ClientSessionSync { syncedStateKey?: string; replay: number; syncedReplay?: number; + unsupported?: boolean; pending?: Promise; } @@ -254,6 +255,9 @@ export class LanguageService implements Disposable { return Promise.resolve(); } let sync = this.clientSessionSync.get(client); + if (sync?.unsupported) { + return Promise.resolve(); + } const state = this.sessionStates.get(scope)?.state ?? // Retain a disconnect clear on the client until it is acknowledged. @@ -290,6 +294,7 @@ export class LanguageService implements Disposable { // this promise settles. Reconcile it without retrying a failure loop. if ( !this.disposed && + !current.unsupported && this.clients.get(key) === client && (current.syncedStateKey !== current.stateKey || current.syncedReplay !== current.replay) && @@ -316,7 +321,17 @@ export class LanguageService implements Disposable { } const { state, stateKey, replay } = sync; try { - const applied = await client.sendRequest('r/syncSessionState', state); + const applied = await client.sendRequest( + 'r/syncSessionState', + state, + ); + if (applied === 'unsupported') { + sync.unsupported = true; + this.outputChannel.appendLine( + 'Session package synchronization is unsupported; using static completion.', + ); + return; + } if (applied === false) { this.outputChannel.appendLine( 'R language server rejected the session package state; completion will retry.', diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index 1541cc60e..950debdd3 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -105,7 +105,7 @@ suite('Session completion with real R language server', () => { text: 'bs', }, }); - type Completion = { label: string; data?: { package?: string } }; + type Completion = { label: string; data?: { package?: string; type?: string } }; const hasSplineCompletion = async (folder: vscode.WorkspaceFolder) => { const response = await languageClient.sendRequest< Completion[] | { items: Completion[] } | null @@ -168,12 +168,19 @@ suite('Session completion with real R language server', () => { ); await fs.writeFile( path.join(packageRoot, 'NAMESPACE'), - 'export(sessionOnlyFixture)\nexport(sessionGeneric)\nS3method(sessionGeneric, data.frame)\n', + [ + 'export(sessionOnlyFixture)', + 'export(sessionGeneric)', + 'export(sessionChain)', + 'S3method(sessionGeneric, data.frame)', + '', + ].join('\n'), ); await fs.writeFile( path.join(packageRoot, 'R', 'fixture.R'), [ 'sessionOnlyFixture <- function() 1L', + 'sessionChain <- function() list(all = function() list(sunrise = TRUE))', 'sessionGeneric <- function(x, ...) UseMethod("sessionGeneric")', 'sessionGeneric.data.frame <- function(x, sessionParameter = TRUE, ...) x', '', @@ -199,6 +206,48 @@ suite('Session completion with real R language server', () => { await languageClient.sendNotification('textDocument/didOpen', { textDocument: { uri: fixtureUri, languageId: 'r', version: 1, text: source }, }); + const providerProbe = spawnSync( + rPath, + [ + '--vanilla', + '--slave', + '-e', + 'cat(exists("member_resolve_packages", asNamespace("languageserver"), inherits=FALSE))', + ], + { encoding: 'utf8' }, + ); + let nestedMembers: (() => Promise) | undefined; + if (providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE') { + const chain = 'sessionChain()$all()$su'; + const chainUri = protocolUri( + vscode.Uri.joinPath(projects[0].uri, 'session-members.R'), + ); + await languageClient.sendNotification('textDocument/didOpen', { + textDocument: { + uri: chainUri, + languageId: 'r', + version: 1, + text: chain, + }, + }); + nestedMembers = async () => { + const response = await languageClient.sendRequest< + Completion[] | { items: Completion[] } | null + >('textDocument/completion', { + textDocument: { uri: chainUri }, + position: { line: 0, character: chain.length }, + }); + return (Array.isArray(response) ? response : (response?.items ?? [])).some( + (item) => item.label === 'sunrise' && item.data?.type === 'member', + ); + }; + assert.strictEqual( + await nestedMembers(), + false, + 'nested members of the installed package must be absent before attachment', + ); + } + service.syncSessionState( { search: ['package:sessionfixture', 'package:base'], @@ -228,17 +277,12 @@ suite('Session completion with real R language server', () => { 'a package in a session-specific library must offer editor completion', ); - const providerProbe = spawnSync( - rPath, - [ - '--vanilla', - '--slave', - '-e', - 'cat(exists("member_resolve_packages", asNamespace("languageserver"), inherits=FALSE))', - ], - { encoding: 'utf8' }, - ); - if (providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE') { + if (nestedMembers) { + assert.strictEqual( + await nestedMembers(), + true, + 'a session-only package must offer nested member completion without library()', + ); const call = 'sessionGeneric(data.frame(x = 1), )'; await languageClient.sendNotification('textDocument/didChange', { textDocument: { uri: fixtureUri, version: 2 }, @@ -277,6 +321,13 @@ suite('Session completion with real R language server', () => { ), 'a detached session must not leave private-library completions', ); + if (nestedMembers) { + assert.strictEqual( + await nestedMembers(), + false, + 'detaching the session must remove its nested member metadata', + ); + } service.syncSessionState(undefined, projects[0].uri, 'session'); await internals.syncClientSessionState('global'); diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 18de67e80..da29b6102 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -538,6 +538,27 @@ suite('Session package completion', () => { }); }); + test('unsupported server stops retrying but replacement clients can synchronize', async () => { + const request = client('global', 'global'); + request.resolves('unsupported'); + service.syncSessionState(packages('splines'), folder.uri, 'old-session'); + await settle(); + sinon.assert.calledOnce(request); + + service.syncSessionState(packages('dplyr'), folder.uri, 'old-session'); + service.syncSessionState(undefined, folder.uri, 'old-session'); + await (service as unknown as ServiceInternals).syncClientSessionState('global', true); + sinon.assert.calledOnce(request); + + const replacement = client('global', 'global'); + service.syncSessionState(packages('stats'), folder.uri, 'new-session'); + await settle(); + sinon.assert.calledOnceWithExactly(replacement, 'r/syncSessionState', { + attachedPackages: ['stats', 'base'], + loadedNamespaces: ['stats', 'base'], + }); + }); + test('an unselected session acquires a newly added workspace', async () => { multiServer = true; const outside = vscode.Uri.file( From ed8e10bb1e2f93fd9e0aa02d5d8638090b882a54 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:27:27 +1100 Subject: [PATCH 21/23] fix: preserve namespace imports and scope startup timer assertion Derive project NAMESPACE imports independently of session-injected packages, including when a package belongs to both sets. Avoid retaining removed R workspace objects across syncs. Cover imports added and removed while a session is attached using real language-server completion requests. Assert that the pending startup deadline is cleared on expiration rather than counting timers belonging to unrelated extension-host components. --- R/languageServer.R | 48 +++++++++---------- .../integration/sessionCompletion.test.ts | 29 +++++++++++ .../suite/sessionTerminalLifecycle.test.ts | 9 +++- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index 6acc198b7..02fb14789 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -72,8 +72,29 @@ default_startup <- if (is.function(startup_fn)) startup_fn() else character() # attached in the interactive R process; S3 resolution consults imports as # well as the workspace startup list. session_state <- new.env(parent = emptyenv()) -session_state$imports <- list() -session_state$previous_packages <- character() +namespace_imports <- get0("extract_namespace_imports", namespace, inherits = FALSE) + +# Read static NAMESPACE imports independently from session-attached packages. +# The imported package set is otherwise indistinguishable when they overlap. +workspace_static_imports <- function(root) { + if (length(root) != 1L) return(character()) + file <- file.path(root, "NAMESPACE") + if (!file.exists(file)) return(character()) + if (is.function(namespace_imports)) return(namespace_imports(file)$packages) + + # Released languageserver versions with startup_packages, but without the + # newer namespace parser, handle ordinary import() directives here. + expressions <- tryCatch(parse(file, keep.source = FALSE), error = function(e) expression()) + packages <- unlist(lapply(expressions, function(expr) { + if (!is.call(expr) || !identical(expr[[1L]], quote(import))) return(character()) + args <- as.list(expr)[-1L] + values <- vapply(args, function(arg) { + if (is.name(arg) || is.character(arg)) as.character(arg)[1L] else "" + }, character(1L), USE.NAMES = FALSE) + values[nzchar(values)] + }), use.names = FALSE) + unique(packages) +} session_state$revision <- 0L session_state$pending_reply <- NULL @@ -131,27 +152,7 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { # A server can outlive an R session. Removing the previous package # metadata avoids stale members and S3 signatures after detach. for (workspace in self$workspaces$values()) { - matches <- vapply( - session_state$imports, - function(entry) identical(entry$workspace, workspace), - logical(1L) - ) - index <- which(matches) - if (!length(index)) { - base_imports <- workspace$imported_packages - session_state$imports[[length(session_state$imports) + 1L]] <- - list(workspace = workspace, imports = base_imports) - } else { - base_imports <- session_state$imports[[index[[1L]]]]$imports - actual <- workspace$imported_packages - if (!setequal(actual, union(base_imports, session_state$previous_packages))) { - base_imports <- union( - setdiff(actual, session_state$previous_packages), - intersect(base_imports, session_state$previous_packages) - ) - session_state$imports[[index[[1L]]]]$imports <- base_imports - } - } + base_imports <- workspace_static_imports(workspace$root) previous <- workspace$startup_packages new_startup <- if (length(attached_packages)) rev(attached_packages) else default_startup removed <- setdiff(previous, unique(c(new_startup, base_imports))) @@ -187,7 +188,6 @@ server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { } } - session_state$previous_packages <- attached_packages missing <- attached_packages[ !vapply( attached_packages, diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index 950debdd3..0cbddfd6b 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -338,6 +338,35 @@ suite('Session completion with real R language server', () => { 'disconnect removes session-only completions', ); } + + // When a package is both session-attached and statically imported, + // a changed NAMESPACE must remain authoritative after detach. + const namespaceFile = path.join(projects[1].uri.fsPath, 'NAMESPACE'); + const splines = { + search: ['package:splines', 'package:base'], + loaded_namespaces: ['splines', 'base'], + }; + service.syncSessionState(splines, projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + await fs.writeFile(namespaceFile, 'import(splines)\n'); + service.syncSessionState(undefined, projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + assert.strictEqual( + await hasSplineCompletion(projects[1]), + true, + 'a static import added during attachment must survive detachment', + ); + + service.syncSessionState(splines, projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + await fs.rm(namespaceFile); + service.syncSessionState(undefined, projects[0].uri, 'session'); + await internals.syncClientSessionState('global'); + assert.strictEqual( + await hasSplineCompletion(projects[1]), + false, + 'a static import removed during attachment must not survive detachment', + ); } finally { try { await service?.dispose(); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index 014c7e218..f123f6350 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -1098,16 +1098,21 @@ suite('Session Terminal Lifecycle', () => { } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); const timerSandbox = sinon.createSandbox(); - const clock = timerSandbox.useFakeTimers(); + timerSandbox.useFakeTimers(); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); + const clearTimerSpy = timerSandbox.spy(globalThis, 'clearTimeout'); const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); const waiting = session.waitForTerminalReady(terminal, 30000, file, token); try { const expire = await waitForTimerDelay(timerSpy, 600000, file); + const deadline = timerSpy.getCalls().find((call) => call.args[1] === 600000); + assert.ok(deadline); expire(); assert.strictEqual(await waiting, false); - assert.strictEqual(clock.countTimers(), 0); + // Another extension-host component can schedule unrelated fake timers. + // Only the timer owned by this startup wait must be cleared. + sinon.assert.calledWith(clearTimerSpy, deadline.returnValue); } finally { close.fire(terminal); timerSandbox.restore(); From 21ae8dd80a8c25873669140850bcdf0c8a1792c8 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:56:23 +1100 Subject: [PATCH 22/23] test: require newer languageserver member and S3 integration in CI Add a focused Linux extension-test job using a pinned languageserver revision with member_resolve_packages. Keep the existing released-version matrix unchanged, and require the provider API so the nested-member and S3 assertions cannot be silently skipped in the development-server job. --- .github/workflows/main.yml | 35 +++++++++++++++++++ .../integration/sessionCompletion.test.ts | 11 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 46b8a60a2..a997f6020 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -106,6 +106,41 @@ jobs: .vscode-test/user-data/**/logs/**/exthost/output_logging_*/*.log .vscode-test/user-data/**/logs/**/exthost/REditorSupport.r/*.log retention-days: 7 + test-development-languageserver: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: r-lib/actions/setup-r@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2 + with: + use-public-rspm: true + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - name: Install development languageserver + shell: Rscript {0} + run: | + install.packages(c("remotes", "pkgbuild", "lintr", "renv")) + remotes::install_github( + "REditorSupport/languageserver@649a82fb3856765b116924f46e4ddcea1ffe93aa", + dependencies = NA, + upgrade = "never", + force = TRUE + ) + stopifnot(is.function(get0( + "member_resolve_packages", asNamespace("languageserver"), inherits = FALSE + ))) + - name: Compile extension and tests + run: pnpm run build && pnpm run clean && pnpm exec tsc + - name: Test session member and S3 providers + env: + VSCR_TEST_REQUIRE_LSP_PROVIDERS: '1' + run: >- + xvfb-run -a pnpm exec vscode-test --label sess-install-tasks + --grep "Session completion with real R language server" + build: runs-on: ubuntu-latest env: diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index 0cbddfd6b..8f72156fc 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -216,8 +216,17 @@ suite('Session completion with real R language server', () => { ], { encoding: 'utf8' }, ); + const hasMemberProvider = + providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE'; + if (process.env.VSCR_TEST_REQUIRE_LSP_PROVIDERS === '1') { + assert.strictEqual( + hasMemberProvider, + true, + `Missing member_resolve_packages: ${providerProbe.stderr}`, + ); + } let nestedMembers: (() => Promise) | undefined; - if (providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE') { + if (hasMemberProvider) { const chain = 'sessionChain()$all()$su'; const chainUri = protocolUri( vscode.Uri.joinPath(projects[0].uri, 'session-members.R'), From 96bea440511f19ee53f9c8bb13317552179ad227 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:22:38 +1100 Subject: [PATCH 23/23] revert: remove development languageserver CI job and test guard Remove the separately introduced development languageserver job and its mandatory-provider test flag. Restore the previously passing CI configuration and integration test without changing production code. --- .github/workflows/main.yml | 35 ------------------- .../integration/sessionCompletion.test.ts | 11 +----- 2 files changed, 1 insertion(+), 45 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a997f6020..46b8a60a2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -106,41 +106,6 @@ jobs: .vscode-test/user-data/**/logs/**/exthost/output_logging_*/*.log .vscode-test/user-data/**/logs/**/exthost/REditorSupport.r/*.log retention-days: 7 - test-development-languageserver: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: r-lib/actions/setup-r@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2 - with: - use-public-rspm: true - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - name: Install development languageserver - shell: Rscript {0} - run: | - install.packages(c("remotes", "pkgbuild", "lintr", "renv")) - remotes::install_github( - "REditorSupport/languageserver@649a82fb3856765b116924f46e4ddcea1ffe93aa", - dependencies = NA, - upgrade = "never", - force = TRUE - ) - stopifnot(is.function(get0( - "member_resolve_packages", asNamespace("languageserver"), inherits = FALSE - ))) - - name: Compile extension and tests - run: pnpm run build && pnpm run clean && pnpm exec tsc - - name: Test session member and S3 providers - env: - VSCR_TEST_REQUIRE_LSP_PROVIDERS: '1' - run: >- - xvfb-run -a pnpm exec vscode-test --label sess-install-tasks - --grep "Session completion with real R language server" - build: runs-on: ubuntu-latest env: diff --git a/src/test/integration/sessionCompletion.test.ts b/src/test/integration/sessionCompletion.test.ts index 8f72156fc..0cbddfd6b 100644 --- a/src/test/integration/sessionCompletion.test.ts +++ b/src/test/integration/sessionCompletion.test.ts @@ -216,17 +216,8 @@ suite('Session completion with real R language server', () => { ], { encoding: 'utf8' }, ); - const hasMemberProvider = - providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE'; - if (process.env.VSCR_TEST_REQUIRE_LSP_PROVIDERS === '1') { - assert.strictEqual( - hasMemberProvider, - true, - `Missing member_resolve_packages: ${providerProbe.stderr}`, - ); - } let nestedMembers: (() => Promise) | undefined; - if (hasMemberProvider) { + if (providerProbe.status === 0 && providerProbe.stdout.trim() === 'TRUE') { const chain = 'sessionChain()$all()$su'; const chainUri = protocolUri( vscode.Uri.joinPath(projects[0].uri, 'session-members.R'),