Skip to content

Closing a subscription leaves a distinct source iterator open #283

Closed

Description

@AryanRG

On current main (d913a61), map_async_iterable registers cleanup on the async iterable object, while async iteration consumes the object returned by its aiter method. A valid wrapper can return a separate async generator. Closing the mapped stream then leaves that generator open, so its finally block does not run. The same behavior is reachable through subscribe().

Minimal reproducer:

import asyncio
from graphql.execution.async_iterables import map_async_iterable

async def main():
    closed = False

    async def events():
        nonlocal closed
        try:
            yield 1
            yield 2
        finally:
            closed = True

    iterator = events()

    class Events:
        def __aiter__(self):
            return iterator

    async def identity(value):
        return value

    mapped = map_async_iterable(Events(), identity)
    try:
        assert await anext(mapped) == 1
        await mapped.aclose()
        assert closed  # Fails: the source iterator is still open.
    finally:
        await iterator.aclose()

asyncio.run(main())

Expected: closing the mapped stream awaits cleanup of the iterator actually supplying events. Actual: cleanup is attempted on the wrapper; the generator remains open and can yield the second event.

A narrow local fix acquires the iterator before registering cleanup (aclosing(aiter(iterable))). Four new regressions fail on the base and pass with that change, including one through the public subscription API. The full local suites pass on Python 3.10 and 3.14. This differs from the broader AnyIO/cancellation work in #165 and #166; it does not change pending-next cancellation behavior.

Does closing the consumed iterator match the intended port contract? I can link the focused patch after discussion, following the PR template. AI assistance was used to investigate and validate this report.

Activity

  1. Cito commented on Oct 19, 2026

    @Cito
    Member

    Thanks for the report and the clear reproducer, @AryanRG. Yes, closing the iterator that is actually consumed is the intended contract. This is a regression in 3.3: in 3.2, MapAsyncIterator called iterable.__aiter__() and closed that iterator. GraphQL.js does the same in mapAsyncIterable: it calls return() on iterable[Symbol.asyncIterator](), not on the iterable itself.

    So aclosing(aiter(iterable)) is the right fix, and aiter() is fine since we require Python 3.10. Please go ahead and open a PR. Keep it focused, with the tests in tests/execution/test_map_async_iterable.py plus the one going through subscribe(). The fix will then be part of 3.3.1.

  2. AryanRG commented on Oct 19, 2026

    @AryanRG
    ContributorAuthor

    Thanks @Cito, I've opened #286 with the iterator cleanup fix and regression tests, including the subscribe() case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions