Skip to content

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

Description

@anish-nair-410

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • 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:

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreproducedBug reproduced by maintainers on a released build

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions