mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
d46516ce2d
### 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 -->
158 lines
6.4 KiB
TypeScript
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\//)
|
|
})
|
|
})
|
|
})
|