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: 9 additions & 2 deletions aiocop/core/audit_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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().
"""
Expand Down
2 changes: 2 additions & 0 deletions aiocop/core/blocking_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
4 changes: 2 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
49 changes: 47 additions & 2 deletions tests/test_aiocop.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for aiocop package."""

import asyncio
import sys
import tempfile
import time
from pathlib import Path
Expand Down Expand Up @@ -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
# =============================================================================
Expand Down Expand Up @@ -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."""
Expand Down
Loading