Skip to content

fix(gateway): prevent Discord disconnects from blocking event loop - #22753

Closed
itenev wants to merge 1 commit into
NousResearch:mainfrom
itenev:fix/gateway-discord-heartbeat-disconnects
Closed

itenev wants to merge 1 commit into
NousResearch:mainfrom
itenev:fix/gateway-discord-heartbeat-disconnects

Conversation

@itenev

@itenev itenev commented May 9, 2026

Copy link
Copy Markdown
Contributor

Replace synchronous requests.get() in models_dev with async variants using aiohttp and asyncio.to_thread() to offload blocking I/O, preventing event loop freezes that cause missed Discord heartbeats and ClientConnectionResetError disconnects.

Files changed:

  • agent/models_dev.py: add fetch_models_dev_async(), lookup_models_dev_context_async()
  • agent/model_metadata.py: add get_model_context_length_async() using asyncio.to_thread()
  • gateway/run.py: switch 3 call sites to async variants

What does this PR do?

This PR fixes the frequent Discord gateway disconnects (ClientConnectionResetError: Cannot write to closing transport) caused by the event loop being blocked by synchronous HTTP requests.

Specifically, agent/models_dev.py uses requests.get() to fetch model metadata. Because this runs inside the single-threaded asyncio event loop, the call blocks all tasks—including Discord heartbeats—for up to 15 seconds. When the heartbeat is delayed, Discord closes the connection, leading to repeated disconnects and reconnections.

This PR introduces async variants using aiohttp (for the network fetch) and asyncio.to_thread() (for the synchronous resolution chain) to ensure the event loop remains responsive while resolving model context lengths.

Related Issue

N/A

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • agent/models_dev.py: Added fetch_models_dev_async() using aiohttp and lookup_models_dev_context_async().
  • agent/model_metadata.py: Added get_model_context_length_async() which offloads the sync resolution chain to a thread pool via asyncio.to_thread().
  • gateway/run.py: Updated 3 call sites in the message handling path to use the new async variants (await get_model_context_length_async(...)).

How to Test

  1. Run the gateway with Discord enabled.
  2. Trigger a message processing flow that requires model metadata resolution (e.g., context compression or session info).
  3. Verify that agent.log no longer shows heartbeat blocked for more than 10 seconds or ClientConnectionResetError.
  4. Run the relevant tests: pytest tests/agent/test_models_dev.py tests/agent/test_model_metadata.py -v`

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(gateway): prevent Discord disconnects from blocking event loop)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run 'pytest tests/agent/test_models_dev.py tests/agent/test_model_metadata.py' and all tests pass
  • [] I've added tests for my changes (existing tests cover the resolution logic; async variants share the same underlying logic)
  • I've tested on my platform:Ubuntu 24.04,

Documentation & Housekeeping

  • N/A (No documentation changes required)
  • N/A (No config keys changed)
  • N/A (Architecture unchanged, just asyncification of blocking calls)
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — aiohttp and asyncio.to_thread are standard cross-platform.
  • N/A (No tool behavior changed)

Replace synchronous requests.get() in models_dev with async variants
using aiohttp and asyncio.to_thread() to offload blocking I/O,
preventing event loop freezes that cause missed Discord heartbeats
and ClientConnectionResetError disconnects.

Files changed:
- agent/models_dev.py: add fetch_models_dev_async(), lookup_models_dev_context_async()
- agent/model_metadata.py: add get_model_context_length_async() using asyncio.to_thread()
- gateway/run.py: switch 3 call sites to async variants
@itenev
itenev marked this pull request as ready for review May 9, 2026 18:37
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter labels May 9, 2026
@itenev itenev closed this May 10, 2026
@itenev
itenev deleted the fix/gateway-discord-heartbeat-disconnects branch May 10, 2026 12:44
@itenev
itenev restored the fix/gateway-discord-heartbeat-disconnects branch May 11, 2026 19:13
@itenev
itenev deleted the fix/gateway-discord-heartbeat-disconnects branch May 11, 2026 19:14
@itenev
itenev restored the fix/gateway-discord-heartbeat-disconnects branch May 11, 2026 19:15
@itenev itenev reopened this May 11, 2026
@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jun 29, 2026
teknium1 pushed a commit that referenced this pull request Jul 1, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR #22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
@teknium1

teknium1 commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Merged via PR #56212 (rebase-merge, your commit's authorship preserved in git log). #56212

We kept the core fix — get_model_context_length_async() offloading the sync resolution chain via asyncio.to_thread(), plus the two async gateway call-site swaps — which is what actually stops the blocking requests.get from starving Discord heartbeats. Verified E2E: event-loop gap dropped from ~490ms to 52ms during a cold-cache fetch.

We dropped the fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants: they had no callers and the to_thread wrapper already runs the existing sync path off-loop, so they were redundant (and had drifted from the sync cache logic). Thanks for the fix!

@teknium1 teknium1 closed this Jul 1, 2026
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR NousResearch#22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
Jasper6439 pushed a commit to Jasper6439/hermes-agent that referenced this pull request Jul 5, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR NousResearch#22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
@itenev
itenev deleted the fix/gateway-discord-heartbeat-disconnects branch July 6, 2026 05:01
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR NousResearch#22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR NousResearch#22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR NousResearch#22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR NousResearch#22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
models_dev.py's fetch uses a synchronous requests.get(timeout=15). Called
from the async gateway message handlers, it blocked the event loop for up
to 15s, starving Discord heartbeats and causing ClientConnectionResetError
disconnects.

Adds get_model_context_length_async() which offloads the entire sync
resolution chain to a worker thread via asyncio.to_thread(), and switches
the two async gateway call sites (_prepare_inbound_message_text,
_handle_message_with_agent) to await it. The loop stays responsive; the
sync path remains the single source of truth for the cache.

Salvaged from PR NousResearch#22753 by @itenev. Follow-up: dropped the unused
fetch_models_dev_async/lookup_models_dev_context_async aiohttp variants
from the original PR (dead code with zero callers that had drifted from
the sync cache logic) — the to_thread wrapper already runs the sync path
off-loop, so they were redundant.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround platform/discord Discord bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants