Skip to content

fix: force-kill the captured PET child so restart cannot lose or misfire the kill - #11

Closed
StellaHuang95 wants to merge 1 commit into
mainfrom
fix-pet-force-kill
Closed

fix: force-kill the captured PET child so restart cannot lose or misfire the kill#11
StellaHuang95 wants to merge 1 commit into
mainfrom
fix-pet-force-kill

Conversation

@StellaHuang95

@StellaHuang95 StellaHuang95 commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Fixes a force-kill ownership bug in the PET (python-environment-tools) child-process teardown in src/managers/common/nativePythonFinder.ts.

Problem

PET runs as a long-lived child process managed by NativePythonFinderImpl. When a request times out or the RPC connection breaks, recovery calls killProcess() to terminate the hung child, then restart() spawns a fresh one into this.proc. The teardown could act on the wrong process across that restart boundary.

Root cause

killProcess() sent SIGTERM, scheduled a 500 ms setTimeout that re-read the mutable this.proc field to decide whether to SIGKILL, then synchronously set this.proc = undefined. When the callback ran, this.proc was either already undefined (so a hung PET was never SIGKILLed) or had been reassigned by a concurrent restart (so the callback could SIGKILL the healthy replacement).

Fix

Extract killPetProcessWithGrace(getProc, clearProc, outputChannel, graceMs), which captures the current child and clears the holder before signalling, then sends SIGTERM and — after the grace period — SIGKILLs only that captured child. killProcess() delegates via () => this.proc / () => { this.proc = undefined; }. Because the delayed SIGKILL closes over the captured local child rather than the mutable field, a concurrent restart's replacement is never targeted, and a hung child is reliably force-killed. The grace period is unchanged (KILL_PROCESS_GRACE_PERIOD_MS = 500). No PET source is touched and there is no extension-API change.

Tests

nativePythonFinder.killProcess.unit.test.ts (6 cases, sinon fake timers): SIGTERMSIGKILL on the captured child, synchronous ownership release, exit-during-grace not force-killed, already-exited child not signalled (holder still cleared), a replacement assigned during the grace period is never killed (key regression), and SIGTERM errors are caught/logged without throwing. All 6 pass; npm run lint and npm run compile-tests are clean.

@StellaHuang95 StellaHuang95 changed the title fix: force-kill the captured PET child so restart cannot lose or misfire the kill fix: make PET child-process teardown restart-safe (force-kill ownership + late lifecycle events) Aug 22, 2026
@StellaHuang95 StellaHuang95 added the bug Something isn't working label Aug 22, 2026
@StellaHuang95

Copy link
Copy Markdown
Owner Author

🔒 Automated review in progress — @StellaHuang95 is auto-reviewing this PR.

const RESTART_BACKOFF_BASE_MS = 1_000; // 1 second base, exponential: 1s, 2s, 4s
const MAX_CONFIGURE_TIMEOUTS_BEFORE_KILL = 2; // Kill on the 2nd consecutive timeout
const MAX_REFRESH_RETRIES = 1; // Retry refresh once after timeout
const KILL_PROCESS_GRACE_PERIOD_MS = 500; // Grace after SIGTERM before escalating to SIGKILL

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

The restart teardown now centralizes the grace period and checks both exitCode and signalCode, while the disposable teardown still uses a hard-coded 500 and exitCode alone. Consider sharing the constant and running predicate to prevent these sibling lifecycle policies from drifting.

[verified]

@StellaHuang95 StellaHuang95 added the review-auto:approved Automated review: no blocking findings (approval posted). label Aug 22, 2026
StellaHuang95 added a commit that referenced this pull request Aug 22, 2026
…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>
@StellaHuang95 StellaHuang95 changed the title fix: make PET child-process teardown restart-safe (force-kill ownership + late lifecycle events) fix: force-kill the captured PET child so restart cannot lose or misfire the kill Aug 23, 2026
@StellaHuang95 StellaHuang95 removed the review-auto:approved Automated review: no blocking findings (approval posted). label Aug 23, 2026
…ire the kill

killProcess() sent SIGTERM, scheduled a 500ms callback that re-read the
mutable this.proc field, then cleared it synchronously. By the time the
callback ran, this.proc was either already undefined (a hung PET was
never SIGKILLed) or reassigned by a concurrent restart (the delayed
SIGKILL could hit the healthy replacement).

Extract killPetProcessWithGrace, which captures the child and clears the
holder before signalling, so the delayed SIGKILL only ever targets that
captured child. Add focused ownership regression tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
if (proc.exitCode === null) {
proc.kill('SIGKILL');
}
}, graceMs);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

The captured-process predicate only checks exitCode. A child that exits from SIGTERM can retain exitCode === null while reporting signalCode === 'SIGTERM', so the grace callback may still attempt SIGKILL against an already-exited PID. Include signalCode in the running predicate, and reuse that predicate plus KILL_PROCESS_GRACE_PERIOD_MS in the disposable teardown path to keep the sibling lifecycle policies aligned.

@StellaHuang95

Copy link
Copy Markdown
Owner Author

Non-blocking follow-ups: intentional exits from a superseded PET child should not be logged as unexpected errors, and a narrow production-wiring test around start() would prevent future divergence from the extracted helper tests.

@StellaHuang95 StellaHuang95 added the review-auto:approved Automated review: no blocking findings (approval posted). label Aug 23, 2026
if (proc && proc.exitCode === null) {
try {
outputChannel.info('[pet] Killing hung/crashed PET process');
proc.kill('SIGTERM');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

A child terminated by SIGTERM can retain exitCode === null while setting signalCode, so the delayed callback can send a redundant SIGKILL. Include signalCode in the running-process predicate and add coverage for a signal-terminated child.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working review-auto:approved Automated review: no blocking findings (approval posted).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant