Telegram channel plugin: poller gives up on 409 after ~28 s and never recovers, while /mcp still reports "connected"
Summary
After a host reboot, the telegram channel came up in a state where /mcp listed the server as connected and outbound tools (reply, react, edit_message) worked normally, but no inbound Telegram message ever arrived. The channel only recovered after a manual /mcp reconnect.
The failure is completely silent: nothing in the UI distinguishes it from a healthy channel, and the assistant side cannot tell either, because sending still works.
Environment
| | |
|---|---|
| Claude Code | 2.1.220 |
| Plugin | telegram 0.0.6 from claude-plugins-official |
| Runtime | bun 1.3.14 |
| OS | Linux 6.18.39+rpt-rpi-2712 aarch64 (Raspberry Pi) |
| Session start | claude --channels plugin:telegram@claude-plugins-official |
Source referenced below: ~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6/server.ts (1038 lines).
Steps to reproduce
- Run a Claude Code session with the Telegram channel attached, so
server.tsis long-pollinggetUpdates. - Hard-reboot the host (power loss,
reboot -f) — anything that kills the process without letting the TCP connection close cleanly. Telegram keeps the single-consumer slot bound to the dead connection until its own timeout expires. - Within ~2–3 minutes of boot, start a new session with the same channel.
- The new poller hits
409 Conflict, burns through its eight attempts in ~28 s, and exits the polling loop. /mcpshows connected. Outbound tools work. Inbound messages are silently dropped for the entire life of the process.
Observed timeline (2026-08-03, MSK)
| Time | Event | Evidence |
|---|---|---|
| 08:10 | host boot | who -b |
| 08:12:45 | session with --channels plugin:telegram@… starts (pid 22297) | ps -o lstart |
| 08:12–08:15 | MCP reports connected; inbound messages do not arrive | user observation |
| ~08:15 | user runs /mcp → "Reconnected to plugin:telegram:telegram" | session transcript |
| 08:15:35 | a new bun server.ts starts (pid 97203) and works ever since | ps -o lstart |
The first poller process was alive the whole time — it just was not polling.
Root cause
server.ts:999-1038 retries bot.start() with backoff, but bails out permanently on 409:
const is409 = err instanceof GrammyError && err.error_code === 409
if (is409 && attempt >= 8) {
process.stderr.write(
`telegram channel: 409 Conflict persists after ${attempt} attempts — ` +
`another poller is holding the bot token (stray 'bun server.ts' process or a second session). Exiting.\n`,
)
return // ← polling is over for the life of the process
}
const delay = Math.min(1000 * attempt, 15000)
With delay = 1000 * attempt, the backoff before the eighth attempt totals 1+2+3+4+5+6+7 = 28 seconds — the cap of 15 s never even comes into play. That budget is far shorter than the window in which a post-reboot stale slot resolves itself.
After return, the process stays alive because MCP holds it on stdin, so:
/mcpshows connected;- outbound tools keep working — they use
bot.api, not polling; - inbound messages are dropped forever, with no signal anywhere.
Notably, the comment at server.ts:994-998 documents that this exact class of bug was already fixed once for network errors:
Previously only 409 was retried — a single ETIMEDOUT/ECONNRESET/DNS failure rejected bot.start(), the catch returned, and polling stopped permanently while the process stayed alive […] Outbound tools kept working but the bot was deaf to inbound messages until a full restart.
The 409 path kept the old give-up behaviour.
Impact
The user cannot tell the channel is dead. Everything the assistant sends arrives normally, so the channel looks healthy from both sides — only the human notices that replies stop coming, and only if they happen to be waiting for one. Recovery requires knowing to run /mcp, which is not discoverable from any symptom.
Suggested fixes (in order of value)
- Never stop retrying 409. Keep the same backoff, cap it (e.g. 60 s), and retry indefinitely. A stale slot is by definition temporary — either the zombie dies, or Telegram times out its connection. Giving up is only correct when a second live session is polling, and even then the losing side should stay in retry so it takes over when the other exits.
- If it must give up, say so. Exiting the poll loop should surface a visible signal: an MCP notification, a tool that reports poller state, or at minimum flipping the server to a non-connected status so
/mcpstops claiming health it does not have. - Expose poller health separately from process health. Today "connected" means "the MCP process answers on stdin". A
channel_statustool (polling: yes/no, last update at) would make the failure diagnosable from inside the session instead of by readingpsoutput.
Secondary issue: stale-PID kill can hit an unrelated process
server.ts:60-69 reads bot.pid and SIGTERMs whatever PID it finds, checking only that the process exists:
const stale = parseInt(readFileSync(PID_FILE, 'utf8'), 10)
if (stale > 1 && stale !== process.pid) {
process.kill(stale, 0)
process.stderr.write(`telegram channel: replacing stale poller pid=${stale}\n`)
process.kill(stale, 'SIGTERM')
}
After a reboot the PID file holds a number from the previous boot, and Linux will have reassigned it to an unrelated process. Suggested guard: confirm the target really is the poller before signalling it — e.g. read /proc/<pid>/cmdline and require it to contain server.ts, or write a boot id alongside the PID and ignore the file when it does not match.
Related issues (not duplicates)
- #46637 — same 409 symptom, different cause: zombie
bunholds the slot becauseprocess.ppidis cached. That issue is about who holds the slot; this one is about the new poller giving up on a slot that is about to free itself. - #74019, #82443 — orphaned pollers spinning at high CPU. Also about stale holders, not about the retry budget or the false "connected" state.