Skip to content

Commit 28414ad

Browse files
committed
fix: reject sync fingerprint calls from async contexts
FingerprintStore.check_sync() and add_sync() now raise RuntimeError if called from within a running event loop instead of silently bypassing the asyncio.Lock. Callers in async contexts must use the async check()/add() methods directly. Removes _check_unlocked() and _add_unlocked() private helpers that allowed lock-free access — these were a race condition vector.
1 parent 0712cc6 commit 28414ad

1 file changed

Lines changed: 24 additions & 62 deletions

File tree

airlock/semantic/fingerprint.py

Lines changed: 24 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -154,77 +154,39 @@ async def add(self, fingerprint: AnswerFingerprint) -> None:
154154
self._exact_hashes[fingerprint.exact_hash] = fingerprint
155155

156156
def check_sync(self, fingerprint: AnswerFingerprint) -> FingerprintMatch:
157-
"""Synchronous wrapper -- for use outside an async context only."""
158-
loop: asyncio.AbstractEventLoop | None = None
157+
"""Synchronous wrapper -- for use outside an async context only.
158+
159+
Raises ``RuntimeError`` if called from within a running event loop.
160+
Callers inside async contexts MUST use ``await check()`` instead.
161+
"""
159162
try:
160-
loop = asyncio.get_running_loop()
163+
asyncio.get_running_loop()
161164
except RuntimeError:
162-
pass
163-
164-
if loop is not None and loop.is_running():
165-
# We're inside a running event loop (e.g. pytest-asyncio).
166-
# The caller should use ``await check()`` instead. Fall back
167-
# to a direct (unlocked) check so sync tests still work.
168-
return self._check_unlocked(fingerprint)
165+
pass # No running loop — safe to use asyncio.run()
166+
else:
167+
raise RuntimeError(
168+
"check_sync() called from a running event loop. "
169+
"Use 'await store.check(fp)' instead."
170+
)
169171
return asyncio.run(self.check(fingerprint))
170172

171173
def add_sync(self, fingerprint: AnswerFingerprint) -> None:
172-
"""Synchronous wrapper -- for use outside an async context only."""
173-
loop: asyncio.AbstractEventLoop | None = None
174+
"""Synchronous wrapper -- for use outside an async context only.
175+
176+
Raises ``RuntimeError`` if called from within a running event loop.
177+
Callers inside async contexts MUST use ``await add()`` instead.
178+
"""
174179
try:
175-
loop = asyncio.get_running_loop()
180+
asyncio.get_running_loop()
176181
except RuntimeError:
177-
pass
178-
179-
if loop is not None and loop.is_running():
180-
self._add_unlocked(fingerprint)
181-
return
182+
pass # No running loop — safe to use asyncio.run()
183+
else:
184+
raise RuntimeError(
185+
"add_sync() called from a running event loop. "
186+
"Use 'await store.add(fp)' instead."
187+
)
182188
asyncio.run(self.add(fingerprint))
183189

184-
# ------------------------------------------------------------------
185-
# Internal helpers used by sync wrappers (no lock, no await)
186-
# ------------------------------------------------------------------
187-
188-
def _check_unlocked(self, fingerprint: AnswerFingerprint) -> FingerprintMatch:
189-
"""Lock-free check -- only safe when no concurrent access."""
190-
if fingerprint.exact_hash in self._exact_hashes:
191-
existing = self._exact_hashes[fingerprint.exact_hash]
192-
if existing.agent_did != fingerprint.agent_did:
193-
return FingerprintMatch(
194-
is_exact_duplicate=True,
195-
hamming_distance=0,
196-
matching_session_id=existing.session_id,
197-
matching_agent_did=existing.agent_did,
198-
)
199-
200-
for stored in self._fingerprints:
201-
if stored.agent_did == fingerprint.agent_did:
202-
continue
203-
if stored.question_hash != fingerprint.question_hash:
204-
continue
205-
206-
dist = hamming_distance(fingerprint.simhash, stored.simhash)
207-
if dist <= self._hamming_threshold:
208-
return FingerprintMatch(
209-
is_near_duplicate=True,
210-
hamming_distance=dist,
211-
matching_session_id=stored.session_id,
212-
matching_agent_did=stored.agent_did,
213-
)
214-
215-
return FingerprintMatch()
216-
217-
def _add_unlocked(self, fingerprint: AnswerFingerprint) -> None:
218-
"""Lock-free add -- only safe when no concurrent access."""
219-
if len(self._fingerprints) >= self._window_size:
220-
evicted = self._fingerprints[0]
221-
stored = self._exact_hashes.get(evicted.exact_hash)
222-
if stored is not None and stored.session_id == evicted.session_id:
223-
del self._exact_hashes[evicted.exact_hash]
224-
225-
self._fingerprints.append(fingerprint)
226-
self._exact_hashes[fingerprint.exact_hash] = fingerprint
227-
228190
def build_fingerprint(
229191
self,
230192
session_id: str,

0 commit comments

Comments
 (0)