Files
vercel__next.js/test/e2e/app-dir/webpack-loader-errors/webpack-loader-errors.test.ts
Luke Sandberg d46516ce2d Turbopack: fix error reporting with crashing webpack loaders (#93926)
### What?

When a Turbopack webpack-loader subprocess crashes (e.g. a loader calls
`process.exit()`, a native fatal error, or the IPC socket otherwise
closes mid-message), the error users see today is:

```
- Execution of <WebpackLoadersProcessedAsset as Asset>::content failed
- Execution of WebpackLoadersProcessedAsset::process failed
- Execution of evaluate_webpack_loader failed
- failed to receive message
- reading packet length
- unexpected end of file
```

After this PR, the same crash produces:

```
⨯ ./data/crash.data
Error evaluating Node.js code
Error: Node.js subprocess crashed while evaluating loaders [/path/to/loaders/crash-loader.js]: failed to receive message

Caused by:
- Node.js process exited with exit status: 7
- reading packet length
- unexpected end of file

Debug info:
- failed to receive message
- Node.js process exited with exit status: 7
  Recent process stderr:
  <whatever the loader wrote to stderr before exiting>
- reading packet length
- unexpected end of file
```

### Why?

The original message gave no actionable information: no exit code, no
captured stdout/stderr, no indication of which loader was running. It
also looked like an internal turbopack bug rather than a user-fixable
error, and a transient pool failure could cascade into an unrelated
"issue formatter crashed while reading the source for a code frame"
failure on the way out.

### How?

Four orthogonal fixes, plus a regression test:

1. **Capture stdout/stderr on subprocess crash.** `OutputStreamHandler`
now keeps a bounded ring buffer (last 100 lines per stream) shared with
the owning `NodeJsPoolProcess`. When `NodeJsPoolProcess::recv` fails,
the buffers and the child's exit status are attached to the error via
`anyhow::Error::context`.
2. **Recover from subprocess crash in `pull_operation`.** Instead of
propagating the recv error up through `evaluate_webpack_loader` →
`process()` → `Asset::content` (the cascade above), `pull_operation`
catches it, synthesizes a `StructuredError` via
`evaluate_context.emit_error(...)`, disables process reuse, and returns
`Ok(None)`. This mirrors the existing in-band loader-error path, so the
asset's existing `FileContent::NotFound` degradation kicks in naturally
— `Asset::content` never errors.
3. **Include the loader chain in the error message and issue detail.**
`WebpackLoaderContext` gained a `loader_names: Vec<RcStr>` field. A new
optional `EvaluateContext::crash_context_prefix()` trait method lets
webpack-loader evaluations describe what was being evaluated (\"loaders
[a, b, c]\") in the synthesized crash message. `EvaluationIssue` also
gained an optional `detail` field for the same chain, surfacing it in
`--log-detail` output. PostCSS evaluations are labelled \"postcss\".
4. **Crash-proof the issue formatter.** `PlainSource::from_source` and
`IssueSource::into_plain` previously propagated errors from
`asset.content()` with `?`. They now degrade to `FileContent::NotFound`
(and `range = None`) on read failure, so a future regression in some
other code path can never cause the issue reporter itself to crash on
top of whatever the user was debugging.

### Tests

- Added
`test/e2e/app-dir/webpack-loader-errors/loaders/crash-loader.js`: a
loader that writes a marker to stderr and calls `process.exit(7)`.
- Added an e2e test that fetches `/crash` and asserts the marker, the
absence of the internal cascade, the loader name, and the resource name
are all present in the CLI output.
- All 11 tests in `webpack-loader-errors.test.ts` pass; the 5 Rust
`turbopack-node` pool tests still pass.

Some snapshot/golden tests for error formatting may need updating in CI
since `EvaluationIssue` now emits a non-empty `detail`.

<!-- NEXT_JS_LLM_PR -->
2026-05-20 11:29:59 +02:00

158 lines
6.4 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { retry, waitForRedbox, getRedboxSource } from 'next-test-utils'
import stripAnsi from 'strip-ansi'
describe('webpack-loader-errors', () => {
const { next, isNextDev, isTurbopack, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
skipStart: true,
})
if (skipped) return
if (!isNextDev) {
it('should skip in non-dev mode', () => {})
return
}
beforeAll(async () => {
await next.start()
})
describe('CLI output', () => {
// Test string-error before error to ensure each error appears independently
// in the CLI output (webpack only shows errors[0] per compilation)
it('should show the loader path and error message when a loader throws a plain string', async () => {
await next.fetch('/string-error')
await retry(
async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('string-error.data')
expect(output).toContain(
'A string error thrown by string-error-loader'
)
expect(output).toMatch(/\(from .+loaders\/string-error-loader/)
},
// webpack compilation output appears asynchronously
30_000
)
})
it('should show the loader path and error message when a loader throws an Error', async () => {
await next.fetch('/error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('error.data')
expect(output).toContain('An error thrown by error-loader')
expect(output).toMatch(/\(from .+loaders\/error-loader/)
}, 30_000)
})
// The following CLI tests are Turbopack-only because webpack's CLI output
// only logs errors[0] per compilation (see store.ts). When multiple pages
// have errors, only the first error (by module order) is shown. These
// error types still work correctly and are tested via the overlay tests.
if (isTurbopack) {
it('should surface an unhandled rejected Promise from a loader', async () => {
await next.fetch('/promise-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('An error thrown by promise-error-loader')
})
})
it('should surface a setTimeout error thrown after loader completion', async () => {
await next.fetch('/timeout-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('An error thrown by timeout-error-loader')
})
})
it('should show the loader path and error message when a loader throws an Error without stack', async () => {
await next.fetch('/no-stack-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain(
'An error without stack from no-stack-error-loader'
)
expect(output).toMatch(/\(from .+loaders\/no-stack-error-loader/)
})
})
it('should show the loader path and error message when a loader throws a filesystem error', async () => {
await next.fetch('/fs-error')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
expect(output).toContain('ENOENT')
expect(output).toMatch(/\(from .+loaders\/fs-error-loader/)
})
})
// Turbopack-only: webpack runs loaders in-process, so a loader calling
// process.exit() would kill the dev server itself. In Turbopack the
// loader runs in a Node.js subprocess from a worker pool; a hard
// process exit closes the IPC socket mid-message. This used to surface
// as an opaque "failed to receive message / unexpected end of file"
// cascade with no diagnostic context.
it('should surface a useful error when a loader crashes the Node.js subprocess', async () => {
await next.fetch('/crash')
await retry(async () => {
const output = stripAnsi(next.cliOutput)
// The crashing loader wrote to stderr before exiting. With the
// fix, that output is captured and attached to the error.
expect(output).toContain('TURBOPACK_CRASH_LOADER_STDERR_MARKER')
// The crash should not surface as the raw internal cascade.
expect(output).not.toContain(
'<WebpackLoadersProcessedAsset as Asset>::content failed'
)
// The synthesized error should reference both the crashing
// resource and the loader that was running, so the user knows
// exactly which loader to look at.
expect(output).toContain('crash.data')
expect(output).toMatch(/loaders \[[^\]]*loaders\/crash-loader/)
}, 30_000)
})
}
})
// Build errors accumulate globally and the overlay shows the first build
// error (no pagination for build errors). After the CLI tests compile all
// error routes, the overlay may show any accumulated error. So we only
// verify that a loader error with "(from ...)" is displayed, not which
// specific one. The CLI tests above validate each error type specifically.
describe('error overlay', () => {
it('should show error overlay with loader path when a loader throws a plain string', async () => {
const browser = await next.browser('/string-error')
await waitForRedbox(browser)
const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})
it('should show error overlay with loader path when a loader throws an Error', async () => {
const browser = await next.browser('/error')
await waitForRedbox(browser)
const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})
it('should show error overlay with loader path when a loader throws an Error without stack', async () => {
const browser = await next.browser('/no-stack-error')
await waitForRedbox(browser)
const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})
it('should show error overlay with loader path when a loader throws a filesystem error', async () => {
const browser = await next.browser('/fs-error')
await waitForRedbox(browser)
const source = await getRedboxSource(browser)
expect(source).toMatch(/\(from .+loaders\//)
})
})
})