Skip to content

Commit

Permalink
handle uncaught exceptions in worker
Browse files Browse the repository at this point in the history
Previously any uncaught exception in a build worker would lead to the worker process being exited. This change adds a custom handler to only terminate the process if the error is a Next.js specific API that expects to be called in a context where the framework can observe it. Other errors that are uncaught are logged but won't terminate the worker process.
  • Loading branch information
gnoff committed Aug 27, 2024
1 parent 50b5c7b commit 9dbf753
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 3 deletions.
4 changes: 3 additions & 1 deletion packages/next/src/export/helpers/is-dynamic-usage-error.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { isDynamicServerError } from '../../client/components/hooks-server-context'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { isNextRouterError } from '../../client/components/is-next-router-error'
import { isDynamicPostpone } from '../../server/app-render/dynamic-rendering'

export const isDynamicUsageError = (err: unknown) =>
isDynamicServerError(err) ||
isBailoutToCSRError(err) ||
isNextRouterError(err)
isNextRouterError(err) ||
isDynamicPostpone(err)
14 changes: 14 additions & 0 deletions packages/next/src/export/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,3 +548,17 @@ process.on('rejectionHandled', () => {
// prefetching patterns to avoid waterfalls. We ignore logging these.
// We should've already errored in anyway unhandledRejection.
})

const FATAL_UNHANDLED_NEXT_API_EXIT_CODE = 78

process.on('uncaughtException', (err) => {
if (isDynamicUsageError(err)) {
console.error(
'A Next.js API that uses exceptions to signal framework behavior was uncaught. This suggests improper usage of a Next.js API. The original error is printed below and the build will now exit.'
)
console.error(err)
process.exit(FATAL_UNHANDLED_NEXT_API_EXIT_CODE)
} else {
console.error(err)
}
})
37 changes: 35 additions & 2 deletions packages/next/src/server/app-render/dynamic-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,45 @@ function postponeWithTracking(
expression,
})
}
const reason =

React.unstable_postpone(createPostponeReason(route, expression))
}

function createPostponeReason(route: string, expression: string) {
return (
`Route ${route} needs to bail out of prerendering at this point because it used ${expression}. ` +
`React throws this special object to indicate where. It should not be caught by ` +
`your own try/catch. Learn more: https://nextjs.org/docs/messages/ppr-caught-error`
)
}

export function isDynamicPostpone(err: unknown) {
console.log(err)
if (
typeof err === 'object' &&
err !== null &&
typeof (err as any).message === 'string'
) {
return isDynamicPostponeReason((err as any).message)
}
return false
}

function isDynamicPostponeReason(reason: string) {
return (
reason.includes(
'needs to bail out of prerendering at this point because it used'
) &&
reason.includes(
'Learn more: https://nextjs.org/docs/messages/ppr-caught-error'
)
)
}

React.unstable_postpone(reason)
if (isDynamicPostponeReason(createPostponeReason('%%%', '^^^')) === false) {
throw new Error(
'Invariant: isDynamicPostpone misidentified a postpone reason. This is a bug in Next.js'
)
}

export function usedDynamicAPIs(
Expand Down
20 changes: 20 additions & 0 deletions test/production/app-dir/build-output/app/uncaught-error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PHASE_PRODUCTION_BUILD } from 'next/constants'

export default function Page() {
setTimeout(() => {
throw new Error('Boom')
}, 0)
return (
<>
<p>
This page should be statically generated even though we threw an
unhandled exception in a setTimeout.
</p>
<p id="sentinel">
{process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD
? 'at buildtime'
: 'at runtime'}
</p>
</>
)
}
112 changes: 112 additions & 0 deletions test/production/app-dir/build-output/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { nextTestSetup } from 'e2e-utils'
import stripAnsi from 'strip-ansi'
import { outdent } from 'outdent'

describe('production - app dir - build output', () => {
const { next } = nextTestSetup({
Expand Down Expand Up @@ -38,4 +39,115 @@ describe('production - app dir - build output', () => {
// output type
expect(output).toContain('○ (Static) prerendered as static content')
})

it('should log errors not caught by the worker without terminating the process', async () => {
expect(output).toContain('Error: Boom')
expect(output).not.toContain('Static worker exited with code: 78')

const $ = await next.render$('/uncaught-error')
expect($('#sentinel').text()).toEqual('at buildtime')
})

it('should fail the build if you use a dynamic API outside of a render context - cookies', async () => {
await next.stop()
await next.patchFile(
'app/out-of-band-dynamic-api/page.tsx',
outdent`
import { cookies } from 'next/headers'
export default async function Page() {
setTimeout(() => {
cookies();
}, 0)
return <div>Hello World</div>
}
`
)
const { cliOutput } = await next.build()
await next.deleteFile('app/out-of-band-dynamic-api/page.tsx')

expect(cliOutput).toContain('Static worker exited with code: 78')
})

it('should fail the build if you use a dynamic API outside of a render context - headers', async () => {
await next.stop()
await next.patchFile(
'app/out-of-band-dynamic-api/page.tsx',
outdent`
import { headers } from 'next/headers'
export default async function Page({ searchParams }) {
setTimeout(() => {
headers()
}, 0)
return <div>Hello World</div>
}
`
)
const { cliOutput } = await next.build()
await next.deleteFile('app/out-of-band-dynamic-api/page.tsx')

expect(cliOutput).toContain('Static worker exited with code: 78')
})

it('should fail the build if you use a dynamic API outside of a render context - searchParams', async () => {
await next.stop()
await next.patchFile(
'app/out-of-band-dynamic-api/page.tsx',
outdent`
export default async function Page({ searchParams }) {
setTimeout(() => {
searchParams.foo
}, 0)
return <div>Hello World</div>
}
`
)
const { cliOutput } = await next.build()
await next.deleteFile('app/out-of-band-dynamic-api/page.tsx')

expect(cliOutput).toContain('Static worker exited with code: 78')
})

it('should fail the build if you use a dynamic API outside of a render context - redirect', async () => {
await next.stop()
await next.patchFile(
'app/out-of-band-dynamic-api/page.tsx',
outdent`
import { redirect } from 'next/navigation'
export default async function Page({ searchParams }) {
setTimeout(() => {
redirect('/whatever')
}, 0)
return <div>Hello World</div>
}
`
)
const { cliOutput } = await next.build()
await next.deleteFile('app/out-of-band-dynamic-api/page.tsx')

expect(cliOutput).toContain('Static worker exited with code: 78')
})

it('should fail the build if you use a dynamic API outside of a render context - notFound', async () => {
await next.stop()
await next.patchFile(
'app/out-of-band-dynamic-api/page.tsx',
outdent`
import { notFound } from 'next/navigation'
export default async function Page({ searchParams }) {
setTimeout(() => {
notFound()
}, 0)
return <div>Hello World</div>
}
`
)
const { cliOutput } = await next.build()
await next.deleteFile('app/out-of-band-dynamic-api/page.tsx')

expect(cliOutput).toContain('Static worker exited with code: 78')
})
})

0 comments on commit 9dbf753

Please sign in to comment.