Telegram channel plugin: poller gives up on 409 after ~28 s and never recovers, while /mcp still reports "connected"

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 0 comments · opened Aug 4, 2026

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

  1. Run a Claude Code session with the Telegram channel attached, so server.ts is long-polling getUpdates.
  2. 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.
  3. Within ~2–3 minutes of boot, start a new session with the same channel.
  4. The new poller hits 409 Conflict, burns through its eight attempts in ~28 s, and exits the polling loop.
  5. /mcp shows 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:

  • /mcp shows 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)

  1. 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.
  2. 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 /mcp stops claiming health it does not have.
  3. Expose poller health separately from process health. Today "connected" means "the MCP process answers on stdin". A channel_status tool (polling: yes/no, last update at) would make the failure diagnosable from inside the session instead of by reading ps output.

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 bun holds the slot because process.ppid is 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.

View original on GitHub ↗