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
importprocessfrom'node:process'
66
importdefufrom'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
functionrestart(){
166168
if(!restarted){
167169
restarted=true
170+
process.off('uncaughtException',restartOnError)
171+
process.off('unhandledRejection',restartOnError)
168172
callback(devServer)
169173
}
170174
}
175+
functionrestartOnError(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
exportasyncfunctioncopyURL(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 }=awaitimport('tinyclip')
262266
awaitwriteText(url)
@@ -268,6 +272,20 @@ export async function copyURL(url: string): Promise<void> {
268272
}
269273
}
270274

275+
constDISPLAY_REQUIRED_PLATFORMS=newSet<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+
functionisClipboardAvailable(env: NodeJS.ProcessEnv=process.env): boolean{
283+
if(!DISPLAY_REQUIRED_PLATFORMS.has(process.platform)){
284+
returntrue
285+
}
286+
return!!(env.WSL_DISTRO_NAME||env.WAYLAND_DISPLAY||env.DISPLAY)
287+
}
288+
271289
functioncenterBlock(block: string): string{
272290
constlines=block.split('\n')
273291
constwidth=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
functionwrapReporter(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+
functionreport(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+
exportfunctionisBrokenPipe(error: unknown): boolean{
8+
constcode=(errorasNodeJS.ErrnoException|undefined)?.code
9+
returncode==='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(newError('write EPIPE'),{code: 'EPIPE'}))).toBe(true)
8+
expect(isBrokenPipe(Object.assign(newError('destroyed'),{code: 'ERR_STREAM_DESTROYED'}))).toBe(true)
9+
})
10+
11+
it('should ignore other errors',()=>{
12+
expect(isBrokenPipe(newError('boom'))).toBe(false)
13+
expect(isBrokenPipe(Object.assign(newError('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+
constwriteText=vi.hoisted(()=>vi.fn())
10+
11+
vi.mock('tinyclip',()=>({ writeText }))
812

913
constspawn=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+
constplatform=process.platform
128+
constenv={ ...process.env}
129+
130+
afterEach(()=>{
131+
Object.defineProperty(process,'platform',{value: platform,configurable: true})
132+
process.env={ ...env}
133+
vi.clearAllMocks()
134+
})
135+
136+
functionstubPlatform(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+
awaitcopyURL('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+
awaitcopyURL('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+
awaitcopyURL('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(newError('no clipboard tool found'))
168+
169+
awaitexpect(copyURL('http://localhost:3000/')).resolves.toBeUndefined()
170+
})
171+
})

0 commit comments

Comments
 (0)