[BUG] Telegram channel plugin: starting a second session kills the running poller, leaving the channel permanently dead

Status Open
Reported on v2.1.220
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 27, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

telegram@claude-plugins-official (v0.0.6) enforces a single Telegram long-poller via
~/.claude/channels/telegram/bot.pid. The startup takeover guard SIGTERMs any live holder, so a
newly started Claude Code session steals the channel from a session already using it. If that new
session is short-lived, it takes the poller down with it on exit and no poller remains — inbound
messages queue at Telegram forever with nothing draining them.

The failure is completely silent: no error surfaces in either session, and restarting does not help
while the thief is alive. The user-visible symptom is "my bot stopped responding".

Because the thief deletes bot.pid during its own shutdown, the diagnosable end state is no poller
process and no bot.pid at all
, with getWebhookInfo showing pending_update_count climbing and
no consumer.

Root cause

server.ts v0.0.6, lines 56–69:

// Telegram allows exactly one getUpdates consumer per token. If a previous
// session crashed (SIGKILL, terminal closed) its server.ts grandchild can
// survive as an orphan and hold the slot forever, so every new session sees
// 409 Conflict. Kill any stale holder before we start polling.
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
try {
  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')
  }
} catch {}
writeFileSync(PID_FILE, String(process.pid))

process.kill(pid, 0) throws ESRCH only when the process is dead. The branches are therefore
inverted relative to the intent stated in the comment directly above them:

  • holder dead → probe throws → catch → left alone (nothing to do, fine)
  • holder alive and healthy → probe succeeds → SIGTERM ← the bug

The comment says the guard exists to reap an orphaned server.ts left by a crashed session. But pid
liveness alone cannot distinguish an orphan from a healthy incumbent serving another session — both
are simply "a live pid" — so the guard evicts every incumbent.

The file already contains an orphan watchdog (setInterval, ~line 671) that self-terminates a
reparented or stdin-dead server within ~5 s. That already covers the crashed-session scenario the
startup kill claims to handle, which makes the unconditional kill largely redundant.

What Should Happen?

A second Claude Code session starting up should not terminate a healthy poller that another
session is actively using. It should detect that the channel is already served and defer — leaving
the incumbent running and exiting quietly — while still being able to reclaim the slot from a poller
that is genuinely dead or wedged.

Concretely: after starting session B alongside a working session A, session A's poller should still
be alive and still delivering Telegram messages, and closing B should leave A's channel untouched.

Error Messages/Logs

# Session B's stderr as it steals the channel from healthy session A (pid 14329):
telegram channel: replacing stale poller pid=14329
telegram channel: shutting down

# Resulting state — B has exited, A was killed, nothing is polling:
$ pgrep -f "bun .../telegram/0.0.6/server.ts" | wc -l
0
$ cat ~/.claude/channels/telegram/bot.pid
cat: bot.pid: No such file or directory     # deleted during B's shutdown

$ curl -s "https://api.telegram.org/bot$TOKEN/getWebhookInfo"
{"ok":true,"result":{"url":"","has_custom_certificate":false,"pending_update_count":1}}
# ^ messages queued at Telegram with no consumer; nothing in either session reports an error

Steps to Reproduce

Deterministic, and needs no real bot token — the pid logic runs before any network call, so a dummy
token is enough (the resulting 401 Unauthorized polling errors are expected and irrelevant). The
server honours TELEGRAM_STATE_DIR, so this does not touch a real channel.

  1. Set up an isolated state dir with a dummy token:
V=0.0.6
S=~/.claude/plugins/cache/claude-plugins-official/telegram/$V/server.ts
T=/tmp/tg-repro; rm -rf $T; mkdir -p $T
printf 'TELEGRAM_BOT_TOKEN=123456789:AAHdummy_token_for_pid_logic_only\n' > $T/.env
chmod 600 $T/.env
  1. Start server A as the incumbent, with stdin held open so it behaves like a live session:
TELEGRAM_STATE_DIR=$T tail -f /dev/null | TELEGRAM_STATE_DIR=$T bun $S > $T/a.out 2> $T/a.err &
sleep 4; A=$(cat $T/bot.pid); echo "A=$A"
  1. Start server B — a second, short-lived session — and let it exit:
TELEGRAM_STATE_DIR=$T bun $S < /dev/null > $T/b.out 2> $T/b.err
cat $T/b.err
  1. Observe that A was killed and nothing is left polling:
ps -p $A >/dev/null && echo "A ALIVE" || echo "A KILLED  <-- bug"
pgrep -f "bun $S" | wc -l     # => 0
cat $T/bot.pid                # => absent, deleted by B's shutdown

Actual output on v0.0.6:

A=14329
telegram channel: replacing stale poller pid=14329
telegram channel: shutting down
A KILLED  <-- bug
0
cat: bot.pid: No such file or directory

Real-world equivalent (no script): configure the Telegram channel, confirm the bot replies, then
start a second Claude Code session with the plugin enabled and close it again. The bot goes silent
permanently. Observed independently on three separate machines; on one, the stealing session logged
telegram channel: replacing stale poller pid=12233, then took SIGINT and exited ~2 s later,
leaving no poller behind.

Claude Model

None

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.220

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

Suggested fix

Require the holder to look wedged, not merely alive. The incumbent heartbeats; a starting server
defers unless the heartbeat is stale.

const HEARTBEAT_MS = 5_000
const HEARTBEAT_STALE_MS = 20_000
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
try {
  const holder = parseInt(readFileSync(PID_FILE, 'utf8'), 10)
  if (holder > 1 && holder !== process.pid) {
    let alive = true
    try { process.kill(holder, 0) } catch { alive = false }
    if (alive) {
      const ageMs = Date.now() - statSync(PID_FILE).mtimeMs
      if (ageMs < HEARTBEAT_STALE_MS) {
        process.stderr.write(
          `telegram channel: another session already holds the channel ` +
          `(pid=${holder}, heartbeat ${Math.round(ageMs / 1000)}s ago) — deferring to it.\n`)
        process.exit(0)
      }
      process.stderr.write(`telegram channel: taking over wedged poller pid=${holder}\n`)
      process.kill(holder, 'SIGTERM')
    }
  }
} catch {}
writeFileSync(PID_FILE, String(process.pid))

// heartbeat — rewriting the pid file refreshes its mtime, which is what lets a
// later server tell "healthy incumbent" from "wedged orphan"
setInterval(() => {
  try { if (!shuttingDown) writeFileSync(PID_FILE, String(process.pid)) } catch {}
}, HEARTBEAT_MS).unref()

Notes:

  • statSync is already imported; using writeFileSync for the heartbeat avoids adding utimesSync.
  • shuttingDown is declared later with let; the interval callback only runs after module

evaluation, and the guard stops the heartbeat resurrecting the pid file shutdown() deletes.

  • unref() so the timer never by itself keeps the process alive.
  • Orphan recovery is preserved: an orphan stops heartbeating and is reclaimable after 20 s, and the

existing ppid/stdin watchdog still reaps it within ~5 s.

Verification

Patched locally and tested in an isolated TELEGRAM_STATE_DIR. bun build server.ts --target=bun
compiles clean (243 modules).

| Scenario | Expected | Result |
|---|---|---|
| Second server starts, incumbent heartbeat 4 s old | defer, incumbent survives | pass — deferring to it (pid=14088, heartbeat 4s ago), exit 0, incumbent alive, pid file unchanged |
| Incumbent SIGSTOPped, pid-file mtime backdated past the 20 s threshold | take over | pass — taking over wedged poller pid=14088 (no heartbeat for 129s), pid file reclaimed to new pid |
| Same harness against unpatched v0.0.6 | (control) | incumbent killed, zero pollers remain |

Not duplicates of

GitHub flagged potential duplicates; checked each:

  • #81107 (open) — leaks one 100%-CPU bun server.ts per session. Cites the same block

(server.ts:60-68) but the opposite direction: SIGTERM is a no-op against an incumbent whose
event loop is wedged. Distinct, and this fix does not resolve it — a wedged process ignores
SIGTERM either way. Same nine lines though; probably best fixed together.

  • #75626 (open) — plugin connects then gets SIGINT'd with --continue. Harness-level session

teardown; no bot.pid, no sibling plugin process, different signal.

  • #66106 (closed, not_planned) — concurrent sessions racing the single-slot pid file; earlier

processes lose their pid record and spin on 409. Complains the takeover misses processes, not
that it kills healthy ones.

  • #45852 (closed, not_planned) — zombie accumulation; different mechanism, no mention of the guard.
  • #79276 (open) — explicitly rules out this guard as its cause.

Impact

Affects anyone running more than one Claude Code session concurrently, which is routine for
multi-agent and worker-session setups. Because the plugin lives in a version-pinned cache directory,
a local patch is wiped by the next plugin upgrade, so there is no durable user-side workaround.

View original on GitHub ↗

3 Comments

bcherny collaborator · 1 month ago

Reproduced, including on the current plugin version 0.0.7. Using the dummy-token steps from the report (isolated state dir, no real bot involved): a second server instance logs replacing stale poller pid=<incumbent>, terminates the healthy incumbent that another session is actively using, and — being short-lived — then exits and removes the pid file on its own shutdown. End state matches the report exactly: no poller process left, no pid file, and nothing draining the bot's updates, with no error surfaced in either session.

The takeover logic changed slightly in 0.0.7 (it now verifies the recorded pid actually belongs to a poller process before terminating it, which guards against pid reuse) but that doesn't address this report: the check confirms the incumbent is a live poller and then still evicts it. Liveness alone can't distinguish an orphaned leftover poller from a healthy one serving another running session, so starting and closing a second Claude Code session with the plugin enabled still silently takes the channel down.

Marking as reproduced; this needs a fix in the Telegram channel plugin's single-poller takeover behavior so a healthy incumbent is left running and only genuinely orphaned/dead pollers are reclaimed.

🤖 Generated with Claude Code

marcindulak · 19 days ago

For your information: the label reproduced on this issue does not prevent it from becoming stale and eventually auto-closed. See https://github.com/anthropics/claude-code/issues/87647 for details, and consider upvoting that for visibility.

This means to avoid auto-closing, this issue needs to acquire at least 10 thumbs up, or get new comments every time before the 14 days stale bot interval expires.

k1p1l0 · 19 days ago

Two workarounds until the takeover logic changes. Enable the plugin at project scope in the bot's folder only (see #84108), so other sessions never start a poller. Or decide who may take over the poller by an explicit role rather than by whether the holder is alive: in my fork, the long-running host starts with TELEGRAM_DAEMON_MODE=1 and is the only instance allowed to evict. Any other instance that finds a live server.ts holding the lock runs tools-only, and the supervisor cleans up orphans reparented to PID 1 at startup. That keeps one healthy poller on my machine: https://github.com/k1p1l0/claude-telegram-supercharged (see acquireLock; I maintain it).