Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

handle uncaught exceptions in worker #69335

Merged
merged 1 commit into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the exit code just for the tests? If so, checking for this log instead would be better than creating a new special exit code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My rationale is that if we get an error report with a status code having a fast path to know that it came from an unhandled exception of a framework API might be helpful. We could exit with any code but using 1 seems to lose specificity. Then again we could just use the log message

} 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')
})
})
Loading