Skip to content

fix(memory): enforce closed state in AsyncSQLiteSession - #4109

Merged
seratch merged 1 commit into
openai:mainfrom
chinmayv095:fix/async-sqlite-closed-state
Aug 2, 2026
Merged

fix(memory): enforce closed state in AsyncSQLiteSession#4109
seratch merged 1 commit into
openai:mainfrom
chinmayv095:fix/async-sqlite-closed-state

Conversation

@chinmayv095

Copy link
Copy Markdown
Contributor

Summary

#4035 fixed RedisSession and DaprSession and stated the contract in the process:

SQLiteSession makes close() terminal: it sets _closed under the session lock, and every operation re-checks that flag inside the same lock.

AsyncSQLiteSession is the async sibling of the session named there as the reference, it owns the connection it opens, and it never got that treatment. It has no closed state at all:

async def close(self) -> None:
    """Close the database connection."""
    if self._connection is None:
        return
    async with self._lock:
        await self._connection.close()
        self._connection = None

close() drops the connection but records nothing, and _get_connection() recreates a connection whenever self._connection is None. So a closed session quietly reopens the database on the next call:

session = AsyncSQLiteSession("s", db_path="sessions.db")
await session.add_items([{"role": "user", "content": "hi"}])
await session.close()

await session.get_items()      # returns history instead of raising
await session.add_items([...]) # silently writes to a reopened database
await session.pop_item()       # silently mutates
await session.clear_session()  # silently clears

All four succeed. This is the same failure #4035 describes for Redis ("later operations reconnect"), except SQLite reconnects all the way to a fresh file handle and a fresh WAL, so a caller that closed the session to release the file keeps writing to it.

Two more consequences of there being no flag:

  • close() before first use is not terminal at all. The early return fires while _connection is still None, so nothing is recorded and the next operation opens the database normally.
  • close() is not idempotent. Two concurrent calls both pass the is None check, then serialize on the lock; the second awaits self._connection.close() after the first set it to None and raises AttributeError: 'NoneType' object has no attribute 'close'. The new idempotency test reproduces this on main.

Fix: mirror SQLiteSession and #4035 exactly. close() takes the lock, marks the session terminal, then releases the connection if one was opened. _locked_connection() re-checks the flag inside the lock, which covers get_items, add_items, pop_item and clear_session through the one path they all share. add_items also checks before its empty-list fast path, because add_items([]) would otherwise slip past — the same gap #4035 closed for Redis.

The error message follows the existing convention: RuntimeError("AsyncSQLiteSession is closed").

Scope — MongoDBSession has the same gap and is deliberately not in this PR. Its close() also records no state, so a session created via from_uri (the path that sets _owns_client = True) stays usable after close. I left it out because it is a different mechanism, not the same edit: MongoDBSession has no per-session asyncio.Lock to hang the flag on — it deliberately uses only a threading.Lock, and only for the class-level init registry — so making close terminal there is a design choice about what to guard rather than a copy of the SQLite idiom. Happy to send that as a follow-up in whichever shape you prefer.

I also did not add an ownership branch here. Unlike Redis and Dapr, AsyncSQLiteSession has no injected-connection constructor — it always opens its own connection from db_path — so the _owns_client distinction from #4035 has nothing to select on and the session is always terminal on close.

Test plan

Four tests added to tests/extensions/memory/test_async_sqlite_session.py, mirroring the ones #4035 added for Redis:

Test Asserts
test_async_sqlite_session_closed_operations_raise_runtime_error get_items / add_items / pop_item / clear_session all raise after close()
test_async_sqlite_session_closed_rejects_empty_add_items add_items([]) does not bypass the check via the empty-list fast path
test_async_sqlite_session_close_before_use_is_terminal close() before the connection is opened still makes the session terminal
test_async_sqlite_session_close_is_idempotent repeated and concurrent close() calls are safe no-ops

All four were run against unpatched code first and fail there4 failed, 19 deselected, with close_is_idempotent failing on the AttributeError described above rather than on a missing raise. After the fix the file is 23 passed (19 existing + 4 new).

Full stack from the repo root:

  • make format — 844 files left unchanged.
  • make lint — All checks passed.
  • make typecheck — pyright 0 errors, 0 warnings, 0 informations; mypy Success: no issues found in 835 source files. Both run as UV_PROJECT_ENVIRONMENT=.venv_313 UV_PYTHON=3.13, since on Python 3.11 the repo has a pre-existing unrelated failure in tests/test_run_step_execution.py (asyncio.eager_task_factory is 3.12+).
  • make tests6084 passed, 8 skipped parallel plus 38 passed, 5 skipped serial. Worth flagging honestly: an earlier run of the same suite showed one failure in tests/sandbox/test_runtime.py::test_remote_realpath_guard_fails_closed_on_symlink_cycle. It passes in isolation both on main and with this change, and the full suite is clean on re-run, so it looks flaky under parallelism and unrelated to this diff, which touches only AsyncSQLiteSession.

Issue number

None — found by reading the sessions after #4035, so the report is in the summary above.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh — I ran the four stages it wraps individually instead (results above). The combined script did not finish on this machine: it pins typecheck to the default 3.11 environment and runs mypy and pyright in parallel with the test suite, which I had to kill on a local time budget. That is a local environment limit, not a failing check — every stage passes when run on its own.
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

@seratch seratch added this to the 0.19.x milestone Aug 2, 2026
@seratch
seratch enabled auto-merge (squash) August 2, 2026 21:08
@seratch
seratch merged commit c06e1e3 into openai:main Aug 2, 2026
9 checks passed
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.

2 participants