Skip to content

fix: peer gate race condition + async client connection leak#83

Open
yasinlex wants to merge 1 commit into
genlayerlabs:mainfrom
yasinlex:fix/peer-gates-leak-and-async-client-leak
Open

fix: peer gate race condition + async client connection leak#83
yasinlex wants to merge 1 commit into
genlayerlabs:mainfrom
yasinlex:fix/peer-gates-leak-and-async-client-leak

Conversation

@yasinlex

@yasinlex yasinlex commented Jul 22, 2026

Copy link
Copy Markdown

Problem

Two bugs in provider_adapters/openai_compatible.py that affect every LLM request routed through unhardcoded:

  1. _PEER_GATES race condition and unbounded memory growth - two concurrent coroutines can both see None and create separate semaphores for the same peer_id, the second silently overwrites the first, dropping any coroutines already waiting on it. The dict only grows as AntSeed peers are dynamically discovered and retired, but old entries are never evicted.

  2. stream_openai_compatible() leaks httpx.AsyncClient - when no external client is passed, a new AsyncClient is created but never closed, leaking a TCP connection pool on every streaming call.

Fix

Peer gates: _peer_gate() is now async, guarded by asyncio.Lock, with TTL-based eviction (600s) for stale entries when dict exceeds 256 entries. Lock is lazy-initialised to avoid creating asyncio primitives at import time.

AsyncClient: track ownership via _owns_client flag, close in finally block.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability when handling concurrent requests to the same provider.
    • Prevented stale connection limits from accumulating over time.
    • Fixed potential connection leaks during streaming responses.
    • Preserved existing streaming behavior, timeout handling, and error reporting.

Two issues in provider_adapters/openai_compatible.py:

1. _PEER_GATES race condition and memory leak:
   - The previous _peer_gate() was synchronous and had a TOCTOU race:
     two concurrent coroutines could both see None and create separate
     semaphores for the same peer_id. The second write would silently
     overwrite the first, dropping any coroutines already waiting on it
     (permanent hang until the semaphore is GC'd).
   - _PEER_GATES grew without bound as AntSeed peers were dynamically
     discovered and retired — old entries were never evicted.
   Fix: _peer_gate is now async, guarded by an asyncio.Lock, and stale
   entries are evicted after a TTL (600s) when the dict exceeds 256
   entries. The lock is lazy-initialised to avoid creating asyncio
   primitives at import time (safe for test/sync contexts).

2. stream_openai_compatible() leaked httpx.AsyncClient:
   When no external client was passed, a new AsyncClient was created but
   never closed — each call leaked a TCP connection pool. Under sustained
   streaming traffic this exhausts the file descriptor limit.
   Fix: track whether we own the client via _owns_client and close it in
   a finally block after the response is fully consumed.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The provider adapter now manages peer concurrency gates with a locked, TTL-evicted cache and awaits asynchronous gate acquisition. Streaming requests track client ownership and close internally created HTTP clients while preserving response parsing and error handling.

Changes

Provider adapter safety

Layer / File(s) Summary
Async peer gate cache
provider_adapters/openai_compatible.py
Peer semaphores are created under a lazily initialized lock, stale entries are evicted using monotonic timestamps and an idle TTL, and the async call path awaits gate acquisition.
Streaming client cleanup
provider_adapters/openai_compatible.py
Streaming execution retains SSE parsing, timeout behavior, and error classification while closing clients created internally by stream_openai_compatible.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: jmlago, muncleuscles

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the two main fixes in the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@provider_adapters/openai_compatible.py`:
- Around line 213-263: Update _peer_gate to track the capacity associated with
each cached peer semaphore and recreate the semaphore when the requested cap
differs from the cached cap. Keep the existing semaphore when cap is unchanged,
preserve lock protection and timestamp updates, and ensure replacement entries
update the stored capacity so subsequent calls use the new limit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ecb1055-8211-460f-8804-5d07e1b0703f

📥 Commits

Reviewing files that changed from the base of the PR and between 7f6bb75 and cc2732a.

📒 Files selected for processing (1)
  • provider_adapters/openai_compatible.py

Comment on lines 213 to +263
_PEER_GATES: dict[str, asyncio.Semaphore] = {}


def _peer_gate(peer_id: str, cap: int) -> asyncio.Semaphore:
sem = _PEER_GATES.get(peer_id)
if sem is None:
sem = asyncio.Semaphore(cap)
_PEER_GATES[peer_id] = sem
return sem
_PEER_GATE_LOCK: asyncio.Lock | None = None

# Maximum idle time (seconds) before a peer gate is evicted to prevent
# unbounded memory growth when peers are dynamically discovered and retired.
_PEER_GATE_TTL: float = 600.0
_PEER_GATE_TIMESTAMPS: dict[str, float] = {}


def _peer_gate_lock() -> asyncio.Lock:
"""Lazy-initialised lock to avoid creating asyncio primitives at import time
(which may happen outside an event loop in test/synchronous contexts)."""
global _PEER_GATE_LOCK
if _PEER_GATE_LOCK is None:
_PEER_GATE_LOCK = asyncio.Lock()
return _PEER_GATE_LOCK


async def _peer_gate(peer_id: str, cap: int) -> asyncio.Semaphore:
"""Return a per-peer concurrency semaphore, creating one if needed.

Uses a lock to prevent a race where two concurrent coroutines both see
``None`` and create separate semaphores for the same peer_id — the
second would overwrite the first, silently dropping any coroutines
already waiting on it.

Old entries are evicted after ``_PEER_GATE_TTL`` seconds of inactivity
so that the dict does not grow without bound when AntSeed peers are
dynamically discovered and later retired.
"""
import time as _time

lock = _peer_gate_lock()
async with lock:
# Evict stale entries first (best-effort, not on every call to
# avoid quadratic sweep cost — only when the lock is already held
# and the dict is getting large).
if len(_PEER_GATES) > 256:
now = _time.monotonic()
stale = [k for k, ts in _PEER_GATE_TIMESTAMPS.items()
if now - ts > _PEER_GATE_TTL]
for k in stale:
_PEER_GATES.pop(k, None)
_PEER_GATE_TIMESTAMPS.pop(k, None)

sem = _PEER_GATES.get(peer_id)
if sem is None:
sem = asyncio.Semaphore(cap)
_PEER_GATES[peer_id] = sem
_PEER_GATE_TIMESTAMPS[peer_id] = _time.monotonic()
return sem

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Solid async gate cache design — but cached semaphore ignores a changed cap for the same peer.

The lock-protected creation and TTL eviction are correctly implemented (no await inside the critical section, so no race). However, once a semaphore exists for a peer_id, a later call with a different cap (e.g., seller updates max_concurrency) is silently ignored — sem = _PEER_GATES.get(peer_id) short-circuits before the new cap is ever used, and the stale limit persists until the entry ages out (_PEER_GATE_TTL = 600s).

♻️ Proposed fix: recreate semaphore when cap changes
+_PEER_GATE_CAPS: dict[str, int] = {}
+
 async def _peer_gate(peer_id: str, cap: int) -> asyncio.Semaphore:
     ...
         sem = _PEER_GATES.get(peer_id)
-        if sem is None:
+        if sem is None or _PEER_GATE_CAPS.get(peer_id) != cap:
             sem = asyncio.Semaphore(cap)
             _PEER_GATES[peer_id] = sem
+            _PEER_GATE_CAPS[peer_id] = cap
         _PEER_GATE_TIMESTAMPS[peer_id] = _time.monotonic()
         return sem

Note: in-flight callers still holding the old semaphore continue enforcing the old cap briefly, same as the existing TTL-eviction transition — acceptable tradeoff.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_PEER_GATES: dict[str, asyncio.Semaphore] = {}
def _peer_gate(peer_id: str, cap: int) -> asyncio.Semaphore:
sem = _PEER_GATES.get(peer_id)
if sem is None:
sem = asyncio.Semaphore(cap)
_PEER_GATES[peer_id] = sem
return sem
_PEER_GATE_LOCK: asyncio.Lock | None = None
# Maximum idle time (seconds) before a peer gate is evicted to prevent
# unbounded memory growth when peers are dynamically discovered and retired.
_PEER_GATE_TTL: float = 600.0
_PEER_GATE_TIMESTAMPS: dict[str, float] = {}
def _peer_gate_lock() -> asyncio.Lock:
"""Lazy-initialised lock to avoid creating asyncio primitives at import time
(which may happen outside an event loop in test/synchronous contexts)."""
global _PEER_GATE_LOCK
if _PEER_GATE_LOCK is None:
_PEER_GATE_LOCK = asyncio.Lock()
return _PEER_GATE_LOCK
async def _peer_gate(peer_id: str, cap: int) -> asyncio.Semaphore:
"""Return a per-peer concurrency semaphore, creating one if needed.
Uses a lock to prevent a race where two concurrent coroutines both see
``None`` and create separate semaphores for the same peer_idthe
second would overwrite the first, silently dropping any coroutines
already waiting on it.
Old entries are evicted after ``_PEER_GATE_TTL`` seconds of inactivity
so that the dict does not grow without bound when AntSeed peers are
dynamically discovered and later retired.
"""
import time as _time
lock = _peer_gate_lock()
async with lock:
# Evict stale entries first (best-effort, not on every call to
# avoid quadratic sweep cost — only when the lock is already held
# and the dict is getting large).
if len(_PEER_GATES) > 256:
now = _time.monotonic()
stale = [k for k, ts in _PEER_GATE_TIMESTAMPS.items()
if now - ts > _PEER_GATE_TTL]
for k in stale:
_PEER_GATES.pop(k, None)
_PEER_GATE_TIMESTAMPS.pop(k, None)
sem = _PEER_GATES.get(peer_id)
if sem is None:
sem = asyncio.Semaphore(cap)
_PEER_GATES[peer_id] = sem
_PEER_GATE_TIMESTAMPS[peer_id] = _time.monotonic()
return sem
_PEER_GATES: dict[str, asyncio.Semaphore] = {}
_PEER_GATE_LOCK: asyncio.Lock | None = None
# Maximum idle time (seconds) before a peer gate is evicted to prevent
# unbounded memory growth when peers are dynamically discovered and retired.
_PEER_GATE_TTL: float = 600.0
_PEER_GATE_TIMESTAMPS: dict[str, float] = {}
_PEER_GATE_CAPS: dict[str, int] = {}
def _peer_gate_lock() -> asyncio.Lock:
"""Lazy-initialised lock to avoid creating asyncio primitives at import time
(which may happen outside an event loop in test/synchronous contexts)."""
global _PEER_GATE_LOCK
if _PEER_GATE_LOCK is None:
_PEER_GATE_LOCK = asyncio.Lock()
return _PEER_GATE_LOCK
async def _peer_gate(peer_id: str, cap: int) -> asyncio.Semaphore:
"""Return a per-peer concurrency semaphore, creating one if needed.
Uses a lock to prevent a race where two concurrent coroutines both see
``None`` and create separate semaphores for the same peer_idthe
second would overwrite the first, silently dropping any coroutines
already waiting on it.
Old entries are evicted after ``_PEER_GATE_TTL`` seconds of inactivity
so that the dict does not grow without bound when AntSeed peers are
dynamically discovered and later retired.
"""
import time as _time
lock = _peer_gate_lock()
async with lock:
# Evict stale entries first (best-effort, not on every call to
# avoid quadratic sweep cost — only when the lock is already held
# and the dict is getting large).
if len(_PEER_GATES) > 256:
now = _time.monotonic()
stale = [k for k, ts in _PEER_GATE_TIMESTAMPS.items()
if now - ts > _PEER_GATE_TTL]
for k in stale:
_PEER_GATES.pop(k, None)
_PEER_GATE_TIMESTAMPS.pop(k, None)
_PEER_GATE_CAPS.pop(k, None)
sem = _PEER_GATES.get(peer_id)
if sem is None or _PEER_GATE_CAPS.get(peer_id) != cap:
sem = asyncio.Semaphore(cap)
_PEER_GATES[peer_id] = sem
_PEER_GATE_CAPS[peer_id] = cap
_PEER_GATE_TIMESTAMPS[peer_id] = _time.monotonic()
return sem
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider_adapters/openai_compatible.py` around lines 213 - 263, Update
_peer_gate to track the capacity associated with each cached peer semaphore and
recreate the semaphore when the requested cap differs from the cached cap. Keep
the existing semaphore when cap is unchanged, preserve lock protection and
timestamp updates, and ensure replacement entries update the stored capacity so
subsequent calls use the new limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant