Skip to content

fix: prevent stdout drain listener accumulation in long-running sessions#1

Open
sorlen008 wants to merge 2 commits intomainfrom
fix/stdout-drain-listener-leak
Open

fix: prevent stdout drain listener accumulation in long-running sessions#1
sorlen008 wants to merge 2 commits intomainfrom
fix/stdout-drain-listener-leak

Conversation

@sorlen008
Copy link
Copy Markdown
Owner

Summary

Fixes wonderwhy-er#391 — during extended Claude Desktop sessions with heavy tool usage (50+ tool calls), Desktop Commander emits MaxListenersExceededWarning on process.stdout and tool calls begin timing out.

Root cause

sendLogNotification, sendLog, sendProgress, and sendCustomNotification all wrote to stdout by calling originalStdoutWrite directly in a fire-and-forget manner — no backpressure handling. When the stdout buffer fills up under heavy load:

  1. Each in-flight MCP SDK send() call registers process.stdout.once('drain', resolve) while waiting for the buffer to drain
  2. Meanwhile, the direct originalStdoutWrite calls keep adding data to the buffer, delaying drain
  3. With 11+ concurrent send() calls pending, Node.js emits MaxListenersExceededWarning (default limit: 10)
  4. Sessions degrade: tool call responses are delayed or timeout

Fix

Serialised notification write queue (writeNotificationToStdout / flushNotificationQueue): all notification/progress/custom writes now go through a queue that registers at most one once('drain', ...) listener at a time. When stdout drains, the queue flushes sequentially. This eliminates the listener pile-up while also preventing dropped notifications (previously originalStdoutWrite returning false silently discarded the write).

process.stdout.setMaxListeners(50) in the constructor: defence-in-depth for the SDK's own legitimate concurrent drain listeners during bursts of parallel tool responses.

Changes

  • src/custom-stdio.ts:
    • Add stdoutQueue: string[] and stdoutDraining: boolean fields
    • Add writeNotificationToStdout(data) and flushNotificationQueue() methods
    • Replace all 8 direct originalStdoutWrite.call(...) calls in notification methods with writeNotificationToStdout(...)
    • Call process.stdout.setMaxListeners(50) in constructor

No breaking changes

Normal write path for JSON-RPC responses (through setupStdoutFiltering) is unchanged. The queue only affects the custom notification write path.

Test plan

  • Start a long session (50+ tool calls mixing read_file, list_directory, start_process, edit_block)
  • Confirm mcp-server-desktop-commander.log shows no MaxListenersExceededWarning
  • Confirm drain listener count stays at 1 in logs (add process.stdout.listenerCount('drain') trace if needed)
  • Confirm all log notifications are delivered (no silent drops under load)
  • npx tsc --noEmit passes with zero errors

🤖 Generated with Claude Code

sorlen008 and others added 2 commits March 26, 2026 16:10
…ectory traversal

When a file doesn't exist yet (e.g., write operations), fs.realpath() fails with
ENOENT and the code fell back to the unresolved path for the allowedDirectories
check. An attacker could create a symlink inside an allowed directory pointing to
a restricted location, then write a new file through that symlink — the path
appeared to be inside the allowed directory but actually targeted a restricted one.

The fix resolves the parent directory (and walks up to the deepest existing
ancestor if needed) so symlinks anywhere in the path chain are followed to their
real targets before the directory allowlist check runs.

Closes wonderwhy-er#219

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ccumulation

Fixes wonderwhy-er#391 — during long sessions with heavy tool usage, the Node.js
MaxListenersExceededWarning fires on process.stdout because multiple
concurrent MCP response writes each register an `once('drain', ...)` 
listener while the stdout buffer is backed up, exceeding the default
limit of 10.

Root cause: `sendLogNotification`, `sendProgress`, and `sendCustomNotification`
all called `originalStdoutWrite` directly in fire-and-forget fashion, 
without backpressure handling. Each call could fill the write buffer, 
causing subsequent SDK `send()` calls to pile up drain listeners.

Fix: route all custom notification writes through a serialised write
queue (`writeNotificationToStdout` / `flushNotificationQueue`) that
registers at most one `once('drain', ...)` listener at a time. Also
raise `process.stdout.setMaxListeners(50)` as defence-in-depth to
accommodate the SDK's own legitimate concurrent drain listeners during
heavy tool usage without false-positive warnings.

No API changes. No effect on normal operation when stdout is not full.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] EventEmitter memory leak: drain listeners accumulate on Socket during long sessions

1 participant