← reports

#2783 · Plugin CLI output mishandles a closed pipe

Bug High Effort: Low plugins cli open on GitHub 2026-09-01 · base fa97c86a1

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

A plugin command can return more output than its next pipeline command needs. The next command then closes the pipe. The plugin CLI proxy writes to the closed stream without an error listener. Node emits an unhandled EPIPE event and exits with code 1. Two clean checkouts produced the same failure.

2. Claims vs findings

ClaimStatusEvidence
Large plugin output fails when the consumer closes stdout early.VerifiedThe real CLI process exited with code 1 in both clean runs.
The process emits an unhandled EPIPE event.VerifiedBoth runs produced the same Node stream error and stack.
The shared proxy causes the failure.VerifiedA controlled plugin endpoint returned the data. The real CLI proxy wrote it to process.stdout.
The defect needs a specific plugin implementation.RefutedThe reproduction used a small fixture plugin. The failure did not need plugin-specific code.
A normal core CLI command has the same defect.UnverifiedThis report tested only the plugin proxy path.
The latest trusted main commit already has a fix.RefutedThe public main commit still equals fa97c86a17d10b6e60d07bd6d1f524fe3cb6d929.

3. Environment

4. Minimal reproduction

  1. Use a clean checkout at the trusted commit.
  2. Save the test below as /tmp/plugin-cli-early-close.test.mjs.
  3. Install the locked packages and build the CLI with Turbo.
  4. Run the Node test against the built CLI entry.
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --filter=@bb/cli
BB_REPRO_CLI_ENTRY="$PWD/apps/cli/dist/index.js" node --test /tmp/plugin-cli-early-close.test.mjs

Reproduction test

import assert from "node:assert/strict";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { once } from "node:events";
import test from "node:test";

const cliEntry = process.env.BB_REPRO_CLI_ENTRY;

test("a plugin command exits cleanly after its output consumer closes", async () => {
  assert.ok(cliEntry, "BB_REPRO_CLI_ENTRY must name the built CLI entry");
  const stdout = Array.from({ length: 200_000 }, (_, index) => "row-" + index).join("\n");
  const server = createServer((request, response) => {
    response.setHeader("content-type", "application/json");
    if (request.url === "/api/v1/plugins/contributions") {
      response.end(JSON.stringify({
        cliCommands: [{
          pluginId: "fixture-plugin",
          name: "fixture",
          summary: "Fixture",
          commands: [{ name: "drain", summary: "Drain", usage: "bb fixture drain" }],
        }],
      }));
      return;
    }
    if (request.url === "/api/v1/plugins/fixture-plugin/cli") {
      response.end(JSON.stringify({ exitCode: 0, stdout, stderr: "" }));
      return;
    }
    response.statusCode = 404;
    response.end();
  });
  server.listen(0, "127.0.0.1");
  await once(server, "listening");
  const address = server.address();
  assert.ok(address && typeof address !== "string");
  const env = {
    ...process.env,
    BB_SERVER_URL: "http://127.0.0.1:" + address.port,
  };
  delete env.BB_CLI;
  delete env.BB_PROJECT_ID;
  delete env.BB_THREAD_ID;
  const child = spawn(process.execPath, [cliEntry, "fixture", "drain"], {
    env,
    stdio: ["ignore", "pipe", "pipe"],
  });
  let stderr = "";
  child.stderr.setEncoding("utf8");
  child.stderr.on("data", (chunk) => {
    stderr += chunk;
  });
  child.stdout.once("data", () => {
    child.stdout.destroy();
  });
  const [code, signal] = await once(child, "close");
  server.close();
  await once(server, "close");
  assert.equal(signal, null);
  assert.equal(code, 0, stderr);
  assert.equal(stderr, "");
});

Expected and actual results

Expected:
signal = null
exit code = 0
stderr = ""

Actual:
signal = null
exit code = 1
stderr contains:
Error: write EPIPE
Emitted 'error' event on Socket instance
code: 'EPIPE'
syscall: 'write'

Verification

The first run used the initial clean worktree at the trusted commit. The full repository build passed with 18 tasks. The focused test failed because the child process returned code 1.

The second run used a new detached worktree at the same full commit. It used a new loopback port. The CLI build passed with four tasks. The focused test failed with the same unhandled event and exit code. The second run required no report correction.

First run:  not ok 1 · expected 0 · actual 1 · Error: write EPIPE
Second run: not ok 1 · expected 0 · actual 1 · Error: write EPIPE

5. Root cause

The output stream contract exposes only write. It has no way to add an error listener. See plugin-cli-proxy.ts lines 273–280.

writePluginCliOutput waits for the write callback. It rejects a callback error, but it does not observe the stream event. See plugin-cli-proxy.ts lines 282–294.

The shared command path passes process.stdout and process.stderr into this helper. See plugin-cli-proxy.ts lines 305–351.

Node emits an error event when the consumer closes the pipe. No listener receives that event. The promise rejection handler cannot catch an unhandled stream event. The process therefore exits before it can return the plugin exit code. The main promise catch also cannot receive this event. See index.ts lines 125–134.

6. Proposed fix

Add a temporary stream error listener before each write. Treat EPIPE as normal consumer completion. Keep all other write failures as errors. Remove the listener after a successful write. Add one process test for an early close and one control test for a non-EPIPE failure.

7. PR review

PR #2784 · addresses the root cause; policy cleanup remains

The open pull request changes three CLI files with 168 additions and two deletions. It adds an error listener before the write. It classifies EPIPE as successful output termination. It keeps other output errors as failures. These changes address the verified cause.

The pull request adds a real process test and two stream tests. I reviewed only its GitHub metadata and diff. I did not check out or run its untrusted branch.

Low-severity finding: the production change adds a normal code comment. The repository rules forbid non-directive code comments. The author should remove that comment before merge.

8. Related issues

#1505 and #2509 also concern unhandled stream or socket errors. They affect other subsystems. The search found no other issue for this plugin CLI output path.

9. Appendix

Trusted commands

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --output-logs=errors-only
pnpm exec turbo run build --filter=@bb/cli --output-logs=errors-only
BB_REPRO_CLI_ENTRY="$PWD/apps/cli/dist/index.js" node --test /tmp/plugin-cli-early-close.test.mjs

The issue and pull request data were untrusted. I used them only as claims and static metadata. I ran only the trusted main commit and the test above. I did not use a live bb instance, user data, a provider, or a pull request branch.