Skip to content

Commit 810231e

Browse files
authored
fix(dev): do not restart when a client aborts a connection (#1412)
1 parent f30cdf3 commit 810231e

5 files changed

Lines changed: 89 additions & 26 deletions

File tree

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

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './
66
import process from 'node:process'
77
import defu from 'defu'
88
import { overrideEnv } from '../utils/env.ts'
9-
import { isBrokenPipe } from '../utils/errors'
9+
import { isRemotePeerError } from '../utils/errors'
1010
import { debug } from '../utils/logger'
1111
import { startCpuProfile, stopCpuProfile } from '../utils/profile.ts'
1212
import { openInspector } from './inspect'
@@ -18,6 +18,22 @@ function formatErrorMessage(error: unknown): string {
1818
return error instanceof Error ? error.toString() : 'Unhandled Rejection'
1919
}
2020

21+
/**
22+
* Hand an unhandled rejection to the parent process and stop this one, unless
23+
* it is only a client that went away — that is traffic, not a crash, and the
24+
* session has to survive it.
25+
*/
26+
export function createRejectionHandler(report: (message: string) => void, stop: () => void): (reason: unknown) => void {
27+
return (reason: unknown) => {
28+
if (isRemotePeerError(reason)) {
29+
debug('Ignoring remote peer error:', reason)
30+
return
31+
}
32+
report(formatErrorMessage(reason))
33+
stop()
34+
}
35+
}
36+
2137
interface InitializeOptions {
2238
data?: {
2339
overrides?: NuxtConfig
@@ -37,10 +53,10 @@ class IPC {
3753
process.once('disconnect', () => {
3854
process.exit(0)
3955
})
40-
process.once('unhandledRejection', (reason) => {
41-
this.send({ type: 'nuxt:internal:dev:rejection', message: formatErrorMessage(reason) })
42-
process.exit()
43-
})
56+
process.on('unhandledRejection', createRejectionHandler(
57+
message => this.send({ type: 'nuxt:internal:dev:rejection', message }),
58+
() => process.exit(),
59+
))
4460
}
4561
process.on('message', async (message: NuxtParentIPCMessage) => {
4662
if (message.type === 'nuxt:internal:dev:context') {
@@ -219,8 +235,8 @@ export function createRestartHook(source: RestartSource): (callback: (reason?: D
219235
}
220236

221237
function restartOnError(error: unknown) {
222-
if (isBrokenPipe(error)) {
223-
debug('Ignoring broken pipe:', error)
238+
if (isRemotePeerError(error)) {
239+
debug('Ignoring remote peer error:', error)
224240
return
225241
}
226242
restart({ type: 'error', message: formatErrorMessage(error) })

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

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

55
import { consola } from 'consola'
66

7-
import { isBrokenPipe } from './errors'
7+
import { isRemotePeerError } from './errors'
88
import { debug } from './logger'
99

1010
// Filter out unwanted logs
@@ -68,8 +68,8 @@ export function restoreRawMode(): void {
6868
}
6969

7070
function report(label: string, error: unknown) {
71-
if (isBrokenPipe(error)) {
72-
debug(`${label} ignoring broken pipe:`, error)
71+
if (isRemotePeerError(error)) {
72+
debug(`${label} ignoring remote peer error:`, error)
7373
return
7474
}
7575
consola.error(label, error)
Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
const REMOTE_PEER_ERROR_CODES = new Set([
2+
'EPIPE',
3+
'ERR_STREAM_DESTROYED',
4+
'ECONNRESET',
5+
'ECONNABORTED',
6+
'ERR_STREAM_PREMATURE_CLOSE',
7+
])
8+
19
/**
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.
10+
* Errors that say something about the other end of a connection rather than
11+
* about this process: a broken pipe, a client hanging up mid-request, a tab
12+
* closed mid-navigation. These should not be reported as crashes or trigger
13+
* a restart.
614
*/
7-
export function isBrokenPipe(error: unknown): boolean {
15+
export function isRemotePeerError(error: unknown): boolean {
816
const code = (error as NodeJS.ErrnoException | undefined)?.code
9-
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED'
17+
return !!code && REMOTE_PEER_ERROR_CODES.has(code)
1018
}

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import { describe, expect, it } from 'vitest'
22

33
import { stripCwd } from '../../src/dev/error'
4-
import { isBrokenPipe } from '../../src/utils/errors'
4+
import { isRemotePeerError } from '../../src/utils/errors'
55

6-
describe('isBrokenPipe', () => {
7-
it('should detect closed pipes', () => {
8-
expect(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))).toBe(true)
9-
expect(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' }))).toBe(true)
6+
describe('isRemotePeerError', () => {
7+
it('should detect errors from the other end of a connection', () => {
8+
expect(isRemotePeerError(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))).toBe(true)
9+
expect(isRemotePeerError(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' }))).toBe(true)
10+
expect(isRemotePeerError(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))).toBe(true)
11+
expect(isRemotePeerError(Object.assign(new Error('aborted'), { code: 'ECONNABORTED' }))).toBe(true)
12+
expect(isRemotePeerError(Object.assign(new Error('premature close'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }))).toBe(true)
1013
})
1114

1215
it('should ignore other errors', () => {
13-
expect(isBrokenPipe(new Error('boom'))).toBe(false)
14-
expect(isBrokenPipe(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false)
15-
expect(isBrokenPipe(undefined)).toBe(false)
16+
expect(isRemotePeerError(new Error('boom'))).toBe(false)
17+
expect(isRemotePeerError(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false)
18+
expect(isRemotePeerError(undefined)).toBe(false)
1619
})
1720
})
1821

packages/nuxt-cli/test/unit/restart-hook.spec.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import process from 'node:process'
55

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

8-
import { createRestartHook } from '../../src/dev'
8+
import { createRejectionHandler, createRestartHook } from '../../src/dev'
99

1010
const ERROR_EVENTS = ['uncaughtException', 'unhandledRejection'] as const
1111

@@ -48,7 +48,7 @@ describe('restart hook', () => {
4848
expect(callback).toHaveBeenCalledExactlyOnceWith({ type: 'shortcut' })
4949
})
5050

51-
it('should restart on an error that is not a broken pipe', () => {
51+
it('should restart on an error that is not a remote peer error', () => {
5252
const source = new EventEmitter()
5353
const callback = vi.fn()
5454
arm(source, callback)
@@ -57,6 +57,9 @@ describe('restart hook', () => {
5757
onError!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
5858
expect(callback).not.toHaveBeenCalled()
5959

60+
onError!(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
61+
expect(callback).not.toHaveBeenCalled()
62+
6063
onError!(new Error('boom'))
6164
expect(callback).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('boom') })
6265
})
@@ -106,3 +109,36 @@ describe('restart hook', () => {
106109
expect(second).toHaveBeenCalledTimes(1)
107110
})
108111
})
112+
113+
describe('rejection handler', () => {
114+
it('should report the rejection and stop', () => {
115+
const report = vi.fn()
116+
const stop = vi.fn()
117+
118+
createRejectionHandler(report, stop)(new Error('boom'))
119+
120+
expect(report).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('boom'))
121+
expect(stop).toHaveBeenCalledTimes(1)
122+
})
123+
124+
it('should describe a rejection that is not an error', () => {
125+
const report = vi.fn()
126+
127+
createRejectionHandler(report, vi.fn())('nope')
128+
129+
expect(report).toHaveBeenCalledExactlyOnceWith('Unhandled Rejection')
130+
})
131+
132+
it('should keep the process alive on remote peer errors', () => {
133+
const report = vi.fn()
134+
const stop = vi.fn()
135+
const handle = createRejectionHandler(report, stop)
136+
137+
handle(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
138+
handle(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
139+
handle(Object.assign(new Error('premature close'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }))
140+
141+
expect(report).not.toHaveBeenCalled()
142+
expect(stop).not.toHaveBeenCalled()
143+
})
144+
})

0 commit comments

Comments
 (0)