[BUG] Telegram channel plugin: starting a second session kills the running poller, leaving the channel permanently dead
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.
- 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
- 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"
- 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
- 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 loggedtelegram 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:
statSyncis already imported; usingwriteFileSyncfor the heartbeat avoids addingutimesSync.shuttingDownis declared later withlet; 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.tsper 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.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗