@@ -179,6 +179,65 @@ describe('CodeQLServerManager', () => {
179179 expect ( server1 ) . toBe ( server2 ) ;
180180 expect ( manager . isRunning ( 'language' ) ) . toBe ( true ) ;
181181 } ) ;
182+
183+ it ( 'should allow a retry after a failed start' , async ( ) => {
184+ const { spawn } = await import ( 'child_process' ) ;
185+ const manager = new CodeQLServerManager ( { sessionId : 'ls-fail-retry' } ) ;
186+ const config = { searchPath : '/ql' } ;
187+
188+ // First call: spawn throws so the start rejects
189+ ( spawn as ReturnType < typeof vi . fn > ) . mockImplementationOnce ( ( ) => {
190+ throw new Error ( 'mock spawn failure' ) ;
191+ } ) ;
192+
193+ await expect ( manager . getLanguageServer ( config ) ) . rejects . toThrow ( 'mock spawn failure' ) ;
194+ expect ( manager . isRunning ( 'language' ) ) . toBe ( false ) ;
195+
196+ // Second call: spawn works again — should succeed, not hang on a
197+ // stale pendingStarts entry.
198+ const server = await manager . getLanguageServer ( config ) ;
199+ expect ( server ) . toBeDefined ( ) ;
200+ expect ( server . isRunning ( ) ) . toBe ( true ) ;
201+ } ) ;
202+
203+ it ( 'should not leak pendingStarts when concurrent calls race and the first rejects' , async ( ) => {
204+ const { spawn } = await import ( 'child_process' ) ;
205+ const manager = new CodeQLServerManager ( { sessionId : 'ls-race-reject' } ) ;
206+ const config = { searchPath : '/ql' } ;
207+
208+ let callCount = 0 ;
209+ const originalImpl = ( spawn as ReturnType < typeof vi . fn > ) . getMockImplementation ( ) ;
210+ ( spawn as ReturnType < typeof vi . fn > ) . mockImplementation ( ( ...args : unknown [ ] ) => {
211+ callCount ++ ;
212+ if ( callCount === 1 ) {
213+ // First spawn fails — simulates a transient error
214+ throw new Error ( 'transient spawn failure' ) ;
215+ }
216+ // Subsequent spawns succeed using the original mock implementation
217+ if ( originalImpl ) return originalImpl ( ...args ) ;
218+ // Fallback: should not happen, but satisfy the type checker
219+ throw new Error ( 'no original implementation' ) ;
220+ } ) ;
221+
222+ // Fire two concurrent requests — first will reject, second should
223+ // still succeed because pendingStarts is cleaned up properly.
224+ const results = await Promise . allSettled ( [
225+ manager . getLanguageServer ( config ) ,
226+ manager . getLanguageServer ( config ) ,
227+ ] ) ;
228+
229+ // At least one call should have succeeded
230+ const fulfilled = results . filter ( r => r . status === 'fulfilled' ) ;
231+ expect ( fulfilled . length ) . toBeGreaterThanOrEqual ( 1 ) ;
232+
233+ // The manager should have a running language server afterwards
234+ expect ( manager . isRunning ( 'language' ) ) . toBe ( true ) ;
235+
236+ // Restore original mock
237+ if ( originalImpl ) {
238+ ( spawn as ReturnType < typeof vi . fn > ) . mockImplementation ( originalImpl ) ;
239+ }
240+ } ) ;
182241 } ) ;
183242
184243 describe ( 'getQueryServer' , ( ) => {
0 commit comments