telegram plugin: leaks one 100%-CPU `bun server.ts` per session — SIGTERM, bot.pid eviction, and orphan watchdog are all defeated by a wedged event loop
Summary
The official telegram plugin leaks one bun server.ts process per Claude Code session. On my Mac mini 52 had accumulated since 16 July, together consuming 827% CPU (~8 of 10 M4 cores) and 17% RAM permanently. Each survives indefinitely; nothing ever reclaims them.
There are three independent cleanup mechanisms in server.ts, and all three are unable to run, for the same underlying reason.
Root cause
The server process wedges its event loop — a single server with no competitor sits at a sustained 98–100% CPU (measured over 10s, five samples, one process, no 409 contention). Once the loop is wedged, no JS callback can execute. That defeats every shutdown path, because all of them are JS callbacks:
SIGTERMhandler (server.ts:663) →shutdown()→setTimeout(() => process.exit(0), 2000)(server.ts:658). AsetTimeouton a wedged loop never fires, so the process ignores SIGTERM entirely.kill -TERM <pid>returns exit status 0 while the process keeps running; onlySIGKILLworks.
- Startup eviction (
server.ts:60-68) SIGTERMs the PID recorded inbot.pid. Per (1) that signal is a no-op — so each new session's "evict the previous instance" step silently fails, and its predecessor survives. This is the accumulation mechanism: one leaked process per session.
- Orphan watchdog (
server.ts:671-677) is asetInterval, so it cannot tick on a wedged loop either. Its logic is also independently wrong — see below.
Secondary bug: the orphan watchdog's parent check
Even on a healthy loop, the watchdog could not detect this failure mode:
const bootPpid = process.ppid
setInterval(() => {
const orphaned =
(process.platform !== 'win32' && process.ppid !== bootPpid) ||
process.stdin.destroyed ||
process.stdin.readableEnded
if (orphaned) shutdown()
}, 5000).unref()
The immediate parent is the bun run wrapper, which is orphaned alongside server.ts rather than dying — so process.ppid never changes and the first clause is permanently false. The same wrapper holds the stdin pipe open, defeating the stdin.destroyed / readableEnded fallbacks. Both escape hatches are neutralised by the same intermediate process.
Worth noting for anyone writing an external reaper: the wrapper is detached, so a legitimate server is reparented to launchd (PID 1) exactly like a leaked one. "Grandparent is launchd" is therefore not a valid orphan test — it selects the live server too.
Reproduction
- Start a Claude Code session with
--channels plugin:telegram@claude-plugins-official. - Exit it uncleanly (kill the terminal, or let the machine sleep/reboot the session).
pgrep -f "bun server.ts" | wc -l— the count increments and never decreases.kill -TERM <pid>; echo $?→0, thenkill -0 <pid>→ still alive.- Sample CPU:
ps -o %cpu= -p <pid>→ ~100% sustained even when it is the only server running.
Environment
- macOS 26.5 (Darwin 25.5.0), Apple M4, 16 GB
- Claude Code 2.1.211, plugin
telegramfromclaude-plugins-official - bun (
~/.bun/bin/bun) - Observed 2026-07-25; oldest leaked process dated 17 July
Suggested fixes
- Find and fix the event-loop spin. ~100% CPU on an idle long-poller is the primary defect; every other symptom follows from it. Worth checking the grammy long-poll + AbortSignal path under bun specifically.
- Don't rely on JS timers for shutdown.
process.exit()should be reachable without the loop turning — call it synchronously in the signal handler afterbot.stop()is requested, rather than viasetTimeout. - Make eviction use SIGKILL after a short SIGTERM grace. As written, the
bot.pideviction cannot ever succeed against a wedged process. - Fix or drop the ppid watchdog. Checking the immediate parent cannot work while the
bun runwrapper sits in between. Either remove the wrapper indirection, or walk the ancestor chain, or key on something else entirely (e.g. a heartbeat file the parent touches). - Consider a bounded reaper at startup that kills stale servers by age rather than by PID identity, since
bot.pidis deleted on exit and is frequently absent.
Workaround
Until this is fixed I reap them from a SessionStart hook — SIGKILL every bun server.ts that is both not the newest and older than 10 minutes, with a guard that refuses to kill all of them. Patching server.ts directly isn't durable since plugin code is replaced on update.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗