Skip to content

Commit c8d1e91

Browse files
committed
fix(cli): ensure blocking stdio (#2173)
Fixes #2165. ## Root cause Node marks non-TTY stdio as non-blocking, and the flag is shared with inherited child descriptors. Rust's standard I/O and embedded dependencies assume blocking writes, so a full consumer pipe can return `EAGAIN`, truncate diagnostics, or panic. ## Why this approach #2169 retries writes in code we control. Dependencies like vite-task write directly to the same stdio, so those retries cannot protect them. Clearing `O_NONBLOCK` at startup covers every writer.
1 parent 4147903 commit c8d1e91

10 files changed

Lines changed: 125 additions & 8 deletions

File tree

crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.global.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,25 @@ vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets
44

55
## `vpt backpressure-run --digest 6,8 -- vp check`
66

7-
**Exit code:** 1
8-
97
```
10-
backpressure-run detected truncated child output under stdio backpressure
8+
--- stdout ---
9+
stdout: 1282 lines
10+
pass: All 3 files are correctly formatted (<duration>, <n> threads)
11+
! eslint(no-unused-vars): Variable 'unused000' is declared but never used. Unused variables should start with a '_'.
12+
,-[src/index.js:2:9]
13+
1 | export function emitDiagnostics() {
14+
2 | const unused000 = 0;
15+
: ^^^^|^^^^
16+
... 1268 lines elided ...
17+
129 | const unused127 = 127;
18+
: ^^^^|^^^^
19+
: `-- 'unused127' is declared here
20+
130 | }
21+
`----
22+
help: Consider removing this declaration.
23+
24+
Found 0 errors and 128 warnings in 2 files (<duration>, <n> threads)
25+
--- stderr ---
26+
stderr: 1 lines
27+
warn: Lint warnings found
1128
```

crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.local.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,25 @@ vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets
44

55
## `vpt backpressure-run --digest 6,8 -- vp check`
66

7-
**Exit code:** 1
8-
97
```
10-
backpressure-run detected truncated child output under stdio backpressure
8+
--- stdout ---
9+
stdout: 1282 lines
10+
pass: All 3 files are correctly formatted (<duration>, <n> threads)
11+
! eslint(no-unused-vars): Variable 'unused000' is declared but never used. Unused variables should start with a '_'.
12+
,-[src/index.js:2:9]
13+
1 | export function emitDiagnostics() {
14+
2 | const unused000 = 0;
15+
: ^^^^|^^^^
16+
... 1268 lines elided ...
17+
129 | const unused127 = 127;
18+
: ^^^^|^^^^
19+
: `-- 'unused127' is declared here
20+
130 | }
21+
`----
22+
help: Consider removing this declaration.
23+
24+
Found 0 errors and 128 warnings in 2 files (<duration>, <n> threads)
25+
--- stderr ---
26+
stderr: 1 lines
27+
warn: Lint warnings found
1128
```

crates/vite_global_cli/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,8 @@ fn print_unknown_argument_error(error: &clap::Error) -> bool {
296296

297297
#[tokio::main]
298298
async fn main() -> ExitCode {
299+
vite_shared::ensure_blocking_stdio();
300+
299301
// Initialize tracing
300302
vite_shared::init_tracing();
301303

crates/vite_shared/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ rust-version.workspace = true
99

1010
[dependencies]
1111
directories = { workspace = true }
12-
nix = { workspace = true, features = ["poll", "term"] }
12+
nix = { workspace = true, features = ["fs", "poll", "term"] }
1313
owo-colors = { workspace = true }
1414
serde = { workspace = true }
1515
# use `preserve_order` feature to preserve the order of the fields in `package.json`

crates/vite_shared/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod json_edit;
1717
pub mod output;
1818
mod package_json;
1919
mod path_env;
20+
mod stdio;
2021
pub mod string_similarity;
2122
mod tls;
2223
mod tracing;
@@ -33,5 +34,6 @@ pub use path_env::{
3334
PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend,
3435
prepend_to_path_env,
3536
};
37+
pub use stdio::ensure_blocking_stdio;
3638
pub use tls::ensure_tls_provider;
3739
pub use tracing::init_tracing;

crates/vite_shared/src/stdio.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/// Ensure inherited standard streams use the blocking semantics expected by
2+
/// Rust's standard library and by the tools embedded in Vite+.
3+
///
4+
/// Node.js marks non-TTY stdio as non-blocking, and child processes inherit
5+
/// that flag through the shared open file description. Clear `O_NONBLOCK`
6+
/// once at process entry so unowned writers do not panic or truncate output
7+
/// when a consumer applies backpressure.
8+
#[cfg(unix)]
9+
pub fn ensure_blocking_stdio() {
10+
use std::{io, os::fd::AsFd};
11+
12+
let stdin = io::stdin();
13+
let stdout = io::stdout();
14+
let stderr = io::stderr();
15+
16+
for fd in [stdin.as_fd(), stdout.as_fd(), stderr.as_fd()] {
17+
ensure_blocking_fd(fd);
18+
}
19+
}
20+
21+
#[cfg(unix)]
22+
fn ensure_blocking_fd(fd: std::os::fd::BorrowedFd<'_>) {
23+
use nix::{
24+
fcntl::{FcntlArg, OFlag, fcntl},
25+
unistd::isatty,
26+
};
27+
28+
if isatty(fd).unwrap_or(false) {
29+
return;
30+
}
31+
32+
let Ok(flags) = fcntl(fd, FcntlArg::F_GETFL) else {
33+
return;
34+
};
35+
let flags = OFlag::from_bits_retain(flags);
36+
if flags.contains(OFlag::O_NONBLOCK) {
37+
let _ = fcntl(fd, FcntlArg::F_SETFL(flags.difference(OFlag::O_NONBLOCK)));
38+
}
39+
}
40+
41+
#[cfg(not(unix))]
42+
pub fn ensure_blocking_stdio() {}
43+
44+
#[cfg(all(test, unix))]
45+
mod tests {
46+
use std::os::{fd::AsFd, unix::net::UnixStream};
47+
48+
use nix::fcntl::{FcntlArg, OFlag, fcntl};
49+
50+
use super::ensure_blocking_fd;
51+
52+
#[test]
53+
fn clears_nonblocking_from_a_non_tty_file_description() {
54+
let (stream, _peer) = UnixStream::pair().unwrap();
55+
stream.set_nonblocking(true).unwrap();
56+
57+
ensure_blocking_fd(stream.as_fd());
58+
59+
let flags = fcntl(&stream, FcntlArg::F_GETFL).unwrap();
60+
assert!(!OFlag::from_bits_retain(flags).contains(OFlag::O_NONBLOCK));
61+
}
62+
}

packages/cli/binding/index.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,7 @@ module.exports.shutdownAsyncRuntime = nativeBinding.shutdownAsyncRuntime;
849849
module.exports.startAsyncRuntime = nativeBinding.startAsyncRuntime;
850850
module.exports.detectWorkspace = nativeBinding.detectWorkspace;
851851
module.exports.downloadPackageManager = nativeBinding.downloadPackageManager;
852+
module.exports.ensureBlockingStdio = nativeBinding.ensureBlockingStdio;
852853
module.exports.hasConfigKey = nativeBinding.hasConfigKey;
853854
module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig;
854855
module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig;

packages/cli/binding/index.d.cts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3366,6 +3366,9 @@ export interface DownloadPackageManagerResult {
33663366
version: string;
33673367
}
33683368

3369+
/** Re-enable blocking stdio after Node.js has initialized its lazy standard streams. */
3370+
export declare function ensureBlockingStdio(): void;
3371+
33693372
/**
33703373
* Whether `config_key` is already declared as a top-level property in the
33713374
* vite config's `defineConfig({...})` (or equivalent) object literal.

packages/cli/binding/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ use crate::cli::{
4242
#[napi_derive::module_init]
4343
#[allow(clippy::disallowed_macros)]
4444
pub fn init() {
45+
vite_shared::ensure_blocking_stdio();
4546
crate::cli::init_tracing();
4647

4748
// Install a Vite+ panic hook so panics are correctly attributed to Vite+.
@@ -55,6 +56,12 @@ pub fn init() {
5556
}));
5657
}
5758

59+
/// Re-enable blocking stdio after Node.js has initialized its lazy standard streams.
60+
#[napi]
61+
pub fn ensure_blocking_stdio() {
62+
vite_shared::ensure_blocking_stdio();
63+
}
64+
5865
/// Configuration options passed from JavaScript to Rust.
5966
#[napi(object, object_to_js = false)]
6067
pub struct CliOptions {

packages/cli/src/bin.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import path from 'node:path';
1414

15-
import { run } from '../binding/index.js';
15+
import { ensureBlockingStdio, run } from '../binding/index.js';
1616
import { applyToolInitConfigToViteConfig, inspectInitCommand } from './init-config.ts';
1717
import { doc } from './resolve-doc.ts';
1818
import { fmt } from './resolve-fmt.ts';
@@ -23,6 +23,12 @@ import { resolveUniversalViteConfig } from './resolve-vite-config.ts';
2323
import { vite } from './resolve-vite.ts';
2424
import { accent, errorMsg, log } from './utils/terminal.ts';
2525

26+
// Node.js sets O_NONBLOCK when pipe-backed stdio is first accessed. Materialize
27+
// the output streams before restoring the blocking semantics expected by Rust.
28+
void process.stdout;
29+
void process.stderr;
30+
ensureBlockingStdio();
31+
2632
function getErrorMessage(err: unknown): string {
2733
if (err instanceof Error) {
2834
return err.message;

0 commit comments

Comments
 (0)