Telegram plugin: zombie bun processes cause 409 Conflict — process.ppid cached in bun runtime

Resolved 💬 2 comments Opened Apr 11, 2026 by ropelletier Closed May 6, 2026

Summary

The Telegram channel plugin (plugin:telegram from claude-plugins-official) creates zombie bun processes that persist after Claude Code sessions end. These zombies hold the Telegram Bot API getUpdates long-poll, causing HTTP 409 Conflict errors that prevent new sessions from receiving Telegram messages. The root cause is a bun runtime bug: process.ppid is cached at startup and never updated when the parent process dies, defeating the plugin's built-in orphan detection.

Environment

| Component | Value |
|-----------|-------|
| OS | Ubuntu, kernel 6.8.0-107-generic x86_64 |
| Hardware | QEMU VM, 2 vCPU @ 2.69GHz, 8GB RAM, 4GB swap |
| Bun version | 1.3.12 (also reproduced on 1.3.11) |
| Claude Code | Latest as of 2026-04-11, started with --channels |
| Plugin | plugin:telegram v0.0.5 from claude-plugins-official |

Reproduction Steps

  1. Start Claude Code with claude --channels (Telegram plugin enabled)
  2. Send a message from Telegram — bot responds normally
  3. End the Claude Code session (Ctrl+C, crash, or terminal close)
  4. Start a new Claude Code session with claude --channels
  5. Result: New session's Telegram bot gets repeated 409 Conflict from https://api.telegram.org/bot<token>/getUpdates because the old bun process from step 3 is still alive and holding the long-poll

Reproduces reliably when the parent Claude process dies without cleanly killing the bun child (crash, SIGKILL, terminal disconnect).

Root Cause Analysis

Bug 1: process.ppid is cached in bun (PRIMARY)

The Telegram plugin's orphan detection logic checks process.ppid to detect when the parent Claude process has died. On POSIX systems, when a parent dies, the child is reparented to PID 1 (init/systemd). The plugin expects process.ppid to change from the original parent PID to 1.

In Node.js, process.ppid is a getter that reads from the OS each time — this works correctly.

In bun, process.ppid is cached at startup and never updated. After the parent dies, process.ppid still returns the original (now-dead) parent PID. The orphan watchdog never fires. The bun process becomes a zombie that holds the Telegram bot token indefinitely.

Verification from our patched logging:

// bun's process.ppid reports the ORIGINAL parent even after it's dead:
// process.ppid = 73241 (dead parent, no longer exists)
// /proc/73248/stat field 4 = 1 (actual ppid from kernel = init)

Bug 2: Session hook race condition (SECONDARY)

An initial workaround of pkill -f 'bun server\.ts' in a SessionStart hook caused a race condition:

  1. Claude Code starts → launches Telegram MCP plugin (spawns bun server.ts)
  2. SessionStart hook fires → pkill kills all matching bun processes
  3. The freshly-launched bot from step 1 is killed immediately
  4. Bot is dead on arrival — no Telegram messages received

The hook and the plugin launch are not ordered — the hook can fire after the child is already spawned.

Evidence: Bot Logs

Full bot.log from affected session showing 409 cascade:

2026-04-11T11:27:01.148Z [INFO] pid=73248 startup — bun=1.3.12 ppid=73241
2026-04-11T11:27:01.205Z [INFO] pid=73248 MCP transport connected
2026-04-11T11:27:01.761Z [INFO] pid=73248 polling started
2026-04-11T11:27:07.352Z [WARN] pid=73248 409 Conflict (attempt 1/8), retrying in 1s
2026-04-11T11:27:17.275Z [WARN] pid=73248 409 Conflict (attempt 2/8), retrying in 2s
2026-04-11T11:27:27.006Z [WARN] pid=73248 409 Conflict (attempt 3/8), retrying in 3s
2026-04-11T11:27:37.922Z [WARN] pid=73248 409 Conflict (attempt 4/8), retrying in 4s
2026-04-11T11:27:45.658Z [WARN] pid=73248 409 Conflict (attempt 5/8), retrying in 5s
2026-04-11T11:27:57.577Z [WARN] pid=73248 409 Conflict (attempt 6/8), retrying in 6s
2026-04-11T11:28:09.311Z [WARN] pid=73248 409 Conflict (attempt 7/8), retrying in 7s
2026-04-11T11:28:22.227Z [ERROR] pid=73248 409 Conflict persists after 8 attempts — another poller is holding the bot token. Exiting.
2026-04-11T11:29:21.261Z [INFO] pid=73248 received SIGTERM

New session starts clean ~2min later (after zombie timed out):

2026-04-11T11:31:02.728Z [INFO] pid=78287 startup — bun=1.3.12 ppid=78276
2026-04-11T11:31:03.313Z [INFO] pid=78287 polling started

Workarounds Applied (User-Side)

Three layers of workarounds were needed:

Layer 1: Smart cleanup script (SessionStart hook)

Kills only orphaned bun processes (ppid=1), not freshly-launched ones:

for pid in $(pgrep -f 'bun server\.ts' 2>/dev/null); do
    ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
    if [ "$ppid" = "1" ]; then
        kill "$pid" 2>/dev/null
    fi
done

Layer 2: Plugin server.ts patch — /proc based orphan detection

Replaces the broken process.ppid check with direct /proc/<pid>/stat reads:

function getRealPpid(pid: number): number {
  try {
    const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
    const fields = stat.split(' ')
    return parseInt(fields[3], 10)
  } catch {
    return -1
  }
}

function isOrphaned(): boolean {
  if (process.platform === 'win32') return false
  const myRealPpid = getRealPpid(process.pid)
  if (myRealPpid === 1 || myRealPpid === -1) return true
  const parentPpid = getRealPpid(myRealPpid)
  if (parentPpid === 1 || parentPpid === -1) return true
  return process.stdin.destroyed || process.stdin.readableEnded
}

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

Layer 3: Auto-repatch script

SessionStart hook that restores the patch after plugin updates overwrite server.ts.

Contributing Factors

  • Memory pressure: 8GB VM running multiple concurrent Claude Code sessions + Docker stack. May increase likelihood of unclean process termination.
  • Plugin version mismatch: Plugin registry showed v0.0.5 but running session had v0.0.4 cached. v0.0.4 lacked any zombie prevention.
  • No stdin close detection: The plugin checks process.stdin.destroyed but bun doesn't reliably set this when the parent's end of the stdio pipe closes.

Suggested Fixes

For the Telegram plugin (Anthropic-side):

  1. Replace process.ppid with /proc reads on Linux — the getRealPpid() function above works reliably
  2. Add PID file locking — write PID on startup, check/kill stale PID before polling
  3. Add 409 retry with backoff and eventual exit — don't retry forever; exit after N attempts so the next session can start clean
  4. Add Stop hook guidance — document that users should kill the bot on session end, or have the plugin register its own cleanup

For Claude Code itself:

  • Consider sending SIGTERM to MCP plugin child processes on session end, not just closing stdin
  • Consider a process group kill (kill -TERM -$PGID) to catch the entire bun process tree

Upstream bun issue:

  • process.ppid should be a live getter (like Node.js), not a cached value. This breaks any code that relies on parent-death detection via ppid reparenting to init.

Timeline

| Date | Event |
|------|-------|
| 2026-04-04 | Telegram plugin first enabled. Bot stops responding after session restarts. |
| 2026-04-04 | Diagnosed as zombie bun process holding bot token. Initial fix: pkill in SessionStart hook. |
| 2026-04-04 | Discovered race condition — pkill kills the new bot too. Switched to targeted orphan cleanup. |
| 2026-04-11 | Full investigation. Identified process.ppid caching as root cause. Patched server.ts with /proc reads. |
| 2026-04-11 | Added repatch.sh to survive plugin updates. Updated bun 1.3.11 → 1.3.12 (issue persists). |
| 2026-04-11 | Added file logging, PID file, enhanced shutdown logging for future debugging. |
| 2026-04-11 | Telegram MCP disconnected again during active session while composing this report. |

View original on GitHub ↗

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