Skip to content

[Bug]:CLI truncates large output at 64KB when stdout is a pipe (forced process.exit() before stdout drains) #214

Description

@badmoo

Summary

When a command (e.g. mcporter call ... / mcporter list --schema --output json) prints a large result to a pipe, the output is silently truncated at exactly 65536 bytes (64KB). Writing the same result to a file (> out.json) produces the complete output.

Root cause: after the command finishes, closeRuntimeAfterCommand() schedules a forced process.exit() ~50ms later without waiting for stdout to drain. On POSIX, process.stdout writes to a pipe are asynchronousconsole.log() queues the data in libuv and returns immediately. The kernel pipe buffer only holds ~64KB, so any remaining bytes are still in libuv's write queue when the process is killed, and the pipe reader never receives them. (Writes to a file are synchronous, which is why > file is unaffected.)

Reproduction

# stdout is a pipe -> truncated to 65536 bytes
mcporter call <server> <tool> ... | wc -c        # => 65536  (truncated)
mcporter call <server> <tool> ... | cat > out    # => 65536  (truncated)

# stdout is a file -> complete
mcporter call <server> <tool> ... > out.json     # => e.g. 71846 (complete)

Use any tool/list whose serialized output exceeds ~64KB.

Root cause (source pointers)

  • src/cli/output-utils.ts — large result is emitted in one shot:

    console.log(JSON.stringify(value, null, 2));

    For a pipe this is a non-blocking process.stdout.write(); the data is queued in libuv and flushed asynchronously.

  • src/cli.tsconst FORCE_EXIT_GRACE_MS = 50;

  • src/cli.ts closeRuntimeAfterCommand() — after cleanup, a forced exit is scheduled (force-exit is on by default; opt out via MCPORTER_NO_FORCE_EXIT=1):

    const scheduleForcedExit = () => {
      if (shouldForceExit) {
        setTimeout(() => {
          process.exit(process.exitCode ?? 0);  // kills the process before stdout drains
        }, FORCE_EXIT_GRACE_MS);
      }
    };

There is no hardcoded 64KB limit in the source (65536 / 64 * 1024 / maxBuffer all have 0 matches); 64KB is the OS pipe buffer capacity. There is also no stdout drain handling anywhere (writableNeedDrain / once('drain') have 0 matches).

Why > file works

File writes are synchronous on POSIX and don't go through the pipe buffer, so the full payload reaches the fd before exit.

Timeline

t event stdout pipe state
T+0ms console.log(72KB) returns first 64KB lands in kernel pipe buffer (full); ~6KB still queued in libuv
T+0–1ms finally -> closeRuntimeAfterCommand() -> runtime.close() + terminateChildProcesses()
T+1ms setImmediate schedules 50ms timer
T+51ms process.exit(0) remaining ~6KB never flushed -> reader sees only 65536 bytes

Proposed fix

Flush stdout before the forced exit (no-op for TTY/file; only waits when a pipe write is backpressured), with a safety cap so a stalled reader can't hang the process:

setTimeout(() => {
  process.stdout.write('', () => {
    process.exit(process.exitCode ?? 0);
  });
}, FORCE_EXIT_GRACE_MS);

Environment

  • mcporter 0.12.1
  • OS: POSIX (pipe buffer 64KB); reproduces wherever stdout is a pipe

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal priority bug or improvement with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:data-lossThis issue is about lost, corrupted, or silently dropped user/session/config data.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions