Telegram MCP plugin doesn't exit when Claude Code closes (orphaned bun server.ts processes accumulate over uptime)

Open 💬 1 comment Opened Jul 3, 2026 by zxyasa

Summary

The telegram plugin from claude-plugins-official runs an MCP server over stdio (StdioServerTransport). When the Claude Code parent process exits, the MCP server should detect the closed stdin and exit gracefully — but instead it stays alive as an orphaned process (ppid=1) and keeps running indefinitely.

Over multi-day / multi-week uptime, these orphans accumulate to dozens of processes, each consuming 50–80% CPU (the grammy Telegram bot library keeps the event loop hot), driving system load average past 100 on a 10-core machine.

Environment

| | |
|---|---|
| OS | macOS 26.5 (build 25F71) |
| Hardware | Mac Studio, Apple M1 Max (10 core) |
| Claude Code | 2.1.199 |
| bun | 1.3.11 |
| Plugin | claude-plugins-official/telegram v0.0.6 |
| MCP SDK | @modelcontextprotocol/sdk ^1.0.0 (per plugin package.json) |
| System uptime at reproduction | 38 days |

Repro

  1. Install the plugin: /plugins install claude-plugins-official/telegram
  2. Configure and use it in a Claude Code session, then exit Claude Code.
  3. Repeat across sessions over several days without rebooting.
  4. Observe accumulating bun server.ts processes with ppid=1.

Diagnosis

After 38 days of uptime with routine Claude Code use:

$ pgrep -f "bun server.ts" | wc -l
35

$ for pid in $(pgrep -f "bun server.ts" | head -5); do
    printf "  pid=%s ppid=%s cwd=%s\n" \
      $pid \
      $(ps -o ppid= -p $pid | tr -d ' ') \
      $(lsof -p $pid 2>/dev/null | awk '/cwd/{print $NF; exit}')
  done
  pid=12124 ppid=1 cwd=/Users/michaelzhao/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6
  pid=80498 ppid=1 cwd=/Users/michaelzhao/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6
  pid=28804 ppid=1 cwd=/Users/michaelzhao/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6
  pid=65370 ppid=1 cwd=/Users/michaelzhao/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6
  pid=28643 ppid=1 cwd=/Users/michaelzhao/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6

$ top -l 2 -o cpu -stats pid,command,cpu,time | grep bun | head -8
  12124  bun              74.4   849 hrs         ← running 35 days as an orphan
  32587  bun              55.4   721 hrs
  3955   bun              52.1   737 hrs
  80498  bun              77.4   48:56:43
  ... (35 total, aggregate ~700%+ CPU)

$ uptime
  load averages: 88.64 100.00 137.19    ← healthy on 10-core = <10

All 35 daemons were reparented to pid 1 (their Claude Code parents exited days/weeks ago) and their cwd is the plugin's cache dir. So the MCP server is not receiving/acting on the stdin EOF that a well-behaved stdio server should exit on.

After killing them:

for pid in $(pgrep -f "bun server.ts"); do
  cwd=$(lsof -p $pid 2>/dev/null | awk '/cwd/{print $NF; exit}')
  [[ "$cwd" == *"claude-plugins-official/telegram"* ]] && kill -9 $pid
done

Load average dropped from 107 → 39 within 2 minutes.

Where the bug likely lives

An MCP server using StdioServerTransport should exit when stdin closes (this is the contract implied by the stdio transport). Candidates:

  1. The plugin's server.ts — doesn't attach a process.stdin.on('end'|'close') handler or watchdog, and the grammy Bot.start() polling loop keeps the bun event loop alive indefinitely.
  2. @modelcontextprotocol/sdk's StdioServerTransport — may not close/end the underlying stream in a way that triggers bun's event loop to drain.
  3. grammy — its long-polling loop is designed to run forever; it doesn't watch for parent death.

Most likely the plugin needs to explicitly wire up "exit when stdin closes" — since grammy will never notice.

Impact

  • ~4 GB RAM wasted (18 orphans × ~220 MB when I first checked, up to 35 total)
  • CPU load average 100+ on a 10-core machine → constant thermal throttling (kernel_task was consuming 11.8% CPU purely for cooling)
  • Machine noticeably slow, fans loud, unrelated workflows impacted

Suggested fix

In server.ts, after transport setup, add:

process.stdin.on('end', () => process.exit(0))
process.stdin.on('close', () => process.exit(0))

Or wrap StdioServerTransport so the transport's own EOF handler calls process.exit().

For existing orphans on user machines, a "cleanup on install/update" step in the plugin runner would help too.

Happy to test a fix if there's a candidate build.

View original on GitHub ↗

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