Skip to content

Commit fafb89b

Browse files
authored
fix(gateway): persist memory flush state to prevent redundant re-flushes on restart (NousResearch#4481)
* fix: force-close TCP sockets on client cleanup, detect and recover dead connections When a provider drops connections mid-stream (e.g. OpenRouter outage), httpx's graceful close leaves sockets in CLOSE-WAIT indefinitely. These zombie connections accumulate and can prevent recovery without restarting. Changes: - _force_close_tcp_sockets: walks the httpx connection pool and issues socket.shutdown(SHUT_RDWR) + close() to force TCP RST on every socket when a client is closed, preventing CLOSE-WAIT accumulation - _cleanup_dead_connections: probes the primary client's pool for dead sockets (recv MSG_PEEK), rebuilds the client if any are found - Pre-turn health check at the start of each run_conversation call that auto-recovers with a user-facing status message - Primary client rebuild after stale stream detection to purge pool - User-facing messages on streaming connection failures: "Connection to provider dropped — Reconnecting (attempt 2/3)" "Connection failed after 3 attempts — try again in a moment" Made-with: Cursor * fix: pool entry missing base_url for openrouter, clean error messages - _resolve_runtime_from_pool_entry: add OPENROUTER_BASE_URL fallback when pool entry has no runtime_base_url (pool entries from auth.json credential_pool often omit base_url) - Replace Rich console.print for auth errors with plain print() to prevent ANSI escape code mangling through prompt_toolkit's stdout patch - Force-close TCP sockets on client cleanup to prevent CLOSE-WAIT accumulation after provider outages - Pre-turn dead connection detection with auto-recovery and user message - Primary client rebuild after stale stream detection - User-facing status messages on streaming connection failures/retries Made-with: Cursor * fix(gateway): persist memory flush state to prevent redundant re-flushes on restart The _session_expiry_watcher tracked flushed sessions in an in-memory set (_pre_flushed_sessions) that was lost on gateway restart. Expired sessions remained in sessions.json and were re-discovered every restart, causing redundant AIAgent runs that burned API credits and blocked the event loop. Fix: Add a memory_flushed boolean field to SessionEntry, persisted in sessions.json. The watcher sets it after a successful flush. On restart, the flag survives and the watcher skips already-flushed sessions. - Add memory_flushed field to SessionEntry with to_dict/from_dict support - Old sessions.json entries without the field default to False (backward compat) - Remove the ephemeral _pre_flushed_sessions set from SessionStore - Update tests: save/load roundtrip, legacy entry compat, auto-reset behavior
1 parent 4c87d62 commit fafb89b

6 files changed

Lines changed: 290 additions & 35 deletions

File tree

cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,10 +1979,12 @@ def _ensure_runtime_credentials(self) -> bool:
19791979
base_url, _source,
19801980
)
19811981
else:
1982-
self.console.print("[bold red]Provider resolver returned an empty API key.[/]")
1982+
print("\n⚠️ Provider resolver returned an empty API key. "
1983+
"Set OPENROUTER_API_KEY or run: hermes setup")
19831984
return False
19841985
if not isinstance(base_url, str) or not base_url:
1985-
self.console.print("[bold red]Provider resolver returned an empty base URL.[/]")
1986+
print("\n⚠️ Provider resolver returned an empty base URL. "
1987+
"Check your provider config or run: hermes setup")
19861988
return False
19871989

19881990
credentials_changed = api_key != self.api_key or base_url != self.base_url

gateway/run.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,8 +1280,8 @@ async def _session_expiry_watcher(self, interval: int = 300):
12801280
try:
12811281
self.session_store._ensure_loaded()
12821282
for key, entry in list(self.session_store._entries.items()):
1283-
if entry.session_id in self.session_store._pre_flushed_sessions:
1284-
continue # already flushed this session
1283+
if entry.memory_flushed:
1284+
continue # already flushed this session (persisted to disk)
12851285
if not self.session_store._is_session_expired(entry):
12861286
continue # session still active
12871287
# Session has expired — flush memories in the background
@@ -1292,7 +1292,15 @@ async def _session_expiry_watcher(self, interval: int = 300):
12921292
try:
12931293
await self._async_flush_memories(entry.session_id, key)
12941294
self._shutdown_gateway_honcho(key)
1295-
self.session_store._pre_flushed_sessions.add(entry.session_id)
1295+
# Mark as flushed and persist to disk so the flag
1296+
# survives gateway restarts.
1297+
with self.session_store._lock:
1298+
entry.memory_flushed = True
1299+
self.session_store._save()
1300+
logger.info(
1301+
"Pre-reset memory flush completed for session %s",
1302+
entry.session_id,
1303+
)
12961304
except Exception as e:
12971305
logger.debug("Proactive memory flush failed for %s: %s", entry.session_id, e)
12981306
except Exception as e:

gateway/session.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,12 @@ class SessionEntry:
364364
auto_reset_reason: Optional[str] = None # "idle" or "daily"
365365
reset_had_activity: bool = False # whether the expired session had any messages
366366

367+
# Set by the background expiry watcher after it successfully flushes
368+
# memories for this session. Persisted to sessions.json so the flag
369+
# survives gateway restarts (the old in-memory _pre_flushed_sessions
370+
# set was lost on restart, causing redundant re-flushes).
371+
memory_flushed: bool = False
372+
367373
def to_dict(self) -> Dict[str, Any]:
368374
result = {
369375
"session_key": self.session_key,
@@ -381,6 +387,7 @@ def to_dict(self) -> Dict[str, Any]:
381387
"last_prompt_tokens": self.last_prompt_tokens,
382388
"estimated_cost_usd": self.estimated_cost_usd,
383389
"cost_status": self.cost_status,
390+
"memory_flushed": self.memory_flushed,
384391
}
385392
if self.origin:
386393
result["origin"] = self.origin.to_dict()
@@ -416,6 +423,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
416423
last_prompt_tokens=data.get("last_prompt_tokens", 0),
417424
estimated_cost_usd=data.get("estimated_cost_usd", 0.0),
418425
cost_status=data.get("cost_status", "unknown"),
426+
memory_flushed=data.get("memory_flushed", False),
419427
)
420428

421429

@@ -479,9 +487,6 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig,
479487
self._loaded = False
480488
self._lock = threading.Lock()
481489
self._has_active_processes_fn = has_active_processes_fn
482-
# on_auto_reset is deprecated — memory flush now runs proactively
483-
# via the background session expiry watcher in GatewayRunner.
484-
self._pre_flushed_sessions: set = set() # session_ids already flushed by watcher
485490

486491
# Initialize SQLite session database
487492
self._db = None
@@ -684,15 +689,12 @@ def get_or_create_session(
684689
self._save()
685690
return entry
686691
else:
687-
# Session is being auto-reset. The background expiry watcher
688-
# should have already flushed memories proactively; discard
689-
# the marker so it doesn't accumulate.
692+
# Session is being auto-reset.
690693
was_auto_reset = True
691694
auto_reset_reason = reset_reason
692695
# Track whether the expired session had any real conversation
693696
reset_had_activity = entry.total_tokens > 0
694697
db_end_session_id = entry.session_id
695-
self._pre_flushed_sessions.discard(entry.session_id)
696698
else:
697699
was_auto_reset = False
698700
auto_reset_reason = None

hermes_cli/runtime_provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ def _resolve_runtime_from_pool_entry(
133133
if cfg_provider == "anthropic":
134134
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
135135
base_url = cfg_base_url or base_url or "https://api.anthropic.com"
136+
elif provider == "openrouter":
137+
base_url = base_url or OPENROUTER_BASE_URL
136138
elif provider == "nous":
137139
api_mode = "chat_completions"
138140
elif provider == "copilot":

run_agent.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3543,15 +3543,78 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo
35433543
)
35443544
return client
35453545

3546+
@staticmethod
3547+
def _force_close_tcp_sockets(client: Any) -> int:
3548+
"""Force-close underlying TCP sockets to prevent CLOSE-WAIT accumulation.
3549+
3550+
When a provider drops a connection mid-stream, httpx's ``client.close()``
3551+
performs a graceful shutdown which leaves sockets in CLOSE-WAIT until the
3552+
OS times them out (often minutes). This method walks the httpx transport
3553+
pool and issues ``socket.shutdown(SHUT_RDWR)`` + ``socket.close()`` to
3554+
force an immediate TCP RST, freeing the file descriptors.
3555+
3556+
Returns the number of sockets force-closed.
3557+
"""
3558+
import socket as _socket
3559+
3560+
closed = 0
3561+
try:
3562+
http_client = getattr(client, "_client", None)
3563+
if http_client is None:
3564+
return 0
3565+
transport = getattr(http_client, "_transport", None)
3566+
if transport is None:
3567+
return 0
3568+
pool = getattr(transport, "_pool", None)
3569+
if pool is None:
3570+
return 0
3571+
# httpx uses httpcore connection pools; connections live in
3572+
# _connections (list) or _pool (list) depending on version.
3573+
connections = (
3574+
getattr(pool, "_connections", None)
3575+
or getattr(pool, "_pool", None)
3576+
or []
3577+
)
3578+
for conn in list(connections):
3579+
stream = (
3580+
getattr(conn, "_network_stream", None)
3581+
or getattr(conn, "_stream", None)
3582+
)
3583+
if stream is None:
3584+
continue
3585+
sock = getattr(stream, "_sock", None)
3586+
if sock is None:
3587+
sock = getattr(stream, "stream", None)
3588+
if sock is not None:
3589+
sock = getattr(sock, "_sock", None)
3590+
if sock is None:
3591+
continue
3592+
try:
3593+
sock.shutdown(_socket.SHUT_RDWR)
3594+
except OSError:
3595+
pass
3596+
try:
3597+
sock.close()
3598+
except OSError:
3599+
pass
3600+
closed += 1
3601+
except Exception as exc:
3602+
logger.debug("Force-close TCP sockets sweep error: %s", exc)
3603+
return closed
3604+
35463605
def _close_openai_client(self, client: Any, *, reason: str, shared: bool) -> None:
35473606
if client is None:
35483607
return
3608+
# Force-close TCP sockets first to prevent CLOSE-WAIT accumulation,
3609+
# then do the graceful SDK-level close.
3610+
force_closed = self._force_close_tcp_sockets(client)
35493611
try:
35503612
client.close()
35513613
logger.info(
3552-
"OpenAI client closed (%s, shared=%s) %s",
3614+
"OpenAI client closed (%s, shared=%s, tcp_force_closed=%d) %s",
35533615
reason,
35543616
shared,
3617+
force_closed,
35553618
self._client_log_context(),
35563619
)
35573620
except Exception as exc:
@@ -3596,6 +3659,76 @@ def _ensure_primary_openai_client(self, *, reason: str) -> Any:
35963659
with self._openai_client_lock():
35973660
return self.client
35983661

3662+
def _cleanup_dead_connections(self) -> bool:
3663+
"""Detect and clean up dead TCP connections on the primary client.
3664+
3665+
Inspects the httpx connection pool for sockets in unhealthy states
3666+
(CLOSE-WAIT, errors). If any are found, force-closes all sockets
3667+
and rebuilds the primary client from scratch.
3668+
3669+
Returns True if dead connections were found and cleaned up.
3670+
"""
3671+
client = getattr(self, "client", None)
3672+
if client is None:
3673+
return False
3674+
try:
3675+
http_client = getattr(client, "_client", None)
3676+
if http_client is None:
3677+
return False
3678+
transport = getattr(http_client, "_transport", None)
3679+
if transport is None:
3680+
return False
3681+
pool = getattr(transport, "_pool", None)
3682+
if pool is None:
3683+
return False
3684+
connections = (
3685+
getattr(pool, "_connections", None)
3686+
or getattr(pool, "_pool", None)
3687+
or []
3688+
)
3689+
dead_count = 0
3690+
for conn in list(connections):
3691+
# Check for connections that are idle but have closed sockets
3692+
stream = (
3693+
getattr(conn, "_network_stream", None)
3694+
or getattr(conn, "_stream", None)
3695+
)
3696+
if stream is None:
3697+
continue
3698+
sock = getattr(stream, "_sock", None)
3699+
if sock is None:
3700+
sock = getattr(stream, "stream", None)
3701+
if sock is not None:
3702+
sock = getattr(sock, "_sock", None)
3703+
if sock is None:
3704+
continue
3705+
# Probe socket health with a non-blocking recv peek
3706+
import socket as _socket
3707+
try:
3708+
sock.setblocking(False)
3709+
data = sock.recv(1, _socket.MSG_PEEK | _socket.MSG_DONTWAIT)
3710+
if data == b"":
3711+
dead_count += 1
3712+
except BlockingIOError:
3713+
pass # No data available — socket is healthy
3714+
except OSError:
3715+
dead_count += 1
3716+
finally:
3717+
try:
3718+
sock.setblocking(True)
3719+
except OSError:
3720+
pass
3721+
if dead_count > 0:
3722+
logger.warning(
3723+
"Found %d dead connection(s) in client pool — rebuilding client",
3724+
dead_count,
3725+
)
3726+
self._replace_primary_openai_client(reason="dead_connection_cleanup")
3727+
return True
3728+
except Exception as exc:
3729+
logger.debug("Dead connection check error: %s", exc)
3730+
return False
3731+
35993732
def _create_request_openai_client(self, *, reason: str) -> Any:
36003733
from unittest.mock import Mock
36013734

@@ -4387,14 +4520,33 @@ def _call():
43874520
type(e).__name__,
43884521
e,
43894522
)
4523+
self._emit_status(
4524+
f"⚠️ Connection to provider dropped "
4525+
f"({type(e).__name__}). Reconnecting… "
4526+
f"(attempt {_stream_attempt + 2}/{_max_stream_retries + 1})"
4527+
)
43904528
# Close the stale request client before retry
43914529
stale = request_client_holder.get("client")
43924530
if stale is not None:
43934531
self._close_request_openai_client(
43944532
stale, reason="stream_retry_cleanup"
43954533
)
43964534
request_client_holder["client"] = None
4535+
# Also rebuild the primary client to purge
4536+
# any dead connections from the pool.
4537+
try:
4538+
self._replace_primary_openai_client(
4539+
reason="stream_retry_pool_cleanup"
4540+
)
4541+
except Exception:
4542+
pass
43974543
continue
4544+
self._emit_status(
4545+
"❌ Connection to provider failed after "
4546+
f"{_max_stream_retries + 1} attempts. "
4547+
"The provider may be experiencing issues — "
4548+
"try again in a moment."
4549+
)
43984550
logger.warning(
43994551
"Streaming exhausted %s retries on transient error, "
44004552
"falling back to non-streaming: %s",
@@ -4466,6 +4618,12 @@ def _call():
44664618
self._close_request_openai_client(rc, reason="stale_stream_kill")
44674619
except Exception:
44684620
pass
4621+
# Rebuild the primary client too — its connection pool
4622+
# may hold dead sockets from the same provider outage.
4623+
try:
4624+
self._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
4625+
except Exception:
4626+
pass
44694627
# Reset the timer so we don't kill repeatedly while
44704628
# the inner thread processes the closure.
44714629
last_chunk_time["t"] = time.time()
@@ -6254,6 +6412,20 @@ def run_conversation(
62546412
self._last_content_with_tools = None
62556413
self._mute_post_response = False
62566414
self._surrogate_sanitized = False
6415+
6416+
# Pre-turn connection health check: detect and clean up dead TCP
6417+
# connections left over from provider outages or dropped streams.
6418+
# This prevents the next API call from hanging on a zombie socket.
6419+
if self.api_mode != "anthropic_messages":
6420+
try:
6421+
if self._cleanup_dead_connections():
6422+
self._emit_status(
6423+
"🔌 Detected stale connections from a previous provider "
6424+
"issue — cleaned up automatically. Proceeding with fresh "
6425+
"connection."
6426+
)
6427+
except Exception:
6428+
pass
62576429
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
62586430
# They are initialized in __init__ and must persist across run_conversation
62596431
# calls so that nudge logic accumulates correctly in CLI mode.

0 commit comments

Comments
 (0)