diff --git a/docs/introduction/design-decisions.md b/docs/introduction/design-decisions.md index 357372fc..2f2c6a62 100644 --- a/docs/introduction/design-decisions.md +++ b/docs/introduction/design-decisions.md @@ -29,6 +29,9 @@ Each cache item, the cached instance of one `Factory` in one container, has its shared by the whole container tree, so an override set on any container is seen by every container in it. Set overrides during setup, never from competing threads. +- Closing one container concurrently is unsupported: never overlap two + `close_async()` calls on it, or call `close_sync()` while a `close_async()` + is still running. - Free-threaded CPython (PEP 703) is supported at `2 - Beta`. It is tested under real multithreading on the `3.14t` build. It is Beta rather than Stable for one specific reason: modern-di relies on object-publication ordering diff --git a/justfile b/justfile index 6cbd9a5c..37465488 100644 --- a/justfile +++ b/justfile @@ -34,9 +34,9 @@ adr-check: test *args: uv run --no-sync pytest {{ args }} -# Run the thread_race tests only, collecting just the files that hold them. Passes args through. +# Run the thread_race tests only, selected by marker across the whole suite. Passes args through. test-race *args: - uv run --no-sync pytest -m thread_race tests/test_free_threading.py tests/providers/test_cached_factory.py tests/registries/test_providers_registry.py {{ args }} + uv run --no-sync pytest -m thread_race {{ args }} # The gated full run: 100% line and branch coverage of modern_di required. CI runs this. test-ci: diff --git a/tests/providers/test_cached_factory.py b/tests/providers/test_cached_factory.py index 9fa1185f..039c7b84 100644 --- a/tests/providers/test_cached_factory.py +++ b/tests/providers/test_cached_factory.py @@ -817,3 +817,74 @@ class SlowGroup(Group): await container.close_async() assert events == ["second", "first-cancelled", "first"] + + +def test_app_singleton_resolved_through_a_child_is_finalized_by_the_app_container() -> None: + calls: list[_First] = [] + + class G(Group): + first = providers.Factory(creator=_First, cache=providers.CacheSettings(finalizer=calls.append)) + + app_container = Container(groups=[G]) + request_container = app_container.build_child_container(scope=Scope.REQUEST) + instance = request_container.resolve(_First) + + request_container.close_sync() + + assert calls == [] + assert app_container.resolve(_First) is instance + app_container.close_sync() + assert calls == [instance] + + +async def test_async_finalizers_left_by_close_sync_run_newest_first() -> None: + events: list[str] = [] + + async def finalize_first(_: _First) -> None: + events.append("first") + + async def finalize_second(_: _Second) -> None: + events.append("second") + + class G(Group): + first = providers.Factory(creator=_First, cache=providers.CacheSettings(finalizer=finalize_first)) + second = providers.Factory(creator=_Second, cache=providers.CacheSettings(finalizer=finalize_second)) + + container = Container(groups=[G]) + container.resolve(_First) + container.resolve(_Second) + + with pytest.raises(FinalizerError): + container.close_sync() + await container.close_async() + + assert events == ["second", "first"] + + +async def test_close_async_after_a_cancelled_close_does_not_refinalize_a_clear_cache_false_instance() -> None: + calls: list[str] = [] + + async def blocking_finalizer(_: _First) -> None: + if "first-cancelled" not in calls: + calls.append("first-cancelled") + await asyncio.Event().wait() + calls.append("first") + + async def keep_finalizer(_: _Second) -> None: + calls.append("second") + + class G(Group): + first = providers.Factory(creator=_First, cache=providers.CacheSettings(finalizer=blocking_finalizer)) + second = providers.Factory( + creator=_Second, cache=providers.CacheSettings(clear_cache=False, finalizer=keep_finalizer) + ) + + container = Container(groups=[G]) + container.resolve(_First) + container.resolve(_Second) + + with pytest.raises(TimeoutError): + await asyncio.wait_for(container.close_async(), timeout=0.05) + await container.close_async() + + assert calls == ["second", "first-cancelled", "first"] diff --git a/tests/test_cache.py b/tests/test_cache.py index 2eb0e727..d7ede76c 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -2,6 +2,8 @@ import typing from concurrent.futures import ThreadPoolExecutor +import pytest + from modern_di.cache import CacheItem, close_async, fetch_cache_item from modern_di.providers import CacheSettings, Factory from modern_di.types import UNSET @@ -112,6 +114,7 @@ def test_each_cache_item_owns_its_lock() -> None: assert first.lock is not second.lock +@pytest.mark.thread_race def test_concurrent_fetches_of_one_provider_share_one_item() -> None: n = 8 cache_items: dict[int, CacheItem] = {}