CLI: unbounded EPIPE loop in handleUnexpected aborts the process when stdout closes early
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):
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:
vercel env ls production | head -20
vercel ls | grep -q ReadyThis 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:
- Reentrancy guard. A module-level
let handling = false;early-returning on re-entry. - Short-circuit stream errors. On
EPIPE/ERR_STREAM_DESTROYED, write tostderronly if it is a different fd from the failed stream, elseprocess.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.
Source: vercel/vercel