vercel/next.js · #99887
turbopack: report an actionable error when Node.js child processes cannot connect
test/e2e/turbopack-css-node-process-connect-failure/fixture/app/globals.cssadded3 + / 0 −
@@ -0,0 +1,3 @@+.hello {+ display: flex;+}test/e2e/turbopack-css-node-process-connect-failure/fixture/app/layout.jsadded9 + / 0 −
@@ -0,0 +1,9 @@+import './globals.css'++export default function RootLayout({ children }) {+ return (+ <html>+ <body>{children}</body>+ </html>+ )+}test/e2e/turbopack-css-node-process-connect-failure/fixture/app/page.jsadded3 + / 0 −
@@ -0,0 +1,3 @@+export default function Page() {+ return <p className="hello">hello world</p>+}test/e2e/turbopack-css-node-process-connect-failure/fixture/deny-local-port-connect.jsadded38 + / 0 −
@@ -0,0 +1,38 @@+// Preloaded into every Node.js process of the build (via NODE_OPTIONS) to+// emulate an environment where binding/connecting to a local port is denied,+// e.g. a sandbox with no usable loopback interface.+//+// Binding/listening keeps working (that is what Turbopack's Rust side does),+// only outgoing connections to the loopback interface fail with ENETUNREACH,+// exactly like `connect(127.0.0.1)` inside a network namespace whose loopback+// device is down.+const net = require('node:net')++const originalConnect = net.Socket.prototype.connect++net.Socket.prototype.connect = function connect(...args) {+ let host+ if (typeof args[0] === 'object' && args[0] !== null) {+ host = args[0].host+ } else if (typeof args[1] === 'string') {+ host = args[1]+ }++ const isLocal =+ host === undefined ||+ host === 'localhost' ||+ host === '127.0.0.1' ||+ host === '::1'++ if (!isLocal) {+ return originalConnect.apply(this, args)+ }++ process.nextTick(() => {+ const error = new Error(`connect ENETUNREACH ${host ?? '127.0.0.1'}`)+ error.code = 'ENETUNREACH'+ this.destroy(error)+ })++ return this+}test/e2e/turbopack-css-node-process-connect-failure/fixture/noop-postcss-plugin.jsadded2 + / 0 −
@@ -0,0 +1,2 @@+module.exports = () => ({ postcssPlugin: 'noop-postcss-plugin' })+module.exports.postcss = truetest/e2e/turbopack-css-node-process-connect-failure/fixture/postcss.config.jsadded4 + / 0 −
@@ -0,0 +1,4 @@+// A PostCSS config is required to make Turbopack run the CSS through its+// Node.js side process (`PostCssTransformedAsset`), which is the code path+// that fails when the side process cannot use a local port.+module.exports = { plugins: [require('./noop-postcss-plugin')] }test/e2e/turbopack-css-node-process-connect-failure/index.test.tsadded63 + / 0 −
@@ -0,0 +1,63 @@+import path from 'path'+import { nextTestSetup } from 'e2e-utils'+import { retry } from 'next-test-utils'++// Turbopack runs PostCSS in a Node.js side process that talks to the Rust+// process over a local TCP socket. When connecting to that local port is+// denied (e.g. a sandbox without a usable loopback interface), the build should+// fail with an actionable diagnostic rather than an internal compiler error.+// @force-gate turbopack+describe('turbopack css node process connect failure', () => {+ const { next, isNextDev } = nextTestSetup({+ files: path.join(__dirname, 'fixture'),+ skipStart: true,+ skipDeployment: true,+ })++ it('reports an actionable error when the CSS side process cannot use a local port', async () => {+ const env = {+ NODE_OPTIONS: `--require "${path.join(+ next.testDir,+ 'deny-local-port-connect.js'+ )}"`,+ }++ let cliOutput: string+ if (isNextDev) {+ await next.start({ env })+ // Compiling the page runs PostCSS on its global CSS.+ await next.fetch('/')+ await retry(async () => {+ expect(next.cliOutput).toContain(+ 'Node.js worker processes could not connect to Turbopack'+ )+ })+ cliOutput = next.cliOutput+ } else {+ const result = await next.build({ env })+ expect(result.exitCode).toBe(1)+ cliOutput = result.cliOutput+ }++ expect(cliOutput).toContain(+ 'Node.js worker processes could not connect to Turbopack'+ )+ // The issue is attributed to the file being transformed, and says which+ // transform was running.+ expect(cliOutput).toContain('./app/globals.css')+ expect(cliOutput).toContain('while evaluating loaders [postcss]')+ // The underlying reason reported by the child process.+ expect(cliOutput).toContain('connect ENETUNREACH 127.0.0.1')+ // The suggested workaround.+ expect(cliOutput).toContain(+ "experimental.turbopackPluginRuntimeStrategy: 'workerThreads'"+ )+ expect(cliOutput).toContain(+ 'https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackPluginRuntimeStrategy'+ )++ // This is not a Turbopack bug, so don't present it as one.+ expect(cliOutput).not.toContain('TurbopackInternalError')+ expect(cliOutput).not.toContain('https://bugs.nextjs.org/')+ })+})turbopack/crates/turbopack-node/js/src/child_process/index.ts21 + / 0 −
@@ -1,3 +1,4 @@+import { writeSync } from 'node:fs' import { createConnection } from 'node:net' import { Writable } from 'node:stream' import { structuredError } from '../error'@@ -18,6 +19,15 @@ export type Ipc<TIncoming, TOutgoing> = { sendReady(): Promise<void> } +/**+ * Exit code used when we cannot connect to the parent process, so that it can+ * report an actionable error. Node.js reserves most small exit codes, so this+ * uses `EX_UNAVAILABLE` from sysexits.h.+ *+ * Keep in sync with `CONNECT_FAILED_EXIT_CODE` in `process_pool/mod.rs`.+ */+const CONNECT_FAILED_EXIT_CODE = 69+ function createIpc<TIncoming, TOutgoing>( port: number ): Ipc<TIncoming, TOutgoing> {@@ -26,6 +36,16 @@ function createIpc<TIncoming, TOutgoing>( host: '127.0.0.1', }) + // If we never connect there is nobody to report errors to, so exit right+ // away with a dedicated exit code. This must be synchronous: other handlers+ // (the 'close' handler below, or `sendError` after failed writes) would+ // otherwise race us and exit with a different code.+ const onConnectError = (err: Error) => {+ writeSync(process.stderr.fd, `${err.message}\n`)+ process.exit(CONNECT_FAILED_EXIT_CODE)+ }+ socket.once('error', onConnectError)+ /** * A writable stream that writes to the socket. * We don't write directly to the socket because we need to@@ -60,6 +80,7 @@ function createIpc<TIncoming, TOutgoing>( let state: State = { type: 'waiting' } let buffer: Buffer = Buffer.alloc(0) socket.once('connect', () => {+ socket.off('error', onConnectError) socket.setNoDelay(true) socket.on('data', (chunk) => { buffer = Buffer.concat([buffer, chunk])turbopack/crates/turbopack-node/src/evaluate.rs135 + / 5 −
@@ -22,7 +22,7 @@ use turbopack_core::{ context::AssetContext, file_source::FileSource, ident::AssetIdent,- issue::{Issue, IssueExt, IssueSource, IssueStage, StyledString},+ issue::{Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, StyledString}, module::Module, module_graph::{ GraphEntries, ModuleGraph,@@ -297,6 +297,37 @@ async fn common_node_env(env: Vc<Box<dyn ProcessEnv>>) -> Result<Vc<EnvMap>> { Ok(Vc::cell(filtered)) } +/// A Node.js process could not connect back to us over the loopback interface.+///+/// Backends return this from [`EvaluateOperation::operation`] so that it can be+/// reported as an actionable issue. This is not a bug in the evaluated code and+/// retrying will not help, it usually means a sandbox blocks local networking.+#[derive(Debug)]+pub struct NodeJsConnectError {+ /// The error output of the child process, which contains the reason the+ /// connection failed.+ pub stderr: String,+}++impl std::fmt::Display for NodeJsConnectError {+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+ write!(+ f,+ "The Node.js child process could not connect to the parent process\n{}",+ self.stderr+ )+ }+}++impl std::error::Error for NodeJsConnectError {}++impl NodeJsConnectError {+ /// Finds a [`NodeJsConnectError`] anywhere in the cause chain of `err`.+ pub fn find(err: &anyhow::Error) -> Option<&Self> {+ err.chain().find_map(|cause| cause.downcast_ref::<Self>())+ }+}+ struct PoolErrorHandler; /// Number of attempts before we start slowing down the retry.@@ -308,7 +339,8 @@ impl futures_retry::ErrorHandler<anyhow::Error> for PoolErrorHandler { type OutError = anyhow::Error; fn handle(&mut self, attempt: usize, err: anyhow::Error) -> RetryPolicy<Self::OutError> {- if attempt >= MAX_ATTEMPTS {+ // A process that cannot connect to us will not be able to on a retry either.+ if attempt >= MAX_ATTEMPTS || NodeJsConnectError::find(&err).is_some() { RetryPolicy::ForwardError(err) } else if attempt >= MAX_FAST_ATTEMPTS { RetryPolicy::WaitRetry(Duration::from_secs(1))@@ -330,6 +362,8 @@ pub trait EvaluateContext { } fn args(&self) -> &[ResolvedVc<JsonValue>]; fn cwd(&self) -> Vc<FileSystemPath>;+ /// The source that issues about this evaluation are attributed to.+ fn context_source_for_issue(&self) -> ResolvedVc<Box<dyn Source>>; fn emit_error( &self, error: StructuredError,@@ -381,7 +415,7 @@ pub async fn custom_evaluate(evaluate_context: impl EvaluateContext) -> Result<V // worker. So we retry picking workers from the pools until we succeed // sending the job. - let (mut operation, _) = FutureRetry::new(+ let operation = FutureRetry::new( async || { let mut operation = pool.operation().await?; operation@@ -395,8 +429,27 @@ pub async fn custom_evaluate(evaluate_context: impl EvaluateContext) -> Result<V }, PoolErrorHandler, )- .await- .map_err(|(e, _)| e)?;+ .await;+ let mut operation = match operation {+ Ok((operation, _)) => operation,+ Err((err, _)) => {+ if let Some(connect_error) = NodeJsConnectError::find(&err) {+ NodeJsConnectIssue {+ source: IssueSource::from_source_only(+ evaluate_context.context_source_for_issue(),+ ),+ crash_context: evaluate_context.crash_context_prefix(),+ // Connection errors happen first thing on startup, so we shouldn't be too+ // worried about this being overly long.+ stderr: connect_error.stderr.as_str().trim().into(),+ }+ .resolved_cell()+ .emit();+ return Ok(Vc::cell(None));+ }+ return Err(err);+ }+ }; // The evaluation sent an initial intermediate value without completing. We'll // need to spawn a new thread to continually pull data out of the process,@@ -677,6 +730,10 @@ impl EvaluateContext for BasicEvaluateContext { self.cwd.clone().cell() } + fn context_source_for_issue(&self) -> ResolvedVc<Box<dyn Source>> {+ self.context_source_for_issue+ }+ fn keep_alive(&self) -> bool { !self.args.is_empty() }@@ -769,3 +826,76 @@ impl Issue for EvaluationIssue { Some(self.source) } }++/// Node.js child processes could not connect back to the parent process, which+/// makes it impossible to run any Node.js code (webpack loaders, PostCSS, ...).+#[turbo_tasks::value(shared)]+pub struct NodeJsConnectIssue {+ /// The source being transformed when the connection failed. The failure is+ /// unrelated to its contents, but this tells the user which transform hit it.+ pub source: IssueSource,+ /// What was being evaluated, e.g. the loader chain.+ pub crash_context: Option<RcStr>,+ /// Error output of the child process, describing why the connection failed.+ pub stderr: RcStr,+}++#[async_trait]+#[turbo_tasks::value_impl]+impl Issue for NodeJsConnectIssue {+ fn severity(&self) -> IssueSeverity {+ IssueSeverity::Error+ }++ async fn title(&self) -> Result<StyledString> {+ Ok(StyledString::Text(rcstr!(+ "Node.js worker processes could not connect to Turbopack"+ )))+ }++ fn stage(&self) -> IssueStage {+ IssueStage::Transform+ }++ async fn file_path(&self) -> Result<FileSystemPath> {+ self.source.file_path().await+ }++ fn source(&self) -> Option<IssueSource> {+ Some(self.source)+ }++ async fn description(&self) -> Result<Option<StyledString>> {+ let mut first_line = vec![StyledString::Text(rcstr!(+ "Turbopack is configured to run webpack loaders and PostCSS transforms in child \+ processes that communicate with it over a loopback TCP connection, but the child \+ process was unable to connect"+ ))];++ if let Some(crash_context) = &self.crash_context {+ first_line.push(StyledString::Text(rcstr!(" while evaluating ")));+ first_line.push(StyledString::Code(crash_context.clone()));+ };+ first_line.push(StyledString::Text(rcstr!(":")));+ Ok(Some(StyledString::Stack(vec![+ StyledString::Line(first_line),+ StyledString::Code(self.stderr.clone()),+ StyledString::Line(vec![+ StyledString::Text(rcstr!(+ "This is likely caused by a sandbox or firewall that blocks connections. \+ Allow local connections, or run this code using worker threads by setting "+ )),+ StyledString::Code(rcstr!(+ "experimental.turbopackPluginRuntimeStrategy: 'workerThreads'"+ )),+ StyledString::Text(rcstr!(" in your Next.js config.")),+ ]),+ ])))+ }++ fn documentation_link(&self) -> RcStr {+ rcstr!(+ "https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackPluginRuntimeStrategy"+ )+ }+}turbopack/crates/turbopack-node/src/process_pool/mod.rs8 + / 1 −
@@ -33,7 +33,7 @@ use turbopack_ecmascript::magic_identifier::unmangle_identifiers; use crate::{ AssetsForSourceMapping, backend::{CreatePoolFuture, CreatePoolOptions, NodeBackend},- evaluate::{EvaluateOperation, EvaluatePool, Operation},+ evaluate::{EvaluateOperation, EvaluatePool, NodeJsConnectError, Operation}, format::FormattingMode, pool_stats::{AcquiredPermits, NodeJsPoolStats, PoolStatsSnapshot}, source_map::apply_source_mapping,@@ -85,6 +85,10 @@ impl PartialEq for NodeJsPoolProcess { const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +/// Exit code used by the child process when it cannot connect to the port we+/// listen on (see `js/src/child_process/index.ts`).+const CONNECT_FAILED_EXIT_CODE: i32 = 69;+ #[derive(Clone, PartialEq, Eq, Hash)] struct OutputEntry { data: Arc<[u8]>,@@ -380,6 +384,9 @@ impl NodeJsPoolProcess { match status { Ok(status) => { let (stdout, stderr) = get_output(&mut child).await?;+ if status.code() == Some(CONNECT_FAILED_EXIT_CODE) {+ return Err(NodeJsConnectError { stderr }.into());+ } bail!("node process exited before we could connect to it with {status}\nProcess output:\n{stdout}\nProcess error output:\n{stderr}"); } Err(err) => {turbopack/crates/turbopack-node/src/transforms/webpack.rs4 + / 0 −
@@ -771,6 +771,10 @@ impl EvaluateContext for WebpackLoaderContext { self.cwd.clone().cell() } + fn context_source_for_issue(&self) -> ResolvedVc<Box<dyn Source>> {+ self.context_source_for_issue+ }+ fn keep_alive(&self) -> bool { true }