From daa7fb7b4311d11750f9af74c5dd6a72a9be201e Mon Sep 17 00:00:00 2001 From: Hadi Zamani Date: Mon, 13 Jul 2026 13:43:30 +0330 Subject: [PATCH 1/2] Add idempotency guards to patch_audit_functions() and start_blocking_io_detection() Calling either twice in the same process silently double-wraps stdlib functions and/or double-registers the sys.audit hook, permanently double-counting every blocking event with no warning - e.g. when two independent conftest.py fixtures or an app factory both run setup. Mirrors the guard detect_slow_tasks() already had. Adds test coverage for all three setup functions (detect_slow_tasks() had none either), and resets the new flags in test_uvloop.py's fixture so its per-test setup stays isolated. --- aiocop/core/audit_patcher.py | 10 +++++++++ aiocop/core/blocking_io.py | 9 +++++++- tests/test_aiocop.py | 43 ++++++++++++++++++++++++++++++++++++ tests/test_uvloop.py | 6 ++++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/aiocop/core/audit_patcher.py b/aiocop/core/audit_patcher.py index fec7d19..a4f674c 100644 --- a/aiocop/core/audit_patcher.py +++ b/aiocop/core/audit_patcher.py @@ -51,6 +51,8 @@ patched_functions: list[str] = [] +_patch_audit_functions_configured = False + def _get_target(path: str) -> tuple[Any | None, str | None]: """ @@ -81,6 +83,12 @@ def patch_audit_functions() -> None: Should be called early in application startup, before start_blocking_io_detection(). """ + global _patch_audit_functions_configured + + if _patch_audit_functions_configured is True: + logger.warning("patch_audit_functions called more than once, ignoring") + return + patched_functions.clear() for func_path in FUNCTIONS_TO_PATCH: @@ -118,6 +126,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: logger.info("Patched functions for audit: %s", patched_functions) + _patch_audit_functions_configured = True + def get_patched_functions() -> list[str]: """Return a copy of the list of functions that have been patched.""" diff --git a/aiocop/core/blocking_io.py b/aiocop/core/blocking_io.py index 8791850..a612a7a 100644 --- a/aiocop/core/blocking_io.py +++ b/aiocop/core/blocking_io.py @@ -15,6 +15,7 @@ _main_thread_id: int | None = None _trace_depth: int = 20 +_start_blocking_io_detection_configured = False MAX_EVENTS_PER_TASK = 50 @@ -92,13 +93,19 @@ def start_blocking_io_detection(trace_depth: int = 20) -> None: Should be called after patch_audit_functions() and before detect_slow_tasks(). """ - global _main_thread_id, _trace_depth + global _main_thread_id, _trace_depth, _start_blocking_io_detection_configured + + if _start_blocking_io_detection_configured is True: + logger.warning("start_blocking_io_detection called more than once, ignoring") + return + _main_thread_id = threading.main_thread().ident _trace_depth = trace_depth try: sys.addaudithook(_audit_hook) logger.info("Blocking I/O detection hook registered via sys.audit") + _start_blocking_io_detection_configured = True except Exception as e: logger.error("Failed to register blocking I/O detection hook: %s", e) diff --git a/tests/test_aiocop.py b/tests/test_aiocop.py index 759f9cf..d72af7c 100644 --- a/tests/test_aiocop.py +++ b/tests/test_aiocop.py @@ -842,6 +842,49 @@ def test_severity_constants_exported(self) -> None: assert aiocop.THRESHOLD_LOW == 1 +# ============================================================================= +# Setup Idempotency Tests +# ============================================================================= + + +class TestSetupIdempotency: + """Test that setup functions are safe to call more than once.""" + + def test_patch_audit_functions_twice_is_noop(self, setup_aiocop, caplog) -> None: + import os + + # Checking that os.getcwd is not wrapped again by the second call (object identity). + before_func = os.getcwd + + with caplog.at_level("WARNING"): + aiocop.patch_audit_functions() + + assert os.getcwd is before_func + assert "patch_audit_functions called more than once" in caplog.text + + def test_start_blocking_io_detection_twice_is_noop(self, setup_aiocop, caplog) -> None: + from aiocop.core import blocking_io + + # Checking that trace_depth is not overwritten by the second call. + before_trace_depth = blocking_io._trace_depth + + with caplog.at_level("WARNING"): + aiocop.start_blocking_io_detection(trace_depth=before_trace_depth + 999) + + assert blocking_io._trace_depth == before_trace_depth + assert "start_blocking_io_detection called more than once" in caplog.text + + def test_detect_slow_tasks_twice_is_noop(self, setup_aiocop, caplog) -> None: + # Checking that the threshold is not overwritten by the second call. + before_threshold = aiocop.get_slow_task_threshold_ms() + + with caplog.at_level("WARNING"): + aiocop.detect_slow_tasks(threshold_ms=before_threshold + 999) + + assert aiocop.get_slow_task_threshold_ms() == before_threshold + assert "detect_slow_tasks called more than once" in caplog.text + + # ============================================================================= # Raise on Violations Integration Tests # ============================================================================= diff --git a/tests/test_uvloop.py b/tests/test_uvloop.py index 0b6ba33..047f46e 100644 --- a/tests/test_uvloop.py +++ b/tests/test_uvloop.py @@ -23,7 +23,7 @@ pytest.skip("uvloop not supported on Windows", allow_module_level=True) import aiocop # noqa: E402 -from aiocop.core import slow_tasks, state # noqa: E402 +from aiocop.core import audit_patcher, blocking_io, slow_tasks, state # noqa: E402 from aiocop.types.events import SlowTaskEvent # noqa: E402 # ============================================================================= @@ -42,6 +42,8 @@ def reset_aiocop_state(): state._monitoring_active = False state._on_activate_hooks.clear() slow_tasks._detect_slow_tasks_configured = False + audit_patcher._patch_audit_functions_configured = False + blocking_io._start_blocking_io_detection_configured = False aiocop.clear_slow_task_callbacks() aiocop.clear_context_providers() @@ -51,6 +53,8 @@ def reset_aiocop_state(): state._monitoring_active = False state._on_activate_hooks.clear() slow_tasks._detect_slow_tasks_configured = False + audit_patcher._patch_audit_functions_configured = False + blocking_io._start_blocking_io_detection_configured = False aiocop.clear_slow_task_callbacks() aiocop.clear_context_providers() From 3af6415f01f754c853483b68ed61e38eba45d5b4 Mon Sep 17 00:00:00 2001 From: Hadi Zamani Date: Mon, 13 Jul 2026 16:08:18 +0330 Subject: [PATCH 2/2] Fix test_uvloop.py fixture resetting flags that shouldn't be reset --- tests/test_uvloop.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_uvloop.py b/tests/test_uvloop.py index 047f46e..8946ab5 100644 --- a/tests/test_uvloop.py +++ b/tests/test_uvloop.py @@ -23,7 +23,7 @@ pytest.skip("uvloop not supported on Windows", allow_module_level=True) import aiocop # noqa: E402 -from aiocop.core import audit_patcher, blocking_io, slow_tasks, state # noqa: E402 +from aiocop.core import slow_tasks, state # noqa: E402 from aiocop.types.events import SlowTaskEvent # noqa: E402 # ============================================================================= @@ -37,13 +37,17 @@ def reset_aiocop_state(): This is critical for uvloop tests since we need fresh state for each test to properly test loop patching. + + Note: _detect_slow_tasks_configured is reset since it gates a fresh event + loop object created every test. _patch_audit_functions_configured and + _start_blocking_io_detection_configured are NOT reset - they guard + permanent, process-wide state with no teardown, so resetting them would + re-wrap functions and re-register the audit hook each test. """ # Reset before test state._monitoring_active = False state._on_activate_hooks.clear() slow_tasks._detect_slow_tasks_configured = False - audit_patcher._patch_audit_functions_configured = False - blocking_io._start_blocking_io_detection_configured = False aiocop.clear_slow_task_callbacks() aiocop.clear_context_providers() @@ -53,8 +57,6 @@ def reset_aiocop_state(): state._monitoring_active = False state._on_activate_hooks.clear() slow_tasks._detect_slow_tasks_configured = False - audit_patcher._patch_audit_functions_configured = False - blocking_io._start_blocking_io_detection_configured = False aiocop.clear_slow_task_callbacks() aiocop.clear_context_providers()