mirror of
https://github.com/python/cpython.git
synced 2025-12-08 06:10:17 +00:00
The tests were flaky on slow machines because subprocesses could finish before enough samples were collected. This adds synchronization similar to test_external_inspection: test scripts now signal when they start working, and the profiler waits for this signal before sampling. Test scripts now run in infinite loops until killed rather than for fixed iterations, ensuring the profiler always has active work to sample regardless of machine speed.
433 lines
15 KiB
Python
433 lines
15 KiB
Python
"""Tests for sampling profiler mode filtering (CPU and GIL modes)."""
|
|
|
|
import io
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
try:
|
|
import _remote_debugging # noqa: F401
|
|
import profiling.sampling
|
|
import profiling.sampling.sample
|
|
from profiling.sampling.pstats_collector import PstatsCollector
|
|
except ImportError:
|
|
raise unittest.SkipTest(
|
|
"Test only runs when _remote_debugging is available"
|
|
)
|
|
|
|
from test.support import requires_subprocess
|
|
|
|
from .helpers import test_subprocess
|
|
from .mocks import MockFrameInfo, MockInterpreterInfo
|
|
|
|
|
|
class TestCpuModeFiltering(unittest.TestCase):
|
|
"""Test CPU mode filtering functionality (--mode=cpu)."""
|
|
|
|
def test_mode_validation(self):
|
|
"""Test that CLI validates mode choices correctly."""
|
|
# Invalid mode choice should raise SystemExit
|
|
test_args = [
|
|
"profiling.sampling.cli",
|
|
"attach",
|
|
"12345",
|
|
"--mode",
|
|
"invalid",
|
|
]
|
|
|
|
with (
|
|
mock.patch("sys.argv", test_args),
|
|
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
|
self.assertRaises(SystemExit) as cm,
|
|
):
|
|
from profiling.sampling.cli import main
|
|
main()
|
|
|
|
self.assertEqual(cm.exception.code, 2) # argparse error
|
|
error_msg = mock_stderr.getvalue()
|
|
self.assertIn("invalid choice", error_msg)
|
|
|
|
def test_frames_filtered_with_skip_idle(self):
|
|
"""Test that frames are actually filtered when skip_idle=True."""
|
|
# Import thread status flags
|
|
try:
|
|
from _remote_debugging import (
|
|
THREAD_STATUS_HAS_GIL,
|
|
THREAD_STATUS_ON_CPU,
|
|
)
|
|
except ImportError:
|
|
THREAD_STATUS_HAS_GIL = 1 << 0
|
|
THREAD_STATUS_ON_CPU = 1 << 1
|
|
|
|
# Create mock frames with different thread statuses
|
|
class MockThreadInfoWithStatus:
|
|
def __init__(self, thread_id, frame_info, status):
|
|
self.thread_id = thread_id
|
|
self.frame_info = frame_info
|
|
self.status = status
|
|
|
|
# Create test data: active thread (HAS_GIL | ON_CPU), idle thread (neither), and another active thread
|
|
ACTIVE_STATUS = (
|
|
THREAD_STATUS_HAS_GIL | THREAD_STATUS_ON_CPU
|
|
) # Has GIL and on CPU
|
|
IDLE_STATUS = 0 # Neither has GIL nor on CPU
|
|
|
|
test_frames = [
|
|
MockInterpreterInfo(
|
|
0,
|
|
[
|
|
MockThreadInfoWithStatus(
|
|
1,
|
|
[MockFrameInfo("active1.py", 10, "active_func1")],
|
|
ACTIVE_STATUS,
|
|
),
|
|
MockThreadInfoWithStatus(
|
|
2,
|
|
[MockFrameInfo("idle.py", 20, "idle_func")],
|
|
IDLE_STATUS,
|
|
),
|
|
MockThreadInfoWithStatus(
|
|
3,
|
|
[MockFrameInfo("active2.py", 30, "active_func2")],
|
|
ACTIVE_STATUS,
|
|
),
|
|
],
|
|
)
|
|
]
|
|
|
|
# Test with skip_idle=True - should only process running threads
|
|
collector_skip = PstatsCollector(
|
|
sample_interval_usec=1000, skip_idle=True
|
|
)
|
|
collector_skip.collect(test_frames)
|
|
|
|
# Should only have functions from running threads (status 0)
|
|
active1_key = ("active1.py", 10, "active_func1")
|
|
active2_key = ("active2.py", 30, "active_func2")
|
|
idle_key = ("idle.py", 20, "idle_func")
|
|
|
|
self.assertIn(active1_key, collector_skip.result)
|
|
self.assertIn(active2_key, collector_skip.result)
|
|
self.assertNotIn(
|
|
idle_key, collector_skip.result
|
|
) # Idle thread should be filtered out
|
|
|
|
# Test with skip_idle=False - should process all threads
|
|
collector_no_skip = PstatsCollector(
|
|
sample_interval_usec=1000, skip_idle=False
|
|
)
|
|
collector_no_skip.collect(test_frames)
|
|
|
|
# Should have functions from all threads
|
|
self.assertIn(active1_key, collector_no_skip.result)
|
|
self.assertIn(active2_key, collector_no_skip.result)
|
|
self.assertIn(
|
|
idle_key, collector_no_skip.result
|
|
) # Idle thread should be included
|
|
|
|
@requires_subprocess()
|
|
def test_cpu_mode_integration_filtering(self):
|
|
"""Integration test: CPU mode should only capture active threads, not idle ones."""
|
|
# Script with one mostly-idle thread and one CPU-active thread
|
|
cpu_vs_idle_script = """
|
|
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
|
|
|
|
idle_thread = threading.Thread(target=idle_worker)
|
|
cpu_thread = threading.Thread(target=cpu_active_worker)
|
|
idle_thread.start()
|
|
cpu_thread.start()
|
|
cpu_ready.wait()
|
|
_test_sock.sendall(b"working")
|
|
idle_thread.join()
|
|
cpu_thread.join()
|
|
"""
|
|
with test_subprocess(cpu_vs_idle_script, wait_for_working=True) as subproc:
|
|
|
|
with (
|
|
io.StringIO() as captured_output,
|
|
mock.patch("sys.stdout", captured_output),
|
|
):
|
|
try:
|
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=True)
|
|
profiling.sampling.sample.sample(
|
|
subproc.process.pid,
|
|
collector,
|
|
duration_sec=2.0,
|
|
mode=1, # CPU mode
|
|
all_threads=True,
|
|
)
|
|
collector.print_stats(show_summary=False, mode=1)
|
|
except (PermissionError, RuntimeError) as e:
|
|
self.skipTest(
|
|
"Insufficient permissions for remote profiling"
|
|
)
|
|
|
|
cpu_mode_output = captured_output.getvalue()
|
|
|
|
# Test wall-clock mode (mode=0) - should capture both functions
|
|
with (
|
|
io.StringIO() as captured_output,
|
|
mock.patch("sys.stdout", captured_output),
|
|
):
|
|
try:
|
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=False)
|
|
profiling.sampling.sample.sample(
|
|
subproc.process.pid,
|
|
collector,
|
|
duration_sec=2.0,
|
|
mode=0, # Wall-clock mode
|
|
all_threads=True,
|
|
)
|
|
collector.print_stats(show_summary=False)
|
|
except (PermissionError, RuntimeError) as e:
|
|
self.skipTest(
|
|
"Insufficient permissions for remote profiling"
|
|
)
|
|
|
|
wall_mode_output = captured_output.getvalue()
|
|
|
|
# Verify both modes captured samples
|
|
self.assertIn("Captured", cpu_mode_output)
|
|
self.assertIn("samples", cpu_mode_output)
|
|
self.assertIn("Captured", wall_mode_output)
|
|
self.assertIn("samples", wall_mode_output)
|
|
|
|
# CPU mode should strongly favor cpu_active_worker over mostly_idle_worker
|
|
self.assertIn("cpu_active_worker", cpu_mode_output)
|
|
self.assertNotIn("idle_worker", cpu_mode_output)
|
|
|
|
# Wall-clock mode should capture both types of work
|
|
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 = PstatsCollector(sample_interval_usec=5000, skip_idle=True)
|
|
mock_collector.stats = {}
|
|
|
|
with (
|
|
io.StringIO() as captured_output,
|
|
mock.patch("sys.stdout", captured_output),
|
|
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
|
|
mock_collector,
|
|
duration_sec=0.5,
|
|
mode=1, # CPU mode
|
|
all_threads=True,
|
|
)
|
|
|
|
mock_collector.print_stats(show_summary=False, mode=1)
|
|
|
|
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)."""
|
|
|
|
def test_gil_mode_validation(self):
|
|
"""Test that CLI accepts gil mode choice correctly."""
|
|
from profiling.sampling.cli import main
|
|
|
|
test_args = [
|
|
"profiling.sampling.cli",
|
|
"attach",
|
|
"12345",
|
|
"--mode",
|
|
"gil",
|
|
]
|
|
|
|
with (
|
|
mock.patch("sys.argv", test_args),
|
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
|
):
|
|
try:
|
|
main()
|
|
except (SystemExit, OSError, RuntimeError):
|
|
pass # Expected due to invalid PID
|
|
|
|
# Should have attempted to call sample with mode=2 (GIL mode)
|
|
mock_sample.assert_called_once()
|
|
call_args = mock_sample.call_args
|
|
# Check the mode parameter (should be in kwargs)
|
|
self.assertEqual(call_args.kwargs.get("mode"), 2) # PROFILING_MODE_GIL
|
|
|
|
def test_gil_mode_sample_function_call(self):
|
|
"""Test that sample() function correctly uses GIL mode."""
|
|
with (
|
|
mock.patch(
|
|
"profiling.sampling.sample.SampleProfiler"
|
|
) as mock_profiler,
|
|
):
|
|
# Mock the profiler instance
|
|
mock_instance = mock.Mock()
|
|
mock_profiler.return_value = mock_instance
|
|
|
|
# Create a real collector instance
|
|
collector = PstatsCollector(sample_interval_usec=1000, skip_idle=True)
|
|
|
|
# Call sample with GIL mode
|
|
profiling.sampling.sample.sample(
|
|
12345,
|
|
collector,
|
|
mode=2, # PROFILING_MODE_GIL
|
|
duration_sec=1,
|
|
)
|
|
|
|
# Verify SampleProfiler was created with correct mode
|
|
mock_profiler.assert_called_once()
|
|
call_args = mock_profiler.call_args
|
|
self.assertEqual(call_args[1]["mode"], 2) # mode parameter
|
|
|
|
# Verify profiler.sample was called
|
|
mock_instance.sample.assert_called_once()
|
|
|
|
def test_gil_mode_cli_argument_parsing(self):
|
|
"""Test CLI argument parsing for GIL mode with various options."""
|
|
from profiling.sampling.cli import main
|
|
|
|
test_args = [
|
|
"profiling.sampling.cli",
|
|
"attach",
|
|
"12345",
|
|
"--mode",
|
|
"gil",
|
|
"-i",
|
|
"500",
|
|
"-d",
|
|
"5",
|
|
]
|
|
|
|
with (
|
|
mock.patch("sys.argv", test_args),
|
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
|
):
|
|
try:
|
|
main()
|
|
except (SystemExit, OSError, RuntimeError):
|
|
pass # Expected due to invalid PID
|
|
|
|
# Verify all arguments were parsed correctly
|
|
mock_sample.assert_called_once()
|
|
call_args = mock_sample.call_args
|
|
self.assertEqual(call_args.kwargs.get("mode"), 2) # GIL mode
|
|
self.assertEqual(call_args.kwargs.get("duration_sec"), 5)
|
|
|
|
@requires_subprocess()
|
|
def test_gil_mode_integration_behavior(self):
|
|
"""Integration test: GIL mode should capture GIL-holding threads."""
|
|
# Create a test script with GIL-releasing operations
|
|
gil_test_script = """
|
|
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
|
|
|
|
idle_thread = threading.Thread(target=gil_releasing_work)
|
|
cpu_thread = threading.Thread(target=gil_holding_work)
|
|
idle_thread.start()
|
|
cpu_thread.start()
|
|
gil_ready.wait()
|
|
_test_sock.sendall(b"working")
|
|
idle_thread.join()
|
|
cpu_thread.join()
|
|
"""
|
|
with test_subprocess(gil_test_script, wait_for_working=True) as subproc:
|
|
|
|
with (
|
|
io.StringIO() as captured_output,
|
|
mock.patch("sys.stdout", captured_output),
|
|
):
|
|
try:
|
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=True)
|
|
profiling.sampling.sample.sample(
|
|
subproc.process.pid,
|
|
collector,
|
|
duration_sec=2.0,
|
|
mode=2, # GIL mode
|
|
all_threads=True,
|
|
)
|
|
collector.print_stats(show_summary=False)
|
|
except (PermissionError, RuntimeError) as e:
|
|
self.skipTest(
|
|
"Insufficient permissions for remote profiling"
|
|
)
|
|
|
|
gil_mode_output = captured_output.getvalue()
|
|
|
|
# Test wall-clock mode for comparison
|
|
with (
|
|
io.StringIO() as captured_output,
|
|
mock.patch("sys.stdout", captured_output),
|
|
):
|
|
try:
|
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=False)
|
|
profiling.sampling.sample.sample(
|
|
subproc.process.pid,
|
|
collector,
|
|
duration_sec=0.5,
|
|
mode=0, # Wall-clock mode
|
|
all_threads=True,
|
|
)
|
|
collector.print_stats(show_summary=False)
|
|
except (PermissionError, RuntimeError) as e:
|
|
self.skipTest(
|
|
"Insufficient permissions for remote profiling"
|
|
)
|
|
|
|
wall_mode_output = captured_output.getvalue()
|
|
|
|
# GIL mode should primarily capture GIL-holding work
|
|
# (Note: actual behavior depends on threading implementation)
|
|
self.assertIn("gil_holding_work", gil_mode_output)
|
|
|
|
# Wall-clock mode should capture both types of work
|
|
self.assertIn("gil_holding_work", wall_mode_output)
|
|
|
|
def test_mode_constants_are_defined(self):
|
|
"""Test that all profiling mode constants are properly defined."""
|
|
self.assertEqual(profiling.sampling.sample.PROFILING_MODE_WALL, 0)
|
|
self.assertEqual(profiling.sampling.sample.PROFILING_MODE_CPU, 1)
|
|
self.assertEqual(profiling.sampling.sample.PROFILING_MODE_GIL, 2)
|
|
|
|
def test_parse_mode_function(self):
|
|
"""Test the _parse_mode function with all valid modes."""
|
|
from profiling.sampling.cli import _parse_mode
|
|
self.assertEqual(_parse_mode("wall"), 0)
|
|
self.assertEqual(_parse_mode("cpu"), 1)
|
|
self.assertEqual(_parse_mode("gil"), 2)
|
|
|
|
# Test invalid mode raises KeyError
|
|
with self.assertRaises(KeyError):
|
|
_parse_mode("invalid")
|