mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Unit 3 of the Codex proxy resilience plan. Eliminates the "aged process
has leaked relay tasks" hypothesis by making every WS session explicitly
tracked and both relay tasks deterministically cancelled when either
exits.
- New headroom/proxy/ws_session_registry.py: dict-backed
WebSocketSessionRegistry + WSSessionHandle with register /
deregister / attach_tasks / snapshot. Deregister is idempotent and
clears relay-task references so coroutine frames are not retained
past session end.
- HeadroomProxy exposes proxy.ws_sessions so /debug/ws-sessions
(Unit 5) can read the live snapshot.
- handle_openai_responses_ws now registers on websocket.accept()
success and deregisters in the outermost finally so no leak can
survive handshake-phase, mid-stream, or upstream-error paths. The
session_id / termination_cause is threaded through both relay
halves and both sides raise asyncio.CancelledError cleanly.
- Replaced asyncio.gather(_client_to_upstream(), _upstream_to_client(),
return_exceptions=True) with explicit asyncio.create_task(...)
(named codex-ws-c2u-<sid> / codex-ws-u2c-<sid>) +
asyncio.wait(FIRST_COMPLETED) + cancel-and-await on the survivor.
Termination cause is classified as client_disconnect /
client_error / upstream_disconnect / upstream_error /
response_completed from which task completed first plus inline
error captures from the halves.
- Prometheus metrics: new active_ws_sessions and active_relay_tasks
gauges plus ws_session_duration_ms_{sum,count,max} histogram
bucketed by termination cause. Mirrors the Unit 2 stage_timing_*
shape.
Preserved: upstream WS retry loop, WS→HTTP fallback, memory-context
timeout, compression pipeline, Unit 2 stage timings. Memory-tool
execution inside _upstream_to_client still runs when the client task
exits first; however, if the client disconnects *before* the upstream
emits response.completed, pending memory writes in `pending_fcs` are
dropped (unchanged from prior behavior — a crashing upstream has the
same effect). Note: handle_openai_responses (HTTP, line ~800) is a
single-shot HTTP request; lifecycle tracking isn't added there
(scope boundary).
Tests:
- tests/test_ws_session_registry.py: 8 registry unit tests
(register/deregister idempotency, snapshot shape, attach merging,
reference release).
- tests/test_openai_codex_ws_lifecycle.py: 6 integration tests
using real relay tasks (only upstream WS endpoint mocked):
happy-path, failing-test-first "client disconnect cancels upstream
relay within 100 ms", upstream-closes-first, upstream-error mid-
stream, handshake-failure deregister, 50 concurrent sessions.
- Regression: test_openai_codex_ws_timings, test_openai_codex_routing,
test_proxy_codex_route_aliases, test_ws_memory_relay all pass.
- Tests pass under python -W error::RuntimeWarning (no "coroutine
was never awaited").
192 lines
7.1 KiB
Python
192 lines
7.1 KiB
Python
"""WebSocket session registry for Codex relay lifecycle tracking.
|
|
|
|
Unit 3 of the Codex-proxy resilience plan. Every ``/v1/responses`` WS
|
|
session is explicitly registered on accept and deregistered in the
|
|
outermost ``finally`` of the handler. The registry provides:
|
|
|
|
* First-class visibility of active sessions (``/debug/ws-sessions``
|
|
from Unit 5 consumes :meth:`WebSocketSessionRegistry.snapshot`).
|
|
* Gauges for Prometheus (``active_ws_sessions``, ``active_relay_tasks``).
|
|
* A home for relay-task references so the handler's orchestrator can
|
|
attach them at creation time for introspection, without the registry
|
|
itself owning or cancelling them — cancellation is the handler's job.
|
|
|
|
Design notes
|
|
------------
|
|
* Single-event-loop usage. All mutations happen on the proxy's event
|
|
loop from within the handler coroutine, so no asyncio.Lock / asyncio
|
|
primitives are needed. Python dict mutations are atomic under the
|
|
GIL; snapshot copies happen under the event loop's cooperative
|
|
scheduling, so iteration + mutation cannot interleave across an
|
|
``await`` point.
|
|
* ``deregister`` must be idempotent. The handler calls it from the
|
|
outermost ``finally`` — if upstream never connected, the session may
|
|
have been registered or may not have been, depending on how far
|
|
handshake got. Either way ``deregister`` is safe.
|
|
* ``deregister`` clears the handle's ``relay_tasks`` list so the
|
|
registry does not retain references to task coroutine frames after a
|
|
session ends.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Protocol
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"WebSocketSessionRegistry",
|
|
"WSSessionHandle",
|
|
"TerminationCause",
|
|
]
|
|
|
|
|
|
TerminationCause = str # "client_disconnect" | "upstream_disconnect" | "upstream_error"
|
|
# | "client_error" | "response_completed" | "unknown"
|
|
|
|
|
|
class _TaskLike(Protocol):
|
|
def done(self) -> bool: ...
|
|
def cancel(self) -> bool: ...
|
|
def get_name(self) -> str: ...
|
|
|
|
|
|
@dataclass
|
|
class WSSessionHandle:
|
|
"""Per-session state entry held in :class:`WebSocketSessionRegistry`.
|
|
|
|
Timestamps use :func:`time.perf_counter` so age computations are
|
|
monotonic and independent of wall-clock adjustments.
|
|
"""
|
|
|
|
session_id: str
|
|
request_id: str
|
|
client_addr: str | None = None
|
|
upstream_url: str | None = None
|
|
started_at: float = field(default_factory=time.perf_counter)
|
|
last_activity_at: float = field(default_factory=time.perf_counter)
|
|
relay_tasks: list[Any] = field(default_factory=list)
|
|
termination_cause: TerminationCause | None = None
|
|
|
|
def mark_activity(self) -> None:
|
|
self.last_activity_at = time.perf_counter()
|
|
|
|
def age_seconds(self) -> float:
|
|
return max(0.0, time.perf_counter() - self.started_at)
|
|
|
|
def to_snapshot_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"session_id": self.session_id,
|
|
"request_id": self.request_id,
|
|
"client_addr": self.client_addr,
|
|
"upstream_url": self.upstream_url,
|
|
"age_seconds": self.age_seconds(),
|
|
"idle_seconds": max(0.0, time.perf_counter() - self.last_activity_at),
|
|
"relay_task_count": len(self.relay_tasks),
|
|
"relay_task_names": [
|
|
getattr(t, "get_name", lambda: "")() for t in self.relay_tasks
|
|
],
|
|
"termination_cause": self.termination_cause,
|
|
}
|
|
|
|
|
|
class WebSocketSessionRegistry:
|
|
"""In-memory registry of active Codex WS sessions.
|
|
|
|
Methods are safe to call from the event loop. Unit 5's
|
|
``/debug/ws-sessions`` endpoint consumes :meth:`snapshot`; the
|
|
Prometheus exporter reads :meth:`active_count` and
|
|
:meth:`active_relay_task_count`.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._sessions: dict[str, WSSessionHandle] = {}
|
|
# Tracked separately from ``sum(len(h.relay_tasks))`` so that
|
|
# repeated snapshotting is O(1) rather than O(N * tasks).
|
|
self._active_relay_tasks = 0
|
|
|
|
# ------------------------------------------------------------------
|
|
# Core lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
def register(self, handle: WSSessionHandle) -> None:
|
|
"""Register a session. Idempotent by ``session_id``.
|
|
|
|
If the session id is already present, the existing entry is
|
|
replaced and the active count stays the same. This matches the
|
|
invariant that one ``session_id`` corresponds to at most one
|
|
active session at a time.
|
|
"""
|
|
existing = self._sessions.get(handle.session_id)
|
|
if existing is not None:
|
|
# Re-registration: release any tasks we were tracking on the
|
|
# old handle before replacing it.
|
|
self._active_relay_tasks -= len(existing.relay_tasks)
|
|
existing.relay_tasks.clear()
|
|
self._sessions[handle.session_id] = handle
|
|
self._active_relay_tasks += len(handle.relay_tasks)
|
|
|
|
def deregister(
|
|
self, session_id: str, cause: TerminationCause = "unknown"
|
|
) -> WSSessionHandle | None:
|
|
"""Remove a session. Idempotent: returns ``None`` if unknown.
|
|
|
|
Also clears the handle's ``relay_tasks`` list so the registry
|
|
stops holding references to coroutine frames after the session
|
|
ends.
|
|
"""
|
|
handle = self._sessions.pop(session_id, None)
|
|
if handle is None:
|
|
return None
|
|
handle.termination_cause = cause
|
|
self._active_relay_tasks = max(
|
|
0, self._active_relay_tasks - len(handle.relay_tasks)
|
|
)
|
|
handle.relay_tasks.clear()
|
|
return handle
|
|
|
|
def attach_tasks(self, session_id: str, tasks: Iterable[_TaskLike]) -> None:
|
|
"""Attach relay tasks to an existing session (merge, not replace).
|
|
|
|
If ``session_id`` is not registered, this is a no-op (handler
|
|
should have registered before spawning tasks; defensive so a
|
|
race during deregister doesn't crash the handler).
|
|
"""
|
|
handle = self._sessions.get(session_id)
|
|
if handle is None:
|
|
return
|
|
task_list = list(tasks)
|
|
handle.relay_tasks.extend(task_list)
|
|
self._active_relay_tasks += len(task_list)
|
|
handle.mark_activity()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Introspection
|
|
# ------------------------------------------------------------------
|
|
|
|
def get(self, session_id: str) -> WSSessionHandle | None:
|
|
return self._sessions.get(session_id)
|
|
|
|
def active_count(self) -> int:
|
|
return len(self._sessions)
|
|
|
|
def active_relay_task_count(self) -> int:
|
|
return self._active_relay_tasks
|
|
|
|
def snapshot(self) -> list[dict[str, Any]]:
|
|
"""JSON-serializable view of the registry (for ``/debug/ws-sessions``)."""
|
|
return [handle.to_snapshot_dict() for handle in self._sessions.values()]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Convenience
|
|
# ------------------------------------------------------------------
|
|
|
|
def __contains__(self, session_id: str) -> bool:
|
|
return session_id in self._sessions
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._sessions)
|