From 7024bd1f410b34ac690b04650542f522c6d7b65a Mon Sep 17 00:00:00 2001 From: Keming Date: Sat, 13 Dec 2025 10:35:49 +0800 Subject: [PATCH 1/7] gh-142654: show the clear error message when sampling on an unknown PID Signed-off-by: Keming --- Lib/profiling/sampling/sample.py | 29 ++++++++++--------- .../test_integration.py | 2 +- ...-12-13-10-34-59.gh-issue-142654.fmm974.rst | 2 ++ 3 files changed, 19 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-12-13-10-34-59.gh-issue-142654.fmm974.rst diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py index 294ec3003fc6bc..6460bcd4eb7a7d 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 Exception as err: + raise SystemExit(err) # Track sample intervals and total sample count self.sample_intervals = deque(maxlen=100) self.total_samples = 0 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 b98f1e1191429e..cbf4040795fa7f 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_integration.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_integration.py @@ -635,7 +635,7 @@ def test_sample_target_module(self): ) class TestSampleProfilerErrorHandling(unittest.TestCase): def test_invalid_pid(self): - with self.assertRaises((OSError, RuntimeError)): + with self.assertRaises(SystemExit): collector = PstatsCollector(sample_interval_usec=100, skip_idle=False) profiling.sampling.sample.sample(-1, collector, duration_sec=1) 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. From b1a508258929c122fd2322ef725cc568924c03d9 Mon Sep 17 00:00:00 2001 From: Keming Date: Sat, 13 Dec 2025 11:23:02 +0800 Subject: [PATCH 2/7] only catch the RuntimeError, leave the PermissionError for downstream Signed-off-by: Keming --- Lib/profiling/sampling/sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py index 6460bcd4eb7a7d..8e087b2b0938fe 100644 --- a/Lib/profiling/sampling/sample.py +++ b/Lib/profiling/sampling/sample.py @@ -48,7 +48,7 @@ def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MOD opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads, cache_frames=True, stats=collect_stats ) - except Exception as err: + except RuntimeError as err: raise SystemExit(err) # Track sample intervals and total sample count self.sample_intervals = deque(maxlen=100) From cfea35b7824f8c6f6abb03ceb03baff68621c328 Mon Sep 17 00:00:00 2001 From: Keming Date: Sat, 13 Dec 2025 11:46:42 +0800 Subject: [PATCH 3/7] catch the PermissionError for macOS Signed-off-by: Keming --- .../test_profiling/test_sampling_profiler/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cbf4040795fa7f..98ca55949297b3 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_integration.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_integration.py @@ -635,7 +635,7 @@ def test_sample_target_module(self): ) class TestSampleProfilerErrorHandling(unittest.TestCase): def test_invalid_pid(self): - with self.assertRaises(SystemExit): + with self.assertRaises((SystemExit, PermissionError)): collector = PstatsCollector(sample_interval_usec=100, skip_idle=False) profiling.sampling.sample.sample(-1, collector, duration_sec=1) From ab9c0c63b24a1fc3476ea2c99223ab2574f54a6d Mon Sep 17 00:00:00 2001 From: Keming Date: Mon, 15 Dec 2025 23:01:55 +0800 Subject: [PATCH 4/7] check if the pid is running before attaching Signed-off-by: Keming --- Lib/profiling/sampling/cli.py | 4 +- Lib/profiling/sampling/sample.py | 37 ++++++++++--------- .../test_sampling_profiler/test_cli.py | 7 ++++ .../test_integration.py | 6 +-- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py index 3a0444db4c3636..f8876f18f9ef00 100644 --- a/Lib/profiling/sampling/cli.py +++ b/Lib/profiling/sampling/cli.py @@ -6,7 +6,7 @@ import subprocess import sys -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 @@ -596,6 +596,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 8e087b2b0938fe..a78ed0b8076f18 100644 --- a/Lib/profiling/sampling/sample.py +++ b/Lib/profiling/sampling/sample.py @@ -89,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 @@ -151,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: @@ -282,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 e1892ec9155940..b560f4b27ca2b6 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py @@ -434,6 +434,7 @@ def test_cli_default_collapsed_filename(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.sample._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): from profiling.sampling.cli import main @@ -476,6 +477,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.sample._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -516,6 +518,7 @@ def test_argument_parsing_basic(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.sample._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): from profiling.sampling.cli import main @@ -540,6 +543,7 @@ def test_sort_options(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.sample._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): from profiling.sampling.cli import main @@ -554,6 +558,7 @@ def test_async_aware_flag_defaults_to_running(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.sample._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): from profiling.sampling.cli import main @@ -570,6 +575,7 @@ def test_async_aware_with_async_mode_all(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.sample._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): from profiling.sampling.cli import main @@ -585,6 +591,7 @@ def test_async_aware_default_is_none(self): with ( mock.patch("sys.argv", test_args), + mock.patch("profiling.sampling.sample._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): from profiling.sampling.cli import 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 98ca55949297b3..fe4c185d562a6e 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" @@ -681,7 +681,7 @@ def test_is_process_running(self): self.skipTest( "Insufficient permissions to read the stack trace" ) - 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() @@ -690,7 +690,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 ) From dcad05a11446ecce5cda695f9d6b19b70b9793e9 Mon Sep 17 00:00:00 2001 From: Keming Date: Mon, 15 Dec 2025 23:31:40 +0800 Subject: [PATCH 5/7] fix sampling test failure due to the _is_process_running check Signed-off-by: Keming --- .../test_sampling_profiler/test_cli.py | 14 +++++++------- .../test_sampling_profiler/test_modes.py | 4 ++++ 2 files changed, 11 insertions(+), 7 deletions(-) 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 788c304bad96d2..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,7 +436,7 @@ def test_cli_default_collapsed_filename(self): with ( mock.patch("sys.argv", test_args), - mock.patch("profiling.sampling.sample._is_process_running", return_value=True), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -476,7 +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.sample._is_process_running", return_value=True), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -515,7 +515,7 @@ def test_argument_parsing_basic(self): with ( mock.patch("sys.argv", test_args), - mock.patch("profiling.sampling.sample._is_process_running", return_value=True), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -537,7 +537,7 @@ def test_sort_options(self): with ( mock.patch("sys.argv", test_args), - mock.patch("profiling.sampling.sample._is_process_running", return_value=True), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -551,7 +551,7 @@ def test_async_aware_flag_defaults_to_running(self): with ( mock.patch("sys.argv", test_args), - mock.patch("profiling.sampling.sample._is_process_running", return_value=True), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -567,7 +567,7 @@ def test_async_aware_with_async_mode_all(self): with ( mock.patch("sys.argv", test_args), - mock.patch("profiling.sampling.sample._is_process_running", return_value=True), + mock.patch("profiling.sampling.cli._is_process_running", return_value=True), mock.patch("profiling.sampling.cli.sample") as mock_sample, ): main() @@ -582,7 +582,7 @@ def test_async_aware_default_is_none(self): with ( mock.patch("sys.argv", test_args), - mock.patch("profiling.sampling.sample._is_process_running", return_value=True), + 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_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: From 4d554010d59fd983e16b8997f224e2af9f6e1aad Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 16 Dec 2025 22:56:35 +0800 Subject: [PATCH 6/7] add custom sampling errors Signed-off-by: Keming --- Lib/profiling/sampling/__main__.py | 7 +++++ Lib/profiling/sampling/cli.py | 7 +++-- Lib/profiling/sampling/errors.py | 23 +++++++++++++++ Lib/profiling/sampling/sample.py | 29 ++++++++++--------- .../test_sampling_profiler/test_cli.py | 19 ++++++++---- 5 files changed, 63 insertions(+), 22 deletions(-) create mode 100644 Lib/profiling/sampling/errors.py diff --git a/Lib/profiling/sampling/__main__.py b/Lib/profiling/sampling/__main__.py index 47bd3a0113eb3d..a45b645eae05fc 100644 --- a/Lib/profiling/sampling/__main__.py +++ b/Lib/profiling/sampling/__main__.py @@ -46,6 +46,7 @@ """ from .cli import main +from .errors import SamplingUnknownProcessError, SamplingModuleNotFoundError, SamplingScriptNotFoundError def handle_permission_error(): """Handle PermissionError by displaying appropriate error message.""" @@ -64,3 +65,9 @@ def handle_permission_error(): main() except PermissionError: handle_permission_error() + except SamplingUnknownProcessError as err: + print(f"Tachyon cannot find the process: {err}", file=sys.stderr) + sys.exit(1) + except (SamplingModuleNotFoundError, SamplingScriptNotFoundError) as err: + print(f"Tachyon cannot find the target: {err}", file=sys.stderr) + sys.exit(1) diff --git a/Lib/profiling/sampling/cli.py b/Lib/profiling/sampling/cli.py index fb67081d9bda3f..554167e43f5ed8 100644 --- a/Lib/profiling/sampling/cli.py +++ b/Lib/profiling/sampling/cli.py @@ -10,6 +10,7 @@ import time from contextlib import nullcontext +from .errors import SamplingUnknownProcessError, SamplingModuleNotFoundError, SamplingScriptNotFoundError from .sample import sample, sample_live, _is_process_running from .pstats_collector import PstatsCollector from .stack_collector import CollapsedStackCollector, FlamegraphCollector @@ -744,7 +745,7 @@ 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.") + raise SamplingUnknownProcessError(args.pid) # Check if live mode is requested if args.live: _handle_live_attach(args, args.pid) @@ -794,13 +795,13 @@ def _handle_run(args): added_cwd = True try: if importlib.util.find_spec(args.target) is None: - sys.exit(f"Error: Module not found: {args.target}") + raise SamplingModuleNotFoundError(args.target) finally: if added_cwd: sys.path.remove(cwd) else: if not os.path.exists(args.target): - sys.exit(f"Error: Script not found: {args.target}") + raise SamplingScriptNotFoundError(args.target) # Check if live mode is requested if args.live: diff --git a/Lib/profiling/sampling/errors.py b/Lib/profiling/sampling/errors.py new file mode 100644 index 00000000000000..0246f201f21d5d --- /dev/null +++ b/Lib/profiling/sampling/errors.py @@ -0,0 +1,23 @@ +"""Custom exceptions for the sampling profiler.""" + +class SamplingProfilerError(Exception): + """Base exception for sampling profiler errors.""" + +class SamplingPermissionError(SamplingProfilerError): + def __init__(self): + super().__init__(f"Insufficient permission to access process.") + +class SamplingUnknownProcessError(SamplingProfilerError): + def __init__(self, pid): + self.pid = pid + super().__init__(f"Process with PID '{pid}' does not exist.") + +class SamplingScriptNotFoundError(SamplingProfilerError): + def __init__(self, script_path): + self.script_path = script_path + super().__init__(f"Script '{script_path}' not found.") + +class SamplingModuleNotFoundError(SamplingProfilerError): + def __init__(self, module_name): + self.module_name = module_name + super().__init__(f"Module '{module_name}' not found.") diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py index a78ed0b8076f18..805a8332bc66c5 100644 --- a/Lib/profiling/sampling/sample.py +++ b/Lib/profiling/sampling/sample.py @@ -35,19 +35,7 @@ def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MOD self.mode = mode # Store mode for later use self.collect_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 - ) + self.unwinder = self._new_unwinder(native, gc, opcodes, skip_non_matching_threads) except RuntimeError as err: raise SystemExit(err) # Track sample intervals and total sample count @@ -55,6 +43,21 @@ def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MOD self.total_samples = 0 self.realtime_stats = False + def _new_unwinder(self, native, gc, opcodes, skip_non_matching_threads): + if _FREE_THREADED_BUILD: + unwinder = _remote_debugging.RemoteUnwinder( + self.pid, all_threads=self.all_threads, mode=self.mode, native=native, gc=gc, + opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads, + cache_frames=True, stats=self.collect_stats + ) + else: + unwinder = _remote_debugging.RemoteUnwinder( + self.pid, only_active_thread=bool(self.all_threads), mode=self.mode, native=native, gc=gc, + opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads, + cache_frames=True, stats=self.collect_stats + ) + return unwinder + def sample(self, collector, duration_sec=10, *, async_aware=False): sample_interval_sec = self.sample_interval_usec / 1_000_000 running_time = 0 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 f87ac154a1ea41..9b2b16d6e1965b 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_cli.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_cli.py @@ -16,6 +16,7 @@ from test.support import is_emscripten, requires_remote_subprocess_debugging from profiling.sampling.cli import main +from profiling.sampling.errors import SamplingScriptNotFoundError, SamplingModuleNotFoundError, SamplingUnknownProcessError class TestSampleProfilerCLI(unittest.TestCase): @@ -203,12 +204,12 @@ def test_cli_mutually_exclusive_pid_script(self): with ( mock.patch("sys.argv", test_args), mock.patch("sys.stderr", io.StringIO()) as mock_stderr, - self.assertRaises(SystemExit) as cm, + self.assertRaises(SamplingScriptNotFoundError) as cm, ): main() # Verify the error is about the non-existent script - self.assertIn("12345", str(cm.exception.code)) + self.assertIn("12345", str(cm.exception)) def test_cli_no_target_specified(self): # In new CLI, must specify a subcommand @@ -704,14 +705,20 @@ def test_async_aware_incompatible_with_all_threads(self): def test_run_nonexistent_script_exits_cleanly(self): """Test that running a non-existent script exits with a clean error.""" with mock.patch("sys.argv", ["profiling.sampling.cli", "run", "/nonexistent/script.py"]): - with self.assertRaises(SystemExit) as cm: + with self.assertRaisesRegex(SamplingScriptNotFoundError, "Script '[\\w/.]+' not found."): main() - self.assertIn("Script not found", str(cm.exception.code)) @unittest.skipIf(is_emscripten, "subprocess not available") def test_run_nonexistent_module_exits_cleanly(self): """Test that running a non-existent module exits with a clean error.""" with mock.patch("sys.argv", ["profiling.sampling.cli", "run", "-m", "nonexistent_module_xyz"]): - with self.assertRaises(SystemExit) as cm: + with self.assertRaisesRegex(SamplingModuleNotFoundError, "Module '[\\w/.]+' not found."): + main() + + def test_cli_attach_nonexistent_pid(self): + fake_pid = "99999" + with mock.patch("sys.argv", ["profiling.sampling.cli", "attach", fake_pid]): + with self.assertRaises(SamplingUnknownProcessError) as cm: main() - self.assertIn("Module not found", str(cm.exception.code)) + + self.assertIn(fake_pid, str(cm.exception)) From 5b5ad2f5e2a324ed8de070ff57c2d5f746009c11 Mon Sep 17 00:00:00 2001 From: Keming Date: Tue, 16 Dec 2025 23:39:48 +0800 Subject: [PATCH 7/7] use os.kill(pid, 0) for all the posix OS Signed-off-by: Keming --- Lib/profiling/sampling/sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py index 805a8332bc66c5..12506d694885fb 100644 --- a/Lib/profiling/sampling/sample.py +++ b/Lib/profiling/sampling/sample.py @@ -272,7 +272,7 @@ def _print_unwinder_stats(self): def _is_process_running(pid): if pid <= 0: return False - if sys.platform == "linux" or sys.platform == "darwin": + if os.name == "posix": try: os.kill(pid, 0) return True