diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py b/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py index 1f4b6da3281056..0a5541c733d4c7 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py @@ -1,6 +1,9 @@ """Tests for blocking mode sampling profiler.""" import io +import os +import subprocess +import sys import textwrap import unittest from unittest import mock @@ -15,7 +18,11 @@ "Test only runs when _remote_debugging is available" ) -from test.support import requires_remote_subprocess_debugging +from test.support import ( + SHORT_TIMEOUT, + os_helper, + requires_remote_subprocess_debugging, +) from .helpers import test_subprocess @@ -158,3 +165,51 @@ def test_generator_not_under_consumer_arithmetic(self): f"fibonacci_generator appears in the stack when consume_generator " f"is the leaf frame on an arithmetic line. This indicates " f"torn/inconsistent stack traces are being captured.") + + +@requires_remote_subprocess_debugging() +@unittest.skipUnless(sys.platform == "win32", "Windows only") +class TestBlockingModeCLI(unittest.TestCase): + def test_run_blocking_exits_after_target_process_exits(self): + script = 'print("done")\n' + + tmpdir = os.path.abspath(os_helper.TESTFN + "_profiling_blocking") + with os_helper.temp_dir(tmpdir) as tmpdir: + script_path = os.path.join(tmpdir, "tiny_target.py") + profile_path = os.path.join(tmpdir, "blocking.bin") + with open(script_path, "w", encoding="utf-8") as file: + file.write(script) + + cmd = [ + sys.executable, "-m", "profiling.sampling", "run", + "--binary", "-o", profile_path, + "--mode=cpu", "--blocking", "-r", "100", + script_path, + ] + result = subprocess.run( + cmd, + cwd=tmpdir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=SHORT_TIMEOUT, + ) + + self.assertEqual( + result.returncode, 0, + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + self.assertGreater(os.path.getsize(profile_path), 0) + + replay = subprocess.run( + [sys.executable, "-m", "profiling.sampling", "replay", + profile_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=SHORT_TIMEOUT, + ) + self.assertEqual( + replay.returncode, 0, + f"stdout:\n{replay.stdout}\nstderr:\n{replay.stderr}", + ) diff --git a/Misc/NEWS.d/next/Library/2026-06-28-12-45-09.gh-issue-152356.Dr4w2Q.rst b/Misc/NEWS.d/next/Library/2026-06-28-12-45-09.gh-issue-152356.Dr4w2Q.rst new file mode 100644 index 00000000000000..6a4ceecbe9279e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-28-12-45-09.gh-issue-152356.Dr4w2Q.rst @@ -0,0 +1,3 @@ +Fix a hang in ``profiling.sampling run --blocking`` on Windows when the +target process exits. The profiler now finalizes binary profiles instead of +continuing to sample the exited process. diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index 29f22f14c9b29f..04c70cc96d6bd1 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -862,6 +862,12 @@ _Py_RemoteDebug_StopAllThreads(RemoteUnwinderObject *unwinder, _Py_RemoteDebug_T return 0; } + if (!is_process_alive(unwinder->handle.hProcess)) { + PyErr_Format(PyExc_ProcessLookupError, + "Process %d has terminated", unwinder->handle.pid); + return -1; + } + PyErr_Format(PyExc_RuntimeError, "NtSuspendProcess failed: 0x%lx", status); return -1; }