Files
aiturk-hermes-ide/tests/test_hermes_state_conn_lock_audit.py
T

110 lines
4.1 KiB
Python

"""Lock audit for every call on the shared writer connection (#99349).
``SessionDB._conn`` is opened with ``check_same_thread=False`` and shared
across threads (``AsyncSessionDB`` offloads every method via
``asyncio.to_thread``), so every *call* on it must hold ``self._lock``.
A lock-free ``self._conn.execute(...)`` — even a pure SELECT — can run
concurrently with ``close()`` deallocating the connection's pysqlite
statement cache, which segfaults the interpreter (observed in the field:
``_PyDict_GetItem_KnownHash`` via ``bounded_lru_cache_wrapper`` on one
thread while ``pysqlite_connection_close`` tears the cache down on
another). "Read-only" is not an exemption: the race is on the connection
object, not the database file.
Reads that must not contend on the writer lock go through
``SessionDB._read_ctx()`` instead — it borrows a pooled read connection
(exclusively checked out for the block) and its non-WAL fallback is the
writer connection *under* ``self._lock``.
This is an AST audit in the spirit of
``tests/gateway/test_async_session_db.py``: it fails on the next
``self._conn.<method>(...)`` call site added outside ``with self._lock:``.
"""
import ast
from pathlib import Path
# Functions allowed to touch self._conn without the lock: construction-time
# code that runs before the instance is ever shared with another thread.
_ALLOWED_UNLOCKED_FNS = frozenset({
"__init__",
"_connect_and_init",
"_connect_and_init_with_lock_patience",
})
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _nearest_enclosing_fn(tree: ast.AST) -> dict:
"""Map id(node) -> name of the nearest enclosing function ("<module>"
at module level). Nested defs override their parents."""
enclosing: dict = {}
def visit(node: ast.AST, current: str) -> None:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
current = node.name
enclosing[id(node)] = current
for child in ast.iter_child_nodes(node):
visit(child, current)
visit(tree, "<module>")
return enclosing
def _unlocked_conn_calls(tree: ast.AST):
"""Return (lineno, enclosing_fn, method) for each self._conn.<m>(...)
call that is not lexically inside a ``with self._lock:`` block."""
locked_ids = set()
for node in ast.walk(tree):
if isinstance(node, (ast.With, ast.AsyncWith)):
for item in node.items:
ctx = item.context_expr
if (
isinstance(ctx, ast.Attribute)
and ctx.attr == "_lock"
and isinstance(ctx.value, ast.Name)
and ctx.value.id == "self"
):
locked_ids.update(id(child) for child in ast.walk(node))
enclosing = _nearest_enclosing_fn(tree)
offending = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not (
isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Attribute)
and func.value.attr == "_conn"
and isinstance(func.value.value, ast.Name)
and func.value.value.id == "self"
):
continue
if id(node) in locked_ids:
continue
fn = enclosing.get(id(node), "<module>")
if fn in _ALLOWED_UNLOCKED_FNS:
continue
offending.append((node.lineno, fn, func.attr))
return offending
def test_every_conn_call_outside_construction_holds_the_lock():
src = (_repo_root() / "hermes_state.py").read_text(encoding="utf-8")
tree = ast.parse(src)
offending = _unlocked_conn_calls(tree)
assert not offending, (
"self._conn.<method>() called without `with self._lock:` — this "
"races SessionDB.close() inside pysqlite's statement cache and "
"segfaults the process (#99349). Use `with self._read_ctx() as "
"conn:` for reads, or take self._lock. Sites: "
+ ", ".join(
f"line {lineno} in {fn}(): self._conn.{meth}(...)"
for lineno, fn, meth in offending
)
)