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

Status Open
Reported on v2.1.211
Maintainer reply None cached
Activity 1 comment · opened Jul 25, 2026

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:

  1. SIGTERM handler (server.ts:663)shutdown()setTimeout(() => process.exit(0), 2000) (server.ts:658). A setTimeout on a wedged loop never fires, so the process ignores SIGTERM entirely. kill -TERM <pid> returns exit status 0 while the process keeps running; only SIGKILL works.
  1. Startup eviction (server.ts:60-68) SIGTERMs the PID recorded in bot.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.
  1. Orphan watchdog (server.ts:671-677) is a setInterval, 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

  1. Start a Claude Code session with --channels plugin:telegram@claude-plugins-official.
  2. Exit it uncleanly (kill the terminal, or let the machine sleep/reboot the session).
  3. pgrep -f "bun server.ts" | wc -l — the count increments and never decreases.
  4. kill -TERM <pid>; echo $?0, then kill -0 <pid> → still alive.
  5. 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 telegram from claude-plugins-official
  • bun (~/.bun/bin/bun)
  • Observed 2026-07-25; oldest leaked process dated 17 July

Suggested fixes

  1. 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.
  2. Don't rely on JS timers for shutdown. process.exit() should be reachable without the loop turning — call it synchronously in the signal handler after bot.stop() is requested, rather than via setTimeout.
  3. Make eviction use SIGKILL after a short SIGTERM grace. As written, the bot.pid eviction cannot ever succeed against a wedged process.
  4. Fix or drop the ppid watchdog. Checking the immediate parent cannot work while the bun run wrapper 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).
  5. Consider a bounded reaper at startup that kills stale servers by age rather than by PID identity, since bot.pid is 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.

View original on GitHub ↗

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