"""Step definitions for TDD Issue #10409 — get_provider_registry() thread-safety race condition. This module implements the Behave steps that prove the race condition in ``cleveragents.providers.registry.get_provider_registry``. Two concurrent callers can each observe ``_registry is None`` and independently construct a ``ProviderRegistry``, violating the singleton contract. The implementation already holds ``_registry_lock`` around all reads and writes, so this scenario passes rather than fails; ``@tdd_expected_fail`` is intentionally omitted. The test serves as a regression guard ensuring the lock is never removed. """ from __future__ import annotations import contextlib import threading from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when # type: ignore[import-untyped] import cleveragents.providers.registry as _registry_module from cleveragents.providers.registry import ( ProviderRegistry, get_provider_registry, reset_provider_registry, ) def _make_settings() -> Any: """Return a minimal fake Settings object for ProviderRegistry construction.""" settings = MagicMock() settings.openai_api_key = None settings.anthropic_api_key = None settings.google_api_key = None settings.gemini_api_key = None settings.azure_api_key = None settings.openrouter_api_key = None settings.cohere_api_key = None settings.groq_api_key = None settings.together_api_key = None settings.default_provider = None settings.default_model = None settings.azure_openai_endpoint = None settings.azure_openai_api_version = None settings.azure_openai_deployment = None settings.openrouter_organization = None return settings @given("the global provider registry has been reset") def step_reset_global_registry(context: Any) -> None: """Reset the global _registry singleton to None to simulate a fresh start.""" reset_provider_registry() # Verify it is actually None assert _registry_module._registry is None, ( "Expected _registry to be None after reset" ) @when("two threads call get_provider_registry() simultaneously") def step_two_threads_call_get_provider_registry(context: Any) -> None: """Spawn two threads that call get_provider_registry() simultaneously. A threading.Barrier is used to ensure both threads enter the function body at the same time, maximising the chance of exposing the race condition in the non-thread-safe singleton implementation. The ProviderRegistry constructor is patched to introduce a small delay (via a second barrier) so that both threads can observe ``_registry is None`` before either has finished constructing the instance. """ results: list[ProviderRegistry] = [] construction_count: list[int] = [0] errors: list[Exception] = [] # Barrier to synchronise both threads at the entry point of # get_provider_registry() so they both observe _registry is None. entry_barrier = threading.Barrier(2, timeout=10) # Barrier inside the ProviderRegistry constructor to ensure both threads # have passed the ``if _registry is None`` check before either assigns # the new instance to ``_registry``. construction_barrier = threading.Barrier(2, timeout=10) original_init = ProviderRegistry.__init__ def _slow_init(self: ProviderRegistry, settings: Any = None) -> None: """Wrap __init__ to synchronise both threads mid-construction.""" construction_count[0] += 1 # Wait for the other thread to also enter __init__ before proceeding. # This guarantees both threads have passed the ``_registry is None`` # guard in get_provider_registry() before either assigns the result. with contextlib.suppress(threading.BrokenBarrierError): construction_barrier.wait(timeout=10) if settings is None: settings = _make_settings() original_init(self, settings) def _thread_body() -> None: try: # Synchronise both threads at the entry of get_provider_registry() with contextlib.suppress(threading.BrokenBarrierError): entry_barrier.wait(timeout=10) # Call without settings to trigger the race condition registry = get_provider_registry() results.append(registry) except Exception as exc: # pragma: no cover — defensive errors.append(exc) with ( patch.object(ProviderRegistry, "__init__", _slow_init), patch( "cleveragents.providers.registry.get_settings", return_value=_make_settings(), ), ): t1 = threading.Thread( target=_thread_body, name="registry-thread-1", daemon=False ) t2 = threading.Thread( target=_thread_body, name="registry-thread-2", daemon=False ) t1.start() t2.start() t1.join(timeout=15) t2.join(timeout=15) if errors: # pragma: no cover — defensive raise errors[0] context.registry_results = results context.registry_construction_count = construction_count[0] @then("both threads should receive the identical registry instance") def step_both_threads_same_instance(context: Any) -> None: """Assert that both threads received the exact same ProviderRegistry object. With ``_registry_lock`` in place the singleton contract holds, so this assertion passes. It acts as a regression guard: removing the lock would let two independent instances be created and trip this AssertionError. """ results: list[ProviderRegistry] = context.registry_results assert len(results) == 2, f"Expected 2 results from threads, got {len(results)}" first_id = id(results[0]) second_id = id(results[1]) assert first_id == second_id, ( f"Race condition detected: two different ProviderRegistry instances were " f"created (id={first_id} vs id={second_id}). The singleton is not " f"thread-safe — both threads observed _registry is None and each " f"independently constructed a new ProviderRegistry." ) @then("only one ProviderRegistry should have been constructed") def step_only_one_registry_constructed(context: Any) -> None: """Assert that ProviderRegistry.__init__ was called exactly once. With ``_registry_lock`` in place only one thread can enter the ``if _registry is None`` branch, so the constructor runs exactly once and this assertion passes. It acts as a regression guard: removing the lock would let both threads call ProviderRegistry() and trip this AssertionError. """ count: int = context.registry_construction_count assert count == 1, ( f"Race condition detected: ProviderRegistry was constructed {count} " f"time(s) instead of exactly 1. Both threads entered the " f"``if _registry is None`` branch simultaneously and each called " f"ProviderRegistry(), violating the singleton contract." )