Skip to content

Fix scoped resource leak on cancellation in exit-stack cleanup#142

Merged
maldoinc merged 1 commit into
maldoinc:masterfrom
glitch-ux:fix/exit-stack-baseexception-leak
Jun 16, 2026
Merged

Fix scoped resource leak on cancellation in exit-stack cleanup#142
maldoinc merged 1 commit into
maldoinc:masterfrom
glitch-ux:fix/exit-stack-baseexception-leak

Conversation

@glitch-ux

Copy link
Copy Markdown
Contributor

What

Fixes a scoped-resource leak during cancellation. Both clean_exit_stack and async_clean_exit_stack in wireup/ioc/_exit_stack.py iterated the exit stack catching only except Exception. asyncio.CancelledError (and KeyboardInterrupt / SystemExit) are BaseException, not Exception, so when a generator's finally re-raised the exception being unwound, it escaped the loop early — skipping cleanup of every earlier-registered generator and the exit_stack.clear() call.

In practice, a cancelled async with container.enter_scope() block (an asyncio.wait_for timeout, a task.cancel(), structured-concurrency cancellation) tore down only the last scoped resource and leaked the rest.

Fixes #141.

Reproduction (before this PR)

import asyncio
from collections.abc import AsyncIterator
from typing import NewType

import wireup
from wireup import injectable

A, B = NewType("A", str), NewType("B", str)
teardowns: list[str] = []

@injectable(lifetime="scoped")
async def factory_a() -> AsyncIterator[A]:
    try:
        yield A("a")
    finally:
        teardowns.append("A")

@injectable(lifetime="scoped")
async def factory_b() -> AsyncIterator[B]:
    try:
        yield B("b")
    finally:
        teardowns.append("B")

async def handler(container):
    async with container.enter_scope() as scope:
        await scope.get(A)
        await scope.get(B)
        await asyncio.sleep(10)

async def main():
    container = wireup.create_async_container(injectables=[factory_a, factory_b])
    try:
        await asyncio.wait_for(handler(container), timeout=0.05)
    except (asyncio.TimeoutError, TimeoutError):
        pass
    print(teardowns)

asyncio.run(main())
teardowns
before ['B']A leaks
after ['B', 'A'] — matches contextlib.AsyncExitStack

How

Add an except BaseException branch after the existing except Exception in both cleanup loops:

except BaseException as e:
    # CancelledError / KeyboardInterrupt / SystemExit are BaseException, not
    # Exception. When the exception being unwound (exc_val) re-emerges from a
    # generator's teardown, keep closing the remaining generators instead of
    # letting it abort cleanup, which would skip exit_stack.clear() and leak
    # every earlier-registered resource. A genuinely new one is propagated.
    if e is not exc_val:
        raise
  • The re-raised exc_val is swallowed so cleanup of the remaining generators continues and the stack is cleared; it keeps propagating naturally through __aexit__ afterwards (which returns None).
  • A new BaseException raised during teardown is re-raised rather than appended to errors, because ContainerCloseError subclasses ExceptionGroup on Python 3.11+ and would reject non-Exception members.
  • async_clean_exit_stack now trips ruff C901 (complexity 11 > 10), so it carries a # noqa: C901, consistent with factory_compiler.py / _wrapper_compiler.py.

Tests

Added to test/unit/test_exit_stack.py:

  • test_clean_exit_stack_continues_teardown_when_unwinding_base_exception — sync path.
  • test_async_clean_exit_stack_continues_teardown_on_cancellation — async path with asyncio.CancelledError (the reported scenario).
  • test_async_clean_exit_stack_propagates_new_base_exception_from_teardown — locks in that a genuinely new BaseException still propagates.

The first two fail on master and pass with this change.

Checks

make check-ruff, make check-fmt, make check-mypy, and make test (355 passed) are all green locally. No new dependencies; targets Python 3.10+.

clean_exit_stack and async_clean_exit_stack caught only `except Exception`
while iterating the exit stack. asyncio.CancelledError (and KeyboardInterrupt
/ SystemExit) are BaseException, not Exception, so when a generator's teardown
re-raised the exception being unwound it escaped the loop early, skipping the
remaining generators and exit_stack.clear(). On a cancelled `async with
container.enter_scope()` block (asyncio.wait_for timeout, task cancellation)
every earlier-registered scoped resource leaked, matching neither the
documented behaviour nor contextlib.AsyncExitStack.

Add an `except BaseException` branch to both loops: swallow the re-raised
exc_val so cleanup of the remaining generators continues (and the stack is
cleared), while propagating any genuinely new BaseException. New BaseExceptions
are not appended to the errors list since ContainerCloseError subclasses
ExceptionGroup on 3.11+, which rejects non-Exception members.

Fixes maldoinc#141
@maldoinc maldoinc merged commit 69d45ab into maldoinc:master Jun 16, 2026
8 checks passed
@maldoinc

Copy link
Copy Markdown
Owner

Thank you @glitch-ux. This is now released in 2.11.3

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.

Async scoped resources leak on cancellation: exit-stack cleanup catches only Exception, not BaseException

2 participants