Skip to content

Commit f131971

Browse files
committed
fix: bound reconnect and fail fast when the session pool is exhausted
Two issues combined to make every tool call hang indefinitely once more MCP clients were running than the session pool had slots for. `_acquire_session` treated an exhausted pool as a warning and returned `pool[0]` — a session another live client already holds. Telegram reacts to a duplicated auth key by permanently invalidating it, so this both fails for the new client and burns the slot for the client that owned it. Refusing to start is recoverable; a burned session is not. `_force_reconnect` then called `cl.connect()` with no timeout. The 5s guard in `ensure_connected` only wraps the liveness ping, not the reconnect, so a client with an invalidated auth key retried forever and the tool call never returned. Reconnects are now bounded by `_RECONNECT_TIMEOUT`, and `AuthKeyDuplicatedError` is surfaced with an actionable message instead of being retried — that error is permanent, so a retry loop can never recover from it.
1 parent fbb374a commit f131971

4 files changed

Lines changed: 71 additions & 11 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,11 @@ Generate extra sessions with `uv run session_string_generator.py`. The pool
307307
takes precedence over `TELEGRAM_SESSION_STRING` for the default account. As an
308308
extra safety net, a transient `AuthKeyDuplicatedError` at connect time (e.g.
309309
during a VPN reconnect) is retried with backoff before the server gives up.
310+
311+
Size the pool to the number of clients you actually run concurrently. If every
312+
slot is already claimed, the server refuses to start with an explicit error
313+
rather than reusing a session another client holds — reuse would make Telegram
314+
permanently invalidate that session for both clients.
310315
- "Send this from my work account to @example"
311316

312317
## Device Identity

telegram_mcp/runtime.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from mcp.shared.exceptions import McpError
2121
from pythonjsonlogger import jsonlogger
2222
from telethon import TelegramClient, functions, types, utils
23+
from telethon.errors import AuthKeyDuplicatedError
2324
from telethon.sessions import StringSession
2425
from telethon.tl.types import (
2526
User,
@@ -393,13 +394,16 @@ def _acquire_session(pool: List[str]) -> str:
393394
pass
394395
print(f"Using Telegram session slot {idx + 1}/{len(pool)}.", file=sys.stderr)
395396
return session
396-
print(
397-
f"WARNING: all {len(pool)} pooled Telegram session(s) are already in use "
398-
"by other clients; reusing the first (may raise AuthKeyDuplicatedError). "
399-
"Add another session to TELEGRAM_SESSION_STRINGS to run more clients.",
400-
file=sys.stderr,
397+
# Handing out an already-claimed session here would make Telegram burn it
398+
# with AuthKeyDuplicatedError — losing the slot for the client that owns it
399+
# too. Refusing to start is recoverable; a burned session is not.
400+
raise RuntimeError(
401+
f"All {len(pool)} pooled Telegram session(s) are already claimed by other "
402+
"live clients, so this one has no session to use. Add another session to "
403+
"TELEGRAM_SESSION_STRINGS (generate it with "
404+
"`uv run session_string_generator.py`) — one slot per concurrent client — "
405+
"or stop one of the other clients."
401406
)
402-
return pool[0]
403407

404408

405409
def _discover_accounts() -> dict[str, TelegramClient]:
@@ -521,6 +525,7 @@ async def _call_for(label):
521525

522526
_last_conn_verified: dict[int, float] = {}
523527
_CONN_VERIFY_INTERVAL: float = 30.0 # seconds between live pings
528+
_RECONNECT_TIMEOUT: float = 30.0 # seconds before a reconnect attempt is abandoned
524529

525530

526531
async def _force_reconnect(cl: TelegramClient):
@@ -531,10 +536,26 @@ async def _force_reconnect(cl: TelegramClient):
531536
await cl.disconnect()
532537
except Exception:
533538
pass
534-
await cl.connect()
539+
try:
540+
await asyncio.wait_for(cl.connect(), timeout=_RECONNECT_TIMEOUT)
541+
except AuthKeyDuplicatedError as exc:
542+
# Telegram permanently invalidates an auth key used from two IPs at
543+
# once, so retrying here can never succeed — surface it instead of
544+
# letting the caller sit in a reconnect loop.
545+
raise RuntimeError(
546+
"Telegram session is no longer usable: the same session string was "
547+
"used by another client at the same time (AuthKeyDuplicatedError). "
548+
"Give each concurrent client its own session via "
549+
"TELEGRAM_SESSION_STRINGS or TELEGRAM_SESSION_STRING_<LABEL>, then "
550+
"regenerate the burned session with `uv run session_string_generator.py`."
551+
) from exc
552+
except asyncio.TimeoutError as exc:
553+
raise RuntimeError(
554+
f"Reconnecting to Telegram timed out after {_RECONNECT_TIMEOUT:.0f}s."
555+
) from exc
535556
if not await cl.is_user_authorized():
536557
reconnect_logger.warning("Client not authorized after reconnect, calling start()...")
537-
await cl.start()
558+
await asyncio.wait_for(cl.start(), timeout=_RECONNECT_TIMEOUT)
538559
_last_conn_verified[id(cl)] = time.time()
539560
reconnect_logger.warning("Forced reconnect successful")
540561

tests/test_runtime.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import json
23
from datetime import datetime, timezone
34
from pathlib import Path
@@ -430,6 +431,37 @@ async def test_ensure_connected_skips_recently_verified_client(monkeypatch):
430431
assert client.calls == ["is_connected"]
431432

432433

434+
class _HangingConnectClient(_ConnectivityClient):
435+
async def connect(self):
436+
self.calls.append("connect")
437+
await asyncio.sleep(3600)
438+
439+
440+
class _DuplicatedKeyClient(_ConnectivityClient):
441+
async def connect(self):
442+
from telethon.errors import AuthKeyDuplicatedError
443+
444+
self.calls.append("connect")
445+
raise AuthKeyDuplicatedError(request=None)
446+
447+
448+
@pytest.mark.asyncio
449+
async def test_force_reconnect_times_out_instead_of_hanging(monkeypatch):
450+
client = _HangingConnectClient(connected=False, authorized=True)
451+
monkeypatch.setattr(runtime, "_RECONNECT_TIMEOUT", 0.01)
452+
453+
with pytest.raises(RuntimeError, match="timed out"):
454+
await runtime._force_reconnect(client)
455+
456+
457+
@pytest.mark.asyncio
458+
async def test_force_reconnect_reports_burned_session(monkeypatch):
459+
client = _DuplicatedKeyClient(connected=False, authorized=True)
460+
461+
with pytest.raises(RuntimeError, match="no longer usable"):
462+
await runtime._force_reconnect(client)
463+
464+
433465
class _ResolvingClient:
434466
def __init__(self, method_name, failures):
435467
self.method_name = method_name

tests/test_session_pool.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ def test_acquire_session_skips_slot_locked_by_another_client(isolated_lock_dir):
5252
foreign.close()
5353

5454

55-
def test_acquire_session_falls_back_to_first_when_pool_exhausted(isolated_lock_dir):
55+
def test_acquire_session_raises_when_pool_exhausted(isolated_lock_dir):
5656
held = [_lock_slot(isolated_lock_dir, s) for s in ("AAA", "BBB")]
5757
try:
58-
# Only two slots exist and both are taken -> reuse the first.
59-
assert runtime._acquire_session(["AAA", "BBB"]) == "AAA"
58+
# Only two slots exist and both are taken -> refuse rather than hand out
59+
# a session another live client is already using.
60+
with pytest.raises(RuntimeError, match="already claimed"):
61+
runtime._acquire_session(["AAA", "BBB"])
6062
finally:
6163
for fh in held:
6264
fh.close()

0 commit comments

Comments
 (0)