Telegram plugin: zombie bun processes consume 100% CPU after session close

Status Closed — not planned
Maintainer reply None cached
Activity 8 comments · opened Apr 3, 2026 · closed May 28, 2026

Bug

When a Claude Code session ends without cleanly closing stdin (e.g. force quit, crash, or the stdin pipe not being torn down properly), the Telegram plugin's bun server.ts process becomes orphaned (PPID becomes 1) and enters an infinite 409-retry loop in bot.start(), consuming 100% CPU permanently. Multiple sessions produce multiple zombies — I had 3 orphaned processes each at 100% CPU (984 minutes of cumulative CPU time).

Root cause

Two issues in external_plugins/telegram/server.ts:

  1. Cleanup relies solely on stdin EOF (process.stdin.on('end', shutdown) at line 629-630) — there is no fallback mechanism (PID lock file, parent-process heartbeat, etc.) to detect that the parent Claude Code process is gone.
  1. The 409 Conflict retry loop (line 960) has no max-attempt cap — when a zombie holds the polling token, new instances retry forever with only 15s max backoff. Meanwhile the zombie itself is also looping. All of them burn CPU indefinitely.
// line 959-988 — infinite loop, no exit condition
void (async () => {
  for (let attempt = 1; ; attempt++) {
    try {
      await bot.start({ ... })
      return
    } catch (err) {
      if (err instanceof GrammyError && err.error_code === 409) {
        const delay = Math.min(1000 * attempt, 15000)
        // retries forever...
        await new Promise(r => setTimeout(r, delay))
        continue
      }
      ...
    }
  }

Suggested fix

  • Add a max retry count (e.g. 10-20) to the 409 loop — exit the process after exhausting retries.
  • Implement a PID-file lock or parent-process liveness check so orphaned processes can detect the parent is gone and self-terminate.

Environment

  • macOS Darwin 25.4.0
  • Telegram plugin version 0.0.4
  • Bun runtime

View original on GitHub ↗

8 Comments

akorenevski · 6 months ago

+1 — confirmed on macOS Darwin 25.3.0, Telegram plugin v0.0.4. Accumulated 12+ orphaned bun server.ts processes over 3 days (some since March 31), two pegging 65% CPU each. Plugin disabled as workaround. Processes required kill -9 — regular SIGTERM was ignored.

BVCampos · 6 months ago

+1, reproducing on macOS 15.7.4 (Darwin 24.6.0), Claude Code 2.1.97, Telegram plugin v0.0.4, Bun 1.3.11.

A data point on how bad this gets in the wild — a user came to me convinced their Mac battery was failing (slow charging, fast drain). Real cause was load average ~9 from 6 stacked bun server.ts processes, each pegged at 100% CPU:

| PID | age | state |
|---|---|---|
| 13490 | 8d 3h | orphan |
| 19787 | 8d 3h | orphan |
| 47956 | 8d 1h | orphan |
| 2859 | 5d 22h | orphan |
| 57914 | 4d 1h | orphan |
| 66421 | 5h 27m | active (current session) |

Two observations worth adding to the existing root-cause analysis:

  1. The currently-live process was also at 100% CPU, not just the orphans. The infinite 409-retry loop explained in the issue body covers why new instances don't crash when zombies hold the polling token, but it doesn't explain why healthy active processes also burn a full core — there's a second hot loop firing even for the single legitimate instance.
  1. SIGTERM was completely ignored on all 6 processes — only kill -9 worked. That rules out "stuck on async I/O" and points at a blocked event loop (synchronous tight loop). The shutdown handler's 2-second setTimeout force-exit fallback at server.ts:626 won't fire under those conditions either, so adding a liveness-based fallback won't help unless the sync-loop root cause is also fixed.

Workaround for anyone landing here: disable via enabledPlugins in ~/.claude/settings.json, then kill -9 $(pgrep -f 'bun.*server\.ts').

---
Posted by Claude (Opus 4.6) on behalf of @BVCampos, after diagnosing his machine in a Claude Code session. Process evidence and environment details were gathered directly from his system.

Ozy311 · 5 months ago

Reproducing on Linux (Ubuntu 22.04, kernel 6.8) — adds platform coverage. Same pattern: plugin versions 0.0.5 and 0.0.6 both affected. Zombies recur within ~2 hours of clearing — killed two orphans this morning and a new one (plugin 0.0.6) appeared 1h 45m later, same 100% CPU signature.

Forensic detail from a live zombie (PID 319988, 22 threads, State R):

  • No TCP connections — the process has no sockets open to Telegram's API. Failure mode isn't network retry storm.
  • stdin/stdout/stderr are orphaned sockets — FDs 0/1/2 point to socket inodes whose peer (the Claude Code MCP stdio pipe) is gone. No SIGPIPE/EPIPE delivered, and grammy's polling timers keep firing.
  • 3 active timerfds + eventfd + eventpoll — classic busy event loop with no downstream work.
  • PPID=1 (reparented to init) — original Claude Code session already exited cleanly.

Suggests the fix belongs at the MCP stdio shutdown path: detect peer-closed stdio and exit the bun process, rather than relying on grammy to notice via a network error that never comes.

someone-eng · 5 months ago

Reproducing on Linux (Ubuntu 24.04.4 LTS, Node v24.13.0, Claude Code 2.1.116) — the current platform:macos label is narrower than the actual footprint.

Additional trigger beyond session close: /reload-plugins also leaks. Every reload leaves the old bun server.ts orphaned (PPID becomes 1), and multiple zombies compete for Telegram's long-polling getUpdates (the Bot API allows only one client at a time), causing 409 Conflict loops at ~100% CPU each. This also explains the observed symptom where Telegram MCP disconnects ~1–2 minutes after every /reload-plugins, then works briefly, then breaks again as zombies fight over the poll connection.

Observed today on an otherwise-idle workstation after ~4 /reload-plugins cycles:

PID 889464  PPID=1  39:26 elapsed  97.2% CPU  /home/yifeng/.bun/bin/bun server.ts
PID 894144  PPID=1  35:58 elapsed  96.8% CPU  /home/yifeng/.bun/bin/bun server.ts
PID 897197  PPID=1  33:41 elapsed  96.5% CPU  /home/yifeng/.bun/bin/bun server.ts
PID 900105  PPID=1  31:29 elapsed  96.2% CPU  /home/yifeng/.bun/bin/bun server.ts
PID 908098  PPID=1  25:34 elapsed  100%  CPU  /home/yifeng/.bun/bin/bun server.ts

SIGTERM is ignored (the retry loop traps it but yields rarely enough that the signal handler never runs) — SIGKILL cleans up.

Suggested fix: the plugin host should track MCP-server PIDs (or their process groups) and SIGKILL them before spawning replacements on /reload-plugins, in addition to cleaning up on session close.

rbah31 · 5 months ago

+1, adding a new trigger and a PPID pattern that existing workarounds miss.

Env: macOS 15 (Darwin 24.6.0), Apple M4 Pro, Telegram plugin v0.0.6, Bun latest.

Trigger: Claude Code launched from a Cursor integrated terminal

Starting claude inside Cursor's integrated terminal and closing the tab (or reload-window) reliably produces zombies — 3 stacked at ~100% CPU for 6+ hours in my repro. The same Claude Code launched from Terminal.app / iTerm doesn't reproduce on normal close. Suggests Cursor doesn't cascade SIGHUP through the shell → bun run wrapper → bun server.ts chain when tearing down an integrated terminal.

PPID chain pattern — existing ppid=1 workarounds miss this

In most reports, bun server.ts has PPID=1 directly. Here the chain is two-level:

bun server.ts (zombie, 100% CPU, STAT=R)  →  PPID = bun run wrapper (alive, 0% CPU, STAT=S)
bun run wrapper                           →  PPID = 1 (init)

The bun run --cwd ... --silent start wrapper survives orphaned with PPID=1, but its child server.ts keeps the wrapper as direct PPID. So "kill where PPID=1" misses these — and so would a fixed process.ppid (per #46637), since the direct parent is literally alive. The watchdog needs to walk one level up the PPID chain.

Suggested fix — portable orphan detection (drop-in for server.ts:657-664)

Works on macOS (no /proc) via ps, covers both direct-orphan and wrapper-orphan cases:

import { execSync } from 'child_process'

function getRealPpid(pid: number): number {
  try {
    const out = execSync(`ps -o ppid= -p ${pid}`, {
      encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
    })
    const n = parseInt(out.trim(), 10)
    return Number.isFinite(n) ? n : -1
  } catch { return -1 }
}

function isOrphaned(): boolean {
  if (process.platform === 'win32') {
    return process.stdin.destroyed || process.stdin.readableEnded
  }
  const myPpid = getRealPpid(process.pid)
  if (myPpid <= 1) return true
  // Wrapper-orphan: our parent is alive but *its* parent is init.
  const gppid = getRealPpid(myPpid)
  if (gppid <= 1) return true
  return process.stdin.destroyed || process.stdin.readableEnded
}

setInterval(() => { if (isOrphaned()) shutdown() }, 5000).unref()

User-side hotfix handling the two-level chain

Extension of the ppid=1 script from #46637:

for pid in $(pgrep -f 'bun.*server\.ts' 2>/dev/null); do
  ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
  [ -z "$ppid" ] && continue
  if [ "$ppid" = "1" ]; then
    kill -9 "$pid"
  else
    gppid=$(ps -o ppid= -p "$ppid" 2>/dev/null | tr -d ' ')
    [ "$gppid" = "1" ] && kill -9 "$pid" "$ppid"
  fi
done
rm -f ~/.claude/channels/telegram/bot.pid
github-actions[bot] · 4 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

Sugumaran-Balasubramaniyan · 4 months ago

Filed #66309 independently: Diagnosed the root cause as the orphan watchdog checking process.ppid !== bootPpid, but missing the case where bun run (the direct parent) gets reparented to init when CC exits. server.ts's own ppid never changes, so the watchdog doesn't fire. Fix: read grandparent PID from /proc/<ppid>/status at startup and check for reparenting. Patch available in #66309.

github-actions[bot] · 2 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.