@@ -2,6 +2,7 @@ import * as assert from 'assert';
22import * as fs from 'fs' ;
33import * as os from 'os' ;
44import * as path from 'path' ;
5+ import * as net from 'net' ;
56import * as vscode from 'vscode' ;
67import * as sinon from 'sinon' ;
78import { execFile } from 'child_process' ;
@@ -18,7 +19,7 @@ import { shellQuote } from '../../interactive/launcher';
1819import type { InteractiveManager } from '../../interactive/manager' ;
1920import type { GlobalEnvItem , WorkspaceDataProvider } from '../../workspaceViewer' ;
2021import type { WorkspaceData } from '../../session' ;
21- import type { LanguageClient } from 'vscode-languageclient/node' ;
22+ import { LanguageClient , State , StreamMessageReader , StreamMessageWriter , createMessageConnection } from 'vscode-languageclient/node' ;
2223
2324function treeTooltip ( item : vscode . TreeItem ) : string {
2425 return typeof item . tooltip === 'string' ? item . tooltip : item . tooltip ?. value ?? '' ;
@@ -85,6 +86,8 @@ function treeTooltip(item: vscode.TreeItem): string {
8586 const service = languageService ( ) ;
8687 await Promise . all ( service . clientUpdates . values ( ) ) ;
8788 const clients = new Set ( [ ...service . clients . values ( ) ] . filter ( client => ! originalLanguageClients . has ( client ) ) ) ;
89+ // Await shutdown before tab-close handlers can start an unobserved stop.
90+ await Promise . all ( [ ...clients ] . map ( stopLanguageClient ) ) ;
8891 // Detach retains editors. Close this suite's tabs and await its language
8992 // servers before deleting directories that those R processes use as cwd.
9093 await vscode . window . tabGroups . close ( vscode . window . tabGroups . all . flatMap ( group => group . tabs )
@@ -96,7 +99,7 @@ function treeTooltip(item: vscode.TreeItem): string {
9699 service . clients . delete ( key ) ;
97100 }
98101 }
99- await Promise . all ( [ ...clients ] . map ( client => client . stop ( ) ) ) ;
102+ await Promise . all ( [ ...clients ] . map ( stopLanguageClient ) ) ;
100103 await new Promise ( resolve => setTimeout ( resolve , 200 ) ) ;
101104 fs . rmSync ( root , { recursive : true , force : true } ) ;
102105 sourceDirectories . forEach ( directory => fs . rmSync ( directory , { recursive : true , force : true } ) ) ;
@@ -2094,3 +2097,72 @@ par(mfrow=c(1,1))`);
20942097 } ) ;
20952098
20962099} ) ;
2100+
2101+ async function stopLanguageClient ( client : LanguageClient ) : Promise < void > {
2102+ try {
2103+ await client . stop ( ) ;
2104+ } catch ( error ) {
2105+ // R's language server can close on shutdown before the client's exit
2106+ // notification is written. The client has already completed cleanup.
2107+ if ( client . state === State . Stopped && error instanceof Error && 'code' in error && error . code === 'EPIPE' ) { return ; }
2108+ throw error ;
2109+ }
2110+ }
2111+
2112+ suite ( 'Interactive language client cleanup' , ( ) => {
2113+ test ( 'accepts a completed shutdown when the server closes before the exit notification' , async ( ) => {
2114+ const connections : Array < { dispose ( ) : void } > = [ ] ;
2115+ let serverSocket : net . Socket | undefined ;
2116+ const server = net . createServer ( socket => {
2117+ serverSocket = socket ;
2118+ const connection = createMessageConnection ( new StreamMessageReader ( socket ) , new StreamMessageWriter ( socket ) ) ;
2119+ connections . push ( connection ) ;
2120+ connection . onRequest ( 'initialize' , ( ) => ( { capabilities : { } } ) ) ;
2121+ connection . onRequest ( 'shutdown' , ( ) => null ) ;
2122+ connection . listen ( ) ;
2123+ } ) ;
2124+ await new Promise < void > ( resolve => server . listen ( 0 , '127.0.0.1' , resolve ) ) ;
2125+ const socket = net . connect ( ( server . address ( ) as net . AddressInfo ) . port , '127.0.0.1' ) ;
2126+ const ended = new Promise < void > ( resolve => socket . once ( 'end' , resolve ) ) ;
2127+ const writer = new StreamMessageWriter ( socket ) ;
2128+ const write = writer . write . bind ( writer ) ;
2129+ let exitErrorCode : string | undefined ;
2130+ // Force the same ordering that macOS can produce during R shutdown.
2131+ writer . write = async message => {
2132+ const isExit = 'method' in message && message . method === 'exit' ;
2133+ if ( isExit ) {
2134+ serverSocket ! . end ( ) ;
2135+ await ended ;
2136+ }
2137+ try {
2138+ return await write ( message ) ;
2139+ } catch ( error ) {
2140+ if ( isExit ) { exitErrorCode = ( error as NodeJS . ErrnoException ) . code ; }
2141+ throw error ;
2142+ }
2143+ } ;
2144+ const channel = vscode . window . createOutputChannel ( 'Test language client cleanup' , { log : true } ) ;
2145+ const client = new LanguageClient ( 'cleanup-test' , 'Cleanup test' , ( ) => Promise . resolve ( {
2146+ reader : new StreamMessageReader ( socket ) , writer,
2147+ } ) , { documentSelector : [ ] , outputChannel : channel } ) ;
2148+ try {
2149+ await client . start ( ) ;
2150+ await stopLanguageClient ( client ) ;
2151+ assert . strictEqual ( client . state , State . Stopped ) ;
2152+ assert . strictEqual ( exitErrorCode , 'EPIPE' ) ;
2153+ } finally {
2154+ writer . dispose ( ) ; socket . destroy ( ) ;
2155+ connections . forEach ( connection => connection . dispose ( ) ) ;
2156+ await new Promise < void > ( resolve => server . close ( ( ) => resolve ( ) ) ) ;
2157+ channel . dispose ( ) ;
2158+ }
2159+ } ) ;
2160+
2161+ test ( 'does not suppress unrelated shutdown failures or an unstopped client' , async ( ) => {
2162+ const error = Object . assign ( new Error ( 'Socket shutdown failed' ) , { code : 'EPIPE' } ) ;
2163+ for ( const [ state , failure ] of [ [ State . Running , error ] , [ State . Stopped , new Error ( 'Unexpected shutdown failure' ) ] ] as const ) {
2164+ const client = { state, stop : ( ) => Promise . reject ( failure ) } as unknown as LanguageClient ;
2165+ await assert . rejects ( stopLanguageClient ( client ) , thrown => thrown === failure ) ;
2166+ }
2167+ } ) ;
2168+ } ) ;
0 commit comments