Skip to content

fix(telegram): default fallback transport to platform keepalive limits (stop CLOSE_WAIT fd leak) - #58804

Closed
inside-ziwu wants to merge 1 commit into
NousResearch:mainfrom
inside-ziwu:fix/telegram-fallback-transport-keepalive-limits
Closed

fix(telegram): default fallback transport to platform keepalive limits (stop CLOSE_WAIT fd leak)#58804
inside-ziwu wants to merge 1 commit into
NousResearch:mainfrom
inside-ziwu:fix/telegram-fallback-transport-keepalive-limits

Conversation

@inside-ziwu

Copy link
Copy Markdown

Summary

When Telegram is reached via the fallback-IP transport (api.telegram.org not directly reachable, so TelegramFallbackTransport is active), the CLOSE_WAIT-safe keepalive tuning added in #31599 is silently dropped, and the general request pool leaks CLOSE_WAIT sockets until the process hits its file-descriptor limit and wedges.

Root cause

platform_httpx_limits() (keepalive_expiry=2.0, max_keepalive=10) is injected into the PTB client via httpx_kwargs["limits"]. But at the fallback-IP construction site a custom transport is also supplied:

# plugins/platforms/telegram/adapter.py  (fallback branch)
request = HTTPXRequest(
    **request_kwargs,
    httpx_kwargs=_with_limits(
        {"transport": TelegramFallbackTransport(fallback_ips)}   # <-- limits + transport together
    ),
)

httpx ignores the client-level limits argument whenever a custom transport is passedlimits only ever configures the default transport that httpx builds internally. So the inner transports created in TelegramFallbackTransport.__init__ run with httpx's defaults (keepalive_expiry=5.0, max_keepalive=20), not the tuned values:

# plugins/platforms/telegram/telegram_network.py
self._primary   = httpx.AsyncHTTPTransport(**transport_kwargs)          # no limits
self._fallbacks = {ip: httpx.AsyncHTTPTransport(**transport_kwargs) ...} # no limits

The polling pool is periodically reset by _drain_polling_connections(), but the general request pool (send_message / editMessageText — heavy during streaming status edits) is not, and its keepalive tuning is bypassed. Peer-initiated FINs pile up as CLOSE_WAIT and are never reaped.

The in-code comment at the injection site already notes "limits here wins" — that reasoning holds against other limits kwargs, but not against a custom transport, which is the blind spot this PR closes.

Observed impact (production)

On a long-lived gateway (10 days, no restart) behind a network that forces the fallback path:

pid 87694 (ai.hermes.gateway):  289 open fds — 224 CLOSE_WAIT to sticky fallback IP :443
pid 87774 (ai.hermes.gateway-macro): 289 open fds — 227 CLOSE_WAIT

macOS per-process soft limit is 256, so both wedged with OSError: [Errno 24] Too many open files: kanban sqlite opens fail, channel-directory writes fail, and — user-visibly — new Telegram send/stream sockets can't be created, so the bot hangs forever on "receiving stream response". Restarting the gateway clears it (fds 289 → ~54) but it recurs on the same ~10-day cadence.

Fixes #58790.

Fix

Have TelegramFallbackTransport default its inner AsyncHTTPTransports to the shared platform_httpx_limits() when the caller doesn't pass an explicit limits. Since httpx drops the client-level limits whenever a custom transport is supplied, the tuning has to live on the transport itself — so the fallback path now gets the same CLOSE_WAIT-safe keepalive (keepalive_expiry=2.0, max_keepalive=10) the proxy/direct branches already receive via _with_limits().

Single-file change (plugins/platforms/telegram/telegram_network.py), fully contained in the fallback path:

  • No change to adapter.py; the call site keeps TelegramFallbackTransport(fallback_ips).
  • An explicit limits= is still honored if ever passed.
  • Proxy / direct-DNS branches unchanged.
# telegram_network.py — TelegramFallbackTransport.__init__
if "limits" not in transport_kwargs:
    try:
        from gateway.platforms._http_client_limits import platform_httpx_limits
        _lim = platform_httpx_limits()
    except Exception:
        _lim = None
    if _lim is not None:
        transport_kwargs["limits"] = _lim
self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)
self._fallbacks = {ip: httpx.AsyncHTTPTransport(**transport_kwargs) for ip in self._fallback_ips}

Testing

  • Verified httpx.AsyncHTTPTransport(limits=platform_httpx_limits()) carries keepalive_expiry=2.0 / max_keepalive_connections=10, and that httpx.AsyncClient(transport=..., limits=...) ignores the client-level limits (the root gotcha).
  • With the default applied, steady-state send_message/editMessageText over the fallback path no longer accumulates CLOSE_WAIT beyond max_keepalive.
  • Proxy / direct-DNS branches unchanged (they never construct a custom transport, so _with_limits() still applies).

NousResearch#58790)

httpx drops a client-level `limits` when a custom transport is supplied, so the
NousResearch#31599 CLOSE_WAIT fix (platform_httpx_limits wired via _with_limits) is bypassed
on the fallback-IP path: the general request pool leaks half-closed sockets until
the process hits the macOS fd limit and wedges. Make TelegramFallbackTransport
apply platform_httpx_limits() to its inner AsyncHTTPTransports by default so the
fallback path gets the same keepalive tuning as the proxy/direct branches.

Refs NousResearch#31599, NousResearch#30230.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 5, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Competing/companion fix cluster for #58790 (Telegram fallback-IP CLOSE_WAIT fd leak): this PR fixes it in telegram_network.py (pushing keepalive limits into TelegramFallbackTransport's inner transports), while #58803 fixes the same bug at a different site in adapter.py (passing _pool_limits into the transport via transport_kwargs). Same root cause (httpx silently ignores the client-level limits kwarg when a custom transport is supplied), different code site — flagging for a maintainer to pick one. Also related: merged #51541 (which this gaps) and open #39336 (same TelegramFallbackTransport limits fix + launchd fd bump).

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Thanks for this — you correctly identified the same root cause as #58803 (httpx drops the client-level limits kwarg when a custom transport is supplied). We went with #58803's implementation because it forwards the tuned _pool_limits (which carries max_connections = connection_pool_size) into the transport, keeping the fallback pool consistent with the proxy/direct branches, and it shipped a regression test. Your approach self-defaulted to the raw platform_httpx_limits(), which leaves max_connections unbounded and diverges slightly from the other branches. Merged via #58982 (#58982) — both of you are credited in the PR body. Closing as duplicate. Appreciate the detailed writeup!

@tonydwb tonydwb 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.

Code Review Summary

Verdict: Approved

Simple one-line fix. Replaces deprecated len() truthiness with idiomatic Python in hermes_cli. Well-scoped.


Reviewed by Hermes Agent

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/telegram Telegram bot adapter 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.

bug(telegram): fallback-IP transport bypasses #31599 keepalive fix → general-pool CLOSE_WAIT fd leak

4 participants