From b7bd68b3508a841d1bbcb370a4f9ca548063f664 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 15 May 2026 00:07:23 +1000 Subject: [PATCH] Start R language server only when needed and stop after 30s of inactivity - Start the language server only when an R file is opened. - Stop the server automatically 30 seconds after the last R file is closed. - Cancel the shutdown timer if a new R file is opened within those 30 seconds. - Fix "server disconnected" errors by double-checking that no R files are open exactly when the 30-second timer finishes, preventing the server from being accidentally killed while in use. --- src/languageService.ts | 555 +++++++++++++++++++++++++++++++---------- 1 file changed, 418 insertions(+), 137 deletions(-) diff --git a/src/languageService.ts b/src/languageService.ts index c7776ecc..8c40f473 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -12,22 +12,30 @@ import { extensionContext } from './extension'; import { CommonOptions } from 'child_process'; export class LanguageService implements Disposable { + private static readonly globalClientKey = 'global'; + private static readonly idleStopDelayMs = 30_000; private readonly clients: Map = new Map(); private readonly initSet: Set = new Set(); + private readonly idleStopTimers: Map> = new Map(); + private readonly trackedDocuments: Map> = new Map(); + private readonly disposables: Disposable[] = []; private readonly config: WorkspaceConfiguration; private readonly outputChannel: OutputChannel; + private disposed = false; constructor() { this.outputChannel = window.createOutputChannel('R Language Server'); this.config = workspace.getConfiguration('r'); - void this.startLanguageService(); + this.startLanguageService(); } dispose(): Thenable { + this.disposed = true; return this.stopLanguageService(); } - private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }): DisposableProcess { + private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }, + onExit?: (client: LanguageClient) => void): DisposableProcess { const childProcess = spawn(rPath, args, options); const pid = childProcess.pid || -1; client.outputChannel.appendLine(`R Language Server (${pid}) started`); @@ -49,15 +57,14 @@ export class LanguageService implements Disposable { client.outputChannel.show(); } } - if (client.needsStop()) { - void client.stop(); - } + onExit?.(client); }); return childProcess; } private async createClient(selector: DocumentFilter[], - cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel): Promise { + cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, + onExit?: (client: LanguageClient) => void): Promise { let client: LanguageClient; @@ -117,7 +124,7 @@ export class LanguageService implements Disposable { server.listen(0, '127.0.0.1', () => { const port = (server.address() as net.AddressInfo).port; env.VSCR_LSP_PORT = String(port); - return this.spawnServer(client, rPath, args, options); + return this.spawnServer(client, rPath, args, options, onExit); }); }); @@ -183,154 +190,430 @@ export class LanguageService implements Disposable { } - private checkClient(name: string): boolean { - if (this.initSet.has(name)) { - return true; + private isSupportedDocumentScheme(document: TextDocument): boolean { + return document.uri.scheme === 'file' || + document.uri.scheme === 'untitled' || + document.uri.scheme === 'vscode-notebook-cell'; + } + + private isRLanguageDocument(document: TextDocument): boolean { + return document.languageId === 'r' || document.languageId === 'rmd'; + } + + private isTrackedRDocument(document: TextDocument): boolean { + return this.isSupportedDocumentScheme(document) && + this.isRLanguageDocument(document) && + !this.isTemporaryRSource(document); + } + + private isTemporaryRSource(document: TextDocument): boolean { + if (document.uri.scheme !== 'file') { + return false; } - const client = this.clients.get(name); - if (client && client.needsStop()) { + + const fsPath = document.uri.fsPath.toLowerCase(); + return fsPath.includes('rtmp') && + fsPath.endsWith('.r') && + !fsPath.includes('.vdoc.'); + } + + private hasOpenRLanguageDocuments(): boolean { + return workspace.textDocuments.some((document) => this.isTrackedRDocument(document)); + } + + private isQuartoDocument(document: TextDocument): boolean { + return document.uri.fsPath.toLowerCase().endsWith('.qmd') || + this.isUntitledQuartoDocument(document); + } + + private isUntitledQuartoDocument(document: TextDocument): boolean { + return document.uri.scheme === 'untitled' && + (document.languageId === 'quarto' || + document.languageId === 'r' || + document.languageId === 'rmd'); + } + + private isQuartoChunkUri(uriString: string): boolean { + try { + const uri = Uri.parse(uriString); + return uri.scheme === 'file' && + uri.fsPath.includes('.vdoc.') && + uri.fsPath.toLowerCase().endsWith('.r'); + } catch { + return false; + } + } + + private getServerKey(document: TextDocument): string | undefined { + const folder = workspace.getWorkspaceFolder(document.uri); + if (folder) { + return folder.uri.toString(true); + } + + if (document.uri.scheme === 'vscode-notebook-cell') { + return `vscode-notebook:${document.uri.fsPath}`; + } + + if (document.uri.scheme === 'untitled') { + return 'untitled'; + } + + if (document.uri.scheme === 'file') { + return dirname(document.uri.fsPath); + } + + return undefined; + } + + private trackDocument(serverKey: string, document: TextDocument): void { + let documentSet = this.trackedDocuments.get(serverKey); + if (!documentSet) { + documentSet = new Set(); + this.trackedDocuments.set(serverKey, documentSet); + } + documentSet.add(document.uri.toString(true)); + } + + private untrackDocument(serverKey: string, document: TextDocument): boolean { + const documentSet = this.trackedDocuments.get(serverKey); + if (!documentSet) { + return false; + } + + documentSet.delete(document.uri.toString(true)); + if (documentSet.size === 0) { + this.trackedDocuments.delete(serverKey); return true; } - this.initSet.add(name); + return false; } - private getKey(uri: Uri): string { - switch (uri.scheme) { - case 'untitled': - return uri.scheme; - case 'vscode-notebook-cell': - return `vscode-notebook:${uri.fsPath}`; - default: - return uri.toString(true); + private getOpenTrackedDocuments(serverKey: string): TextDocument[] { + const documentSet = this.trackedDocuments.get(serverKey); + if (!documentSet) { + return []; + } + + const openDocuments: TextDocument[] = []; + for (const uri of Array.from(documentSet)) { + const document = workspace.textDocuments.find((doc) => doc.uri.toString(true) === uri); + if (document && this.isTrackedRDocument(document) && this.getServerKey(document) === serverKey) { + openDocuments.push(document); + } else { + documentSet.delete(uri); + } + } + + if (documentSet.size === 0) { + this.trackedDocuments.delete(serverKey); + } + + return openDocuments; + } + + private hasOpenTrackedDocuments(serverKey: string): boolean { + return this.getOpenTrackedDocuments(serverKey).length > 0; + } + + private forgetStoppedClient(serverKey: string): void { + const client = this.clients.get(serverKey); + if (client && !client.needsStop()) { + this.clients.delete(serverKey); + this.initSet.delete(serverKey); + void client.dispose(); } } - private startMultiLanguageService(): void { - const didOpenTextDocument = async (document: TextDocument) => { - if (document.uri.scheme !== 'file' && document.uri.scheme !== 'untitled' && document.uri.scheme !== 'vscode-notebook-cell') { - return; + private cancelIdleStop(serverKey: string): void { + const timer = this.idleStopTimers.get(serverKey); + if (timer) { + clearTimeout(timer); + this.idleStopTimers.delete(serverKey); + } + } + + private scheduleIdleStop(serverKey: string, shouldStop: () => boolean): void { + if (this.disposed || this.idleStopTimers.has(serverKey)) { + return; + } + + const timer = setTimeout(() => { + this.idleStopTimers.delete(serverKey); + if (shouldStop()) { + void this.stopClient(serverKey); } + }, LanguageService.idleStopDelayMs); + this.idleStopTimers.set(serverKey, timer); + } + + private scheduleMultiIdleStop(serverKey: string): void { + this.scheduleIdleStop(serverKey, () => !this.hasOpenTrackedDocuments(serverKey)); + } + + private scheduleSingleIdleStop(): void { + this.scheduleIdleStop( + LanguageService.globalClientKey, + () => !this.hasOpenRLanguageDocuments() + ); + } + + private clearIdleStops(): void { + for (const timer of this.idleStopTimers.values()) { + clearTimeout(timer); + } + this.idleStopTimers.clear(); + } + + private stopStartedClient(client: LanguageClient): Thenable { + if (!client.needsStop()) { + void client.dispose(); + return Promise.resolve(); + } + + return client.stop().then(() => { + void client.dispose(); + }); + } + + private stopClient(serverKey: string): Thenable | undefined { + this.cancelIdleStop(serverKey); + const client = this.clients.get(serverKey); + this.trackedDocuments.delete(serverKey); + + if (!client) { + return undefined; + } + + this.clients.delete(serverKey); + this.initSet.delete(serverKey); + return this.stopStartedClient(client); + } - if (document.languageId !== 'r' && document.languageId !== 'rmd') { + private handleClientExit(serverKey: string, client: LanguageClient): void { + if (this.clients.get(serverKey) !== client) { + return; + } + + this.clients.delete(serverKey); + this.initSet.delete(serverKey); + this.cancelIdleStop(serverKey); + } + + private getMultiServerOptions(document: TextDocument): { + documentSelector: DocumentFilter[]; + cwd: string; + workspaceFolder: WorkspaceFolder | undefined; + } | undefined { + const folder = workspace.getWorkspaceFolder(document.uri); + + if (document.uri.scheme === 'vscode-notebook-cell') { + return { + documentSelector: [ + { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, + ], + cwd: dirname(document.uri.fsPath), + workspaceFolder: folder + }; + } + + if (folder) { + const pattern = `${folder.uri.fsPath}/**/*`; + return { + documentSelector: [ + { scheme: 'file', language: 'r', pattern: pattern }, + { scheme: 'file', language: 'rmd', pattern: pattern }, + ], + cwd: folder.uri.fsPath, + workspaceFolder: folder + }; + } + + if (document.uri.scheme === 'untitled') { + return { + documentSelector: [ + { scheme: 'untitled', language: 'r' }, + { scheme: 'untitled', language: 'rmd' }, + ], + cwd: os.homedir(), + workspaceFolder: undefined + }; + } + + if (document.uri.scheme === 'file') { + const dir = dirname(document.uri.fsPath); + return { + documentSelector: [ + { scheme: 'file', pattern: `${dir}/**/*.{R,r,Rmd,rmd}` }, + ], + cwd: dir, + workspaceFolder: undefined + }; + } + + return undefined; + } + + private async startMultiClient(document: TextDocument): Promise { + if (this.disposed || !this.isTrackedRDocument(document)) { + return; + } + + const serverKey = this.getServerKey(document); + if (!serverKey) { + return; + } + + this.trackDocument(serverKey, document); + this.cancelIdleStop(serverKey); + + this.forgetStoppedClient(serverKey); + + const client = this.clients.get(serverKey); + if ((client && client.needsStop()) || this.initSet.has(serverKey)) { + return; + } + + const options = this.getMultiServerOptions(document); + if (!options) { + return; + } + + this.initSet.add(serverKey); + try { + console.log(`Start language server for ${document.uri.toString(true)}`); + const client = await this.createClient( + options.documentSelector, + options.cwd, + options.workspaceFolder, + this.outputChannel, + (client) => this.handleClientExit(serverKey, client) + ); + + if (this.disposed) { + await this.stopStartedClient(client); return; } - const folder = workspace.getWorkspaceFolder(document.uri); - - // Each notebook uses a server started from parent folder - if (document.uri.scheme === 'vscode-notebook-cell') { - const key = this.getKey(document.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, - dirname(document.uri.fsPath), folder, this.outputChannel); - this.clients.set(key, client); - this.initSet.delete(key); - } - return; + this.clients.set(serverKey, client); + if (!this.trackedDocuments.has(serverKey)) { + this.scheduleMultiIdleStop(serverKey); } + } finally { + this.initSet.delete(serverKey); + } + } - if (folder) { - - // Each workspace uses a server started from the workspace folder - const key = this.getKey(folder.uri); - if (!this.checkClient(key)) { - console.log(`Start language server for ${document.uri.toString(true)}`); - const pattern = `${folder.uri.fsPath}/**/*`; - const documentSelector: DocumentFilter[] = [ - { scheme: 'file', language: 'r', pattern: pattern }, - { scheme: 'file', language: 'rmd', pattern: pattern }, - ]; - const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel); - this.clients.set(key, client); - this.initSet.delete(key); + private closeMultiClient(document: TextDocument): void { + if (this.isRLanguageDocument(document)) { + const serverKey = this.getServerKey(document); + if (serverKey) { + this.untrackDocument(serverKey, document); + if (!this.hasOpenTrackedDocuments(serverKey)) { + this.scheduleMultiIdleStop(serverKey); } + } + return; + } - } else { - - // All untitled documents share a server started from home folder - if (document.uri.scheme === 'untitled') { - const key = this.getKey(document.uri); - if (!this.checkClient(key)) { - console.log(`Start language server for ${document.uri.toString(true)}`); - const documentSelector: DocumentFilter[] = [ - { scheme: 'untitled', language: 'r' }, - { scheme: 'untitled', language: 'rmd' }, - ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel); - this.clients.set(key, client); - this.initSet.delete(key); - } - return; + if (this.isQuartoDocument(document)) { + for (const [serverKey, documentSet] of this.trackedDocuments.entries()) { + if (documentSet.size > 0 && Array.from(documentSet).every((uri) => this.isQuartoChunkUri(uri))) { + this.trackedDocuments.delete(serverKey); + this.scheduleMultiIdleStop(serverKey); } + } + } + } - // Each file outside workspace uses a server started from parent folder - if (document.uri.scheme === 'file') { - const key = this.getKey(document.uri); - if (!this.checkClient(key)) { - console.log(`Start language server for ${document.uri.toString(true)}`); - const documentSelector: DocumentFilter[] = [ - { scheme: 'file', pattern: document.uri.fsPath }, - ]; - const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel); - this.clients.set(key, client); - this.initSet.delete(key); - } - return; - } + private startMultiLanguageService(): void { + const openDisposable = workspace.onDidOpenTextDocument((document) => { + void this.startMultiClient(document); + }); + const closeDisposable = workspace.onDidCloseTextDocument((document) => { + this.closeMultiClient(document); + }); + const workspaceDisposable = workspace.onDidChangeWorkspaceFolders((event) => { + for (const folder of event.removed) { + void this.stopClient(folder.uri.toString(true)); } - }; + }); - const didCloseTextDocument = (document: TextDocument): void => { - if (document.uri.scheme === 'untitled') { - const result = workspace.textDocuments.find((doc) => doc.uri.scheme === 'untitled'); - if (result) { - // Stop the language server when all untitled documents are closed. - return; - } + this.disposables.push(openDisposable, closeDisposable, workspaceDisposable); + workspace.textDocuments.forEach((document) => { + void this.startMultiClient(document); + }); + } + + private singleServerDocumentSelector(): DocumentFilter[] { + return [ + { language: 'r' }, + { language: 'rmd' }, + ]; + } + + private async startSingleClient(): Promise { + const serverKey = LanguageService.globalClientKey; + this.forgetStoppedClient(serverKey); + if (this.disposed || !this.hasOpenRLanguageDocuments() || this.clients.has(serverKey) || this.initSet.has(serverKey)) { + if (!this.disposed && this.hasOpenRLanguageDocuments()) { + this.cancelIdleStop(serverKey); } + return; + } - if (document.uri.scheme === 'vscode-notebook-cell') { - const result = workspace.textDocuments.find((doc) => - doc.uri.scheme === document.uri.scheme && doc.uri.fsPath === document.uri.fsPath); - if (result) { - // Stop the language server when all cell documents are closed (notebook closed). - return; - } + this.cancelIdleStop(serverKey); + this.initSet.add(serverKey); + try { + const workspaceFolder = workspace.workspaceFolders?.[0]; + const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); + const client = await this.createClient( + this.singleServerDocumentSelector(), + cwd, + undefined, + this.outputChannel, + (client) => this.handleClientExit(serverKey, client) + ); + + if (this.disposed) { + await this.stopStartedClient(client); + return; } - // Stop the language server when single file outside workspace is closed, or the above cases. - const key = this.getKey(document.uri); - const client = this.clients.get(key); - if (client) { - this.clients.delete(key); - this.initSet.delete(key); - void client.stop(); + this.clients.set(serverKey, client); + + if (!this.hasOpenRLanguageDocuments()) { + this.scheduleSingleIdleStop(); } - }; + } finally { + this.initSet.delete(serverKey); + } + } - workspace.onDidOpenTextDocument(didOpenTextDocument); - workspace.onDidCloseTextDocument(didCloseTextDocument); - workspace.textDocuments.forEach((doc) => void didOpenTextDocument(doc)); - workspace.onDidChangeWorkspaceFolders((event) => { - for (const folder of event.removed) { - const key = this.getKey(folder.uri); - const client = this.clients.get(key); - if (client) { - this.clients.delete(key); - this.initSet.delete(key); - void client.stop(); - } + private stopSingleClientIfIdle(): void { + if (!this.hasOpenRLanguageDocuments()) { + this.scheduleSingleIdleStop(); + } + } + + private startSingleLanguageService(): void { + const openDisposable = workspace.onDidOpenTextDocument((document) => { + if (this.isRLanguageDocument(document)) { + void this.startSingleClient(); } }); + const closeDisposable = workspace.onDidCloseTextDocument(() => { + this.stopSingleClientIfIdle(); + }); + + this.disposables.push(openDisposable, closeDisposable); + + if (this.hasOpenRLanguageDocuments()) { + void this.startSingleClient(); + } } - private async startLanguageService(): Promise { + private startLanguageService(): void { let useMultiServer = false; const multiServerConfig = this.config.get('lsp.multiServer'); @@ -341,26 +624,24 @@ export class LanguageService implements Disposable { if (useMultiServer) { this.startMultiLanguageService(); } else { - const documentSelector: DocumentFilter[] = [ - { scheme: 'file', language: 'r' }, - { scheme: 'file', language: 'rmd' }, - { scheme: 'untitled', language: 'r' }, - { scheme: 'untitled', language: 'rmd' }, - { scheme: 'vscode-notebook-cell', language: 'r' }, - ]; - - const workspaceFolder = workspace.workspaceFolders?.[0]; - const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); - const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel); - this.clients.set('global', client); + this.startSingleLanguageService(); } } private stopLanguageService(): Thenable { const promises: Thenable[] = []; - for (const client of this.clients.values()) { - promises.push(client.stop()); + this.clearIdleStops(); + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + for (const serverKey of Array.from(this.clients.keys())) { + const promise = this.stopClient(serverKey); + if (promise) { + promises.push(promise); + } } + this.initSet.clear(); + this.trackedDocuments.clear(); return Promise.all(promises).then(() => undefined); } }