Open source / Runtime reliability

Stopping Windows profiler sampling after process exit

How one error classification kept a blocking profiler retry loop alive after its target had ended—and how the fix preserves genuine suspension failures while making process exit terminal.

Project
CPython
Contribution
PR #152471
Related issue
Issue #152356
Role
PR author
Outcome
Merged 28 June 2026

A finished target was treated like a temporary sampling failure.

CPython’s remote debugging support includes a blocking profiler mode that repeatedly samples another Python process. On Windows, the profiler suspends the target before inspecting it. Issue #152356 reported that blocking sampling could continue after a short-lived target process had exited, leaving the profiler running instead of finalizing its binary output.

PR #152471, authored by Zain Nadeem, corrected the Windows error mapping and added an end-to-end regression that checks both termination and profile replay.

Blocking mode relies on terminal errors to end the sampling loop.

A blocking sampler expects individual samples to fail occasionally. Some failures are recoverable: the profiler can wait and try again. Process exit is different. Once the target no longer exists, no later retry can produce a sample, so the loop must stop and allow the profile writer to close cleanly.

Start targetCollect samplesTarget exitsTerminal signalFinalize profile

That finalization is observable behavior, not an internal detail. A sampling command that continues waiting after its target ends prevents normal command completion and leaves the binary profile unavailable for the expected replay workflow.

The Windows path violated that lifecycle because the target’s exit surfaced through the same broad exception class used for recoverable runtime failures.

Follow the native failure into the Python retry policy.

The relevant native operation is NtSuspendProcess(). When a target ended between sampling steps, that call failed. The Windows remote-debugging layer translated the failure into RuntimeError. Higher-level blocking sampling treated that exception as a reason to continue rather than a signal that the process was gone.

Comparing platform behavior showed the intended contract: a missing process is represented as ProcessLookupError, which is terminal to the loop. The investigation therefore focused on distinguishing two states after a suspend failure:

  • the target is no longer alive, so sampling must terminate;
  • the target is still alive, so the native suspension failure remains a genuine runtime error.

Error type carried the control-flow decision.

The issue was not simply that a Windows API returned an error. It was that the translation layer erased the lifecycle meaning of that error. Once the target-exit case became a generic RuntimeError, the blocking loop could not distinguish a permanent terminal state from a retryable sample failure.

Affected pathNtSuspendProcess failure → RuntimeError → retry
Required pathtarget exited → ProcessLookupError → stop

Because the loop remained active, the binary writer did not reach its normal finalization path. A correct solution had to restore the missing semantic distinction, not merely place an arbitrary retry cap around the symptom.

Check process liveness before choosing the exception.

The merged patch checks whether the target remains alive after NtSuspendProcess() fails. If the process has terminated, the native layer raises ProcessLookupError. If the target is still alive, the existing RuntimeError behavior is retained for a real suspension failure.

// Simplified decision after a failed suspend
if (!is_process_alive(target)) {
    raise ProcessLookupError;
}
raise RuntimeError;

This preserves diagnostic fidelity. It does not hide valid Windows suspension errors, and it gives the higher-level loop the terminal signal it already understands on other platforms.

Exercise the full lifecycle, including the output artifact.

The regression test starts a short-lived target and launches blocking profiling in binary-output mode on Windows. It then verifies that the profiler process exits rather than waiting indefinitely. The resulting profile file must be non-empty and replayable.

CheckWhat it protects
Short-lived target exitsReproduces the terminal lifecycle boundary
Profiler process terminatesProtects against the retry loop continuing forever
Binary profile is non-emptyConfirms recorded samples were finalized
Profile replay succeedsConfirms the produced artifact is usable

This is stronger than checking the exception type in isolation: it validates the relationship between native error classification, loop termination, writer cleanup, and the user-visible profile.

The fix aligned Windows with the profiler’s cross-platform contract.

Zain authored PR #152471 against CPython issue #152356. It updated the Windows remote-debugging code, added a Windows end-to-end test, and included the corresponding news entry. The change was merged on 28 June 2026 as commit 37b238f1a6f0d8738e44ccc516ca2a476c38e5ce.

The patch is deliberately narrow: it changes the exception only when liveness establishes that the process has ended. Other suspension failures continue to surface as runtime errors.

Error taxonomies are part of a control-flow interface.

  • Native error translation should preserve whether a failure is terminal, retryable, or genuinely exceptional.
  • Retry loops should be tested against lifecycle completion, not only successful steady-state sampling.
  • For file-producing tools, termination tests should also validate that the output was finalized and can be consumed.
  • Cross-platform code benefits from semantic parity even when each platform reaches that semantic result through different system calls.

Authoritative upstream record.