From e471a1635db6e83192bf9ec7de8b338e584e92f8 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 7 Oct 2026 21:02:58 +0300 Subject: [PATCH] test: pin finalizer ownership and close ordering, mark the threaded cache test (#616) Adds tests for finalizer ownership by the resolving container, newest-first order of async finalizers left by close_sync, and no re-finalize of a clear_cache=False instance after a cancelled close_async. Documents concurrent close of one container as unsupported. test-race now selects the thread_race marker over the whole suite. --- docs/introduction/design-decisions.md | 3 ++ justfile | 4 +- tests/providers/test_cached_factory.py | 71 ++++++++++++++++++++++++++ tests/test_cache.py | 3 ++ 4 files changed, 79 insertions(+), 2 deletions(-) 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] = {}