From 6bc474046ecb4b3ff83c0508d6ee8f27217ae0bc Mon Sep 17 00:00:00 2001 From: Hashim Khan <64767361+Hashim1999164@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:44:10 +0500 Subject: [PATCH 1/2] Exit when stdout is a broken pipe Fixes #8458 --- src/commands/main.ts | 13 +++++++ src/utils/command-helpers.ts | 27 +++++++++++-- tests/unit/utils/command-helpers.test.ts | 49 +++++++++++++++++++++++- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/commands/main.ts b/src/commands/main.ts index 0eef5a1433e..a0df8a017bf 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -16,6 +16,7 @@ import { NETLIFY_CYAN, USER_AGENT, logError, + isBrokenPipe, } from '../utils/command-helpers.js' import execa from '../utils/execa.js' import { EXIT_CODES } from '../utils/exit-codes.js' @@ -76,9 +77,21 @@ export const CI_FORCED_COMMANDS = { const SYSTEM_INFO_TIMEOUT = 5_000 +const exitIfBrokenPipe = (err: NodeJS.ErrnoException) => { + if (isBrokenPipe(err)) { + process.exit(0) + } +} + +process.stdout.on('error', exitIfBrokenPipe) +process.stderr.on('error', exitIfBrokenPipe) + let isHandlingUncaughtException = false process.on('uncaughtException', async (err: AddressInUseError | Error) => { + if (isBrokenPipe(err)) { + process.exit(0) + } if (isHandlingUncaughtException) { process.exit(1) } diff --git a/src/utils/command-helpers.ts b/src/utils/command-helpers.ts index 0775c79f230..884b32d7597 100644 --- a/src/utils/command-helpers.ts +++ b/src/utils/command-helpers.ts @@ -153,12 +153,31 @@ export const getToken = async (tokenFromOptions?: string): Promise = // 'functions:invoke' need to return the data from the function as is const isDefaultJson = () => argv[0] === 'functions:invoke' || (argv[0] === 'api' && !argv.includes('--list')) +export const isBrokenPipe = (err: unknown): boolean => { + if (!err || typeof err !== 'object') { + return false + } + const code = 'code' in err ? err.code : undefined + return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED' +} + +const writeOrExit = (stream: NodeJS.WriteStream, chunk: string) => { + try { + stream.write(chunk) + } catch (err) { + if (isBrokenPipe(err)) { + process.exit(0) + } + throw err + } +} + /** * logs a json message */ export const logJson = (message: unknown = '') => { if (argv.includes('--json') || isDefaultJson()) { - process.stdout.write(JSON.stringify(message, null, 2)) + writeOrExit(process.stdout, JSON.stringify(message, null, 2)) } } @@ -168,7 +187,7 @@ export const log = (message = '', ...args: string[]) => { return } message = typeof message === 'string' ? message : inspect(message) - process.stdout.write(`${format(message, ...args)}\n`) + writeOrExit(process.stdout, `${format(message, ...args)}\n`) } export const logPadded = (message = '', ...args: string[]) => { @@ -204,9 +223,9 @@ export const logError = (message: unknown): void => { const bang = chalk.red(BANG) if (process.env.DEBUG) { - process.stderr.write(` ${bang} Warning: ${err.stack?.split('\n').join(`\n ${bang} `)}\n`) + writeOrExit(process.stderr, ` ${bang} Warning: ${err.stack?.split('\n').join(`\n ${bang} `)}\n`) } else { - process.stderr.write(` ${bang} ${chalk.red(`${err.name}:`)} ${err.message}\n`) + writeOrExit(process.stderr, ` ${bang} ${chalk.red(`${err.name}:`)} ${err.message}\n`) } } diff --git a/tests/unit/utils/command-helpers.test.ts b/tests/unit/utils/command-helpers.test.ts index 7e367db8141..9b5c2f4c42c 100644 --- a/tests/unit/utils/command-helpers.test.ts +++ b/tests/unit/utils/command-helpers.test.ts @@ -1,6 +1,51 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' + +import { isBrokenPipe, log, normalizeConfig } from '../../../src/utils/command-helpers.js' + +describe('normalizeConfig', () => { + test('should remove publish and publishOrigin property if publishOrigin is "default"', () => { + const config = { build: { publish: 'a', publishOrigin: 'default' } } + + // @ts-expect-error TS(2345) FIXME: Argument of type '{ build: { publish: string; publ... Remove this comment to see the full error message + expect(normalizeConfig(config)).toEqual({ build: {} }) + }) + + test('should return same config object if publishOrigin is not "default"', () => { + const config = { build: { publish: 'a', publishOrigin: 'b' } } + + // @ts-expect-error TS(2345) FIXME: Argument of type '{ build: { publish: string; publ... Remove this comment to see the full error message + expect(normalizeConfig(config)).toBe(config) + }) +}) + +describe('isBrokenPipe', () => { + test('matches EPIPE and destroyed stream codes', () => { + expect(isBrokenPipe({ code: 'EPIPE' })).toBe(true) + expect(isBrokenPipe({ code: 'ERR_STREAM_DESTROYED' })).toBe(true) + expect(isBrokenPipe({ code: 'EIO' })).toBe(false) + expect(isBrokenPipe(null)).toBe(false) + }) +}) + +describe('log', () => { + test('exits 0 when stdout write throws EPIPE', () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => { + const err = new Error('broken pipe') as NodeJS.ErrnoException + err.code = 'EPIPE' + throw err + }) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exited') + }) + + expect(() => log('hello')).toThrow('exited') + expect(exit).toHaveBeenCalledWith(0) + + write.mockRestore() + exit.mockRestore() + }) +}) -import { normalizeConfig } from '../../../src/utils/command-helpers.js' describe('normalizeConfig', () => { test('should remove publish and publishOrigin property if publishOrigin is "default"', () => { From 74d04b2c20dd8095975ee7cd5ccb29a8dc02edf6 Mon Sep 17 00:00:00 2001 From: Hashim Khan <64767361+Hashim1999164@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:10:37 +0500 Subject: [PATCH 2/2] Rethrow stream errors that are not a broken pipe. --- src/commands/main.ts | 1 + tests/unit/utils/command-helpers.test.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/main.ts b/src/commands/main.ts index a0df8a017bf..05ca69c0fcc 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -81,6 +81,7 @@ const exitIfBrokenPipe = (err: NodeJS.ErrnoException) => { if (isBrokenPipe(err)) { process.exit(0) } + throw err } process.stdout.on('error', exitIfBrokenPipe) diff --git a/tests/unit/utils/command-helpers.test.ts b/tests/unit/utils/command-helpers.test.ts index 9b5c2f4c42c..fdc8959cfbf 100644 --- a/tests/unit/utils/command-helpers.test.ts +++ b/tests/unit/utils/command-helpers.test.ts @@ -38,7 +38,9 @@ describe('log', () => { throw new Error('exited') }) - expect(() => log('hello')).toThrow('exited') + expect(() => { + log('hello') + }).toThrow('exited') expect(exit).toHaveBeenCalledWith(0) write.mockRestore()