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", + }, + }, +};