fix: promptly reject in-flight PET RPC requests on process exit/error - #14
fix: promptly reject in-flight PET RPC requests on process exit/error#14StellaHuang95 wants to merge 3 commits into
Conversation
PET's stdout was piped into a PassThrough with {end:false}, and the child
exit/error handlers only set flags. The readable stream therefore never ended,
StreamMessageReader never observed EOF, the JSON-RPC connection never closed or
disposed, and pending configure/refresh/resolve/info requests hung for their
full 30-60s timeouts after PET had already died.
Tie each spawned child to its own streams, connection and disposables and end
those streams on that child's exit/error so the connection disposes and pending
requests reject promptly.
- Per-child localDisposables captured in the exit/error/onClose closures so a
dead child only ever tears down its own resources.
- Identity guard (this.proc === proc) stops a stale child from flipping shared
exit flags on a live replacement after restart().
- onClose disposes the captured localDisposables, never this.startDisposables,
so a stale close can't dispose a replacement connection.
- Idempotent endStreams() makes a duplicate error+exit harmless.
- Export NativePythonFinderImpl as an @internal test seam (not public API).
- Add deterministic fake-child/fake-RPC unit tests.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟢 Approval recommended
The fix is well-scoped, addresses the documented root cause directly, and is backed by deterministic unit tests; the remaining feedback is a minor test-mocking convention issue.
Pull request overview
This PR fixes a hang in NativePythonFinderImpl where in-flight PET JSON-RPC requests could wait out their full 30–60s timeout when the PET child process exits/errors, by ensuring the underlying JSON-RPC streams are ended and the per-child connection is disposed promptly (rejecting pending requests immediately). It also adds focused unit tests that exercise the real vscode-jsonrpc wiring against a fake child process to verify correct teardown and restart behavior.
Changes:
- Refactors PET process lifecycle management so each spawned child owns its own disposables, and termination always ends the RPC streams to trigger connection disposal.
- Guards shared “process exited” state updates with an identity check to prevent stale children from impacting a replacement connection after restart.
- Adds deterministic unit tests covering exit/error teardown, idempotency, stale-child safety, and restart usability.
File summaries
| File | Description |
|---|---|
| src/managers/common/nativePythonFinder.ts | Implements per-child resource ownership and guaranteed stream shutdown on PET exit/error to promptly reject pending RPC requests and avoid stale-close teardown of replacements. |
| src/test/managers/common/nativePythonFinder.petExit.unit.test.ts | Adds unit tests that drive the real jsonrpc connection against a fake child to validate prompt rejection and restart/stale-child race safety. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| function createFinder(): NativePythonFinderImpl { | ||
| finder = new NativePythonFinderImpl( | ||
| makeOutputChannel() as never, | ||
| 'fake-pet-tool', | ||
| {} as unknown as PythonProjectApi, |
|
🔒 Automated review in progress — @StellaHuang95 is auto-reviewing this PR. |
…le and avoid write-after-end on exit (PR #14) Prompt RPC rejection on PET exit now surfaces as ResponseError(PendingResponseRejected). Add an isPetConnectionLostError() classifier plus a lifecycle-aware isRecoverableConnectionLoss() gate (skips restart/dispose-induced disposal) so refresh/resolve recovery and telemetry treat a mid-request crash as recoverable instead of an immediate non-retryable failure. Also unpipe the child stdout/stdin in endStreams() before ending the local streams to avoid ERR_STREAM_WRITE_AFTER_END when exit fires before stdout drains. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| prefix: '/env', | ||
| })); | ||
| this.connection.onRequest('refresh', () => { | ||
| if (this.refreshMode === 'hang') { |
There was a problem hiding this comment.
Issue · Please address or respond
📍 src/test/managers/common/nativePythonFinder.petExit.unit.test.ts:12
This violates tests-helper-placement.md: all file- and suite-scoped helpers are used only by this suite but precede its tests. Move FakeChild, flush, isPendingResponseRejected, makeOutputChannel, createFinder, getConnection, and getState to the end of the suite block.
[verified]
| }; | ||
| return { | ||
| info: noop, | ||
| warn: noop, |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
📍 src/test/managers/common/nativePythonFinder.petExit.unit.test.ts:118
The tests cast through several private fields and methods (connection, process state, start, and restart), tightly coupling coverage to class layout. Prefer a narrow process/session injection or observable lifecycle seam so internal refactors do not break behavior-focused tests.
[verified]
| * the child is faked. Termination is driven explicitly via {@link simulateExit}/{@link simulateError} | ||
| * so tests are fully deterministic (no real process, no real timers on the hot path). | ||
| */ | ||
| class FakeChild extends EventEmitter { |
There was a problem hiding this comment.
Issue · Please address or respond
The file- and suite-local helpers precede the tests. Move them to the end of the smallest suite/file scope that uses them, as required by the test helper-placement convention.
| clear: noop, | ||
| dispose: noop, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Issue · Please address or respond
makeOutputChannel() duplicates the existing typed createMockLogOutputChannel() utility and requires an as never cast. Use the shared helper instead.
| childStdout?.unpipe(readable); | ||
| writable.unpipe(); | ||
| readable.end(); | ||
| writable.end(); |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
Unpiping stdout on exit can prevent a complete response already buffered in the child stream from reaching readable, converting a successful request into PendingResponseRejected. Drain stdout through its close event, with a bounded fallback, before ending the reader; add coverage for delayed delivery of a response written before exit.
|
|
||
| if (!this.proc.stdout || !this.proc.stderr || !this.proc.stdin) { | ||
| if (!proc.stdout || !proc.stderr || !proc.stdin) { | ||
| throw new Error('Failed to create stdio streams for PET process'); |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
If spawning succeeds but a required stdio stream is absent, this throws before registering the child cleanup disposable, leaving a live child without teardown ownership. Register cleanup immediately after spawning or explicitly terminate the child on this failure path, and cover it with a live fake child missing stdio.
|
Minor follow-up: the lifecycle tests access several private fields and methods through casts; a narrow test seam would reduce coupling to the class layout. |
…LI fallback (PR #14) Closes the pre-existing dispose-during-restart-backoff race flagged by both reviews: if dispose() ran while restart() was parked on its backoff, the restart resumed and spawned a replacement PET child that nothing would ever dispose. Use the disposed state consistently via a precise NativePythonFinderDisposedError and a throwIfDisposed() guard at every spawn/restart/retry/CLI chokepoint (resolve/refresh entry, ensureProcessRunning, restart before+after backoff, start, resolve/refreshViaJsonCli). Make the restart backoff cancelable so dispose() unblocks it immediately (clearing the timer so none leaks) and the post-backoff guard aborts before any spawn; skip restart error telemetry for an intentional-disposal abort. isServerExhausted() is false once disposed so neither resolve nor refresh falls through to the JSON CLI. Non-disposed restart/recovery behavior is unchanged, and PR #11's force-kill escalation is intentionally not duplicated here. Adds 3 deterministic fake-timer tests: dispose during backoff (no replacement child, no leaked timer via countTimers()===0, settles without ticking the clock), start() after dispose throws and never spawns, and resolve()/refresh() after dispose reject with the disposed error and never spawn. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| sinon.restore(); | ||
| }); | ||
|
|
||
| function createFinder(): NativePythonFinderImpl { |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
The lifecycle tests cast through numerous private fields and methods. Introduce a narrow process/session test seam so this behavior coverage does not couple to the class's private layout.
| import { StopWatch } from '../../common/stopWatch'; | ||
| import { EventNames } from '../../common/telemetry/constants'; | ||
| import { classifyError, isTimeoutErrorType } from '../../common/telemetry/errorClassifier'; | ||
| import { classifyError, isPetConnectionLostError, isTimeoutErrorType } from '../../common/telemetry/errorClassifier'; |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
Recovery policy now depends on a telemetry module that imports RpcTimeoutError from this manager, reinforcing a circular dependency. Move the PET error types and predicates into a dependency-neutral common module consumed by both layers.
Context
NativePythonFinderImpl(src/managers/common/nativePythonFinder.ts) runs the PET (Python Environment Tools) locator as a child process and talks to it over a JSON-RPC connection built onStreamMessageReader/StreamMessageWriter. Callers issueconfigure/refresh/resolve/inforequests wrapped in 30-60s timeouts.Reproduction
Root cause
start()pipes PET stdout into aPassThrough(readable) with{ end: false }:The child
exit/errorhandlers only set flags (processExited,processExitReason); they never endreadable. So when PET dies,readablenever ends,StreamMessageReadernever observes EOF,connection.onClosenever runs, and the connection is never disposed. Invscode-jsonrpc, pending requests reject only whenconnection.dispose()runs (aResponseErrorwith codePendingResponseRejected). With no disposal, pending requests hang until their own timeouts elapse.Second latent bug:
onClosedisposed the sharedthis.startDisposables. After arestart()reassigns that field to the replacement connection's disposables, a stale old-connection close would dispose the replacement.Fix — per-child ownership & teardown
Each spawned child now owns its own resources, captured in closures so a dead child only ever tears down its own connection:
const localDisposables: Disposable[] = []perstart();this.startDisposablespoints at it (assigned immediately so a spawn/wiring failure still leaves the graceful-kill disposable reachable for cleanup).endStreams()endsreadable/writableexactly once.const proc = spawnProcess(...)captured locally;this.proc = proc. The exit/error handlers referenceproc, notthis.proc.handleChildTermination(reason)always callsendStreams()(so the connection disposes and pending requests reject even for a killed/replaced child) and mutates sharedprocessExited/processExitReasononly whenthis.proc === proc(identity guard).onClosedisposes the capturedlocalDisposables, neverthis.startDisposables.Teardown path: child exit/error →
endStreams()→readableEOF → reader close →connection.onClose→ disposelocalDisposables(incl. the connection) → pending requests reject promptly.Follow-up fix 1 — recoverable connection-loss classification (review-driven)
Prompt rejection means a mid-request PET crash now surfaces as
ResponseError(PendingResponseRejected)instead ofrpc.ConnectionError. The refresh/resolve recovery blocks and telemetry classifier previously recognized onlyRpcTimeoutError/rpc.ConnectionError, so the new rejection would have turned a crash that used to time-out-then-restart-and-retry into an immediate, non-retryable failure. Fixed with one precise, shared helper plus a lifecycle-aware gate:isPetConnectionLostError(ex)(src/common/telemetry/errorClassifier.ts) — the single source of truth for "PET connection loss":rpc.ConnectionErrororrpc.ResponseErrorwithErrorCodes.PendingResponseRejected. Used inclassifyError()(mapped toconnection_error, ordered before the genericResponseError → rpc_errorbranch so a dispose-driven rejection isn't mislabeled).isRecoverableConnectionLoss(ex)— the lifecycle-aware wrapper used at the recovery call sites:!disposed && !isRestarting && isPetConnectionLostError(ex). The!isRestarting && !disposedguard is essential:restart()anddispose()also dispose the connection and reject in-flight requests withPendingResponseRejected; without the guard a self-inflicted disposal would be mistaken for a fresh crash and trigger a double-restart or kill the freshly-started replacement child. Aprivate disposedflag is set at the very top ofdispose().resolvecatch,doRefreshretry loop,doRefreshAttemptcatch) now gate onisRecoverableConnectionLoss(ex)and key theirreason/processExitReasonstrings offex instanceof RpcTimeoutError(timeout) vs. else (crash) — correct for all three shapes (RpcTimeoutError,ConnectionError,PendingResponseRejected). Configure timeouts remain excluded (configure handles its own retry); a configure-time connection loss correctly still recovers because the child is genuinely gone.Follow-up fix 2 — avoid ERR_STREAM_WRITE_AFTER_END on exit (review-driven)
exitcan fire whileproc.stdoutstill has buffered bytes queued forreadable. With stdout still piped,readable.end()could receive those late bytes as a write to an already-ended stream (ERR_STREAM_WRITE_AFTER_END), which escapes once the reader's error handler is torn down. In the per-childendStreams(), the child's stdout is now unpiped from the localreadable(and the localwritabledetached from stdin) before ending/destroying them:EOF still propagates because
readable.end()is unchanged — unpipe only stops future source bytes (already-buffered data flushes before EOF). Both unpipes are no-ops when the pipe was never established/already removed, so they stay safe under duplicate exit/error/dispose, guarded by the existingstreamsEndedidempotency flag.Follow-up fix 3 — a disposed finder must never spawn / restart / retry / CLI-fall-back (review-driven)
Both prior reviews flagged a pre-existing race left out of scope: if
dispose()runs whilerestart()is parked on its exponential-backoffawait, the restart resumes after the timer fires and callsstart()— spawning a replacement PET child that nothing will ever dispose (itsstartDisposables/connection were already torn down). A post-disposeresolve()could likewise still reach the CLI fallback. Now that thedisposedflag exists, this is closed by using it consistently:NativePythonFinderDisposedError— a precise, exported error so an in-flight or post-dispose caller settles exactly once with a recognizable error instead of leaking a process/timer.throwIfDisposed()is invoked at every spawn/restart/retry/CLI chokepoint:resolve()andrefresh()entry (public in-flight paths fail fast),ensureProcessRunning()(covers in-flight resolve/refresh),restart()before the backoff and immediately after it (before anystart()),start()itself (immediately before spawn), and bothresolveViaJsonCli()/refreshViaJsonCli()(hard stop before a CLI process).restart()'s backoff wait is now cancelable via acancelRestartBackoffhook.dispose()(after settingdisposed = true) calls it, which clears the backoff timer (no leak) and unblocks the wait immediately —restart()then hits the post-backoffthrowIfDisposed()and aborts without spawning. The abort is recognized inrestart()'s catch, which skips error telemetry/logs (intentional teardown, not a failure) and rethrows the precise error;finallyclears the hook andisRestarting.isServerExhausted()returnsfalsewhen disposed, so neitherresolve()nordoRefresh()falls through to the JSON CLI after teardown;resolve()'s outer catch also rethrows the disposed error before any telemetry/CLI. A disposed finder therefore never spawns a server or CLI process, restarts, or retries.Race & idempotency safety
endStreams()is guarded bystreamsEnded;connection.dispose()is idempotent; the firstprocessExitReasonwins. Harmless.restart(): thethis.proc === procidentity guard prevents a dead child from flipping shared flags on a live replacement;onClosedisposing onlylocalDisposablesprevents a stale close from tearing down the replacement connection.restart()disposes the old connection before its backoffawait, andthis.procisn't reassigned to the replacement until insidestart()whileisRestartingis still true — so every crash-driven rejection microtask drains whileisRecoverableConnectionLoss()returnsfalse, and no stale rejection can restart twice or kill the replacement.disposedre-check means a disposal during the wait aborts the restart promptly (no clock advance needed), spawns no replacement child, and leaks no timer.disposedis set at the top ofdispose()and never reset, so post-disposePendingResponseRejectedrejections are permanently classified non-recoverable, and every lifecycle chokepoint fails fast withNativePythonFinderDisposedError.PassThrough.end()firescloseasynchronously;connection.dispose()fires a separate dispose emitter;closeHandleris state-guarded — soonClose → forEach → endStreamscannot recurse.restart()/dispose()behavior preserved for the non-disposed path; PET itself is untouched.Tests
New deterministic unit tests drive the real
vscode-jsonrpcconnection against a fake child (EventEmitter +PassThrough), stubbing onlyspawnProcess.src/test/managers/common/nativePythonFinder.petExit.unit.test.ts(12 tests):ResponseError/PendingResponseRejected).restart()produces a usable connection and resets exit state.FakePetServer).stdout.listenerCount('data') === 0post-exit).clock.countTimers() === 0) — proven without advancing the clock.start()after dispose throwsNativePythonFinderDisposedErrorand spawns no child.resolve()/refresh()after dispose reject with the disposed error and spawn no process.src/test/common/telemetry/errorClassifier.unit.test.ts:PendingResponseRejected→connection_error, plus anisPetConnectionLostErrorsuite.Determinism:
kickoffInfoFetchis stubbed to avoid the pre-existing uncleared 2sinfotimer; timing-sensitive tests fake onlysetTimeout/clearTimeoutand restore them;killProcess/doRefreshAttemptare stubbed where a real kill timer or precise attempt count is needed. No uncancelled real timers; the suite exits promptly with no leaked-timer or unhandled-rejection warnings.npm run lint,npm run compile-tests, and the targeted suites (35 tests) all pass. The fullnpm run unittestis green (1707 passing); an occasional pre-existing, environment-specific WindowsEPERMflake (inlineScriptCacheLayoutconcurrent rename) is unrelated to these changes.To expose the production wiring to tests (there is no other injection seam for the spawned PET server),
NativePythonFinderImplandNativePythonFinderDisposedErrorare exported as@internaltest seams only — they are not referenced bysrc/api.tsand remain out of the public extension API; production still constructs the finder viacreateNativePythonFinder().Overlap with #11 — required companion (merge ordering)
PR #11 (
fix-pet-force-kill, make PET child-process teardown restart-safe) edits the samenativePythonFinder.tslifecycle/kill handlers (start()'s exit/error handling andkillProcess()). Both branches are cut frommain; this PR does not pull in #11's changes, to keep the issue separation.#11 is the required companion for the pre-existing force-kill escalation (
killProcess()'s delayed SIGKILL readsthis.procat fire time). That hardening is intentionally left to #11 and not duplicated here. The two are complementary (force-kill ownership + late-lifecycle events vs. prompt RPC rejection + recoverable-connection-loss classification + dispose-race safety) but will textually conflict instart()/killProcess. Whichever PR merges second must rebase and re-run the combined PET-exit/kill/lifecycle unit suites; no logic from the two fixes is mutually exclusive.Limitations
PendingResponseRejectedon an in-flight request now classifies asconnection_errorrather thanrpc_error(result:'error'unchanged) — the more accurate categorization.sendRequestWithTimeoutdoesn'tclearTimeouton success. ThekillProcess()delayed-SIGKILLthis.procread is addressed by companion PR fix: make PET child-process teardown restart-safe (force-kill ownership + late lifecycle events) #11 (see above), not here.