Skip to content

Commit 66d21bd

Browse files
committed
fix(dev): do not report or restart on broken pipes
1 parent bf72c89 commit 66d21bd

6 files changed

Lines changed: 122 additions & 7 deletions

File tree

packages/nuxt-cli/src/dev/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './
55
import process from 'node:process'
66
import defu from 'defu'
77
import { overrideEnv } from '../utils/env.ts'
8+
import { isBrokenPipe } from '../utils/errors'
9+
import { debug } from '../utils/logger'
810
import { startCpuProfile, stopCpuProfile } from '../utils/profile.ts'
911
import { NuxtDevServer } from './utils'
1012

@@ -165,12 +167,21 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti
165167
function restart() {
166168
if (!restarted) {
167169
restarted = true
170+
process.off('uncaughtException', restartOnError)
171+
process.off('unhandledRejection', restartOnError)
168172
callback(devServer)
169173
}
170174
}
175+
function restartOnError(error: unknown) {
176+
if (isBrokenPipe(error)) {
177+
debug('Ignoring broken pipe:', error)
178+
return
179+
}
180+
restart()
181+
}
171182
devServer.once('restart', restart)
172-
process.once('uncaughtException', restart)
173-
process.once('unhandledRejection', restart)
183+
process.on('uncaughtException', restartOnError)
184+
process.on('unhandledRejection', restartOnError)
174185
},
175186
}
176187
}

packages/nuxt-cli/src/dev/listen.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ export async function printQRCode(url: string): Promise<void> {
257257
}
258258

259259
export async function copyURL(url: string): Promise<void> {
260+
if (!isClipboardAvailable()) {
261+
logger.warn('No clipboard is available in this environment.')
262+
return
263+
}
260264
try {
261265
const { writeText } = await import('tinyclip')
262266
await writeText(url)
@@ -268,6 +272,20 @@ export async function copyURL(url: string): Promise<void> {
268272
}
269273
}
270274

275+
const DISPLAY_REQUIRED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'freebsd', 'openbsd'])
276+
277+
/**
278+
* Clipboard tools on Linux and BSD (`wl-copy`, `xsel`, `xclip`) need a display
279+
* server. Without one they exit before receiving any input, and the resulting
280+
* `EPIPE` escapes as an uncaught exception from inside the writing library.
281+
*/
282+
function isClipboardAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
283+
if (!DISPLAY_REQUIRED_PLATFORMS.has(process.platform)) {
284+
return true
285+
}
286+
return !!(env.WSL_DISTRO_NAME || env.WAYLAND_DISPLAY || env.DISPLAY)
287+
}
288+
271289
function centerBlock(block: string): string {
272290
const lines = block.split('\n')
273291
const width = Math.max(...lines.map(line => line.length))

packages/nuxt-cli/src/utils/console.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import process from 'node:process'
44

55
import { consola } from 'consola'
66

7+
import { isBrokenPipe } from './errors'
8+
import { debug } from './logger'
9+
710
// Filter out unwanted logs
811
// TODO: Use better API from consola for intercepting logs
912
function wrapReporter(reporter: ConsolaReporter) {
@@ -37,9 +40,15 @@ export function setupGlobalConsole(opts: { dev?: boolean } = {}) {
3740
consola.wrapConsole()
3841
}
3942

40-
process.on('unhandledRejection', err =>
41-
consola.error('[unhandledRejection]', err))
43+
process.on('unhandledRejection', err => report('[unhandledRejection]', err))
44+
45+
process.on('uncaughtException', err => report('[uncaughtException]', err))
46+
}
4247

43-
process.on('uncaughtException', err =>
44-
consola.error('[uncaughtException]', err))
48+
function report(label: string, error: unknown) {
49+
if (isBrokenPipe(error)) {
50+
debug(`${label} ignoring broken pipe:`, error)
51+
return
52+
}
53+
consola.error(label, error)
4554
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* A pipe closing early is a property of the other end of the pipe, not a fault
3+
* in the dev server: a clipboard tool that exited before we wrote to it, or
4+
* `nuxt dev | head` closing stdout. Such errors should never be reported as
5+
* crashes or trigger a restart.
6+
*/
7+
export function isBrokenPipe(error: unknown): boolean {
8+
const code = (error as NodeJS.ErrnoException | undefined)?.code
9+
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED'
10+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { isBrokenPipe } from '../../src/utils/errors'
4+
5+
describe('isBrokenPipe', () => {
6+
it('should detect closed pipes', () => {
7+
expect(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))).toBe(true)
8+
expect(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' }))).toBe(true)
9+
})
10+
11+
it('should ignore other errors', () => {
12+
expect(isBrokenPipe(new Error('boom'))).toBe(false)
13+
expect(isBrokenPipe(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false)
14+
expect(isBrokenPipe(undefined)).toBe(false)
15+
})
16+
})

packages/nuxt-cli/test/unit/listen.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { networkInterfaces } from 'node:os'
44

55
import { afterEach, describe, expect, it, vi } from 'vitest'
66

7-
import { getNetworkAddresses, listen, resolveOpenCommand } from '../../src/dev/listen'
7+
import { copyURL, getNetworkAddresses, listen, resolveOpenCommand } from '../../src/dev/listen'
8+
9+
const writeText = vi.hoisted(() => vi.fn())
10+
11+
vi.mock('tinyclip', () => ({ writeText }))
812

913
const spawn = vi.hoisted(() => vi.fn((_command: string, _args: string[]) => ({ on: () => ({ unref: () => {} }) })))
1014

@@ -118,3 +122,50 @@ describe('listen', () => {
118122
expect(listener.address.port).toBe(port)
119123
})
120124
})
125+
126+
describe('copyURL', () => {
127+
const platform = process.platform
128+
const env = { ...process.env }
129+
130+
afterEach(() => {
131+
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
132+
process.env = { ...env }
133+
vi.clearAllMocks()
134+
})
135+
136+
function stubPlatform(value: NodeJS.Platform, overrides: NodeJS.ProcessEnv = {}) {
137+
Object.defineProperty(process, 'platform', { value, configurable: true })
138+
process.env = { ...env, DISPLAY: undefined, WAYLAND_DISPLAY: undefined, WSL_DISTRO_NAME: undefined, ...overrides }
139+
}
140+
141+
it('should skip copying without a display server on linux', async () => {
142+
stubPlatform('linux')
143+
144+
await copyURL('http://localhost:3000/')
145+
146+
expect(writeText).not.toHaveBeenCalled()
147+
})
148+
149+
it('should copy when a display server is available', async () => {
150+
stubPlatform('linux', { DISPLAY: ':0' })
151+
152+
await copyURL('http://localhost:3000/')
153+
154+
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/')
155+
})
156+
157+
it('should copy on platforms that do not need a display server', async () => {
158+
stubPlatform('darwin')
159+
160+
await copyURL('http://localhost:3000/')
161+
162+
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/')
163+
})
164+
165+
it('should warn rather than throw when copying fails', async () => {
166+
stubPlatform('darwin')
167+
writeText.mockRejectedValueOnce(new Error('no clipboard tool found'))
168+
169+
await expect(copyURL('http://localhost:3000/')).resolves.toBeUndefined()
170+
})
171+
})

0 commit comments

Comments
 (0)