Files
cleveragents-core/features/steps/audit_session_race_steps.py
T
HAL9000 e6878c2c30
CI / coverage (push) Blocked by required conditions
CI / docker (push) Blocked by required conditions
CI / push-validation (push) Waiting to run
CI / status-check (push) Blocked by required conditions
CI / benchmark-publish (push) Waiting to run
CI / lint (push) Successful in 45s
CI / typecheck (push) Successful in 1m11s
CI / unit_tests (push) Has started running
CI / integration_tests (push) Has started running
CI / quality (push) Successful in 1m5s
CI / security (push) Successful in 1m12s
CI / e2e_tests (push) Has started running
CI / build (push) Has started running
CI / helm (push) Has started running
fix(audit): protect AuditService._ensure_session() with threading.Lock
2026-05-02 21:54:17 +00:00

155 lines
6.0 KiB
Python

"""Step definitions for bug #991 - AuditService._ensure_session() TOCTOU race.
This module provides the Behave step implementations for the TDD bug-capture
test that proves bug #991 is fixed. The bug was a Time-Of-Check-to-Time-Of-Use
(TOCTOU) race in AuditService._ensure_session(): the method checked
if self._session is None and then created an engine, ran create_all,
built a sessionmaker, and assigned self._session - all without holding
a threading.Lock. Concurrent callers could each see _session is None
and create duplicate engines, leaking all but the last.
The fix adds a threading.Lock with double-checked locking so only the
first thread to acquire the lock performs session initialisation.
Originally tagged @tdd_expected_fail so the expected assertion failure
(proving the bug exists) would pass CI. After the fix for #991 was
implemented, @tdd_expected_fail was removed and the test now runs
normally - verifying that create_engine is called exactly once even
under concurrent access.
See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/991
"""
from __future__ import annotations
import contextlib
import os
import tempfile
import threading
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine as _real_create_engine
from sqlalchemy.engine import Engine
from cleveragents.application.services.audit_service import AuditService
from cleveragents.config.settings import Settings
def _make_settings(**overrides: object) -> Settings:
"""Create a Settings instance with optional field overrides.
Resets the Settings singleton so a fresh instance is constructed.
This follows the established project pattern used in environment.py
and dozens of other step files.
"""
Settings._instance = None
base = Settings()
if overrides:
return base.model_copy(update=overrides)
return base
@given("an AuditService with no pre-injected session and a temp database")
def step_audit_service_no_session(context: Context) -> None:
"""Create an AuditService that will use _ensure_session() for lazy init."""
fd, db_path = tempfile.mkstemp(suffix=".db", prefix="test_race_991_")
os.close(fd)
os.unlink(db_path)
context.db_path = db_path
db_url = f"sqlite:///{db_path}"
settings = _make_settings(database_url=db_url, audit_async=False)
context.service = AuditService(settings=settings, database_url=db_url)
context.thread_sessions = []
context.thread_errors = []
context.engine_create_count = 0
def _cleanup_db(path: str = db_path) -> None:
svc: AuditService = context.service
if svc._session is not None:
bind = svc._session.bind
svc._session.close()
if isinstance(bind, Engine):
bind.dispose()
for suffix in ("", "-journal", "-wal", "-shm"):
with contextlib.suppress(OSError):
os.unlink(path + suffix)
context._cleanup_handlers.append(_cleanup_db)
@when("{n:d} threads call _ensure_session concurrently through a barrier")
def step_concurrent_ensure_session(context: Context, n: int) -> None:
"""Launch n threads that all call _ensure_session() simultaneously."""
barrier = threading.Barrier(n)
sessions: list[object] = []
errors: list[Exception] = []
collect_lock = threading.Lock()
engine_mock = MagicMock(side_effect=_real_create_engine)
def worker() -> None:
"""Thread worker: wait on barrier, then call _ensure_session()."""
try:
barrier.wait(timeout=10)
session = context.service._ensure_session()
with collect_lock:
sessions.append(session)
except Exception as exc:
with collect_lock:
errors.append(exc)
with patch(
"cleveragents.application.services.audit_service.create_engine",
engine_mock,
):
threads = [threading.Thread(target=worker, daemon=True) for _ in range(n)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
for t in threads:
assert not t.is_alive(), (
f"Thread {t.name} is still alive after join timeout - "
f"possible deadlock in _ensure_session()"
)
barrier_errors = [
err for err in errors if isinstance(err, threading.BrokenBarrierError)
]
assert not barrier_errors, (
"Barrier synchronization failed while coordinating concurrent "
"_ensure_session() calls. This invalidates race setup. "
f"Barrier errors: {barrier_errors}; all errors: {errors}"
)
context.engine_create_count = len(engine_mock.call_args_list)
context.thread_sessions = sessions
context.thread_errors = errors
context.thread_count = n
@then("create_engine should have been called exactly once")
def step_create_engine_called_once(context: Context) -> None:
"""Assert create_engine was called exactly once."""
count = context.engine_create_count
assert count == 1, (
f"create_engine was called {count} time(s), expected exactly 1. "
f"This indicates the TOCTOU race in _ensure_session() is not fixed - "
f"multiple threads saw _session as None because the threading.Lock is "
f"missing or not effective. "
f"Thread errors (if any): {context.thread_errors}"
)
@then("all {n:d} threads should have received a valid session")
def step_all_threads_got_session(context: Context, n: int) -> None:
"""Verify that all n threads completed - either with a session or error."""
total = len(context.thread_sessions) + len(context.thread_errors)
assert total == n, (
f"Expected {n} threads to complete but only {total} did "
f"({len(context.thread_sessions)} succeeded, "
f"{len(context.thread_errors)} errored). "
"Each worker should report exactly one outcome (session or error). "
"A mismatch indicates a hung thread or missing outcome capture."
)