Skip to content

Commit 0c66da8

Browse files
authored
gh-140137: Handle empty collections in profiling.sampling (#140154)
1 parent a05aece commit 0c66da8

File tree

3 files changed

+97
-33
lines changed

3 files changed

+97
-33
lines changed

Lib/profiling/sampling/sample.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -642,9 +642,14 @@ def sample(
642642

643643
if output_format == "pstats" and not filename:
644644
stats = pstats.SampledStats(collector).strip_dirs()
645-
print_sampled_stats(
646-
stats, sort, limit, show_summary, sample_interval_usec
647-
)
645+
if not stats.stats:
646+
print("No samples were collected.")
647+
if mode == PROFILING_MODE_CPU:
648+
print("This can happen in CPU mode when all threads are idle.")
649+
else:
650+
print_sampled_stats(
651+
stats, sort, limit, show_summary, sample_interval_usec
652+
)
648653
else:
649654
collector.export(filename)
650655

Lib/pstats.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def load_stats(self, arg):
154154
arg.create_stats()
155155
self.stats = arg.stats
156156
arg.stats = {}
157+
return
157158
if not self.stats:
158159
raise TypeError("Cannot create or construct a %r object from %r"
159160
% (self.__class__, arg))

Lib/test/test_profiling/test_sampling_profiler.py

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import sys
1212
import tempfile
1313
import unittest
14+
from collections import namedtuple
1415
from unittest import mock
1516

1617
from profiling.sampling.pstats_collector import PstatsCollector
@@ -84,6 +85,8 @@ def __repr__(self):
8485
"Test only runs on Linux, Windows and MacOS",
8586
)
8687

88+
SubprocessInfo = namedtuple('SubprocessInfo', ['process', 'socket'])
89+
8790

8891
@contextlib.contextmanager
8992
def test_subprocess(script):
@@ -123,7 +126,7 @@ def test_subprocess(script):
123126
if response != b"ready":
124127
raise RuntimeError(f"Unexpected response from subprocess: {response}")
125128

126-
yield proc
129+
yield SubprocessInfo(proc, client_socket)
127130
finally:
128131
if client_socket is not None:
129132
client_socket.close()
@@ -1752,13 +1755,13 @@ def main_loop():
17521755

17531756
def test_sampling_basic_functionality(self):
17541757
with (
1755-
test_subprocess(self.test_script) as proc,
1758+
test_subprocess(self.test_script) as subproc,
17561759
io.StringIO() as captured_output,
17571760
mock.patch("sys.stdout", captured_output),
17581761
):
17591762
try:
17601763
profiling.sampling.sample.sample(
1761-
proc.pid,
1764+
subproc.process.pid,
17621765
duration_sec=2,
17631766
sample_interval_usec=1000, # 1ms
17641767
show_summary=False,
@@ -1782,15 +1785,15 @@ def test_sampling_with_pstats_export(self):
17821785
)
17831786
self.addCleanup(close_and_unlink, pstats_out)
17841787

1785-
with test_subprocess(self.test_script) as proc:
1788+
with test_subprocess(self.test_script) as subproc:
17861789
# Suppress profiler output when testing file export
17871790
with (
17881791
io.StringIO() as captured_output,
17891792
mock.patch("sys.stdout", captured_output),
17901793
):
17911794
try:
17921795
profiling.sampling.sample.sample(
1793-
proc.pid,
1796+
subproc.process.pid,
17941797
duration_sec=1,
17951798
filename=pstats_out.name,
17961799
sample_interval_usec=10000,
@@ -1826,7 +1829,7 @@ def test_sampling_with_collapsed_export(self):
18261829
self.addCleanup(close_and_unlink, collapsed_file)
18271830

18281831
with (
1829-
test_subprocess(self.test_script) as proc,
1832+
test_subprocess(self.test_script) as subproc,
18301833
):
18311834
# Suppress profiler output when testing file export
18321835
with (
@@ -1835,7 +1838,7 @@ def test_sampling_with_collapsed_export(self):
18351838
):
18361839
try:
18371840
profiling.sampling.sample.sample(
1838-
proc.pid,
1841+
subproc.process.pid,
18391842
duration_sec=1,
18401843
filename=collapsed_file.name,
18411844
output_format="collapsed",
@@ -1876,14 +1879,14 @@ def test_sampling_with_collapsed_export(self):
18761879

18771880
def test_sampling_all_threads(self):
18781881
with (
1879-
test_subprocess(self.test_script) as proc,
1882+
test_subprocess(self.test_script) as subproc,
18801883
# Suppress profiler output
18811884
io.StringIO() as captured_output,
18821885
mock.patch("sys.stdout", captured_output),
18831886
):
18841887
try:
18851888
profiling.sampling.sample.sample(
1886-
proc.pid,
1889+
subproc.process.pid,
18871890
duration_sec=1,
18881891
all_threads=True,
18891892
sample_interval_usec=10000,
@@ -1969,14 +1972,14 @@ def test_invalid_pid(self):
19691972
profiling.sampling.sample.sample(-1, duration_sec=1)
19701973

19711974
def test_process_dies_during_sampling(self):
1972-
with test_subprocess("import time; time.sleep(0.5); exit()") as proc:
1975+
with test_subprocess("import time; time.sleep(0.5); exit()") as subproc:
19731976
with (
19741977
io.StringIO() as captured_output,
19751978
mock.patch("sys.stdout", captured_output),
19761979
):
19771980
try:
19781981
profiling.sampling.sample.sample(
1979-
proc.pid,
1982+
subproc.process.pid,
19801983
duration_sec=2, # Longer than process lifetime
19811984
sample_interval_usec=50000,
19821985
)
@@ -2018,17 +2021,17 @@ def test_invalid_output_format_with_mocked_profiler(self):
20182021
)
20192022

20202023
def test_is_process_running(self):
2021-
with test_subprocess("import time; time.sleep(1000)") as proc:
2024+
with test_subprocess("import time; time.sleep(1000)") as subproc:
20222025
try:
2023-
profiler = SampleProfiler(pid=proc.pid, sample_interval_usec=1000, all_threads=False)
2026+
profiler = SampleProfiler(pid=subproc.process.pid, sample_interval_usec=1000, all_threads=False)
20242027
except PermissionError:
20252028
self.skipTest(
20262029
"Insufficient permissions to read the stack trace"
20272030
)
20282031
self.assertTrue(profiler._is_process_running())
20292032
self.assertIsNotNone(profiler.unwinder.get_stack_trace())
2030-
proc.kill()
2031-
proc.wait()
2033+
subproc.process.kill()
2034+
subproc.process.wait()
20322035
self.assertRaises(ProcessLookupError, profiler.unwinder.get_stack_trace)
20332036

20342037
# Exit the context manager to ensure the process is terminated
@@ -2037,20 +2040,20 @@ def test_is_process_running(self):
20372040

20382041
@unittest.skipUnless(sys.platform == "linux", "Only valid on Linux")
20392042
def test_esrch_signal_handling(self):
2040-
with test_subprocess("import time; time.sleep(1000)") as proc:
2043+
with test_subprocess("import time; time.sleep(1000)") as subproc:
20412044
try:
2042-
unwinder = _remote_debugging.RemoteUnwinder(proc.pid)
2045+
unwinder = _remote_debugging.RemoteUnwinder(subproc.process.pid)
20432046
except PermissionError:
20442047
self.skipTest(
20452048
"Insufficient permissions to read the stack trace"
20462049
)
20472050
initial_trace = unwinder.get_stack_trace()
20482051
self.assertIsNotNone(initial_trace)
20492052

2050-
proc.kill()
2053+
subproc.process.kill()
20512054

20522055
# Wait for the process to die and try to get another trace
2053-
proc.wait()
2056+
subproc.process.wait()
20542057

20552058
with self.assertRaises(ProcessLookupError):
20562059
unwinder.get_stack_trace()
@@ -2644,35 +2647,47 @@ def test_cpu_mode_integration_filtering(self):
26442647
import time
26452648
import threading
26462649
2650+
cpu_ready = threading.Event()
2651+
26472652
def idle_worker():
26482653
time.sleep(999999)
26492654
26502655
def cpu_active_worker():
2656+
cpu_ready.set()
26512657
x = 1
26522658
while True:
26532659
x += 1
26542660
26552661
def main():
2656-
# Start both threads
2662+
# Start both threads
26572663
idle_thread = threading.Thread(target=idle_worker)
26582664
cpu_thread = threading.Thread(target=cpu_active_worker)
26592665
idle_thread.start()
26602666
cpu_thread.start()
2667+
2668+
# Wait for CPU thread to be running, then signal test
2669+
cpu_ready.wait()
2670+
_test_sock.sendall(b"threads_ready")
2671+
26612672
idle_thread.join()
26622673
cpu_thread.join()
26632674
26642675
main()
26652676
26662677
'''
2667-
with test_subprocess(cpu_vs_idle_script) as proc:
2678+
with test_subprocess(cpu_vs_idle_script) as subproc:
2679+
# Wait for signal that threads are running
2680+
response = subproc.socket.recv(1024)
2681+
self.assertEqual(response, b"threads_ready")
2682+
26682683
with (
26692684
io.StringIO() as captured_output,
26702685
mock.patch("sys.stdout", captured_output),
26712686
):
26722687
try:
26732688
profiling.sampling.sample.sample(
2674-
proc.pid,
2675-
duration_sec=0.5,
2689+
subproc.process.pid,
2690+
duration_sec=2.0,
26762691
sample_interval_usec=5000,
26772692
mode=1, # CPU mode
26782693
show_summary=False,
@@ -2690,8 +2705,8 @@ def main():
26902705
):
26912706
try:
26922707
profiling.sampling.sample.sample(
2693-
proc.pid,
2694-
duration_sec=0.5,
2708+
subproc.process.pid,
2709+
duration_sec=2.0,
26952710
sample_interval_usec=5000,
26962711
mode=0, # Wall-clock mode
26972712
show_summary=False,
@@ -2716,6 +2731,37 @@ def main():
27162731
self.assertIn("cpu_active_worker", wall_mode_output)
27172732
self.assertIn("idle_worker", wall_mode_output)
27182733

2734+
def test_cpu_mode_with_no_samples(self):
2735+
"""Test that CPU mode handles no samples gracefully when no samples are collected."""
2736+
# Mock a collector that returns empty stats
2737+
mock_collector = mock.MagicMock()
2738+
mock_collector.stats = {}
2739+
mock_collector.create_stats = mock.MagicMock()
2740+
2741+
with (
2742+
io.StringIO() as captured_output,
2743+
mock.patch("sys.stdout", captured_output),
2744+
mock.patch("profiling.sampling.sample.PstatsCollector", return_value=mock_collector),
2745+
mock.patch("profiling.sampling.sample.SampleProfiler") as mock_profiler_class,
2746+
):
2747+
mock_profiler = mock.MagicMock()
2748+
mock_profiler_class.return_value = mock_profiler
2749+
2750+
profiling.sampling.sample.sample(
2751+
12345, # dummy PID
2752+
duration_sec=0.5,
2753+
sample_interval_usec=5000,
2754+
mode=1, # CPU mode
2755+
show_summary=False,
2756+
all_threads=True,
2757+
)
2758+
2759+
output = captured_output.getvalue()
2760+
2761+
# Should see the "No samples were collected" message
2762+
self.assertIn("No samples were collected", output)
2763+
self.assertIn("CPU mode", output)
2764+
27192765

27202766
class TestGilModeFiltering(unittest.TestCase):
27212767
"""Test GIL mode filtering functionality (--mode=gil)."""
@@ -2852,34 +2898,46 @@ def test_gil_mode_integration_behavior(self):
28522898
import time
28532899
import threading
28542900
2901+
gil_ready = threading.Event()
2902+
28552903
def gil_releasing_work():
28562904
time.sleep(999999)
28572905
28582906
def gil_holding_work():
2907+
gil_ready.set()
28592908
x = 1
28602909
while True:
28612910
x += 1
28622911
28632912
def main():
2864-
# Start both threads
2913+
# Start both threads
28652914
idle_thread = threading.Thread(target=gil_releasing_work)
28662915
cpu_thread = threading.Thread(target=gil_holding_work)
28672916
idle_thread.start()
28682917
cpu_thread.start()
2918+
2919+
# Wait for GIL-holding thread to be running, then signal test
2920+
gil_ready.wait()
2921+
_test_sock.sendall(b"threads_ready")
2922+
28692923
idle_thread.join()
28702924
cpu_thread.join()
28712925
28722926
main()
28732927
'''
2874-
with test_subprocess(gil_test_script) as proc:
2928+
with test_subprocess(gil_test_script) as subproc:
2929+
# Wait for signal that threads are running
2930+
response = subproc.socket.recv(1024)
2931+
self.assertEqual(response, b"threads_ready")
2932+
28752933
with (
28762934
io.StringIO() as captured_output,
28772935
mock.patch("sys.stdout", captured_output),
28782936
):
28792937
try:
28802938
profiling.sampling.sample.sample(
2881-
proc.pid,
2882-
duration_sec=0.5,
2939+
subproc.process.pid,
2940+
duration_sec=2.0,
28832941
sample_interval_usec=5000,
28842942
mode=2, # GIL mode
28852943
show_summary=False,
@@ -2897,7 +2955,7 @@ def main():
28972955
):
28982956
try:
28992957
profiling.sampling.sample.sample(
2900-
proc.pid,
2958+
subproc.process.pid,
29012959
duration_sec=0.5,
29022960
sample_interval_usec=5000,
29032961
mode=0, # Wall-clock mode

0 commit comments

Comments
 (0)