#17528·vercel

CLI: unbounded EPIPE loop in handleUnexpected aborts the process when stdout closes early

Author: labs-bastionCreated Aug 22, 2026Updated Sep 9, 2026

Summary

When the CLI's stdout pipe closes before it finishes writing, handleUnexpected reports the resulting EPIPE through the same broken stream. That write fails identically, re-entering the handler. There is no reentrancy guard and no special case for stream errors, so the CLI allocates until V8 aborts.

Observed four times on one machine, each ~65s from launch to SIGABRT with a ~300 MB core dump.

Source

dist/index.js:1473 (v59.3.0, unchanged from 59.1.3):

javascript
var handleUnexpected = async (err) => {
  const { message } = err;
  if (message.includes("sentry") && message.includes("ENOTFOUND")) { ... }
  output_manager_default.error(`An unexpected error occurred!
${err.stack}`);          // <-- writes to the stream that just died
  await reportError(await getSentry(), client, err);
  process.exit(1);
};
process.on("uncaughtException", handleUnexpected);

A stream error is the one error class this handler cannot safely report through that stream.

Because the handler is async, the recursion runs through the event loop rather than the call stack, so it never trips a stack-overflow guard — it silently allocates. Each iteration's error embeds the previous stack, so the string grows ~10 frames per cycle until the ~4 GB heap ceiling triggers FatalProcessOutOfMemory -> abort().

Evidence

Counted from the 4.9 GB core dump, not inferred from the code:

Metric Value
write EPIPE strings in heap 1,171,765
at process.handleUnexpected repetitions 586,045
Time to abort ~65 s

The repeated heap block:

afterWriteDispatched (node:internal/stream_base_commons:159:15)
    at Socket._write (node:net:1270:8)
    at Writable.write (node:internal/streams/writable:512:10)
    at Output.print (vercel/dist/chunks/chunk-OX7KI3LF.js:4503:19)
    at Output.error (vercel/dist/chunks/chunk-OX7KI3LF.js:4527:12)
    at process.handleUnexpected (vercel/dist/index.js:1473:26)

Socket._write rather than a TTY write confirms stdout was a pipe.

Four crashes, all read-only output-producing commands, all with byte-identical stacks through 64 frames:

Command Handler repetitions
vercel project ls (not counted)
vercel list --yes (not counted)
vercel logs <url> 584,061
vercel env ls production 586,045

Reproducing

Pipe a vercel command into a reader that exits early:

bash
vercel env ls production | head -20
vercel ls | grep -q Ready

This is a race — it needs the reader to exit while the CLI still has writes pending, so with output under the ~64 KB pipe buffer it often completes normally. Polling loops (until vercel ls | grep -q Ready; do sleep 15; done) hit it far more often than one-shot commands, simply by retrying. I have not found a deterministic one-liner; the four occurrences above happened in ordinary use over five days.

Setting --max-old-space-size higher only lengthens the loop and enlarges the core dump.

Suggested fix

Two independent changes, either of which stops the unbounded loop:

  1. Reentrancy guard. A module-level let handling = false; early-returning on re-entry.
  2. Short-circuit stream errors. On EPIPE/ERR_STREAM_DESTROYED, write to stderr only if it is a different fd from the failed stream, else process.exit(1) silently — a closed pipe conventionally means the consumer is done and wants no more output.

Worth applying to handleRejection (dist/index.js:1456) too, since it routes into the same handler.

Environment

Vercel CLI 59.1.3 and 59.3.0 (both affected)
Node 26.7.0
OS Arch Linux (kernel 7.1.8)

The machine was not under memory pressure: 38 GiB RAM, 29 GiB free, swap untouched, no kernel OOM killer involvement. The abort was V8 heap exhaustion.