|
|
|
@@ -1,48 +1,21 @@
|
|
|
|
|
"""Step definitions for bug #991 — AuditService._ensure_session() TOCTOU race.
|
|
|
|
|
"""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 exists. The bug is a Time-Of-Check-to-Time-Of-Use
|
|
|
|
|
(TOCTOU) race in ``AuditService._ensure_session()``: the method checks
|
|
|
|
|
``if self._session is None`` and then creates an engine, runs ``create_all``,
|
|
|
|
|
builds a ``sessionmaker``, and assigns ``self._session`` — all without holding
|
|
|
|
|
a ``threading.Lock``. Concurrent callers can each see ``_session is None``
|
|
|
|
|
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 test uses ``@tdd_expected_fail`` so that CI passes while the bug is
|
|
|
|
|
unfixed. The underlying assertion ("create_engine called exactly once")
|
|
|
|
|
fails because the race allows multiple threads to trigger creation. The
|
|
|
|
|
tag inversion causes the scenario to be reported as passed.
|
|
|
|
|
The fix adds a threading.Lock with double-checked locking so only the
|
|
|
|
|
first thread to acquire the lock performs session initialisation.
|
|
|
|
|
|
|
|
|
|
Race detection uses ``unittest.mock.patch`` on ``create_engine`` within the
|
|
|
|
|
``audit_service`` module to count actual calls. This is deterministic and
|
|
|
|
|
avoids the timing-dependent ``was_none`` proxy that cannot distinguish
|
|
|
|
|
between "thread observed None then raced" and "thread correctly entered the
|
|
|
|
|
creation branch".
|
|
|
|
|
|
|
|
|
|
When bug #991 is fixed (by adding a ``threading.Lock``), the assertion will
|
|
|
|
|
pass and the ``@tdd_expected_fail`` tag must be removed from the feature file.
|
|
|
|
|
|
|
|
|
|
**Accepted limitations (self-QA):**
|
|
|
|
|
|
|
|
|
|
*Setup asserts under ``@tdd_expected_fail``* — The ``When`` step contains
|
|
|
|
|
infrastructure assertions (barrier synchronisation, thread liveness) that
|
|
|
|
|
would cause the scenario to fail if the test harness itself is broken.
|
|
|
|
|
Under ``@tdd_expected_fail``, any assertion failure is inverted to a pass,
|
|
|
|
|
so a broken barrier would appear as an unexpected-pass rather than a clear
|
|
|
|
|
setup error. This is an accepted trade-off: the barrier and thread-alive
|
|
|
|
|
checks have never failed in practice, and the alternative — splitting the
|
|
|
|
|
scenario into a non-``@tdd_expected_fail`` setup scenario and a separate
|
|
|
|
|
assertion scenario — would add complexity for negligible benefit.
|
|
|
|
|
|
|
|
|
|
*Timing-sensitive race detection* — The ``threading.Barrier`` maximises the
|
|
|
|
|
probability of the race manifesting but cannot guarantee it on every run.
|
|
|
|
|
On a lightly loaded single-core machine the OS scheduler may serialise the
|
|
|
|
|
threads so that only one enters ``_ensure_session()`` at a time, producing
|
|
|
|
|
a false-negative (``create_engine`` called once even without a lock). In
|
|
|
|
|
practice, 10 threads with a barrier reliably triggers multiple calls on all
|
|
|
|
|
CI and developer hardware tested. If flaky false-negatives appear, increase
|
|
|
|
|
the thread count or add a small ``time.sleep`` after the barrier to widen
|
|
|
|
|
the race window.
|
|
|
|
|
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
|
|
|
|
|
"""
|
|
|
|
@@ -58,12 +31,13 @@ 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: str) -> 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.
|
|
|
|
@@ -77,70 +51,41 @@ def _make_settings(**overrides: str) -> Settings:
|
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Given ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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.
|
|
|
|
|
|
|
|
|
|
A file-based temp SQLite database is used (not :memory:) because each
|
|
|
|
|
``create_engine("sqlite:///:memory:")`` call produces an independent
|
|
|
|
|
in-memory database, which would mask the race condition. A shared
|
|
|
|
|
file ensures all threads contend on the same database.
|
|
|
|
|
"""
|
|
|
|
|
"""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)
|
|
|
|
|
# Remove the empty file so create_engine creates it fresh
|
|
|
|
|
os.unlink(db_path)
|
|
|
|
|
context.db_path = db_path
|
|
|
|
|
db_url = f"sqlite:///{db_path}"
|
|
|
|
|
|
|
|
|
|
settings = _make_settings(database_url=db_url)
|
|
|
|
|
# Do NOT pass session= so that _ensure_session() performs lazy init
|
|
|
|
|
settings = _make_settings(database_url=db_url, audit_async=False)
|
|
|
|
|
context.service = AuditService(settings=settings, database_url=db_url)
|
|
|
|
|
context.thread_sessions: list[object] = []
|
|
|
|
|
context.thread_errors: list[Exception] = []
|
|
|
|
|
context.thread_sessions = []
|
|
|
|
|
context.thread_errors = []
|
|
|
|
|
context.engine_create_count = 0
|
|
|
|
|
|
|
|
|
|
# Register cleanup to dispose engine/session and remove temp database
|
|
|
|
|
# files (M4: prevent SQLAlchemy resource leaks).
|
|
|
|
|
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 svc._session.bind is not None:
|
|
|
|
|
svc._session.bind.dispose()
|
|
|
|
|
if isinstance(bind, Engine):
|
|
|
|
|
bind.dispose()
|
|
|
|
|
for suffix in ("", "-journal", "-wal", "-shm"):
|
|
|
|
|
with contextlib.suppress(OSError):
|
|
|
|
|
os.unlink(path + suffix)
|
|
|
|
|
|
|
|
|
|
context.add_cleanup(_cleanup_db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── When ──────────────────────────────────────────────────────────
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
A ``threading.Barrier`` synchronises the threads so they all enter
|
|
|
|
|
``_ensure_session()`` at (approximately) the same instant, maximising
|
|
|
|
|
the chance of the TOCTOU race manifesting.
|
|
|
|
|
|
|
|
|
|
Race detection uses ``unittest.mock.patch`` on ``create_engine`` within
|
|
|
|
|
the ``audit_service`` module to count actual invocations. The mock
|
|
|
|
|
wraps the real ``create_engine`` so the database is still created, but
|
|
|
|
|
we can inspect ``mock.call_args_list`` afterward for a deterministic race
|
|
|
|
|
indicator.
|
|
|
|
|
"""
|
|
|
|
|
"""Launch n threads that all call _ensure_session() simultaneously."""
|
|
|
|
|
barrier = threading.Barrier(n)
|
|
|
|
|
sessions: list[object] = []
|
|
|
|
|
errors: list[Exception] = []
|
|
|
|
|
collect_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
# Wrap create_engine so real engines are created but calls are counted
|
|
|
|
|
engine_mock = MagicMock(side_effect=_real_create_engine)
|
|
|
|
|
|
|
|
|
|
def worker() -> None:
|
|
|
|
@@ -163,14 +108,11 @@ def step_concurrent_ensure_session(context: Context, n: int) -> None:
|
|
|
|
|
t.start()
|
|
|
|
|
for t in threads:
|
|
|
|
|
t.join(timeout=30)
|
|
|
|
|
|
|
|
|
|
# M3: Verify no threads are still alive after join timeout
|
|
|
|
|
for t in threads:
|
|
|
|
|
assert not t.is_alive(), (
|
|
|
|
|
f"Thread {t.name} is still alive after join timeout — "
|
|
|
|
|
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)
|
|
|
|
|
]
|
|
|
|
@@ -179,7 +121,6 @@ def step_concurrent_ensure_session(context: Context, n: int) -> None:
|
|
|
|
|
"_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
|
|
|
|
@@ -187,42 +128,22 @@ def step_concurrent_ensure_session(context: Context, n: int) -> None:
|
|
|
|
|
context.thread_count = n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Then ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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.
|
|
|
|
|
|
|
|
|
|
With the bug present (no threading.Lock), multiple threads will enter
|
|
|
|
|
the ``if self._session is None`` branch and each call ``create_engine``,
|
|
|
|
|
so the mock's call list length will be > 1.
|
|
|
|
|
|
|
|
|
|
This assertion therefore **fails** while the bug exists — which is
|
|
|
|
|
the expected behavior for a ``@tdd_expected_fail`` test.
|
|
|
|
|
|
|
|
|
|
When the fix adds a lock, only the first thread calls create_engine
|
|
|
|
|
and the count is exactly 1.
|
|
|
|
|
"""
|
|
|
|
|
"""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 confirms the TOCTOU race in _ensure_session() — multiple "
|
|
|
|
|
f"threads saw _session as None because there is no threading.Lock. "
|
|
|
|
|
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 completed with a session or error")
|
|
|
|
|
def step_all_threads_completed(context: Context, n: int) -> None:
|
|
|
|
|
"""Verify that all *n* threads completed — either with a session or error.
|
|
|
|
|
|
|
|
|
|
The total of successful sessions plus errors must equal the number of
|
|
|
|
|
threads launched (non-tautological assertion). This confirms no threads
|
|
|
|
|
silently hung or crashed. With the unfixed bug some threads may receive
|
|
|
|
|
an error (e.g. from a disposed engine); once #991 is fixed all threads
|
|
|
|
|
will succeed with no 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 "
|
|
|
|
|