cpython/Lib/test/test_profiling/test_sampling_profiler/test_modes.py
Pablo Galindo Salgado ef51a7c8f3
gh-138122: Make sampling profiler integration tests more resilient (#142382)
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.
2025-12-07 22:41:15 +00:00

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