1pub mod levels;
10pub mod measure;
11pub mod memory;
12pub mod report;
13pub mod score;
14
15use std::io::{self, IsTerminal, Write};
16use std::path::{Path, PathBuf};
17#[cfg(any(unix, test))]
18use std::sync::atomic::AtomicI32;
19use std::sync::atomic::{AtomicBool, Ordering};
20
21use stuffr::{Error, FormatKind};
22
23use crate::cli::BenchArgs;
24use levels::{backend_of, benchable_codecs, levels_of};
25use measure::{
26 Clock, Input, Mode, Phase, Plan, RealClock, Snapshot, governor_for, load_input, measure, warm,
27};
28use report::{Conditions, MemoryMode};
29use score::{Outcome, Row};
30
31pub const DEBUG_REFUSAL: &str =
34 "stuffr: bench: timings from a debug build are meaningless; build with --release";
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum Ran {
39 Measured,
41 RefusedDebugBuild,
44}
45
46pub fn run(args: BenchArgs) -> stuffr::Result<Ran> {
49 let paths = expand_inputs(&args.inputs)?;
50 let codecs = chosen_codecs(&args.formats)?;
51 let memory_limit = match &args.memory_limit {
52 Some(s) => crate::size::parse_size(s).map_err(Error::Usage)?,
53 None => stuffr::default_memory_limit(),
54 };
55 if cfg!(debug_assertions) {
57 return Ok(Ran::RefusedDebugBuild);
58 }
59 #[cfg(unix)]
62 let _signals = crate::compat::hook_run_signals(on_run_signal);
63 let memory_mode = if args.no_memory {
64 MemoryMode::Skipped
65 } else if memory::MEASURES {
66 MemoryMode::Measured
67 } else {
68 MemoryMode::Unsupported
69 };
70
71 let (input, bytes_in) = load_input(&paths, memory_limit)?;
72 warm(&input)?;
73 let plan = Plan {
74 mode: if args.per_file {
75 Mode::PerFile
76 } else {
77 Mode::OneStream
78 },
79 repeat: args.repeat,
80 threads: args.threads,
81 memory_limit,
82 };
83 let planned = plan_rows(&codecs, args.levels);
84 let on = io::stderr().is_terminal() && !(args.json || args.csv || args.table_only);
85 let mut meter = Meter::new(
86 on.then(|| Box::new(io::stderr()) as Box<dyn Write>),
87 Box::new(RealClock::new()),
88 plan.repeat,
89 terminal_width,
90 );
91 let mut clock = RealClock::new();
92 let snapshots = snapshots_of(&paths, &input);
93 let mut runner = memory::ProcessRunner::new();
94 let mut rows = Vec::with_capacity(planned.len());
95 for (k, p) in planned.iter().enumerate() {
96 meter.row(RowLabel {
97 k: k + 1,
98 n: planned.len(),
99 format: p.format,
100 level: p.label,
101 });
102 let outcome = measure(
104 p.format,
105 p.level,
106 &input,
107 &plan,
108 &mut clock,
109 &mut |ph, d, t| meter.progress(ph, d, t),
110 );
111 let memory = (memory_mode == MemoryMode::Measured && matches!(outcome, Outcome::Ok(_)))
114 .then(|| {
115 meter.memory();
116 memory::measure_memory(&mut runner, p.format, p.level, &plan, &snapshots)
117 });
118 rows.push(Row {
119 format: p.format,
120 level: p.label,
121 backend: backend_of(p.format),
122 outcome,
123 memory,
124 });
125 }
126 meter.clear();
127
128 let conditions = Conditions {
129 files: paths.len(),
130 bytes_in,
131 mode: plan.mode,
132 threads: plan.threads,
133 workers: governor_for(&plan).map_or(1, |g| g.workers()),
135 repeat: plan.repeat,
136 streamed: matches!(input, Input::Files(_)),
137 memory: memory_mode,
138 };
139 let mut out = io::stdout().lock();
140 if args.json {
141 report::json(&conditions, &rows, &mut out)?;
142 } else if args.csv {
143 report::csv(&conditions, &rows, &mut out)?;
144 } else if args.table_only {
145 report::table_only(&rows, conditions.bytes_in, conditions.memory, &mut out)?;
146 } else {
147 report::human(&conditions, &rows, &mut out)?;
148 }
149 out.flush()?;
150 Ok(Ran::Measured)
151}
152
153fn snapshots_of(paths: &[PathBuf], input: &Input) -> Vec<Snapshot> {
156 match input {
157 Input::Files(snaps) => snaps.clone(),
158 Input::Memory(parts) => paths
159 .iter()
160 .zip(parts)
161 .map(|(p, b)| Snapshot {
162 path: p.clone(),
163 len: b.len() as u64,
164 })
165 .collect(),
166 }
167}
168
169static BAR_DRAWN: AtomicBool = AtomicBool::new(false);
172
173const CLEAR_LINE: &[u8] = b"\r\x1b[K";
175
176#[cfg(unix)]
181extern "C" fn on_run_signal() {
182 pre_exit(
183 &memory::RUNNING_CHILD,
184 &BAR_DRAWN,
185 |pid| unsafe {
188 libc::kill(pid, libc::SIGKILL);
189 },
190 || unsafe {
192 libc::write(
193 libc::STDERR_FILENO,
194 CLEAR_LINE.as_ptr().cast(),
195 CLEAR_LINE.len(),
196 );
197 },
198 );
199}
200
201#[cfg(any(unix, test))]
204fn pre_exit(child: &AtomicI32, drawn: &AtomicBool, kill: impl FnOnce(i32), clear: impl FnOnce()) {
205 let pid = child.load(Ordering::SeqCst);
206 if pid > 0 {
207 kill(pid);
208 }
209 if drawn.load(Ordering::SeqCst) {
210 clear();
211 }
212}
213
214const REDRAW_NS: u64 = 100_000_000;
216
217const CELLS: u64 = 20;
219
220const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
222
223#[derive(Clone, Copy, Debug)]
225struct RowLabel {
226 k: usize,
227 n: usize,
228 format: &'static str,
229 level: Option<i32>,
230}
231
232struct Meter {
237 out: Option<Box<dyn Write>>,
238 clock: Box<dyn Clock>,
240 repeats: u32,
241 row: Option<RowLabel>,
242 repeat: u32,
244 drawn_at: Option<u64>,
246 frame: usize,
247 shown: bool,
248 drawn: &'static AtomicBool,
250 width: fn() -> usize,
252}
253
254impl Meter {
255 fn new(
256 out: Option<Box<dyn Write>>,
257 clock: Box<dyn Clock>,
258 repeats: u32,
259 width: fn() -> usize,
260 ) -> Self {
261 Self {
262 out,
263 clock,
264 width,
265 repeats: repeats.max(1),
266 row: None,
267 repeat: 0,
268 drawn_at: None,
269 frame: 0,
270 shown: false,
271 drawn: &BAR_DRAWN,
272 }
273 }
274
275 #[cfg(test)]
277 fn with_drawn_flag(mut self, drawn: &'static AtomicBool) -> Self {
278 self.drawn = drawn;
279 self
280 }
281
282 fn memory(&mut self) {
285 let (Some(out), Some(row)) = (self.out.as_mut(), self.row) else {
286 return;
287 };
288 let level = row.level.map_or_else(|| "-".to_string(), |l| l.to_string());
289 let line = format!("[{}/{}] {} {level} memory", row.k, row.n, row.format);
290 let line = fit(&line, (self.width)().saturating_sub(1));
291 self.drawn.store(true, Ordering::SeqCst);
293 let _ = write!(out, "\r{line}\x1b[K");
294 let _ = out.flush();
295 self.shown = true;
296 }
297
298 fn row(&mut self, row: RowLabel) {
300 self.row = Some(row);
301 self.repeat = 0;
302 self.drawn_at = None;
303 }
304
305 fn progress(&mut self, phase: Phase, done: u64, total: u64) {
307 let (Some(out), Some(row)) = (self.out.as_mut(), self.row) else {
308 return;
309 };
310 if phase == Phase::Compress && done == 0 {
311 self.repeat = (self.repeat + 1).min(self.repeats);
312 }
313 let now = self.clock.now();
314 if let Some(at) = self.drawn_at
315 && now.saturating_sub(at) < REDRAW_NS
316 {
317 return;
318 }
319 let repeat = (self.repeats > 1).then_some((self.repeat.max(1), self.repeats));
320 let line = bar_line(&row, phase, repeat, done, total, self.frame);
321 let line = fit(&line, (self.width)().saturating_sub(1));
325 self.drawn.store(true, Ordering::SeqCst);
327 let _ = write!(out, "\r{line}\x1b[K");
329 let _ = out.flush();
330 self.frame = (self.frame + 1) % SPINNER.len();
331 self.drawn_at = Some(now);
332 self.shown = true;
333 }
334
335 fn clear(&mut self) {
336 if let Some(out) = self.out.as_mut()
337 && self.shown
338 {
339 self.drawn.store(false, Ordering::SeqCst);
342 let _ = out.write_all(CLEAR_LINE);
343 let _ = out.flush();
344 self.shown = false;
345 }
346 }
347}
348
349fn bar_line(
353 row: &RowLabel,
354 phase: Phase,
355 repeat: Option<(u32, u32)>,
356 done: u64,
357 total: u64,
358 frame: usize,
359) -> String {
360 let done = done.min(total);
361 let share = |scale: u64| {
363 if total == 0 {
364 scale
365 } else {
366 u64::try_from(u128::from(done) * u128::from(scale) / u128::from(total)).unwrap_or(scale)
367 }
368 };
369 let cells = share(CELLS);
370 let bar: String = (0..CELLS)
371 .map(|i| if i < cells { '█' } else { '░' })
372 .collect();
373 let RowLabel {
374 k,
375 n,
376 format,
377 level,
378 } = *row;
379 let level = level.map_or_else(|| "-".to_string(), |l| l.to_string());
380 let phase = match phase {
381 Phase::Compress => "compress",
382 Phase::Decompress => "decompress",
383 };
384 let repeat = repeat.map_or_else(String::new, |(r, n)| format!(" {r}/{n}"));
385 let spinner = SPINNER[frame % SPINNER.len()];
386 let pct = share(100);
387 format!("[{k}/{n}] {format} {level} {phase}{repeat} ▕{bar}▏{spinner} {pct}%")
388}
389
390#[cfg(unix)]
393fn terminal_width() -> usize {
394 let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
396 let ok = unsafe { libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut ws) } == 0;
398 if ok && ws.ws_col > 0 {
399 usize::from(ws.ws_col)
400 } else {
401 80
402 }
403}
404
405#[cfg(not(unix))]
407fn terminal_width() -> usize {
408 80
409}
410
411fn literal_prefix(pattern: &str) -> Option<&Path> {
415 let first_glob = pattern.find(['*', '?', '['])?;
416 let cut = pattern[..first_glob].rfind('/')?;
417 Some(Path::new(&pattern[..cut.max(1)]))
418}
419
420fn fit(line: &str, cols: usize) -> String {
425 if line.chars().count() <= cols {
426 return line.to_string();
427 }
428 let label = line.split_once(" ").map_or(line, |(l, _)| l);
430 truncate_cols(label, cols).to_string()
431}
432
433fn truncate_cols(s: &str, cols: usize) -> &str {
436 match s.char_indices().nth(cols) {
437 Some((at, _)) => &s[..at],
438 None => s,
439 }
440}
441
442#[derive(Clone, Copy, Debug, PartialEq, Eq)]
445struct Planned {
446 format: &'static str,
447 level: Option<i32>,
448 label: Option<i32>,
449}
450
451fn plan_rows(codecs: &[&'static str], sweep: bool) -> Vec<Planned> {
454 let mut rows = Vec::new();
455 for &format in codecs {
456 let lv = levels_of(format);
457 match lv.map(|l| l.sweep).filter(|s| sweep && !s.is_empty()) {
458 Some(levels) => rows.extend(levels.iter().map(|&l| Planned {
459 format,
460 level: Some(l),
461 label: Some(l),
462 })),
463 None => rows.push(Planned {
464 format,
465 level: None,
466 label: lv.and_then(|l| l.default),
467 }),
468 }
469 }
470 rows
471}
472
473fn chosen_codecs(names: &[String]) -> stuffr::Result<Vec<&'static str>> {
476 let benchable = benchable_codecs();
477 if names.is_empty() {
478 return Ok(benchable);
479 }
480 let mut chosen = Vec::new();
481 for name in names {
482 let id = crate::format_by_name(name)?;
483 let Some(&codec) = benchable.iter().find(|c| **c == id.as_str()) else {
484 return Err(Error::Usage(format!(
485 "{name} cannot be benchmarked in this build ({})",
486 why_not_benchable(name)
487 )));
488 };
489 if !chosen.contains(&codec) {
490 chosen.push(codec);
491 }
492 }
493 Ok(chosen)
494}
495
496fn why_not_benchable(name: &str) -> &'static str {
498 let row = stuffr::registry()
499 .matrix()
500 .into_iter()
501 .find(|r| r.id.as_str() == name);
502 match row {
503 Some(r) => why_not(r.kind, r.write),
504 None => why_not(FormatKind::Codec, true),
505 }
506}
507
508fn why_not(kind: FormatKind, write: bool) -> &'static str {
511 match (kind, write) {
512 (FormatKind::Container, _) => "container",
513 (_, false) => "read-only",
514 _ => "weak fallback encoder",
515 }
516}
517
518fn has_glob_chars(s: &str) -> bool {
519 s.contains(['*', '?', '['])
520}
521
522fn expand_inputs(operands: &[String]) -> stuffr::Result<Vec<PathBuf>> {
532 let mut files = Vec::new();
533 for op in operands {
534 let path = Path::new(op);
535 match path.symlink_metadata() {
541 Ok(_) => {
542 if path.is_dir() {
543 return Err(directory_error(path));
544 }
545 files.push(regular(path)?);
546 continue;
547 }
548 Err(e) if e.kind() != io::ErrorKind::NotFound && !has_glob_chars(op) => {
549 return Err(unreadable(path, &e));
550 }
551 Err(_) => {}
552 }
553 if !has_glob_chars(op) {
554 return Err(no_match(op));
555 }
556 let opts = glob::MatchOptions {
557 require_literal_leading_dot: true,
558 ..Default::default()
559 };
560 let matches = glob::glob_with(op, opts)
561 .map_err(|e| Error::Usage(format!("bad pattern {op}: {e}")))?;
562 let mut found = Vec::new();
563 let mut first_dir = None;
564 for m in matches {
565 let m = m.map_err(|e| unreadable(e.path(), e.error()))?;
566 if m.is_dir() {
567 first_dir.get_or_insert(m);
568 } else {
569 found.push(regular(&m)?);
570 }
571 }
572 if found.is_empty() {
573 if let Some(d) = first_dir {
574 return Err(directory_error(&d));
575 }
576 if let Some(prefix) = literal_prefix(op)
579 && let Err(e) = prefix.symlink_metadata()
580 && !matches!(
581 e.kind(),
582 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
583 )
584 {
585 return Err(unreadable(prefix, &e));
586 }
587 return Err(no_match(op));
588 }
589 found.sort();
590 files.extend(found);
591 }
592 Ok(files)
593}
594
595fn regular(path: &Path) -> stuffr::Result<PathBuf> {
600 let meta = std::fs::metadata(path).map_err(|e| unreadable(path, &e))?;
601 if meta.is_file() {
602 std::fs::File::open(path).map_err(|e| unreadable(path, &e))?;
603 Ok(path.to_path_buf())
604 } else {
605 Err(Error::Usage(format!(
606 "{} is not a regular file; bench needs files whose size it can read",
607 path.display()
608 )))
609 }
610}
611
612fn unreadable(path: &Path, e: &io::Error) -> Error {
613 Error::Usage(format!("{}: cannot read it ({e})", path.display()))
614}
615
616fn no_match(op: &str) -> Error {
617 Error::Usage(format!("no input matched {op}"))
618}
619
620fn directory_error(path: &Path) -> Error {
621 let p = path.display();
622 Error::Usage(format!("{p} is a directory; name its files (e.g. {p}/*)"))
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use std::fs;
629 use std::sync::atomic::{AtomicU32, Ordering};
630
631 struct Scratch(PathBuf);
633
634 impl Scratch {
635 fn new() -> Self {
636 static N: AtomicU32 = AtomicU32::new(0);
637 let n = N.fetch_add(1, Ordering::Relaxed);
638 let dir =
639 std::env::temp_dir().join(format!("stuffr-benchrun-{}-{n}", std::process::id()));
640 fs::create_dir_all(&dir).unwrap();
641 Scratch(dir)
642 }
643
644 fn file(&self, name: &str) -> PathBuf {
645 let p = self.0.join(name);
646 fs::write(&p, b"x").unwrap();
647 p
648 }
649
650 fn s(&self, rest: &str) -> String {
651 format!("{}/{rest}", self.0.display())
652 }
653 }
654
655 impl Drop for Scratch {
656 fn drop(&mut self) {
657 let _ = fs::remove_dir_all(&self.0);
658 }
659 }
660
661 fn usage(r: stuffr::Result<Vec<PathBuf>>) -> String {
662 match r {
663 Err(Error::Usage(m)) => m,
664 other => panic!("expected a usage error, got {other:?}"),
665 }
666 }
667
668 #[test]
669 fn a_default_row_runs_the_encoders_default_labelled_with_the_table_default() {
670 let rows = plan_rows(&["bzip2", "compress", "lz4"], false);
671 assert_eq!(
672 rows,
673 vec![
674 Planned {
675 format: "bzip2",
676 level: None,
677 label: Some(6)
678 },
679 Planned {
680 format: "compress",
681 level: None,
682 label: Some(16)
683 },
684 Planned {
685 format: "lz4",
686 level: None,
687 label: None
688 },
689 ]
690 );
691 }
692
693 #[test]
694 fn a_sweep_runs_every_level_and_one_row_for_a_codec_without_levels() {
695 let rows = plan_rows(&["compress", "snappy"], true);
696 let got: Vec<_> = rows.iter().map(|r| (r.format, r.level, r.label)).collect();
697 let mut want: Vec<_> = (9..=16).map(|l| ("compress", Some(l), Some(l))).collect();
698 want.push(("snappy", None, None));
699 assert_eq!(got, want);
700 }
701
702 #[test]
703 fn a_glob_expands_sorted_and_skips_dot_files_and_directories() {
704 let s = Scratch::new();
705 s.file("b.log");
706 s.file("a.log");
707 s.file(".hidden.log");
708 fs::create_dir(s.0.join("dir.log")).unwrap();
709 let got = expand_inputs(&[s.s("*.log")]).unwrap();
710 assert_eq!(got, vec![s.0.join("a.log"), s.0.join("b.log")]);
711 }
712
713 #[test]
714 fn an_existing_path_is_used_as_is_even_with_glob_characters() {
715 let s = Scratch::new();
716 let odd = s.file("a[1].log");
717 assert_eq!(expand_inputs(&[s.s("a[1].log")]).unwrap(), vec![odd]);
718 }
719
720 #[test]
721 fn no_match_and_a_missing_path_are_usage_errors() {
722 let s = Scratch::new();
723 let pat = s.s("nomatch*.log");
724 assert_eq!(
725 usage(expand_inputs(std::slice::from_ref(&pat))),
726 format!("no input matched {pat}")
727 );
728 let missing = s.s("absent.log");
729 assert!(usage(expand_inputs(&[missing])).starts_with("no input matched"));
730 }
731
732 #[test]
733 fn a_directory_is_refused_named_or_as_a_patterns_only_match() {
734 let s = Scratch::new();
735 fs::create_dir(s.0.join("logs")).unwrap();
736 let named = usage(expand_inputs(&[s.s("logs")]));
737 let dir = s.s("logs");
738 assert_eq!(
739 named,
740 format!("{dir} is a directory; name its files (e.g. {dir}/*)")
741 );
742 assert!(usage(expand_inputs(&[s.s("lo*")])).contains("is a directory"));
743 }
744
745 #[cfg(unix)]
746 #[test]
747 fn a_device_is_refused_and_a_symlink_to_a_file_is_not() {
748 let s = Scratch::new();
749 let want = "/dev/null is not a regular file; bench needs files whose size it can read";
750 assert_eq!(usage(expand_inputs(&["/dev/null".into()])), want);
751 assert_eq!(usage(expand_inputs(&["/dev/nul?".into()])), want);
752 let target = s.file("real.log");
753 let link = s.0.join("link.log");
754 std::os::unix::fs::symlink(&target, &link).unwrap();
755 assert_eq!(expand_inputs(&[s.s("link.log")]).unwrap(), vec![link]);
756 }
757
758 #[cfg(unix)]
759 #[test]
760 fn a_dangling_symlink_is_a_usage_error() {
761 let s = Scratch::new();
762 s.file("real.log");
763 let link = s.0.join("dangling.log");
764 std::os::unix::fs::symlink(s.0.join("gone.log"), &link).unwrap();
765 let named = usage(expand_inputs(&[s.s("dangling.log")]));
766 assert!(
767 named.starts_with(&format!("{}: cannot read it (", link.display())),
768 "{named}"
769 );
770 let globbed = usage(expand_inputs(&[s.s("*.log")]));
772 assert!(
773 globbed.starts_with(&format!("{}: cannot read it (", link.display())),
774 "{globbed}"
775 );
776 }
777
778 #[cfg(unix)]
780 struct Mode000(PathBuf);
781
782 #[cfg(unix)]
783 impl Drop for Mode000 {
784 fn drop(&mut self) {
785 use std::os::unix::fs::PermissionsExt;
786 let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o700));
787 }
788 }
789
790 #[cfg(unix)]
791 #[test]
792 fn an_unreadable_directory_under_a_pattern_is_a_usage_error() {
793 use std::os::unix::fs::PermissionsExt;
794 let s = Scratch::new();
795 s.file("a.log");
796 let locked = s.0.join("locked");
797 fs::create_dir(&locked).unwrap();
798 fs::write(locked.join("b.log"), b"x").unwrap();
799 let _restore = Mode000(locked.clone());
800 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
801 if fs::read_dir(&locked).is_ok() {
803 return;
804 }
805 let m = usage(expand_inputs(&[s.s("*/*.log")]));
806 assert!(m.contains("locked"), "{m}");
807 assert!(m.contains(": cannot read it ("), "{m}");
808 }
809
810 #[cfg(unix)]
811 #[test]
812 fn a_named_path_in_an_unreadable_directory_cannot_be_read() {
813 use std::os::unix::fs::PermissionsExt;
814 let s = Scratch::new();
815 let locked = s.0.join("locked");
816 fs::create_dir(&locked).unwrap();
817 fs::write(locked.join("b.log"), b"x").unwrap();
818 let _restore = Mode000(locked.clone());
819 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
820 if fs::read_dir(&locked).is_ok() {
822 return;
823 }
824 let named = locked.join("b.log");
825 let m = usage(expand_inputs(&[s.s("locked/b.log")]));
826 assert!(
827 m.starts_with(&format!("{}: cannot read it (", named.display())),
828 "{m}"
829 );
830 let m = usage(expand_inputs(&[s.s("locked/*.log")]));
832 assert!(m.contains(": cannot read it ("), "{m}");
833 }
834
835 #[derive(Clone, Default)]
837 struct Captured(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
838
839 impl Write for Captured {
840 fn write(&mut self, b: &[u8]) -> io::Result<usize> {
841 self.0.borrow_mut().extend_from_slice(b);
842 Ok(b.len())
843 }
844
845 fn flush(&mut self) -> io::Result<()> {
846 Ok(())
847 }
848 }
849
850 impl Captured {
851 fn lines(&self) -> Vec<String> {
853 String::from_utf8(self.0.borrow().clone())
854 .unwrap()
855 .split('\r')
856 .map(|l| l.trim_end_matches("\x1b[K").to_string())
857 .filter(|l| !l.is_empty())
858 .collect()
859 }
860 }
861
862 struct HandClock(std::rc::Rc<std::cell::Cell<u64>>);
864
865 impl Clock for HandClock {
866 fn now(&mut self) -> u64 {
867 self.0.get()
868 }
869 }
870
871 fn label(k: usize, n: usize, format: &'static str, level: Option<i32>) -> RowLabel {
872 RowLabel {
873 k,
874 n,
875 format,
876 level,
877 }
878 }
879
880 #[test]
881 fn the_bar_renders_blocks_and_a_spinner() {
882 let xz = label(3, 8, "xz", Some(6));
883 assert_eq!(
884 bar_line(&xz, Phase::Compress, None, 0, 1000, 0),
885 "[3/8] xz 6 compress ▕░░░░░░░░░░░░░░░░░░░░▏⠋ 0%"
886 );
887 assert_eq!(
888 bar_line(&xz, Phase::Compress, None, 370, 1000, 1),
889 "[3/8] xz 6 compress ▕███████░░░░░░░░░░░░░▏⠙ 37%"
890 );
891 assert_eq!(
892 bar_line(&xz, Phase::Decompress, Some((2, 3)), 370, 1000, 2),
893 "[3/8] xz 6 decompress 2/3 ▕███████░░░░░░░░░░░░░▏⠹ 37%"
894 );
895 let lz4 = label(8, 8, "lz4", None);
896 assert_eq!(
897 bar_line(&lz4, Phase::Decompress, None, 1000, 1000, 9),
898 "[8/8] lz4 - decompress ▕████████████████████▏⠏ 100%"
899 );
900 assert_eq!(
902 bar_line(&lz4, Phase::Decompress, None, 2000, 1000, 9),
903 "[8/8] lz4 - decompress ▕████████████████████▏⠏ 100%"
904 );
905 assert_eq!(
907 bar_line(&lz4, Phase::Compress, None, 0, 1, 10),
908 bar_line(&lz4, Phase::Compress, None, 0, 1, 0)
909 );
910
911 let out = Captured::default();
914 let t = std::rc::Rc::new(std::cell::Cell::new(0));
915 let mut meter = Meter::new(
916 Some(Box::new(out.clone())),
917 Box::new(HandClock(t.clone())),
918 3,
919 || 80,
920 );
921 meter.row(xz);
922 meter.progress(Phase::Compress, 0, 1000);
923 t.set(REDRAW_NS - 1);
924 meter.progress(Phase::Compress, 370, 1000);
925 t.set(REDRAW_NS);
926 meter.progress(Phase::Compress, 370, 1000);
927 t.set(2 * REDRAW_NS);
928 meter.progress(Phase::Compress, 0, 1000);
929 assert_eq!(
930 out.lines(),
931 vec![
932 "[3/8] xz 6 compress 1/3 ▕░░░░░░░░░░░░░░░░░░░░▏⠋ 0%",
933 "[3/8] xz 6 compress 1/3 ▕███████░░░░░░░░░░░░░▏⠙ 37%",
934 "[3/8] xz 6 compress 2/3 ▕░░░░░░░░░░░░░░░░░░░░▏⠹ 0%",
935 ]
936 );
937 meter.row(lz4);
939 meter.progress(Phase::Compress, 0, 1000);
940 assert_eq!(
941 out.lines().last().unwrap(),
942 "[8/8] lz4 - compress 1/3 ▕░░░░░░░░░░░░░░░░░░░░▏⠸ 0%"
943 );
944 meter.clear();
945 assert!(out.0.borrow().ends_with(b"\r\x1b[K"));
946 }
947
948 #[test]
949 fn an_empty_input_draws_full_and_divides_nothing() {
950 let out = Captured::default();
951 let mut meter = Meter::new(
952 Some(Box::new(out.clone())),
953 Box::new(RealClock::new()),
954 1,
955 || 80,
956 );
957 meter.row(label(1, 1, "gzip", Some(6)));
958 let input = Input::Memory(vec![Vec::new()]);
959 let plan = Plan {
960 mode: Mode::OneStream,
961 repeat: 1,
962 threads: None,
963 memory_limit: 1 << 30,
964 };
965 let mut seen = Vec::new();
966 let o = measure(
967 "gzip",
968 None,
969 &input,
970 &plan,
971 &mut RealClock::new(),
972 &mut |ph, d, t| {
973 seen.push((ph, d, t));
974 meter.progress(ph, d, t)
975 },
976 );
977 assert!(matches!(o, score::Outcome::Ok(_)), "{o:?}");
978 assert_eq!(seen.first(), Some(&(Phase::Compress, 0, 0)));
979 assert_eq!(seen.last(), Some(&(Phase::Decompress, 0, 0)));
980 assert_eq!(
982 out.lines().first().map(String::as_str),
983 Some("[1/1] gzip 6 compress ▕████████████████████▏⠋ 100%")
984 );
985 }
986
987 #[test]
988 fn formats_are_checked_and_deduplicated() {
989 let all = benchable_codecs();
990 assert_eq!(chosen_codecs(&[]).unwrap(), all);
991 let gz = vec!["gzip".to_string(), "bzip2".into(), "gzip".into()];
992 assert_eq!(chosen_codecs(&gz).unwrap(), vec!["gzip", "bzip2"]);
993 let msg = |n: &str| match chosen_codecs(&[n.to_string()]) {
994 Err(Error::Usage(m)) => m,
995 other => panic!("{other:?}"),
996 };
997 assert_eq!(
998 msg("tar"),
999 "tar cannot be benchmarked in this build (container)"
1000 );
1001 assert!(msg("nosuch").starts_with("unknown format `nosuch`"));
1002 if !all.contains(&"zstd") {
1003 assert_eq!(
1004 msg("zstd"),
1005 "zstd cannot be benchmarked in this build (weak fallback encoder)"
1006 );
1007 }
1008 }
1009
1010 #[test]
1011 fn the_reason_follows_the_matrix_facts() {
1012 assert_eq!(why_not(FormatKind::Container, true), "container");
1015 assert_eq!(why_not(FormatKind::Container, false), "container");
1016 assert_eq!(why_not(FormatKind::Codec, false), "read-only");
1017 assert_eq!(why_not(FormatKind::Codec, true), "weak fallback encoder");
1018 assert_eq!(why_not_benchable("tar"), "container");
1019 }
1020
1021 #[cfg(unix)]
1022 #[test]
1023 fn a_file_that_cannot_be_opened_is_a_usage_error_named_or_matched() {
1024 use std::os::unix::fs::PermissionsExt;
1025 let s = Scratch::new();
1026 s.file("a.log");
1027 let locked = s.file("locked.log");
1028 let _restore = Mode000(locked.clone());
1029 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
1030 if fs::File::open(&locked).is_ok() {
1032 return;
1033 }
1034 let want = format!("{}: cannot read it (", locked.display());
1035 let named = usage(expand_inputs(&[s.s("locked.log")]));
1036 assert!(named.starts_with(&want), "{named}");
1037 let globbed = usage(expand_inputs(&[s.s("*.log")]));
1038 assert!(globbed.starts_with(&want), "{globbed}");
1039 }
1040
1041 #[cfg(unix)]
1045 #[test]
1046 fn a_pattern_under_an_unreadable_directory_names_the_prefix() {
1047 use std::os::unix::fs::PermissionsExt;
1048 let s = Scratch::new();
1049 let locked = s.0.join("locked");
1050 fs::create_dir_all(locked.join("sub")).unwrap();
1051 fs::write(locked.join("sub/b.log"), b"x").unwrap();
1052 let _restore = Mode000(locked.clone());
1053 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
1054 if fs::read_dir(&locked).is_ok() {
1056 return;
1057 }
1058 let m = usage(expand_inputs(&[s.s("locked/sub/*.log")]));
1059 let prefix = locked.join("sub");
1060 assert!(
1061 m.starts_with(&format!("{}: cannot read it (", prefix.display())),
1062 "{m}"
1063 );
1064 let pat = s.s("absent/sub/*.log");
1066 assert_eq!(
1067 usage(expand_inputs(std::slice::from_ref(&pat))),
1068 format!("no input matched {pat}")
1069 );
1070 }
1071
1072 #[test]
1073 fn the_literal_prefix_stops_before_the_first_glob_component() {
1074 assert_eq!(literal_prefix("a/b/*.log"), Some(Path::new("a/b")));
1075 assert_eq!(literal_prefix("/x/l?ck/sub/*.log"), Some(Path::new("/x")));
1076 assert_eq!(literal_prefix("/*.log"), Some(Path::new("/")));
1077 assert_eq!(literal_prefix("*.log"), None);
1078 assert_eq!(literal_prefix("a/[b]/c"), Some(Path::new("a")));
1079 }
1080
1081 #[test]
1082 fn the_bar_line_is_cut_to_the_terminal_width() {
1083 let full = bar_line(
1084 &label(3, 8, "xz", Some(6)),
1085 Phase::Decompress,
1086 Some((2, 3)),
1087 370,
1088 1000,
1089 2,
1090 );
1091 assert_eq!(full.chars().count(), 54);
1093 assert_eq!(fit(&full, 80), full);
1094 assert_eq!(fit(&full, 54), full);
1095 assert_eq!(fit(&full, 53), "[3/8] xz 6");
1097 assert_eq!(fit(&full, 40), "[3/8] xz 6");
1098 assert_eq!(fit(&full, 20), "[3/8] xz 6");
1099 assert_eq!(fit(&full, 5), "[3/8]");
1101 assert_eq!(fit(&full, 0), "");
1102 let long = bar_line(
1103 &label(12, 34, "compress", Some(16)),
1104 Phase::Compress,
1105 None,
1106 0,
1107 1,
1108 0,
1109 );
1110 assert_eq!(fit(&long, 20), "[12/34] compress 16");
1111 assert_eq!(fit(&long, 10), "[12/34] co");
1112 }
1113
1114 #[test]
1115 fn truncation_counts_columns_not_bytes() {
1116 let line = "ab ▕██░▏⠹ 37%";
1117 assert_eq!(truncate_cols(line, 3), "ab ");
1119 assert_eq!(truncate_cols(line, 4), "ab ▕");
1120 assert_eq!(truncate_cols(line, 6), "ab ▕██");
1121 assert_eq!(truncate_cols(line, 9), "ab ▕██░▏⠹");
1122 assert_eq!(truncate_cols(line, 100), line);
1123 assert_eq!(truncate_cols(line, 0), "");
1124 }
1125
1126 #[test]
1127 fn the_meter_draws_within_the_terminal_width() {
1128 let out = Captured::default();
1129 let mut meter = Meter::new(
1130 Some(Box::new(out.clone())),
1131 Box::new(HandClock(std::rc::Rc::new(std::cell::Cell::new(0)))),
1132 1,
1133 || 30,
1134 );
1135 meter.row(label(3, 8, "xz", Some(6)));
1136 meter.progress(Phase::Compress, 0, 1000);
1137 assert_eq!(out.lines(), vec!["[3/8] xz 6"]);
1139 }
1140
1141 struct CountClock(std::rc::Rc<std::cell::Cell<u32>>);
1143
1144 impl Clock for CountClock {
1145 fn now(&mut self) -> u64 {
1146 self.0.set(self.0.get() + 1);
1147 0
1148 }
1149 }
1150
1151 #[test]
1154 fn a_meter_that_is_off_writes_nothing_and_reads_no_clock() {
1155 let reads = std::rc::Rc::new(std::cell::Cell::new(0));
1156 let mut meter = Meter::new(None, Box::new(CountClock(reads.clone())), 3, || {
1157 panic!("an off meter asked for the width")
1158 });
1159 meter.row(label(1, 2, "gzip", Some(6)));
1160 meter.progress(Phase::Compress, 0, 1000);
1161 meter.progress(Phase::Compress, 500, 1000);
1162 meter.progress(Phase::Decompress, 1000, 1000);
1163 meter.row(label(2, 2, "xz", Some(6)));
1164 meter.progress(Phase::Compress, 0, 1000);
1165 meter.clear();
1166 assert_eq!(reads.get(), 0, "an off meter read its clock");
1167 assert!(!meter.shown);
1168 }
1169
1170 #[test]
1174 fn the_meter_flag_tracks_drawing() {
1175 static DRAWN: AtomicBool = AtomicBool::new(false);
1176 let out = Captured::default();
1177 let t = std::rc::Rc::new(std::cell::Cell::new(0));
1178 let mut meter = Meter::new(
1179 Some(Box::new(out.clone())),
1180 Box::new(HandClock(t)),
1181 1,
1182 || 80,
1183 )
1184 .with_drawn_flag(&DRAWN);
1185 meter.row(label(1, 2, "xz", Some(6)));
1186 assert!(
1187 !DRAWN.load(Ordering::SeqCst),
1188 "the flag stays down until the first draw"
1189 );
1190 meter.progress(Phase::Compress, 0, 1000);
1191 assert!(DRAWN.load(Ordering::SeqCst), "a draw raises the flag");
1192 meter.clear();
1193 assert!(!DRAWN.load(Ordering::SeqCst), "clear lowers the flag");
1194 meter.memory();
1195 assert!(
1196 DRAWN.load(Ordering::SeqCst),
1197 "the memory line raises the flag"
1198 );
1199 meter.clear();
1200 assert!(!DRAWN.load(Ordering::SeqCst), "clear lowers the flag");
1201
1202 static OFF: AtomicBool = AtomicBool::new(false);
1203 let mut off = Meter::new(None, Box::new(RealClock::new()), 1, || 80).with_drawn_flag(&OFF);
1204 off.row(label(1, 1, "xz", Some(6)));
1205 off.progress(Phase::Compress, 0, 1000);
1206 off.memory();
1207 assert!(
1208 !OFF.load(Ordering::SeqCst),
1209 "an off meter leaves the flag down"
1210 );
1211 }
1212
1213 #[test]
1216 fn the_memory_phase_shows_the_row_and_memory() {
1217 static DRAWN: AtomicBool = AtomicBool::new(false);
1218 let out = Captured::default();
1219 let mut meter = Meter::new(
1220 Some(Box::new(out.clone())),
1221 Box::new(RealClock::new()),
1222 3,
1223 || 80,
1224 )
1225 .with_drawn_flag(&DRAWN);
1226 meter.row(label(3, 8, "xz", Some(9)));
1227 meter.memory();
1228 meter.row(label(8, 8, "lz4", None));
1229 meter.memory();
1230 assert_eq!(
1231 out.lines(),
1232 vec!["[3/8] xz 9 memory", "[8/8] lz4 - memory"]
1233 );
1234 let mut narrow = Meter::new(
1235 Some(Box::new(out.clone())),
1236 Box::new(RealClock::new()),
1237 1,
1238 || 12,
1239 )
1240 .with_drawn_flag(&DRAWN);
1241 narrow.row(label(3, 8, "xz", Some(9)));
1242 narrow.memory();
1243 assert_eq!(out.lines().last().unwrap(), "[3/8] xz 9");
1244 }
1245
1246 #[test]
1251 fn the_signal_hook_kills_the_child_then_clears_the_bar() {
1252 let steps = std::cell::RefCell::new(Vec::new());
1253 let run = |pid: i32, drawn: bool| {
1254 steps.borrow_mut().clear();
1255 pre_exit(
1256 &AtomicI32::new(pid),
1257 &AtomicBool::new(drawn),
1258 |p| steps.borrow_mut().push(format!("kill {p}")),
1259 || steps.borrow_mut().push("clear".to_string()),
1260 );
1261 steps.borrow().clone()
1262 };
1263 assert_eq!(run(4242, true), vec!["kill 4242", "clear"]);
1264 assert_eq!(run(4242, false), vec!["kill 4242"]);
1265 assert_eq!(run(0, true), vec!["clear"]);
1266 assert_eq!(run(-1, true), vec!["clear"]);
1267 assert!(run(0, false).is_empty());
1268 }
1269
1270 #[cfg(unix)]
1276 #[test]
1277 fn a_signal_kills_the_memory_child_clears_the_bar_and_kills_bench() {
1278 use std::os::unix::process::ExitStatusExt;
1279 struct Victim(std::process::Child);
1281 impl Drop for Victim {
1282 fn drop(&mut self) {
1283 let _ = self.0.kill();
1284 let _ = self.0.wait();
1285 }
1286 }
1287 for sig in [libc::SIGINT, libc::SIGQUIT] {
1288 let mut victim = Victim(
1289 std::process::Command::new("sleep")
1290 .arg("60")
1291 .spawn()
1292 .unwrap(),
1293 );
1294 let pid = i32::try_from(victim.0.id()).unwrap();
1295 let fds = cloexec_pipe();
1296 let status = unsafe {
1301 let child = libc::fork();
1302 assert!(child >= 0, "fork: {}", io::Error::last_os_error());
1303 if child == 0 {
1304 for s in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT] {
1305 libc::signal(s, libc::SIG_DFL);
1306 }
1307 let none = libc::rlimit {
1308 rlim_cur: 0,
1309 rlim_max: 0,
1310 };
1311 libc::setrlimit(libc::RLIMIT_CORE, &none);
1312 crate::compat::disarm_inherited();
1316 libc::dup2(fds[1], libc::STDERR_FILENO);
1317 memory::RUNNING_CHILD.store(pid, Ordering::SeqCst);
1318 BAR_DRAWN.store(true, Ordering::SeqCst);
1319 let _hook = crate::compat::hook_run_signals(on_run_signal);
1320 libc::raise(sig);
1321 libc::_exit(99);
1322 }
1323 libc::close(fds[1]);
1324 let mut status = 0;
1325 while libc::waitpid(child, &mut status, 0) < 0 {
1326 assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EINTR));
1327 }
1328 status
1329 };
1330 let got = read_within_10s(fds[0]);
1331 assert!(libc::WIFSIGNALED(status), "{sig}: exited: {status:#x}");
1332 assert_eq!(libc::WTERMSIG(status), sig);
1333 assert_eq!(got, CLEAR_LINE, "{sig}");
1334 let mut ended = None;
1337 for _ in 0..1000 {
1338 ended = victim.0.try_wait().unwrap();
1339 if ended.is_some() {
1340 break;
1341 }
1342 std::thread::sleep(std::time::Duration::from_millis(10));
1343 }
1344 let ended = ended.expect("the hook should have killed the stand-in child");
1345 assert_eq!(ended.signal(), Some(libc::SIGKILL), "{sig}: {ended:?}");
1346 }
1347 }
1348
1349 #[cfg(unix)]
1352 fn cloexec_pipe() -> [libc::c_int; 2] {
1353 let mut fds = [0; 2];
1354 unsafe {
1357 assert_eq!(libc::pipe(fds.as_mut_ptr()), 0);
1358 for fd in fds {
1359 libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC);
1360 }
1361 }
1362 fds
1363 }
1364
1365 #[cfg(unix)]
1368 fn read_within_10s(fd: libc::c_int) -> Vec<u8> {
1369 let mut pfd = libc::pollfd {
1370 fd,
1371 events: libc::POLLIN,
1372 revents: 0,
1373 };
1374 let mut got = [0u8; 16];
1375 let n = unsafe {
1378 let n = if libc::poll(&mut pfd, 1, 10_000) > 0 {
1379 libc::read(fd, got.as_mut_ptr().cast(), got.len())
1380 } else {
1381 0
1382 };
1383 libc::close(fd);
1384 n
1385 };
1386 got[..usize::try_from(n).unwrap_or(0)].to_vec()
1387 }
1388}