Skip to content

Commit 49a4def

Browse files
committed
fix: reset configureTimeoutCount on non-timeout errors, extract ConfigureRetryState with full test coverage (PR #1275)
1 parent b2135ec commit 49a4def

2 files changed

Lines changed: 127 additions & 14 deletions

File tree

src/managers/common/nativePythonFinder.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,46 @@ export function getConfigureTimeoutMs(retryCount: number): number {
3434
return Math.min(CONFIGURE_TIMEOUT_MS * Math.pow(2, retryCount), REFRESH_TIMEOUT_MS);
3535
}
3636

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+
}
76+
3777
export async function getNativePythonToolsPath(): Promise<string> {
3878
const envsExt = getExtension(ENVS_EXTENSION_ID);
3979
if (envsExt) {
@@ -184,7 +224,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
184224
private startFailed: boolean = false;
185225
private restartAttempts: number = 0;
186226
private isRestarting: boolean = false;
187-
private configureTimeoutCount: number = 0;
227+
private readonly configureRetry = new ConfigureRetryState();
188228

189229
constructor(
190230
private readonly outputChannel: LogOutputChannel,
@@ -285,7 +325,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
285325
this.processExited = false;
286326
this.startFailed = false;
287327
this.lastConfiguration = undefined; // Force reconfiguration
288-
this.configureTimeoutCount = 0;
328+
this.configureRetry.reset();
289329

290330
// Start fresh
291331
this.connection = this.start();
@@ -611,38 +651,37 @@ class NativePythonFinderImpl implements NativePythonFinder {
611651
}
612652
this.outputChannel.info('[pet] configure: Sending configuration update:', JSON.stringify(options));
613653
// Exponential backoff: 30s, 60s on retry. Capped at REFRESH_TIMEOUT_MS.
614-
const timeoutMs = getConfigureTimeoutMs(this.configureTimeoutCount);
615-
if (this.configureTimeoutCount > 0) {
654+
const timeoutMs = this.configureRetry.getTimeoutMs();
655+
if (this.configureRetry.timeoutCount > 0) {
616656
this.outputChannel.info(
617-
`[pet] configure: Using extended timeout of ${timeoutMs}ms (retry ${this.configureTimeoutCount})`,
657+
`[pet] configure: Using extended timeout of ${timeoutMs}ms (retry ${this.configureRetry.timeoutCount})`,
618658
);
619659
}
620660
try {
621661
await sendRequestWithTimeout(this.connection, 'configure', options, timeoutMs);
622662
// Only cache after success so failed/timed-out calls will retry
623663
this.lastConfiguration = options;
624-
this.configureTimeoutCount = 0;
664+
this.configureRetry.onSuccess();
625665
} catch (ex) {
626666
// Clear cached config so the next call retries instead of short-circuiting via configurationEquals
627667
this.lastConfiguration = undefined;
628668
if (ex instanceof RpcTimeoutError) {
629-
this.configureTimeoutCount++;
630-
if (this.configureTimeoutCount >= MAX_CONFIGURE_TIMEOUTS_BEFORE_KILL) {
631-
// Repeated configure timeouts suggest PET is truly hung — kill and restart
669+
const shouldKill = this.configureRetry.onTimeout();
670+
if (shouldKill) {
632671
this.outputChannel.error(
633-
`[pet] Configure timed out ${this.configureTimeoutCount} consecutive times, killing hung process for restart`,
672+
'[pet] Configure timed out on consecutive attempts, killing hung process for restart',
634673
);
635674
this.killProcess();
636675
this.processExited = true;
637-
this.configureTimeoutCount = 0;
638676
} else {
639-
// First timeout — PET may still be working. Let it continue and retry next call.
640677
this.outputChannel.warn(
641-
`[pet] Configure request timed out (attempt ${this.configureTimeoutCount}/${MAX_CONFIGURE_TIMEOUTS_BEFORE_KILL}), ` +
678+
`[pet] Configure request timed out (attempt ${this.configureRetry.timeoutCount}/${MAX_CONFIGURE_TIMEOUTS_BEFORE_KILL}), ` +
642679
'will retry on next request without killing process',
643680
);
644681
}
645682
} else {
683+
// Non-timeout errors reset the counter so only consecutive timeouts are counted
684+
this.configureRetry.reset();
646685
this.outputChannel.error('[pet] configure: Configuration error', ex);
647686
}
648687
throw ex;

src/test/managers/common/nativePythonFinder.configureTimeout.unit.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import assert from 'node:assert';
2-
import { RpcTimeoutError, getConfigureTimeoutMs } from '../../../managers/common/nativePythonFinder';
2+
import {
3+
ConfigureRetryState,
4+
RpcTimeoutError,
5+
getConfigureTimeoutMs,
6+
} from '../../../managers/common/nativePythonFinder';
37

48
suite('RpcTimeoutError', () => {
59
test('has correct name property', () => {
@@ -43,3 +47,73 @@ suite('getConfigureTimeoutMs', () => {
4347
assert.strictEqual(getConfigureTimeoutMs(10), 120_000);
4448
});
4549
});
50+
51+
suite('ConfigureRetryState', () => {
52+
let state: ConfigureRetryState;
53+
54+
setup(() => {
55+
state = new ConfigureRetryState();
56+
});
57+
58+
test('initial timeout count is 0', () => {
59+
assert.strictEqual(state.timeoutCount, 0);
60+
});
61+
62+
test('initial timeout is base (30s)', () => {
63+
assert.strictEqual(state.getTimeoutMs(), 30_000);
64+
});
65+
66+
test('onSuccess resets timeout count', () => {
67+
state.onTimeout(); // count = 1
68+
state.onSuccess();
69+
assert.strictEqual(state.timeoutCount, 0);
70+
assert.strictEqual(state.getTimeoutMs(), 30_000);
71+
});
72+
73+
test('first timeout does not kill (returns false)', () => {
74+
const shouldKill = state.onTimeout();
75+
assert.strictEqual(shouldKill, false);
76+
assert.strictEqual(state.timeoutCount, 1);
77+
});
78+
79+
test('first timeout increases next timeout to 60s', () => {
80+
state.onTimeout();
81+
assert.strictEqual(state.getTimeoutMs(), 60_000);
82+
});
83+
84+
test('second consecutive timeout kills (returns true)', () => {
85+
state.onTimeout(); // count = 1
86+
const shouldKill = state.onTimeout(); // count = 2 → kill → reset to 0
87+
assert.strictEqual(shouldKill, true);
88+
assert.strictEqual(state.timeoutCount, 0); // Reset after kill
89+
});
90+
91+
test('non-timeout error resets counter via reset()', () => {
92+
state.onTimeout(); // count = 1
93+
state.reset(); // simulates non-timeout error
94+
assert.strictEqual(state.timeoutCount, 0);
95+
// Next timeout should not trigger kill
96+
const shouldKill = state.onTimeout();
97+
assert.strictEqual(shouldKill, false);
98+
});
99+
100+
test('interleaved non-timeout error prevents premature kill', () => {
101+
state.onTimeout(); // count = 1
102+
state.reset(); // non-timeout error resets
103+
state.onTimeout(); // count = 1 again (not 2)
104+
assert.strictEqual(state.timeoutCount, 1);
105+
// Still shouldn't kill — only 1 consecutive timeout
106+
assert.strictEqual(state.getTimeoutMs(), 60_000);
107+
});
108+
109+
test('reset after kill allows fresh retry cycle', () => {
110+
state.onTimeout();
111+
state.onTimeout(); // kill → reset
112+
// Counter was reset by onTimeout when it returned true
113+
assert.strictEqual(state.timeoutCount, 0);
114+
assert.strictEqual(state.getTimeoutMs(), 30_000);
115+
// First timeout of new cycle should not kill
116+
const shouldKill = state.onTimeout();
117+
assert.strictEqual(shouldKill, false);
118+
});
119+
});

0 commit comments

Comments
 (0)