MCP stdio child loses long-poll socket under macOS memory pressure

Resolved 💬 3 comments Opened Apr 27, 2026 by MisterV111 Closed May 1, 2026

Summary

The Telegram plugin's bun MCP child silently loses its long-poll TCP socket to api.telegram.org after extended sessions on memory-pressured macOS systems. The plugin process remains alive in ps but has zero file descriptors and 0% CPU, and inbound messages stop arriving. This is distinct from #41275 (which is about cold-start notification delivery) — this failure mode happens after hours of successful operation.

Environment

  • Claude Code 2.1.88
  • telegram@claude-plugins-official 0.0.6
  • macOS Darwin 25.3.0 (Apple Silicon)
  • bun 1.3.11
  • Workstation: 56 GB RAM, frequently swap-saturated under heavy AI production workloads

Symptom

After a Claude Code session has been open for hours:

  1. Inbound Telegram messages stop arriving in the session.
  2. ps -p <bun_pid> shows the process is still alive (RSS ~6 MB, CPU 0%).
  3. lsof -p <bun_pid> shows zero file descriptors.
  4. MCP transport eventually reports a disconnect.
  5. Outbound tool calls (reply, react) start failing.

Reproduction (non-deterministic — requires sustained memory pressure)

  1. Run Claude Code with the Telegram plugin enabled on a memory-constrained macOS system.
  2. Drive the system into heavy swap usage (we hit it at 99% swap saturation, 55.7 GB used / 56 GB total) — happens naturally during multi-hour AI workloads.
  3. Send messages to the bot intermittently.
  4. After a few hours, observe that mcp.notification() calls stop reaching Claude Code despite the plugin process appearing alive.

We've reproduced this 3× in one session, then consistently across days of operation before deploying our workaround.

Hypothesis

Under memory pressure, macOS evicts the bun child to swap. While swapped out, the kernel drops the long-poll TCP socket as part of normal socket cleanup for inactive processes. When the process is paged back in, bun does not detect or recover the dropped socket. The plugin appears healthy from the OS perspective (process exists, RSS small, CPU idle) but the underlying connection is gone.

This is structural: an MCP child running stdio transport has no signal that its long-poll socket has been killed by the OS. The plugin can't observe the failure to recover from it.

Workaround

We split the plugin into two processes:

  • A --daemon mode running under launchd (with RunAtLoad=true, KeepAlive=true) that owns the long-poll socket. This process is independent of any Claude Code session and is restarted automatically by launchd if it exits.
  • A normal MCP shim spawned by Claude Code that consumes inbound messages from a shared inbox directory.

Full writeup with architecture diagram, code, and install scripts: https://github.com/MisterV111/claude-plugins-official/blob/telegram/daemon-mode/external_plugins/telegram/DAEMON-MODE.md

This sidesteps the issue but does not fix it — the underlying problem (stdio MCP child unable to recover from OS-killed sockets) likely affects any plugin that holds long-running outbound connections, not just Telegram.

What would be useful from upstream

Open question on scope. Possibilities:

  1. Plugin SDK level: a connection-watchdog helper that detects FD loss and triggers reconnect.
  2. MCP SDK level: transport-layer reconnect semantics for stdio children.
  3. Claude Code client level: restart the MCP child if it stops responding to pings within N seconds.

Happy to put up a PR scoped to whichever of these the team prefers. Sharing the data here so the design discussion can happen first.

View original on GitHub ↗

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