Skip to content

Commit bba25f7

Browse files
authored
Merge pull request #186 from diyor28/fix/bound-reconnect-and-pool-exhaustion
fix: bound reconnect and fail fast when the session pool is exhausted
2 parents 9bcfd85 + f131971 commit bba25f7

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
@@ -329,6 +329,11 @@ Generate extra sessions with `uv run session_string_generator.py`. The pool
329329
takes precedence over `TELEGRAM_SESSION_STRING` for the default account. As an
330330
extra safety net, a transient `AuthKeyDuplicatedError` at connect time (e.g.
331331
during a VPN reconnect) is retried with backoff before the server gives up.
332+
333+
Size the pool to the number of clients you actually run concurrently. If every
334+
slot is already claimed, the server refuses to start with an explicit error
335+
rather than reusing a session another client holds — reuse would make Telegram
336+
permanently invalidate that session for both clients.
332337
- "Send this from my work account to @example"
333338

334339
## Device Identity

telegram_mcp/runtime.py

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

407411

408412
def _discover_accounts() -> dict[str, TelegramClient]:
@@ -524,6 +528,7 @@ async def _call_for(label):
524528

525529
_last_conn_verified: dict[int, float] = {}
526530
_CONN_VERIFY_INTERVAL: float = 30.0 # seconds between live pings
531+
_RECONNECT_TIMEOUT: float = 30.0 # seconds before a reconnect attempt is abandoned
527532

528533

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

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)