Skip to content

Raise on async finalizer in close_sync; drop Group cache; slot exceptions#186

Merged
lesnik512 merged 2 commits into
mainfrom
raise-on-async-finalizer-sync-close-and-drop-group-cache
May 30, 2026
Merged

Raise on async finalizer in close_sync; drop Group cache; slot exceptions#186
lesnik512 merged 2 commits into
mainfrom
raise-on-async-finalizer-sync-close-and-drop-group-cache

Conversation

@lesnik512
Copy link
Copy Markdown
Member

@lesnik512 lesnik512 commented May 30, 2026

Summary

Four changes — items #1 and #9 from plan.md, plus two adjacent cleanups discovered during verification.

#1close_sync raises on async finalizers

Previously CacheItem.close_sync emitted a RuntimeWarning and silently returned when it found a cached resource with an async finalizer. The cache was preserved, but users had no actionable signal — they'd have to know to call close_async() next. Most won't.

Now CacheItem.close_sync raises AsyncFinalizerInSyncCloseError (a new exception class). The existing CacheRegistry.close_sync aggregation loop already wraps per-item exceptions into FinalizerError, so users get one clean error containing the list of offending types and a clear actionable message:

FinalizerError: Errors during sync cleanup: [
  AsyncFinalizerInSyncCloseError('Cannot run async finalizer for Resource during sync close. Use `await container.close_async()` (or `async with container:`) instead.')
]

Cached resources are preserved on the raise path, so the recovery flow ("call close_async() to actually clean up") is unchanged — just made explicit instead of silent.

Picked "raise" over "unified close()" per the plan.md options. A unified close() would still have to refuse on async finalizers (you can't await from sync code), so it'd be sugar over a louder close_sync() — feature creep without solving the actual choice.

#9 — drop Group.providers cache

Group.get_providers() snapshotted into a class attribute on first call. Dynamic mutation of a Group after the first introspection silently went unseen. Audit confirmed: only one caller (Container.__init__), trivial cost (a cls.__dict__ iteration over 2-10 attributes), no tests depend on the freeze. Now it just returns a fresh list every call. Pure correctness.

Bonus — __slots__ on all exception classes

Marker classes (ModernDIError, ContainerError, RegistrationError, ResolutionError) get __slots__ = (). Subclasses with instance attrs get explicit tuples. Matches the project's existing lean-memory style (Container, registries, dataclasses with slots=True).

Bonus — CacheItem.cache uses UNSET sentinel

None was overloaded as both "not cached" and a potentially-valid cached value. Two consequences:

  1. A creator returning None was treated as never-cached — re-created on every resolve, finalizer never ran.
  2. if self.cache and ... skipped finalizers for any cached-but-falsy resource (empty dict, 0, False, empty string). Same anti-pattern PR Argument suggestions, context falsy/None fix, docs lead with with #184 fixed in ContextRegistry.

Migrated CacheItem.cache to use types.UNSET as the "not cached" sentinel. All four discriminating sites updated (_clear, close_async, close_sync, cached_count in cache_registry.py; the two cached-value short-circuit reads in factory.py:resolve). Three new regression tests cover falsy-sync, falsy-async, and the None-returning creator path.

Test plan

  • just lint — clean (ruff + ty)
  • just test — 125 passed (3 new), 100% coverage
  • tests/providers/test_singleton.py::test_request_singleton updated to expect FinalizerError wrapping AsyncFinalizerInSyncCloseError and uses is UNSET for cleared-cache assertion
  • Sibling test test_sync_finalizer_exception_does_not_abort_remaining_cleanup continues to pass (same aggregation path)
  • Manual smoke checks: container with async-finalized resource → FinalizerError(AsyncFinalizerInSyncCloseError); container with None-returning creator caches once and finalizes; container with empty-dict creator finalizes correctly

🤖 Generated with Claude Code

…dd __slots__ to exceptions

- CacheItem.close_sync no longer warns-and-returns when it sees an
  async finalizer. It now raises AsyncFinalizerInSyncCloseError, which
  the registry collects into the existing FinalizerError aggregation.
  Cached resources are preserved on the raise path, so users can still
  recover with `await close_async()` or by switching to `async with`.
  The silent-no-op pattern is gone.
- Group.get_providers no longer caches the introspection result on the
  class. Dynamic mutation of a Group now sees fresh data. The cost
  (one cls.__dict__ iteration per Container) is negligible against
  Container init cost and was the only thing standing between users
  and a quietly stale provider list.
- All exception classes gained __slots__ — both for declaring intent
  and for a tiny memory win on instances. Marker classes get
  __slots__ = () for hygiene.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lesnik512 lesnik512 self-assigned this May 30, 2026
… value

CacheItem.cache used None both as the 'not cached' sentinel and as a
potentially-valid cached value. Two consequences:

1. A creator that returns None was treated as never-cached: it would
   be re-created on every resolve and its finalizer would never run.
2. The `if self.cache and ...` guard in close_sync/close_async skipped
   finalizers for any cached-but-falsy resource (empty dict, 0, False,
   empty string). Same anti-pattern PR #184 fixed in ContextRegistry.

Switched to types.UNSET as the 'not cached' sentinel. Now:
- A None-returning creator caches once, returns the cached None on
  subsequent resolves, and runs its finalizer at close time.
- Falsy-but-set cached values (empty dict/list/0/False) get their
  finalizers called too.

Touches the four sites that distinguished cached from not-cached:
cache_registry.py (_clear, close_async, close_sync, cached_count) and
factory.py (the two cached-value short-circuit reads in resolve).

Three new regression tests cover: falsy-sync, falsy-async, and the
None-returning creator path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lesnik512 lesnik512 merged commit d9b7bcd into main May 30, 2026
7 checks passed
@lesnik512 lesnik512 deleted the raise-on-async-finalizer-sync-close-and-drop-group-cache branch May 30, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant