Skip to main content

stuffr_cli/bench/
mod.rs

1//! `stuffr bench`: compares codecs on the user's own files.
2//!
3//! [`run`] wires the pieces together in order: expand the operands, check
4//! `--formats`, refuse a debug build, load and warm the input once, then
5//! measure every row strictly one after another (there is one process-wide
6//! signal-cleanup slot, so two `measure` calls must never overlap) and
7//! render the result.
8
9pub 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
31/// The refusal `main` prints, verbatim, for [`Ran::RefusedDebugBuild`] (exit
32/// 2). Not an `Error::Usage`, whose display would prefix "usage error:".
33pub const DEBUG_REFUSAL: &str =
34    "stuffr: bench: timings from a debug build are meaningless; build with --release";
35
36/// How a [`run`] that raised no error ended.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum Ran {
39    /// The rows ran and the report reached stdout.
40    Measured,
41    /// The arguments were valid, but this is a debug build, so nothing was
42    /// timed, written or created.
43    RefusedDebugBuild,
44}
45
46/// Runs `stuffr bench`. Usage problems are `Error::Usage` (exit 2); a format
47/// that fails is a failed row, never an error here.
48pub 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    // After validation, so a debug build still reports usage errors.
56    if cfg!(debug_assertions) {
57        return Ok(Ran::RefusedDebugBuild);
58    }
59    // For the whole run: SIGINT, SIGTERM and SIGHUP kill a running memory
60    // child, clear the bar, remove an armed temp file and kill bench.
61    #[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        // `measure` reports only between its timed pieces.
103        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        // Strictly after the timed runs, in untimed child processes; a
112        // failed row has nothing to measure.
113        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        // The budget every row's encoder is given; no governor is one.
134        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
153/// The files as the memory children read them: each with the length it
154/// had at load (the bytes read, for input held in memory).
155fn 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
169/// Whether the bar line is on stderr now (drawn and not yet cleared). The
170/// signal hook reads it, so it is an atomic, not the meter's own field.
171static BAR_DRAWN: AtomicBool = AtomicBool::new(false);
172
173/// What clears the bar line: back to its start, erase to the end.
174const CLEAR_LINE: &[u8] = b"\r\x1b[K";
175
176/// The pre-exit hook `compat::host` runs on SIGINT, SIGTERM and SIGHUP
177/// before it removes an armed temp file and re-raises: kill the memory
178/// child, then clear the bar. In a signal handler: `kill`, `write(2)` and
179/// atomics only.
180#[cfg(unix)]
181extern "C" fn on_run_signal() {
182    pre_exit(
183        &memory::RUNNING_CHILD,
184        &BAR_DRAWN,
185        // SAFETY: kill is async-signal-safe; `pre_exit` passes only a
186        // positive pid, never 0 or -1 (a process group, everyone).
187        |pid| unsafe {
188            libc::kill(pid, libc::SIGKILL);
189        },
190        // SAFETY: write is async-signal-safe; the bytes are a static.
191        || unsafe {
192            libc::write(
193                libc::STDERR_FILENO,
194                CLEAR_LINE.as_ptr().cast(),
195                CLEAR_LINE.len(),
196            );
197        },
198    );
199}
200
201/// The hook's steps, in order: `kill` the running child (a positive pid
202/// in `child`; 0 is none), then `clear` the bar when `drawn` says it is up.
203#[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
214/// The least time between two redraws of the bar, in nanoseconds.
215const REDRAW_NS: u64 = 100_000_000;
216
217/// The bar's cells.
218const CELLS: u64 = 20;
219
220/// The spinner after the bar, one frame per redraw.
221const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
222
223/// The row a bar line is about.
224#[derive(Clone, Copy, Debug)]
225struct RowLabel {
226    k: usize,
227    n: usize,
228    format: &'static str,
229    level: Option<i32>,
230}
231
232/// The progress meter on stderr: one bar line for the row being measured,
233/// redrawn from `measure`'s reports (which come only between its timed
234/// pieces) at most every [`REDRAW_NS`], and cleared before anything reaches
235/// stdout. `out` is `None` when the meter is off.
236struct Meter {
237    out: Option<Box<dyn Write>>,
238    /// Read only between timed pieces, for the throttle.
239    clock: Box<dyn Clock>,
240    repeats: u32,
241    row: Option<RowLabel>,
242    /// The repeat in progress, counted from its opening report.
243    repeat: u32,
244    /// When this row's bar was last drawn; `None` before its first draw.
245    drawn_at: Option<u64>,
246    frame: usize,
247    shown: bool,
248    /// `shown`, for the signal hook: [`BAR_DRAWN`] outside tests.
249    drawn: &'static AtomicBool,
250    /// The terminal's width in columns, asked at each draw.
251    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    /// The flag to keep `shown` in, instead of [`BAR_DRAWN`].
276    #[cfg(test)]
277    fn with_drawn_flag(mut self, drawn: &'static AtomicBool) -> Self {
278        self.drawn = drawn;
279        self
280    }
281
282    /// The row's memory phase: `[k/N] format level  memory`, drawn at
283    /// once. The children report no progress, so there is no bar.
284    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        // Up before the write: a signal mid-write still clears the line.
292        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    /// The next row; its first report is drawn at once.
299    fn row(&mut self, row: RowLabel) {
300        self.row = Some(row);
301        self.repeat = 0;
302        self.drawn_at = None;
303    }
304
305    /// One report from `measure`. `(Compress, 0, _)` opens a repeat.
306    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        // One column short of the width: a line that wrapped would leave
322        // its first part behind on the line above at the next redraw, and
323        // one filling the last column leaves some terminals pending a wrap.
324        let line = fit(&line, (self.width)().saturating_sub(1));
325        // Up before the write: a signal mid-write still clears the line.
326        self.drawn.store(true, Ordering::SeqCst);
327        // `\x1b[K` after the text clears what a longer previous line left.
328        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            // Down first: a signal between the two finds the line cleared
340            // or about to be, and clearing twice is harmless.
341            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
349/// `[k/N] <format> <level>  <phase> ▕<bar>▏<spinner> <pct>%`, `<phase>`
350/// followed by ` r/N` when `repeat` is given. `done` past `total` counts
351/// as `total`; a `total` of 0 is 100%, with nothing divided.
352fn 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    // Widened, so `done * 100` cannot overflow; no division when total is 0.
362    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/// stderr's width in columns (`TIOCGWINSZ`); 80 when the ioctl fails or
391/// reports no width.
392#[cfg(unix)]
393fn terminal_width() -> usize {
394    // SAFETY: winsize is plain data, filled by the ioctl.
395    let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
396    // SAFETY: TIOCGWINSZ writes one winsize we own.
397    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/// 80 columns: no window-size query off unix.
406#[cfg(not(unix))]
407fn terminal_width() -> usize {
408    80
409}
410
411/// The directories a pattern spells before its first glob character: the
412/// part up to the last `/` before it (`/` itself when that is the first
413/// character). `None` when the glob character is in the first component.
414fn 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
420/// The bar line fitted to `cols` columns: whole when it fits; otherwise
421/// the row's label alone (`[k/N] format level`, the bar dropped), itself
422/// cut to `cols` when even that is too wide. Every character [`bar_line`]
423/// draws is one column wide.
424fn fit(line: &str, cols: usize) -> String {
425    if line.chars().count() <= cols {
426        return line.to_string();
427    }
428    // The label ends at the two spaces before the phase.
429    let label = line.split_once("  ").map_or(line, |(l, _)| l);
430    truncate_cols(label, cols).to_string()
431}
432
433/// `s` cut to its first `cols` columns, one per character (never inside
434/// a character's bytes).
435fn 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/// One row to run: `level` is what `measure` gets (`None`: the encoder's
443/// own default), `label` what the table shows.
444#[derive(Clone, Copy, Debug, PartialEq, Eq)]
445struct Planned {
446    format: &'static str,
447    level: Option<i32>,
448    label: Option<i32>,
449}
450
451/// One default row per codec, or with `sweep` one row per sweep level; a
452/// codec without levels gets its one row either way.
453fn 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
473/// The codecs to run: `names` in order without repeats, each checked, or
474/// every benchable codec when none are named.
475fn 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
496/// Why a registered format is not in `benchable_codecs`.
497fn 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
508/// The reason, from the matrix facts: a container, a codec that cannot
509/// write, or (`benchable_codecs`' remaining filter) a weak encoder.
510fn 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
522/// The operands as files, in order. An existing path is used as is; a
523/// missing one with glob characters is expanded (sorted, dot files only
524/// when the pattern spells the dot, as a shell does). Directories a pattern
525/// matches are passed over; one named outright, or the only kind of match a
526/// pattern has, is refused. A FIFO, device or socket, named or matched, is
527/// refused from its metadata (it is never opened): bench needs each file's
528/// size up front. A symlink counts as what it points to. A path that
529/// cannot be read, named, matched or on a pattern's literal prefix, is
530/// refused naming it (exit 2), never reported as no match.
531fn 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        // A dangling symlink does not "exist" but is named: it is reported
536        // as unreadable, not as a missing path; so is a named path whose
537        // lookup fails for any reason but its absence (a directory without
538        // search permission on the way). A pattern goes on to the glob,
539        // which reports such a directory itself.
540        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            // glob drops a literal directory it cannot stat, so a prefix
577            // under an unreadable directory looks like no match.
578            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
595/// `path` if it is (or links to) a regular file, else the usage error. A
596/// path whose metadata cannot be read (a dangling symlink, no permission)
597/// is a usage error too, and so is a regular file that cannot be opened
598/// (it is opened once here, and closed, so a debug build reports it too).
599fn 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    /// A scratch directory `stuffr-benchrun-<pid>-<n>`, removed on drop.
632    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        // Matched by a pattern, beside a real file.
771        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    /// Restores the mode so `Scratch`'s removal can enter the directory.
779    #[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        // Root reads it anyway; there is nothing to test.
802        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        // Root reads it anyway; there is nothing to test.
821        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        // Named by a pattern, the glob reports the directory.
831        let m = usage(expand_inputs(&[s.s("locked/*.log")]));
832        assert!(m.contains(": cannot read it ("), "{m}");
833    }
834
835    /// The bar's text goes here, for the tests to read.
836    #[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        /// Each drawn line, without its `\r` and `\x1b[K`.
852        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    /// A clock the test moves by hand.
863    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        // Past the total is still 100%, never more.
901        assert_eq!(
902            bar_line(&lz4, Phase::Decompress, None, 2000, 1000, 9),
903            "[8/8] lz4 -  decompress ▕████████████████████▏⠏ 100%"
904        );
905        // The spinner wraps after its ten frames.
906        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        // Through the meter: the first report of a row draws at once, the
912        // next ones at most every 100 ms, the spinner one frame per draw.
913        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        // A new row draws at once, whatever the clock says.
938        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        // The row's first report draws at once, full.
981        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        // No codec in today's builds is read-only, so the classifier is
1013        // tested on the facts rather than on a registered format.
1014        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        // Root opens it anyway; there is nothing to test.
1031        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    /// glob drops a literal component it cannot stat, so a pattern under
1042    /// an unreadable directory would match nothing; the literal prefix is
1043    /// checked instead.
1044    #[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        // Root reads it anyway; there is nothing to test.
1055        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        // A prefix that is simply absent is still no match.
1065        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        // 54 columns, all of them one column wide.
1092        assert_eq!(full.chars().count(), 54);
1093        assert_eq!(fit(&full, 80), full);
1094        assert_eq!(fit(&full, 54), full);
1095        // Too narrow for the bar: the row's label alone.
1096        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        // Narrower than the label: the label is cut too.
1100        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        // Each block, bar end and spinner is three bytes and one column.
1118        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        // One column kept free, so the cursor never sits past the edge.
1138        assert_eq!(out.lines(), vec!["[3/8] xz 6"]);
1139    }
1140
1141    /// A clock that counts its reads.
1142    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    /// Off (no terminal, or a machine-readable report), the meter neither
1152    /// writes nor reads its clock, so it costs a run nothing.
1153    #[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    /// The atomic the signal hook reads follows the meter: up from a
1171    /// row's first draw (and the memory line), down at `clear`; an off
1172    /// meter never raises it.
1173    #[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    /// The memory phase is the row's label and the word `memory`, no bar,
1214    /// fitted to the width like the bar line.
1215    #[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    /// The hook's order: the child is killed before the bar is cleared;
1247    /// no child (0) is never killed, nor is a non-positive pid (kill(0)
1248    /// and kill(-1) would reach the process group or everyone); a bar
1249    /// that is not drawn is not cleared.
1250    #[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    /// End to end in a forked child, as bench registers it: SIGINT or
1271    /// SIGQUIT with a memory child running and the bar up kills that child
1272    /// (SIGKILL), writes the clear to stderr, and the process dies of the
1273    /// signal it got (SIGQUIT re-raised at its default, core and all; the
1274    /// child's core limit is 0 so no core file is written).
1275    #[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        /// The stand-in child, killed and reaped however the test ends.
1280        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            // SAFETY: the child makes only async-signal-safe calls (signal,
1297            // setrlimit, dup2, atomics, sigaction inside
1298            // `hook_run_signals`, raise) and ends by the signal or `_exit`;
1299            // it never returns into the harness.
1300            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                    // The parent's arm and hook (another test's) are not
1313                    // ours; clearing the hook also keeps the one-hook
1314                    // debug check from panicking here.
1315                    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            // Waited for at most 10 s: a stand-in the hook missed is killed
1335            // by the guard instead of holding the test for a minute.
1336            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    /// A pipe with `FD_CLOEXEC` on both ends, so a test elsewhere that
1350    /// forks and execs meanwhile does not keep its write end open.
1351    #[cfg(unix)]
1352    fn cloexec_pipe() -> [libc::c_int; 2] {
1353        let mut fds = [0; 2];
1354        // SAFETY: pipe fills the two-element array it is given; fcntl on
1355        // the two descriptors it just made.
1356        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    /// What `fd` holds within 10 s (nothing after that, rather than a
1366    /// hang if the writer never wrote), then `fd` closed.
1367    #[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        // SAFETY: poll on one pollfd we own; read into a buffer we own;
1376        // close the descriptor this function was given.
1377        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}