@@ -23,6 +23,56 @@ const RESOLVE_TIMEOUT_MS = 30_000; // 30 seconds for single resolve
2323// Restart/recovery constants
2424const MAX_RESTART_ATTEMPTS = 3 ;
2525const RESTART_BACKOFF_BASE_MS = 1_000 ; // 1 second base, exponential: 1s, 2s, 4s
26+ const MAX_CONFIGURE_TIMEOUTS_BEFORE_KILL = 2 ; // Kill on the 2nd consecutive timeout
27+
28+ /**
29+ * Computes the configure timeout with exponential backoff.
30+ * @param retryCount Number of consecutive configure timeouts so far
31+ * @returns Timeout in milliseconds: 30s, 60s, 120s, ... capped at REFRESH_TIMEOUT_MS
32+ */
33+ export function getConfigureTimeoutMs ( retryCount : number ) : number {
34+ return Math . min ( CONFIGURE_TIMEOUT_MS * Math . pow ( 2 , retryCount ) , REFRESH_TIMEOUT_MS ) ;
35+ }
36+
37+ /**
38+ * Encapsulates the configure retry state machine.
39+ * Tracks consecutive timeout count and decides whether to kill the process.
40+ */
41+ export class ConfigureRetryState {
42+ private _timeoutCount : number = 0 ;
43+
44+ get timeoutCount ( ) : number {
45+ return this . _timeoutCount ;
46+ }
47+
48+ /** Returns the timeout duration for the current attempt (with exponential backoff). */
49+ getTimeoutMs ( ) : number {
50+ return getConfigureTimeoutMs ( this . _timeoutCount ) ;
51+ }
52+
53+ /** Call after a successful configure. Resets the timeout counter. */
54+ onSuccess ( ) : void {
55+ this . _timeoutCount = 0 ;
56+ }
57+
58+ /**
59+ * Call after a configure timeout. Increments the counter and returns
60+ * whether the process should be killed (true = kill, false = let it continue).
61+ */
62+ onTimeout ( ) : boolean {
63+ this . _timeoutCount ++ ;
64+ if ( this . _timeoutCount >= MAX_CONFIGURE_TIMEOUTS_BEFORE_KILL ) {
65+ this . _timeoutCount = 0 ;
66+ return true ; // Kill the process
67+ }
68+ return false ; // Let PET continue
69+ }
70+
71+ /** Call after a non-timeout error or process restart. Resets the counter. */
72+ reset ( ) : void {
73+ this . _timeoutCount = 0 ;
74+ }
75+ }
2676
2777export async function getNativePythonToolsPath ( ) : Promise < string > {
2878 const envsExt = getExtension ( ENVS_EXTENSION_ID ) ;
@@ -119,14 +169,27 @@ interface RefreshOptions {
119169 searchPaths ?: string [ ] ;
120170}
121171
172+ /**
173+ * Error thrown when a JSON-RPC request times out.
174+ */
175+ export class RpcTimeoutError extends Error {
176+ constructor (
177+ public readonly method : string ,
178+ timeoutMs : number ,
179+ ) {
180+ super ( `Request '${ method } ' timed out after ${ timeoutMs } ms` ) ;
181+ this . name = this . constructor . name ;
182+ }
183+ }
184+
122185/**
123186 * Wraps a JSON-RPC sendRequest call with a timeout.
124187 * @param connection The JSON-RPC connection
125188 * @param method The RPC method name
126189 * @param params The parameters to send
127190 * @param timeoutMs Timeout in milliseconds
128191 * @returns The result of the request
129- * @throws Error if the request times out
192+ * @throws RpcTimeoutError if the request times out
130193 */
131194async function sendRequestWithTimeout < T > (
132195 connection : rpc . MessageConnection ,
@@ -138,7 +201,7 @@ async function sendRequestWithTimeout<T>(
138201 const timeoutPromise = new Promise < never > ( ( _ , reject ) => {
139202 const timer = setTimeout ( ( ) => {
140203 cts . cancel ( ) ;
141- reject ( new Error ( `Request ' ${ method } ' timed out after ${ timeoutMs } ms` ) ) ;
204+ reject ( new RpcTimeoutError ( method , timeoutMs ) ) ;
142205 } , timeoutMs ) ;
143206 // Clear timeout if the CancellationTokenSource is disposed
144207 cts . token . onCancellationRequested ( ( ) => clearTimeout ( timer ) ) ;
@@ -161,6 +224,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
161224 private startFailed : boolean = false ;
162225 private restartAttempts : number = 0 ;
163226 private isRestarting : boolean = false ;
227+ private readonly configureRetry = new ConfigureRetryState ( ) ;
164228
165229 constructor (
166230 private readonly outputChannel : LogOutputChannel ,
@@ -192,8 +256,9 @@ class NativePythonFinderImpl implements NativePythonFinder {
192256 this . restartAttempts = 0 ;
193257 return environment ;
194258 } catch ( ex ) {
195- // On timeout, kill the hung process so next request triggers restart
196- if ( ex instanceof Error && ex . message . includes ( 'timed out' ) ) {
259+ // On resolve timeout (not configure — configure handles its own timeout),
260+ // kill the hung process so next request triggers restart
261+ if ( ex instanceof RpcTimeoutError && ex . method !== 'configure' ) {
197262 this . outputChannel . warn ( '[pet] Resolve request timed out, killing hung process for restart' ) ;
198263 this . killProcess ( ) ;
199264 this . processExited = true ;
@@ -260,6 +325,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
260325 this . processExited = false ;
261326 this . startFailed = false ;
262327 this . lastConfiguration = undefined ; // Force reconfiguration
328+ this . configureRetry . reset ( ) ;
263329
264330 // Start fresh
265331 this . connection = this . start ( ) ;
@@ -544,8 +610,9 @@ class NativePythonFinderImpl implements NativePythonFinder {
544610 // Reset restart attempts on successful refresh
545611 this . restartAttempts = 0 ;
546612 } catch ( ex ) {
547- // On timeout, kill the hung process so next request triggers restart
548- if ( ex instanceof Error && ex . message . includes ( 'timed out' ) ) {
613+ // On refresh timeout (not configure — configure handles its own timeout),
614+ // kill the hung process so next request triggers restart
615+ if ( ex instanceof RpcTimeoutError && ex . method !== 'configure' ) {
549616 this . outputChannel . warn ( '[pet] Request timed out, killing hung process for restart' ) ;
550617 this . killProcess ( ) ;
551618 this . processExited = true ;
@@ -583,17 +650,40 @@ class NativePythonFinderImpl implements NativePythonFinder {
583650 return ;
584651 }
585652 this . outputChannel . info ( '[pet] configure: Sending configuration update:' , JSON . stringify ( options ) ) ;
653+ // Exponential backoff: 30s, 60s on retry. Capped at REFRESH_TIMEOUT_MS.
654+ const timeoutMs = this . configureRetry . getTimeoutMs ( ) ;
655+ if ( this . configureRetry . timeoutCount > 0 ) {
656+ this . outputChannel . info (
657+ `[pet] configure: Using extended timeout of ${ timeoutMs } ms (retry ${ this . configureRetry . timeoutCount } )` ,
658+ ) ;
659+ }
586660 try {
661+ await sendRequestWithTimeout ( this . connection , 'configure' , options , timeoutMs ) ;
662+ // Only cache after success so failed/timed-out calls will retry
587663 this . lastConfiguration = options ;
588- await sendRequestWithTimeout ( this . connection , 'configure' , options , CONFIGURE_TIMEOUT_MS ) ;
664+ this . configureRetry . onSuccess ( ) ;
589665 } catch ( ex ) {
590- // On timeout, kill the hung process so next request triggers restart
591- if ( ex instanceof Error && ex . message . includes ( 'timed out' ) ) {
592- this . outputChannel . warn ( '[pet] Configure request timed out, killing hung process for restart' ) ;
593- this . killProcess ( ) ;
594- this . processExited = true ;
666+ // Clear cached config so the next call retries instead of short-circuiting via configurationEquals
667+ this . lastConfiguration = undefined ;
668+ if ( ex instanceof RpcTimeoutError ) {
669+ const shouldKill = this . configureRetry . onTimeout ( ) ;
670+ if ( shouldKill ) {
671+ this . outputChannel . error (
672+ '[pet] Configure timed out on consecutive attempts, killing hung process for restart' ,
673+ ) ;
674+ this . killProcess ( ) ;
675+ this . processExited = true ;
676+ } else {
677+ this . outputChannel . warn (
678+ `[pet] Configure request timed out (attempt ${ this . configureRetry . timeoutCount } /${ MAX_CONFIGURE_TIMEOUTS_BEFORE_KILL } ), ` +
679+ 'will retry on next request without killing process' ,
680+ ) ;
681+ }
682+ } else {
683+ // Non-timeout errors reset the counter so only consecutive timeouts are counted
684+ this . configureRetry . reset ( ) ;
685+ this . outputChannel . error ( '[pet] configure: Configuration error' , ex ) ;
595686 }
596- this . outputChannel . error ( '[pet] configure: Configuration error' , ex ) ;
597687 throw ex ;
598688 }
599689 }
0 commit comments