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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 97 additions and 33 deletions

View file

@ -642,6 +642,11 @@ def sample(
if output_format == "pstats" and not filename:
stats = pstats.SampledStats(collector).strip_dirs()
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
)

View file

@ -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))

View file

@ -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,10 +2647,13 @@ 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
@ -2658,21 +2664,30 @@ def main():
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,10 +2898,13 @@ 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
@ -2866,20 +2915,29 @@ def main():
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