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]

Reply via email to