Skip to content

fix: unwind exit stacks with in-flight exception details so generator dependencies can roll back - #259

Merged
JasperSui merged 2 commits into
mainfrom
fix/exit-stack-exception-unwinding
Aug 16, 2026
Merged

JasperSui merged 2 commits into
mainfrom
fix/exit-stack-exception-unwinding

Conversation

@JasperSui

Copy link
Copy Markdown
Owner

Fixes #255

Problem

When a function decorated with @injectable raises, generator dependencies never see the exception: exit stacks are always closed via aclose() (i.e. __aexit__(None, None, None)), so a dependency's except/rollback branch can never run — unlike a failing FastAPI request, which unwinds its exit stack with the exception details.

There was also a deeper, silent layer to this: on fastapi>=0.121 generator dependencies are entered into the two inner request-scope stacks (fastapi_inner_astack / fastapi_function_astack), and those were registered on the owning stack as push_async_callback(stack.aclose) — stripping exception details even when the owning stack did unwind with them. This meant InjectableScope.__aexit__'s existing exception forwarding silently did nothing for generator dependencies.

Fix

  1. main.py — register the inner FastAPI stacks via push_async_exit so exception details reach the stacks where generator teardowns actually live.
  2. util.py / async_exit_stack.py — new optional exc= parameter on cleanup_exit_stack_of_func() / cleanup_all_exit_stacks() (plumbed through AsyncExitStackManager): when provided, stacks unwind via __aexit__(type(exc), exc, exc.__traceback__), exactly as if the exception had propagated out of an async with block. A dependency re-raising the in-flight exception (except: rollback(); raise) is recognized as normal context-manager protocol, not reported as a cleanup failure; genuinely distinct teardown failures still surface as DependencyCleanupError.
  3. injectable_scope() — with (1) in place, an exception propagating out of the async with block now reaches generator dependencies automatically, giving full FastAPI parity with zero extra plumbing:
async with injectable_scope():
    await process()  # raises -> conn.rollback() + conn.close() run during unwind

Intentional semantic note

As in FastAPI, when an exception is delivered to a generator dependency, code placed after a bare yield (no try/finally) does not run — teardown that must always run belongs in finally. test_exception_inside_scope_still_cleans_up was updated to pin this contract (cleanup still always runs when written with finally).

Pre-existing bugs fixed along the way

  • Sync-path loop stickiness (concurrency.py): under the default "current" strategy, once the thread had no policy event loop set (e.g. after any pytest-asyncio test), every run_coroutine_sync call created a fresh throwaway loop without registering it. Dependencies were then resolved on loop A while cleanup ran on loop B, and the per-loop stack registry ([BUG] pytest hangs when using async_get_injected_obj #186) rightly refused to close A's stacks from B — silently skipping generator teardown. Reproducible on main: running any async test file before test_integration.py breaks 12+ sync integration tests. The created loop is now registered via asyncio.set_event_loop (mirroring pre-3.12 get_event_loop auto-create semantics), with a guard for closed policy loops.
  • Latest-mypy failure (util.py): return value inside the asyncgen loop now uses cast("T2", value) — the previous type: ignore[no-any-return] fails on the current mypy release (CI installs unpinned mypy).

Verification

  • Full suite green on nox 3.10 / 3.13 / 3.14 (230 passed each) and in the dev venv
  • nox -s coverage: 100% statements + branches
  • nox -s mypy: clean; ruff check + ruff format --check: clean; nox -s docs-build: success
  • New end-to-end regression tests (test/test_exit_stack_exception_unwind.py) cover: the issue's exact repro via cleanup_all_exit_stacks(exc=...) and cleanup_exit_stack_of_func(..., exc=...), automatic forwarding via injectable_scope(), sync generator dependencies, the fully-sync entry point, a dependency that swallows the exception, and the unchanged no-exception commit path

… dependencies can roll back (#255)

When a decorated function raises, generator dependencies never saw the
exception: exit stacks were always closed via aclose(), and the inner
FastAPI stacks (where generator teardowns actually live on fastapi>=0.121)
were registered as aclose() callbacks that stripped exception details even
when the owning stack unwound with them.

- Register the inner FastAPI stacks via push_async_exit so exception
  details flow through to generator dependencies at their yield point.
- Plumb an optional exc= through cleanup_exit_stack_of_func /
  cleanup_all_exit_stacks (and AsyncExitStackManager) to unwind stacks
  with __aexit__(type(exc), exc, tb), mirroring how FastAPI unwinds a
  failing request. A dependency re-raising the in-flight exception is
  treated as normal CM protocol, not a cleanup failure.
- injectable_scope() now forwards the in-flight exception end to end, so
  except/rollback branches run with no extra plumbing.
- Make _get_or_create_current_loop sticky (set_event_loop on create):
  consecutive synchronous calls previously landed on fresh throwaway
  loops, so the per-loop stack registry silently skipped teardown of
  stacks owned by an earlier loop.
- Fix a latest-mypy failure in util.py (cast instead of a stale ignore).
Latest nox annotates nox.options.sessions as 'list[str] | None'; the tuple
assignment fails the noxfile mypy step in CI (nox is installed unpinned
via pipx there).
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (28f2609) to head (ec6334d).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##              main      #259   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           11        11           
  Lines          580       595   +15     
  Branches        69        72    +3     
=========================================
+ Hits           580       595   +15     
Files with missing lines Coverage Δ
src/fastapi_injectable/async_exit_stack.py 100.00% <100.00%> (ø)
src/fastapi_injectable/concurrency.py 100.00% <100.00%> (ø)
src/fastapi_injectable/main.py 100.00% <100.00%> (ø)
src/fastapi_injectable/scope.py 100.00% <ø> (ø)
src/fastapi_injectable/util.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JasperSui
JasperSui merged commit 1b0c4c6 into main Aug 16, 2026
65 checks passed
@JasperSui
JasperSui deleted the fix/exit-stack-exception-unwinding branch August 16, 2026 07:55
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.

[BUG] Exits stacks can not be unwound with exception details

1 participant