diff --git a/aiocop/core/audit_patcher.py b/aiocop/core/audit_patcher.py index fec7d19..d603e09 100644 --- a/aiocop/core/audit_patcher.py +++ b/aiocop/core/audit_patcher.py @@ -11,7 +11,6 @@ logger = logging.getLogger(__name__) FUNCTIONS_TO_PATCH_DICT: dict[str, int] = { - "time.sleep": WEIGHT_HEAVY, # --- Path & Metadata (Fast / Cached) --- "os.getcwd": WEIGHT_TRIVIAL, "os.path.abspath": WEIGHT_TRIVIAL, @@ -47,6 +46,13 @@ "ssl.SSLSocket.recv": WEIGHT_MODERATE, } +if sys.version_info < (3, 13): + # time.sleep only gained its own native sys.audit event in Python 3.13 + # (https://docs.python.org/3/library/time.html#time.sleep). On older + # versions there is no native event, so it still needs to be patched here; + # on 3.13+ it's recognized directly via BLOCKING_EVENTS_DICT instead. + FUNCTIONS_TO_PATCH_DICT["time.sleep"] = WEIGHT_HEAVY + FUNCTIONS_TO_PATCH = list(FUNCTIONS_TO_PATCH_DICT.keys()) patched_functions: list[str] = [] @@ -77,7 +83,8 @@ def patch_audit_functions() -> None: Patch Python stdlib functions to emit audit events for blocking IO detection. This patches functions that don't have native audit events (like socket operations, - time.sleep, etc.) to emit custom audit events that can be captured by the audit hook. + or time.sleep on Python < 3.13) to emit custom audit events that can be captured by + the audit hook. Should be called early in application startup, before start_blocking_io_detection(). """ diff --git a/aiocop/core/blocking_io.py b/aiocop/core/blocking_io.py index 8791850..612721f 100644 --- a/aiocop/core/blocking_io.py +++ b/aiocop/core/blocking_io.py @@ -19,6 +19,8 @@ MAX_EVENTS_PER_TASK = 50 BLOCKING_EVENTS_DICT: dict[str, int] = { + # --- Sleep --- + "time.sleep": WEIGHT_HEAVY, # --- Network Operations (Socket Level) --- "socket.getaddrinfo": WEIGHT_HEAVY, "socket.getnameinfo": WEIGHT_HEAVY, diff --git a/docs/api.md b/docs/api.md index 103407d..cb56042 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,7 +14,7 @@ Patches Python stdlib functions to emit audit events for blocking I/O detection. **Must be called first**, before `start_blocking_io_detection()`. -Functions patched include `time.sleep`, socket operations, SSL operations, and various `os` functions that don't emit native audit events. +Functions patched include socket operations, SSL operations, and various `os` functions that don't emit native audit events. Also includes `time.sleep` on Python < 3.13, which gained its own native audit event in 3.13. --- diff --git a/docs/guide.md b/docs/guide.md index fa2aa5f..9d6ffff 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -19,7 +19,7 @@ This guide covers all aiocop features in detail. aiocop uses three mechanisms to detect blocking I/O: -1. **Audit Hook Patching** (`patch_audit_functions`): Wraps stdlib functions that don't emit native audit events (like `time.sleep`, socket operations) to emit custom audit events. +1. **Audit Hook Patching** (`patch_audit_functions`): Wraps stdlib functions that don't emit native audit events (like socket operations, or `time.sleep` on Python < 3.13) to emit custom audit events. 2. **Audit Hook Registration** (`start_blocking_io_detection`): Registers a `sys.audit` hook that listens for blocking I/O events and captures stack traces. @@ -51,7 +51,7 @@ print(f"Patched {len(patched)} functions: {patched[:5]}...") ``` Functions patched include: -- `time.sleep` +- `time.sleep` (Python < 3.13 only - it gained its own native audit event in 3.13) - `socket.socket.connect`, `send`, `recv`, etc. - `ssl.SSLSocket.read`, `write`, etc. - `os.stat`, `os.access`, etc. diff --git a/tests/test_aiocop.py b/tests/test_aiocop.py index 759f9cf..df41f20 100644 --- a/tests/test_aiocop.py +++ b/tests/test_aiocop.py @@ -1,6 +1,7 @@ """Tests for aiocop package.""" import asyncio +import sys import tempfile import time from pathlib import Path @@ -358,6 +359,46 @@ async def task_with_heavy_io(): assert event.severity_level == "high" +# ============================================================================= +# time.sleep Event Count Tests +# ============================================================================= + + +class TestTimeSleepEventCount: + """Regression tests: time.sleep gained its own native sys.audit event in + Python 3.13 (https://docs.python.org/3/library/time.html#time.sleep). + aiocop's own wrapper must not also emit one on 3.13+, or a single real + call gets double-counted. + """ + + def test_time_sleep_patched_only_below_py313(self) -> None: + from aiocop.core.audit_patcher import FUNCTIONS_TO_PATCH_DICT + + assert ("time.sleep" in FUNCTIONS_TO_PATCH_DICT) == (sys.version_info < (3, 13)) + + def test_time_sleep_always_recognized_regardless_of_version(self) -> None: + assert "time.sleep" in get_blocking_events_dict() + + @pytest.mark.asyncio + async def test_single_time_sleep_call_is_not_double_counted(self, setup_aiocop, captured_events) -> None: + aiocop.activate() + + async def task_with_sleep(): + time.sleep(0.02) + + task = asyncio.create_task(task_with_sleep()) + await task + await asyncio.sleep(0) + + assert len(captured_events) == 1 + event = captured_events[0] + sleep_events = [e for e in event.blocking_events if "time.sleep" in e["event"]] + assert len(sleep_events) == 1, ( + f"expected exactly 1 time.sleep event, got {len(sleep_events)} - " + f"this Python version is {sys.version_info[:2]}" + ) + + # ============================================================================= # Context Provider Tests # ============================================================================= @@ -794,8 +835,12 @@ def test_get_patched_functions_returns_list(self, setup_aiocop) -> None: patched = aiocop.get_patched_functions() assert isinstance(patched, list) assert len(patched) > 0 - # Should include time.sleep - assert "time.sleep" in patched + # time.sleep is only patched below 3.13 - it gained its own native + # audit event in 3.13 (see TestTimeSleepEventCount). + assert ("time.sleep" in patched) == (sys.version_info < (3, 13)) + # Control: only time.sleep is version-gated - everything else is + # patched unconditionally on every version. + assert "os.getcwd" in patched def test_get_blocking_events_dict(self) -> None: """Test that get_blocking_events_dict returns event weights."""