use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use std::sync::atomic::{AtomicI32, Ordering};
use stuffr::source::{GuardedSeek, Source};
use stuffr::{Codec, DecodeOpts, EncodeOpts, Error, FormatKind};
use super::measure::{
CHUNK, Mode, Plan, Snapshot, TempOut, fill, governor_for, read_some, shrank, temp_source,
};
use super::score::Memory;
pub const CHILD_ARG: &str = "__bench-memory-child";
const DEBUG_CHILD_REFUSAL: &str = "memory runs from a debug build are meaningless";
pub static RUNNING_CHILD: AtomicI32 = AtomicI32::new(0);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Compress,
Decompress,
}
impl Side {
fn word(self) -> &'static str {
match self {
Side::Compress => "compress",
Side::Decompress => "decompress",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChildJob {
pub side: Side,
pub codec: &'static str,
pub level: Option<i32>,
pub threads: Option<usize>,
pub memory_limit: u64,
pub out: Option<PathBuf>,
pub files: Vec<Snapshot>,
}
const NONE: &str = "-";
impl ChildJob {
pub fn to_args(&self) -> Vec<OsString> {
let opt = |v: Option<String>| OsString::from(v.unwrap_or_else(|| NONE.to_string()));
let mut args = vec![
OsString::from(self.side.word()),
OsString::from(self.codec),
opt(self.level.map(|l| l.to_string())),
opt(self.threads.map(|t| t.to_string())),
OsString::from(self.memory_limit.to_string()),
self.out
.clone()
.map_or_else(|| OsString::from(NONE), PathBuf::into_os_string),
];
for s in &self.files {
args.push(OsString::from(s.len.to_string()));
args.push(s.path.clone().into_os_string());
}
args
}
pub fn from_args(args: &[OsString]) -> Result<Self, String> {
fn text<'a>(a: Option<&'a OsString>, what: &str) -> Result<&'a str, String> {
a.ok_or_else(|| format!("missing {what}"))?
.to_str()
.ok_or_else(|| format!("{what} is not UTF-8"))
}
fn num<T: std::str::FromStr>(s: &str, what: &str) -> Result<T, String> {
s.parse().map_err(|_| format!("bad {what} `{s}`"))
}
fn opt<T: std::str::FromStr>(s: &str, what: &str) -> Result<Option<T>, String> {
if s == NONE {
Ok(None)
} else {
num(s, what).map(Some)
}
}
let mut it = args.iter();
let side = match text(it.next(), "side")? {
"compress" => Side::Compress,
"decompress" => Side::Decompress,
other => return Err(format!("bad side `{other}`")),
};
let codec = codec_id(text(it.next(), "codec")?)?.as_str();
let level = opt(text(it.next(), "level")?, "level")?;
let threads = opt(text(it.next(), "threads")?, "threads")?;
let memory_limit = num(text(it.next(), "memory limit")?, "memory limit")?;
let out = it.next().ok_or("missing output")?;
let out = (out != NONE).then(|| PathBuf::from(out));
let mut files = Vec::new();
while let Some(len) = it.next() {
let len = num(text(Some(len), "length")?, "length")?;
let path = it.next().ok_or("a length without its path")?;
files.push(Snapshot {
path: path.into(),
len,
});
}
Ok(Self {
side,
codec,
level,
threads,
memory_limit,
out,
files,
})
}
fn plan(&self) -> Plan {
Plan {
mode: Mode::OneStream,
repeat: 1,
threads: self.threads,
memory_limit: self.memory_limit,
}
}
}
fn codec_id(name: &str) -> Result<stuffr::FormatId, String> {
stuffr::registry()
.matrix()
.into_iter()
.find(|r| r.kind == FormatKind::Codec && r.id.as_str() == name)
.map(|r| r.id)
.ok_or_else(|| format!("`{name}` is not a codec in this build"))
}
pub fn parse_reply(stdout: &[u8]) -> Option<Result<u64, String>> {
let line = std::str::from_utf8(stdout).ok()?.lines().next()?;
if let Some(n) = line.strip_prefix("ok ") {
if !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
return n.parse().ok().map(Ok);
}
let text = line.strip_prefix("err ")?.trim();
(!text.is_empty()).then(|| Err(text.to_string()))
}
fn reply_line(r: &Result<u64, String>) -> String {
match r {
Ok(n) => format!("ok {n}"),
Err(e) => format!("err {}", e.replace(['\n', '\r'], " ")),
}
}
pub fn describe(e: &Error) -> String {
match e {
Error::ResourceLimit(s) => format!("refused by --memory-limit: {s}"),
other => other.to_string(),
}
}
pub fn interpret(success: bool, status: &str, stdout: &[u8], stderr: &[u8]) -> Result<u64, String> {
match (parse_reply(stdout), success) {
(Some(Ok(n)), true) => Ok(n),
(Some(Err(e)), false) => Err(e),
_ => {
let why = String::from_utf8_lossy(stderr);
match why.lines().map(str::trim).find(|l| !l.is_empty()) {
Some(line) => Err(format!("the memory run failed ({status}): {line}")),
None => Err(format!("the memory run failed ({status})")),
}
}
}
}
pub fn child_entry(args: &[OsString]) -> Option<ExitCode> {
if args.get(1).map(OsString::as_os_str) != Some(OsStr::new(CHILD_ARG)) {
return None;
}
let (result, code) = if cfg!(debug_assertions) {
(Err(DEBUG_CHILD_REFUSAL.to_string()), ExitCode::FAILURE)
} else {
match ChildJob::from_args(&args[2..]) {
Ok(job) => (child_main(job), ExitCode::FAILURE),
Err(e) => (Err(e), ExitCode::from(2)),
}
};
let mut out = io::stdout().lock();
let printed = writeln!(out, "{}", reply_line(&result)).and_then(|()| out.flush());
Some(if result.is_ok() && printed.is_ok() {
ExitCode::SUCCESS
} else if result.is_err() {
code
} else {
ExitCode::FAILURE
})
}
pub fn child_main(job: ChildJob) -> Result<u64, String> {
let reg = stuffr::registry();
let id = codec_id(job.codec)?;
match job.side {
Side::Compress => {
let codec = reg.require_encoder(id).map_err(|e| describe(&e))?;
let encode = EncodeOpts {
level: job.level,
governor: governor_for(&job.plan()),
..Default::default()
};
codec.check_encode_opts(&encode).map_err(|e| describe(&e))?;
compress_child(codec.as_ref(), &encode, &job.files, job.out.as_deref())
.map_err(|e| describe(&e))
}
Side::Decompress => {
let codec = reg.require_decoder(id).map_err(|e| describe(&e))?;
let decode = DecodeOpts {
memory_limit: Some(job.memory_limit),
..Default::default()
};
let [copy] = &job.files[..] else {
return Err(format!(
"a decompress run takes one file, not {}",
job.files.len()
));
};
decompress_child(codec.as_ref(), &decode, copy).map_err(|e| describe(&e))
}
}
}
fn compress_child(
codec: &dyn Codec,
encode: &EncodeOpts,
files: &[Snapshot],
out: Option<&Path>,
) -> stuffr::Result<u64> {
let mut feeds = files
.iter()
.map(|s| s.open().map(|f| (s, f)))
.collect::<io::Result<Vec<_>>>()?;
let mut buf = vec![0u8; CHUNK];
let dst: Box<dyn Write + Send> = match out {
Some(path) => Box::new(File::options().write(true).truncate(true).open(path)?),
None => Box::new(io::sink()),
};
let dst = BufWriter::with_capacity(CHUNK, dst);
let baseline = baseline_rss()?;
let mut sink = codec.encoder(Box::new(dst), encode)?;
for (snap, f) in &mut feeds {
feed(snap, f, &mut buf, &mut *sink)?;
}
sink.finish()?;
Ok(peak_rss()?.saturating_sub(baseline))
}
fn decompress_child(
codec: &dyn Codec,
decode: &DecodeOpts,
copy: &Snapshot,
) -> stuffr::Result<u64> {
let src: Box<dyn Source> = Box::new(temp_source(
GuardedSeek::new(File::open(©.path)?),
copy.len,
));
let mut buf = vec![0u8; CHUNK];
let baseline = baseline_rss()?;
let mut decoder = codec.decoder(src, decode)?;
while fill(&mut decoder, &mut buf).map_err(Error::from_decode_io)? == buf.len() {}
drop(decoder);
Ok(peak_rss()?.saturating_sub(baseline))
}
fn feed(
snap: &Snapshot,
f: &mut io::Take<File>,
buf: &mut [u8],
sink: &mut dyn Write,
) -> stuffr::Result<()> {
let mut read = 0u64;
loop {
let n = read_some(f, buf)?;
if n == 0 {
break;
}
read += n as u64;
sink.write_all(&buf[..n])?;
}
if read < snap.len {
return Err(shrank(snap, read));
}
Ok(())
}
pub const MEASURES: bool = cfg!(any(target_vendor = "apple", target_os = "linux"));
fn baseline_rss() -> io::Result<u64> {
#[cfg(target_os = "linux")]
{
proc_status_bytes(b"VmRSS:")
}
#[cfg(not(target_os = "linux"))]
{
peak_rss()
}
}
#[cfg(target_vendor = "apple")]
fn peak_rss() -> io::Result<u64> {
let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(u64::try_from(usage.ru_maxrss).unwrap_or(0))
}
#[cfg(target_os = "linux")]
fn peak_rss() -> io::Result<u64> {
proc_status_bytes(b"VmHWM:")
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux")))]
fn peak_rss() -> io::Result<u64> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"memory is not measured on this platform",
))
}
#[cfg(target_os = "linux")]
fn proc_status_bytes(field: &[u8]) -> io::Result<u64> {
use std::io::Read;
let mut buf = [0u8; 8192];
let mut f = File::open("/proc/self/status")?;
let mut len = 0;
while len < buf.len() {
match f.read(&mut buf[len..]) {
Ok(0) => break,
Ok(n) => len += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
status_field_kib(&buf[..len], field)
.map(|kib| kib.saturating_mul(1024))
.ok_or_else(|| io::Error::other("no memory figure in /proc/self/status"))
}
#[cfg(any(target_os = "linux", test))]
fn status_field_kib(text: &[u8], field: &[u8]) -> Option<u64> {
let line = text.split(|&b| b == b'\n').find(|l| l.starts_with(field))?;
let rest = line[field.len()..].trim_ascii_start();
let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count();
if digits == 0 {
return None;
}
rest[..digits].iter().try_fold(0u64, |n, &b| {
n.checked_mul(10)?.checked_add(u64::from(b - b'0'))
})
}
pub trait Runner {
fn run(&mut self, job: &ChildJob) -> Result<u64, String>;
}
pub struct ProcessRunner {
exe: Result<PathBuf, String>,
}
impl Default for ProcessRunner {
fn default() -> Self {
Self::new()
}
}
impl ProcessRunner {
pub fn new() -> Self {
Self {
exe: std::env::current_exe()
.map_err(|e| format!("cannot find bench's own executable: {e}")),
}
}
}
impl Runner for ProcessRunner {
fn run(&mut self, job: &ChildJob) -> Result<u64, String> {
let exe = self.exe.clone()?;
let child = crate::compat::with_run_signals_held(|| {
let child = Command::new(exe)
.arg(CHILD_ARG)
.args(job.to_args())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
RUNNING_CHILD.store(i32::try_from(child.id()).unwrap_or(0), Ordering::SeqCst);
Ok::<_, io::Error>(child)
})
.map_err(|e| format!("cannot start the memory run: {e}"))?;
let out = child.wait_with_output();
RUNNING_CHILD.store(0, Ordering::SeqCst);
let out = out.map_err(|e| format!("the memory run failed: {e}"))?;
interpret(
out.status.success(),
&out.status.to_string(),
&out.stdout,
&out.stderr,
)
}
}
pub fn measure_memory(
runner: &mut ProcessRunner,
codec: &'static str,
level: Option<i32>,
plan: &Plan,
input_files: &[Snapshot],
) -> Memory {
measure_memory_with(runner, codec, level, plan, input_files)
}
pub fn measure_memory_with(
runner: &mut dyn Runner,
codec: &'static str,
level: Option<i32>,
plan: &Plan,
input_files: &[Snapshot],
) -> Memory {
if input_files.is_empty() {
return Memory {
error: Some("no input to measure".into()),
..Memory::default()
};
}
let streams: Vec<&[Snapshot]> = match plan.mode {
Mode::OneStream => vec![input_files],
Mode::PerFile => input_files.chunks(1).collect(),
};
let job = |side, out, files| ChildJob {
side,
codec,
level: if side == Side::Compress { level } else { None },
threads: plan.threads,
memory_limit: plan.memory_limit,
out,
files,
};
let mut compress = Some(0u64);
let mut decompress = Some(0u64);
let mut error = None;
let mut fail = |side: Side, e: String, cell: &mut Option<u64>| {
*cell = None;
error.get_or_insert_with(|| format!("{}: {e}", side.word()));
};
for files in streams {
let Some(peak) = compress else { break };
let copy = match TempOut::create() {
Ok((copy, _file)) => copy,
Err(e) => {
fail(
Side::Compress,
format!("cannot make the temp file: {e}"),
&mut compress,
);
decompress = None;
break;
}
};
let out = Some(copy.path().to_path_buf());
match runner.run(&job(Side::Compress, out, files.to_vec())) {
Ok(n) => compress = Some(peak.max(n)),
Err(e) => {
fail(
Side::Compress,
format!("{e} (so no decompress run)"),
&mut compress,
);
decompress = None;
break;
}
}
if let Some(peak) = decompress {
let r = std::fs::metadata(copy.path())
.map_err(|e| format!("cannot read the compressed copy: {e}"))
.and_then(|m| {
let snap = Snapshot {
path: copy.path().to_path_buf(),
len: m.len(),
};
runner.run(&job(Side::Decompress, None, vec![snap]))
});
match r {
Ok(n) => decompress = Some(peak.max(n)),
Err(e) => fail(Side::Decompress, e, &mut decompress),
}
}
}
Memory {
compress,
decompress,
error,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("stuffr-benchmem-{}-{n}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn file(&self, name: &str, bytes: &[u8]) -> Snapshot {
let path = self.0.join(name);
fs::write(&path, bytes).unwrap();
Snapshot {
path,
len: bytes.len() as u64,
}
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn armed_lock() -> std::sync::MutexGuard<'static, ()> {
crate::compat::ARM_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn text(len: usize, seed: u8) -> Vec<u8> {
b"the quick brown fox jumps over the lazy dog, "
.iter()
.cycle()
.enumerate()
.map(|(i, &b)| b.wrapping_add(seed).wrapping_add((i / 997) as u8))
.take(len)
.collect()
}
fn plan(mode: Mode) -> Plan {
Plan {
mode,
repeat: 1,
threads: None,
memory_limit: 1 << 30,
}
}
fn bench_temps() -> Vec<String> {
let prefix = format!("stuffr-bench-{}-", std::process::id());
fs::read_dir(std::env::temp_dir())
.unwrap()
.filter_map(|e| e.ok()?.file_name().into_string().ok())
.filter(|n| n.starts_with(&prefix))
.collect()
}
struct Fake<F: FnMut(&ChildJob) -> Result<u64, String>> {
answer: F,
seen: Vec<(ChildJob, bool)>,
}
impl<F: FnMut(&ChildJob) -> Result<u64, String>> Runner for Fake<F> {
fn run(&mut self, job: &ChildJob) -> Result<u64, String> {
let present = job.files.iter().all(|s| s.path.exists())
&& job.out.as_ref().is_none_or(|o| o.exists());
self.seen.push((job.clone(), present));
let r = (self.answer)(job);
if let (Ok(_), Some(out)) = (&r, &job.out) {
fs::write(out, b"copy").unwrap();
}
r
}
}
fn fake<F: FnMut(&ChildJob) -> Result<u64, String>>(answer: F) -> Fake<F> {
Fake {
answer,
seen: Vec::new(),
}
}
#[test]
fn child_args_round_trip() {
#[cfg(unix)]
let odd: PathBuf = {
use std::os::unix::ffi::OsStringExt;
OsString::from_vec(b"/tmp/a b\xff/-c".to_vec()).into()
};
#[cfg(not(unix))]
let odd = PathBuf::from("C:\\a b\\-c");
let jobs = [
ChildJob {
side: Side::Compress,
codec: "xz",
level: Some(9),
threads: Some(4),
memory_limit: 123_456_789,
out: Some(odd.join("out")),
files: vec![
Snapshot {
path: odd.clone(),
len: 7,
},
Snapshot {
path: PathBuf::from("-"),
len: 0,
},
],
},
ChildJob {
side: Side::Decompress,
codec: "zstd",
level: Some(-5),
threads: None,
memory_limit: u64::MAX,
out: None,
files: vec![Snapshot {
path: odd,
len: u64::MAX,
}],
},
ChildJob {
side: Side::Compress,
codec: "lz4",
level: None,
threads: None,
memory_limit: 0,
out: None,
files: Vec::new(),
},
];
for job in jobs {
let args = job.to_args();
assert_eq!(ChildJob::from_args(&args), Ok(job.clone()), "{args:?}");
}
for bad in [
&["compress", "nope", "-", "-", "1", "-"][..],
&["sideways", "lz4", "-", "-", "1", "-"],
&["compress", "lz4", "-", "-", "1"],
&["compress", "lz4", "-", "-", "1", "/out", "/x"],
&["compress", "lz4", "x", "-", "1", "-"],
] {
let args: Vec<OsString> = bad.iter().map(OsString::from).collect();
assert!(ChildJob::from_args(&args).is_err(), "{bad:?}");
}
}
#[test]
fn proc_status_fields_parse_in_kib() {
let pad = " ".repeat(4);
let status = format!(
"Name:\tstuffr\nVmPeak:\t{pad}123456 kB\nVmHWM:\t{pad}6372 kB\nVmRSS:\t{pad}6100 kB\n"
);
let status = status.as_bytes();
assert_eq!(status_field_kib(status, b"VmHWM:"), Some(6372));
assert_eq!(status_field_kib(status, b"VmRSS:"), Some(6100));
assert_eq!(status_field_kib(status, b"VmSwap:"), None);
assert_eq!(status_field_kib(b"VmHWM:\t kB\n", b"VmHWM:"), None);
}
#[test]
fn child_reply_parses_ok_and_err() {
assert_eq!(parse_reply(b"ok 12345\n"), Some(Ok(12345)));
assert_eq!(parse_reply(b"ok 0\n"), Some(Ok(0)));
assert_eq!(
parse_reply(b"err refused by --memory-limit: too big\n"),
Some(Err("refused by --memory-limit: too big".into()))
);
for junk in [
&b""[..],
b"ok\n",
b"ok -3\n",
b"ok 12x\n",
b"hello\n",
b"err \n",
] {
assert_eq!(
parse_reply(junk),
None,
"{:?}",
String::from_utf8_lossy(junk)
);
}
}
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
const PROBE_CODEC: &str = "STUFFR_BENCH_MEMORY_PROBE_CODEC";
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
const PROBE_LEVEL: &str = "STUFFR_BENCH_MEMORY_PROBE_LEVEL";
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
const PROBE_FILE: &str = "STUFFR_BENCH_MEMORY_PROBE_FILE";
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
const PROBE_MARK: &str = "memory-probe-reply: ";
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
#[test]
#[ignore = "a probe that fresh_processes_measure_xz_9_above_lz4 runs in its own process"]
fn memory_probe() {
let (Ok(codec), Ok(level), Some(file)) = (
std::env::var(PROBE_CODEC),
std::env::var(PROBE_LEVEL),
std::env::var_os(PROBE_FILE),
) else {
return;
};
let path = PathBuf::from(file);
let len = fs::metadata(&path).unwrap().len();
let args: Vec<OsString> = ["compress", &codec, &level, "-", "1073741824", "-"]
.iter()
.map(OsString::from)
.chain([OsString::from(len.to_string()), path.into_os_string()])
.collect();
let job = ChildJob::from_args(&args).unwrap();
println!("{PROBE_MARK}{}", reply_line(&child_main(job)));
}
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
#[test]
fn fresh_processes_measure_xz_9_above_lz4() {
let dir = Scratch::new();
let input = dir.file("in", &text(1 << 20, 3));
let probe = |codec: &str, level: &str| -> u64 {
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"bench::memory::tests::memory_probe",
"--ignored",
"--nocapture",
"--test-threads=1",
])
.env(PROBE_CODEC, codec)
.env(PROBE_LEVEL, level)
.env(PROBE_FILE, &input.path)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let reply = stdout
.lines()
.find_map(|l| l.split_once(PROBE_MARK).map(|(_, r)| r));
match reply.and_then(|r| parse_reply(r.as_bytes())) {
Some(Ok(n)) if out.status.success() => n,
_ => panic!(
"{codec} probe gave no `ok` reply ({}):\n--- stdout\n{stdout}\n--- stderr\n{}",
out.status,
String::from_utf8_lossy(&out.stderr)
),
}
};
let lz4 = probe("lz4", "-");
let xz = probe("xz", "9");
assert!(xz > lz4, "xz -9 {xz} vs lz4 {lz4}");
}
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
fn compressed_by_the_child(
dir: &Scratch,
name: &str,
codec: &'static str,
level: Option<i32>,
input: &Snapshot,
) -> Snapshot {
let out = dir.file(name, b"stale bytes the child must truncate");
let c = child_main(ChildJob {
side: Side::Compress,
codec,
level,
threads: None,
memory_limit: 1 << 30,
out: Some(out.path.clone()),
files: vec![input.clone()],
});
assert!(c.is_ok(), "{codec}: {c:?}");
let len = fs::metadata(&out.path).unwrap().len();
Snapshot {
path: out.path,
len,
}
}
#[cfg(any(target_vendor = "apple", target_os = "linux"))]
#[test]
fn the_compress_child_writes_the_copy_the_decompress_child_reads() {
let dir = Scratch::new();
let original = text(1 << 20, 3);
let input = dir.file("in", &original);
for (codec, level) in [("lz4", None), ("xz", Some(9))] {
let copy = compressed_by_the_child(&dir, codec, codec, level, &input);
let id = codec_id(codec).unwrap();
let decoder = stuffr::registry().require_decoder(id).unwrap();
let mut back = Vec::new();
let src: Box<dyn Source> = Box::new(temp_source(
GuardedSeek::new(File::open(©.path).unwrap()),
copy.len,
));
io::Read::read_to_end(
&mut decoder.decoder(src, &DecodeOpts::default()).unwrap(),
&mut back,
)
.unwrap();
assert!(
back == original,
"{codec}: the copy does not decode to the input"
);
let d = child_main(ChildJob {
side: Side::Decompress,
codec,
level: None,
threads: None,
memory_limit: 1 << 30,
out: None,
files: vec![copy],
});
assert!(d.is_ok(), "{codec}: {d:?}");
}
}
#[test]
fn per_file_reports_the_maximum() {
let _g = armed_lock();
let dir = Scratch::new();
let files = vec![
dir.file("a", &text(10_000, 0)),
dir.file("b", &text(30_000, 1)),
dir.file("c", &text(20_000, 2)),
];
let mut d = [5u64, 9, 2].into_iter();
let mut r = fake(|job: &ChildJob| match job.side {
Side::Compress => Ok(job.files[0].len * 100),
Side::Decompress => Ok(d.next().unwrap()),
});
let m = measure_memory_with(&mut r, "gzip", Some(6), &plan(Mode::PerFile), &files);
assert_eq!(
m,
Memory {
compress: Some(3_000_000),
decompress: Some(9),
error: None
}
);
let sides: Vec<Side> = r.seen.iter().map(|(j, _)| j.side).collect();
use Side::{Compress as C, Decompress as D};
assert_eq!(sides, [C, D, C, D, C, D]);
for (i, (job, present)) in r.seen.iter().enumerate() {
assert_eq!(job.files.len(), 1);
assert!(present, "job {i}'s file and output should exist");
let level = (job.side == Side::Compress).then_some(6);
assert_eq!((job.codec, job.level), ("gzip", level));
if job.side == Side::Compress {
assert_eq!(job.files[0], files[i / 2]);
assert!(job.out.is_some(), "a compress child writes the copy");
} else {
assert_eq!(job.out, None);
assert_eq!(job.files[0].len, 4, "the length the compress child wrote");
assert_eq!(Some(&job.files[0].path), r.seen[i - 1].0.out.as_ref());
}
}
let mut r = fake(|job: &ChildJob| Ok(job.files.iter().map(|s| s.len).sum()));
let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::OneStream), &files);
assert_eq!(m.compress, Some(60_000));
assert!(m.decompress.is_some_and(|n| n > 0));
assert_eq!(r.seen.len(), 2);
assert_eq!(r.seen[0].0.files, files);
assert_eq!(r.seen[1].0.files.len(), 1);
}
#[test]
fn a_failed_child_gives_dashes_and_an_error() {
let _g = armed_lock();
let dir = Scratch::new();
let files = vec![
dir.file("a", &text(10_000, 0)),
dir.file("b", &text(10_000, 1)),
];
let mut n = 0;
let mut r = fake(|job: &ChildJob| match job.side {
Side::Compress => {
n += 1;
if n == 2 {
Err("the memory run failed (signal: 9 (SIGKILL))".into())
} else {
Ok(10)
}
}
Side::Decompress => Ok(20),
});
let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::PerFile), &files);
assert_eq!(m.compress, None);
assert_eq!(m.decompress, None);
let e = m.error.unwrap();
assert!(e.starts_with("compress: ") && e.contains("SIGKILL"), "{e}");
assert!(e.contains("no decompress run"), "{e}");
assert_eq!(r.seen.len(), 3);
let mut r = fake(|job: &ChildJob| match job.side {
Side::Compress => Ok(10),
Side::Decompress => Err("the memory run failed (exit status: 1)".into()),
});
let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::PerFile), &files);
assert_eq!((m.compress, m.decompress), (Some(10), None));
assert!(m.error.unwrap().starts_with("decompress: "));
let mut r = fake(|_: &ChildJob| Err("boom".to_string()));
let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::OneStream), &files);
assert_eq!((m.compress, m.decompress), (None, None));
assert!(m.error.unwrap().contains("boom"));
assert!(interpret(false, "exit status: 101", b"", b"thread 'main' panicked\n").is_err());
assert!(interpret(true, "exit status: 0", b"garbage\n", b"").is_err());
assert!(interpret(false, "signal: 9 (SIGKILL)", b"ok 5\n", b"").is_err());
assert_eq!(interpret(true, "exit status: 0", b"ok 5\n", b""), Ok(5));
assert_eq!(
interpret(false, "exit status: 1", b"err nope\n", b""),
Err("nope".into())
);
}
#[test]
fn a_refusal_names_the_memory_limit() {
let refusal = describe(&stuffr::Error::ResourceLimit(
"xz: header declares a dictionary needing 64 MiB".into(),
));
assert!(
refusal.starts_with("refused by --memory-limit"),
"{refusal}"
);
assert!(refusal.contains("64 MiB"), "{refusal}");
let corrupt = describe(&stuffr::Error::Corrupt("bad".into()));
assert!(!corrupt.contains("--memory-limit"), "{corrupt}");
let _g = armed_lock();
let dir = Scratch::new();
let files = vec![dir.file("a", &text(10_000, 0))];
let mut r = fake(|job: &ChildJob| match job.side {
Side::Compress => Err(refusal.clone()),
Side::Decompress => Ok(1),
});
let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::OneStream), &files);
assert_eq!(m.compress, None);
assert!(
m.error.unwrap().contains("refused by --memory-limit"),
"the refusal is distinguishable from a crash"
);
}
#[cfg(all(
any(target_vendor = "apple", target_os = "linux"),
not(feature = "xz-c")
))]
#[test]
fn a_child_refusal_names_the_memory_limit() {
let dir = Scratch::new();
let input = dir.file("in", &text(100_000, 0));
let copy = compressed_by_the_child(&dir, "in.xz", "xz", Some(9), &input);
let r = child_main(ChildJob {
side: Side::Decompress,
codec: "xz",
level: None,
threads: None,
memory_limit: 1 << 20,
out: None,
files: vec![copy],
});
let e = r.unwrap_err();
assert!(e.starts_with("refused by --memory-limit"), "{e}");
}
#[test]
fn temp_copies_are_removed() {
let _g = armed_lock();
let dir = Scratch::new();
let files = vec![
dir.file("a", &text(10_000, 0)),
dir.file("b", &text(10_000, 1)),
];
assert_eq!(bench_temps(), Vec::<String>::new());
for mode in [Mode::OneStream, Mode::PerFile] {
let mut r = fake(|job: &ChildJob| {
if job.side == Side::Decompress {
assert_eq!(bench_temps().len(), 1);
}
Ok(1)
});
let m = measure_memory_with(&mut r, "gzip", None, &plan(mode), &files);
assert_eq!(m.error, None);
assert!(r.seen.iter().all(|(_, present)| *present));
assert_eq!(bench_temps(), Vec::<String>::new(), "{mode:?}");
}
let mut r = fake(|_: &ChildJob| Err("boom".to_string()));
let _ = measure_memory_with(&mut r, "gzip", None, &plan(Mode::PerFile), &files);
assert_eq!(bench_temps(), Vec::<String>::new());
}
}