1use std::ffi::{OsStr, OsString};
44use std::fs::File;
45use std::io::{self, BufWriter, Write};
46use std::path::{Path, PathBuf};
47use std::process::{Command, ExitCode, Stdio};
48use std::sync::atomic::{AtomicI32, Ordering};
49
50use stuffr::source::{GuardedSeek, Source};
51use stuffr::{Codec, DecodeOpts, EncodeOpts, Error, FormatKind};
52
53use super::measure::{
54 CHUNK, Mode, Plan, Snapshot, TempOut, fill, governor_for, read_some, shrank, temp_source,
55};
56use super::score::Memory;
57
58pub const CHILD_ARG: &str = "__bench-memory-child";
62
63const DEBUG_CHILD_REFUSAL: &str = "memory runs from a debug build are meaningless";
65
66pub static RUNNING_CHILD: AtomicI32 = AtomicI32::new(0);
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum Side {
74 Compress,
75 Decompress,
76}
77
78impl Side {
79 fn word(self) -> &'static str {
80 match self {
81 Side::Compress => "compress",
82 Side::Decompress => "decompress",
83 }
84 }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct ChildJob {
92 pub side: Side,
93 pub codec: &'static str,
94 pub level: Option<i32>,
95 pub threads: Option<usize>,
96 pub memory_limit: u64,
97 pub out: Option<PathBuf>,
98 pub files: Vec<Snapshot>,
99}
100
101const NONE: &str = "-";
103
104impl ChildJob {
105 pub fn to_args(&self) -> Vec<OsString> {
112 let opt = |v: Option<String>| OsString::from(v.unwrap_or_else(|| NONE.to_string()));
113 let mut args = vec![
114 OsString::from(self.side.word()),
115 OsString::from(self.codec),
116 opt(self.level.map(|l| l.to_string())),
117 opt(self.threads.map(|t| t.to_string())),
118 OsString::from(self.memory_limit.to_string()),
119 self.out
120 .clone()
121 .map_or_else(|| OsString::from(NONE), PathBuf::into_os_string),
122 ];
123 for s in &self.files {
124 args.push(OsString::from(s.len.to_string()));
125 args.push(s.path.clone().into_os_string());
126 }
127 args
128 }
129
130 pub fn from_args(args: &[OsString]) -> Result<Self, String> {
133 fn text<'a>(a: Option<&'a OsString>, what: &str) -> Result<&'a str, String> {
134 a.ok_or_else(|| format!("missing {what}"))?
135 .to_str()
136 .ok_or_else(|| format!("{what} is not UTF-8"))
137 }
138 fn num<T: std::str::FromStr>(s: &str, what: &str) -> Result<T, String> {
139 s.parse().map_err(|_| format!("bad {what} `{s}`"))
140 }
141 fn opt<T: std::str::FromStr>(s: &str, what: &str) -> Result<Option<T>, String> {
142 if s == NONE {
143 Ok(None)
144 } else {
145 num(s, what).map(Some)
146 }
147 }
148 let mut it = args.iter();
149 let side = match text(it.next(), "side")? {
150 "compress" => Side::Compress,
151 "decompress" => Side::Decompress,
152 other => return Err(format!("bad side `{other}`")),
153 };
154 let codec = codec_id(text(it.next(), "codec")?)?.as_str();
155 let level = opt(text(it.next(), "level")?, "level")?;
156 let threads = opt(text(it.next(), "threads")?, "threads")?;
157 let memory_limit = num(text(it.next(), "memory limit")?, "memory limit")?;
158 let out = it.next().ok_or("missing output")?;
159 let out = (out != NONE).then(|| PathBuf::from(out));
160 let mut files = Vec::new();
161 while let Some(len) = it.next() {
162 let len = num(text(Some(len), "length")?, "length")?;
163 let path = it.next().ok_or("a length without its path")?;
164 files.push(Snapshot {
165 path: path.into(),
166 len,
167 });
168 }
169 Ok(Self {
170 side,
171 codec,
172 level,
173 threads,
174 memory_limit,
175 out,
176 files,
177 })
178 }
179
180 fn plan(&self) -> Plan {
182 Plan {
183 mode: Mode::OneStream,
184 repeat: 1,
185 threads: self.threads,
186 memory_limit: self.memory_limit,
187 }
188 }
189}
190
191fn codec_id(name: &str) -> Result<stuffr::FormatId, String> {
193 stuffr::registry()
194 .matrix()
195 .into_iter()
196 .find(|r| r.kind == FormatKind::Codec && r.id.as_str() == name)
197 .map(|r| r.id)
198 .ok_or_else(|| format!("`{name}` is not a codec in this build"))
199}
200
201pub fn parse_reply(stdout: &[u8]) -> Option<Result<u64, String>> {
204 let line = std::str::from_utf8(stdout).ok()?.lines().next()?;
205 if let Some(n) = line.strip_prefix("ok ") {
206 if !n.bytes().all(|b| b.is_ascii_digit()) {
207 return None;
208 }
209 return n.parse().ok().map(Ok);
210 }
211 let text = line.strip_prefix("err ")?.trim();
212 (!text.is_empty()).then(|| Err(text.to_string()))
213}
214
215fn reply_line(r: &Result<u64, String>) -> String {
217 match r {
218 Ok(n) => format!("ok {n}"),
219 Err(e) => format!("err {}", e.replace(['\n', '\r'], " ")),
220 }
221}
222
223pub fn describe(e: &Error) -> String {
227 match e {
228 Error::ResourceLimit(s) => format!("refused by --memory-limit: {s}"),
229 other => other.to_string(),
230 }
231}
232
233pub fn interpret(success: bool, status: &str, stdout: &[u8], stderr: &[u8]) -> Result<u64, String> {
237 match (parse_reply(stdout), success) {
238 (Some(Ok(n)), true) => Ok(n),
239 (Some(Err(e)), false) => Err(e),
240 _ => {
241 let why = String::from_utf8_lossy(stderr);
242 match why.lines().map(str::trim).find(|l| !l.is_empty()) {
243 Some(line) => Err(format!("the memory run failed ({status}): {line}")),
244 None => Err(format!("the memory run failed ({status})")),
245 }
246 }
247 }
248}
249
250pub fn child_entry(args: &[OsString]) -> Option<ExitCode> {
254 if args.get(1).map(OsString::as_os_str) != Some(OsStr::new(CHILD_ARG)) {
255 return None;
256 }
257 let (result, usage) = if cfg!(debug_assertions) {
260 (Err(DEBUG_CHILD_REFUSAL.to_string()), None)
261 } else {
262 match ChildJob::from_args(&args[2..]) {
263 Ok(job) => (child_main(job), None),
264 Err(e) => (Err(e), Some(ExitCode::from(2))),
265 }
266 };
267 let mut out = io::stdout().lock();
268 let printed = writeln!(out, "{}", reply_line(&result)).and_then(|()| out.flush());
269 Some(match usage {
270 Some(code) => code,
271 None if result.is_ok() && printed.is_ok() => ExitCode::SUCCESS,
272 None => ExitCode::FAILURE,
273 })
274}
275
276pub fn child_main(job: ChildJob) -> Result<u64, String> {
279 let reg = stuffr::registry();
280 let id = codec_id(job.codec)?;
281 match job.side {
282 Side::Compress => {
283 let codec = reg.require_encoder(id).map_err(|e| describe(&e))?;
284 let encode = EncodeOpts {
285 level: job.level,
286 governor: governor_for(&job.plan()),
287 ..Default::default()
288 };
289 codec.check_encode_opts(&encode).map_err(|e| describe(&e))?;
290 compress_child(codec.as_ref(), &encode, &job.files, job.out.as_deref())
291 .map_err(|e| describe(&e))
292 }
293 Side::Decompress => {
294 let codec = reg.require_decoder(id).map_err(|e| describe(&e))?;
295 let decode = DecodeOpts {
296 memory_limit: Some(job.memory_limit),
297 ..Default::default()
298 };
299 let [copy] = &job.files[..] else {
300 return Err(format!(
301 "a decompress run takes one file, not {}",
302 job.files.len()
303 ));
304 };
305 decompress_child(codec.as_ref(), &decode, copy).map_err(|e| describe(&e))
306 }
307 }
308}
309
310fn compress_child(
314 codec: &dyn Codec,
315 encode: &EncodeOpts,
316 files: &[Snapshot],
317 out: Option<&Path>,
318) -> stuffr::Result<u64> {
319 let mut feeds = files
320 .iter()
321 .map(|s| s.open().map(|f| (s, f)))
322 .collect::<io::Result<Vec<_>>>()?;
323 let mut buf = vec![0u8; CHUNK];
324 let dst: Box<dyn Write + Send> = match out {
325 Some(path) => Box::new(open_copy(path, true)?),
326 None => Box::new(io::sink()),
327 };
328 let dst = BufWriter::with_capacity(CHUNK, dst);
329 let baseline = baseline_rss()?;
330 let mut sink = codec.encoder(Box::new(dst), encode)?;
331 for (snap, f) in &mut feeds {
332 feed(snap, f, &mut buf, &mut *sink)?;
333 }
334 sink.finish()?;
335 Ok(peak_rss()?.saturating_sub(baseline))
336}
337
338fn open_copy(path: &Path, write: bool) -> io::Result<File> {
354 let mut opts = File::options();
355 if write {
356 opts.write(true);
357 } else {
358 opts.read(true);
359 }
360 #[cfg(unix)]
361 {
362 use std::os::unix::fs::OpenOptionsExt;
363 opts.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
364 }
365 let file = opts.open(path)?;
366 #[cfg(unix)]
367 {
368 use std::os::unix::fs::MetadataExt;
369 let m = file.metadata()?;
370 let me = unsafe { libc::geteuid() };
372 if !m.file_type().is_file() || m.nlink() != 1 || m.uid() != me {
373 return Err(io::Error::new(
374 io::ErrorKind::PermissionDenied,
375 format!(
376 "{} is not bench's own temp file (a regular file with one link, \
377 owned by this user)",
378 path.display()
379 ),
380 ));
381 }
382 }
383 if write {
384 file.set_len(0)?;
385 }
386 Ok(file)
387}
388
389fn decompress_child(
391 codec: &dyn Codec,
392 decode: &DecodeOpts,
393 copy: &Snapshot,
394) -> stuffr::Result<u64> {
395 let src: Box<dyn Source> = Box::new(temp_source(
396 GuardedSeek::new(open_copy(©.path, false)?),
397 copy.len,
398 ));
399 let mut buf = vec![0u8; CHUNK];
400 let baseline = baseline_rss()?;
401 let mut decoder = codec.decoder(src, decode)?;
402 while fill(&mut decoder, &mut buf).map_err(Error::from_decode_io)? == buf.len() {}
403 drop(decoder);
404 Ok(peak_rss()?.saturating_sub(baseline))
405}
406
407fn feed(
410 snap: &Snapshot,
411 f: &mut io::Take<File>,
412 buf: &mut [u8],
413 sink: &mut dyn Write,
414) -> stuffr::Result<()> {
415 let mut read = 0u64;
416 loop {
417 let n = read_some(f, buf)?;
418 if n == 0 {
419 break;
420 }
421 read += n as u64;
422 sink.write_all(&buf[..n])?;
423 }
424 if read < snap.len {
425 return Err(shrank(snap, read));
426 }
427 Ok(())
428}
429
430pub const MEASURES: bool = cfg!(any(target_vendor = "apple", target_os = "linux"));
434
435fn baseline_rss() -> io::Result<u64> {
443 #[cfg(target_os = "linux")]
444 {
445 proc_status_bytes(b"VmRSS:")
446 }
447 #[cfg(not(target_os = "linux"))]
448 {
449 peak_rss()
450 }
451}
452
453#[cfg(target_vendor = "apple")]
457fn peak_rss() -> io::Result<u64> {
458 let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
461 if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 {
463 return Err(io::Error::last_os_error());
464 }
465 Ok(u64::try_from(usage.ru_maxrss).unwrap_or(0))
466}
467
468#[cfg(target_os = "linux")]
469fn peak_rss() -> io::Result<u64> {
470 proc_status_bytes(b"VmHWM:")
471}
472
473#[cfg(not(any(target_vendor = "apple", target_os = "linux")))]
475fn peak_rss() -> io::Result<u64> {
476 Err(io::Error::new(
477 io::ErrorKind::Unsupported,
478 "memory is not measured on this platform",
479 ))
480}
481
482#[cfg(target_os = "linux")]
486fn proc_status_bytes(field: &[u8]) -> io::Result<u64> {
487 use std::io::Read;
488 let mut buf = [0u8; 8192];
489 let mut f = File::open("/proc/self/status")?;
490 let mut len = 0;
491 while len < buf.len() {
492 match f.read(&mut buf[len..]) {
493 Ok(0) => break,
494 Ok(n) => len += n,
495 Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
496 Err(e) => return Err(e),
497 }
498 }
499 status_field_kib(&buf[..len], field)
500 .map(|kib| kib.saturating_mul(1024))
501 .ok_or_else(|| io::Error::other("no memory figure in /proc/self/status"))
502}
503
504#[cfg(any(target_os = "linux", test))]
507fn status_field_kib(text: &[u8], field: &[u8]) -> Option<u64> {
508 let line = text.split(|&b| b == b'\n').find(|l| l.starts_with(field))?;
509 let rest = line[field.len()..].trim_ascii_start();
510 let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count();
511 if digits == 0 {
512 return None;
513 }
514 rest[..digits].iter().try_fold(0u64, |n, &b| {
515 n.checked_mul(10)?.checked_add(u64::from(b - b'0'))
516 })
517}
518
519pub trait Runner {
522 fn run(&mut self, job: &ChildJob) -> Result<u64, String>;
523}
524
525pub struct ProcessRunner {
530 exe: Result<PathBuf, String>,
531}
532
533impl Default for ProcessRunner {
534 fn default() -> Self {
535 Self::new()
536 }
537}
538
539impl ProcessRunner {
540 pub fn new() -> Self {
541 Self {
542 exe: std::env::current_exe()
543 .map_err(|e| format!("cannot find bench's own executable: {e}")),
544 }
545 }
546}
547
548impl Runner for ProcessRunner {
549 fn run(&mut self, job: &ChildJob) -> Result<u64, String> {
550 let exe = self.exe.clone()?;
551 let child = crate::compat::with_run_signals_held(|| {
556 let child = Command::new(exe)
557 .arg(CHILD_ARG)
558 .args(job.to_args())
559 .stdin(Stdio::null())
560 .stdout(Stdio::piped())
561 .stderr(Stdio::piped())
562 .spawn()?;
563 RUNNING_CHILD.store(i32::try_from(child.id()).unwrap_or(0), Ordering::SeqCst);
564 Ok::<_, io::Error>(child)
565 })
566 .map_err(|e| format!("cannot start the memory run: {e}"))?;
567 let out = child.wait_with_output();
574 RUNNING_CHILD.store(0, Ordering::SeqCst);
575 let out = out.map_err(|e| format!("the memory run failed: {e}"))?;
576 interpret(
577 out.status.success(),
578 &out.status.to_string(),
579 &out.stdout,
580 &out.stderr,
581 )
582 }
583}
584
585pub fn measure_memory(
588 runner: &mut ProcessRunner,
589 codec: &'static str,
590 level: Option<i32>,
591 plan: &Plan,
592 input_files: &[Snapshot],
593) -> Memory {
594 measure_memory_with(runner, codec, level, plan, input_files)
595}
596
597pub fn measure_memory_with(
610 runner: &mut dyn Runner,
611 codec: &'static str,
612 level: Option<i32>,
613 plan: &Plan,
614 input_files: &[Snapshot],
615) -> Memory {
616 if input_files.is_empty() {
619 return Memory {
620 error: Some("no input to measure".into()),
621 ..Memory::default()
622 };
623 }
624 let streams: Vec<&[Snapshot]> = match plan.mode {
625 Mode::OneStream => vec![input_files],
626 Mode::PerFile => input_files.chunks(1).collect(),
627 };
628 let job = |side, out, files| ChildJob {
630 side,
631 codec,
632 level: if side == Side::Compress { level } else { None },
633 threads: plan.threads,
634 memory_limit: plan.memory_limit,
635 out,
636 files,
637 };
638 let mut compress = Some(0u64);
639 let mut decompress = Some(0u64);
640 let mut error = None;
641 let mut fail = |side: Side, e: String, cell: &mut Option<u64>| {
642 *cell = None;
643 error.get_or_insert_with(|| format!("{}: {e}", side.word()));
644 };
645 for files in streams {
646 let Some(peak) = compress else { break };
647 let copy = match TempOut::create() {
650 Ok(copy) => copy,
651 Err(e) => {
652 fail(
653 Side::Compress,
654 format!("cannot make the temp file: {e}"),
655 &mut compress,
656 );
657 decompress = None;
658 break;
659 }
660 };
661 let out = Some(copy.path().to_path_buf());
662 match runner.run(&job(Side::Compress, out, files.to_vec())) {
663 Ok(n) => compress = Some(peak.max(n)),
664 Err(e) => {
665 fail(
666 Side::Compress,
667 format!("{e} (so no decompress run)"),
668 &mut compress,
669 );
670 decompress = None;
671 break;
672 }
673 }
674 if let Some(peak) = decompress {
675 let r = std::fs::metadata(copy.path())
676 .map_err(|e| format!("cannot read the compressed copy: {e}"))
677 .and_then(|m| {
678 let snap = Snapshot {
679 path: copy.path().to_path_buf(),
680 len: m.len(),
681 };
682 runner.run(&job(Side::Decompress, None, vec![snap]))
683 });
684 match r {
685 Ok(n) => decompress = Some(peak.max(n)),
686 Err(e) => fail(Side::Decompress, e, &mut decompress),
687 }
688 }
689 }
690 Memory {
691 compress,
692 decompress,
693 error,
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use std::fs;
701 use std::path::PathBuf;
702 use std::sync::atomic::{AtomicU32, Ordering};
703
704 struct Scratch(PathBuf);
706
707 impl Scratch {
708 fn new() -> Self {
709 static N: AtomicU32 = AtomicU32::new(0);
710 let n = N.fetch_add(1, Ordering::Relaxed);
711 let dir =
712 std::env::temp_dir().join(format!("stuffr-benchmem-{}-{n}", std::process::id()));
713 fs::create_dir_all(&dir).unwrap();
714 Scratch(dir)
715 }
716
717 fn file(&self, name: &str, bytes: &[u8]) -> Snapshot {
718 let path = self.0.join(name);
719 fs::write(&path, bytes).unwrap();
720 Snapshot {
721 path,
722 len: bytes.len() as u64,
723 }
724 }
725 }
726
727 impl Drop for Scratch {
728 fn drop(&mut self) {
729 let _ = fs::remove_dir_all(&self.0);
730 }
731 }
732
733 fn armed_lock() -> std::sync::MutexGuard<'static, ()> {
736 crate::compat::ARM_TEST_LOCK
737 .lock()
738 .unwrap_or_else(|e| e.into_inner())
739 }
740
741 fn text(len: usize, seed: u8) -> Vec<u8> {
742 b"the quick brown fox jumps over the lazy dog, "
743 .iter()
744 .cycle()
745 .enumerate()
746 .map(|(i, &b)| b.wrapping_add(seed).wrapping_add((i / 997) as u8))
747 .take(len)
748 .collect()
749 }
750
751 fn plan(mode: Mode) -> Plan {
752 Plan {
753 mode,
754 repeat: 1,
755 threads: None,
756 memory_limit: 1 << 30,
757 }
758 }
759
760 #[test]
761 fn measure_memory_with_no_input_files_measures_nothing() {
762 struct Counting(u32);
763 impl Runner for Counting {
764 fn run(&mut self, _job: &ChildJob) -> Result<u64, String> {
765 self.0 += 1;
766 Ok(1)
767 }
768 }
769 let mut stub = Counting(0);
770 let got = measure_memory_with(&mut stub, "lz4", None, &plan(Mode::OneStream), &[]);
771 assert_eq!(
772 got,
773 Memory {
774 compress: None,
775 decompress: None,
776 error: Some("no input to measure".to_string()),
777 }
778 );
779 assert_eq!(stub.0, 0);
780 }
781
782 fn bench_temps() -> Vec<String> {
783 let prefix = format!("stuffr-bench-{}-", std::process::id());
784 fs::read_dir(std::env::temp_dir())
785 .unwrap()
786 .filter_map(|e| e.ok()?.file_name().into_string().ok())
787 .filter(|n| n.starts_with(&prefix))
788 .collect()
789 }
790
791 struct Fake<F: FnMut(&ChildJob) -> Result<u64, String>> {
796 answer: F,
797 seen: Vec<(ChildJob, bool)>,
798 }
799
800 impl<F: FnMut(&ChildJob) -> Result<u64, String>> Runner for Fake<F> {
801 fn run(&mut self, job: &ChildJob) -> Result<u64, String> {
802 let present = job.files.iter().all(|s| s.path.exists())
803 && job.out.as_ref().is_none_or(|o| o.exists());
804 self.seen.push((job.clone(), present));
805 let r = (self.answer)(job);
806 if let (Ok(_), Some(out)) = (&r, &job.out) {
807 fs::write(out, b"copy").unwrap();
808 }
809 r
810 }
811 }
812
813 fn fake<F: FnMut(&ChildJob) -> Result<u64, String>>(answer: F) -> Fake<F> {
814 Fake {
815 answer,
816 seen: Vec::new(),
817 }
818 }
819
820 #[test]
821 fn child_args_round_trip() {
822 #[cfg(unix)]
823 let odd: PathBuf = {
824 use std::os::unix::ffi::OsStringExt;
825 OsString::from_vec(b"/tmp/a b\xff/-c".to_vec()).into()
826 };
827 #[cfg(not(unix))]
828 let odd = PathBuf::from("C:\\a b\\-c");
829 let jobs = [
830 ChildJob {
831 side: Side::Compress,
832 codec: "xz",
833 level: Some(9),
834 threads: Some(4),
835 memory_limit: 123_456_789,
836 out: Some(odd.join("out")),
837 files: vec![
838 Snapshot {
839 path: odd.clone(),
840 len: 7,
841 },
842 Snapshot {
843 path: PathBuf::from("-"),
844 len: 0,
845 },
846 ],
847 },
848 ChildJob {
849 side: Side::Decompress,
850 codec: "zstd",
851 level: Some(-5),
852 threads: None,
853 memory_limit: u64::MAX,
854 out: None,
855 files: vec![Snapshot {
856 path: odd,
857 len: u64::MAX,
858 }],
859 },
860 ChildJob {
861 side: Side::Compress,
862 codec: "lz4",
863 level: None,
864 threads: None,
865 memory_limit: 0,
866 out: None,
867 files: Vec::new(),
868 },
869 ];
870 for job in jobs {
871 let args = job.to_args();
872 assert_eq!(ChildJob::from_args(&args), Ok(job.clone()), "{args:?}");
873 }
874 for bad in [
877 &["compress", "nope", "-", "-", "1", "-"][..],
878 &["sideways", "lz4", "-", "-", "1", "-"],
879 &["compress", "lz4", "-", "-", "1"],
880 &["compress", "lz4", "-", "-", "1", "/out", "/x"],
881 &["compress", "lz4", "x", "-", "1", "-"],
882 ] {
883 let args: Vec<OsString> = bad.iter().map(OsString::from).collect();
884 assert!(ChildJob::from_args(&args).is_err(), "{bad:?}");
885 }
886 }
887
888 #[test]
889 fn proc_status_fields_parse_in_kib() {
890 let pad = " ".repeat(4);
893 let status = format!(
894 "Name:\tstuffr\nVmPeak:\t{pad}123456 kB\nVmHWM:\t{pad}6372 kB\nVmRSS:\t{pad}6100 kB\n"
895 );
896 let status = status.as_bytes();
897 assert_eq!(status_field_kib(status, b"VmHWM:"), Some(6372));
898 assert_eq!(status_field_kib(status, b"VmRSS:"), Some(6100));
899 assert_eq!(status_field_kib(status, b"VmSwap:"), None);
900 assert_eq!(status_field_kib(b"VmHWM:\t kB\n", b"VmHWM:"), None);
901 }
902
903 #[test]
904 fn child_reply_parses_ok_and_err() {
905 assert_eq!(parse_reply(b"ok 12345\n"), Some(Ok(12345)));
906 assert_eq!(parse_reply(b"ok 0\n"), Some(Ok(0)));
907 assert_eq!(
908 parse_reply(b"err refused by --memory-limit: too big\n"),
909 Some(Err("refused by --memory-limit: too big".into()))
910 );
911 for junk in [
912 &b""[..],
913 b"ok\n",
914 b"ok -3\n",
915 b"ok 12x\n",
916 b"hello\n",
917 b"err \n",
918 ] {
919 assert_eq!(
920 parse_reply(junk),
921 None,
922 "{:?}",
923 String::from_utf8_lossy(junk)
924 );
925 }
926 }
927
928 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
931 const PROBE_CODEC: &str = "STUFFR_BENCH_MEMORY_PROBE_CODEC";
932 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
933 const PROBE_LEVEL: &str = "STUFFR_BENCH_MEMORY_PROBE_LEVEL";
934 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
935 const PROBE_FILE: &str = "STUFFR_BENCH_MEMORY_PROBE_FILE";
936 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
937 const PROBE_MARK: &str = "memory-probe-reply: ";
938
939 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
945 #[test]
946 #[ignore = "a probe that fresh_processes_measure_xz_9_above_lz4 runs in its own process"]
947 fn memory_probe() {
948 let (Ok(codec), Ok(level), Some(file)) = (
949 std::env::var(PROBE_CODEC),
950 std::env::var(PROBE_LEVEL),
951 std::env::var_os(PROBE_FILE),
952 ) else {
953 return;
954 };
955 let path = PathBuf::from(file);
956 let len = fs::metadata(&path).unwrap().len();
957 let args: Vec<OsString> = ["compress", &codec, &level, "-", "1073741824", "-"]
958 .iter()
959 .map(OsString::from)
960 .chain([OsString::from(len.to_string()), path.into_os_string()])
961 .collect();
962 let job = ChildJob::from_args(&args).unwrap();
963 println!("{PROBE_MARK}{}", reply_line(&child_main(job)));
964 }
965
966 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
972 #[test]
973 fn fresh_processes_measure_xz_9_above_lz4() {
974 let dir = Scratch::new();
975 let input = dir.file("in", &text(1 << 20, 3));
976 let probe = |codec: &str, level: &str| -> u64 {
977 let out = std::process::Command::new(std::env::current_exe().unwrap())
978 .args([
979 "--exact",
980 "bench::memory::tests::memory_probe",
981 "--ignored",
982 "--nocapture",
983 "--test-threads=1",
984 ])
985 .env(PROBE_CODEC, codec)
986 .env(PROBE_LEVEL, level)
987 .env(PROBE_FILE, &input.path)
988 .stdin(std::process::Stdio::null())
989 .output()
990 .unwrap();
991 let stdout = String::from_utf8_lossy(&out.stdout);
992 let reply = stdout
993 .lines()
994 .find_map(|l| l.split_once(PROBE_MARK).map(|(_, r)| r));
995 match reply.and_then(|r| parse_reply(r.as_bytes())) {
996 Some(Ok(n)) if out.status.success() => n,
997 _ => panic!(
998 "{codec} probe gave no `ok` reply ({}):\n--- stdout\n{stdout}\n--- stderr\n{}",
999 out.status,
1000 String::from_utf8_lossy(&out.stderr)
1001 ),
1002 }
1003 };
1004 let lz4 = probe("lz4", "-");
1005 let xz = probe("xz", "9");
1006 assert!(xz > lz4, "xz -9 {xz} vs lz4 {lz4}");
1007 }
1008
1009 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
1012 fn compressed_by_the_child(
1013 dir: &Scratch,
1014 name: &str,
1015 codec: &'static str,
1016 level: Option<i32>,
1017 input: &Snapshot,
1018 ) -> Snapshot {
1019 let out = dir.file(name, b"stale bytes the child must truncate");
1020 let c = child_main(ChildJob {
1021 side: Side::Compress,
1022 codec,
1023 level,
1024 threads: None,
1025 memory_limit: 1 << 30,
1026 out: Some(out.path.clone()),
1027 files: vec![input.clone()],
1028 });
1029 assert!(c.is_ok(), "{codec}: {c:?}");
1030 let len = fs::metadata(&out.path).unwrap().len();
1031 Snapshot {
1032 path: out.path,
1033 len,
1034 }
1035 }
1036
1037 #[cfg(unix)]
1042 #[test]
1043 fn the_child_opens_only_bench_s_own_temp_copy() {
1044 let dir = Scratch::new();
1045 let victim = dir.file("victim", b"precious");
1046 let link = dir.0.join("link");
1047 std::os::unix::fs::symlink(&victim.path, &link).unwrap();
1048 for write in [true, false] {
1049 assert!(open_copy(&link, write).is_err(), "symlink, write {write}");
1050 }
1051 assert_eq!(fs::read(&victim.path).unwrap(), b"precious");
1052 let hard = dir.0.join("hard");
1053 fs::hard_link(&victim.path, &hard).unwrap();
1054 let e = open_copy(&hard, true).unwrap_err();
1055 assert_eq!(e.kind(), io::ErrorKind::PermissionDenied, "{e}");
1056 assert_eq!(fs::read(&victim.path).unwrap(), b"precious");
1057 fs::remove_file(&hard).unwrap();
1058 let fifo = dir.0.join("fifo");
1059 let c = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
1060 assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0);
1062 assert!(open_copy(&fifo, false).is_err(), "a FIFO is no temp copy");
1063 let copy = dir.file("copy", b"stale");
1064 drop(open_copy(©.path, true).unwrap());
1065 assert_eq!(fs::metadata(©.path).unwrap().len(), 0, "emptied");
1066 }
1067
1068 #[cfg(any(target_vendor = "apple", target_os = "linux"))]
1074 #[test]
1075 fn the_compress_child_writes_the_copy_the_decompress_child_reads() {
1076 let dir = Scratch::new();
1077 let original = text(1 << 20, 3);
1078 let input = dir.file("in", &original);
1079 for (codec, level) in [("lz4", None), ("xz", Some(9))] {
1080 let copy = compressed_by_the_child(&dir, codec, codec, level, &input);
1081 let id = codec_id(codec).unwrap();
1082 let decoder = stuffr::registry().require_decoder(id).unwrap();
1083 let mut back = Vec::new();
1084 let src: Box<dyn Source> = Box::new(temp_source(
1085 GuardedSeek::new(File::open(©.path).unwrap()),
1086 copy.len,
1087 ));
1088 io::Read::read_to_end(
1089 &mut decoder.decoder(src, &DecodeOpts::default()).unwrap(),
1090 &mut back,
1091 )
1092 .unwrap();
1093 assert!(
1094 back == original,
1095 "{codec}: the copy does not decode to the input"
1096 );
1097 let d = child_main(ChildJob {
1098 side: Side::Decompress,
1099 codec,
1100 level: None,
1101 threads: None,
1102 memory_limit: 1 << 30,
1103 out: None,
1104 files: vec![copy],
1105 });
1106 assert!(d.is_ok(), "{codec}: {d:?}");
1107 }
1108 }
1109
1110 #[test]
1111 fn per_file_reports_the_maximum() {
1112 let _g = armed_lock();
1113 let dir = Scratch::new();
1114 let files = vec![
1115 dir.file("a", &text(10_000, 0)),
1116 dir.file("b", &text(30_000, 1)),
1117 dir.file("c", &text(20_000, 2)),
1118 ];
1119 let mut d = [5u64, 9, 2].into_iter();
1122 let mut r = fake(|job: &ChildJob| match job.side {
1123 Side::Compress => Ok(job.files[0].len * 100),
1124 Side::Decompress => Ok(d.next().unwrap()),
1125 });
1126 let m = measure_memory_with(&mut r, "gzip", Some(6), &plan(Mode::PerFile), &files);
1127 assert_eq!(
1128 m,
1129 Memory {
1130 compress: Some(3_000_000),
1131 decompress: Some(9),
1132 error: None
1133 }
1134 );
1135 let sides: Vec<Side> = r.seen.iter().map(|(j, _)| j.side).collect();
1139 use Side::{Compress as C, Decompress as D};
1140 assert_eq!(sides, [C, D, C, D, C, D]);
1141 for (i, (job, present)) in r.seen.iter().enumerate() {
1142 assert_eq!(job.files.len(), 1);
1143 assert!(present, "job {i}'s file and output should exist");
1144 let level = (job.side == Side::Compress).then_some(6);
1145 assert_eq!((job.codec, job.level), ("gzip", level));
1146 if job.side == Side::Compress {
1147 assert_eq!(job.files[0], files[i / 2]);
1148 assert!(job.out.is_some(), "a compress child writes the copy");
1149 } else {
1150 assert_eq!(job.out, None);
1151 assert_eq!(job.files[0].len, 4, "the length the compress child wrote");
1152 assert_eq!(Some(&job.files[0].path), r.seen[i - 1].0.out.as_ref());
1153 }
1154 }
1155
1156 let mut r = fake(|job: &ChildJob| Ok(job.files.iter().map(|s| s.len).sum()));
1158 let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::OneStream), &files);
1159 assert_eq!(m.compress, Some(60_000));
1160 assert!(m.decompress.is_some_and(|n| n > 0));
1161 assert_eq!(r.seen.len(), 2);
1162 assert_eq!(r.seen[0].0.files, files);
1163 assert_eq!(r.seen[1].0.files.len(), 1);
1164 }
1165
1166 #[test]
1167 fn a_failed_child_gives_dashes_and_an_error() {
1168 let _g = armed_lock();
1169 let dir = Scratch::new();
1170 let files = vec![
1171 dir.file("a", &text(10_000, 0)),
1172 dir.file("b", &text(10_000, 1)),
1173 ];
1174 let mut n = 0;
1178 let mut r = fake(|job: &ChildJob| match job.side {
1179 Side::Compress => {
1180 n += 1;
1181 if n == 2 {
1182 Err("the memory run failed (signal: 9 (SIGKILL))".into())
1183 } else {
1184 Ok(10)
1185 }
1186 }
1187 Side::Decompress => Ok(20),
1188 });
1189 let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::PerFile), &files);
1190 assert_eq!(m.compress, None);
1191 assert_eq!(m.decompress, None);
1192 let e = m.error.unwrap();
1193 assert!(e.starts_with("compress: ") && e.contains("SIGKILL"), "{e}");
1194 assert!(e.contains("no decompress run"), "{e}");
1195 assert_eq!(r.seen.len(), 3);
1197
1198 let mut r = fake(|job: &ChildJob| match job.side {
1201 Side::Compress => Ok(10),
1202 Side::Decompress => Err("the memory run failed (exit status: 1)".into()),
1203 });
1204 let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::PerFile), &files);
1205 assert_eq!((m.compress, m.decompress), (Some(10), None));
1206 assert!(m.error.unwrap().starts_with("decompress: "));
1207
1208 let mut r = fake(|_: &ChildJob| Err("boom".to_string()));
1210 let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::OneStream), &files);
1211 assert_eq!((m.compress, m.decompress), (None, None));
1212 assert!(m.error.unwrap().contains("boom"));
1213
1214 assert!(interpret(false, "exit status: 101", b"", b"thread 'main' panicked\n").is_err());
1216 assert!(interpret(true, "exit status: 0", b"garbage\n", b"").is_err());
1217 assert!(interpret(false, "signal: 9 (SIGKILL)", b"ok 5\n", b"").is_err());
1218 assert_eq!(interpret(true, "exit status: 0", b"ok 5\n", b""), Ok(5));
1219 assert_eq!(
1220 interpret(false, "exit status: 1", b"err nope\n", b""),
1221 Err("nope".into())
1222 );
1223 }
1224
1225 #[test]
1226 fn a_refusal_names_the_memory_limit() {
1227 let refusal = describe(&stuffr::Error::ResourceLimit(
1228 "xz: header declares a dictionary needing 64 MiB".into(),
1229 ));
1230 assert!(
1231 refusal.starts_with("refused by --memory-limit"),
1232 "{refusal}"
1233 );
1234 assert!(refusal.contains("64 MiB"), "{refusal}");
1235 let corrupt = describe(&stuffr::Error::Corrupt("bad".into()));
1237 assert!(!corrupt.contains("--memory-limit"), "{corrupt}");
1238
1239 let _g = armed_lock();
1241 let dir = Scratch::new();
1242 let files = vec![dir.file("a", &text(10_000, 0))];
1243 let mut r = fake(|job: &ChildJob| match job.side {
1244 Side::Compress => Err(refusal.clone()),
1245 Side::Decompress => Ok(1),
1246 });
1247 let m = measure_memory_with(&mut r, "gzip", None, &plan(Mode::OneStream), &files);
1248 assert_eq!(m.compress, None);
1249 assert!(
1250 m.error.unwrap().contains("refused by --memory-limit"),
1251 "the refusal is distinguishable from a crash"
1252 );
1253 }
1254
1255 #[cfg(all(
1259 any(target_vendor = "apple", target_os = "linux"),
1260 not(feature = "xz-c")
1261 ))]
1262 #[test]
1263 fn a_child_refusal_names_the_memory_limit() {
1264 let dir = Scratch::new();
1265 let input = dir.file("in", &text(100_000, 0));
1266 let copy = compressed_by_the_child(&dir, "in.xz", "xz", Some(9), &input);
1267 let r = child_main(ChildJob {
1268 side: Side::Decompress,
1269 codec: "xz",
1270 level: None,
1271 threads: None,
1272 memory_limit: 1 << 20,
1273 out: None,
1274 files: vec![copy],
1275 });
1276 let e = r.unwrap_err();
1277 assert!(e.starts_with("refused by --memory-limit"), "{e}");
1278 }
1279
1280 #[test]
1281 fn temp_copies_are_removed() {
1282 let _g = armed_lock();
1283 let dir = Scratch::new();
1284 let files = vec![
1285 dir.file("a", &text(10_000, 0)),
1286 dir.file("b", &text(10_000, 1)),
1287 ];
1288 assert_eq!(bench_temps(), Vec::<String>::new());
1289 for mode in [Mode::OneStream, Mode::PerFile] {
1290 let mut r = fake(|job: &ChildJob| {
1293 if job.side == Side::Decompress {
1294 assert_eq!(bench_temps().len(), 1);
1295 }
1296 Ok(1)
1297 });
1298 let m = measure_memory_with(&mut r, "gzip", None, &plan(mode), &files);
1299 assert_eq!(m.error, None);
1300 assert!(r.seen.iter().all(|(_, present)| *present));
1301 assert_eq!(bench_temps(), Vec::<String>::new(), "{mode:?}");
1302 }
1303 let mut r = fake(|_: &ChildJob| Err("boom".to_string()));
1305 let _ = measure_memory_with(&mut r, "gzip", None, &plan(Mode::PerFile), &files);
1306 assert_eq!(bench_temps(), Vec::<String>::new());
1307 }
1308}