1use 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
116fn 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
130fn 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
157fn 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
172pub fn stats() -> (u32, u32) { (HITS.load(Ordering::Relaxed), MISSES.load(Ordering::Relaxed)) }
175
176#[cfg(test)]
178pub fn reset_stats() {
179 HITS.store(0, Ordering::Relaxed);
180 MISSES.store(0, Ordering::Relaxed);
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum CachePolicy {
199 #[default]
201 Shared,
202 Bypass,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct RenderKey {
216 pub page: Option<u32>,
218 pub density: u32,
220 pub ext: &'static str,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct CachedDims {
230 pub width: u32,
231 pub height: u32,
232}
233
234fn 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 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 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
264fn 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
276fn 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
283fn 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 if fs::hard_link(src, dst).is_ok() {
294 return true;
295 }
296 let _ = fs::remove_file(dst);
299 if fs::hard_link(src, dst).is_ok() {
300 return true;
301 }
302 fs::copy(src, dst).is_ok()
304}
305
306fn 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
315fn 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#[derive(Debug, Clone, Copy)]
343pub struct CacheHit {
344 pub dims: Option<CachedDims>,
345}
346
347pub 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 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
374pub 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 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 let _ = fs::rename(&tmp, &cached);
407 } else {
408 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 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#[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 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
467fn 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 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 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 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 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 if fs::remove_file(&path).is_ok() {
523 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 }
532
533#[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
555pub 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 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
610pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}