gh-140137: Handle empty collections in profiling.sampling (#140154)

This commit is contained in:
Pablo Galindo Salgado
2025-10-15 14:59:12 +01:00
committed by GitHub
parent a05aece543
commit 0c66da8de4
3 changed files with 97 additions and 33 deletions

View File

@@ -642,9 +642,14 @@ def sample(
if output_format == "pstats" and not filename: if output_format == "pstats" and not filename:
stats = pstats.SampledStats(collector).strip_dirs() stats = pstats.SampledStats(collector).strip_dirs()
print_sampled_stats( if not stats.stats:
stats, sort, limit, show_summary, sample_interval_usec 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: else:
collector.export(filename) collector.export(filename)

View File

@@ -154,6 +154,7 @@ class Stats:
arg.create_stats() arg.create_stats()
self.stats = arg.stats self.stats = arg.stats
arg.stats = {} arg.stats = {}
return
if not self.stats: if not self.stats:
raise TypeError("Cannot create or construct a %r object from %r" raise TypeError("Cannot create or construct a %r object from %r"
% (self.__class__, arg)) % (self.__class__, arg))

View File

@@ -11,6 +11,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from collections import namedtuple
from unittest import mock from unittest import mock
from profiling.sampling.pstats_collector import PstatsCollector from profiling.sampling.pstats_collector import PstatsCollector
@@ -84,6 +85,8 @@ skip_if_not_supported = unittest.skipIf(
"Test only runs on Linux, Windows and MacOS", "Test only runs on Linux, Windows and MacOS",
) )
SubprocessInfo = namedtuple('SubprocessInfo', ['process', 'socket'])
@contextlib.contextmanager @contextlib.contextmanager
def test_subprocess(script): def test_subprocess(script):
@@ -123,7 +126,7 @@ _test_sock.sendall(b"ready")
if response != b"ready": if response != b"ready":
raise RuntimeError(f"Unexpected response from subprocess: {response}") raise RuntimeError(f"Unexpected response from subprocess: {response}")
yield proc yield SubprocessInfo(proc, client_socket)
finally: finally:
if client_socket is not None: if client_socket is not None:
client_socket.close() client_socket.close()
@@ -1752,13 +1755,13 @@ if __name__ == "__main__":
def test_sampling_basic_functionality(self): def test_sampling_basic_functionality(self):
with ( with (
test_subprocess(self.test_script) as proc, test_subprocess(self.test_script) as subproc,
io.StringIO() as captured_output, io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output), mock.patch("sys.stdout", captured_output),
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=2, duration_sec=2,
sample_interval_usec=1000, # 1ms sample_interval_usec=1000, # 1ms
show_summary=False, show_summary=False,
@@ -1782,7 +1785,7 @@ if __name__ == "__main__":
) )
self.addCleanup(close_and_unlink, pstats_out) 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 # Suppress profiler output when testing file export
with ( with (
io.StringIO() as captured_output, io.StringIO() as captured_output,
@@ -1790,7 +1793,7 @@ if __name__ == "__main__":
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=1, duration_sec=1,
filename=pstats_out.name, filename=pstats_out.name,
sample_interval_usec=10000, sample_interval_usec=10000,
@@ -1826,7 +1829,7 @@ if __name__ == "__main__":
self.addCleanup(close_and_unlink, collapsed_file) self.addCleanup(close_and_unlink, collapsed_file)
with ( with (
test_subprocess(self.test_script) as proc, test_subprocess(self.test_script) as subproc,
): ):
# Suppress profiler output when testing file export # Suppress profiler output when testing file export
with ( with (
@@ -1835,7 +1838,7 @@ if __name__ == "__main__":
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=1, duration_sec=1,
filename=collapsed_file.name, filename=collapsed_file.name,
output_format="collapsed", output_format="collapsed",
@@ -1876,14 +1879,14 @@ if __name__ == "__main__":
def test_sampling_all_threads(self): def test_sampling_all_threads(self):
with ( with (
test_subprocess(self.test_script) as proc, test_subprocess(self.test_script) as subproc,
# Suppress profiler output # Suppress profiler output
io.StringIO() as captured_output, io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output), mock.patch("sys.stdout", captured_output),
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=1, duration_sec=1,
all_threads=True, all_threads=True,
sample_interval_usec=10000, sample_interval_usec=10000,
@@ -1969,14 +1972,14 @@ class TestSampleProfilerErrorHandling(unittest.TestCase):
profiling.sampling.sample.sample(-1, duration_sec=1) profiling.sampling.sample.sample(-1, duration_sec=1)
def test_process_dies_during_sampling(self): 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 ( with (
io.StringIO() as captured_output, io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output), mock.patch("sys.stdout", captured_output),
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=2, # Longer than process lifetime duration_sec=2, # Longer than process lifetime
sample_interval_usec=50000, sample_interval_usec=50000,
) )
@@ -2018,17 +2021,17 @@ class TestSampleProfilerErrorHandling(unittest.TestCase):
) )
def test_is_process_running(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: 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: except PermissionError:
self.skipTest( self.skipTest(
"Insufficient permissions to read the stack trace" "Insufficient permissions to read the stack trace"
) )
self.assertTrue(profiler._is_process_running()) self.assertTrue(profiler._is_process_running())
self.assertIsNotNone(profiler.unwinder.get_stack_trace()) self.assertIsNotNone(profiler.unwinder.get_stack_trace())
proc.kill() subproc.process.kill()
proc.wait() subproc.process.wait()
self.assertRaises(ProcessLookupError, profiler.unwinder.get_stack_trace) self.assertRaises(ProcessLookupError, profiler.unwinder.get_stack_trace)
# Exit the context manager to ensure the process is terminated # Exit the context manager to ensure the process is terminated
@@ -2037,9 +2040,9 @@ class TestSampleProfilerErrorHandling(unittest.TestCase):
@unittest.skipUnless(sys.platform == "linux", "Only valid on Linux") @unittest.skipUnless(sys.platform == "linux", "Only valid on Linux")
def test_esrch_signal_handling(self): 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: try:
unwinder = _remote_debugging.RemoteUnwinder(proc.pid) unwinder = _remote_debugging.RemoteUnwinder(subproc.process.pid)
except PermissionError: except PermissionError:
self.skipTest( self.skipTest(
"Insufficient permissions to read the stack trace" "Insufficient permissions to read the stack trace"
@@ -2047,10 +2050,10 @@ class TestSampleProfilerErrorHandling(unittest.TestCase):
initial_trace = unwinder.get_stack_trace() initial_trace = unwinder.get_stack_trace()
self.assertIsNotNone(initial_trace) self.assertIsNotNone(initial_trace)
proc.kill() subproc.process.kill()
# Wait for the process to die and try to get another trace # Wait for the process to die and try to get another trace
proc.wait() subproc.process.wait()
with self.assertRaises(ProcessLookupError): with self.assertRaises(ProcessLookupError):
unwinder.get_stack_trace() unwinder.get_stack_trace()
@@ -2644,35 +2647,47 @@ class TestCpuModeFiltering(unittest.TestCase):
import time import time
import threading import threading
cpu_ready = threading.Event()
def idle_worker(): def idle_worker():
time.sleep(999999) time.sleep(999999)
def cpu_active_worker(): def cpu_active_worker():
cpu_ready.set()
x = 1 x = 1
while True: while True:
x += 1 x += 1
def main(): def main():
# Start both threads # Start both threads
idle_thread = threading.Thread(target=idle_worker) idle_thread = threading.Thread(target=idle_worker)
cpu_thread = threading.Thread(target=cpu_active_worker) cpu_thread = threading.Thread(target=cpu_active_worker)
idle_thread.start() idle_thread.start()
cpu_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() idle_thread.join()
cpu_thread.join() cpu_thread.join()
main() 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 ( with (
io.StringIO() as captured_output, io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output), mock.patch("sys.stdout", captured_output),
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=0.5, duration_sec=2.0,
sample_interval_usec=5000, sample_interval_usec=5000,
mode=1, # CPU mode mode=1, # CPU mode
show_summary=False, show_summary=False,
@@ -2690,8 +2705,8 @@ main()
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=0.5, duration_sec=2.0,
sample_interval_usec=5000, sample_interval_usec=5000,
mode=0, # Wall-clock mode mode=0, # Wall-clock mode
show_summary=False, show_summary=False,
@@ -2716,6 +2731,37 @@ main()
self.assertIn("cpu_active_worker", wall_mode_output) self.assertIn("cpu_active_worker", wall_mode_output)
self.assertIn("idle_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): class TestGilModeFiltering(unittest.TestCase):
"""Test GIL mode filtering functionality (--mode=gil).""" """Test GIL mode filtering functionality (--mode=gil)."""
@@ -2852,34 +2898,46 @@ class TestGilModeFiltering(unittest.TestCase):
import time import time
import threading import threading
gil_ready = threading.Event()
def gil_releasing_work(): def gil_releasing_work():
time.sleep(999999) time.sleep(999999)
def gil_holding_work(): def gil_holding_work():
gil_ready.set()
x = 1 x = 1
while True: while True:
x += 1 x += 1
def main(): def main():
# Start both threads # Start both threads
idle_thread = threading.Thread(target=gil_releasing_work) idle_thread = threading.Thread(target=gil_releasing_work)
cpu_thread = threading.Thread(target=gil_holding_work) cpu_thread = threading.Thread(target=gil_holding_work)
idle_thread.start() idle_thread.start()
cpu_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() idle_thread.join()
cpu_thread.join() cpu_thread.join()
main() 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 ( with (
io.StringIO() as captured_output, io.StringIO() as captured_output,
mock.patch("sys.stdout", captured_output), mock.patch("sys.stdout", captured_output),
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=0.5, duration_sec=2.0,
sample_interval_usec=5000, sample_interval_usec=5000,
mode=2, # GIL mode mode=2, # GIL mode
show_summary=False, show_summary=False,
@@ -2897,7 +2955,7 @@ main()
): ):
try: try:
profiling.sampling.sample.sample( profiling.sampling.sample.sample(
proc.pid, subproc.process.pid,
duration_sec=0.5, duration_sec=0.5,
sample_interval_usec=5000, sample_interval_usec=5000,
mode=0, # Wall-clock mode mode=0, # Wall-clock mode