From 7f1e760be436bd4bb8bd7d091653cfbf0dcd577f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Wed, 15 Jul 2026 06:51:13 +0000 Subject: [PATCH] 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. --- Cargo.lock | 2 + crates/vite_cli_snapshots/Cargo.toml | 3 + .../src/bin/vpt/backpressure_run.rs | 195 ++++++++++++++++++ crates/vite_cli_snapshots/src/bin/vpt/main.rs | 6 +- .../tests/cli_snapshots/README.md | 4 +- .../fixtures/check_backpressure/package.json | 5 + .../check_backpressure/snapshots.toml | 9 + ..._backpressure_nonblocking_stdout.global.md | 11 + ...k_backpressure_nonblocking_stdout.local.md | 11 + .../fixtures/check_backpressure/src/index.js | 130 ++++++++++++ .../check_backpressure/vite.config.ts | 7 + 11 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 crates/vite_cli_snapshots/src/bin/vpt/backpressure_run.rs create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/src/index.js create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/vite.config.ts diff --git a/Cargo.lock b/Cargo.lock index f744f883c5..e1ca5d2cd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3833,6 +3833,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", ] [[package]] @@ -8394,6 +8395,7 @@ dependencies = [ "dunce", "junction", "libtest-mimic", + "nix 0.30.1", "pty_terminal_test", "pty_terminal_test_client", "regex", diff --git a/crates/vite_cli_snapshots/Cargo.toml b/crates/vite_cli_snapshots/Cargo.toml index 846b53e092..d03b0367d9 100644 --- a/crates/vite_cli_snapshots/Cargo.toml +++ b/crates/vite_cli_snapshots/Cargo.toml @@ -19,6 +19,9 @@ ctrlc = { workspace = true } pty_terminal_test_client = { workspace = true, features = ["testing"] } serde_json = { workspace = true } +[target.'cfg(unix)'.dependencies] +nix = { workspace = true, features = ["fs", "poll", "socket"] } + [dev-dependencies] cow-utils = { workspace = true } dunce = { workspace = true } diff --git a/crates/vite_cli_snapshots/src/bin/vpt/backpressure_run.rs b/crates/vite_cli_snapshots/src/bin/vpt/backpressure_run.rs new file mode 100644 index 0000000000..ab32c1cad3 --- /dev/null +++ b/crates/vite_cli_snapshots/src/bin/vpt/backpressure_run.rs @@ -0,0 +1,195 @@ +use std::{ + fs::File, + io::{self, Read}, + os::fd::AsFd, + process::{Command, ExitStatus, Stdio}, + thread, + time::Duration, +}; + +use nix::{ + fcntl::{FcntlArg, OFlag, fcntl}, + poll::{PollFd, PollFlags, PollTimeout, poll}, + sys::socket::{ + AddressFamily, SockFlag, SockType, setsockopt, socketpair, + sockopt::{RcvBuf, SndBuf}, + }, +}; + +const REQUESTED_SOCKET_BUFFER: usize = 1024; +const DRAIN_CHUNK: usize = 1024; + +#[derive(Debug, Default)] +struct Options { + digest: Option<(usize, usize)>, + command: Vec, +} + +/// Run a command with a deliberately non-blocking, backpressured stdout. +/// +/// The reader consumes one small chunk whenever the channel fills. That lets a +/// blocking writer advance while keeping enough pressure for a non-blocking +/// writer to encounter `EAGAIN`. +pub fn run(args: &[String]) -> Result<(), Box> { + let options = parse_options(args)?; + let (reader_fd, writer_fd) = + socketpair(AddressFamily::Unix, SockType::Stream, None, SockFlag::empty())?; + setsockopt(&reader_fd, RcvBuf, &REQUESTED_SOCKET_BUFFER)?; + setsockopt(&writer_fd, SndBuf, &REQUESTED_SOCKET_BUFFER)?; + + let mut reader = File::from(reader_fd); + let writer = File::from(writer_fd); + set_nonblocking(&writer, true)?; + + let retained_writer = writer.try_clone()?; + + let mut child = Command::new(&options.command[0]) + .args(&options.command[1..]) + .stdout(Stdio::from(writer)) + .stderr(Stdio::piped()) + .spawn()?; + + let stderr = child.stderr.take().ok_or("failed to capture child stderr")?; + let stderr_thread = thread::spawn(move || -> io::Result> { + let mut stderr = stderr; + let mut captured = Vec::new(); + stderr.read_to_end(&mut captured)?; + Ok(captured) + }); + + let (status, stdout, saw_nonblocking_backpressure) = + capture_backpressured_stdout(&mut child, &mut reader, retained_writer)?; + let stderr = stderr_thread.join().map_err(|_| "stderr reader thread panicked")??; + + if saw_nonblocking_backpressure { + eprintln!("backpressure-run detected truncated child output under stdio backpressure"); + std::process::exit(1); + } + + replay("stdout", &stdout, options.digest); + replay("stderr", &stderr, options.digest); + + std::process::exit(status.code().unwrap_or(1)); +} + +fn parse_options(args: &[String]) -> Result> { + let Some(separator) = args.iter().position(|arg| arg == "--") else { + return Err(usage().into()); + }; + + let mut options = Options { command: args[separator + 1..].to_vec(), ..Options::default() }; + if options.command.is_empty() { + return Err(usage().into()); + } + + let mut index = 0; + while index < separator { + match args[index].as_str() { + "--digest" => { + let value = args.get(index + 1).ok_or_else(usage)?; + options.digest = Some(parse_digest(value)?); + index += 2; + } + _ => return Err(usage().into()), + } + } + + Ok(options) +} + +fn usage() -> String { + "Usage: vpt backpressure-run [--digest ,] -- [args...]".to_owned() +} + +fn parse_digest(value: &str) -> Result<(usize, usize), Box> { + let Some((head, tail)) = value.split_once(',') else { + return Err("--digest must be ,".into()); + }; + Ok((head.parse()?, tail.parse()?)) +} + +fn set_nonblocking(fd: &impl AsFd, nonblocking: bool) -> io::Result<()> { + let flags = fcntl(fd, FcntlArg::F_GETFL).map_err(io::Error::from)?; + let mut flags = OFlag::from_bits_retain(flags); + flags.set(OFlag::O_NONBLOCK, nonblocking); + fcntl(fd, FcntlArg::F_SETFL(flags)).map_err(io::Error::from)?; + Ok(()) +} + +fn capture_backpressured_stdout( + child: &mut std::process::Child, + reader: &mut File, + retained_writer: File, +) -> io::Result<(ExitStatus, Vec, bool)> { + let mut retained_writer = Some(retained_writer); + let mut captured = Vec::new(); + let mut saw_nonblocking_backpressure = false; + + loop { + if let Some(status) = child.try_wait()? { + retained_writer.take(); + reader.read_to_end(&mut captured)?; + return Ok((status, captured, saw_nonblocking_backpressure)); + } + + let writer = retained_writer.as_ref().expect("writer is retained until child exit"); + if !is_writable(writer)? { + saw_nonblocking_backpressure |= is_nonblocking(writer)?; + + let mut chunk = [0_u8; DRAIN_CHUNK]; + let read = reader.read(&mut chunk)?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "stdout socket closed before the child exited", + )); + } + captured.extend_from_slice(&chunk[..read]); + } else { + thread::sleep(Duration::from_millis(1)); + } + } +} + +fn is_writable(fd: &impl AsFd) -> io::Result { + let mut fds = [PollFd::new(fd.as_fd(), PollFlags::POLLOUT)]; + loop { + match poll(&mut fds, PollTimeout::ZERO) { + Ok(ready) => return Ok(ready > 0), + Err(nix::errno::Errno::EINTR) => {} + Err(error) => return Err(io::Error::from(error)), + } + } +} + +fn is_nonblocking(fd: &impl AsFd) -> io::Result { + let flags = fcntl(fd, FcntlArg::F_GETFL).map_err(io::Error::from)?; + Ok(OFlag::from_bits_retain(flags).contains(OFlag::O_NONBLOCK)) +} + +fn replay(name: &str, bytes: &[u8], digest: Option<(usize, usize)>) { + println!("--- {name} ---"); + let text = String::from_utf8_lossy(bytes); + if let Some((head, tail)) = digest { + let lines: Vec<_> = text.lines().collect(); + println!("{name}: {} lines", lines.len()); + if lines.len() <= head + tail { + for line in lines { + println!("{line}"); + } + } else { + for line in &lines[..head] { + println!("{line}"); + } + println!("... {} lines elided ...", lines.len() - head - tail); + for line in &lines[lines.len() - tail..] { + println!("{line}"); + } + } + } else { + print!("{text}"); + if !text.is_empty() && !text.ends_with('\n') { + println!(); + } + } +} diff --git a/crates/vite_cli_snapshots/src/bin/vpt/main.rs b/crates/vite_cli_snapshots/src/bin/vpt/main.rs index ab39c2a57f..ca21d1e06f 100644 --- a/crates/vite_cli_snapshots/src/bin/vpt/main.rs +++ b/crates/vite_cli_snapshots/src/bin/vpt/main.rs @@ -9,6 +9,8 @@ #![expect(clippy::print_stderr, reason = "CLI tool error output")] #![expect(clippy::print_stdout, reason = "CLI tool output")] +#[cfg(unix)] +mod backpressure_run; mod barrier; mod check_tty; mod chmod; @@ -61,7 +63,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vpt [args...]"); eprintln!( - "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" + "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" ); std::process::exit(1); } @@ -70,6 +72,8 @@ fn main() { } let result: Result<(), Box> = match args[1].as_str() { + #[cfg(unix)] + "backpressure-run" => backpressure_run::run(&args[2..]), "barrier" => barrier::run(&args[2..]), "check-tty" => { check_tty::run(); diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md index 1d2b9b1c80..ff623a661f 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/README.md @@ -131,7 +131,9 @@ is identical on every platform: `vpt print`, `vpt print-color`, `vpt print-env`, `vpt print-cwd`, `vpt print-native-path` (prints OS-native separators, for redaction self-tests), `vpt check-tty`, `vpt read-stdin`, `vpt exit `, -`vpt exit-on-ctrlc`, `vpt barrier`. +`vpt exit-on-ctrlc`, `vpt barrier`, and the Unix-only +`vpt backpressure-run [--digest ,] -- ` +for running a command with deliberately backpressured, non-blocking stdout. ## Interactive cases diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/package.json new file mode 100644 index 0000000000..927a92eab6 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/package.json @@ -0,0 +1,5 @@ +{ + "name": "check-backpressure", + "version": "0.0.0", + "private": true +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots.toml new file mode 100644 index 0000000000..25796307ba --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "check_backpressure_nonblocking_stdout" +vp = ["local", "global"] +skip-platforms = ["windows"] +comment = "vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets a non-blocking, backpressured pipe (#2165)." +env = { VITE_DISABLE_AUTO_INSTALL = "1" } +steps = [ + { argv = ["vpt", "backpressure-run", "--digest", "6,8", "--", "vp", "check"], tty = false, timeout = 120000 }, +] 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 new file mode 100644 index 0000000000..f233a35978 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.global.md @@ -0,0 +1,11 @@ +# check_backpressure_nonblocking_stdout + +vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets a non-blocking, backpressured pipe (#2165). + +## `vpt backpressure-run --digest 6,8 -- vp check` + +**Exit code:** 1 + +``` +backpressure-run detected truncated child output under stdio backpressure +``` 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 new file mode 100644 index 0000000000..f233a35978 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/snapshots/check_backpressure_nonblocking_stdout.local.md @@ -0,0 +1,11 @@ +# check_backpressure_nonblocking_stdout + +vp check exposes the stdout EAGAIN failure when a large diagnostic replay meets a non-blocking, backpressured pipe (#2165). + +## `vpt backpressure-run --digest 6,8 -- vp check` + +**Exit code:** 1 + +``` +backpressure-run detected truncated child output under stdio backpressure +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/src/index.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/src/index.js new file mode 100644 index 0000000000..e4714335b0 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/src/index.js @@ -0,0 +1,130 @@ +export function emitDiagnostics() { + const unused000 = 0; + const unused001 = 1; + const unused002 = 2; + const unused003 = 3; + const unused004 = 4; + const unused005 = 5; + const unused006 = 6; + const unused007 = 7; + const unused008 = 8; + const unused009 = 9; + const unused010 = 10; + const unused011 = 11; + const unused012 = 12; + const unused013 = 13; + const unused014 = 14; + const unused015 = 15; + const unused016 = 16; + const unused017 = 17; + const unused018 = 18; + const unused019 = 19; + const unused020 = 20; + const unused021 = 21; + const unused022 = 22; + const unused023 = 23; + const unused024 = 24; + const unused025 = 25; + const unused026 = 26; + const unused027 = 27; + const unused028 = 28; + const unused029 = 29; + const unused030 = 30; + const unused031 = 31; + const unused032 = 32; + const unused033 = 33; + const unused034 = 34; + const unused035 = 35; + const unused036 = 36; + const unused037 = 37; + const unused038 = 38; + const unused039 = 39; + const unused040 = 40; + const unused041 = 41; + const unused042 = 42; + const unused043 = 43; + const unused044 = 44; + const unused045 = 45; + const unused046 = 46; + const unused047 = 47; + const unused048 = 48; + const unused049 = 49; + const unused050 = 50; + const unused051 = 51; + const unused052 = 52; + const unused053 = 53; + const unused054 = 54; + const unused055 = 55; + const unused056 = 56; + const unused057 = 57; + const unused058 = 58; + const unused059 = 59; + const unused060 = 60; + const unused061 = 61; + const unused062 = 62; + const unused063 = 63; + const unused064 = 64; + const unused065 = 65; + const unused066 = 66; + const unused067 = 67; + const unused068 = 68; + const unused069 = 69; + const unused070 = 70; + const unused071 = 71; + const unused072 = 72; + const unused073 = 73; + const unused074 = 74; + const unused075 = 75; + const unused076 = 76; + const unused077 = 77; + const unused078 = 78; + const unused079 = 79; + const unused080 = 80; + const unused081 = 81; + const unused082 = 82; + const unused083 = 83; + const unused084 = 84; + const unused085 = 85; + const unused086 = 86; + const unused087 = 87; + const unused088 = 88; + const unused089 = 89; + const unused090 = 90; + const unused091 = 91; + const unused092 = 92; + const unused093 = 93; + const unused094 = 94; + const unused095 = 95; + const unused096 = 96; + const unused097 = 97; + const unused098 = 98; + const unused099 = 99; + const unused100 = 100; + const unused101 = 101; + const unused102 = 102; + const unused103 = 103; + const unused104 = 104; + const unused105 = 105; + const unused106 = 106; + const unused107 = 107; + const unused108 = 108; + const unused109 = 109; + const unused110 = 110; + const unused111 = 111; + const unused112 = 112; + const unused113 = 113; + const unused114 = 114; + const unused115 = 115; + const unused116 = 116; + const unused117 = 117; + const unused118 = 118; + const unused119 = 119; + const unused120 = 120; + const unused121 = 121; + const unused122 = 122; + const unused123 = 123; + const unused124 = 124; + const unused125 = 125; + const unused126 = 126; + const unused127 = 127; +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/vite.config.ts new file mode 100644 index 0000000000..579fc14ead --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/check_backpressure/vite.config.ts @@ -0,0 +1,7 @@ +export default { + lint: { + rules: { + "no-unused-vars": "warn", + }, + }, +};