telegram plugin: infinite CPU loop on EPIPE (broken pipe causes uncaughtException storm)

Resolved 💬 8 comments Opened May 20, 2026 by luiramji Closed Jul 15, 2026

Bug description

When the MCP stdio transport socket between server.ts and its parent Claude Code process breaks (EPIPE), the uncaughtException handler fires and tries to write the error to process.stderr. If stderr is backed by the same broken socket, the write itself throws another EPIPE — which triggers uncaughtException again, infinitely.

Steps to reproduce

  1. Have Claude Code running with the Telegram plugin active
  2. Trigger a network event that breaks the stdio socket between bun server.ts and the parent Claude process (e.g. activate WireGuard with wg-quick up wg0, VPN reconnect, or network interface restart)
  3. The plugin process enters an infinite exception loop

Observed behavior

strace output (sampled over 3 seconds, pid=987370):
  17,786 write() calls — ALL returning -1 EPIPE
  98.5% of process CPU time in this loop

Repeated syscall pattern:

write(11, "telegram channel: uncaught exception: Error: EPIPE: broken pipe, write\n") = -1 EPIPE
--- SIGPIPE ---
write(11, "telegram channel: uncaught exception: Error: EPIPE: broken pipe, write\n") = -1 EPIPE
--- SIGPIPE ---
(thousands of times per second)

Multiple Claude Code sessions running in parallel → multiple plugin instances → compounding CPU usage (3 instances observed at ~60% CPU each).

Root cause

server.ts lines 73–78:

process.on('unhandledRejection', err => {
  process.stderr.write(`telegram channel: unhandled rejection: ${err}\n`)
})
process.on('uncaughtException', err => {
  process.stderr.write(`telegram channel: uncaught exception: ${err}\n`)
})

When err.code === 'EPIPE', writing to process.stderr (backed by the same broken socket) throws another EPIPE. The handler catches it and writes again — infinite loop.

The orphan watchdog (setInterval at lines 670–677, fires every 5s) should detect the broken pipe and call shutdown(), but it never gets to run because the synchronous exception loop starves the event loop entirely.

Environment

  • OS: Linux 6.8.0 (Ubuntu), 1.9 GB RAM
  • Plugin: telegram@claude-plugins-official v0.0.6
  • Trigger: wg-quick up wg0 (WireGuard activation changed network routing, breaking the socket)
  • Sessions: multiple Claude Code sessions in tmux, each with its own plugin instance

Suggested fix

Guard against EPIPE in both error handlers and exit cleanly when the output pipe itself is broken:

process.on('unhandledRejection', err => {
  if ((err as NodeJS.ErrnoException)?.code === 'EPIPE') { process.exit(0); return }
  try { process.stderr.write(`telegram channel: unhandled rejection: ${err}\n`) } catch {}
})
process.on('uncaughtException', err => {
  if ((err as NodeJS.ErrnoException)?.code === 'EPIPE') { process.exit(0); return }
  try { process.stderr.write(`telegram channel: uncaught exception: ${err}\n`) } catch {}
})

Alternatively, listen for the error event on process.stderr:

process.stderr.on('error', (err: NodeJS.ErrnoException) => {
  if (err.code === 'EPIPE') shutdown()
})

View original on GitHub ↗

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