diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.global.md index f233a35978..6565962928 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.global.md @@ -4,8 +4,25 @@ vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets ## `vpt backpressure-run --digest 6,8 -- vp check` -**Exit code:** 1 - ``` -backpressure-run detected truncated child output under stdio backpressure +--- stdout --- +stdout: 1282 lines +pass: All 3 files are correctly formatted (, threads) +! eslint(no-unused-vars): Variable 'unused000' is declared but never used. Unused variables should start with a '_'. + ,-[src/index.js:2:9] + 1 | export function emitDiagnostics() { + 2 | const unused000 = 0; + : ^^^^|^^^^ +... 1268 lines elided ... + 129 | const unused127 = 127; + : ^^^^|^^^^ + : `-- 'unused127' is declared here + 130 | } + `---- + help: Consider removing this declaration. + +Found 0 errors and 128 warnings in 2 files (, threads) +--- stderr --- +stderr: 1 lines +warn: Lint warnings found ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.local.md index f233a35978..6565962928 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.local.md @@ -4,8 +4,25 @@ vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets ## `vpt backpressure-run --digest 6,8 -- vp check` -**Exit code:** 1 - ``` -backpressure-run detected truncated child output under stdio backpressure +--- stdout --- +stdout: 1282 lines +pass: All 3 files are correctly formatted (, threads) +! eslint(no-unused-vars): Variable 'unused000' is declared but never used. Unused variables should start with a '_'. + ,-[src/index.js:2:9] + 1 | export function emitDiagnostics() { + 2 | const unused000 = 0; + : ^^^^|^^^^ +... 1268 lines elided ... + 129 | const unused127 = 127; + : ^^^^|^^^^ + : `-- 'unused127' is declared here + 130 | } + `---- + help: Consider removing this declaration. + +Found 0 errors and 128 warnings in 2 files (, threads) +--- stderr --- +stderr: 1 lines +warn: Lint warnings found ``` diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 1fdd658238..5a90eef107 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -296,6 +296,8 @@ fn print_unknown_argument_error(error: &clap::Error) -> bool { #[tokio::main] async fn main() -> ExitCode { + vite_shared::ensure_blocking_stdio(); + // Initialize tracing vite_shared::init_tracing(); diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index 62dbde59a6..b40385dc27 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -9,7 +9,7 @@ rust-version.workspace = true [dependencies] directories = { workspace = true } -nix = { workspace = true, features = ["poll", "term"] } +nix = { workspace = true, features = ["fs", "poll", "term"] } owo-colors = { workspace = true } serde = { workspace = true } # use `preserve_order` feature to preserve the order of the fields in `package.json` diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 5abf7b727f..d8d7d7ea2d 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -17,6 +17,7 @@ mod json_edit; pub mod output; mod package_json; mod path_env; +mod stdio; pub mod string_similarity; mod tls; mod tracing; @@ -33,5 +34,6 @@ pub use path_env::{ PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend, prepend_to_path_env, }; +pub use stdio::ensure_blocking_stdio; pub use tls::ensure_tls_provider; pub use tracing::init_tracing; diff --git a/crates/vite_shared/src/stdio.rs b/crates/vite_shared/src/stdio.rs new file mode 100644 index 0000000000..16f007703d --- /dev/null +++ b/crates/vite_shared/src/stdio.rs @@ -0,0 +1,62 @@ +/// Ensure inherited standard streams use the blocking semantics expected by +/// Rust's standard library and by the tools embedded in Vite+. +/// +/// Node.js marks non-TTY stdio as non-blocking, and child processes inherit +/// that flag through the shared open file description. Clear `O_NONBLOCK` +/// once at process entry so unowned writers do not panic or truncate output +/// when a consumer applies backpressure. +#[cfg(unix)] +pub fn ensure_blocking_stdio() { + use std::{io, os::fd::AsFd}; + + let stdin = io::stdin(); + let stdout = io::stdout(); + let stderr = io::stderr(); + + for fd in [stdin.as_fd(), stdout.as_fd(), stderr.as_fd()] { + ensure_blocking_fd(fd); + } +} + +#[cfg(unix)] +fn ensure_blocking_fd(fd: std::os::fd::BorrowedFd<'_>) { + use nix::{ + fcntl::{FcntlArg, OFlag, fcntl}, + unistd::isatty, + }; + + if isatty(fd).unwrap_or(false) { + return; + } + + let Ok(flags) = fcntl(fd, FcntlArg::F_GETFL) else { + return; + }; + let flags = OFlag::from_bits_retain(flags); + if flags.contains(OFlag::O_NONBLOCK) { + let _ = fcntl(fd, FcntlArg::F_SETFL(flags.difference(OFlag::O_NONBLOCK))); + } +} + +#[cfg(not(unix))] +pub fn ensure_blocking_stdio() {} + +#[cfg(all(test, unix))] +mod tests { + use std::os::{fd::AsFd, unix::net::UnixStream}; + + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + + use super::ensure_blocking_fd; + + #[test] + fn clears_nonblocking_from_a_non_tty_file_description() { + let (stream, _peer) = UnixStream::pair().unwrap(); + stream.set_nonblocking(true).unwrap(); + + ensure_blocking_fd(stream.as_fd()); + + let flags = fcntl(&stream, FcntlArg::F_GETFL).unwrap(); + assert!(!OFlag::from_bits_retain(flags).contains(OFlag::O_NONBLOCK)); + } +} diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index 61a11fa03a..cae6df7a0c 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -849,6 +849,7 @@ module.exports.shutdownAsyncRuntime = nativeBinding.shutdownAsyncRuntime; module.exports.startAsyncRuntime = nativeBinding.startAsyncRuntime; module.exports.detectWorkspace = nativeBinding.detectWorkspace; module.exports.downloadPackageManager = nativeBinding.downloadPackageManager; +module.exports.ensureBlockingStdio = nativeBinding.ensureBlockingStdio; module.exports.hasConfigKey = nativeBinding.hasConfigKey; module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig; module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 3404c19c10..e4d0d3874f 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3366,6 +3366,9 @@ export interface DownloadPackageManagerResult { version: string; } +/** Re-enable blocking stdio after Node.js has initialized its lazy standard streams. */ +export declare function ensureBlockingStdio(): void; + /** * Whether `config_key` is already declared as a top-level property in the * vite config's `defineConfig({...})` (or equivalent) object literal. diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index a310a88b50..ed9d1008b3 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -42,6 +42,7 @@ use crate::cli::{ #[napi_derive::module_init] #[allow(clippy::disallowed_macros)] pub fn init() { + vite_shared::ensure_blocking_stdio(); crate::cli::init_tracing(); // Install a Vite+ panic hook so panics are correctly attributed to Vite+. @@ -55,6 +56,12 @@ pub fn init() { })); } +/// Re-enable blocking stdio after Node.js has initialized its lazy standard streams. +#[napi] +pub fn ensure_blocking_stdio() { + vite_shared::ensure_blocking_stdio(); +} + /// Configuration options passed from JavaScript to Rust. #[napi(object, object_to_js = false)] pub struct CliOptions { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index ab86d07e83..86f0d6f255 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -12,7 +12,7 @@ import path from 'node:path'; -import { run } from '../binding/index.js'; +import { ensureBlockingStdio, run } from '../binding/index.js'; import { applyToolInitConfigToViteConfig, inspectInitCommand } from './init-config.ts'; import { doc } from './resolve-doc.ts'; import { fmt } from './resolve-fmt.ts'; @@ -23,6 +23,12 @@ import { resolveUniversalViteConfig } from './resolve-vite-config.ts'; import { vite } from './resolve-vite.ts'; import { accent, errorMsg, log } from './utils/terminal.ts'; +// Node.js sets O_NONBLOCK when pipe-backed stdio is first accessed. Materialize +// the output streams before restoring the blocking semantics expected by Rust. +void process.stdout; +void process.stderr; +ensureBlockingStdio(); + function getErrorMessage(err: unknown): string { if (err instanceof Error) { return err.message;