https://github.com/python/cpython/commit/0c66da8de45e7decdd06face441f7517eb8efe22
commit: 0c66da8de45e7decdd06face441f7517eb8efe22
branch: main
author: Pablo Galindo Salgado <[email protected]>
committer: pablogsal <[email protected]>
date: 2025-10-15T14:59:12+01:00
summary:
gh-140137: Handle empty collections in profiling.sampling (#140154)
files:
M Lib/profiling/sampling/sample.py
M Lib/pstats.py
M Lib/test/test_profiling/test_sampling_profiler.py
diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py
index e0d4583f0a1aec..7a0f739a5428c6 100644
--- a/Lib/profiling/sampling/sample.py
+++ b/Lib/profiling/sampling/sample.py
@@ -642,9 +642,14 @@ def sample(
if output_format == "pstats" and not filename:
stats = pstats.SampledStats(collector).strip_dirs()
- print_sampled_stats(
- stats, sort, limit, show_summary, sample_interval_usec
- )
+ if not stats.stats:
+ print("No samples were collected.")
+ if mode == PROFILING_MODE_CPU:
+ print("This can happen in CPU mode when all threads are idle.")
+ else:
+ print_sampled_stats(
+ stats, sort, limit, show_summary, sample_interval_usec
+ )
else:
collector.export(filename)
diff --git a/Lib/pstats.py b/Lib/pstats.py
index 079abd2c1b81df..07ecda07796e44 100644
--- a/Lib/pstats.py
+++ b/Lib/pstats.py
@@ -154,6 +154,7 @@ def load_stats(self, arg):
arg.create_stats()
self.stats = arg.stats
arg.stats = {}
+ return
if not self.stats:
raise TypeError("Cannot create or construct a %r object from %r"
% (self.__class__, arg))
diff --git a/Lib/test/test_profiling/test_sampling_profiler.py
b/Lib/test/test_profiling/test_sampling_profiler.py
index a1342cafff1f83..59bc18b9bcf14d 100644
--- a/Lib/test/test_profiling/test_sampling_profiler.py
+++ b/Lib/test/test_profiling/test_sampling_profiler.py
@@ -11,6 +11,7 @@
import sys
import tempfile
import unittest
+from collections import namedtuple
from unittest import mock
from profiling.sampling.pstats_collector import PstatsCollector
@@ -84,6 +85,8 @@ def __repr__(self):
"Test only runs on Linux, Windows and MacOS",
)
+SubprocessInfo = namedtuple('SubprocessInfo', ['process', 'socket'])
+
@contextlib.contextmanager
def test_subprocess(script):
@@ -123,7 +126,7 @@ def test_subprocess(script):
if response != b"ready":
raise RuntimeError(f"Unexpected response from subprocess:
{response}")
- yield proc
+ yield SubprocessInfo(proc, client_socket)
finally:
if client_socket is not None:
client_socket.close()
@@ -1752,13 +1755,13 @@ def main_loop():
def test_sampling_basic_functionality(self):
with (
- test_subprocess(self.test_script) as proc,
+ test_subprocess(self.test_script) as subproc,
io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output),
):
try:
profiling.sampling.sample.sample(
- proc.pid,
+ subproc.process.pid,
duration_sec=2,
sample_interval_usec=1000, # 1ms
show_summary=False,
@@ -1782,7 +1785,7 @@ def test_sampling_with_pstats_export(self):
)
self.addCleanup(close_and_unlink, pstats_out)
- with test_subprocess(self.test_script) as proc:
+ with test_subprocess(self.test_script) as subproc:
# Suppress profiler output when testing file export
with (
io.StringIO() as captured_output,
@@ -1790,7 +1793,7 @@ def test_sampling_with_pstats_export(self):
):
try:
profiling.sampling.sample.sample(
- proc.pid,
+ subproc.process.pid,
duration_sec=1,
filename=pstats_out.name,
sample_interval_usec=10000,
@@ -1826,7 +1829,7 @@ def test_sampling_with_collapsed_export(self):
self.addCleanup(close_and_unlink, collapsed_file)
with (
- test_subprocess(self.test_script) as proc,
+ test_subprocess(self.test_script) as subproc,
):
# Suppress profiler output when testing file export
with (
@@ -1835,7 +1838,7 @@ def test_sampling_with_collapsed_export(self):
):
try:
profiling.sampling.sample.sample(
- proc.pid,
+ subproc.process.pid,
duration_sec=1,
filename=collapsed_file.name,
output_format="collapsed",
@@ -1876,14 +1879,14 @@ def test_sampling_with_collapsed_export(self):
def test_sampling_all_threads(self):
with (
- test_subprocess(self.test_script) as proc,
+ test_subprocess(self.test_script) as subproc,
# Suppress profiler output
io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output),
):
try:
profiling.sampling.sample.sample(
- proc.pid,
+ subproc.process.pid,
duration_sec=1,
all_threads=True,
sample_interval_usec=10000,
@@ -1969,14 +1972,14 @@ def test_invalid_pid(self):
profiling.sampling.sample.sample(-1, duration_sec=1)
def test_process_dies_during_sampling(self):
- with test_subprocess("import time; time.sleep(0.5); exit()") as proc:
+ with test_subprocess("import time; time.sleep(0.5); exit()") as
subproc:
with (
io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output),
):
try:
profiling.sampling.sample.sample(
- proc.pid,
+ subproc.process.pid,
duration_sec=2, # Longer than process lifetime
sample_interval_usec=50000,
)
@@ -2018,17 +2021,17 @@ def
test_invalid_output_format_with_mocked_profiler(self):
)
def test_is_process_running(self):
- with test_subprocess("import time; time.sleep(1000)") as proc:
+ with test_subprocess("import time; time.sleep(1000)") as subproc:
try:
- profiler = SampleProfiler(pid=proc.pid,
sample_interval_usec=1000, all_threads=False)
+ profiler = SampleProfiler(pid=subproc.process.pid,
sample_interval_usec=1000, all_threads=False)
except PermissionError:
self.skipTest(
"Insufficient permissions to read the stack trace"
)
self.assertTrue(profiler._is_process_running())
self.assertIsNotNone(profiler.unwinder.get_stack_trace())
- proc.kill()
- proc.wait()
+ subproc.process.kill()
+ subproc.process.wait()
self.assertRaises(ProcessLookupError,
profiler.unwinder.get_stack_trace)
# Exit the context manager to ensure the process is terminated
@@ -2037,9 +2040,9 @@ def test_is_process_running(self):
@unittest.skipUnless(sys.platform == "linux", "Only valid on Linux")
def test_esrch_signal_handling(self):
- with test_subprocess("import time; time.sleep(1000)") as proc:
+ with test_subprocess("import time; time.sleep(1000)") as subproc:
try:
- unwinder = _remote_debugging.RemoteUnwinder(proc.pid)
+ unwinder =
_remote_debugging.RemoteUnwinder(subproc.process.pid)
except PermissionError:
self.skipTest(
"Insufficient permissions to read the stack trace"
@@ -2047,10 +2050,10 @@ def test_esrch_signal_handling(self):
initial_trace = unwinder.get_stack_trace()
self.assertIsNotNone(initial_trace)
- proc.kill()
+ subproc.process.kill()
# Wait for the process to die and try to get another trace
- proc.wait()
+ subproc.process.wait()
with self.assertRaises(ProcessLookupError):
unwinder.get_stack_trace()
@@ -2644,35 +2647,47 @@ def test_cpu_mode_integration_filtering(self):
import time
import threading
+cpu_ready = threading.Event()
+
def idle_worker():
time.sleep(999999)
def cpu_active_worker():
+ cpu_ready.set()
x = 1
while True:
x += 1
def main():
-# Start both threads
+ # Start both threads
idle_thread = threading.Thread(target=idle_worker)
cpu_thread = threading.Thread(target=cpu_active_worker)
idle_thread.start()
cpu_thread.start()
+
+ # Wait for CPU thread to be running, then signal test
+ cpu_ready.wait()
+ _test_sock.sendall(b"threads_ready")
+
idle_thread.join()
cpu_thread.join()
main()
'''
- with test_subprocess(cpu_vs_idle_script) as proc:
+ with test_subprocess(cpu_vs_idle_script) as subproc:
+ # Wait for signal that threads are running
+ response = subproc.socket.recv(1024)
+ self.assertEqual(response, b"threads_ready")
+
with (
io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output),
):
try:
profiling.sampling.sample.sample(
- proc.pid,
- duration_sec=0.5,
+ subproc.process.pid,
+ duration_sec=2.0,
sample_interval_usec=5000,
mode=1, # CPU mode
show_summary=False,
@@ -2690,8 +2705,8 @@ def main():
):
try:
profiling.sampling.sample.sample(
- proc.pid,
- duration_sec=0.5,
+ subproc.process.pid,
+ duration_sec=2.0,
sample_interval_usec=5000,
mode=0, # Wall-clock mode
show_summary=False,
@@ -2716,6 +2731,37 @@ def main():
self.assertIn("cpu_active_worker", wall_mode_output)
self.assertIn("idle_worker", wall_mode_output)
+ def test_cpu_mode_with_no_samples(self):
+ """Test that CPU mode handles no samples gracefully when no samples
are collected."""
+ # Mock a collector that returns empty stats
+ mock_collector = mock.MagicMock()
+ mock_collector.stats = {}
+ mock_collector.create_stats = mock.MagicMock()
+
+ with (
+ io.StringIO() as captured_output,
+ mock.patch("sys.stdout", captured_output),
+ mock.patch("profiling.sampling.sample.PstatsCollector",
return_value=mock_collector),
+ mock.patch("profiling.sampling.sample.SampleProfiler") as
mock_profiler_class,
+ ):
+ mock_profiler = mock.MagicMock()
+ mock_profiler_class.return_value = mock_profiler
+
+ profiling.sampling.sample.sample(
+ 12345, # dummy PID
+ duration_sec=0.5,
+ sample_interval_usec=5000,
+ mode=1, # CPU mode
+ show_summary=False,
+ all_threads=True,
+ )
+
+ output = captured_output.getvalue()
+
+ # Should see the "No samples were collected" message
+ self.assertIn("No samples were collected", output)
+ self.assertIn("CPU mode", output)
+
class TestGilModeFiltering(unittest.TestCase):
"""Test GIL mode filtering functionality (--mode=gil)."""
@@ -2852,34 +2898,46 @@ def test_gil_mode_integration_behavior(self):
import time
import threading
+gil_ready = threading.Event()
+
def gil_releasing_work():
time.sleep(999999)
def gil_holding_work():
+ gil_ready.set()
x = 1
while True:
x += 1
def main():
-# Start both threads
+ # Start both threads
idle_thread = threading.Thread(target=gil_releasing_work)
cpu_thread = threading.Thread(target=gil_holding_work)
idle_thread.start()
cpu_thread.start()
+
+ # Wait for GIL-holding thread to be running, then signal test
+ gil_ready.wait()
+ _test_sock.sendall(b"threads_ready")
+
idle_thread.join()
cpu_thread.join()
main()
'''
- with test_subprocess(gil_test_script) as proc:
+ with test_subprocess(gil_test_script) as subproc:
+ # Wait for signal that threads are running
+ response = subproc.socket.recv(1024)
+ self.assertEqual(response, b"threads_ready")
+
with (
io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output),
):
try:
profiling.sampling.sample.sample(
- proc.pid,
- duration_sec=0.5,
+ subproc.process.pid,
+ duration_sec=2.0,
sample_interval_usec=5000,
mode=2, # GIL mode
show_summary=False,
@@ -2897,7 +2955,7 @@ def main():
):
try:
profiling.sampling.sample.sample(
- proc.pid,
+ subproc.process.pid,
duration_sec=0.5,
sample_interval_usec=5000,
mode=0, # Wall-clock mode
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]