diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py index e1ff3758c0d341..fb67081d9bda3f 100644 --- a/Lib/profiling/sampling/cli.py +++ b/Lib/profiling/sampling/cli.py @@ -10,7 +10,7 @@ import time from contextlib import nullcontext -from .sample import sample, sample_live +from .sample import sample, sample_live, _is_process_running from .pstats_collector import PstatsCollector from .stack_collector import CollapsedStackCollector, FlamegraphCollector from .heatmap_collector import HeatmapCollector @@ -743,6 +743,8 @@ def main(): def _handle_attach(args): """Handle the 'attach' command.""" + if not _is_process_running(args.pid): + raise sys.exit(f"Process with PID {args.pid} is not running.") # Check if live mode is requested if args.live: _handle_live_attach(args, args.pid) diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py index 294ec3003fc6bc..a78ed0b8076f18 100644 --- a/Lib/profiling/sampling/sample.py +++ b/Lib/profiling/sampling/sample.py @@ -34,19 +34,22 @@ def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MOD self.all_threads = all_threads self.mode = mode # Store mode for later use self.collect_stats = collect_stats - if _FREE_THREADED_BUILD: - self.unwinder = _remote_debugging.RemoteUnwinder( - self.pid, all_threads=self.all_threads, mode=mode, native=native, gc=gc, - opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads, - cache_frames=True, stats=collect_stats - ) - else: - only_active_threads = bool(self.all_threads) - self.unwinder = _remote_debugging.RemoteUnwinder( - self.pid, only_active_thread=only_active_threads, mode=mode, native=native, gc=gc, - opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads, - cache_frames=True, stats=collect_stats - ) + try: + if _FREE_THREADED_BUILD: + self.unwinder = _remote_debugging.RemoteUnwinder( + self.pid, all_threads=self.all_threads, mode=mode, native=native, gc=gc, + opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads, + cache_frames=True, stats=collect_stats + ) + else: + only_active_threads = bool(self.all_threads) + self.unwinder = _remote_debugging.RemoteUnwinder( + self.pid, only_active_thread=only_active_threads, mode=mode, native=native, gc=gc, + opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads, + cache_frames=True, stats=collect_stats + ) + except RuntimeError as err: + raise SystemExit(err) # Track sample intervals and total sample count self.sample_intervals = deque(maxlen=100) self.total_samples = 0 @@ -86,7 +89,7 @@ def sample(self, collector, duration_sec=10, *, async_aware=False): collector.collect_failed_sample() errors += 1 except Exception as e: - if not self._is_process_running(): + if not _is_process_running(self.pid): break raise e from None @@ -148,22 +151,6 @@ def sample(self, collector, duration_sec=10, *, async_aware=False): f"({(expected_samples - num_samples) / expected_samples * 100:.2f}%)" ) - def _is_process_running(self): - if sys.platform == "linux" or sys.platform == "darwin": - try: - os.kill(self.pid, 0) - return True - except ProcessLookupError: - return False - elif sys.platform == "win32": - try: - _remote_debugging.RemoteUnwinder(self.pid) - except Exception: - return False - return True - else: - raise ValueError(f"Unsupported platform: {sys.platform}") - def _print_realtime_stats(self): """Print real-time sampling statistics.""" if len(self.sample_intervals) < 2: @@ -279,6 +266,25 @@ def _print_unwinder_stats(self): print(f" {ANSIColors.YELLOW}Stale cache invalidations: {stale_invalidations}{ANSIColors.RESET}") +def _is_process_running(pid): + if pid <= 0: + return False + if sys.platform == "linux" or sys.platform == "darwin": + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + elif sys.platform == "win32": + try: + _remote_debugging.RemoteUnwinder(pid) + except Exception: + return False + return True + else: + raise ValueError(f"Unsupported platform: {sys.platform}") + + def sample( pid, collector, diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py index 4434335130c325..f87ac154a1ea41 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py @@ -436,6 +436,7 @@ def test_cli_default_collapsed_filename(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -475,6 +476,7 @@ def test_cli_custom_output_filenames(self): for test_args, expected_filename, expected_format in test_cases: with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -513,6 +515,7 @@ def test_argument_parsing_basic(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -534,6 +537,7 @@ def test_sort_options(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -547,6 +551,7 @@ def test_async_aware_flag_defaults_to_running(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -562,6 +567,7 @@ def test_async_aware_with_async_mode_all(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -576,6 +582,7 @@ def test_async_aware_default_is_none(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_integration.py b/Lib/test/test_profiling/test_sampling_profiler/test_integration.py index 08a96d7eb459f0..e7f6c59d5cb770 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_integration.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_integration.py @@ -17,7 +17,7 @@ import profiling.sampling.sample from profiling.sampling.pstats_collector import PstatsCollector from profiling.sampling.stack_collector import CollapsedStackCollector - from profiling.sampling.sample import SampleProfiler + from profiling.sampling.sample import SampleProfiler, _is_process_running except ImportError: raise unittest.SkipTest( "Test only runs when _remote_debugging is available" @@ -602,7 +602,7 @@ def test_sample_target_module(self): @requires_remote_subprocess_debugging() class TestSampleProfilerErrorHandling(unittest.TestCase): def test_invalid_pid(self): - with self.assertRaises((OSError, RuntimeError)): + with self.assertRaises((SystemExit, PermissionError)): collector = PstatsCollector(sample_interval_usec=100, skip_idle=False) profiling.sampling.sample.sample(-1, collector, duration_sec=1) @@ -638,7 +638,7 @@ def test_is_process_running(self): sample_interval_usec=1000, all_threads=False, ) - self.assertTrue(profiler._is_process_running()) + self.assertTrue(_is_process_running(profiler.pid)) self.assertIsNotNone(profiler.unwinder.get_stack_trace()) subproc.process.kill() subproc.process.wait() @@ -647,7 +647,7 @@ def test_is_process_running(self): ) # Exit the context manager to ensure the process is terminated - self.assertFalse(profiler._is_process_running()) + self.assertFalse(_is_process_running(profiler.pid)) self.assertRaises( ProcessLookupError, profiler.unwinder.get_stack_trace ) diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_modes.py b/Lib/test/test_profiling/test_sampling_profiler/test_modes.py index f1293544776bc3..247416389daa07 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_modes.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_modes.py @@ -252,6 +252,7 @@ def test_gil_mode_validation(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): try: @@ -313,6 +314,7 @@ def test_gil_mode_cli_argument_parsing(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): try: @@ -432,6 +434,7 @@ def test_exception_mode_validation(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): try: @@ -493,6 +496,7 @@ def test_exception_mode_cli_argument_parsing(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): try: diff --git a/Misc/NEWS.d/next/Library/2025-12-13-10-34-59.gh-issue-142654.fmm974.rst b/Misc/NEWS.d/next/Library/2025-12-13-10-34-59.gh-issue-142654.fmm974.rst new file mode 100644 index 00000000000000..7bb14cb499d850 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-13-10-34-59.gh-issue-142654.fmm974.rst @@ -0,0 +1,2 @@ +Show the clearer error message when using ``profiling.sampling`` on an +unknown PID.