[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 None cached
Activity 1 comment · opened Jul 27, 2026

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 ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗