Skip to content

Commit 931252c

Browse files
dsarnoclaude
andauthored
[Audit v2 #350] Drop SessionRegistry threading.RLock — single asyncio loop, YAGNI (#370)
* Drop SessionRegistry threading.RLock — single asyncio loop, YAGNI Closes #350 (Audit v2 finding #6). The RLock provided no asyncio-level mutual exclusion (it wasn't awaited) and the only callers run inside the single asyncio WS handler — single event loop, no thread crossings. It misled readers into assuming thread-safety the registry doesn't deliver. If a future caller ever runs from another thread, asyncio.Lock is the right answer for this async-first code, not threading.RLock — so the existing lock wasn't even the future-proof choice. Removed: - `from threading import RLock` - `self._lock = RLock()` - every `with self._lock:` block (10 sites) Renamed `_find_matching_session_locked` → `_find_matching_session` since the "_locked" suffix is now misleading. Updated the wait_for_session docstring's "registry lock" reference. Added a class docstring on SessionRegistry making the asyncio-only invariant explicit and naming asyncio.Lock as the future-proof choice should a thread crossing ever appear. Tests: TestSessionRegistryNoThreadingLock pins the deletion against accidental re-introduction (no `_lock` attribute, no `threading` / `RLock` imported in the module). All 28 test_session_registry.py tests plus the full 769-test pytest suite pass. Live smoke: GDScript test_run via MCP against a real Godot 4.6.2 editor — 1157 passed, 0 failed (registry exercised end-to-end via session_manage list, editor_state, and every test that round-trips through the WS handler). Refs umbrella #343. https://claude.ai/code/session_optimistic-tesla-84obZ * Trim docstrings + drop narrating comment per /simplify - SessionRegistry class docstring: trim 5 lines re-litigating the removed RLock down to 3 lines stating the asyncio-only invariant and the forward-looking choice (asyncio.Lock). - TestSessionRegistryNoThreadingLock: collapse the 7-line docstring into one line — test names already convey the invariant. - Drop "# Notify any waiters blocked on wait_for_session()" — the loop body, the to_notify name, and future.set_result(session) already say this. Per CLAUDE.md, avoid comments narrating WHAT. https://claude.ai/code/session_optimistic-tesla-84obZ * Drop prescriptive cross-thread advice from docstrings (Copilot review) asyncio.Lock is a cooperative-scheduling primitive within a single event loop — it does NOT make cross-thread access safe. Recommending it as the future-proof choice for a hypothetical thread crossing was misleading and would steer the next maintainer toward the wrong fix. Drop the "use asyncio.Lock" prescription entirely. Class docstring now states only the current invariant (single asyncio loop, no locking). Test docstring trimmed to neutral framing. If a thread crossing is ever genuinely introduced, the right answer is to marshal through the event loop (asyncio.run_coroutine_threadsafe / loop.call_soon_threadsafe), not to add a lock — but that decision belongs to whoever has the actual cross-thread requirement, not to this docstring. https://claude.ai/code/session_optimistic-tesla-84obZ * Cover the done-waiter skip branch in register() (Codecov patch) The if future.done(): continue branch in register() was uncovered; Codecov flagged it as the one missing line in the patch. Added a regression test that pre-completes a future, sticks it in _session_waiters, and verifies the next register() doesn't re-resolve it. This pins the "don't double-resolve out-of-band-completed waiters" invariant that was previously untested. https://claude.ai/code/session_optimistic-tesla-84obZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aac5483 commit 931252c

2 files changed

Lines changed: 80 additions & 61 deletions

File tree

src/godot_ai/sessions/registry.py

Lines changed: 50 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from dataclasses import dataclass, field
88
from datetime import datetime, timezone
99
from functools import cached_property
10-
from threading import RLock
1110

1211
from godot_ai import __version__ as _SERVER_VERSION
1312

@@ -75,37 +74,38 @@ def to_dict(self) -> dict:
7574

7675

7776
class SessionRegistry:
78-
"""Tracks all connected Godot editor sessions."""
77+
"""Tracks all connected Godot editor sessions.
78+
79+
Callers run on the single asyncio event loop driving the WS transport,
80+
so state is mutated without locking.
81+
"""
7982

8083
def __init__(self):
8184
self._sessions: dict[str, Session] = {}
8285
self._active_session_id: str | None = None
8386
self._session_waiters: list[
8487
tuple[asyncio.Future[Session], str | None, frozenset[str], str | None]
8588
] = []
86-
self._lock = RLock()
8789

8890
def register(self, session: Session) -> None:
8991
to_notify: list[asyncio.Future[Session]] = []
90-
with self._lock:
91-
self._sessions[session.session_id] = session
92-
if self._active_session_id is None:
93-
self._active_session_id = session.session_id
94-
# Notify any waiters blocked on wait_for_session()
95-
remaining = []
96-
for future, exclude_id, known_ids, project_path in self._session_waiters:
97-
if future.done():
98-
continue
99-
if not self._matches_wait_criteria(
100-
session,
101-
exclude_id=exclude_id,
102-
known_ids=known_ids,
103-
project_path=project_path,
104-
):
105-
remaining.append((future, exclude_id, known_ids, project_path))
106-
continue
107-
to_notify.append(future)
108-
self._session_waiters = remaining
92+
self._sessions[session.session_id] = session
93+
if self._active_session_id is None:
94+
self._active_session_id = session.session_id
95+
remaining = []
96+
for future, exclude_id, known_ids, project_path in self._session_waiters:
97+
if future.done():
98+
continue
99+
if not self._matches_wait_criteria(
100+
session,
101+
exclude_id=exclude_id,
102+
known_ids=known_ids,
103+
project_path=project_path,
104+
):
105+
remaining.append((future, exclude_id, known_ids, project_path))
106+
continue
107+
to_notify.append(future)
108+
self._session_waiters = remaining
109109

110110
for future in to_notify:
111111
if not future.done():
@@ -119,41 +119,35 @@ def unregister(self, session_id: str) -> None:
119119
## "routing by registration order" bug. Clear active instead; the
120120
## next register() or an explicit session_activate will set it.
121121
should_log = False
122-
with self._lock:
123-
self._sessions.pop(session_id, None)
124-
if self._active_session_id == session_id:
125-
self._active_session_id = None
126-
should_log = True
122+
self._sessions.pop(session_id, None)
123+
if self._active_session_id == session_id:
124+
self._active_session_id = None
125+
should_log = True
127126
if should_log:
128127
logger.info(
129128
"Active session %s disconnected; no active session until next register/activate",
130129
session_id[:8],
131130
)
132131

133132
def get(self, session_id: str) -> Session | None:
134-
with self._lock:
135-
return self._sessions.get(session_id)
133+
return self._sessions.get(session_id)
136134

137135
def get_active(self) -> Session | None:
138-
with self._lock:
139-
if self._active_session_id:
140-
return self._sessions.get(self._active_session_id)
141-
return None
136+
if self._active_session_id:
137+
return self._sessions.get(self._active_session_id)
138+
return None
142139

143140
def set_active(self, session_id: str) -> None:
144-
with self._lock:
145-
if session_id not in self._sessions:
146-
raise KeyError(f"Session {session_id} not found")
147-
self._active_session_id = session_id
141+
if session_id not in self._sessions:
142+
raise KeyError(f"Session {session_id} not found")
143+
self._active_session_id = session_id
148144

149145
def list_all(self) -> list[Session]:
150-
with self._lock:
151-
return list(self._sessions.values())
146+
return list(self._sessions.values())
152147

153148
@property
154149
def active_session_id(self) -> str | None:
155-
with self._lock:
156-
return self._active_session_id
150+
return self._active_session_id
157151

158152
async def wait_for_session(
159153
self,
@@ -166,35 +160,31 @@ async def wait_for_session(
166160
"""Block until a new session registers (optionally excluding one ID).
167161
168162
If ``known_ids`` is provided, sessions registered after that snapshot but
169-
before this waiter is installed are returned under the same registry lock.
163+
before this waiter is installed are returned synchronously without yielding.
170164
Raises TimeoutError if no matching session appears within timeout.
171165
"""
172166
loop = asyncio.get_running_loop()
173-
with self._lock:
174-
known_ids_frozen = (
175-
frozenset(self._sessions) if known_ids is None else frozenset(known_ids)
176-
)
177-
existing = self._find_matching_session_locked(
178-
exclude_id=exclude_id,
179-
known_ids=known_ids_frozen,
180-
project_path=project_path,
181-
)
182-
if existing is not None:
183-
return existing
184-
future: asyncio.Future[Session] = loop.create_future()
185-
entry = (future, exclude_id, known_ids_frozen, project_path)
186-
self._session_waiters.append(entry)
167+
known_ids_frozen = frozenset(self._sessions) if known_ids is None else frozenset(known_ids)
168+
existing = self._find_matching_session(
169+
exclude_id=exclude_id,
170+
known_ids=known_ids_frozen,
171+
project_path=project_path,
172+
)
173+
if existing is not None:
174+
return existing
175+
future: asyncio.Future[Session] = loop.create_future()
176+
entry = (future, exclude_id, known_ids_frozen, project_path)
177+
self._session_waiters.append(entry)
187178
try:
188179
return await asyncio.wait_for(future, timeout=timeout)
189180
except asyncio.TimeoutError:
190181
raise TimeoutError("Timed out waiting for new session") from None
191182
finally:
192-
with self._lock:
193-
self._session_waiters = [w for w in self._session_waiters if w is not entry]
183+
self._session_waiters = [w for w in self._session_waiters if w is not entry]
194184
if not future.done():
195185
future.cancel()
196186

197-
def _find_matching_session_locked(
187+
def _find_matching_session(
198188
self,
199189
*,
200190
exclude_id: str | None,
@@ -228,5 +218,4 @@ def _matches_wait_criteria(
228218
return True
229219

230220
def __len__(self) -> int:
231-
with self._lock:
232-
return len(self._sessions)
221+
return len(self._sessions)

tests/unit/test_session_registry.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ def test_server_launch_mode_round_trips_through_to_dict(self):
124124
assert s.to_dict()["server_launch_mode"] == "dev_venv"
125125

126126

127+
class TestSessionRegistryNoThreadingLock:
128+
"""Guard against reintroducing a threading lock on the registry."""
129+
130+
def test_registry_has_no_lock_attribute(self):
131+
reg = SessionRegistry()
132+
assert not hasattr(reg, "_lock")
133+
134+
def test_registry_module_does_not_import_threading(self):
135+
import godot_ai.sessions.registry as registry_module
136+
137+
assert "threading" not in vars(registry_module)
138+
assert "RLock" not in vars(registry_module)
139+
140+
127141
class TestSessionMetadata:
128142
def test_name_derived_from_project_path(self):
129143
s = _make_session(project_path="/Users/me/projects/my_game/")
@@ -221,6 +235,22 @@ async def test_rechecks_registered_replacement_before_installing_waiter(self):
221235
assert result is replacement
222236
assert reg._session_waiters == []
223237

238+
async def test_register_skips_waiters_whose_futures_are_already_done(self):
239+
## A waiter whose future was resolved or cancelled out-of-band must
240+
## not be re-resolved on the next register(); its entry stays in
241+
## _session_waiters until its own finally-block evicts it. The
242+
## register loop's `if future.done(): continue` path covers this.
243+
reg = SessionRegistry()
244+
loop = asyncio.get_running_loop()
245+
cancelled_future: asyncio.Future[Session] = loop.create_future()
246+
cancelled_future.cancel()
247+
reg._session_waiters.append((cancelled_future, None, frozenset(), None))
248+
249+
reg.register(_make_session("a"))
250+
251+
assert cancelled_future.cancelled()
252+
assert len(reg) == 1
253+
224254
async def test_concurrent_registers_and_activate_keep_registry_consistent(self):
225255
reg = SessionRegistry()
226256
waiter_task = asyncio.create_task(reg.wait_for_session(exclude_id="a", timeout=1.0))

0 commit comments

Comments
 (0)