Skip to content

Commit 24813bf

Browse files
wan9chiGPT-5.6
andcommitted
test(e2e): reproduce constrained dev shm failure
Co-authored-by: GPT-5.6 <gpt-5.6@openai.com>
1 parent 7121ef2 commit 24813bf

9 files changed

Lines changed: 343 additions & 11 deletions

File tree

Cargo.lock

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

crates/vite_task_bin/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ vite_str = { workspace = true }
3131
vite_task = { workspace = true }
3232
which = { workspace = true }
3333

34+
[target.'cfg(target_os = "linux")'.dependencies]
35+
nix = { workspace = true, features = ["mount", "sched", "user"] }
36+
3437
[dev-dependencies]
3538
cow-utils = { workspace = true }
3639
cp_r = { workspace = true }

crates/vite_task_bin/src/vtt/main.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod print_file;
2323
mod read_stdin;
2424
mod replace_file_content;
2525
mod rm;
26+
mod small_dev_shm;
2627
mod stat_file;
2728
mod touch_file;
2829
mod write_file;
@@ -32,7 +33,7 @@ fn main() {
3233
if args.len() < 2 {
3334
eprintln!("Usage: vtt <subcommand> [args...]");
3435
eprintln!(
35-
"Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file"
36+
"Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, touch-file, write-file"
3637
);
3738
std::process::exit(1);
3839
}
@@ -64,10 +65,8 @@ fn main() {
6465
"read-stdin" => read_stdin::run(),
6566
"replace-file-content" => replace_file_content::run(&args[2..]),
6667
"rm" => rm::run(&args[2..]),
67-
"stat-file" => {
68-
stat_file::run(&args[2..]);
69-
Ok(())
70-
}
68+
"small_dev_shm" => small_dev_shm::run(&args[2..]),
69+
"stat-file" => stat_file::run(&args[2..]),
7170
"touch-file" => touch_file::run(&args[2..]),
7271
"write-file" => write_file::run(&args[2..]),
7372
other => {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use std::error::Error;
2+
3+
const USAGE: &str = "Usage: vtt small_dev_shm <command> [args...]";
4+
5+
pub fn run(args: &[String]) -> Result<(), Box<dyn Error>> {
6+
let (program, command_args) = parse_command(args)?;
7+
run_platform(program, command_args)?;
8+
Ok(())
9+
}
10+
11+
fn parse_command(args: &[String]) -> Result<(&str, &[String]), &'static str> {
12+
args.split_first().map(|(program, args)| (program.as_str(), args)).ok_or(USAGE)
13+
}
14+
15+
#[cfg(target_os = "linux")]
16+
fn write_procfs(path: &str, content: &str) -> std::io::Result<()> {
17+
use std::io::Write as _;
18+
19+
let mut file = std::fs::OpenOptions::new().write(true).open(path)?;
20+
file.write_all(content.as_bytes())
21+
}
22+
23+
#[cfg(target_os = "linux")]
24+
fn run_platform(program: &str, command_args: &[String]) -> std::io::Result<()> {
25+
use std::{os::unix::process::ExitStatusExt as _, process::Command};
26+
27+
use nix::{
28+
mount::{MsFlags, mount},
29+
sched::{CloneFlags, unshare},
30+
unistd::{Gid, Uid},
31+
};
32+
33+
fn operation_error(operation: &str, error: impl std::fmt::Display) -> std::io::Error {
34+
std::io::Error::other(format!("{operation}: {error}"))
35+
}
36+
37+
let uid = Uid::current().as_raw();
38+
let gid = Gid::current().as_raw();
39+
40+
unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
41+
.map_err(|error| operation_error("unshare user and mount namespaces", error))?;
42+
43+
write_procfs("/proc/self/uid_map", &format!("0 {uid} 1\n"))
44+
.map_err(|error| operation_error("write /proc/self/uid_map", error))?;
45+
match write_procfs("/proc/self/setgroups", "deny") {
46+
Ok(()) => {}
47+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
48+
Err(error) => return Err(operation_error("write /proc/self/setgroups", error)),
49+
}
50+
write_procfs("/proc/self/gid_map", &format!("0 {gid} 1\n"))
51+
.map_err(|error| operation_error("write /proc/self/gid_map", error))?;
52+
53+
mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>)
54+
.map_err(|error| operation_error("make / recursively private", error))?;
55+
56+
mount(
57+
Some("tmpfs"),
58+
"/dev/shm",
59+
Some("tmpfs"),
60+
MsFlags::empty(),
61+
Some("nr_blocks=1,huge=never"),
62+
)
63+
.map_err(|error| operation_error("mount one-page tmpfs at /dev/shm", error))?;
64+
65+
let status = Command::new(program).args(command_args).status()?;
66+
let code = status.code().unwrap_or_else(|| status.signal().map_or(1, |signal| 128 + signal));
67+
std::process::exit(code);
68+
}
69+
70+
#[cfg(not(target_os = "linux"))]
71+
fn run_platform(_program: &str, _command_args: &[String]) -> std::io::Result<()> {
72+
Err(std::io::Error::new(
73+
std::io::ErrorKind::Unsupported,
74+
"vtt small_dev_shm is only supported on Linux",
75+
))
76+
}
77+
78+
#[cfg(test)]
79+
mod tests {
80+
use super::*;
81+
82+
#[test]
83+
fn requires_a_command() {
84+
assert_eq!(parse_command(&[]).unwrap_err(), USAGE);
85+
}
86+
87+
#[test]
88+
fn parses_command_and_arguments() {
89+
let args = ["vt".to_owned(), "run".to_owned(), "stress".to_owned()];
90+
let (program, command_args) = parse_command(&args).unwrap();
91+
92+
assert_eq!(program, "vt");
93+
assert_eq!(command_args, ["run", "stress"]);
94+
}
95+
96+
#[cfg(target_os = "linux")]
97+
#[test]
98+
fn procfs_writer_does_not_create_missing_files() {
99+
let tempdir = tempfile::tempdir().unwrap();
100+
let missing = tempdir.path().join("missing");
101+
102+
let error = write_procfs(missing.to_str().unwrap(), "content").unwrap_err();
103+
104+
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
105+
assert!(!missing.exists());
106+
}
107+
108+
#[cfg(not(target_os = "linux"))]
109+
#[test]
110+
fn reports_unsupported_platform() {
111+
let error = run_platform("vt", &[]).unwrap_err();
112+
113+
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
114+
assert_eq!(error.to_string(), "vtt small_dev_shm is only supported on Linux");
115+
}
116+
}
Lines changed: 197 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,200 @@
1-
pub fn run(args: &[String]) {
2-
for file in args {
3-
if std::fs::metadata(file).is_ok() {
4-
println!("{file}: exists");
5-
} else {
6-
println!("{file}: missing");
1+
use std::{error::Error, io};
2+
3+
const USAGE: &str = "Usage: vtt stat-file [--generated-path-bytes <count>] [--quiet] [<path>...]";
4+
5+
#[derive(Debug, PartialEq)]
6+
struct Options<'a> {
7+
generated_path_bytes: Option<usize>,
8+
quiet: bool,
9+
paths: Vec<&'a str>,
10+
}
11+
12+
pub fn run(args: &[String]) -> Result<(), Box<dyn Error>> {
13+
let options = parse_args(args)?;
14+
run_with(&options, metadata, |path, exists| {
15+
println!("{path}: {}", if exists { "exists" } else { "missing" })
16+
});
17+
Ok(())
18+
}
19+
20+
#[cfg(target_os = "linux")]
21+
fn metadata(path: &str) -> io::Result<()> {
22+
use std::{ffi::CString, mem::MaybeUninit};
23+
24+
let path = CString::new(path)
25+
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
26+
let mut statx = MaybeUninit::<libc::statx>::uninit();
27+
// SAFETY: `path` is NUL-terminated, `statx` points to writable storage,
28+
// and the final zero is an ignored sixth syscall argument supplied so
29+
// fspy's variadic syscall interceptor can read a complete argument set.
30+
let result = unsafe {
31+
libc::syscall(
32+
libc::SYS_statx,
33+
libc::AT_FDCWD,
34+
path.as_ptr(),
35+
0,
36+
libc::STATX_BASIC_STATS,
37+
statx.as_mut_ptr(),
38+
0,
39+
)
40+
};
41+
if result == -1 { Err(io::Error::last_os_error()) } else { Ok(()) }
42+
}
43+
44+
#[cfg(not(target_os = "linux"))]
45+
fn metadata(path: &str) -> io::Result<()> {
46+
std::fs::metadata(path).map(|_| ())
47+
}
48+
49+
fn parse_args(args: &[String]) -> Result<Options<'_>, String> {
50+
let mut generated_path_bytes = None;
51+
let mut quiet = false;
52+
let mut paths = Vec::new();
53+
let mut args = args.iter();
54+
55+
while let Some(arg) = args.next() {
56+
match arg.as_str() {
57+
"--generated-path-bytes" => {
58+
if generated_path_bytes.is_some() {
59+
return Err(format!(
60+
"{USAGE}\n--generated-path-bytes may only be specified once"
61+
));
62+
}
63+
let count = args
64+
.next()
65+
.ok_or_else(|| format!("{USAGE}\n--generated-path-bytes requires a count"))?;
66+
generated_path_bytes =
67+
Some(count.parse::<usize>().map_err(|_| {
68+
format!("{USAGE}\ninvalid generated path byte count: {count}")
69+
})?);
70+
}
71+
"--quiet" => quiet = true,
72+
path => paths.push(path),
73+
}
74+
}
75+
76+
if generated_path_bytes.is_some() && !paths.is_empty() {
77+
return Err(format!("{USAGE}\n--generated-path-bytes cannot be used with explicit paths"));
78+
}
79+
80+
Ok(Options { generated_path_bytes, quiet, paths })
81+
}
82+
83+
fn run_with(
84+
options: &Options<'_>,
85+
mut metadata: impl FnMut(&str) -> io::Result<()>,
86+
mut report: impl FnMut(&str, bool),
87+
) {
88+
if let Some(byte_count) = options.generated_path_bytes {
89+
let path = "x".repeat(byte_count);
90+
let exists = metadata(&path).is_ok();
91+
if !options.quiet {
92+
report(&path, exists);
93+
}
94+
return;
95+
}
96+
97+
for path in &options.paths {
98+
let exists = metadata(path).is_ok();
99+
if !options.quiet {
100+
report(path, exists);
7101
}
8102
}
9103
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::*;
108+
109+
fn strings(args: &[&str]) -> Vec<String> {
110+
args.iter().map(|arg| (*arg).to_owned()).collect()
111+
}
112+
113+
#[test]
114+
fn parses_generated_path_and_quiet() {
115+
let args = strings(&["--generated-path-bytes", "1048576", "--quiet"]);
116+
117+
assert_eq!(
118+
parse_args(&args).unwrap(),
119+
Options { generated_path_bytes: Some(1_048_576), quiet: true, paths: vec![] }
120+
);
121+
}
122+
123+
#[test]
124+
fn preserves_explicit_path_behavior() {
125+
let args = strings(&["present", "missing"]);
126+
127+
assert_eq!(
128+
parse_args(&args).unwrap(),
129+
Options { generated_path_bytes: None, quiet: false, paths: vec!["present", "missing"] }
130+
);
131+
}
132+
133+
#[test]
134+
fn rejects_generated_path_with_explicit_paths() {
135+
let args = strings(&["--generated-path-bytes", "12", "explicit"]);
136+
137+
assert!(parse_args(&args).unwrap_err().contains("cannot be used with explicit paths"));
138+
}
139+
140+
#[test]
141+
fn rejects_missing_or_invalid_generated_path_count() {
142+
let missing = strings(&["--generated-path-bytes"]);
143+
let invalid = strings(&["--generated-path-bytes", "many"]);
144+
145+
assert!(parse_args(&missing).unwrap_err().contains("requires a count"));
146+
assert!(parse_args(&invalid).unwrap_err().contains("invalid generated path byte count"));
147+
}
148+
149+
#[test]
150+
fn generated_mode_stats_one_exact_non_nul_ascii_path() {
151+
let options = Options { generated_path_bytes: Some(4096), quiet: false, paths: vec![] };
152+
let mut calls = 0;
153+
let mut generated_path = String::new();
154+
let mut reports = 0;
155+
156+
run_with(
157+
&options,
158+
|path| {
159+
calls += 1;
160+
generated_path = path.to_owned();
161+
Err(io::Error::from_raw_os_error(libc::ENAMETOOLONG))
162+
},
163+
|_, _| reports += 1,
164+
);
165+
166+
assert_eq!(calls, 1);
167+
assert_eq!(generated_path.len(), 4096);
168+
assert!(generated_path.is_ascii());
169+
assert!(!generated_path.as_bytes().contains(&0));
170+
assert_eq!(reports, 1);
171+
}
172+
173+
#[test]
174+
fn generated_mode_treats_missing_as_success() {
175+
let options = Options { generated_path_bytes: Some(8), quiet: false, paths: vec![] };
176+
let mut missing = None;
177+
178+
run_with(
179+
&options,
180+
|_| Err(io::Error::from(io::ErrorKind::NotFound)),
181+
|_, exists| missing = Some(!exists),
182+
);
183+
184+
assert_eq!(missing, Some(true));
185+
}
186+
187+
#[test]
188+
fn quiet_suppresses_output() {
189+
let options = Options { generated_path_bytes: Some(8), quiet: true, paths: vec![] };
190+
let mut output = Vec::new();
191+
192+
run_with(
193+
&options,
194+
|_| Err(io::Error::from(io::ErrorKind::NotFound)),
195+
|path, exists| output.push((path.to_owned(), exists)),
196+
);
197+
198+
assert!(output.is_empty());
199+
}
200+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[[e2e]]
2+
name = "constrained_dev_shm"
3+
platform = "linux-gnu"
4+
steps = [["vtt", "small_dev_shm", "vt", "run", "stress"]]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# constrained_dev_shm
2+
3+
## `vtt small_dev_shm vt run stress`
4+
5+
**Exit code:** 135
6+
7+
```
8+
$ vtt stat-file --generated-path-bytes 1048576 --quiet
9+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"tasks": {
3+
"stress": {
4+
"command": "vtt stat-file --generated-path-bytes 1048576 --quiet",
5+
"cache": true
6+
}
7+
}
8+
}

0 commit comments

Comments
 (0)