Skip to content

fix(memory): enforce closed state in MongoDBSession - #4134

Closed
LHMQ878 wants to merge 8 commits into
openai:mainfrom
LHMQ878:fix/mongodb-session-closed-state
Closed

fix(memory): enforce closed state in MongoDBSession#4134
LHMQ878 wants to merge 8 commits into
openai:mainfrom
LHMQ878:fix/mongodb-session-closed-state

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

MongoDBSession.close() closes the underlying client when the session owns it (created via from_uri), but it never records that the session is closed. Every session method then keeps issuing commands against a closed client instead of failing fast.

This is the same gap that was closed for the other backends:

MongoDBSession was the remaining backend with an unguarded close().

Repro

Against main, with an owned client (from_uri) after await session.close():

=== MongoDBSession, OWNED client (from_uri), after close() ===
   client._closed = True
   add_items      -> RETURNED None
   get_items      -> RETURNED '2 items'
   pop_item       -> RETURNED {'role': 'user', 'content': 'after'}
   clear_session  -> RETURNED None

The same script against the already-fixed backend:

=== AsyncSQLiteSession (fixed by #4109), after close() ===
   add_items / get_items / pop_item / clear_session -> RuntimeError: AsyncSQLiteSession is closed

get_items returning items and add_items returning None after the client is closed is the worst shape of this: the caller has no signal that the write went nowhere.

Fix

Mirrors the merged pattern exactly:

  • self._closed = False in __init__, set to True in close().
  • A _check_not_closed() helper raising RuntimeError("MongoDBSession is closed").
  • The check runs when each session-protocol method enters the _operation() context manager, so all four methods and ping() are covered at a single point. add_items checks the flag directly on its empty-list fast path, which returns before doing any work.
  • ping() is guarded too, matching how fix(memory): enforce closed state in Redis and Dapr sessions #4035 guarded Redis's ping.
  • Operations register on an _active_operations counter guarded by that same threading.Lock, and close() drains it after marking the session closed and before releasing the client, so an operation that already passed the guard cannot issue its command against a closed client. The other backends get this by holding an asyncio.Lock across both the operation and close(), which is not available here; the counter also keeps concurrent reads and writes overlapping as they do today, with only close() waiting.
  • close() follows the Redis/Dapr shape: it returns early for an injected client (a no-op, per the documented contract that lifecycle stays with the caller), and for an owned client the session is terminal from the first attempt while _client_released tracks whether the release actually completed, so a failed or cancelled client.close() is retried by a later close(). The release is claimed under the threading.Lock this class already uses for _init_state, not an asyncio.Lock, so concurrent closes are serialized even across event loops and threads — this session is documented as usable from more than one loop, and an asyncio.Lock binds to whichever loop first acquires it.

After the fix:

=== MongoDBSession, OWNED client (from_uri), after close() ===
   client._closed = True
   add_items      -> RuntimeError: MongoDBSession is closed
   get_items      -> RuntimeError: MongoDBSession is closed
   pop_item       -> RuntimeError: MongoDBSession is closed
   clear_session  -> RuntimeError: MongoDBSession is closed
   close (again)  -> RETURNED None

The MongoDB bullet in docs/sessions/index.md is also updated: it documented only client ownership, while the Redis and Dapr bullets already state the terminal-after-close behaviour. Translations are generated, so they are left to make translate_docs.

Tests

Fifteen tests added to tests/extensions/memory/test_mongodb_session.py, following the convention from #4109:

  • all four protocol methods plus ping raise after close()
  • add_items([]) does not slip past the guard via the empty-list fast path
  • get_items(limit=0) does not slip past it via the non-positive-limit fast path
  • close() before any command still makes the session terminal
  • concurrent + repeated close() are no-ops and the owned client is closed exactly once (the fake client suspends mid-release, without which a same-loop race is unobservable)
  • two threads with their own event loops closing at once still release the client exactly once
  • a caller waiting on someone else's release observes that release failing, rather than returning success
  • cancelling a waiting close() does not abort the release it was waiting on (the shared attempt is shielded, so the releasing caller can still publish its outcome)
  • an abandoned waiter consumes a failed release's exception, in both cancel/fail interleavings
  • close() drains an operation that already passed the guard instead of releasing the client underneath it
  • close() from a second event loop does not raise
  • a failed client release is retried by a later close() rather than being suppressed
  • with an injected client, close() stays a no-op and the session keeps working
  • a negative control asserting normal operation is unchanged

All six guard tests fail on main (DID NOT RAISE <class 'RuntimeError'>) and pass with the fix; the negative control passes on both.

pytest tests/extensions/memory/test_mongodb_session.py → 52 passed (290 across tests/extensions/memory). ruff check, ruff format, mypy and pyright are clean on both changed files, and the full suite shows no change against the merge base.

MongoDBSession.close() closed an owned client but never recorded that the
session was closed, so every session method kept issuing commands against a
closed client instead of failing fast. get_items() would still return items
and add_items() would still return None, leaving the caller with no signal
that the write went nowhere.

Applies the pattern already merged for the other backends (openai#4035 for Redis
and Dapr, openai#4109 for AsyncSQLiteSession): a _closed flag plus a
_check_not_closed() helper, checked in _ensure_indexes() (the chokepoint all
four protocol methods funnel through) and explicitly in add_items and
get_items, which can early-return before reaching it. ping() is guarded too.
close() is terminal from the first call and idempotent, closing an owned
client exactly once and leaving an injected client untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a8445c1e2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 405 to 408
already_closed = self._closed
self._closed = True
if self._owns_client and not already_closed:
await self._client.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve injected-client close() no-op

With a caller-supplied AsyncMongoClient, this now marks the session closed before checking ownership, so await session.close() makes later get_items()/add_items() fail even though the client is still open. That breaks the documented MongoDB lifecycle contract in docs/sessions/index.md:451 that session.close() is a no-op when the application owns the client, and it also diverges from the Redis/Dapr injected-client behavior; only owned from_uri(...) sessions should become terminal here.

AGENTS.md reference: AGENTS.md:L84-L87

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 9b932a7 — thanks, this was a real contract break.

docs/sessions/index.md:451 does say session.close() is a no-op for an injected client, and RedisSession.close() / DaprSession.close() both return before touching _closed when not self._owns_client. My version set _closed first, so an injected-client session became terminal even though its client stayed open.

close() now returns early for injected clients, matching Redis/Dapr:

if not self._owns_client:
    return
self._closed = True

I had also written a test asserting the wrong contract (that an injected-client session becomes terminal). It has been replaced by test_close_injected_client_is_a_noop, which asserts the documented behaviour instead — the client stays open and get_items / add_items / ping all keep working after close(). That test fails against the previous commit.

Comment on lines 405 to 408
already_closed = self._closed
self._closed = True
if self._owns_client and not already_closed:
await self._client.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow retrying failed owned-client cleanup

For an owned from_uri(...) session, if await self._client.close() raises or is cancelled, _closed has already been set and every later close() call takes the already_closed no-op path, so the owned MongoDB client can be left open with no way for the session to retry cleanup. This failure/cancellation path should track whether client release actually completed, or otherwise avoid suppressing later close attempts after unsuccessful cleanup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 9b932a7.

You're right that _closed = True before the release completed made the failure unrecoverable: the next close() took the no-op path and the owned client stayed open forever. RedisSession and DaprSession already solve this with a separate _client_released flag, so I've adopted it:

if not self._owns_client:
    return
self._closed = True
if not self._client_released:
    await self._client.close()
    self._client_released = True

The session is still terminal from the first attempt (operations raise), but an unfinished release is retried. test_failed_owned_client_close_can_be_retried covers it with a client whose first close() raises ConnectionError:

step before 9b932a7 after
1st close() raises ConnectionError, _closed=True raises ConnectionError, _closed=True
get_items() RuntimeError RuntimeError
2nd close() no-op — client left open retries release, client closed
3rd close() no-op no-op (release already done)

That test fails against the previous commit too.

…etry

Addresses two review findings on the previous commit:

- With a caller-supplied client, close() marked the session closed before
  checking ownership, making later operations raise even though the client
  was still open. That contradicts the documented contract in
  docs/sessions/index.md that close() is a no-op when the application owns
  the client, and diverged from Redis/Dapr. close() now returns early for
  injected clients, so the session never becomes terminal.
- For an owned client, _closed was set before the release completed, so a
  failed or cancelled client.close() left the client open with no way to
  retry. Release is now tracked separately via _client_released, matching
  RedisSession and DaprSession: the session is terminal from the first
  attempt, but a later close() retries unfinished cleanup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b932a78d9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +411 to +413
if not self._client_released:
await self._client.close()
self._client_released = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize owned-client close attempts

When two coroutines call close() on the same owned MongoDBSession while AsyncMongoClient.close() is still awaiting I/O, both can pass this _client_released guard before either call sets it. The new idempotence state therefore still allows duplicate cleanup, so a second close can run against an already-closing client or surface a cleanup error even though another close succeeds; the added gather() test misses this because the fake close() never suspends. Guard this section with an async lock or shared close task.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 13ebaf4 — and you're right that my gather() test couldn't have caught it.

The fake client's close() returned without ever suspending, so the second coroutine never got to run while the first was inside the release. Making it await asyncio.sleep(0) mid-release reproduces the double release immediately:

client.close() invocations under concurrent close():  2   (before 13ebaf4)
client.close() invocations under concurrent close():  1   (after)

test_close_is_idempotent_and_closes_owned_client_once now suspends mid-release for exactly this reason, and it fails against 9b932a7 with assert 2 == 1.

One wrinkle worth flagging on the remedy: unlike RedisSession/DaprSession, this class cannot hold an asyncio.Lock created in __init__. Its _init_state design notes explicitly call out that a session may be used from more than one event loop, and an asyncio.Lock binds to the loop that first acquires it. So the lock is created lazily and rebound if close() runs on a different loop:

def _close_guard(self) -> AbstractAsyncContextManager[Any]:
    with self._init_guard:
        loop = asyncio.get_running_loop()
        if self._close_lock is None or self._close_lock_loop is not loop:
            self._close_lock = asyncio.Lock()
            self._close_lock_loop = loop
        return self._close_lock

Rebinding is safe because a differing loop means no concurrent close is in flight on the previous one. test_close_from_a_second_event_loop_does_not_error covers that path (it closes the session from a thread running its own loop), so the serialization fix doesn't quietly reintroduce the cross-loop restriction the class was written to avoid.

_closed is still set before awaiting the lock, so a caller queued behind a slow release still can't issue commands in the meantime.

46 tests pass; ruff, mypy and pyright are clean.

…ed once

Concurrent close() calls could both pass the _client_released guard while
AsyncMongoClient.close() was still awaiting I/O, releasing the client twice.
The previous idempotence test could not catch this because the fake client's
close() never suspended.

close() now holds a lock around the release. The lock is created lazily and
rebound if the session is closed from a different event loop than the one
that created it: an asyncio.Lock binds to a single loop, and this session is
documented as usable across loops (the same reason _init_state is guarded by
a threading.Lock). _closed is still set before awaiting the lock, so a caller
blocked behind a slow release cannot issue commands in the meantime.

The idempotence test now suspends mid-release, which reproduces the double
release against the previous commit, and a new test closes a session from a
second event loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13ebaf47b3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +413 to +415
if self._close_lock is None or self._close_lock_loop is not loop:
self._close_lock = asyncio.Lock()
self._close_lock_loop = loop

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize close across event loops

When two event loops close the same owned session concurrently, the second loop takes this branch and replaces the in-flight lock with a fresh unlocked one, so both callers can enter the if not self._client_released block before either awaits AsyncMongoClient.close() to completion. The same-loop gather() test now passes, but a shutdown path that calls close() from different threads can still release the owned client twice despite the new docstring promising concurrent calls release it exactly once; use a loop-independent guard/shared close task instead of rebinding the lock while a close may be in flight.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in e02c68e — you're right, and the flaw was in the reasoning I put in the docstring, not just the code.

I justified rebinding with "a differing loop means no concurrent close is in flight on the previous one." That's exactly the assumption two threads violate. Reproduced with two threads running their own loops, released behind a threading.Barrier, with a release that stays in-flight for 50ms:

client.close() invocations across two loops:  2   (before e02c68e)
client.close() invocations across two loops:  1   (after)

Rather than rebinding, the release is now claimed under the threading.Lock this class already uses — which serializes callers regardless of loop, and is the same reason _init_state is guarded by a threading lock rather than an asyncio.Lock:

def _claim_client_release(self) -> bool:
    with self._init_guard:
        if self._client_released or self._releasing_client:
            return False
        self._releasing_client = True
        return True

close() returns early if it loses the claim, and a try/finally calls _finish_client_release(released) so failure or cancellation frees the claim without marking the client released — the retry behaviour from 9b932a7 is preserved. No asyncio.Lock is involved anywhere now, so there's nothing left to rebind. _closed is still set before the claim, so a caller that loses it still can't issue commands.

test_concurrent_close_across_event_loops_releases_client_once covers this and fails against 13ebaf4 with assert 2 == 1. The same-loop gather() test (which fails against 9b932a7) is kept, since the two paths reach the guard differently.

47 tests pass; ruff, mypy and pyright clean.

… event loop

The previous fix rebound the asyncio.Lock when close() ran on a different
loop, which defeated the purpose: two loops (or threads) closing the same
session would each install a fresh unlocked lock and both enter the release,
so the owned client was released twice despite the docstring promising once.

Replaced with a claim/finish pair guarded by the existing threading.Lock,
which serializes callers regardless of which loop they run on - the same
reason _init_state uses a threading.Lock rather than an asyncio.Lock. The
claim only guards flag flips, so no async coordination is needed. Release
failures and cancellation free the claim without marking the client
released, preserving the retry behaviour.

test_concurrent_close_across_event_loops_releases_client_once closes from
two threads with their own loops and fails against the previous commit with
assert 2 == 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e02c68e4a4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return
# Mark terminal before releasing, so a concurrent caller that returns
# early below still cannot issue commands against a closing client.
self._closed = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document MongoDB terminal close behavior

Because this change makes an owned MongoDBSession terminal on the first close(), the live MongoDB docs are now incomplete: docs/sessions/index.md:451 still only says from_uri(...) closes the client and injected-client close() is a no-op, while the Redis/Dapr sections tell users that post-close operations raise and repeated/concurrent closes are safe. Please update the English session docs alongside this SDK behavior change so MongoDB users do not learn a different lifecycle from the published docs.

AGENTS.md reference: AGENTS.md:L86-L86

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 42c77db. The Redis and Dapr bullets both already state this and the MongoDB one didn't, so the docs were inconsistent as well as incomplete:

docs/sessions/index.md:367  (Redis)  "the session is terminal and subsequent session operations raise RuntimeError; repeated or concurrent close() calls are safe"
docs/sessions/index.md:417  (Dapr)   "makes an owned-client session terminal; subsequent session operations raise RuntimeError, while repeated or concurrent close() calls are safe"
docs/sessions/index.md:451  (Mongo)  -- only ownership and the injected-client no-op

The MongoDB bullet now covers the same three points in the same order: owned-client sessions are terminal after close() with operations raising RuntimeError, repeated/concurrent closes are safe and release the client exactly once, and with an injected client close() is a no-op leaving both lifecycle and session usability with the caller.

Only the English docs are edited — the translations are generated, so I've left them to make translate_docs.

return
# Mark terminal before releasing, so a concurrent caller that returns
# early below still cannot issue commands against a closing client.
self._closed = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document MongoDB terminal close behavior

Because this change makes an owned MongoDBSession terminal on the first close(), the live MongoDB docs are now incomplete: docs/sessions/index.md:451 still only says from_uri(...) closes the client and injected-client close() is a no-op, while the Redis/Dapr sections tell users that post-close operations raise and repeated/concurrent closes are safe. Please update the English session docs alongside this SDK behavior change so MongoDB users do not learn a different lifecycle from the published docs.

Useful? React with 👍 / 👎.

Comment on lines +439 to +440
if not self._claim_client_release():
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Await in-flight close attempts before returning

When an owned-client close() is already awaiting AsyncMongoClient.close() and that in-flight release later fails or is cancelled, this fast path lets every other concurrent await close() return successfully without waiting for cleanup or observing the failure. Fresh evidence after the earlier fixes is that non-claiming callers now take this immediate return, so a shutdown path can finish with _client_released still false and the Mongo client still open until some later close attempt happens to run.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 42c77db — this one was a genuine weakening versus the sibling backends, and I'd missed why.

In RedisSession/DaprSession the losing caller blocks on the asyncio.Lock and then re-tests _client_released, so it retries a failed release. My claim-based version returned immediately instead, which meant a caller could get a clean return from a release that raised. Reproduced with two concurrent closes where the release fails:

                       before 42c77db          after
close() #1 (claims)    ConnectionError         ConnectionError
close() #2 (loses)     returned None  <-- bug  ConnectionError
client released        no                      no
later close()          retries                 retries

Losing callers now await the in-flight attempt:

claimed, pending = self._claim_client_release()
if not claimed:
    if pending is not None:
        await asyncio.wrap_future(pending)
    return

The attempt is a concurrent.futures.Future, not an asyncio one, so it can be awaited from any loop via asyncio.wrap_future — keeping the cross-loop property from e02c68e rather than trading it back for this fix. _finish_client_release publishes the exception to every waiter and leaves _client_released false, so a later close() still retries.

test_concurrent_close_observes_a_failed_release pins it: both gather() results must be the ConnectionError, the client must still be open, and a third close() must succeed. It fails against e02c68e.

48 tests pass; ruff, mypy and pyright clean; full suite unchanged against the merge base (59 vs 59, the delta being the known flaky guardrail/tracing tests).

…ycle

Two follow-ups from review:

- A caller that lost the release claim returned immediately, so if the
  in-flight release then failed or was cancelled, that caller reported
  success while the client stayed open. Losing callers now await the
  in-flight attempt and observe its failure, matching Redis/Dapr where
  losers wait on the lock. The attempt is a concurrent.futures.Future
  awaited via asyncio.wrap_future, so it still works across event loops -
  an asyncio future would bind the session to one loop.
- docs/sessions/index.md documented only that from_uri owns the client and
  that injected-client close() is a no-op. The Redis and Dapr sections
  already state the terminal behaviour, so the MongoDB bullet now says the
  same: post-close operations raise, repeated and concurrent closes are
  safe and release the client exactly once, and an injected client leaves
  both lifecycle and session usability with the caller.

test_concurrent_close_observes_a_failed_release fails against the previous
commit, where one of two concurrent callers returned successfully from a
release that raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42c77dbf77

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +463 to +466
if pending is not None:
# Await the in-flight release rather than returning early, so a
# failed cleanup surfaces here too instead of looking successful.
await asyncio.wrap_future(pending)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Shield shared close future from waiter cancellation

When one owned-client close() is in flight, a second caller that is cancelled while awaiting asyncio.wrap_future(pending) cancels the shared concurrent.futures.Future; the releasing caller then hits InvalidStateError in _finish_client_release() when it tries to publish the successful/failed cleanup result. This can happen in shutdown paths that wrap concurrent closes in timeouts or cancel sibling tasks, and it makes the primary close report a spurious error even after the Mongo client was released; wrap the pending future in a cancellation shield or otherwise prevent waiter cancellation from cancelling the shared release attempt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 65466c6.

asyncio.wrap_future forwards cancellation to the wrapped concurrent.futures.Future, so a cancelled waiter destroyed the attempt the releasing caller still needed. Reproduced with a slow release, a second close() task cancelled mid-await:

Before (42c77dbf):
   RELEASER BROKE: InvalidStateError CANCELLED: <Future state=cancelled>
After (65466c62):
   releaser completed OK; client closed = True

Waiters now go through a helper that shields the wrapper, so cancellation stops at the waiter and never reaches the shared attempt:

    @staticmethod
    async def _await_client_release(pending: Future[None]) -> None:
        wrapper = asyncio.wrap_future(pending)
        try:
            await asyncio.shield(wrapper)
        except asyncio.CancelledError:
            if not wrapper.done():
                wrapper.add_done_callback(lambda f: f.cancelled() or f.exception())
            raise

The done callback is there for the second half of the problem: an abandoned wrapper left holding an exception (the failed-release path from the previous review) would otherwise emit "exception was never retrieved" at GC time, so its outcome is consumed on the way out.

Added test_cancelled_waiter_does_not_break_the_in_flight_release, which asserts the releaser still completes, the client is released, and the session remains terminal afterwards. It fails against 42c77dbf with exactly the InvalidStateError: CANCELLED above and passes with the fix. 49 passed in the file; ruff, ruff format, mypy and pyright clean on both changed files.

A caller waiting on another close()'s in-flight release awaited the shared
concurrent.futures.Future directly through asyncio.wrap_future, which forwards
cancellation to the wrapped future. Cancelling the waiter therefore cancelled
the shared attempt, and the releasing caller crashed with InvalidStateError
when it tried to publish its outcome.

Shield the wrapper so a cancelled waiter cannot abort a release it does not
own, and discard the abandoned wrapper's outcome so a failed release does not
log an unretrieved exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65466c62ac

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +439 to +440
if not wrapper.done():
wrapper.add_done_callback(lambda f: f.cancelled() or f.exception())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consume completed close failures on cancellation

After the shielding fix, a waiter that is cancelled at the same time the shared close attempt fails can enter this CancelledError handler with wrapper.done() already true; this branch then skips both the callback and wrapper.exception(), so asyncio logs the Mongo close exception as “Future exception was never retrieved” despite the docstring promising to discard abandoned outcomes. This shows up in shutdown or timeout paths that cancel a secondary owned-client close() while the release is failing; consume the exception when the wrapper is already done too.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated this one carefully and the mechanism is not quite as described, but it pointed at a real leak, so the fix is in 73f88d5a.

The wrapper.done() branch is not itself a leak: asyncio.shield's own _inner_done_callback consumes the inner exception when the outer future is already cancelled.

    def _inner_done_callback(inner):
        if outer.cancelled():
            if not inner.cancelled():
                # Mark inner's result as retrieved.
                inner.exception()
            return

What decides the outcome is the ordering, because _outer_done_callback removes that inner callback once the outer is cancelled. Measured both interleavings with the callback stripped out:

cancel delivered, then release fails   -> logged=['Future exception was never retrieved']
release fails, then cancel delivered   -> logged=[]

So the leak lives in the not done() case that my guard was already covering — but the guard was pointless rather than protective, and reasoning about which of two callbacks wins a race is not something worth keeping in the code. The callback is now added unconditionally: asyncio invokes it immediately for an already-completed future, and re-reading a retrieved outcome is harmless.

test_cancelled_waiter_consumes_a_failed_release is parametrized over both orderings and asserts the loop exception handler stays silent. The cancel_first case fails when the callback is removed and passes with it; fail_first passes either way, which is exactly the asymmetry above.

Marking this useful — the conclusion about wrapper.done() didn't hold up, but it sent me to the shield internals, and the simplification is a genuine improvement.

Comment on lines +479 to +480
self._closed = True
claimed, pending = self._claim_client_release()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate in-flight operations before closing the client

When an owned-client close() races with a session operation that already passed _check_not_closed() (for example get_items() after _ensure_indexes() or add_items() while building the payload), this marks the session terminal and proceeds to close the AsyncMongoClient without waiting for that in-flight operation. That operation can then resume and issue its MongoDB command against a client that has already been closed, so shutdown paths can still get PyMongo closed-client failures or partial writes instead of the new RuntimeError contract; serialize close with active session methods or add an equivalent lifecycle gate around the actual Mongo commands.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 73f88d5. This one was a real gap in the port: all three already-fixed backends serialize close() against operations, and I had only carried over the flag.

redis_session.py     every method: async with self._lock: self._check_not_closed()   close(): async with self._lock
dapr_session.py      same shape (lock at :106, close at :441)
async_sqlite_session.py  _locked_connection() holds self._lock; close(): async with self._lock
mongodb_session.py   -- flag only, no serialization

Reproduced by suspending the collection command after the guard had already passed, then closing underneath it:

Before (65466c62):  command ran against a CLOSED client: [True]
After  (73f88d5a):  command ran against a CLOSED client: [False]

I did not copy the lock, though — asyncio.Lock is exactly what this class cannot use, for the reason documented on _init_state and raised in your earlier review: it binds to the loop that first acquires it, and this session is documented as usable from more than one loop. Holding one across every operation would also newly serialize concurrent reads and writes that overlap freely today.

Instead operations register on a counter guarded by the existing threading.Lock, and close() drains it after setting _closed (so nothing new can enter) and before claiming the release:

    @contextlib.contextmanager
    def _operation(self) -> Iterator[None]:
        with self._init_guard:
            self._check_not_closed()
            self._active_operations += 1
        try:
            yield
        finally:
            ...  # signal _idle when the count reaches zero

The drain awaits a concurrent.futures.Future through the same shielded helper, so it works from any loop and a cancelled close() cannot abort a drain other callers are waiting on. add_items([]) still checks the flag directly, since it returns before doing any work.

test_close_waits_for_an_in_flight_operation asserts the command sees an open client, the client is still released, and the session is terminal afterwards. It fails against 65466c62 with At index 0 diff: True != False.


On the sibling finding about consuming completed close failures — that one I could not reproduce as described, but investigating it did surface a real bug next door, so the fix is in.

asyncio.shield's _inner_done_callback already consumes the inner exception when the outer future is cancelled, so the wrapper.done() path is not itself a leak. What actually matters is the ordering, because _outer_done_callback removes that inner callback on cancel:

cancel delivered, then release fails   -> logged=['Future exception was never retrieved']
release fails, then cancel delivered   -> logged=[]

So the leak is in the not done() case, and my if not wrapper.done() guard was the wrong shape for a different reason than stated — it was dead weight rather than protection. The callback is now added unconditionally (asyncio invokes it immediately for an already-completed future, and re-reading a retrieved outcome is harmless), and test_cancelled_waiter_consumes_a_failed_release is parametrized over both interleavings. The cancel_first case fails without the callback and passes with it.

52 passed in the file, 290 across tests/extensions/memory; ruff, ruff format, mypy and pyright clean on both changed files.

…ient

An operation that had already passed the closed check could resume after
close() released the client and issue its command against a closed
AsyncMongoClient, producing PyMongo errors or partial writes instead of the
documented RuntimeError. The other backends avoid this by holding a lock across
both the operation and close(); an asyncio.Lock is unusable here because this
session is documented as usable from more than one event loop.

Track active operations under the threading.Lock this class already uses and
have close() drain them before releasing, so concurrent reads and writes still
overlap and only close() waits. Also consume an abandoned waiter's outcome
unconditionally: asyncio.shield only marks the wrapped exception retrieved when
the failure lands before the cancellation is delivered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@PranavMishra28 PranavMishra28 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The threading design reads well. concurrent.futures.Future plus threading.Lock rather than asyncio primitives is the right call for a class documented as cross-loop, and _operation() closes the check-then-act window properly.

One case worth handling: cancelling close() propagates a cancel into tasks that were never cancelled. At mongodb_session.py:546-553 the except BaseException catches asyncio.CancelledError, and _finish_client_release then reaches attempt.set_exception (:514) with it, publishing a CancelledError onto the shared future. Waiters in _await_client_release (:479-497) sit on await asyncio.shield(wrapper) over asyncio.wrap_future, and asyncio's _convert_future_exc maps a concurrent.futures.CancelledError back to asyncio.CancelledError, so line 495 raises it in a task nobody cancelled and the handler at :496-497 re-raises.

I reproduced the asyncio half standalone, mirroring _await_client_release exactly:

waiter task done      : True
waiter task CANCELLED : True
waiter exception      : t.exception() itself raised CancelledError

So the waiter's task ends up marked cancelled rather than failed. Its own finally still runs, but the real error is invisible to the caller and anything sequenced after that await is skipped, which for a session close is usually the rest of a teardown path.

Would translating it be preferable to propagating, something like attempt.set_exception(RuntimeError("MongoDB client close was cancelled; retry close()")), or leaving _release_attempt = None so waiters retry the release instead of inheriting the cancel? A test where close() is cancelled mid-client.close() while a second close() waits, asserting the waiter's task is not cancelled, would pin whichever you pick.

Cancelling the `close()` that owns the release published the resulting
`CancelledError` onto the shared attempt future. `asyncio` maps a
`concurrent.futures.CancelledError` back to its own on the way out of
`wrap_future`, so every waiter's task ended up marked *cancelled* rather than
*failed*: the real cause was invisible to the caller and anything the teardown
path sequenced after the await was silently skipped.

Publish a `RuntimeError` instead. Only the caller that was actually cancelled
sees `CancelledError`, which `close()` re-raises itself. The client is still
left unreleased, so a later `close()` retries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LHMQ878

LHMQ878 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — you're right, and the reproduction holds end-to-end in the session, not just in the standalone asyncio mirror. Fixed in c05e8b88.

Probe: releaser blocks inside client.close(), a second close() becomes a waiter on the shared attempt, then the releaser is cancelled.

                     before                                          after
closer  cancelled  : True                                            True
waiter  done       : True                                            True
waiter  cancelled  : True                                            False
waiter  exception  : t.exception() itself raised CancelledError       RuntimeError('MongoDB client
                                                                       release was cancelled; call
                                                                       close() again to retry')
client_released    : False                                           False
release_attempt    : None                                            None

I went with your first suggestion — translate rather than propagate:

elif isinstance(error, asyncio.CancelledError):
    attempt.set_exception(
        RuntimeError("MongoDB client release was cancelled; call close() again to retry")
    )

I preferred it over leaving _release_attempt = None for waiters to retry, for two reasons. Silently promoting a waiter to releaser would have it call client.close() a second time on a client whose first close was interrupted mid-flight, which is the sequence this PR set out to make impossible. And a waiter that returns as if it retried successfully hides that the cleanup didn't finish — the whole reason close() awaits the in-flight attempt rather than returning early. The RuntimeError message names the retry explicitly so the caller decides.

Cancellation semantics after the fix: only the caller that was actually cancelled sees CancelledError (close() re-raises it from its own except BaseException); waiters see a failure they didn't cause but can act on. _client_released stays False, so a later close() still retries the unfinished cleanup — the probe confirms the retry closes the client.

Regression: test_cancelling_the_release_owner_fails_waiters_without_cancelling_them. It asserts all four properties — releaser raises CancelledError, waiter raises RuntimeError, not waiter.cancelled(), and the retry closes the client. A/B at 73f88d5a it fails with CancelledError on the waiter; with the fix the file is 53 passed.

Full suite vs the branch base (73f88d5a), JUnit test-ID sets: 69 → 70. The one addition is test_trace_processor::test_backend_span_exporter_deadline_stops_during_5xx_retry_backoff, which is a wall-clock flake and not related to this change — it sets a 10 ms deadline and asserts time.sleep was called, so under a loaded run the deadline expires before the backoff is reached. It passes 5/5 in isolation on this exact tree, and the mongo file is 53/53. ruff check / ruff format --check clean; mypy reports only the two errors already present at the branch base (sandbox/util/tar_utils.py:161 and mongodb_session.py:159, the latter a pymongo stub issue this PR doesn't touch).

@seratch

seratch commented Aug 4, 2026

Copy link
Copy Markdown
Member

Thanks for trying to fix this. The issue is going to be resolved by #4176

@seratch seratch closed this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants