Telegram plugin: zombie bun processes cause 409 Conflict — process.ppid cached in bun runtime
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
- Start Claude Code with
claude --channels(Telegram plugin enabled) - Send a message from Telegram — bot responds normally
- End the Claude Code session (Ctrl+C, crash, or terminal close)
- Start a new Claude Code session with
claude --channels - Result: New session's Telegram bot gets repeated
409 Conflictfromhttps://api.telegram.org/bot<token>/getUpdatesbecause 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:
- Claude Code starts → launches Telegram MCP plugin (spawns
bun server.ts) - SessionStart hook fires →
pkillkills all matching bun processes - The freshly-launched bot from step 1 is killed immediately
- 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
stdinclose detection: The plugin checksprocess.stdin.destroyedbut bun doesn't reliably set this when the parent's end of the stdio pipe closes.
Suggested Fixes
For the Telegram plugin (Anthropic-side):
- Replace
process.ppidwith/procreads on Linux — thegetRealPpid()function above works reliably - Add PID file locking — write PID on startup, check/kill stale PID before polling
- Add 409 retry with backoff and eventual exit — don't retry forever; exit after N attempts so the next session can start clean
- Add
Stophook 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.ppidshould 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. |
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗