44 */
55
66import { Disposable } from 'vscode'
7- import { LanguageClient } from 'vscode-languageclient/node'
7+ import { ErrorHandler , LanguageClient } from 'vscode-languageclient/node'
88import { getLogger } from '../logger/logger'
99import { ToolkitError } from '../errors'
10+ import { LspServerLifecycleController , LspServerLifecycleHooks } from './lspServerLifecycle'
1011
1112const logger = getLogger ( 'lsp' )
1213
@@ -19,31 +20,57 @@ export interface LspInstallationInvalidator {
1920 invalidateResolvedInstallation ( ) : void | Promise < void >
2021}
2122
22- export type LanguageClientFactory = ( serverPath : string , serverRootDir : string ) => Promise < LanguageClient >
23+ /** What the launcher hands to the client factory; `errorHandler` must be set on `LanguageClientOptions`. */
24+ export interface LanguageClientFactoryContext {
25+ serverPath : string
26+ serverRootDir : string
27+ /** Shared close/error policy from {@link LspServerLifecycleController}; do not implement your own. */
28+ errorHandler : ErrorHandler
29+ }
30+
31+ export type LanguageClientFactory = ( context : LanguageClientFactoryContext ) => Promise < LanguageClient >
2332
24- export interface LspLauncherConfig {
33+ export interface LspLauncherConfig extends LspServerLifecycleHooks {
2534 name : string
2635 resolver : LspServerResolver
2736 invalidator : LspInstallationInvalidator
2837 clientFactory : LanguageClientFactory
2938 onStarted ?: ( client : LanguageClient ) => Promise < void >
3039}
3140
41+ interface ResolvedServer {
42+ serverPath : string
43+ serverRootDir : string
44+ }
45+
46+ /**
47+ * Starts a managed language server: resolves (installing if needed), creates the client, and runs the
48+ * shared startup-recovery policy from {@link LspServerLifecycleController}. Owns the running client for
49+ * the session; `stop()`/`dispose()` shut it down.
50+ */
3251export class LspLauncher implements Disposable {
3352 private client ?: LanguageClient
3453 private startPromise ?: Promise < LanguageClient >
3554 private disposed = false
3655 private readonly config : LspLauncherConfig
56+ private readonly lifecycle : LspServerLifecycleController < LanguageClient , ResolvedServer >
3757
3858 constructor ( config : LspLauncherConfig ) {
3959 this . config = config
60+ this . lifecycle = new LspServerLifecycleController < LanguageClient , ResolvedServer > ( {
61+ name : config . name ,
62+ resolveServer : ( ) => this . resolveServer ( ) ,
63+ startProcess : ( server ) => this . startProcess ( server ) ,
64+ invalidateAndReinstall : ( ) => config . invalidator . invalidateResolvedInstallation ( ) ,
65+ shouldRepair : config . shouldRepair ,
66+ onServerStopped : config . onServerStopped ,
67+ onError : config . onError ,
68+ } )
4069 }
4170
4271 async start ( ) : Promise < LanguageClient > {
4372 if ( this . disposed ) {
44- throw new ToolkitError ( `${ this . config . name } : cannot start a disposed launcher` , {
45- code : 'LspLauncherDisposed' ,
46- } )
73+ throw this . disposedError ( 'cannot start a disposed launcher' )
4774 }
4875
4976 if ( this . client ) {
@@ -64,69 +91,70 @@ export class LspLauncher implements Disposable {
6491 }
6592 }
6693
67- private async doStart ( ) : Promise < LanguageClient > {
68- for ( let attempt = 1 ; attempt <= 2 ; attempt ++ ) {
69- const isFinalAttempt = attempt === 2
70-
71- const serverPath = await this . config . resolver . serverExecutable ( )
72- const serverRootDir = await this . config . resolver . serverRootDir ( )
73-
74- logger . info ( `${ this . config . name } : creating client for server at ${ serverPath } ` )
94+ private async resolveServer ( ) : Promise < ResolvedServer > {
95+ const serverPath = await this . config . resolver . serverExecutable ( )
96+ const serverRootDir = await this . config . resolver . serverRootDir ( )
97+ return { serverPath, serverRootDir }
98+ }
7599
76- let candidate : LanguageClient | undefined
77- try {
78- candidate = await this . config . clientFactory ( serverPath , serverRootDir )
79- await candidate . start ( )
80- } catch ( startErr ) {
81- if ( candidate ) {
82- await bestEffortStopDispose ( candidate , this . config . name )
83- }
84-
85- if ( isFinalAttempt ) {
86- throw new ToolkitError (
87- `${ this . config . name } : failed to start language server after retry: ${ startErr } ` ,
88- { code : 'LspStartFailed' , cause : startErr as Error }
89- )
90- }
91-
92- logger . warn ( `${ this . config . name } : process start failed, invalidating and retrying once: ${ startErr } ` )
93- await this . config . invalidator . invalidateResolvedInstallation ( )
94- continue
100+ /** Creates the client and completes `initialize`; a partially created client is cleaned up on failure. */
101+ private async startProcess ( { serverPath, serverRootDir } : ResolvedServer ) : Promise < LanguageClient > {
102+ logger . info ( `${ this . config . name } : creating client for server at ${ serverPath } ` )
103+ let candidate : LanguageClient | undefined
104+ try {
105+ candidate = await this . config . clientFactory ( {
106+ serverPath,
107+ serverRootDir,
108+ errorHandler : this . lifecycle . createErrorHandler ( ) ,
109+ } )
110+ await candidate . start ( )
111+ return candidate
112+ } catch ( err ) {
113+ if ( candidate ) {
114+ await bestEffortStopDispose ( candidate , this . config . name )
95115 }
116+ throw err
117+ }
118+ }
96119
120+ private async doStart ( ) : Promise < LanguageClient > {
121+ // Resolving may download the server; don't start that work if we were disposed during the
122+ // previous attempt (e.g. a restart or deactivation raced with the invalidate-and-retry).
123+ const candidate = await this . lifecycle . launchWithRetry ( ( ) => {
97124 if ( this . disposed ) {
98- await bestEffortStopDispose ( candidate , this . config . name )
99- throw new ToolkitError ( `${ this . config . name } : launcher disposed during start` , {
100- code : 'LspLauncherDisposed' ,
101- } )
125+ throw this . disposedError ( 'launcher disposed during start' )
102126 }
127+ } )
103128
104- this . client = candidate
105- logger . info ( `${ this . config . name } : language client started successfully` )
106-
107- if ( this . config . onStarted ) {
108- try {
109- await this . config . onStarted ( candidate )
110- } catch ( onStartedErr ) {
111- logger . warn ( `${ this . config . name } : onStarted hook failed, cleaning up client: ${ onStartedErr } ` )
112- await this . cleanupClient ( )
113- throw onStartedErr
114- }
115- }
129+ if ( this . disposed ) {
130+ await bestEffortStopDispose ( candidate , this . config . name )
131+ throw this . disposedError ( 'launcher disposed during start' )
132+ }
116133
117- if ( this . disposed ) {
134+ this . client = candidate
135+ this . lifecycle . onInitialized ( )
136+ logger . info ( `${ this . config . name } : language client started successfully` )
137+
138+ if ( this . config . onStarted ) {
139+ try {
140+ await this . config . onStarted ( candidate )
141+ } catch ( onStartedErr ) {
142+ logger . warn ( `${ this . config . name } : onStarted hook failed, cleaning up client: ${ onStartedErr } ` )
118143 await this . cleanupClient ( )
119- throw new ToolkitError ( `${ this . config . name } : launcher disposed during start` , {
120- code : 'LspLauncherDisposed' ,
121- } )
144+ throw onStartedErr
122145 }
146+ }
123147
124- return candidate
148+ if ( this . disposed ) {
149+ await this . cleanupClient ( )
150+ throw this . disposedError ( 'launcher disposed during start' )
125151 }
126152
127- throw new ToolkitError ( `${ this . config . name } : language server start did not complete` , {
128- code : 'LspStartFailed' ,
129- } )
153+ return candidate
154+ }
155+
156+ private disposedError ( message : string ) : ToolkitError {
157+ return new ToolkitError ( `${ this . config . name } : ${ message } ` , { code : 'LspLauncherDisposed' } )
130158 }
131159
132160 private async cleanupClient ( ) : Promise < void > {
@@ -136,6 +164,8 @@ export class LspLauncher implements Disposable {
136164 return
137165 }
138166
167+ // We initiated this stop, so the ErrorHandler will not see it; tell the lifecycle directly.
168+ this . lifecycle . onServerStopped ( true )
139169 try {
140170 await client . stop ( )
141171 } catch ( err ) {
0 commit comments