Skip to content

Commit 7f1e760

Browse files
committed
test(cli): reproduce stdio backpressure (#2172)
## Motivation Reproduces #2165, so the fix in #2173 can be verified. ## How - add a Unix-only `vpt backpressure-run` payload that gives a child a non-blocking socket-backed stdout - keep the channel pressurized by reading one small chunk only when it is full, allowing blocking output to progress while detecting a full `O_NONBLOCK` writer directly - add a `vp check` fixture with 128 deterministic lint warnings, exercised through both local and global CLI flavors - record the current broken behavior with a stable `backpressure-run detected truncated child output under stdio backpressure` diagnostic ## CI behavior The failing command behavior is the expected snapshot in this layer, so the regression is visible without making this PR's CI red. The next PR in the stack changes the same snapshots to the complete successful output.
1 parent 8ea4145 commit 7f1e760

11 files changed

Lines changed: 381 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_cli_snapshots/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ ctrlc = { workspace = true }
1919
pty_terminal_test_client = { workspace = true, features = ["testing"] }
2020
serde_json = { workspace = true }
2121

22+
[target.'cfg(unix)'.dependencies]
23+
nix = { workspace = true, features = ["fs", "poll", "socket"] }
24+
2225
[dev-dependencies]
2326
cow-utils = { workspace = true }
2427
dunce = { workspace = true }
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
use std::{
2+
fs::File,
3+
io::{self, Read},
4+
os::fd::AsFd,
5+
process::{Command, ExitStatus, Stdio},
6+
thread,
7+
time::Duration,
8+
};
9+
10+
use nix::{
11+
fcntl::{FcntlArg, OFlag, fcntl},
12+
poll::{PollFd, PollFlags, PollTimeout, poll},
13+
sys::socket::{
14+
AddressFamily, SockFlag, SockType, setsockopt, socketpair,
15+
sockopt::{RcvBuf, SndBuf},
16+
},
17+
};
18+
19+
const REQUESTED_SOCKET_BUFFER: usize = 1024;
20+
const DRAIN_CHUNK: usize = 1024;
21+
22+
#[derive(Debug, Default)]
23+
struct Options {
24+
digest: Option<(usize, usize)>,
25+
command: Vec<String>,
26+
}
27+
28+
/// Run a command with a deliberately non-blocking, backpressured stdout.
29+
///
30+
/// The reader consumes one small chunk whenever the channel fills. That lets a
31+
/// blocking writer advance while keeping enough pressure for a non-blocking
32+
/// writer to encounter `EAGAIN`.
33+
pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
34+
let options = parse_options(args)?;
35+
let (reader_fd, writer_fd) =
36+
socketpair(AddressFamily::Unix, SockType::Stream, None, SockFlag::empty())?;
37+
setsockopt(&reader_fd, RcvBuf, &REQUESTED_SOCKET_BUFFER)?;
38+
setsockopt(&writer_fd, SndBuf, &REQUESTED_SOCKET_BUFFER)?;
39+
40+
let mut reader = File::from(reader_fd);
41+
let writer = File::from(writer_fd);
42+
set_nonblocking(&writer, true)?;
43+
44+
let retained_writer = writer.try_clone()?;
45+
46+
let mut child = Command::new(&options.command[0])
47+
.args(&options.command[1..])
48+
.stdout(Stdio::from(writer))
49+
.stderr(Stdio::piped())
50+
.spawn()?;
51+
52+
let stderr = child.stderr.take().ok_or("failed to capture child stderr")?;
53+
let stderr_thread = thread::spawn(move || -> io::Result<Vec<u8>> {
54+
let mut stderr = stderr;
55+
let mut captured = Vec::new();
56+
stderr.read_to_end(&mut captured)?;
57+
Ok(captured)
58+
});
59+
60+
let (status, stdout, saw_nonblocking_backpressure) =
61+
capture_backpressured_stdout(&mut child, &mut reader, retained_writer)?;
62+
let stderr = stderr_thread.join().map_err(|_| "stderr reader thread panicked")??;
63+
64+
if saw_nonblocking_backpressure {
65+
eprintln!("backpressure-run detected truncated child output under stdio backpressure");
66+
std::process::exit(1);
67+
}
68+
69+
replay("stdout", &stdout, options.digest);
70+
replay("stderr", &stderr, options.digest);
71+
72+
std::process::exit(status.code().unwrap_or(1));
73+
}
74+
75+
fn parse_options(args: &[String]) -> Result<Options, Box<dyn std::error::Error>> {
76+
let Some(separator) = args.iter().position(|arg| arg == "--") else {
77+
return Err(usage().into());
78+
};
79+
80+
let mut options = Options { command: args[separator + 1..].to_vec(), ..Options::default() };
81+
if options.command.is_empty() {
82+
return Err(usage().into());
83+
}
84+
85+
let mut index = 0;
86+
while index < separator {
87+
match args[index].as_str() {
88+
"--digest" => {
89+
let value = args.get(index + 1).ok_or_else(usage)?;
90+
options.digest = Some(parse_digest(value)?);
91+
index += 2;
92+
}
93+
_ => return Err(usage().into()),
94+
}
95+
}
96+
97+
Ok(options)
98+
}
99+
100+
fn usage() -> String {
101+
"Usage: vpt backpressure-run [--digest <head>,<tail>] -- <command> [args...]".to_owned()
102+
}
103+
104+
fn parse_digest(value: &str) -> Result<(usize, usize), Box<dyn std::error::Error>> {
105+
let Some((head, tail)) = value.split_once(',') else {
106+
return Err("--digest must be <head>,<tail>".into());
107+
};
108+
Ok((head.parse()?, tail.parse()?))
109+
}
110+
111+
fn set_nonblocking(fd: &impl AsFd, nonblocking: bool) -> io::Result<()> {
112+
let flags = fcntl(fd, FcntlArg::F_GETFL).map_err(io::Error::from)?;
113+
let mut flags = OFlag::from_bits_retain(flags);
114+
flags.set(OFlag::O_NONBLOCK, nonblocking);
115+
fcntl(fd, FcntlArg::F_SETFL(flags)).map_err(io::Error::from)?;
116+
Ok(())
117+
}
118+
119+
fn capture_backpressured_stdout(
120+
child: &mut std::process::Child,
121+
reader: &mut File,
122+
retained_writer: File,
123+
) -> io::Result<(ExitStatus, Vec<u8>, bool)> {
124+
let mut retained_writer = Some(retained_writer);
125+
let mut captured = Vec::new();
126+
let mut saw_nonblocking_backpressure = false;
127+
128+
loop {
129+
if let Some(status) = child.try_wait()? {
130+
retained_writer.take();
131+
reader.read_to_end(&mut captured)?;
132+
return Ok((status, captured, saw_nonblocking_backpressure));
133+
}
134+
135+
let writer = retained_writer.as_ref().expect("writer is retained until child exit");
136+
if !is_writable(writer)? {
137+
saw_nonblocking_backpressure |= is_nonblocking(writer)?;
138+
139+
let mut chunk = [0_u8; DRAIN_CHUNK];
140+
let read = reader.read(&mut chunk)?;
141+
if read == 0 {
142+
return Err(io::Error::new(
143+
io::ErrorKind::UnexpectedEof,
144+
"stdout socket closed before the child exited",
145+
));
146+
}
147+
captured.extend_from_slice(&chunk[..read]);
148+
} else {
149+
thread::sleep(Duration::from_millis(1));
150+
}
151+
}
152+
}
153+
154+
fn is_writable(fd: &impl AsFd) -> io::Result<bool> {
155+
let mut fds = [PollFd::new(fd.as_fd(), PollFlags::POLLOUT)];
156+
loop {
157+
match poll(&mut fds, PollTimeout::ZERO) {
158+
Ok(ready) => return Ok(ready > 0),
159+
Err(nix::errno::Errno::EINTR) => {}
160+
Err(error) => return Err(io::Error::from(error)),
161+
}
162+
}
163+
}
164+
165+
fn is_nonblocking(fd: &impl AsFd) -> io::Result<bool> {
166+
let flags = fcntl(fd, FcntlArg::F_GETFL).map_err(io::Error::from)?;
167+
Ok(OFlag::from_bits_retain(flags).contains(OFlag::O_NONBLOCK))
168+
}
169+
170+
fn replay(name: &str, bytes: &[u8], digest: Option<(usize, usize)>) {
171+
println!("--- {name} ---");
172+
let text = String::from_utf8_lossy(bytes);
173+
if let Some((head, tail)) = digest {
174+
let lines: Vec<_> = text.lines().collect();
175+
println!("{name}: {} lines", lines.len());
176+
if lines.len() <= head + tail {
177+
for line in lines {
178+
println!("{line}");
179+
}
180+
} else {
181+
for line in &lines[..head] {
182+
println!("{line}");
183+
}
184+
println!("... {} lines elided ...", lines.len() - head - tail);
185+
for line in &lines[lines.len() - tail..] {
186+
println!("{line}");
187+
}
188+
}
189+
} else {
190+
print!("{text}");
191+
if !text.is_empty() && !text.ends_with('\n') {
192+
println!();
193+
}
194+
}
195+
}

crates/vite_cli_snapshots/src/bin/vpt/main.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
#![expect(clippy::print_stderr, reason = "CLI tool error output")]
1010
#![expect(clippy::print_stdout, reason = "CLI tool output")]
1111

12+
#[cfg(unix)]
13+
mod backpressure_run;
1214
mod barrier;
1315
mod check_tty;
1416
mod chmod;
@@ -61,7 +63,7 @@ fn main() {
6163
if args.len() < 2 {
6264
eprintln!("Usage: vpt <subcommand> [args...]");
6365
eprintln!(
64-
"Subcommands: barrier, check-tty, chmod, cp, exit, exit-on-ctrlc, grep-file, json-edit, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, print-native-path, probe, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file"
66+
"Subcommands: backpressure-run (Unix), barrier, check-tty, chmod, cp, exit, exit-on-ctrlc, grep-file, json-edit, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, print-native-path, probe, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file"
6567
);
6668
std::process::exit(1);
6769
}
@@ -70,6 +72,8 @@ fn main() {
7072
}
7173

7274
let result: Result<(), Box<dyn std::error::Error>> = match args[1].as_str() {
75+
#[cfg(unix)]
76+
"backpressure-run" => backpressure_run::run(&args[2..]),
7377
"barrier" => barrier::run(&args[2..]),
7478
"check-tty" => {
7579
check_tty::run();

crates/vite_cli_snapshots/tests/cli_snapshots/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ is identical on every platform:
131131
`vpt print`, `vpt print-color`, `vpt print-env`, `vpt print-cwd`,
132132
`vpt print-native-path` (prints OS-native separators, for redaction
133133
self-tests), `vpt check-tty`, `vpt read-stdin`, `vpt exit <code>`,
134-
`vpt exit-on-ctrlc`, `vpt barrier`.
134+
`vpt exit-on-ctrlc`, `vpt barrier`, and the Unix-only
135+
`vpt backpressure-run [--digest <head>,<tail>] -- <argv...>`
136+
for running a command with deliberately backpressured, non-blocking stdout.
135137

136138
## Interactive cases
137139

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "check-backpressure",
3+
"version": "0.0.0",
4+
"private": true
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[[case]]
2+
name = "check_backpressure_nonblocking_stdout"
3+
vp = ["local", "global"]
4+
skip-platforms = ["windows"]
5+
comment = "vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets a non-blocking, backpressured pipe (#2165)."
6+
env = { VITE_DISABLE_AUTO_INSTALL = "1" }
7+
steps = [
8+
{ argv = ["vpt", "backpressure-run", "--digest", "6,8", "--", "vp", "check"], tty = false, timeout = 120000 },
9+
]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# check_backpressure_nonblocking_stdout
2+
3+
vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets a non-blocking, backpressured pipe (#2165).
4+
5+
## `vpt backpressure-run --digest 6,8 -- vp check`
6+
7+
**Exit code:** 1
8+
9+
```
10+
backpressure-run detected truncated child output under stdio backpressure
11+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# check_backpressure_nonblocking_stdout
2+
3+
vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets a non-blocking, backpressured pipe (#2165).
4+
5+
## `vpt backpressure-run --digest 6,8 -- vp check`
6+
7+
**Exit code:** 1
8+
9+
```
10+
backpressure-run detected truncated child output under stdio backpressure
11+
```

0 commit comments

Comments
 (0)