diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index f9d396a85..6b3432eb3 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -601,27 +601,10 @@ export interface IEventNamePropertyMapping { "": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" } } */ + // Numeric fields declared in the GDPR block are sent through the measurements payload. [EventNames.PET_REFRESH]: { result: 'success' | 'timeout' | 'error'; - envCount?: number; - /** Number of discovered environments whose kind is Conda. Lets us slice refresh duration by conda footprint. */ - condaEnvCount?: number; - /** Number of discovered environment managers (conda/pyenv/poetry/etc.). */ - managerCount?: number; - unresolvedCount?: number; - workspaceDirCount?: number; - searchPathCount?: number; - attempt: number; errorType?: string; - // breakdown* fields go through the measures payload (numeric); listed here for GDPR only. - /** ms in the Locators phase. */ - breakdownLocators?: number; - /** ms walking PATH env var entries (not a file path). */ - breakdownPathEnv?: number; - /** ms scanning global virtual-env dirs. */ - breakdownGlobalVirtualEnvs?: number; - /** ms scanning workspace dirs. */ - breakdownWorkspaces?: number; /** JSON-serialized Record. Parse with parse_json() in Kusto. */ locatorsJson?: string; /** PET crate version reported by the `info` RPC. 'unknown' if the call failed or the PET binary doesn't implement it. */ @@ -638,14 +621,14 @@ export interface IEventNamePropertyMapping { "workspaceDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }, "envDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }, "retryCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }, + "errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }, "": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" } } */ + // Numeric fields declared in the GDPR block are sent through the measurements payload. [EventNames.PET_CONFIGURE]: { result: 'success' | 'timeout' | 'error' | 'skipped'; - workspaceDirCount?: number; - envDirCount?: number; - retryCount: number; + errorType?: string; }; /* __GDPR__ @@ -660,8 +643,8 @@ export interface IEventNamePropertyMapping { "": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" } } */ + // `attempt` is declared in the GDPR block and sent through the measurements payload. [EventNames.PET_PROCESS_RESTART]: { - attempt: number; result: 'success' | 'error'; errorType?: string; /** diff --git a/src/common/telemetry/errorClassifier.ts b/src/common/telemetry/errorClassifier.ts index 910c179e2..355d9999a 100644 --- a/src/common/telemetry/errorClassifier.ts +++ b/src/common/telemetry/errorClassifier.ts @@ -13,6 +13,10 @@ export type DiscoveryErrorType = | 'command_failed' | 'connection_error' | 'rpc_error' + | 'rpc_timeout' + | 'rpc_configure_timeout' + | 'rpc_refresh_timeout' + | 'rpc_resolve_timeout' | 'process_crash' | 'already_registered' | 'unknown'; @@ -27,7 +31,16 @@ export function classifyError(ex: unknown): DiscoveryErrorType { } if (ex instanceof RpcTimeoutError) { - return 'spawn_timeout'; + switch (ex.method) { + case 'configure': + return 'rpc_configure_timeout'; + case 'refresh': + return 'rpc_refresh_timeout'; + case 'resolve': + return 'rpc_resolve_timeout'; + default: + return 'rpc_timeout'; + } } // JSON-RPC connection errors (e.g., PET process died mid-request, connection closed/disposed) diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index e99394f84..ec6394b7e 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -17,6 +17,7 @@ import { untildify, untildifyArray } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { createRunningWorkerPool, WorkerPool } from '../../common/utils/workerPool'; import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis'; +import { getRefreshTelemetryMeasures, type RefreshPerformance } from './petTelemetry'; import { noop } from './utils'; // Timeout constants for JSON-RPC requests (in milliseconds) @@ -25,6 +26,7 @@ const MAX_CONFIGURE_TIMEOUT_MS = 60_000; // Max configure timeout after retries const REFRESH_TIMEOUT_MS = 30_000; // 30 seconds for full refresh (with 1 retry = 60s max) const RESOLVE_TIMEOUT_MS = 30_000; // 30 seconds for single resolve const INFO_TIMEOUT_MS = 2_000; // `info` is a const lookup on PET; 2s is generous +const INFO_REQUEST_ATTEMPTS = 3; // Retry early startup timeouts without blocking PET operations // CLI fallback timeout: generous budget since it's a full process spawn doing a full scan const CLI_FALLBACK_TIMEOUT_MS = 120_000; // 2 minutes @@ -249,15 +251,6 @@ interface RefreshOptions { searchPaths?: string[]; } -/** Performance breakdown sent by PET via the `telemetry` notification after a refresh. */ -interface RefreshPerformance { - total: number; - /** Phase name (Locators | Path | GlobalVirtualEnvs | Workspaces) → wall-clock ms */ - breakdown: Record; - /** Locator name (Conda | WindowsRegistry | …) → wall-clock ms; only ran locators are present */ - locators: Record; -} - /** Params shape of the PET `telemetry` JSON-RPC notification. */ interface PetTelemetryNotification { event: string; @@ -290,6 +283,29 @@ export class RpcTimeoutError extends Error { } } +/** Retries only JSON-RPC timeout failures; all other errors propagate immediately. */ +export async function retryRpcTimeout( + request: () => Promise, + maxAttempts: number, + shouldRetry: () => boolean = () => true, +): Promise { + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new RangeError('maxAttempts must be a positive integer'); + } + + let attempt = 0; + while (true) { + attempt++; + try { + return await request(); + } catch (ex) { + if (!(ex instanceof RpcTimeoutError) || attempt >= maxAttempts || !shouldRetry()) { + throw ex; + } + } + } +} + /** * Wraps a JSON-RPC sendRequest call with a timeout. * @param connection The JSON-RPC connection @@ -340,10 +356,9 @@ class NativePythonFinderImpl implements NativePythonFinder { private processExitReason: string | undefined = undefined; private readonly configureRetry = new ConfigureRetryState(); /** - * Cached PET `info` response for the current connection. Reset to undefined on every - * `start()` and re-populated asynchronously by `kickoffInfoFetch()`. Telemetry callers - * read this via `getPetInfoProperties()`; if the fetch hasn't finished yet (or the PET - * binary is too old to implement `info`), telemetry reports 'unknown'. + * Last successful PET `info` response. It survives process restarts because the executable + * path is unchanged, then refreshes asynchronously for each new connection. This prevents + * a transient startup timeout from erasing known build attribution. */ private petInfo: NativePetInfo | undefined; @@ -401,7 +416,7 @@ class NativePythonFinderImpl implements NativePythonFinder { EventNames.PET_RESOLVE, sw.elapsedTime, { - result: errorType === 'spawn_timeout' ? 'timeout' : 'error', + result: ex instanceof RpcTimeoutError ? 'timeout' : 'error', errorType, ...this.getPetInfoProperties(), }, @@ -484,21 +499,23 @@ class NativePythonFinderImpl implements NativePythonFinder { this.connection = this.start(); this.outputChannel.info('[pet] Python Environment Tools restarted successfully'); - sendTelemetryEvent(EventNames.PET_PROCESS_RESTART, sw.elapsedTime, { - attempt, - result: 'success', - triggerReason, - ...this.getPetInfoProperties(), - }); + sendTelemetryEvent( + EventNames.PET_PROCESS_RESTART, + { duration: sw.elapsedTime, attempt }, + { + result: 'success', + triggerReason, + ...this.getPetInfoProperties(), + }, + ); // Reset restart attempts on successful start (process didn't immediately fail) // We'll reset this only after a successful request completes } catch (ex) { sendTelemetryEvent( EventNames.PET_PROCESS_RESTART, - sw.elapsedTime, + { duration: sw.elapsedTime, attempt }, { - attempt, result: 'error', errorType: classifyError(ex), triggerReason, @@ -757,7 +774,6 @@ class NativePythonFinderImpl implements NativePythonFinder { connection.listen(); // Stamp PET telemetry with version/buildId/commitSha. Fire-and-forget — must not block refresh. - this.petInfo = undefined; this.kickoffInfoFetch(connection); return connection; @@ -767,13 +783,16 @@ class NativePythonFinderImpl implements NativePythonFinder { * Asks the PET server for its build metadata (version + optional buildId + optional commitSha) * and caches it in `this.petInfo` for downstream telemetry. Runs once per `start()` call. * - * Fire-and-forget by design — the response is not awaited so refresh/resolve callers are - * never blocked. The 2 s timeout caps the worst case if PET is misbehaving. If a newer - * connection has replaced `this.connection` by the time the response arrives, the response - * is dropped to avoid clobbering the cache for the newer process. + * Fire-and-forget by design: refresh/resolve callers are never blocked. Early timeout + * failures are retried with a bounded attempt count; older PET binaries and connection + * failures still fail immediately. Responses from superseded connections are discarded. */ private kickoffInfoFetch(connection: rpc.MessageConnection): void { - sendRequestWithTimeout(connection, 'info', {}, INFO_TIMEOUT_MS) + retryRpcTimeout( + () => sendRequestWithTimeout(connection, 'info', {}, INFO_TIMEOUT_MS), + INFO_REQUEST_ATTEMPTS, + () => connection === this.connection, + ) .then((result) => { if (connection !== this.connection) { return; @@ -785,8 +804,8 @@ class NativePythonFinderImpl implements NativePythonFinder { if (connection !== this.connection) { return; } - // Older PET binaries don't implement `info`; leave petInfo undefined so telemetry reports 'unknown'. - this.outputChannel.debug('[pet] info request failed:', ex); + // Older PET binaries don't implement `info`; preserve any prior successful attribution. + this.outputChannel.debug('[pet] info request failed after bounded retries:', ex); }); } @@ -803,32 +822,6 @@ class NativePythonFinderImpl implements NativePythonFinder { }; } - /** - * Computes environment-shape counts from a refresh result for telemetry. These let us slice - * refresh duration by how many environments (and how many conda environments) were discovered, - * to test whether the slow-refresh cohort is dominated by conda-heavy / many-env setups. - */ - private getEnvShapeProperties(nativeInfo: NativeInfo[]): { - envCount: number; - condaEnvCount: number; - managerCount: number; - } { - let envCount = 0; - let condaEnvCount = 0; - let managerCount = 0; - for (const info of nativeInfo) { - if (isNativeEnvInfo(info)) { - envCount++; - if (info.kind === NativePythonEnvironmentKind.conda) { - condaEnvCount++; - } - } else { - managerCount++; - } - } - return { envCount, condaEnvCount, managerCount }; - } - private async doRefresh(options?: NativePythonEnvironmentKind | Uri[]): Promise { let lastError: unknown; @@ -885,11 +878,14 @@ class NativePythonFinderImpl implements NativePythonFinder { const sw = new StopWatch(); let unresolvedCount = 0; let refreshPerf: RefreshPerformance | undefined; + let workspaceDirCount: number | undefined; + let searchPathCount: number | undefined; try { - await this.configure(); + const configuration = await this.buildConfigurationOptions(); + workspaceDirCount = configuration.workspaceDirectories.length; + searchPathCount = configuration.environmentDirectories.length; + await this.configure(configuration); const refreshOptions = this.getRefreshOptions(options); - const workspaceDirCount = this.lastConfiguration?.workspaceDirectories.length ?? 0; - const searchPathCount = this.lastConfiguration?.environmentDirectories.length ?? 0; disposables.push( this.connection.onNotification('environment', (data: NativeEnvInfo) => { this.outputChannel.info(`Discovered env: ${data.executable || data.prefix}`); @@ -942,28 +938,17 @@ class NativePythonFinderImpl implements NativePythonFinder { sendTelemetryEvent( EventNames.PET_REFRESH, - { + getRefreshTelemetryMeasures({ duration: sw.elapsedTime, - ...(refreshPerf?.breakdown['Locators'] !== undefined && { - breakdownLocators: refreshPerf.breakdown['Locators'], - }), - ...(refreshPerf?.breakdown['Path'] !== undefined && { - breakdownPathEnv: refreshPerf.breakdown['Path'], - }), - ...(refreshPerf?.breakdown['GlobalVirtualEnvs'] !== undefined && { - breakdownGlobalVirtualEnvs: refreshPerf.breakdown['GlobalVirtualEnvs'], - }), - ...(refreshPerf?.breakdown['Workspaces'] !== undefined && { - breakdownWorkspaces: refreshPerf.breakdown['Workspaces'], - }), - }, - { - result: 'success', - ...this.getEnvShapeProperties(nativeInfo), + nativeInfo, unresolvedCount, workspaceDirCount, searchPathCount, attempt, + refreshPerformance: refreshPerf, + }), + { + result: 'success', locatorsJson: refreshPerf ? JSON.stringify(refreshPerf.locators) : undefined, ...this.getPetInfoProperties(), }, @@ -972,12 +957,17 @@ class NativePythonFinderImpl implements NativePythonFinder { const errorType = classifyError(ex); sendTelemetryEvent( EventNames.PET_REFRESH, - sw.elapsedTime, - { - result: errorType === 'spawn_timeout' ? 'timeout' : 'error', - ...this.getEnvShapeProperties(nativeInfo), + getRefreshTelemetryMeasures({ + duration: sw.elapsedTime, + nativeInfo, unresolvedCount, + workspaceDirCount, + searchPathCount, attempt, + refreshPerformance: refreshPerf, + }), + { + result: ex instanceof RpcTimeoutError ? 'timeout' : 'error', errorType, ...this.getPetInfoProperties(), }, @@ -1008,15 +998,21 @@ class NativePythonFinderImpl implements NativePythonFinder { * Configuration request, this must always be invoked before any other request. * Must be invoked when ever there are changes to any data related to the configuration details. */ - private async configure() { - const options = await this.buildConfigurationOptions(); + private async configure(options?: ConfigurationOptions) { + const configuration = options ?? (await this.buildConfigurationOptions()); + const workspaceDirCount = configuration.workspaceDirectories.length; + const envDirCount = configuration.environmentDirectories.length; // No need to send a configuration request if there are no changes. - if (this.lastConfiguration && this.configurationEquals(options, this.lastConfiguration)) { + if (this.lastConfiguration && this.configurationEquals(configuration, this.lastConfiguration)) { this.outputChannel.debug('[pet] configure: No changes detected, skipping configuration update.'); - sendTelemetryEvent(EventNames.PET_CONFIGURE, 0, { result: 'skipped', retryCount: 0 }); + sendTelemetryEvent( + EventNames.PET_CONFIGURE, + { duration: 0, workspaceDirCount, envDirCount, retryCount: 0 }, + { result: 'skipped' }, + ); return; } - this.outputChannel.info('[pet] configure: Sending configuration update:', JSON.stringify(options)); + this.outputChannel.info('[pet] configure: Sending configuration update:', JSON.stringify(configuration)); // Exponential backoff: 30s, 60s on retry. Capped at REFRESH_TIMEOUT_MS. const timeoutMs = this.configureRetry.getTimeoutMs(); if (this.configureRetry.timeoutCount > 0) { @@ -1026,28 +1022,23 @@ class NativePythonFinderImpl implements NativePythonFinder { } const sw = new StopWatch(); const retryCount = this.configureRetry.timeoutCount; - const workspaceDirCount = options.workspaceDirectories.length; - const envDirCount = options.environmentDirectories.length; try { - await sendRequestWithTimeout(this.connection, 'configure', options, timeoutMs); + await sendRequestWithTimeout(this.connection, 'configure', configuration, timeoutMs); // Only cache after success so failed/timed-out calls will retry - this.lastConfiguration = options; + this.lastConfiguration = configuration; this.configureRetry.onSuccess(); - sendTelemetryEvent(EventNames.PET_CONFIGURE, sw.elapsedTime, { - result: 'success', - workspaceDirCount, - envDirCount, - retryCount, - }); + sendTelemetryEvent( + EventNames.PET_CONFIGURE, + { duration: sw.elapsedTime, workspaceDirCount, envDirCount, retryCount }, + { result: 'success' }, + ); } catch (ex) { sendTelemetryEvent( EventNames.PET_CONFIGURE, - sw.elapsedTime, + { duration: sw.elapsedTime, workspaceDirCount, envDirCount, retryCount }, { result: ex instanceof RpcTimeoutError ? 'timeout' : 'error', - workspaceDirCount, - envDirCount, - retryCount, + errorType: classifyError(ex), }, ex instanceof Error ? ex : undefined, ); diff --git a/src/managers/common/petTelemetry.ts b/src/managers/common/petTelemetry.ts new file mode 100644 index 000000000..d7ed71e3f --- /dev/null +++ b/src/managers/common/petTelemetry.ts @@ -0,0 +1,72 @@ +interface RefreshTelemetryInfo { + kind?: string; + tool?: string; +} + +/** Performance breakdown sent by PET via the `telemetry` notification after a refresh. */ +export interface RefreshPerformance { + total: number; + /** Phase name (Locators | Path | GlobalVirtualEnvs | Workspaces) to wall-clock ms. */ + breakdown: Record; + /** Locator name (Conda | WindowsRegistry | ...) to wall-clock ms; only ran locators are present. */ + locators: Record; +} + +export interface RefreshTelemetryMeasuresInput { + duration: number; + nativeInfo: readonly RefreshTelemetryInfo[]; + unresolvedCount: number; + attempt: number; + workspaceDirCount?: number; + searchPathCount?: number; + refreshPerformance?: RefreshPerformance; +} + +const REFRESH_BREAKDOWN_MEASURES = [ + ['Locators', 'breakdownLocators'], + ['Path', 'breakdownPathEnv'], + ['GlobalVirtualEnvs', 'breakdownGlobalVirtualEnvs'], + ['Workspaces', 'breakdownWorkspaces'], +] as const; + +/** Builds the numeric PET refresh payload sent through telemetry measurements. */ +export function getRefreshTelemetryMeasures(input: RefreshTelemetryMeasuresInput): Record { + let envCount = 0; + let condaEnvCount = 0; + let managerCount = 0; + for (const info of input.nativeInfo) { + if (info.tool) { + managerCount++; + } else { + envCount++; + if (info.kind === 'Conda') { + condaEnvCount++; + } + } + } + + const measures: Record = { + duration: input.duration, + envCount, + condaEnvCount, + managerCount, + unresolvedCount: input.unresolvedCount, + attempt: input.attempt, + }; + if (input.workspaceDirCount !== undefined) { + measures.workspaceDirCount = input.workspaceDirCount; + } + if (input.searchPathCount !== undefined) { + measures.searchPathCount = input.searchPathCount; + } + + const breakdown = input.refreshPerformance?.breakdown; + if (breakdown) { + for (const [phase, measure] of REFRESH_BREAKDOWN_MEASURES) { + if (breakdown[phase] !== undefined) { + measures[measure] = breakdown[phase]; + } + } + } + return measures; +} diff --git a/src/test/common/telemetry/errorClassifier.unit.test.ts b/src/test/common/telemetry/errorClassifier.unit.test.ts index dfd3e25ae..8f40a07a3 100644 --- a/src/test/common/telemetry/errorClassifier.unit.test.ts +++ b/src/test/common/telemetry/errorClassifier.unit.test.ts @@ -11,8 +11,11 @@ suite('Error Classifier', () => { assert.strictEqual(classifyError(new CancellationError()), 'canceled'); }); - test('should classify RpcTimeoutError as spawn_timeout', () => { - assert.strictEqual(classifyError(new RpcTimeoutError('resolve', 30000)), 'spawn_timeout'); + test('should classify RPC timeouts by method', () => { + assert.strictEqual(classifyError(new RpcTimeoutError('configure', 30000)), 'rpc_configure_timeout'); + assert.strictEqual(classifyError(new RpcTimeoutError('refresh', 30000)), 'rpc_refresh_timeout'); + assert.strictEqual(classifyError(new RpcTimeoutError('resolve', 30000)), 'rpc_resolve_timeout'); + assert.strictEqual(classifyError(new RpcTimeoutError('info', 2000)), 'rpc_timeout'); }); test('should classify non-Error values as unknown', () => { diff --git a/src/test/common/telemetry/sender.unit.test.ts b/src/test/common/telemetry/sender.unit.test.ts new file mode 100644 index 000000000..5de98e59e --- /dev/null +++ b/src/test/common/telemetry/sender.unit.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert'; +import * as sinon from 'sinon'; +import { EventNames } from '../../../common/telemetry/constants'; +import { sendTelemetryEvent } from '../../../common/telemetry/sender'; +import { vscMockTelemetryReporter } from '../../mocks/vsc/telemetryReporter'; + +suite('Telemetry sender', () => { + let originalTestExecution: string | undefined; + let sendTelemetryStub: sinon.SinonStub; + + setup(() => { + originalTestExecution = process.env.VSC_PYTHON_CI_TEST; + delete process.env.VSC_PYTHON_CI_TEST; + sendTelemetryStub = sinon.stub(vscMockTelemetryReporter.prototype, 'sendTelemetryEvent'); + }); + + teardown(() => { + sinon.restore(); + if (originalTestExecution === undefined) { + delete process.env.VSC_PYTHON_CI_TEST; + } else { + process.env.VSC_PYTHON_CI_TEST = originalTestExecution; + } + }); + + test('sends total and stage setup durations as measurements', () => { + sendTelemetryEvent( + EventNames.SETUP_HANG_DETECTED, + { duration: 120_000, stageDuration: 45_000 }, + { failureStage: 'envSelection', globalScopeDeferred: 'deferred' }, + ); + + assert.strictEqual(sendTelemetryStub.callCount, 1); + assert.deepStrictEqual(sendTelemetryStub.firstCall.args, [ + EventNames.SETUP_HANG_DETECTED, + { failureStage: 'envSelection', globalScopeDeferred: 'deferred' }, + { duration: 120_000, stageDuration: 45_000 }, + ]); + }); +}); diff --git a/src/test/managers/common/nativePythonFinder.telemetry.unit.test.ts b/src/test/managers/common/nativePythonFinder.telemetry.unit.test.ts new file mode 100644 index 000000000..6fb1cac77 --- /dev/null +++ b/src/test/managers/common/nativePythonFinder.telemetry.unit.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert'; +import { + NativeInfo, + NativePythonEnvironmentKind, + RpcTimeoutError, + retryRpcTimeout, +} from '../../../managers/common/nativePythonFinder'; +import { getRefreshTelemetryMeasures } from '../../../managers/common/petTelemetry'; + +suite('NativePythonFinder telemetry', () => { + test('builds numeric refresh measures with available context', () => { + const nativeInfo: NativeInfo[] = [ + { executable: '/envs/conda/bin/python', kind: NativePythonEnvironmentKind.conda }, + { executable: '/workspace/.venv/bin/python', kind: NativePythonEnvironmentKind.venv }, + { tool: 'Conda', executable: '/tools/conda' }, + ]; + + const measures = getRefreshTelemetryMeasures({ + duration: 1200, + nativeInfo, + unresolvedCount: 1, + workspaceDirCount: 2, + searchPathCount: 3, + attempt: 1, + refreshPerformance: { + total: 1100, + breakdown: { + Locators: 100, + Path: 200, + GlobalVirtualEnvs: 300, + Workspaces: 400, + }, + locators: { Conda: 75 }, + }, + }); + + assert.deepStrictEqual(measures, { + duration: 1200, + envCount: 2, + condaEnvCount: 1, + managerCount: 1, + unresolvedCount: 1, + attempt: 1, + workspaceDirCount: 2, + searchPathCount: 3, + breakdownLocators: 100, + breakdownPathEnv: 200, + breakdownGlobalVirtualEnvs: 300, + breakdownWorkspaces: 400, + }); + }); + + test('omits refresh context that was unavailable before an early failure', () => { + const measures = getRefreshTelemetryMeasures({ + duration: 50, + nativeInfo: [], + unresolvedCount: 0, + attempt: 0, + }); + + assert.deepStrictEqual(measures, { + duration: 50, + envCount: 0, + condaEnvCount: 0, + managerCount: 0, + unresolvedCount: 0, + attempt: 0, + }); + }); + + test('retries an RPC timeout and returns the later result', async () => { + let attempts = 0; + + const result = await retryRpcTimeout(async () => { + attempts++; + if (attempts === 1) { + throw new RpcTimeoutError('info', 2000); + } + return { petVersion: '0.1.0', buildId: '42' }; + }, 3); + + assert.deepStrictEqual(result, { petVersion: '0.1.0', buildId: '42' }); + assert.strictEqual(attempts, 2); + }); + + test('does not retry non-timeout RPC failures', async () => { + const expected = new Error('method not found'); + let attempts = 0; + + await assert.rejects( + retryRpcTimeout(async () => { + attempts++; + throw expected; + }, 3), + (error: unknown) => error === expected, + ); + assert.strictEqual(attempts, 1); + }); + + test('stops retrying after the connection is superseded', async () => { + let attempts = 0; + let connectionIsCurrent = true; + + await assert.rejects( + retryRpcTimeout( + async () => { + attempts++; + connectionIsCurrent = false; + throw new RpcTimeoutError('info', 2000); + }, + 3, + () => connectionIsCurrent, + ), + RpcTimeoutError, + ); + assert.strictEqual(attempts, 1); + }); + + test('stops retrying RPC timeouts at the attempt limit', async () => { + let attempts = 0; + + await assert.rejects( + retryRpcTimeout(async () => { + attempts++; + throw new RpcTimeoutError('info', 2000); + }, 3), + RpcTimeoutError, + ); + assert.strictEqual(attempts, 3); + }); +});