Skip to content

Commit a612943

Browse files
authored
Merge pull request #172 from marksmeed/feature/session-lock
Prevent AuthKeyDuplicatedError from concurrent server instances
2 parents dc8c855 + 37e62a2 commit a612943

4 files changed

Lines changed: 247 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ Telegram messages, display names, chat titles, and button labels are untrusted c
592592
interactive phone-code login over stdio.
593593
- **Invalid API credentials:** verify `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` at [my.telegram.org/apps](https://my.telegram.org/apps).
594594
- **Database is locked:** prefer string sessions, or make sure no other process is using the same file session.
595+
- **`AuthKeyDuplicatedError` / "Another telegram-mcp process is already connected with this session":** two processes tried to connect the same Telegram session at once (e.g. an MCP client restarted the connector before the old process exited), which Telegram rejects and can invalidate the session for both. The server now takes an exclusive lock per session before connecting; a second concurrent launch waits briefly (default 20s, override with `TELEGRAM_LOCK_GRACE_SECONDS`) for the first to release it and otherwise exits without ever calling `connect()`, instead of racing into a duplicate connection. Retry once only one instance is running.
595596
- **File tools are disabled:** pass allowed roots or configure MCP Roots in your client.
596597
- **Path rejected:** ensure the path is inside an allowed root and does not use traversal or wildcard patterns.
597598
- **Auth errors after password changes:** regenerate your session string.

telegram_mcp/runner.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,44 @@
1111

1212
from telegram_mcp import runtime as _runtime
1313
from telegram_mcp.runtime import *
14+
from telegram_mcp.singleton import (
15+
DEFAULT_GRACE_SECONDS,
16+
SessionLock,
17+
SessionLockError,
18+
session_identity,
19+
)
1420
import telegram_mcp.tools # noqa: F401 - registers MCP tools via decorators
1521

22+
# Populated as each account's session lock is acquired; released in _main's
23+
# finally block so a lock is never held past this process's lifetime.
24+
_session_locks: dict[str, SessionLock] = {}
25+
26+
27+
def _lock_grace_seconds() -> float:
28+
raw = os.getenv("TELEGRAM_LOCK_GRACE_SECONDS")
29+
if not raw:
30+
return DEFAULT_GRACE_SECONDS
31+
try:
32+
return float(raw)
33+
except ValueError:
34+
return DEFAULT_GRACE_SECONDS
35+
1636

1737
async def _connect_authorized_client(label, client) -> None:
18-
# Tolerate a transient AuthKeyDuplicatedError (the same session briefly seen
19-
# from two IPs, e.g. during a VPN reconnect) with a bounded retry so a blip
20-
# does not take the whole server down. Give each concurrent client its own
21-
# session (TELEGRAM_SESSION_STRINGS pool or TELEGRAM_SESSION_STRING_<LABEL>)
22-
# to avoid the collision entirely.
38+
# First, prevent our own duplicate-spawn case outright: an exclusive
39+
# per-session lock means a second instance of this server never even
40+
# attempts to connect while another instance already holds the same
41+
# session (see telegram_mcp/singleton.py for why and how).
42+
lock = SessionLock(label, session_identity(client))
43+
await asyncio.to_thread(lock.acquire, grace_seconds=_lock_grace_seconds())
44+
_session_locks[label] = lock
45+
46+
# Once we hold the lock, still tolerate a transient AuthKeyDuplicatedError
47+
# from Telegram itself (e.g. the same session briefly seen from two IPs
48+
# during a VPN reconnect) with a bounded retry, since that's not caused by
49+
# a second instance of this server and a blip shouldn't take the server
50+
# down. Give each concurrent client its own session (TELEGRAM_SESSION_STRINGS
51+
# pool or TELEGRAM_SESSION_STRING_<LABEL>) to avoid the collision entirely.
2352
max_attempts = 4
2453
for attempt in range(1, max_attempts + 1):
2554
try:
@@ -137,6 +166,15 @@ async def _warm_caches() -> None:
137166
"Database lock detected. Please ensure no other instances are running.",
138167
file=sys.stderr,
139168
)
169+
elif isinstance(e, SessionLockError):
170+
print(
171+
"Another instance of this MCP server already holds this Telegram "
172+
"session (e.g. the client restarted the connector without the old "
173+
"process exiting yet). This instance is exiting instead of "
174+
"connecting a second time, which would risk Telegram invalidating "
175+
"the session for both. Retry once the other instance is gone.",
176+
file=sys.stderr,
177+
)
140178
sys.exit(1)
141179
finally:
142180
try:
@@ -145,6 +183,9 @@ async def _warm_caches() -> None:
145183
)
146184
except Exception:
147185
pass
186+
for lock in _session_locks.values():
187+
lock.release()
188+
_session_locks.clear()
148189

149190

150191
def main() -> None:

telegram_mcp/singleton.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Per-session file lock guarding against concurrent Telegram connections.
2+
3+
MCP clients (Claude Desktop in particular) sometimes spawn more than one
4+
instance of this server for the same configured session -- most commonly
5+
when a connector is restarted and the old process hasn't exited yet before
6+
the new one starts. Two processes calling ``TelegramClient.connect()`` /
7+
``start()`` with the same auth key at the same time trips Telegram's abuse
8+
protection (``AuthKeyDuplicatedError``), which can knock out *both*
9+
connections rather than just the newcomer.
10+
11+
This module provides an OS-level advisory file lock (``flock`` on POSIX,
12+
``msvcrt.locking`` on Windows) keyed by the session's actual identity (its
13+
string value or file path), not just the account label, since two different
14+
projects/labels could otherwise collide or one label could map to different
15+
sessions across deployments. The lock is held for the lifetime of the
16+
process and is released automatically by the OS if the process exits or is
17+
killed, so there is no stale-lock file to clean up.
18+
19+
A second instance racing for the same session waits briefly (covers the
20+
"restart replaces the old process" case) and, if the lock is still held once
21+
the grace period elapses, raises :class:`SessionLockError` instead of ever
22+
calling ``connect()`` -- so the live session is never disturbed.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import hashlib
28+
import os
29+
import tempfile
30+
import time
31+
from pathlib import Path
32+
from typing import IO, Optional
33+
34+
if os.name == "nt":
35+
import msvcrt
36+
37+
def _try_lock(fh: IO) -> bool:
38+
try:
39+
fh.seek(0)
40+
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
41+
return True
42+
except OSError:
43+
return False
44+
45+
def _unlock(fh: IO) -> None:
46+
try:
47+
fh.seek(0)
48+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
49+
except OSError:
50+
pass
51+
52+
else:
53+
import fcntl
54+
55+
def _try_lock(fh: IO) -> bool:
56+
try:
57+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
58+
return True
59+
except OSError:
60+
return False
61+
62+
def _unlock(fh: IO) -> None:
63+
try:
64+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
65+
except OSError:
66+
pass
67+
68+
69+
DEFAULT_LOCK_DIR = Path(tempfile.gettempdir()) / "telegram-mcp-locks"
70+
DEFAULT_GRACE_SECONDS = 20.0
71+
DEFAULT_POLL_INTERVAL = 0.5
72+
73+
74+
class SessionLockError(RuntimeError):
75+
"""Raised when a session lock isn't acquired before its grace period elapses."""
76+
77+
78+
class SessionLock:
79+
"""Exclusive, OS-released lock for one Telegram session, held for process lifetime."""
80+
81+
def __init__(self, label: str, session_identity: str, *, lock_dir: Path = DEFAULT_LOCK_DIR):
82+
digest = hashlib.sha256(session_identity.encode("utf-8")).hexdigest()[:16]
83+
lock_dir.mkdir(parents=True, exist_ok=True)
84+
self.path = lock_dir / f"{label}-{digest}.lock"
85+
self._fh: Optional[IO] = None
86+
87+
def acquire(
88+
self,
89+
*,
90+
grace_seconds: float = DEFAULT_GRACE_SECONDS,
91+
poll_interval: float = DEFAULT_POLL_INTERVAL,
92+
) -> None:
93+
"""Block (up to ``grace_seconds``) until the lock is free, then take it.
94+
95+
Raises :class:`SessionLockError` if another live process still holds
96+
the lock once the grace period elapses.
97+
"""
98+
fh = open(self.path, "a+")
99+
deadline = time.monotonic() + grace_seconds
100+
while True:
101+
if _try_lock(fh):
102+
self._fh = fh
103+
return
104+
if time.monotonic() >= deadline:
105+
fh.close()
106+
raise SessionLockError(
107+
"Another telegram-mcp process is already connected with this "
108+
f"session (lock held: {self.path}). Refusing to connect a "
109+
"second time to avoid Telegram's AuthKeyDuplicatedError. If "
110+
"that other process already exited, this lock will clear on "
111+
"its own -- retry."
112+
)
113+
time.sleep(poll_interval)
114+
115+
def release(self) -> None:
116+
if self._fh is not None:
117+
_unlock(self._fh)
118+
self._fh.close()
119+
self._fh = None
120+
121+
122+
def session_identity(client: object) -> str:
123+
"""Derive a stable identity string for a ``TelegramClient``'s session.
124+
125+
File-based sessions key on the absolute file path; string sessions key on
126+
their serialized value (same auth key -> same identity, regardless of
127+
which process or working directory loaded it). Falls back to the
128+
client's object id for sessionless test doubles.
129+
"""
130+
session = getattr(client, "session", None)
131+
if session is not None:
132+
filename = getattr(session, "filename", None)
133+
if filename:
134+
return f"file:{os.path.abspath(filename)}"
135+
save = getattr(session, "save", None)
136+
if callable(save):
137+
try:
138+
saved = save()
139+
except Exception:
140+
saved = None
141+
if saved:
142+
return f"string:{saved}"
143+
return f"anon:{id(client)}"

tests/test_runner.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,20 @@
33
from telegram_mcp import runner
44

55

6+
class _FakeSession:
7+
def __init__(self, identity: str):
8+
self._identity = identity
9+
10+
def save(self):
11+
return self._identity
12+
13+
614
class _FakeClient:
7-
def __init__(self, *, authorized: bool):
15+
def __init__(self, *, authorized: bool, identity: str = "test-identity"):
816
self.authorized = authorized
917
self.connected = False
1018
self.started = False
19+
self.session = _FakeSession(identity)
1120

1221
async def connect(self):
1322
self.connected = True
@@ -19,6 +28,25 @@ async def start(self):
1928
self.started = True
2029

2130

31+
@pytest.fixture(autouse=True)
32+
def _isolate_session_locks(tmp_path, monkeypatch):
33+
# Give each test its own lock directory (so locks don't leak across tests
34+
# or collide with a real telegram-mcp instance running on the machine)
35+
# and a near-zero grace period (so a deliberately-contested lock in a
36+
# test fails fast instead of sleeping through the real default).
37+
import telegram_mcp.singleton as singleton_module
38+
39+
original_init = singleton_module.SessionLock.__init__
40+
41+
def _init_with_tmp_dir(self, label, session_identity, *, lock_dir=tmp_path):
42+
original_init(self, label, session_identity, lock_dir=lock_dir)
43+
44+
monkeypatch.setattr(singleton_module.SessionLock, "__init__", _init_with_tmp_dir)
45+
monkeypatch.setattr(runner, "_lock_grace_seconds", lambda: 0.01)
46+
yield
47+
runner._session_locks.clear()
48+
49+
2250
@pytest.mark.asyncio
2351
async def test_connect_authorized_client_uses_existing_session_without_interactive_start():
2452
client = _FakeClient(authorized=True)
@@ -40,6 +68,34 @@ async def test_connect_authorized_client_rejects_unauthorized_session():
4068
assert client.started is False
4169

4270

71+
@pytest.mark.asyncio
72+
async def test_connect_authorized_client_refuses_concurrent_duplicate_session():
73+
first = _FakeClient(authorized=True, identity="shared-session")
74+
second = _FakeClient(authorized=True, identity="shared-session")
75+
76+
await runner._connect_authorized_client("default", first)
77+
78+
with pytest.raises(runner.SessionLockError, match="already connected"):
79+
await runner._connect_authorized_client("default", second)
80+
81+
assert second.connected is False
82+
83+
runner._session_locks["default"].release()
84+
runner._session_locks.clear()
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_connect_authorized_client_allows_different_sessions_concurrently():
89+
first = _FakeClient(authorized=True, identity="session-a")
90+
second = _FakeClient(authorized=True, identity="session-b")
91+
92+
await runner._connect_authorized_client("default", first)
93+
await runner._connect_authorized_client("work", second)
94+
95+
assert first.connected is True
96+
assert second.connected is True
97+
98+
4399
class _FakeSettings:
44100
def __init__(self):
45101
self.host = None

0 commit comments

Comments
 (0)