Skip to main content

stuffr_cli/bench/
memory.rs

1//! Peak memory for `stuffr bench`, measured in untimed child processes.
2//!
3//! After a row's timed runs, the parent runs the row once more, once for
4//! compression and once for decompression, each in a child: bench's own
5//! executable re-run as `stuffr __bench-memory-child <job>` (see
6//! [`ChildJob::to_args`]). `getrusage`'s `ru_maxrss` is a per-process
7//! high-water mark, which is why each measurement needs a fresh process,
8//! and why one per file in `--per-file` mode (the row reports the largest).
9//!
10//! **What a child measures.** Its 64 KiB read buffer, its 64 KiB output
11//! buffer and its open files (the compress child's output file included)
12//! are made first; then it samples its peak resident set as the baseline (the
13//! high-water so far, which is its current size, since nothing has run
14//! yet), runs the codec to the end, and reports the peak minus that
15//! baseline, saturating at 0. So the number is the codec's resident working
16//! memory plus a few small fixed buffers; memory reserved but never touched
17//! does not count. The compress child streams the input from the files
18//! (each read stops at the length snapshotted at load, as in the timed
19//! runs), so an encoder that buffers its whole input (the pure-tier zstd
20//! encoder) shows memory growing with the input: that is what using it
21//! costs. The compress child writes its compressed output to a guarded
22//! temp file the parent created and armed, `stuffr-bench-<pid>-<n>`; the
23//! decompress child then reads that file through the same 64 KiB buffered
24//! source the timed runs use. So the row is compressed once more, not
25//! twice (ruling F8), and a compress child that fails leaves nothing for
26//! the decompress side, which then shows `-` too.
27//!
28//! **Same conditions as the timed runs**: level, `--threads` (through
29//! [`governor_for`]) and `--memory-limit`. The backend is not sent: the
30//! child is the same executable, so it has the same one.
31//!
32//! **The wire.** The child prints one line on stdout, `ok <bytes>` or
33//! `err <text>`, and exits 0 or 1 to match. Anything else (a crash, a
34//! signal, no line) is a failed measurement. A failure never fails the row:
35//! its cell is `-` and the text goes to [`Memory::error`]. A refusal by
36//! `--memory-limit` (an `Error::ResourceLimit`) reads `refused by
37//! --memory-limit: …`, so it is told apart from a crash.
38//!
39//! A debug build's child refuses, as `bench` does, so this path is not
40//! reachable from the CLI tests; [`Runner`] lets the parent's logic be
41//! tested with a fake, and [`child_main`] is tested in-process.
42
43use 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
58/// The hidden first argument that makes the binary a memory child. Not a
59/// clap subcommand: `main` checks for it before clap parses, so it is in no
60/// help text and the examples page's guard (which walks clap) never sees it.
61pub const CHILD_ARG: &str = "__bench-memory-child";
62
63/// What a debug build's child answers: its numbers would be meaningless.
64const DEBUG_CHILD_REFUSAL: &str = "memory runs from a debug build are meaningless";
65
66/// The pid of the memory child the parent is waiting for, 0 when none. Set
67/// once the child is spawned, cleared once it has been waited for; the
68/// signal cleanup reads it to kill the child before bench dies.
69pub static RUNNING_CHILD: AtomicI32 = AtomicI32::new(0);
70
71/// Which half of the round trip a child measures.
72#[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/// What one child runs. Compress: `files` concatenated as one stream,
88/// written to `out` (discarded when `None`). Decompress: exactly one file,
89/// the compressed copy; `out` is `None`.
90#[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
101/// `-` for an absent level or thread count on the wire.
102const NONE: &str = "-";
103
104impl ChildJob {
105    /// The arguments after [`CHILD_ARG`]:
106    /// `<side> <codec> <level|-> <threads|-> <memory-limit> <out|-> [<len> <path>]…`,
107    /// `side` being `compress` or `decompress`, every number in decimal.
108    /// The decompress side ignores the level (the parent sends `-`). `out`
109    /// is `-` for none; the parent's is always an absolute temp path.
110    /// Paths go as they are (not through UTF-8), each its own argument.
111    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    /// Parses [`ChildJob::to_args`]'s form back. The codec must be one of
131    /// this build's.
132    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    /// The plan the timed runs used, as far as a child needs it.
181    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
191/// `name` as this build's codec id.
192fn 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
201/// A child's stdout line: `Some(Ok(n))` for `ok <n>`, `Some(Err(text))` for
202/// `err <text>` (text not empty), `None` for anything else.
203pub 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
215/// A child's result as its one stdout line (no newline inside).
216fn 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
223/// How a measurement failure reads in `mem_error`: a refusal by
224/// `--memory-limit` (`ResourceLimit`, which is also what an allocation
225/// failure becomes) says so; everything else is its own message.
226pub 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
233/// A finished child: its reply when it gave a well-formed one and its exit
234/// status agrees (0 for `ok`, failure for `err`); otherwise a failure
235/// naming `status` and the first line of its stderr, if any.
236pub 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
250/// The hidden entry `main` calls first: `None` unless `args[1]` is
251/// [`CHILD_ARG`]; otherwise runs the child, prints its reply line and
252/// returns its exit code. A debug build refuses.
253pub 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    // A malformed job is a usage error (exit 2): exit 1 is reserved for
258    // stuffr itself failing. The parent takes any non-zero exit with `err`.
259    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
276/// The child: runs `job` and returns its peak resident memory above the
277/// baseline (see the module doc), in bytes.
278pub 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
310/// Streams `files` through the encoder into `out` (the parent's temp file,
311/// which it already created: opened here, truncated), or into a discarding
312/// sink when there is none.
313fn 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
338/// Opens the parent's temp copy, by the name it was handed (HOST-9): the
339/// parent works through the descriptor it created, but a child is another
340/// process. Opened `O_NOFOLLOW` (a symlink put at the name fails, ELOOP),
341/// `O_NONBLOCK` (a FIFO put there cannot hang the open), never created and
342/// never `O_TRUNC`; then `fstat` must show a regular file with one link,
343/// owned by this user, before anything is written — the writer truncates
344/// it only then, through the checked descriptor.
345///
346/// Why not compare its (dev, ino) with the parent's: the copy lives in a
347/// temp directory, where another user can remove a file of ours only if the
348/// directory is not sticky, and can put there only a file of THEIR own
349/// (refused by the owner check) or a link (refused by `O_NOFOLLOW`, or by
350/// the link count for a hard link to a file of ours). What the identity
351/// would add is defence against this same user, who owns every file
352/// involved anyway; it would cost two fields on the child's command line.
353fn 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        // SAFETY: geteuid has no preconditions and cannot fail.
371        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
389/// Reads `copy` through the decoder to its end, discarding the output.
390fn 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(&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
407/// Writes all of `f` (`snap` opened) to `sink` through `buf`; a file that
408/// ends short of its loaded length is an error, as in the timed runs.
409fn 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
430/// Whether this platform's memory figures have been verified: macOS
431/// (`ru_maxrss`) and Linux (`/proc/self/status`). Elsewhere (the BSDs, and
432/// off unix) bench reports memory as not measured (ruling F9).
433pub const MEASURES: bool = cfg!(any(target_vendor = "apple", target_os = "linux"));
434
435/// The resident size just before the codec starts, in bytes.
436///
437/// macOS: `ru_maxrss`, the high-water so far, which is the current size
438/// since nothing has run yet. Linux: `VmRSS` from `/proc/self/status`, not
439/// `ru_maxrss`: exec copies the spawning process's high-water mark into the
440/// new image's `ru_maxrss` (fs/exec.c, `setmax_mm_hiwater_rss`), so there it
441/// starts at bench's own lifetime peak and would swallow the codec's.
442fn 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/// The address space's peak resident size so far, in bytes. macOS:
454/// `ru_maxrss` (bytes there). Linux: `VmHWM` from `/proc/self/status`, the
455/// high-water of this address space only, fresh at exec.
456#[cfg(target_vendor = "apple")]
457fn peak_rss() -> io::Result<u64> {
458    // SAFETY: `getrusage` only writes the `rusage` it is given, which is
459    // plain data and valid zeroed.
460    let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
461    // SAFETY: as above; `usage` is a valid, writable `rusage`.
462    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/// Elsewhere memory is not measured; bench never starts a child there.
474#[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/// `field` (with its colon, e.g. `VmHWM:`) of `/proc/self/status`, given
483/// there in kB, as bytes. Read into a buffer on the stack, so sampling
484/// allocates nothing on the heap that the codec's figure could include.
485#[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/// The number on the line starting with `field` in a `/proc/<pid>/status`
505/// text (`VmHWM:\t   1234 kB`), in kB.
506#[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
519/// Runs one child job and returns its reply. The real one spawns a
520/// process ([`ProcessRunner`]); tests inject a fake.
521pub trait Runner {
522    fn run(&mut self, job: &ChildJob) -> Result<u64, String>;
523}
524
525/// Runs each job as `<this executable> __bench-memory-child …`, with stdin
526/// closed and stdout and stderr captured, keeping its pid in
527/// [`RUNNING_CHILD`] while it runs. The executable is resolved once, when
528/// the runner is made at the start of the run, and reused for every child.
529pub 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        // Bench's signals are held back from the spawn until the pid is
552        // stored, so a signal sent to bench alone in between is delivered
553        // with the pid known, and the cleanup kills the child (no orphan).
554        // The child does not inherit the mask: std resets it before exec.
555        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        // Known window, accepted (ruling F7): `wait_with_output` reaps the
568        // child before the clear below, so a signal arriving in between
569        // makes the cleanup `kill()` a pid that is no longer ours. It lasts
570        // microseconds and pids are handed out sequentially, so reuse
571        // inside it is theoretical. Clearing before the wait instead would
572        // leave the child running (orphaned) on a bare SIGTERM to bench.
573        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
585/// [`measure_memory_with`] with real child processes (`runner`). Only
586/// where [`MEASURES`]: elsewhere the caller reports memory as not measured.
587pub 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
597/// One row's memory under `plan`: a compress child and a decompress child
598/// per stream (`plan.mode`: one stream over every file, or one per file),
599/// reporting each side's largest peak. Per stream the parent creates an
600/// armed temp file, the compress child writes the compressed stream into
601/// it, and the decompress child reads it back; it is removed before the
602/// next stream begins. Strictly after the row's timed runs.
603///
604/// A side whose child fails for any stream is `None`, and the first
605/// failure's text, prefixed by its side, is `error`. A failed decompress
606/// child leaves the compress side standing; a failed compress child (or a
607/// temp file that cannot be made) leaves no copy, so both sides are `None`.
608/// Never an error of its own.
609pub 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    // bench always has at least one input; with none there is nothing to
617    // measure, and a zero would wrongly win "least memory".
618    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    // The decompress side has no level: it is sent as `-`.
629    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        // The copy lives until this stream's decompress child is done; the
648        // compress child opens it by name, so the parent's handle closes.
649        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    /// A scratch directory `stuffr-benchmem-<pid>-<n>`, removed on drop.
705    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    /// Serialises the tests that arm the process-wide signal cleanup (the
734    /// parent's temp copies are armed).
735    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    /// A runner that answers from `answer` and records every job it saw,
792    /// with whether each job's files (and output) existed while it ran. A
793    /// compress job that succeeds writes a stand-in copy to its output, as
794    /// the real child writes the compressed stream there.
795    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        // Not a codec of this build, a bad side, a missing output, a
875        // missing length, a bad level.
876        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        // The kernel pads the numbers with spaces; built, not spelled, so
891        // no literal carries a run of them.
892        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    /// The env vars that make [`memory_probe`] run, and the prefix its
929    /// reply line carries in libtest's captured output.
930    #[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    /// Not a test of its own: [`fresh_processes_measure_xz_9_above_lz4`]
940    /// re-runs this test binary with only this test, so the compress child
941    /// it runs has a process (and an `ru_maxrss` high-water mark) to
942    /// itself. Prints the child's reply line after [`PROBE_MARK`]. Does
943    /// nothing without the env vars.
944    #[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    /// The compress child's figures for xz -9 and lz4 over 1 MiB, each
967    /// from a fresh process running [`memory_probe`]: xz -9's encoder (a
968    /// 64 MiB dictionary's hash table) touches more than lz4's. Each figure
969    /// is that process's own peak above its own baseline, so no other test
970    /// can move it.
971    #[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    /// `input` compressed by the compress child (in-process) into the
1010    /// existing file `name` in `dir`, as the parent's temp file is filled.
1011    #[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    /// HOST-9: the child opens the parent's copy by name, so it refuses
1038    /// what someone else could have put there: a symlink (the victim is
1039    /// untouched), a second hard link, a FIFO (without hanging). The real
1040    /// copy — a regular file of ours with one link — is emptied for writing.
1041    #[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        // SAFETY: a valid NUL-terminated path.
1061        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(&copy.path, true).unwrap());
1065        assert_eq!(fs::metadata(&copy.path).unwrap().len(), 0, "emptied");
1066    }
1067
1068    /// The compress child writes the compressed stream to its output file
1069    /// (truncating what was there), and the decompress child runs to the
1070    /// end over it and gets the input back. Only that each succeeds is
1071    /// checked of the figures: in-process, they are this whole test
1072    /// binary's high-water mark, shared with every test beside it.
1073    #[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(&copy.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        // Compress answers 100 x the file's length; decompress answers by
1120        // order: 5, 9, 2.
1121        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        // One child pair per file, each child given exactly its one file
1136        // (compress: the input, writing to the temp copy; decompress: that
1137        // copy, present, at the length the compress child left it).
1138        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        // One stream: one pair, the compress child given every file.
1157        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        // The second file's compress child fails: it left no copy, so
1175        // there is no decompress run for that file and both cells are
1176        // dashes (ruling F8); the error says why.
1177        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        // No child ran after the failure: two compress, one decompress.
1196        assert_eq!(r.seen.len(), 3);
1197
1198        // A failed decompress child leaves the compress side standing
1199        // (spec call 16).
1200        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        // Both sides failing: two dashes.
1209        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        // A child that printed no reply, or exited badly, is a failure.
1215        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        // Anything else keeps its own text.
1236        let corrupt = describe(&stuffr::Error::Corrupt("bad".into()));
1237        assert!(!corrupt.contains("--memory-limit"), "{corrupt}");
1238
1239        // The parent carries the child's refusal into the row's error.
1240        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    /// The pure xz decoder refuses a header whose dictionary exceeds the
1256    /// limit; the child reports that as a refusal. (The C tier's decoder
1257    /// takes no limit, so this is pure-tier only.)
1258    #[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            // While a decompress child runs, its copy exists and is the
1291            // only bench temp file: per-file copies do not pile up.
1292            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        // A failed child removes its copy too.
1304        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}