Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions architecture/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ state at a single-threaded edge, nothing prevents several threads from then
independently calling `resolve` on that (now-closed) container at once — each
unaware the others are doing the same. A container is open from construction
(see [containers.md](containers.md#optional-open-lifecycle)), so this is the
only path back to `closed = True` in the first place. `resolve_provider` calls
`_prepare()` whenever `self.closed` is `True`; `_prepare()` warns and sets
only path back to `closed = True` in the first place. `resolve` and
`resolve_provider` each call `_prepare()` whenever `self.closed` is `True`; `_prepare()` warns and sets
`closed = False`, unlocked. The reopen needs no lock because it is idempotent —
N threads racing a closed container all write the same `False`, and they go on
to share one singleton via the cache lock below. What is *not* serialized is the
Expand Down
10 changes: 6 additions & 4 deletions architecture/containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ explicitly under `Container` rather than inferred from a type annotation).
`close_async()` can clean them up.

2. **`closed = True`** — set in a `finally` block, even if finalizers raised. A subsequent
`resolve_provider` (or a nested provider resolving at a closed ancestor scope) self-heals: it
`resolve` / `resolve_provider` (or a nested provider resolving at a closed ancestor scope) self-heals: it
reopens the container via `_prepare()` and emits `ContainerClosedWarning`, rather than raising.
Re-enter the container via `with`/`async with`, or call `container.open()`, for a silent reopen
instead — see [Optional-open lifecycle](#optional-open-lifecycle).
Expand All @@ -157,9 +157,11 @@ close, ready to be returned again without re-running the creator.

### Open and reopen (context-manager protocol)

`_prepare()` — not `open()` — is the primitive the resolve path calls: `resolve_provider` (and the
compiled-resolver dispatch it wraps) calls it whenever `self.closed` is `True`, before doing anything
else. That caller-side `if closed` check is the only guard: `_prepare()` itself takes no lock and makes
`_prepare()` — not `open()` — is the primitive the resolve path calls: `resolve_provider` and `resolve`
(and the compiled-resolver dispatch they wrap) call it whenever `self.closed` is `True`, before doing
anything else — `resolve` holds its own copy of that check rather than delegating
([decision](../planning/decisions/2026-08-03-resolve-provider-not-a-seam.md)).
That caller-side `if closed` check is the only guard: `_prepare()` itself takes no lock and makes
no re-check, warning with `ContainerClosedWarning` and clearing `closed` unconditionally. Concurrent
reuse of one closed container therefore warns **at least once**, not exactly once — see
[concurrency.md](concurrency.md#the-lifecycle). `open()` is a separate, public entry point that clears
Expand Down
15 changes: 13 additions & 2 deletions architecture/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,32 @@ deliberate — there is no interpreted fallback to inherit shared behaviour from

## Inlined memo hits

Four lookups are hand-inlined across three call sites, with the method called
Six lookups are hand-inlined across four call sites, with the method called
only on a miss:

| Call site | Inlines | Method still owns |
|---|---|---|
| `Container.resolve_provider` | `providers_registry._resolvers.get(pid)` | the cycle guard and memo write, on a miss |
| `Container.resolve` | `providers_registry._providers.get(dependency_type)` and `._resolvers.get(pid)` | `find_provider`'s absence result, and `resolver_for`'s cycle guard and memo write, on a miss |
| `_compile_cached_factory`'s `resolve` | `cache_registry._items.get(pid)` | `setdefault`, which is what makes concurrent first-resolvers share one `CacheItem` |
| `_compile_alias`'s `resolve` | `providers_registry._providers.get(source_type)` and `._resolvers.get(source.provider_id)` | `_find_source`'s error, and `resolver_for`'s cycle guard and memo write, on a miss |

In each case the method being inlined *opens with exactly that lookup and
returns*, so the inline is not a reimplementation that can drift — it is the
method's own fast path, hoisted past its frame. All three keep calling the real
method's own fast path, hoisted past its frame. All four keep calling the real
method on a miss, so the miss-path invariants (cycle detection, single shared
`CacheItem`, the dangling-source error) are untouched.

`Container.resolve` goes further than a hoisted lookup: it carries a **copy of
`resolve_provider`'s whole body** — the closed check, the memo hit, the
`resolver_for` fallback, the resolver call and the `RecursionError` conversion.
That is the one place in the library where a block of logic is deliberately
duplicated rather than shared, and both copies must be edited together. It is
worth ~-19% on a by-type resolve, and it is licensed by `resolve_provider` not
being an interception seam — an override has seen only top-level calls since the
compiled resolvers landed
([decision](../planning/decisions/2026-08-03-resolve-provider-not-a-seam.md)).

The alias case inlines two lookups rather than one, because the hop is two indirections deep: without them an
alias costs four Python frames (`_find_source`, `find_provider`, `resolve_provider`, then the source's
resolver) where every `Factory` dependency costs one.
Expand Down
6 changes: 4 additions & 2 deletions architecture/resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ made true.
## Entry points

- `container.resolve(SomeType)` — looks up `SomeType` in `providers_registry` (raising
`ProviderNotRegisteredError`, with closest-match suggestions, if none is registered), then delegates to
`resolve_provider`.
`ProviderNotRegisteredError`, with closest-match suggestions, if none is registered), then dispatches to the
compiled resolver itself. It holds its own copy of `resolve_provider`'s body rather than delegating, so the
by-type path pays no extra frame; the two copies must be edited together
([decision](../planning/decisions/2026-08-03-resolve-provider-not-a-seam.md)).
- `container.resolve_provider(provider)` — resolves by provider reference, skipping the registry lookup.
It reopens the entry container if it was closed (see [containers.md](containers.md#closing)), then calls
`providers_registry.resolver_for(provider)(self)` and wraps any escaped `RecursionError` (the runtime cycle
Expand Down
6 changes: 4 additions & 2 deletions architecture/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ as a resolution breadcrumb — including the aligned scope column — so a cycle
read identically. See [resolution.md](resolution.md#one-renderer) for that drawer.

> **Runtime resolution has a cycle guard too — but `validate()` remains the way to see all errors up front.**
> `Container.resolve_provider` wraps the compiled-resolver dispatch (`resolver_for(provider)(self)`) in
> `try/except RecursionError`. The
> `Container.resolve_provider` **and `Container.resolve`** each wrap the compiled-resolver dispatch
> (`resolver_for(provider)(self)`) in `try/except RecursionError` — `resolve` carries its own copy of that
> body rather than delegating, so the by-type entry point pays no extra frame
> ([decision](../planning/decisions/2026-08-03-resolve-provider-not-a-seam.md)). The
> handler first short-circuits: if the registry is already validated (`_validated` is `True`), the static
> graph is known acyclic, so the overflow is genuine self-recursion and the `RecursionError` re-raises untouched
> without any walk. Otherwise, when an unvalidated circular graph's first resolve overflows the stack, the handler
Expand Down
30 changes: 23 additions & 7 deletions modern_di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,28 @@ def lock(self) -> "threading.RLock | None":
return self._lock

def resolve(self, dependency_type: type[types.T]) -> types.T:
"""Resolve a dependency by its type."""
provider = self.providers_registry.find_provider(dependency_type)
if not provider:
"""Resolve a dependency by its type.

Carries its own copy of `resolve_provider`'s body rather than calling it: the extra
frame is ~19% of a by-type resolve. The duplication is deliberate and the two must be
edited together -- see planning/decisions/2026-08-03-resolve-provider-not-a-seam.md.
"""
registry = self.providers_registry
provider = registry._providers.get(dependency_type) # noqa: SLF001
if provider is None:
raise exceptions.ProviderNotRegisteredError(
provider_type=dependency_type,
suggestions=suggester.suggest(dependency_type, self.providers_registry),
suggestions=suggester.suggest(dependency_type, registry),
)

return self.resolve_provider(provider)
if self.closed:
self._prepare()
try:
resolver = registry._resolvers.get(provider.provider_id) # noqa: SLF001
if resolver is None:
resolver = registry.resolver_for(provider)
return resolver(self)
except RecursionError as exc:
_handle_recursion_error(provider, self, exc)

def resolve_dependency(self, dependency: "AbstractProvider[types.T] | type[types.T]") -> types.T:
"""Resolve a provider reference or a type — the marker-dispatch entry point for integrations.
Expand All @@ -213,7 +226,10 @@ def resolve_dependency(self, dependency: "AbstractProvider[types.T] | type[types
return self.resolve(dependency)

def resolve_provider(self, provider: "AbstractProvider[types.T]") -> types.T:
"""Resolve a specific provider by reference via its compiled resolver."""
"""Resolve a specific provider by reference via its compiled resolver.

`resolve` holds a copy of this body; any change here belongs there too.
"""
if self.closed:
self._prepare()
try:
Expand Down
76 changes: 76 additions & 0 deletions planning/decisions/2026-08-03-resolve-provider-not-a-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
summary: `resolve_provider` is not an interception seam and `Container` subclassing is not a supported way to intercept resolution — since the compiled resolvers landed it has only ever seen top-level calls, so inlining it into `resolve()` narrows nothing that worked.
---

# `resolve_provider` is not an interception seam

**Decision:** `Container.resolve_provider` is an entry point, not a hook. Overriding
it in a `Container` subclass is not a supported way to observe or intercept
resolution, and the resolve path is free to bypass it. This licenses inlining its
body into `Container.resolve`. `find_container` is **not** affected and remains a
blessed extension point.

## Context

Inlining `find_provider` + `resolve_provider` into `Container.resolve` measures
**-19% (~38 ns) on every by-type resolve** — the path every `@inject` marker and
framework integration takes. It was deferred partly because the same structural
objection that killed
[`2026-08-01-scope-map-inline-declined.md`](2026-08-01-scope-map-inline-declined.md)
appears to apply: `resolve_provider` is a public method on a subclassable class,
and `Container.__init__` builds children via `self.__class__`, so a subclass rides
the whole tree. Bypassing it would mean a subclass's override no longer runs for
by-type calls.

## Decision & rationale

**It is already not a seam, and that is measurable rather than arguable.** Since
the compiled resolvers shipped in 2.29.0, a resolver calls its dependencies'
resolvers *directly*; nothing routes a nested node through `resolve_provider`. Its
only callers are `resolve()`, `resolve_dependency()`, and the cycle back-edge
thunk in `ProvidersRegistry.resolver_for`. Demonstrated on `main` before this
change: a `Container` subclass overriding `resolve_provider` and resolving a
**4-node chain** records exactly **1** call — the top-level one. An override has
never seen the graph. Inlining removes one of the three top-level call sites; the
by-reference and marker-dispatch entries still route through it.

So the thing the objection protects does not exist. What a subclass can still do
after this change is instrument the *entry points* by overriding `resolve` and
`resolve_provider` — which is what someone wanting that would actually reach for,
and it keeps working.

**This is deliberately narrower than the `_scope_map` ruling, which stands.**
`find_container` is consulted on every cross-scope hop, and the container it
returns owns the cached instance and runs its finalizer — bypassing an override
there silently relocates lifecycle ownership, which is a bug, not a missed hook.
`resolve_provider` has no such consequence: bypassing an override loses
observation, not correctness. The two are not the same call and are not being
ruled on together.

**Field check.** An audit of all 13 sibling integration wheels found zero
`Container` subclasses and zero `resolve_provider` overrides. `Container`
subclassing is not documented as an extension point anywhere in `architecture/` or
`docs/`.

**Accepted costs**, disclosed rather than discovered later:

- A genuinely duplicated ~8-line body (closed check, memo hit, `resolver_for`
fallback, resolver call, `RecursionError` conversion) now lives in both `resolve`
and `resolve_provider` and must be edited in lockstep. This is the real price and
it is permanent.
- An exception raised through `resolve()` loses one traceback frame (5 → 4;
`resolve_provider` no longer appears). Verified directly.
- Recursion headroom moves by one frame in the benign direction.

**Consequence worth naming.** Together with
[`2026-07-30-debug-resolution-tracing-declined.md`](2026-07-30-debug-resolution-tracing-declined.md),
modern-di offers no built-in way to observe *per-node* resolution. That was already
true — the compiled resolvers removed the last interior call — and this decision
records it rather than creating it. Entry-point instrumentation remains available
by overriding both public entry methods.

## Revisit trigger

A concrete request for per-resolve interception from a real integration or user.
The answer then is a designed seam with a stated contract — not a re-blessing of
subclass overrides, which the compiled resolve path stopped honouring in 2.29.0.
76 changes: 0 additions & 76 deletions planning/deferred/2026-08-01-resolve-by-type-inline.md

This file was deleted.

40 changes: 40 additions & 0 deletions tests/test_runtime_cycle_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,43 @@ class G(Group):
pytest.fail("expected CircularDependencyError")
finally: # pragma: no cover
sys.setrecursionlimit(limit)


def test_by_reference_cycle_raises_circular_dependency_error() -> None:
# By-reference twin of the by-type test above, and the only guard on `resolve_provider`'s
# own conversion: `Container.resolve` carries a second copy, so replacing the conversion
# here with a bare re-raise still passes every by-type cycle test. This is a behavioural
# guard, not a coverage one -- the line is covered by
# `test_by_reference_recursionerror_passes_through`, which reaches it without an overflow.
# Same `except`-clause shape and shallow limit, per `_SHALLOW_RECURSION_LIMIT`.
container = Container(groups=[CycleGroup])
container.open()
original_limit = sys.getrecursionlimit()
sys.setrecursionlimit(_SHALLOW_RECURSION_LIMIT)
try:
container.resolve_provider(CycleGroup.a)
except exceptions.CircularDependencyError as exc: # pragma: no cover
_assert_simple_cycle(exc)
else: # pragma: no cover
pytest.fail("expected CircularDependencyError")
finally: # pragma: no cover
sys.setrecursionlimit(original_limit)


def test_by_reference_recursionerror_passes_through() -> None:
# Reaches `resolve_provider`'s handler WITHOUT a stack overflow, so the trace function is
# still alive and the line is recorded on CPython below 3.12. The by-type twin of this
# (`test_validated_graph_reraises_recursionerror_without_walk`) now lands on `resolve`'s
# own copy of the handler, leaving this entry point otherwise untraced.
class SelfRec:
def __init__(self) -> None:
raise RecursionError

class G(Group):
s = providers.Factory(scope=Scope.APP, creator=SelfRec)

container = Container(scope=Scope.APP, groups=[G])
container.validate()
container.open()
with pytest.raises(RecursionError):
container.resolve_provider(G.s)
Loading