fix: bound end-to-end refresh discovery latency - #17
Conversation
Cap NativePythonFinder refresh latency with one monotonic operation budget (184s, derived from existing stage constants) captured at enqueue. The WorkerPool gains optional pending-task expiration so a queued refresh behind a stuck one is rejected with QueueTaskExpiredError and never runs; the same deadline clamps every running stage (configure/refresh/resolve/restart/CLI) and fails fast below a justified 1s floor. The CLI fallback never truncates the enumerated environment list when enrichment runs out of budget. Queue expiration and budget exhaustion classify as rpc_timeout via existing telemetry patterns. resolve() and other non-refresh callers pass no deadline and are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
A moderate CLI telemetry issue and two documentation nits remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Bounds NativePythonFinder refresh latency with a shared deadline across queued work, PET stages, and CLI enrichment.
Changes:
- Adds monotonic refresh budgets and timeout clamping.
- Adds optional queued-task expiration to
WorkerPool. - Adds partial CLI telemetry and timeout classification tests.
File summaries
| File | Review status |
|---|---|
src/test/managers/common/nativePythonFinder.budget.unit.test.ts |
Reviewed; no final comments. |
src/test/common/utils/workerPool.unit.test.ts |
Reviewed; no final comments. |
src/test/common/telemetry/errorClassifier.unit.test.ts |
Reviewed; no final comments. |
src/managers/common/nativePythonFinder.ts |
Moderate issue: mark final-batch budget exhaustion as partial telemetry. Nit: correct “at or below” to “below.” |
src/common/utils/workerPool.ts |
Reviewed; no final comments. |
src/common/telemetry/errorClassifier.ts |
Reviewed; no final comments. |
src/common/telemetry/constants.ts |
Nit: document partial as a valid result. |
Review details
Suppressed comments (1)
src/managers/common/nativePythonFinder.ts:1533
- The deadline is absolute, but
resolveTimeoutis sampled once beforebatch.mapand reused for all four child processes. Since eachresolveViaJsonClistarts its timer only when that map callback runs, spawn/setup time can make later children run past the shared deadline (especially when only the 1s floor remains). Clamp from theDeadlineper child at invocation, or pass the absolute deadline into the process runner.
let resolveTimeout: number;
try {
resolveTimeout = clampTimeoutToRemaining(CLI_FALLBACK_TIMEOUT_MS, deadline, 'cli_resolve');
- Files reviewed: 7/7 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
🔒 Automated review in progress — @StellaHuang95 is auto-reviewing this PR. |
|
GitHub cannot anchor PR review comments to unchanged lines in the diff. Falling back to a general PR comment for src/managers/common/nativePythonFinder.ts:L663.
After sleeping for all remaining time, [verified] |
Corrections to the operation-budget latency bound (PR #17): - Queue expiry is now absolute: WorkQueue stores expiresAt and next() rechecks now() >= expiresAt before running, so a delayed timer / event-loop stall can never let a past-deadline item execute. The parked worker is re-parked if an item expires synchronously at enqueue, so later work still runs. - restart() rechecks the deadline after its clamped backoff and immediately before teardown/spawn; PET is never started after the budget is exhausted. - A deadline-clamped configure/refresh/resolve/CLI timeout is reclassified to RefreshBudgetExceededError before ordinary stage retry counters / telemetry mutate (deadline provenance). Enforcing the budget does not terminate in-flight PET. - Operation budget now reserves the full CLI enumeration timeout: budget = max(server retry path 184s, one worst server attempt + transition + full CLI scan 214s) = 214s; a second server retry is skipped when it would starve the reserved CLI budget, so a valid 60s CLI scan remains fundable after a server failure. - Enrichment-incomplete CLI results are returned to the caller in full but flagged complete:false so the partial is not cached; a later refresh retries enrichment. Enumeration timeouts still reject. Non-refresh resolve() and other deadline-free callers are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses the Copilot review on PR #17: - Extract doRefresh's post-failure control flow into a pure, exported decideRefreshRetry() so the composed retry / CLI-fallback / budget decision is unit-testable without spawning a real PET process. Adds a regression test proving server exhaustion falls back to the CLI (with the full reserved enumeration budget) instead of a budget-starving retry - the exact path the earlier bug got wrong. doRefresh stays behaviorally identical; all logs, throws, and kill/restart side effects remain in place. - The cli-fallback decision now carries its reason ('starvation' vs 'server-exhausted') so the caller's log selection rides with the decision instead of being re-derived. - Correct the Deadline.isExhausted docstring ("at or below" -> "below") to match the strict `<` floor comparison. Already-resolved findings confirmed: the CLI-budget starvation (214s budget + reservation) and the final-batch partial-enrichment telemetry flag were fixed in the prior commit; the telemetry result union already documents 'partial'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses a Copilot review finding on PR #17: toBudgetError converted any RpcTimeoutError to a RefreshBudgetExceededError whenever the deadline was post-hoc exhausted (deadline.isExhausted()), even when the stage ran on its own full, unclamped base timeout and merely happened to finish with less than the floor of budget left. Example: a 30s base timeout that starts with 30.5s remaining is NOT clamped (min(30000, 30500) = 30000 = base); if it genuinely times out (slow PET), remaining is now 0.5s < 1s floor, so the old code misclassified a real PET timeout as a budget cap - skipping normal stage-timeout telemetry, retry state, and PET recovery. - toBudgetError now takes wasClamped and reclassifies iff deadline !== undefined && wasClamped && ex instanceof RpcTimeoutError. The post-hoc isExhausted() check is removed: a stage clamped to the remaining budget that then times out necessarily consumed the whole budget (a true budget cap), while an unclamped stage that times out is genuine PET slowness regardless of leftover budget. - The refresh and configure callers compute wasClamped at clamp time as effectiveTimeout < baseTimeout (the deadline was the binding constraint). refreshTimeoutMs is hoisted so the catch can compare it to the base; configure captures baseConfigureTimeoutMs before clamping (frozen before configureRetry.onTimeout() can mutate it). - Tests: pass wasClamped through the existing cases, add a regression proving an unclamped 30s/30.5s stage that ends exhausted is NOT a budget error, and assert the RefreshBudgetExceededError message exactly. No-deadline / non-refresh resolve() callers are unchanged (wasClamped is always false without a deadline). Verified: lint clean, compile-tests clean, 67 targeted + 1738 full passing (only the pre-existing, unrelated inlineScriptCacheLayout EPERM flake fails). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| this.outputChannel.error('[pet] JSON CLI fallback refresh failed:', ex); | ||
| // A budget-clamped enumeration that timed out is an incomplete enumeration: surface it as | ||
| // a budget error rather than a generic CLI timeout so it classifies consistently. | ||
| if (deadline?.isExhausted()) { |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
Track whether the CLI find timeout was actually deadline-clamped and reclassify only that timeout-specific failure. The current post-failure deadline?.isExhausted() check can turn an unclamped CLI timeout or unrelated CLI failure that happens near the budget boundary into RefreshBudgetExceededError; the enrichment catches have the same provenance problem. Add an injected-clock regression for the ordinary-timeout boundary.
[verified]
Summary
Bounds the end-to-end latency of a
NativePythonFinderrefresh so a single stuck/slow PET run can no longer stall discovery indefinitely. One monotonic operation budget is captured at enqueue time and used two ways: theWorkerPoolrejects the item if it is still queued when the budget elapses, and the same deadline clamps every extension-controlled running stage (configure / refresh / resolve / restart backoff / CLI fallback). This caps both (1) distinct-key queue waits behind a stuck refresh in the 1-worker pool and (2) CLI-fallback enrichment that otherwise scales with the environment count — without ever truncating the set of discovered environments and without changingresolve()or any non-refresh caller.This revision incorporates two rounds of review feedback: the queue expiry is now absolute (not timer-only),
restart()rechecks the deadline after its backoff and before spawn, a deadline-clamped stage timeout is reclassified to a budget error before ordinary stage telemetry/counters mutate, the operation budget now reserves the full CLI enumeration timeout so a valid CLI fallback is never cut to a small remainder, and an enrichment-incomplete refresh is returned to the caller but not cached so a later refresh retries enrichment.Operation budget — exact timing sequence / arithmetic
The budget is derived from existing stage constants, not a magic number, and is the maximum of the two reachable successful paths so it can never truncate either valid flow:
Server retry path (184s). A successful
doRefreshruns at mostMAX_REFRESH_RETRIES + 1 = 2attempts. The attained worst-case is reachable when the operation enters with an elevated restart state (restartAttempts = 2) and a pending configure-timeout backoff (configureRetry.timeoutCount = 1) while the process is healthy:Key coupling that makes this an attained maximum, not a loose over-approximation:
restart()callsconfigureRetry.reset(), so a single attempt can never have both a restart and a 60s extended configure. The failing attempt hits the 60s configure maximum (no restart); the succeeding attempt hits the restart-backoff maximum (configure reset to 30s). Their maxima occur on different attempts under the same reachable entry state, so summing them is legitimate. The formula is expressed in terms ofMAX_REFRESH_RETRIESso it scales if the retry count changes.CLI-fallback path (214s). A valid CLI fallback is itself an existing successful path and must keep its established enumeration timeout instead of being cut to whatever the server path leaves behind. It is bounded as one worst failing server attempt + the restart/kill transition into the fallback + a full CLI scan:
To keep the server path from consuming this reserve,
doRefreshskips the second server retry whenever another attempt would leave less than a full CLI scan:So after a 90s failing server attempt (≈124s of a fresh 214s budget remaining), the guard trips (124_000 < 185_000) and we fall back to the CLI immediately with the full ~124s — a 60s valid CLI scan is always allowed after a server failure while total latency stays ≤ 214s. This deliberately avoids stacking every pathological timeout into an arbitrary cap: the budget is exactly the larger of two successful paths.
Floor:
MIN_STAGE_BUDGET_MS = 1_000. Below 1s remaining we fail fast instead of handing a stage a near-zero timeout that cannot complete (the fastest PET JSON-RPC round-trip,info, is already budgeted a "generous" 2s).Concrete reproductions this fixes
QueueTaskExpiredError— it never executes, and later refreshes keep working. The expiry is absolute: even if the expiry timer callback is delayed (event-loop stall),next()rechecksnow() >= expiresAtbefore dequeuing, so a past-deadline item can never start.pet find --jsonand then resolves each incomplete environment (batched byCLI_RESOLVE_CONCURRENCY). On a machine with hundreds of incomplete envs, enrichment time scales with the count. After: once the shared budget is spent, enrichment stops and every already-discovered environment is still returned (unresolved records retained as-is). The result is additionally flaggedcomplete: falseso it is returned to the caller but not written to the soft cache, and a later refresh retries the enrichment.Deadline & state-machine semantics
Deadlineis an absolute, monotonic deadline (performance.now(), injectable for tests).remainingMs()counts down;isExhausted(floor)isremaining < floor(strict).clampTimeoutToRemaining(base, deadline?, stage, floor?): returnsbaseunchanged whendeadlineisundefined(preserves non-refresh callers), throwsRefreshBudgetExceededErrorwhenremaining < floor, else returnsmin(base, remaining).WorkerPoolabsolute pending-task expiration (addToQueue(item, position?, expiresInMs?)): the queued wrapper stores an absoluteexpiresAt(from an injectablenowclock, defaultDate.now) in addition to arming asetTimeout. Transitions are queued → running | expired → settled, exactly once:expire()(timer wins while queued): guarded byrunning/expiredflags and anindexOfcheck; removes the item from the queue and settles it once viasettleExpired().next()(dequeue): rechecksnow() >= expiresAtbefore transitioning to running, so a delayed timer / event-loop stall can never let a past-deadline item run — it is settled as expired and the loop continues to the next queued item. On a genuine dequeue it setsrunning = trueand clears the timer before the worker starts (one-way transition; a running item can never expire).completed()(settle), and onclear()(stop). NoPromise.race, no abandoned task. Behavior is unchanged whenexpiresInMsis omitted (original unbounded queueing), keeping the pool generic even though only the finder uses it today.restart()deadline recheck: the (clamped) exponential backoff now runs throughbackoffThenCheckBudget(waitMs, deadline), which waits and then rechecks the deadline immediately before teardown / state-reset / spawn — PET is never started after the budget is spent. If the budget expires during the wait, it throwsRefreshBudgetExceededError; the catch undoes the speculativerestartAttempts++(no process was spawned) and skips restart-error telemetry. The pre-existing entry-level fail-fast check (before any state mutation) is retained.toBudgetError(ex, deadline, stage, wasClamped)): only a stage whose timeout was actually clamped to the remaining budget — i.e. the deadline, not the stage's own base timeout, was the binding constraint (effectiveTimeout < baseTimeout, computed at clamp time by each caller) — is reclassified. Such a clamped stage that times out necessarily consumed the whole budget, so itsRpcTimeoutErroris converted toRefreshBudgetExceededErrorbeforeconfigure()/doRefreshAttempt()mutate ordinary stage retry counters (configureRetry.onTimeout()) or emitPET_CONFIGURE/PET_REFRESHstage-timeout telemetry — so a budget cap is never misattributed to a slow PET. A stage that ran on its unclamped base timeout and merely finished with little budget left (e.g. a 30s timeout that started with 30.5s remaining) is a genuine PET timeout and keeps normal timeout/retry/recovery handling. The reclassification uses this clamp-time signal, not a post-hocdeadline.isExhausted()check, so a real slow-PET timeout at a budget boundary is never swallowed by the budget path.Uncancellable PET boundary (explicitly out of scope)
This change bounds only extension-controlled waits. It reuses the existing
sendRequestWithTimeoutcancellation (CancellationTokenSource+ timer) and the existing process-termination paths (killProcess) to stop waiting and, where applicable, kill a hung PET process. It does not add cooperative cancellation inside PET (the Rust locator).Decision on terminating in-flight PET for the deadline (requested in review): enforcing the operation deadline does not terminate the PET process. Exhausting our budget does not mean PET is unhealthy (it may simply be enumerating a very large environment set), and
sendRequestWithTimeoutalready cancels the in-flight RPC via itsCancellationTokenSource. So a budget-clamped configure/refresh timeout flows through the budget path without killing the process or incrementing normal configure-timeout state; the warm process is preserved for the next operation (which starts with a fresh deadline). Ordinary (non-budget) refresh/connection timeouts retain their existing kill-for-restart behavior. PET-side cooperative cancellation remains future work.Safety & compatibility
resolve()and every other non-refresh caller pass no deadline, soclampTimeoutToRemainingreturns the base timeout, restart backoff is unclamped (backoffThenCheckBudgetjust waits), andresolveViaJsonCli's default timeout is unchanged — existing behavior preserved.resolve()does not use the pool and still returnsNativeEnvInfo.RefreshResult { info, complete }is confined to the refresh worker path (doRefresh/doRefreshAttempt/refreshViaJsonCli→handleHardRefresh). Consumers ofrefresh()still receiveNativeInfo[];handleHardRefreshalways returns the full enumerated list and only decides whether to cache it based oncomplete. Because itcache.delete(key)up front, an uncached partial leaves no entry, so the next soft refresh re-hard-refreshes and retries enrichment.finddegrades to "retain all + mark incomplete".QueueTaskExpiredErrorandRefreshBudgetExceededErrorare both time-budget exhaustions, classified via the existing telemetry patterns asrpc_timeout(soisTimeoutErrorTyperecordstimeout). Theinstanceofbranch is placed before message-pattern matching (the budget error message can contain the word "restart", which would otherwise mis-classify asprocess_crash).ReturnType<typeof setTimeout>,perf_hooks).Tests
workerPool.unit.test.ts, deterministic fake timers):expiresInMspreserves unbounded queueing.setTimeout): an event-loop stall where the clock jumps past the deadline but the timer has not fired —next()'s absolute recheck expires the item, it never runs, and later work still runs; a boundary case wherenow == expiresAtexpires (>=);next()skips a stalled-expired item and continues to the next valid queued item; and an already-expired enqueue (expiresInMs <= 0) rejects immediately without stranding the single parked worker (later work still runs, proving the worker is re-parked rather than lost).nativePythonFinder.budget.unit.test.ts, injected clock, pure/injected-clock seams):computeServerRefreshBudgetMs= 184000,computeCliFallbackPathBudgetMs= 214000,computeRefreshOperationBudgetMs= max = 214000, and a boundedness assertion (not an arbitrary stack).retryWouldStarveCliFallbackMs: 185000 threshold; true when a retry would starve the CLI (e.g. 124s remaining); false with ample budget; and an explicit proof that a 60s valid CLI scan remains fundable after a 90s server failure while total latency stays bounded.decideRefreshRetry(composed retry/CLI-fallback decision): the post-failure control flow ofdoRefreshis extracted into a pure, exported decision function so the composition — not just the arithmetic — is unit-tested without spawning a real PET. Cases: a budget error stops immediately; a retryable failure with ample budget retries; a regression test proving server exhaustion → CLI fallback when another server attempt would starve the reserved CLI budget (this is the exact path the earlier 184s budget bug got wrong); a sub-floor budget yields a budget error; the final attempt falls back iff server mode is exhausted else rethrows; and the no-deadline (resolve-like) path always retries a mid-attempt failure.toBudgetErrorprovenance:undefinedwith no deadline / for a non-timeout error / for an unclamped stage while the budget is healthy; a regression proving an unclamped 30s stage that started with 30.5s remaining and times out is not reclassified as a budget error even though the deadline is now exhausted (thewasClampedclamp-time signal, not a post-hocisExhausted(), gates reclassification); reclassifies a genuinely deadline-clampedRpcTimeoutError; and does not reclassify a clamped non-timeout error. TheRefreshBudgetExceededErrormessage is asserted exactly.backoffThenCheckBudget: no-throw with no deadline (still waits); rejects when the budget expires during the wait; resolves when budget remains.Deadline,clampTimeoutToRemainingpassthrough/clamp/floor-throw, and stage-to-stage propagation shrinking the clamp then failing fast.errorClassifier.unit.test.ts): both new errors classify asrpc_timeoutand register as timeouts.inlineScriptCacheLayoutconcurrent writeMetaJson callsWindowsfs.renameEPERM flake, which passes in isolation and is outside this diff). Lint clean,compile-testsclean.Limitations
'all', de-duplicated viainFlightRefreshes), so genuinely distinct-key contention is rare.NativePythonFinderImpl(spawning a real PET process and driving RPC over stdio) is not exercised in-process because the impl is not exported and its constructor spawns a process — adding a spawn/connection injection seam would be a broad test-only export. Instead the composed decision that had the CLI-starvation bug (decideRefreshRetry) is now a pure, directly-tested seam, layered on the realWorkerPoolqueue path plus the exported budget primitives (Deadline,clampTimeoutToRemaining,toBudgetError,backoffThenCheckBudget,retryWouldStarveCliFallbackMs) the stages are built from.bound-discovery-latency(the rename tooling was unavailable in this environment); the intended name is noted here.