-
Notifications
You must be signed in to change notification settings - Fork 4.4k
fix(memory): prevent close() race and enforce closed state in AsyncSQLiteSession #3984
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| import tempfile | ||
| from collections.abc import Sequence | ||
|
|
@@ -12,6 +13,7 @@ | |
| import pytest | ||
|
|
||
| pytest.importorskip("aiosqlite") # Skip tests if aiosqlite is not installed | ||
| import aiosqlite | ||
|
|
||
| from agents import Agent, Runner, TResponseInputItem | ||
| from agents.extensions.memory import AsyncSQLiteSession | ||
|
|
@@ -409,3 +411,113 @@ async def test_async_sqlite_session_pop_item_same_timestamp_returns_latest(): | |
| assert _item_ids(remaining) == ["rs_pop_same_ts"] | ||
|
|
||
| await session.close() | ||
|
|
||
|
|
||
| async def test_async_sqlite_session_concurrent_close(): | ||
| """Test that concurrent and repeated calls to close() are safe and idempotent.""" | ||
| with tempfile.TemporaryDirectory() as temp_dir: | ||
| db_path = Path(temp_dir) / "async_close_race.db" | ||
| session = AsyncSQLiteSession("close_race_test", db_path) | ||
| await session.add_items([{"role": "user", "content": "hello"}]) | ||
|
|
||
| # Multiple concurrent close calls should succeed without raising AttributeError | ||
| await asyncio.gather(session.close(), session.close(), session.close()) | ||
|
|
||
| # Additional sequential close call should also be a safe no-op | ||
|
Comment on lines
+423
to
+426
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
These two newly added comments are complete sentences but omit terminal periods, contrary to the repository's explicit comment-formatting requirement; append periods to both comments. AGENTS.md reference: AGENTS.md:L201-L204 Useful? React with 👍 / 👎. |
||
| await session.close() | ||
|
|
||
|
|
||
| async def test_async_sqlite_session_closed_operations_raise_runtime_error(): | ||
| """Test that operations on a closed AsyncSQLiteSession raise RuntimeError.""" | ||
| with tempfile.TemporaryDirectory() as temp_dir: | ||
| db_path = Path(temp_dir) / "async_closed_ops.db" | ||
| session = AsyncSQLiteSession("closed_ops_test", db_path) | ||
| await session.add_items([{"role": "user", "content": "hello"}]) | ||
| await session.close() | ||
|
|
||
| with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): | ||
| await session.get_items() | ||
|
|
||
| with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): | ||
| await session.add_items([{"role": "user", "content": "more"}]) | ||
|
|
||
| with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): | ||
| await session.pop_item() | ||
|
|
||
| with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): | ||
| await session.clear_session() | ||
|
|
||
|
|
||
| async def test_async_sqlite_session_close_rejects_operation_waiting_on_lock( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| ): | ||
| """An operation queued behind close() must fail closed without reconnecting. | ||
|
|
||
| Controlled interleaving: close() acquires the session lock first, an | ||
| operation starts while close is paused inside the lock, then close | ||
| completes. The waiter must raise RuntimeError and must not recreate the | ||
| connection. | ||
| """ | ||
| with tempfile.TemporaryDirectory() as temp_dir: | ||
| db_path = Path(temp_dir) / "async_close_interleave.db" | ||
| session = AsyncSQLiteSession("close_interleave_test", db_path) | ||
| await session.add_items([{"role": "user", "content": "hello"}]) | ||
|
|
||
| assert session._connection is not None | ||
| original_conn_close = session._connection.close | ||
| original_acquire = session._lock.acquire | ||
| close_started = asyncio.Event() | ||
| release_close = asyncio.Event() | ||
| operation_waiting = asyncio.Event() | ||
| connect_calls = 0 | ||
| real_connect = aiosqlite.connect | ||
|
|
||
| async def paused_close(*args: Any, **kwargs: Any) -> None: | ||
| close_started.set() | ||
| await release_close.wait() | ||
| await original_conn_close(*args, **kwargs) | ||
|
|
||
| async def acquire_and_signal(*args: Any, **kwargs: Any) -> bool: | ||
| # Signal before waiting whenever the lock is already held by close(). | ||
| if session._lock.locked(): | ||
| operation_waiting.set() | ||
| return await original_acquire(*args, **kwargs) | ||
|
|
||
| async def tracking_connect(*args: Any, **kwargs: Any) -> Any: | ||
| nonlocal connect_calls | ||
| connect_calls += 1 | ||
| return await real_connect(*args, **kwargs) | ||
|
|
||
| session._connection.close = paused_close # type: ignore[method-assign] | ||
| session._lock.acquire = acquire_and_signal # type: ignore[method-assign] | ||
| monkeypatch.setattr(aiosqlite, "connect", tracking_connect) | ||
|
|
||
| close_task = asyncio.create_task(session.close()) | ||
| get_task: asyncio.Task[Any] | None = None | ||
| try: | ||
| await asyncio.wait_for(close_started.wait(), timeout=1) | ||
| assert session._lock.locked() | ||
| assert session._closed is True | ||
|
|
||
| get_task = asyncio.create_task(session.get_items()) | ||
| await asyncio.wait_for(operation_waiting.wait(), timeout=1) | ||
| assert not get_task.done() | ||
|
|
||
| release_close.set() | ||
| await asyncio.wait_for(close_task, timeout=1) | ||
|
|
||
| with pytest.raises(RuntimeError, match="AsyncSQLiteSession is closed"): | ||
| await asyncio.wait_for(get_task, timeout=1) | ||
|
|
||
| assert session._closed is True | ||
| assert session._connection is None | ||
| assert connect_calls == 0 | ||
| finally: | ||
| release_close.set() | ||
| tasks = [task for task in (close_task, get_task) if task is not None] | ||
| if tasks: | ||
| _done, pending = await asyncio.wait(tasks, timeout=1) | ||
| for task in pending: | ||
| task.cancel() | ||
| if pending: | ||
| await asyncio.gather(*pending, return_exceptions=True) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a caller invokes
add_items([])afterclose(), the method returns through its empty-list fast path before reaching this new_get_connection()guard, so a closed session silently accepts that public operation while reads and non-empty writes raiseRuntimeError. Check the closed state before the empty-list return and cover this unsupported post-close case.AGENTS.md reference: AGENTS.md:L62-L64
Useful? React with 👍 / 👎.