Skip to main content

latexml_post/
graphics_cache.rs

1//! Content-addressed graphics cache.
2//!
3//! Every `convert_image` / `convert_image_svg` call rasterises a
4//! self-contained source file (PDF / EPS / PNG / SVG) with a small set
5//! of render-shaping inputs (page, density, target format). Across a
6//! canvas of arXiv submissions the same byte content recurs constantly
7//! — common journal logos, reused author-affiliation marks, and
8//! regenerated runs of the same paper. PERFORMANCE.md §5 records the
9//! `graphics` phase as **36.5%** of corpus wall, so a cache that hits
10//! even a third of the time shaves ~11% off corpus wall.
11//!
12//! ## Heritage: Perl `LaTeXML.cache`
13//!
14//! The Perl post-processor stores a tied BerkeleyDB hash named
15//! `LaTeXML.cache` per output directory. Keys are built from the
16//! processor class, the source path, and the transform string; values
17//! are `"dest|width|height"` strings. The features we mirror are
18//! listed below.
19//!
20//! - **Disable flag** (Perl: `nocache`) → env `LATEXML_GRAPHICS_CACHE_OFF`.
21//! - **Re-use across runs** (Perl: one .cache per dest dir) → global XDG cache, shared across all
22//!   conversions on the host. This is a strict superset of the Perl behaviour: cross-paper sharing,
23//!   plus reproducible content-keying instead of path-keying.
24//! - **Source-staleness check** (Perl compared source mtime to cached output mtime) → unnecessary
25//!   here: the cache key is SHA-256 of the source *bytes*, so any source edit produces a different
26//!   key.
27//! - **Cached dimensions** (Perl: width/height in the cached value) → sidecar `<hash>.<ext>.dims`
28//!   file containing `width\nheight\n`. Lookup returns `(success, dims)`; callers skip the
29//!   `read_image_dimensions` syscall on hits.
30//!
31//! Beyond Perl: content-hash keying gives stricter staleness; XDG
32//! placement gives cross-paper reuse; multi-process safety (below)
33//! lets canvas sweeps share the cache without corruption.
34//!
35//! ## Cache key
36//!
37//! SHA-256 of:
38//!   `source bytes ‖ page ‖ density ‖ target-extension`
39//!
40//! The page/density bytes are appended as 8-byte little-endian; the
41//! extension is appended as ASCII lower-case bytes. Bytes-only keying
42//! makes the cache reproducible across machines and gives near-zero
43//! collision risk for our use.
44//!
45//! ## On-disk layout
46//!
47//!   `$XDG_CACHE_HOME/latexml-oxide/graphics/<aa>/<full-hash>.<ext>`
48//!   `$XDG_CACHE_HOME/latexml-oxide/graphics/<aa>/<full-hash>.<ext>.dims`
49//!   `$XDG_CACHE_HOME/latexml-oxide/graphics/.prune.lock`
50//!
51//! Sharded by the first two hex characters of the hash to keep any one
52//! directory's entry count bounded (worst case ~1/256th of the total
53//! cache size).
54//!
55//! `$XDG_CACHE_HOME` falls back to `$HOME/.cache` per the XDG spec.
56//!
57//! ## Multi-process safety
58//!
59//! The cache is designed to be shared by parallel `cortex_worker`
60//! processes within a canvas sweep. Concurrency guarantees:
61//!
62//! 1. **Concurrent writes to the same key**: each writer renders to a private
63//!    `<final>.tmp.<pid>.<nanos>` sidecar then issues a single `rename(2)` into the final path.
64//!    `rename` is atomic on the same filesystem; if two writers race, the last `rename` wins, and
65//!    because both sources are byte-equivalent the outcome is correct. `link_or_copy` retries once
66//!    on `EEXIST` for the same reason.
67//! 2. **Concurrent reads + writes**: a reader hardlinks the cached file into the destination. POSIX
68//!    `link(2)` either succeeds (returning a new directory entry that survives any subsequent
69//!    `unlink` of the source) or fails cleanly. If the cache file is unlinked between hash
70//!    computation and link, the reader gets a miss and falls through — no data corruption.
71//! 3. **Concurrent prunes**: prune holds `flock(LOCK_EX | LOCK_NB)` on `.prune.lock`. If another
72//!    process already holds the lock, this one skips its prune attempt. Only one prune runs at a
73//!    time per machine. The prune itself walks the dir, sorts by mtime, deletes oldest until under
74//!    cap — and tolerates `ENOENT` (another writer could have re-used the entry concurrently).
75//! 4. **Hardlinked dest ↔ cache**: when `link_or_copy` hardlinks the cache file into the
76//!    destination, that hardlink is independent of the cache lifecycle. Even if a prune deletes the
77//!    cache entry afterwards, the destination's hardlink keeps the data alive on the filesystem.
78//!
79//! These guarantees hold on any POSIX filesystem. On Windows (which
80//! lacks robust `flock`), the prune lock is a best-effort `OpenOptions`
81//! create-new sentinel — same correctness, slightly more retry traffic.
82//!
83//! ## Lifecycle
84//!
85//! * **Hit**: hardlink the cache file into the destination (zero-copy on the same filesystem). If
86//!   hardlink fails (cross-filesystem, EXDEV), fall back to a regular file copy. Read the `.dims`
87//!   sidecar if present.
88//! * **Miss-then-success**: copy the produced destination back into the cache. Write the `.dims`
89//!   sidecar alongside.
90//! * **LRU prune**: each insertion checks the on-disk total against the cap
91//!   (`LATEXML_GRAPHICS_CACHE_MAX_MB`, default 2048 = 2 GB). When over cap, holds the prune lock,
92//!   sorts entries by mtime ascending, deletes until under cap. File access on read also refreshes
93//!   the mtime so frequently-hit entries survive.
94//!
95//! ## Disable / tune
96//!
97//! * `LATEXML_GRAPHICS_CACHE_OFF=1` — bypass entirely (read+write both skipped). The wrapper
98//!   devolves to the bare conversion call.
99//! * `LATEXML_GRAPHICS_CACHE_DIR=/path` — override the cache directory.
100//! * `LATEXML_GRAPHICS_CACHE_MAX_MB=N` — cache size cap.
101
102use std::{
103  fs,
104  io::Read,
105  path::{Path, PathBuf},
106  sync::{
107    OnceLock,
108    atomic::{AtomicU32, Ordering},
109  },
110};
111
112use sha2::{Digest, Sha256};
113
114const DEFAULT_MAX_MB: u64 = 2048;
115
116/// Memoised disabled flag (read env once at first call).
117fn disabled() -> bool {
118  static CELL: OnceLock<bool> = OnceLock::new();
119  *CELL.get_or_init(|| {
120    matches!(
121      std::env::var("LATEXML_GRAPHICS_CACHE_OFF")
122        .ok()
123        .as_deref()
124        .map(|s| s.trim()),
125      Some("1") | Some("true") | Some("yes")
126    )
127  })
128}
129
130/// Cache root, recomputed on each call.
131///
132/// Originally OnceLock-cached, but that bakes in whatever value the
133/// env-var has at first call and locks out any later setter. In tests
134/// (where one test binary contains both `graphics::*` and
135/// `graphics_cache::*` tests), an earlier `graphics::*` test that
136/// invokes `Graphics::process` triggers `cache_root()` with no
137/// `LATEXML_GRAPHICS_CACHE_DIR` set — pinning the cache to
138/// `~/.cache/latexml-oxide/graphics`. Subsequent `graphics_cache::*`
139/// tests then set the env var via `shared_cache_dir()` but the
140/// OnceLock ignores it, causing cache files to land outside the test
141/// dir and the assertions to fail. Re-reading the env var on each
142/// call costs ~one syscall in production (LATEXML_GRAPHICS_CACHE_DIR
143/// never changes there) — negligible compared to the disk I/O the
144/// cache layer drives.
145fn cache_root() -> Option<PathBuf> {
146  if let Ok(p) = std::env::var("LATEXML_GRAPHICS_CACHE_DIR") {
147    if !p.is_empty() {
148      return Some(PathBuf::from(p));
149    }
150  }
151  let base = std::env::var_os("XDG_CACHE_HOME")
152    .map(PathBuf::from)
153    .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?;
154  Some(base.join("latexml-oxide").join("graphics"))
155}
156
157/// Cache size cap in bytes, read once from env.
158fn max_bytes() -> u64 {
159  static CELL: OnceLock<u64> = OnceLock::new();
160  *CELL.get_or_init(|| {
161    std::env::var("LATEXML_GRAPHICS_CACHE_MAX_MB")
162      .ok()
163      .and_then(|s| s.parse::<u64>().ok())
164      .unwrap_or(DEFAULT_MAX_MB)
165      .saturating_mul(1024 * 1024)
166  })
167}
168
169static HITS: AtomicU32 = AtomicU32::new(0);
170static MISSES: AtomicU32 = AtomicU32::new(0);
171
172/// Return `(hits, misses)` since process start. Useful for telemetry
173/// and the post-run summary log line.
174pub fn stats() -> (u32, u32) { (HITS.load(Ordering::Relaxed), MISSES.load(Ordering::Relaxed)) }
175
176/// Reset stats (test-only).
177#[cfg(test)]
178pub fn reset_stats() {
179  HITS.store(0, Ordering::Relaxed);
180  MISSES.store(0, Ordering::Relaxed);
181}
182
183/// Whether a given conversion consults the shared graphics cache.
184///
185/// The cache root is *process-global, host-persistent* state: it comes
186/// from `LATEXML_GRAPHICS_CACHE_DIR`, else
187/// `$XDG_CACHE_HOME/latexml-oxide/graphics`, which survives across runs
188/// and across unrelated conversions. A caller that needs its conversions
189/// to actually *run* — rather than be served from whatever a previous
190/// run on this host happened to leave behind — must be able to opt out,
191/// and must be able to do so **without mutating the environment**
192/// (`set_var` is process-wide and racy against every other thread).
193/// Hence an explicit, caller-threaded policy rather than another env var.
194///
195/// The env kill-switch `LATEXML_GRAPHICS_CACHE_OFF=1` still applies
196/// independently, inside [`lookup`] / [`store`].
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum CachePolicy {
199  /// Consult and populate the shared cache. The production default.
200  #[default]
201  Shared,
202  /// Bypass the cache entirely: every conversion runs, nothing is read
203  /// and nothing is stored. Used where the *conversion itself* is the
204  /// observable under test, so a prior run's cached output must not be
205  /// able to substitute for it.
206  Bypass,
207}
208
209/// Render-shaping inputs that go into the cache key alongside source
210/// bytes. Two calls with the same `RenderKey` MUST produce
211/// byte-equivalent output (modulo metadata variation tools like
212/// ImageMagick are known to introduce — see `compare_outputs_strict`
213/// audit in the spawn paths).
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct RenderKey {
216  /// 1-based page selector (graphicx convention). `None` ⇒ default.
217  pub page:    Option<u32>,
218  /// DPI for raster conversions; 0 for pure vector (SVG) paths.
219  pub density: u32,
220  /// Target format, derived from destination extension (lowercase, no dot).
221  /// e.g. `"png"`, `"svg"`, `"jpg"`. Empty if missing.
222  pub ext:     &'static str,
223}
224
225/// Cached dimensions for an image. Mirrors Perl `LaTeXML.cache`
226/// `"dest|width|height"` value triple (we already have the dest path
227/// at the call site — only the dimensions need round-tripping).
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct CachedDims {
230  pub width:  u32,
231  pub height: u32,
232}
233
234/// Compute the cache-key hash for `(source_bytes, render_key)`.
235fn hash_key(source_path: &Path, key: RenderKey) -> Option<String> {
236  let mut file = fs::File::open(source_path).ok()?;
237  let mut hasher = Sha256::new();
238  // 64 KB scratch buffer; large enough to dwarf syscall overhead, small
239  // enough to keep stack/heap pressure low under high concurrency.
240  let mut buf = [0u8; 64 * 1024];
241  loop {
242    match file.read(&mut buf) {
243      Ok(0) => break,
244      Ok(n) => hasher.update(&buf[..n]),
245      Err(_) => return None,
246    }
247  }
248  hasher.update(key.page.unwrap_or(0).to_le_bytes());
249  hasher.update(key.density.to_le_bytes());
250  hasher.update(key.ext.as_bytes());
251  // sha2 0.11 returns a `hybrid_array::Array`, which (unlike 0.10's
252  // `GenericArray`) does not implement `LowerHex`, so `format!("{:x}", _)`
253  // no longer compiles. Render the digest to lowercase hex by hand — same
254  // output as the old `{:x}`, and version-agnostic.
255  use std::fmt::Write as _;
256  let digest = hasher.finalize();
257  let mut hash = String::with_capacity(digest.len() * 2);
258  for byte in digest.iter() {
259    let _ = write!(hash, "{byte:02x}");
260  }
261  Some(hash)
262}
263
264/// Build the cache file path for a given hash + extension.
265fn cache_path(root: &Path, hash: &str, ext: &str) -> PathBuf {
266  let shard = &hash[..2.min(hash.len())];
267  let mut p = root.join(shard);
268  if ext.is_empty() {
269    p.push(hash);
270  } else {
271    p.push(format!("{hash}.{ext}"));
272  }
273  p
274}
275
276/// Path to the `.dims` sidecar for a cache entry.
277fn dims_sidecar(cache_file: &Path) -> PathBuf {
278  let mut s = cache_file.as_os_str().to_owned();
279  s.push(".dims");
280  PathBuf::from(s)
281}
282
283/// Hardlink `src` → `dst`; on cross-filesystem or other failure, fall
284/// back to plain copy. Returns `true` on success.
285fn link_or_copy(src: &Path, dst: &Path) -> bool {
286  if let Some(parent) = dst.parent() {
287    if fs::create_dir_all(parent).is_err() {
288      return false;
289    }
290  }
291  // Try hardlink first (zero-copy). Hardlinking from cache to dest is
292  // safe because graphics outputs are immutable artefacts.
293  if fs::hard_link(src, dst).is_ok() {
294    return true;
295  }
296  // Hard-link can fail for cross-FS (EXDEV), permission, or because
297  // the destination already exists. Try to remove and retry once.
298  let _ = fs::remove_file(dst);
299  if fs::hard_link(src, dst).is_ok() {
300    return true;
301  }
302  // Final fallback: plain copy.
303  fs::copy(src, dst).is_ok()
304}
305
306/// Read a `.dims` sidecar if present.
307fn read_dims_sidecar(sidecar: &Path) -> Option<CachedDims> {
308  let raw = fs::read_to_string(sidecar).ok()?;
309  let mut lines = raw.split('\n').map(|s| s.trim());
310  let width = lines.next()?.parse::<u32>().ok()?;
311  let height = lines.next()?.parse::<u32>().ok()?;
312  Some(CachedDims { width, height })
313}
314
315/// Write a `.dims` sidecar via tmp+rename for atomicity. Idempotent
316/// and best-effort — failures leave no state behind.
317fn write_dims_sidecar(sidecar: &Path, dims: CachedDims) {
318  if let Some(parent) = sidecar.parent() {
319    if fs::create_dir_all(parent).is_err() {
320      return;
321    }
322  }
323  let pid = std::process::id();
324  let nanos = std::time::SystemTime::now()
325    .duration_since(std::time::UNIX_EPOCH)
326    .map(|d| d.as_nanos())
327    .unwrap_or(0);
328  let mut tmp = sidecar.as_os_str().to_owned();
329  tmp.push(format!(".tmp.{pid}.{nanos}"));
330  let tmp_path = PathBuf::from(tmp);
331  let body = format!("{}\n{}\n", dims.width, dims.height);
332  if fs::write(&tmp_path, body).is_err() {
333    let _ = fs::remove_file(&tmp_path);
334    return;
335  }
336  if fs::rename(&tmp_path, sidecar).is_err() {
337    let _ = fs::remove_file(&tmp_path);
338  }
339}
340
341/// Result of a cache lookup.
342#[derive(Debug, Clone, Copy)]
343pub struct CacheHit {
344  pub dims: Option<CachedDims>,
345}
346
347/// Look the cache up. On hit: hardlink/copy into `dest`, refresh the
348/// cache entry's mtime, return `Some(CacheHit{dims})`. On miss or any
349/// I/O hiccup, return `None` so the caller falls through to a real
350/// conversion.
351pub fn lookup(source: &str, dest: &str, key: RenderKey) -> Option<CacheHit> {
352  if disabled() {
353    return None;
354  }
355  let root = cache_root()?;
356  let hash = hash_key(Path::new(source), key)?;
357  let cached = cache_path(&root, &hash, key.ext);
358  if !cached.exists() {
359    MISSES.fetch_add(1, Ordering::Relaxed);
360    return None;
361  }
362  if !link_or_copy(&cached, Path::new(dest)) {
363    MISSES.fetch_add(1, Ordering::Relaxed);
364    return None;
365  }
366  // Refresh mtime so the LRU prune doesn't evict an active entry.
367  // Errors here are non-fatal; the hit already succeeded.
368  let _ = touch_now(&cached);
369  let dims = read_dims_sidecar(&dims_sidecar(&cached));
370  HITS.fetch_add(1, Ordering::Relaxed);
371  Some(CacheHit { dims })
372}
373
374/// Insert `dest` (and optionally its dimensions) into the cache under
375/// `(source, key)`. Idempotent and best-effort: any I/O failure leaves
376/// the cache unchanged.
377pub fn store(source: &str, dest: &str, key: RenderKey, dims: Option<CachedDims>) {
378  if disabled() {
379    return;
380  }
381  let Some(root) = cache_root() else { return };
382  let Some(hash) = hash_key(Path::new(source), key) else {
383    return;
384  };
385  let cached = cache_path(&root, &hash, key.ext);
386  // Atomic install: write to .tmp.<pid>.<nanos>, rename into place.
387  // Concurrent writers each get a private tmp and race the rename —
388  // last rename wins, both sources are byte-equivalent.
389  let pid = std::process::id();
390  let nanos = std::time::SystemTime::now()
391    .duration_since(std::time::UNIX_EPOCH)
392    .map(|d| d.as_nanos())
393    .unwrap_or(0);
394  let mut tmp = cached.clone();
395  let mut filename = cached
396    .file_name()
397    .map(|n| n.to_string_lossy().into_owned())
398    .unwrap_or_else(|| hash.clone());
399  filename.push_str(&format!(".tmp.{pid}.{nanos}"));
400  tmp.set_file_name(filename);
401  if !link_or_copy(Path::new(dest), &tmp) {
402    return;
403  }
404  if !cached.exists() {
405    // rename is atomic on the same filesystem; failures are non-fatal.
406    let _ = fs::rename(&tmp, &cached);
407  } else {
408    // Cache file already present (race with another writer); clean up.
409    let _ = fs::remove_file(&tmp);
410    let _ = touch_now(&cached);
411  }
412  if let Some(d) = dims {
413    write_dims_sidecar(&dims_sidecar(&cached), d);
414  }
415  // Best-effort LRU prune after insert (process-locked).
416  prune_if_over_cap(&root);
417}
418
419fn touch_now(p: &Path) -> std::io::Result<()> {
420  let now = std::time::SystemTime::now();
421  let f = fs::File::options().write(true).open(p)?;
422  f.set_modified(now)?;
423  drop(f);
424  Ok(())
425}
426
427/// Acquire an advisory exclusive lock on the prune sentinel file. The
428/// lock is held for the lifetime of the returned handle. `None` =>
429/// another process already holds the lock; skip the prune.
430///
431/// On Unix uses `flock(LOCK_EX | LOCK_NB)`. On Windows the lock is a
432/// best-effort `create_new` sentinel; multi-process correctness is
433/// still preserved because the prune itself is `ENOENT`-tolerant.
434#[cfg(unix)]
435fn acquire_prune_lock(root: &Path) -> Option<fs::File> {
436  use std::os::fd::AsRawFd;
437  let lock_path = root.join(".prune.lock");
438  let _ = fs::create_dir_all(root);
439  let file = fs::File::options()
440    .create(true)
441    .write(true)
442    .truncate(false)
443    .open(&lock_path)
444    .ok()?;
445  // SAFETY: fd is an owned, open file descriptor for the lock file; flock(2)
446  // operates on it without aliasing Rust memory.
447  let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
448  if rc == 0 { Some(file) } else { None }
449}
450
451#[cfg(not(unix))]
452fn acquire_prune_lock(root: &Path) -> Option<fs::File> {
453  let lock_path = root.join(".prune.lock");
454  let _ = fs::create_dir_all(root);
455  fs::File::options()
456    .create_new(true)
457    .write(true)
458    .open(&lock_path)
459    .ok()
460}
461
462#[cfg(not(unix))]
463fn release_prune_lock(root: &Path, _f: fs::File) {
464  let _ = fs::remove_file(root.join(".prune.lock"));
465}
466
467/// Walk the cache, total up sizes, and if over the cap delete oldest
468/// entries (by mtime) until under. Errors are non-fatal. Only one
469/// process performs a prune at a time (advisory lock).
470fn prune_if_over_cap(root: &Path) {
471  let cap = max_bytes();
472  if cap == 0 {
473    return;
474  }
475  let Some(_lock) = acquire_prune_lock(root) else {
476    // Another process is pruning; let it work.
477    return;
478  };
479  let mut entries: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
480  let mut total: u64 = 0;
481  let Ok(shard_iter) = fs::read_dir(root) else {
482    return;
483  };
484  for shard in shard_iter.flatten() {
485    let path = shard.path();
486    let Ok(meta) = shard.metadata() else { continue };
487    // Skip non-dirs (e.g. .prune.lock).
488    if !meta.is_dir() {
489      continue;
490    }
491    let Ok(entry_iter) = fs::read_dir(&path) else {
492      continue;
493    };
494    for entry in entry_iter.flatten() {
495      let path = entry.path();
496      let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
497        continue;
498      };
499      // Skip in-flight tmp sidecars and dim metadata (they're tiny
500      // and follow their parent's lifecycle when removed).
501      if name.contains(".tmp.") || name.ends_with(".dims") {
502        continue;
503      }
504      let Ok(meta) = entry.metadata() else { continue };
505      let size = meta.len();
506      let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
507      total = total.saturating_add(size);
508      entries.push((path, size, mtime));
509    }
510  }
511  if total <= cap {
512    // Lock auto-released when `_lock` goes out of scope at fn end.
513    return;
514  }
515  entries.sort_by_key(|(_, _, t)| *t);
516  let mut to_free = total.saturating_sub(cap);
517  for (path, size, _) in entries {
518    if to_free == 0 {
519      break;
520    }
521    // Tolerate ENOENT: another writer may have raced this entry away.
522    if fs::remove_file(&path).is_ok() {
523      // Also drop the .dims sidecar if present (best-effort).
524      let mut dims = path.into_os_string();
525      dims.push(".dims");
526      let _ = fs::remove_file(PathBuf::from(dims));
527      to_free = to_free.saturating_sub(size);
528    }
529  }
530  // _lock drops here, releasing the flock.
531}
532
533/// Three-state result from a cached conversion. Disambiguates the two
534/// failure modes that would otherwise collapse to `None`:
535///
536/// * `Ok { dims: Some(_) }` — conversion succeeded (or hit), dims known
537/// * `Ok { dims: None }`     — conversion succeeded but dims unknown
538/// * `Failed`                — conversion did not produce a usable output
539#[derive(Debug, Clone, Copy)]
540pub enum ConvertResult {
541  Ok { dims: Option<CachedDims> },
542  Failed,
543}
544
545impl ConvertResult {
546  pub fn is_ok(&self) -> bool { matches!(self, ConvertResult::Ok { .. }) }
547  pub fn dims(&self) -> Option<CachedDims> {
548    match self {
549      ConvertResult::Ok { dims } => *dims,
550      ConvertResult::Failed => None,
551    }
552  }
553}
554
555/// Cache-aware wrapper around any `(source, dest) -> bool` conversion
556/// that also wants to round-trip dimensions through the cache.
557///
558/// `measure` runs on the freshly-produced `dest` and feeds the cache
559/// for next time. On a cache hit `measure` is NOT called — the cached
560/// `.dims` value is returned instead.
561///
562/// Callers without a dimension hook can pass `measure = || None` to
563/// get the bytes-only cache behaviour.
564///
565/// `policy` decides whether the shared cache is consulted at all; see
566/// [`CachePolicy`].
567pub fn with_cache_dims<F, M>(
568  policy: CachePolicy,
569  source: &str,
570  dest: &str,
571  key: RenderKey,
572  convert: F,
573  measure: M,
574) -> ConvertResult
575where
576  F: FnOnce() -> bool,
577  M: FnOnce() -> Option<CachedDims>,
578{
579  if policy == CachePolicy::Bypass {
580    if !convert() {
581      return ConvertResult::Failed;
582    }
583    return ConvertResult::Ok { dims: measure() };
584  }
585  if let Some(hit) = lookup(source, dest, key) {
586    if let Some(d) = hit.dims {
587      return ConvertResult::Ok { dims: Some(d) };
588    }
589    // Dimensions sidecar missing or unreadable — measure now and
590    // attach so future hits get it for free.
591    let m = measure();
592    if let Some(d) = m {
593      if let (Some(root), Some(hash)) = (cache_root(), hash_key(Path::new(source), key)) {
594        let cached = cache_path(&root, &hash, key.ext);
595        if cached.exists() {
596          write_dims_sidecar(&dims_sidecar(&cached), d);
597        }
598      }
599    }
600    return ConvertResult::Ok { dims: m };
601  }
602  if !convert() {
603    return ConvertResult::Failed;
604  }
605  let dims = measure();
606  store(source, dest, key, dims);
607  ConvertResult::Ok { dims }
608}
609
610/// Bytes-only cache wrapper. Use when the caller doesn't need
611/// dimensions cached (e.g. SVG path where viewBox dims are cheap to
612/// re-read from disk). Returns `true` on success.
613///
614/// `policy` decides whether the shared cache is consulted at all; see
615/// [`CachePolicy`].
616pub fn with_cache<F>(
617  policy: CachePolicy,
618  source: &str,
619  dest: &str,
620  key: RenderKey,
621  convert: F,
622) -> bool
623where
624  F: FnOnce() -> bool,
625{
626  if policy == CachePolicy::Bypass {
627    return convert();
628  }
629  if lookup(source, dest, key).is_some() {
630    return true;
631  }
632  let ok = convert();
633  if ok {
634    store(source, dest, key, None);
635  }
636  ok
637}
638
639#[cfg(test)]
640mod tests {
641  use std::sync::atomic::{AtomicU32, Ordering};
642
643  use super::*;
644
645  fn temp_dir(label: &str) -> PathBuf {
646    let nanos = std::time::SystemTime::now()
647      .duration_since(std::time::UNIX_EPOCH)
648      .map(|d| d.as_nanos())
649      .unwrap_or(0);
650    let p = std::env::temp_dir().join(format!("gcache-{label}-{nanos}"));
651    fs::create_dir_all(&p).unwrap();
652    p
653  }
654
655  fn write_bytes(path: &Path, bytes: &[u8]) {
656    if let Some(parent) = path.parent() {
657      fs::create_dir_all(parent).unwrap();
658    }
659    fs::write(path, bytes).unwrap();
660  }
661
662  // These tests exercise the cache itself, so they need a real cache
663  // root — pointed at a scratch directory rather than the developer's
664  // `~/.cache`. One directory is shared across them (with unique source
665  // bytes per test) because the root is read from the environment, which
666  // is process-global.
667  //
668  // The env write is deliberately NOT undone: it stays set for the rest
669  // of the binary's life, and `LATEXML_GRAPHICS_CACHE_DIR` is leaked into
670  // any test that runs afterwards. That is tolerable only because no
671  // other test's correctness depends on the cache root any more — the one
672  // that used to, `graphics::tests::process_coalesces_only_matching_
673  // conversion_options`, now bypasses the cache explicitly via
674  // `CachePolicy::Bypass` instead of racing this setter (issue 401).
675  // Taking the shared `env_lock` keeps the write from interleaving with
676  // that test's `PATH` window.
677  static SHARED_DIR: OnceLock<PathBuf> = OnceLock::new();
678  fn shared_cache_dir() -> &'static Path {
679    SHARED_DIR.get_or_init(|| {
680      let dir = temp_dir("shared");
681      let _lock = crate::test_env::env_lock()
682        .lock()
683        .unwrap_or_else(|e| e.into_inner());
684      // SAFETY: `set_var` is `unsafe` in edition 2024 because concurrent
685      // env access from another thread is a data race. We hold the
686      // binary-wide `env_lock`, so no other env-mutating test runs
687      // concurrently, and only the first caller runs this initializer.
688      unsafe {
689        std::env::set_var("LATEXML_GRAPHICS_CACHE_DIR", &dir);
690      }
691      dir
692    })
693  }
694
695  static SUFFIX: AtomicU32 = AtomicU32::new(0);
696  fn unique_source_bytes(label: &str) -> Vec<u8> {
697    let n = SUFFIX.fetch_add(1, Ordering::Relaxed);
698    format!("{label}::{n}\n").into_bytes()
699  }
700
701  #[test]
702  fn hash_changes_with_render_key() {
703    let dir = temp_dir("hash");
704    let src = dir.join("a.bin");
705    write_bytes(&src, b"hello");
706    let h_png = hash_key(&src, RenderKey {
707      page:    Some(1),
708      density: 90,
709      ext:     "png",
710    })
711    .unwrap();
712    let h_svg = hash_key(&src, RenderKey {
713      page:    Some(1),
714      density: 0,
715      ext:     "svg",
716    })
717    .unwrap();
718    let h_page2 = hash_key(&src, RenderKey {
719      page:    Some(2),
720      density: 90,
721      ext:     "png",
722    })
723    .unwrap();
724    assert_ne!(h_png, h_svg);
725    assert_ne!(h_png, h_page2);
726  }
727
728  #[test]
729  fn first_call_misses_second_call_hits() {
730    let _ = shared_cache_dir();
731    reset_stats();
732    let dir = temp_dir("hit");
733    let bytes = unique_source_bytes("hit");
734    let src = dir.join("src.bin");
735    write_bytes(&src, &bytes);
736    let dest1 = dir.join("out1.png");
737    let dest2 = dir.join("out2.png");
738    let key = RenderKey {
739      page:    None,
740      density: 90,
741      ext:     "png",
742    };
743    let mut spawn_calls = 0u32;
744    let ok1 = with_cache(
745      CachePolicy::Shared,
746      src.to_str().unwrap(),
747      dest1.to_str().unwrap(),
748      key,
749      || {
750        spawn_calls += 1;
751        fs::write(&dest1, b"converted-output").unwrap();
752        true
753      },
754    );
755    assert!(ok1);
756    assert_eq!(spawn_calls, 1, "first call must spawn");
757
758    let ok2 = with_cache(
759      CachePolicy::Shared,
760      src.to_str().unwrap(),
761      dest2.to_str().unwrap(),
762      key,
763      || {
764        spawn_calls += 1;
765        true
766      },
767    );
768    assert!(ok2);
769    assert_eq!(spawn_calls, 1, "second call must NOT spawn");
770    assert_eq!(
771      fs::read(&dest2).unwrap(),
772      b"converted-output",
773      "cache hit must deliver the original bytes"
774    );
775  }
776
777  #[test]
778  fn dimensions_round_trip_through_cache() {
779    let _ = shared_cache_dir();
780    let dir = temp_dir("dims");
781    let bytes = unique_source_bytes("dims");
782    let src = dir.join("src.bin");
783    write_bytes(&src, &bytes);
784    let dest1 = dir.join("out1.png");
785    let dest2 = dir.join("out2.png");
786    let key = RenderKey {
787      page:    None,
788      density: 90,
789      ext:     "png",
790    };
791    let mut measure_calls = 0u32;
792    // First call: miss → spawn + measure → store dims.
793    let dims1 = with_cache_dims(
794      CachePolicy::Shared,
795      src.to_str().unwrap(),
796      dest1.to_str().unwrap(),
797      key,
798      || {
799        fs::write(&dest1, b"png-bytes").unwrap();
800        true
801      },
802      || {
803        measure_calls += 1;
804        Some(CachedDims { width: 640, height: 480 })
805      },
806    );
807    assert!(matches!(dims1, ConvertResult::Ok {
808      dims: Some(CachedDims { width: 640, height: 480 }),
809    }));
810    assert_eq!(measure_calls, 1, "miss measures dims");
811
812    // Second call: hit → sidecar replay, NO spawn, NO measure.
813    let dims2 = with_cache_dims(
814      CachePolicy::Shared,
815      src.to_str().unwrap(),
816      dest2.to_str().unwrap(),
817      key,
818      || {
819        panic!("hit must skip the conversion closure");
820      },
821      || {
822        measure_calls += 1;
823        Some(CachedDims { width: 999, height: 999 })
824      },
825    );
826    assert!(matches!(dims2, ConvertResult::Ok {
827      dims: Some(CachedDims { width: 640, height: 480 }),
828    }));
829    assert_eq!(measure_calls, 1, "hit replays sidecar dims, no re-measure");
830  }
831
832  #[test]
833  fn different_render_keys_dont_share_cache() {
834    let _ = shared_cache_dir();
835    let dir = temp_dir("keys");
836    let bytes = unique_source_bytes("keys");
837    let src = dir.join("src.bin");
838    write_bytes(&src, &bytes);
839    let dest_png = dir.join("out.png");
840    let dest_svg = dir.join("out.svg");
841    let key_png = RenderKey {
842      page:    None,
843      density: 90,
844      ext:     "png",
845    };
846    let key_svg = RenderKey {
847      page:    None,
848      density: 0,
849      ext:     "svg",
850    };
851    let mut calls = 0u32;
852    let ok_png = with_cache(
853      CachePolicy::Shared,
854      src.to_str().unwrap(),
855      dest_png.to_str().unwrap(),
856      key_png,
857      || {
858        calls += 1;
859        fs::write(&dest_png, b"png-bytes").unwrap();
860        true
861      },
862    );
863    let ok_svg = with_cache(
864      CachePolicy::Shared,
865      src.to_str().unwrap(),
866      dest_svg.to_str().unwrap(),
867      key_svg,
868      || {
869        calls += 1;
870        fs::write(&dest_svg, b"svg-bytes").unwrap();
871        true
872      },
873    );
874    assert!(ok_png && ok_svg);
875    assert_eq!(calls, 2, "distinct render keys must spawn separately");
876  }
877
878  #[test]
879  fn miss_failure_does_not_pollute_cache() {
880    let _ = shared_cache_dir();
881    let dir = temp_dir("miss");
882    let bytes = unique_source_bytes("miss");
883    let src = dir.join("src.bin");
884    write_bytes(&src, &bytes);
885    let dest = dir.join("out.png");
886    let key = RenderKey {
887      page:    None,
888      density: 90,
889      ext:     "png",
890    };
891    let mut calls = 0u32;
892    let ok = with_cache(
893      CachePolicy::Shared,
894      src.to_str().unwrap(),
895      dest.to_str().unwrap(),
896      key,
897      || {
898        calls += 1;
899        // simulate spawn failure: did NOT write dest, returned false
900        false
901      },
902    );
903    assert!(!ok);
904    assert_eq!(calls, 1);
905    let ok2 = with_cache(
906      CachePolicy::Shared,
907      src.to_str().unwrap(),
908      dest.to_str().unwrap(),
909      key,
910      || {
911        calls += 1;
912        false
913      },
914    );
915    assert!(!ok2);
916    assert_eq!(calls, 2, "cache must not memoise failures");
917  }
918
919  #[test]
920  fn missing_disk_file_triggers_quiet_regeneration() {
921    // Scenario: an earlier `store()` registered a cache entry, but
922    // its on-disk file has since been removed (manual `rm`, another
923    // tool clearing /tmp, or an aggressive prune from a parallel
924    // worker on a different machine sharing the same network FS).
925    // The next lookup must NOT raise any error, must NOT panic,
926    // must report a miss, and the next `with_cache` call must
927    // regenerate from the conversion closure and rewrite the entry.
928    let _ = shared_cache_dir();
929    let dir = temp_dir("missing-disk");
930    let bytes = unique_source_bytes("missing-disk");
931    let src = dir.join("src.bin");
932    write_bytes(&src, &bytes);
933    let dest1 = dir.join("out1.png");
934    let dest2 = dir.join("out2.png");
935    let key = RenderKey {
936      page:    None,
937      density: 90,
938      ext:     "png",
939    };
940    let mut calls = 0u32;
941    // Step 1: register a fresh entry via with_cache. Cache file is on disk.
942    let ok = with_cache(
943      CachePolicy::Shared,
944      src.to_str().unwrap(),
945      dest1.to_str().unwrap(),
946      key,
947      || {
948        calls += 1;
949        fs::write(&dest1, b"v1-bytes").unwrap();
950        true
951      },
952    );
953    assert!(ok);
954    assert_eq!(calls, 1);
955
956    // Step 2: locate the cache file and DELETE it from disk, simulating
957    // an externally-deleted entry. The `.dims` sidecar (if present) we
958    // leave behind to test that orphan-sidecar tolerance also works.
959    let root = shared_cache_dir();
960    let hash = hash_key(&src, key).unwrap();
961    let cached = cache_path(root, &hash, key.ext);
962    assert!(cached.exists(), "step-1 should have written the cache file");
963    fs::remove_file(&cached).unwrap();
964    assert!(!cached.exists(), "cache file should now be gone");
965
966    // Step 3: a fresh lookup must return None — no panic, no error log.
967    // (The function returns Option, so we just check the variant.)
968    let hit = lookup(src.to_str().unwrap(), dest2.to_str().unwrap(), key);
969    assert!(
970      hit.is_none(),
971      "lookup must report a miss when disk file is gone"
972    );
973
974    // Step 4: with_cache must regenerate quietly. The closure should fire,
975    // producing fresh output bytes. After this, the cache should hold the
976    // new entry again.
977    let ok2 = with_cache(
978      CachePolicy::Shared,
979      src.to_str().unwrap(),
980      dest2.to_str().unwrap(),
981      key,
982      || {
983        calls += 1;
984        fs::write(&dest2, b"v2-bytes-regenerated").unwrap();
985        true
986      },
987    );
988    assert!(ok2);
989    assert_eq!(
990      calls, 2,
991      "regeneration must run the conversion closure exactly once"
992    );
993    assert_eq!(
994      fs::read(&dest2).unwrap(),
995      b"v2-bytes-regenerated",
996      "dest must contain the regenerated bytes"
997    );
998    assert!(
999      cached.exists(),
1000      "cache file should be restored on disk after regeneration"
1001    );
1002
1003    // Step 5: a third lookup should now hit again (full self-heal).
1004    let dest3 = dir.join("out3.png");
1005    let hit3 = lookup(src.to_str().unwrap(), dest3.to_str().unwrap(), key);
1006    assert!(hit3.is_some(), "self-heal: subsequent lookup must hit");
1007    assert_eq!(fs::read(&dest3).unwrap(), b"v2-bytes-regenerated");
1008  }
1009
1010  #[test]
1011  fn orphan_dims_sidecar_is_silently_overwritten() {
1012    // Scenario: cache file missing, but a stale .dims sidecar
1013    // (perhaps from an aborted concurrent write) is still on disk.
1014    // The lookup must miss (no error), the regeneration must succeed,
1015    // and the new .dims sidecar must contain the FRESH dimensions —
1016    // not the stale ones.
1017    let _ = shared_cache_dir();
1018    let dir = temp_dir("orphan-dims");
1019    let bytes = unique_source_bytes("orphan-dims");
1020    let src = dir.join("src.bin");
1021    write_bytes(&src, &bytes);
1022    let key = RenderKey {
1023      page:    None,
1024      density: 90,
1025      ext:     "png",
1026    };
1027    let root = shared_cache_dir();
1028    let hash = hash_key(&src, key).unwrap();
1029    let cached = cache_path(root, &hash, key.ext);
1030    // Manually plant a stale .dims sidecar without the main file.
1031    let sidecar = dims_sidecar(&cached);
1032    if let Some(parent) = sidecar.parent() {
1033      fs::create_dir_all(parent).unwrap();
1034    }
1035    fs::write(&sidecar, "1234\n5678\n").unwrap();
1036    assert!(sidecar.exists());
1037    assert!(!cached.exists());
1038
1039    let dest = dir.join("out.png");
1040    let result = with_cache_dims(
1041      CachePolicy::Shared,
1042      src.to_str().unwrap(),
1043      dest.to_str().unwrap(),
1044      key,
1045      || {
1046        fs::write(&dest, b"fresh").unwrap();
1047        true
1048      },
1049      || Some(CachedDims { width: 100, height: 200 }),
1050    );
1051    assert!(matches!(result, ConvertResult::Ok {
1052      dims: Some(CachedDims { width: 100, height: 200 }),
1053    }));
1054    // The .dims sidecar should now contain the FRESH dims, not the stale.
1055    let after = read_dims_sidecar(&sidecar).unwrap();
1056    assert_eq!(after, CachedDims { width: 100, height: 200 });
1057  }
1058
1059  #[test]
1060  fn concurrent_writers_converge_to_one_cache_entry() {
1061    // Multi-process safety simulation: spawn 8 threads, all writing
1062    // the SAME key, all writing distinct dests. After all join, the
1063    // cache must contain exactly one final file (no leftover .tmp.*
1064    // sidecars), and a follow-up hit must succeed.
1065    let _ = shared_cache_dir();
1066    let dir = temp_dir("concurrent");
1067    let bytes = unique_source_bytes("concurrent");
1068    let src = dir.join("src.bin");
1069    write_bytes(&src, &bytes);
1070    let key = RenderKey {
1071      page:    None,
1072      density: 90,
1073      ext:     "png",
1074    };
1075
1076    let src_s = src.to_string_lossy().into_owned();
1077    let dir_s = dir.to_string_lossy().into_owned();
1078
1079    std::thread::scope(|s| {
1080      for i in 0..8 {
1081        let src_s = src_s.clone();
1082        let dir_s = dir_s.clone();
1083        s.spawn(move || {
1084          let dest = format!("{dir_s}/dest_{i}.png");
1085          with_cache(CachePolicy::Shared, &src_s, &dest, key, || {
1086            // Tiny artificial work to encourage interleaving.
1087            std::thread::sleep(std::time::Duration::from_millis(5));
1088            fs::write(&dest, b"final-bytes").unwrap();
1089            true
1090          });
1091        });
1092      }
1093    });
1094
1095    // Verify exactly one cache file for this hash, no leftover tmp.
1096    let root = shared_cache_dir();
1097    let hash = hash_key(&src, key).unwrap();
1098    let cached = cache_path(root, &hash, key.ext);
1099    assert!(
1100      cached.exists(),
1101      "cache entry must exist after concurrent writes"
1102    );
1103    let shard_dir = cached.parent().unwrap();
1104    let tmp_count = fs::read_dir(shard_dir)
1105      .unwrap()
1106      .filter_map(Result::ok)
1107      .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
1108      .count();
1109    assert_eq!(
1110      tmp_count, 0,
1111      "no leftover tmp sidecars after concurrent writes"
1112    );
1113
1114    // A fresh hit must still succeed and deliver the correct bytes.
1115    let dest_check = dir.join("dest_check.png");
1116    let hit = lookup(&src_s, dest_check.to_str().unwrap(), key);
1117    assert!(hit.is_some(), "follow-up lookup must hit");
1118    assert_eq!(fs::read(&dest_check).unwrap(), b"final-bytes");
1119  }
1120}