Skip to content

Commit d4095f2

Browse files
kemingypablogsal
andauthored
gh-142654: show the clear error message when sampling on an unknown PID in tachyon (#142655)
Co-authored-by: Pablo Galindo Salgado <[email protected]>
1 parent 1fc3039 commit d4095f2

File tree

8 files changed

+102
-41
lines changed

8 files changed

+102
-41
lines changed

Lib/profiling/sampling/__main__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"""
4747

4848
from .cli import main
49+
from .errors import SamplingUnknownProcessError, SamplingModuleNotFoundError, SamplingScriptNotFoundError
4950

5051
def handle_permission_error():
5152
"""Handle PermissionError by displaying appropriate error message."""
@@ -64,3 +65,9 @@ def handle_permission_error():
6465
main()
6566
except PermissionError:
6667
handle_permission_error()
68+
except SamplingUnknownProcessError as err:
69+
print(f"Tachyon cannot find the process: {err}", file=sys.stderr)
70+
sys.exit(1)
71+
except (SamplingModuleNotFoundError, SamplingScriptNotFoundError) as err:
72+
print(f"Tachyon cannot find the target: {err}", file=sys.stderr)
73+
sys.exit(1)

Lib/profiling/sampling/cli.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
import time
1111
from contextlib import nullcontext
1212

13-
from .sample import sample, sample_live
13+
from .errors import SamplingUnknownProcessError, SamplingModuleNotFoundError, SamplingScriptNotFoundError
14+
from .sample import sample, sample_live, _is_process_running
1415
from .pstats_collector import PstatsCollector
1516
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
1617
from .heatmap_collector import HeatmapCollector
@@ -743,6 +744,8 @@ def main():
743744

744745
def _handle_attach(args):
745746
"""Handle the 'attach' command."""
747+
if not _is_process_running(args.pid):
748+
raise SamplingUnknownProcessError(args.pid)
746749
# Check if live mode is requested
747750
if args.live:
748751
_handle_live_attach(args, args.pid)
@@ -792,13 +795,13 @@ def _handle_run(args):
792795
added_cwd = True
793796
try:
794797
if importlib.util.find_spec(args.target) is None:
795-
sys.exit(f"Error: Module not found: {args.target}")
798+
raise SamplingModuleNotFoundError(args.target)
796799
finally:
797800
if added_cwd:
798801
sys.path.remove(cwd)
799802
else:
800803
if not os.path.exists(args.target):
801-
sys.exit(f"Error: Script not found: {args.target}")
804+
raise SamplingScriptNotFoundError(args.target)
802805

803806
# Check if live mode is requested
804807
if args.live:

Lib/profiling/sampling/errors.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Custom exceptions for the sampling profiler."""
2+
3+
class SamplingProfilerError(Exception):
4+
"""Base exception for sampling profiler errors."""
5+
6+
class SamplingUnknownProcessError(SamplingProfilerError):
7+
def __init__(self, pid):
8+
self.pid = pid
9+
super().__init__(f"Process with PID '{pid}' does not exist.")
10+
11+
class SamplingScriptNotFoundError(SamplingProfilerError):
12+
def __init__(self, script_path):
13+
self.script_path = script_path
14+
super().__init__(f"Script '{script_path}' not found.")
15+
16+
class SamplingModuleNotFoundError(SamplingProfilerError):
17+
def __init__(self, module_name):
18+
self.module_name = module_name
19+
super().__init__(f"Module '{module_name}' not found.")

Lib/profiling/sampling/sample.py

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,29 @@ def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MOD
3434
self.all_threads = all_threads
3535
self.mode = mode # Store mode for later use
3636
self.collect_stats = collect_stats
37+
try:
38+
self.unwinder = self._new_unwinder(native, gc, opcodes, skip_non_matching_threads)
39+
except RuntimeError as err:
40+
raise SystemExit(err) from err
41+
# Track sample intervals and total sample count
42+
self.sample_intervals = deque(maxlen=100)
43+
self.total_samples = 0
44+
self.realtime_stats = False
45+
46+
def _new_unwinder(self, native, gc, opcodes, skip_non_matching_threads):
3747
if _FREE_THREADED_BUILD:
38-
self.unwinder = _remote_debugging.RemoteUnwinder(
39-
self.pid, all_threads=self.all_threads, mode=mode, native=native, gc=gc,
48+
unwinder = _remote_debugging.RemoteUnwinder(
49+
self.pid, all_threads=self.all_threads, mode=self.mode, native=native, gc=gc,
4050
opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads,
41-
cache_frames=True, stats=collect_stats
51+
cache_frames=True, stats=self.collect_stats
4252
)
4353
else:
44-
only_active_threads = bool(self.all_threads)
45-
self.unwinder = _remote_debugging.RemoteUnwinder(
46-
self.pid, only_active_thread=only_active_threads, mode=mode, native=native, gc=gc,
54+
unwinder = _remote_debugging.RemoteUnwinder(
55+
self.pid, only_active_thread=bool(self.all_threads), mode=self.mode, native=native, gc=gc,
4756
opcodes=opcodes, skip_non_matching_threads=skip_non_matching_threads,
48-
cache_frames=True, stats=collect_stats
57+
cache_frames=True, stats=self.collect_stats
4958
)
50-
# Track sample intervals and total sample count
51-
self.sample_intervals = deque(maxlen=100)
52-
self.total_samples = 0
53-
self.realtime_stats = False
59+
return unwinder
5460

5561
def sample(self, collector, duration_sec=10, *, async_aware=False):
5662
sample_interval_sec = self.sample_interval_usec / 1_000_000
@@ -86,7 +92,7 @@ def sample(self, collector, duration_sec=10, *, async_aware=False):
8692
collector.collect_failed_sample()
8793
errors += 1
8894
except Exception as e:
89-
if not self._is_process_running():
95+
if not _is_process_running(self.pid):
9096
break
9197
raise e from None
9298

@@ -148,22 +154,6 @@ def sample(self, collector, duration_sec=10, *, async_aware=False):
148154
f"({(expected_samples - num_samples) / expected_samples * 100:.2f}%)"
149155
)
150156

151-
def _is_process_running(self):
152-
if sys.platform == "linux" or sys.platform == "darwin":
153-
try:
154-
os.kill(self.pid, 0)
155-
return True
156-
except ProcessLookupError:
157-
return False
158-
elif sys.platform == "win32":
159-
try:
160-
_remote_debugging.RemoteUnwinder(self.pid)
161-
except Exception:
162-
return False
163-
return True
164-
else:
165-
raise ValueError(f"Unsupported platform: {sys.platform}")
166-
167157
def _print_realtime_stats(self):
168158
"""Print real-time sampling statistics."""
169159
if len(self.sample_intervals) < 2:
@@ -279,6 +269,28 @@ def _print_unwinder_stats(self):
279269
print(f" {ANSIColors.YELLOW}Stale cache invalidations: {stale_invalidations}{ANSIColors.RESET}")
280270

281271

272+
def _is_process_running(pid):
273+
if pid <= 0:
274+
return False
275+
if os.name == "posix":
276+
try:
277+
os.kill(pid, 0)
278+
return True
279+
except ProcessLookupError:
280+
return False
281+
except PermissionError:
282+
# EPERM means process exists but we can't signal it
283+
return True
284+
elif sys.platform == "win32":
285+
try:
286+
_remote_debugging.RemoteUnwinder(pid)
287+
except Exception:
288+
return False
289+
return True
290+
else:
291+
raise ValueError(f"Unsupported platform: {sys.platform}")
292+
293+
282294
def sample(
283295
pid,
284296
collector,

Lib/test/test_profiling/test_sampling_profiler/test_cli.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from test.support import is_emscripten, requires_remote_subprocess_debugging
1717

1818
from profiling.sampling.cli import main
19+
from profiling.sampling.errors import SamplingScriptNotFoundError, SamplingModuleNotFoundError, SamplingUnknownProcessError
1920

2021

2122
class TestSampleProfilerCLI(unittest.TestCase):
@@ -203,12 +204,12 @@ def test_cli_mutually_exclusive_pid_script(self):
203204
with (
204205
mock.patch("sys.argv", test_args),
205206
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
206-
self.assertRaises(SystemExit) as cm,
207+
self.assertRaises(SamplingScriptNotFoundError) as cm,
207208
):
208209
main()
209210

210211
# Verify the error is about the non-existent script
211-
self.assertIn("12345", str(cm.exception.code))
212+
self.assertIn("12345", str(cm.exception))
212213

213214
def test_cli_no_target_specified(self):
214215
# In new CLI, must specify a subcommand
@@ -436,6 +437,7 @@ def test_cli_default_collapsed_filename(self):
436437

437438
with (
438439
mock.patch("sys.argv", test_args),
440+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
439441
mock.patch("profiling.sampling.cli.sample") as mock_sample,
440442
):
441443
main()
@@ -475,6 +477,7 @@ def test_cli_custom_output_filenames(self):
475477
for test_args, expected_filename, expected_format in test_cases:
476478
with (
477479
mock.patch("sys.argv", test_args),
480+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
478481
mock.patch("profiling.sampling.cli.sample") as mock_sample,
479482
):
480483
main()
@@ -513,6 +516,7 @@ def test_argument_parsing_basic(self):
513516

514517
with (
515518
mock.patch("sys.argv", test_args),
519+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
516520
mock.patch("profiling.sampling.cli.sample") as mock_sample,
517521
):
518522
main()
@@ -534,6 +538,7 @@ def test_sort_options(self):
534538

535539
with (
536540
mock.patch("sys.argv", test_args),
541+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
537542
mock.patch("profiling.sampling.cli.sample") as mock_sample,
538543
):
539544
main()
@@ -547,6 +552,7 @@ def test_async_aware_flag_defaults_to_running(self):
547552

548553
with (
549554
mock.patch("sys.argv", test_args),
555+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
550556
mock.patch("profiling.sampling.cli.sample") as mock_sample,
551557
):
552558
main()
@@ -562,6 +568,7 @@ def test_async_aware_with_async_mode_all(self):
562568

563569
with (
564570
mock.patch("sys.argv", test_args),
571+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
565572
mock.patch("profiling.sampling.cli.sample") as mock_sample,
566573
):
567574
main()
@@ -576,6 +583,7 @@ def test_async_aware_default_is_none(self):
576583

577584
with (
578585
mock.patch("sys.argv", test_args),
586+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
579587
mock.patch("profiling.sampling.cli.sample") as mock_sample,
580588
):
581589
main()
@@ -697,14 +705,20 @@ def test_async_aware_incompatible_with_all_threads(self):
697705
def test_run_nonexistent_script_exits_cleanly(self):
698706
"""Test that running a non-existent script exits with a clean error."""
699707
with mock.patch("sys.argv", ["profiling.sampling.cli", "run", "/nonexistent/script.py"]):
700-
with self.assertRaises(SystemExit) as cm:
708+
with self.assertRaisesRegex(SamplingScriptNotFoundError, "Script '[\\w/.]+' not found."):
701709
main()
702-
self.assertIn("Script not found", str(cm.exception.code))
703710

704711
@unittest.skipIf(is_emscripten, "subprocess not available")
705712
def test_run_nonexistent_module_exits_cleanly(self):
706713
"""Test that running a non-existent module exits with a clean error."""
707714
with mock.patch("sys.argv", ["profiling.sampling.cli", "run", "-m", "nonexistent_module_xyz"]):
708-
with self.assertRaises(SystemExit) as cm:
715+
with self.assertRaisesRegex(SamplingModuleNotFoundError, "Module '[\\w/.]+' not found."):
716+
main()
717+
718+
def test_cli_attach_nonexistent_pid(self):
719+
fake_pid = "99999"
720+
with mock.patch("sys.argv", ["profiling.sampling.cli", "attach", fake_pid]):
721+
with self.assertRaises(SamplingUnknownProcessError) as cm:
709722
main()
710-
self.assertIn("Module not found", str(cm.exception.code))
723+
724+
self.assertIn(fake_pid, str(cm.exception))

Lib/test/test_profiling/test_sampling_profiler/test_integration.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import profiling.sampling.sample
1818
from profiling.sampling.pstats_collector import PstatsCollector
1919
from profiling.sampling.stack_collector import CollapsedStackCollector
20-
from profiling.sampling.sample import SampleProfiler
20+
from profiling.sampling.sample import SampleProfiler, _is_process_running
2121
except ImportError:
2222
raise unittest.SkipTest(
2323
"Test only runs when _remote_debugging is available"
@@ -602,7 +602,7 @@ def test_sample_target_module(self):
602602
@requires_remote_subprocess_debugging()
603603
class TestSampleProfilerErrorHandling(unittest.TestCase):
604604
def test_invalid_pid(self):
605-
with self.assertRaises((OSError, RuntimeError)):
605+
with self.assertRaises((SystemExit, PermissionError)):
606606
collector = PstatsCollector(sample_interval_usec=100, skip_idle=False)
607607
profiling.sampling.sample.sample(-1, collector, duration_sec=1)
608608

@@ -638,7 +638,7 @@ def test_is_process_running(self):
638638
sample_interval_usec=1000,
639639
all_threads=False,
640640
)
641-
self.assertTrue(profiler._is_process_running())
641+
self.assertTrue(_is_process_running(profiler.pid))
642642
self.assertIsNotNone(profiler.unwinder.get_stack_trace())
643643
subproc.process.kill()
644644
subproc.process.wait()
@@ -647,7 +647,7 @@ def test_is_process_running(self):
647647
)
648648

649649
# Exit the context manager to ensure the process is terminated
650-
self.assertFalse(profiler._is_process_running())
650+
self.assertFalse(_is_process_running(profiler.pid))
651651
self.assertRaises(
652652
ProcessLookupError, profiler.unwinder.get_stack_trace
653653
)

Lib/test/test_profiling/test_sampling_profiler/test_modes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ def test_gil_mode_validation(self):
252252

253253
with (
254254
mock.patch("sys.argv", test_args),
255+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
255256
mock.patch("profiling.sampling.cli.sample") as mock_sample,
256257
):
257258
try:
@@ -313,6 +314,7 @@ def test_gil_mode_cli_argument_parsing(self):
313314

314315
with (
315316
mock.patch("sys.argv", test_args),
317+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
316318
mock.patch("profiling.sampling.cli.sample") as mock_sample,
317319
):
318320
try:
@@ -432,6 +434,7 @@ def test_exception_mode_validation(self):
432434

433435
with (
434436
mock.patch("sys.argv", test_args),
437+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
435438
mock.patch("profiling.sampling.cli.sample") as mock_sample,
436439
):
437440
try:
@@ -493,6 +496,7 @@ def test_exception_mode_cli_argument_parsing(self):
493496

494497
with (
495498
mock.patch("sys.argv", test_args),
499+
mock.patch("profiling.sampling.cli._is_process_running", return_value=True),
496500
mock.patch("profiling.sampling.cli.sample") as mock_sample,
497501
):
498502
try:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Show the clearer error message when using ``profiling.sampling`` on an
2+
unknown PID.

0 commit comments

Comments
 (0)