Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Lib/profiling/sampling/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions Lib/pstats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
118 changes: 88 additions & 30 deletions Lib/test/test_profiling/test_sampling_profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -1782,15 +1785,15 @@ 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,
mock.patch("sys.stdout", captured_output),
):
try:
profiling.sampling.sample.sample(
proc.pid,
subproc.process.pid,
duration_sec=1,
filename=pstats_out.name,
sample_interval_usec=10000,
Expand Down Expand Up @@ -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 (
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -2037,20 +2040,20 @@ 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"
)
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()
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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)."""
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading