diff --git a/R/languageServer.R b/R/languageServer.R index b24c1eab..5499a9ab 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 b511094a..e4ebc41e 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 readonly clientScopes: Map = new Map(); + private readonly sessionStates: Map = new Map(); constructor() { this.outputChannel = window.createOutputChannel('R Language Server'); @@ -35,6 +47,68 @@ 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. + 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)) { + return; + } + this.sessionStates.delete(scope); + this.applySessionStateToScope(scope, { + attachedPackages: [], + loadedNamespaces: [], + }); + return; + } + + 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 (current?.stateKey === stateKey && current.sessionId === sessionId) { + return; + } + + 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); + } + } + } + + 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 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; @@ -64,9 +138,9 @@ 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, target?: Session): Promise { + resource?: Uri, sessionScope: string = 'global', target?: Session): Promise { let client: LanguageClient; const virtualOnly = selector.every(filter => 'scheme' in filter @@ -170,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. @@ -227,11 +312,24 @@ export class LanguageService implements Disposable { } extensionContext.subscriptions.push(client); + 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); + } + } + private checkClient(name: string): boolean { if (this.initSet.has(name)) { return true; @@ -244,6 +342,14 @@ export class LanguageService implements Disposable { return false; } + 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 { switch (uri.scheme) { case 'untitled': @@ -272,10 +378,11 @@ 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); 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); - this.clients.set(key, client); 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; } @@ -283,14 +390,14 @@ 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); 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, target); - this.clients.set(key, client); + await this.createClient(key, documentSelector, + target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, scope, target); this.initSet.delete(key); } return; @@ -307,8 +414,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); - this.clients.set(key, client); + await this.createClient(key, documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); this.initSet.delete(key); } @@ -323,8 +429,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); - this.clients.set(key, client); + await this.createClient(key, documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped'); this.initSet.delete(key); } return; @@ -338,9 +443,8 @@ export class LanguageService implements Disposable { const documentSelector: DocumentFilter[] = [ { scheme: 'file', pattern: document.uri.fsPath }, ]; - const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri); - this.clients.set(key, client); + await this.createClient(key, documentSelector, + dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, 'unscoped'); this.initSet.delete(key); } return; @@ -372,6 +476,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(); } @@ -389,7 +494,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); @@ -417,6 +522,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(); } @@ -444,8 +550,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); - this.clients.set('global', client); + await this.createClient('global', documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); this.startMultiLanguageService(true); } } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index d014c637..eb9cad06 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -366,9 +366,9 @@ 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; + return vscode.workspace.getWorkspaceFolder(rTermResource)?.uri; } const creationOptions = term.creationOptions; @@ -377,9 +377,7 @@ function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { 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 { diff --git a/src/session.ts b/src/session.ts index 35403d32..5a387920 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'; @@ -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: {} }; } } @@ -126,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(); } @@ -160,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); } @@ -167,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; } @@ -177,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; } @@ -2063,6 +2069,7 @@ async function refreshActiveSession(session: Session): Promise { sessionDir = session.sessionDir; workingDir = session.workingDir; workspaceData = session.workspaceData; + rLanguageService?.syncSessionState(workspaceData, session.resource, session.sessionId); if (sessionStatusBarItem) { const version = rVer.replace(/^R (?:version )?/, '').replace(/\s+\(.*/, ''); @@ -2099,13 +2106,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); } @@ -2121,7 +2128,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; @@ -2184,9 +2191,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) { @@ -2231,6 +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) + ?? (isLocalHost(host) ? vscode.workspace.getWorkspaceFolder(Uri.file(params.wd))?.uri : undefined); if (terminalPid) { terminalSessionAttached.fire(terminalPid); @@ -2464,6 +2475,7 @@ 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) { await clearActiveSession(); } diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts new file mode 100644 index 00000000..4141ff81 --- /dev/null +++ b/src/test/suite/languageService.test.ts @@ -0,0 +1,163 @@ +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; + 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; + 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); + 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('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'], + }, 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('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)); + 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); + } + }); +}); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 396f3b4f..f42e95c9 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -745,6 +745,46 @@ 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.ok(attached); + 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();