Telegram plugin: zombie bun processes after Claude Code exits (~99% CPU each)

Resolved 💬 2 comments Opened Apr 7, 2026 by OlegTestov Closed May 19, 2026

Problem

When Claude Code exits, the Telegram channel plugin (bun server.ts) keeps running as a zombie process consuming ~99% CPU. Each Claude Code session leaves behind an orphaned process, so they accumulate over time.

After a few days of normal use I had 4 zombie bun server.ts processes, each at 99% CPU and thousands of minutes of CPU time accumulated:

PID   %CPU  STARTED   TIME        COMMAND
26630 99.0  Wed11PM   2010:32.87  /Users/.../.bun/bin/bun server.ts
63451 99.0  Thu11PM   2010:16.14  /Users/.../.bun/bin/bun server.ts
50757 99.4  8:16PM    877:31.59   /Users/.../.bun/bin/bun server.ts
85726 98.9  Sun07PM   8:07.69     /Users/.../.bun/bin/bun server.ts

Root cause

Process chain: Claude Code → bun run (wrapper) → bun server.ts

server.ts has a shutdown handler on process.stdin.on('end', shutdown) (line 629), which should fire when Claude Code closes the MCP connection. However:

  1. When Claude Code exits, the intermediate bun run wrapper gets reparented to PID 1 (launchd) but stays alive
  2. Because bun run stays alive, it keeps stdin open for the child bun server.ts
  3. The stdin.on('end') handler never fires
  4. bun server.ts enters an infinite 409 Conflict retry loop (another instance already has the Telegram polling slot), burning CPU forever

Evidence — all parent bun run wrappers had PPID=1:

PPID  PID   COMMAND
1     26543 bun run --cwd ...telegram/0.0.4 --shell=bun --silent start
1     50502 bun run --cwd ...telegram/0.0.4 --shell=bun --silent start
1     63435 bun run --cwd ...telegram/0.0.4 --shell=bun --silent start
1     85721 bun run --cwd ...telegram/0.0.4 --shell=bun --silent start

Suggested fix

Add an orphan watchdog to server.ts that periodically checks if the grandparent (Claude Code) is still alive. If the parent's ppid becomes 1, it means Claude Code exited and the process should shut down:

// After the existing shutdown handlers (line 632):

if (process.platform !== 'win32') {
  const { spawnSync } = await import('child_process')
  const bootPpid = process.ppid
  const orphanCheck = setInterval(() => {
    try {
      process.kill(bootPpid, 0)
      const result = spawnSync('ps', ['-o', 'ppid=', '-p', String(bootPpid)])
      const grandPpid = result.stdout.toString().trim()
      if (grandPpid === '1') {
        process.stderr.write('telegram channel: orphan detected (grandparent died), shutting down\n')
        clearInterval(orphanCheck)
        shutdown()
      }
    } catch {
      process.stderr.write('telegram channel: parent process gone, shutting down\n')
      clearInterval(orphanCheck)
      shutdown()
    }
  }, 30_000)
  orphanCheck.unref()
}

Alternatively, the root cause could be fixed in Claude Code itself — by ensuring it sends SIGTERM to MCP server process groups (not just the direct child) on exit, or by using a process group so the entire tree gets cleaned up.

Environment

  • macOS 14.7 (Darwin 23.6.0)
  • Telegram plugin version: 0.0.4
  • Bun 1.x (from ~/.bun/bin/bun)

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗