1use md5::{Digest, Md5};
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::time::SystemTime;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ReuseOutcome {
13 Cold,
14 DiskContentHit,
15 RenderCacheHit,
16 UnchangedStub,
17 CrossFileRef,
18 FreshBypass,
19 Stale,
20 VariantEvicted,
21 PolicyBypass,
22}
23
24impl ReuseOutcome {
25 const fn is_render_reuse(self) -> bool {
26 matches!(
27 self,
28 Self::RenderCacheHit | Self::UnchangedStub | Self::CrossFileRef
29 )
30 }
31
32 const fn is_ctx_read_eligible(self) -> bool {
33 !matches!(self, Self::FreshBypass | Self::PolicyBypass)
34 }
35}
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub struct ReuseSnapshot {
43 pub cold: u64,
44 pub disk_content_hits: u64,
45 pub render_cache_hits: u64,
46 pub unchanged_stubs: u64,
47 pub cross_file_refs: u64,
48 pub fresh_bypasses: u64,
49 pub stale: u64,
50 pub variant_evicted: u64,
51 pub policy_bypasses: u64,
52 pub eligible_ctx_reads: u64,
53 pub reused_renderings: u64,
54 pub eligible_search_reads: u64,
55}
56
57impl ReuseSnapshot {
58 pub fn read_reuse_rate(&self) -> f64 {
60 rate(self.reused_renderings, self.eligible_ctx_reads)
61 }
62
63 pub fn disk_reuse_rate(&self) -> f64 {
65 rate(self.disk_content_hits, self.eligible_search_reads)
66 }
67}
68
69struct ReuseCounters {
70 cold: AtomicU64,
71 disk_content_hits: AtomicU64,
72 render_cache_hits: AtomicU64,
73 unchanged_stubs: AtomicU64,
74 cross_file_refs: AtomicU64,
75 fresh_bypasses: AtomicU64,
76 stale: AtomicU64,
77 variant_evicted: AtomicU64,
78 policy_bypasses: AtomicU64,
79 eligible_ctx_reads: AtomicU64,
80 reused_renderings: AtomicU64,
81 eligible_search_reads: AtomicU64,
82}
83
84impl ReuseCounters {
85 const fn new() -> Self {
86 Self {
87 cold: AtomicU64::new(0),
88 disk_content_hits: AtomicU64::new(0),
89 render_cache_hits: AtomicU64::new(0),
90 unchanged_stubs: AtomicU64::new(0),
91 cross_file_refs: AtomicU64::new(0),
92 fresh_bypasses: AtomicU64::new(0),
93 stale: AtomicU64::new(0),
94 variant_evicted: AtomicU64::new(0),
95 policy_bypasses: AtomicU64::new(0),
96 eligible_ctx_reads: AtomicU64::new(0),
97 reused_renderings: AtomicU64::new(0),
98 eligible_search_reads: AtomicU64::new(0),
99 }
100 }
101}
102
103static REUSE_COUNTERS: ReuseCounters = ReuseCounters::new();
104
105fn rate(numerator: u64, denominator: u64) -> f64 {
106 if denominator == 0 {
107 0.0
108 } else {
109 numerator as f64 * 100.0 / denominator as f64
110 }
111}
112
113fn bump_outcome(counters: &ReuseCounters, outcome: ReuseOutcome) {
114 let counter = match outcome {
115 ReuseOutcome::Cold => &counters.cold,
116 ReuseOutcome::DiskContentHit => &counters.disk_content_hits,
117 ReuseOutcome::RenderCacheHit => &counters.render_cache_hits,
118 ReuseOutcome::UnchangedStub => &counters.unchanged_stubs,
119 ReuseOutcome::CrossFileRef => &counters.cross_file_refs,
120 ReuseOutcome::FreshBypass => &counters.fresh_bypasses,
121 ReuseOutcome::Stale => &counters.stale,
122 ReuseOutcome::VariantEvicted => &counters.variant_evicted,
123 ReuseOutcome::PolicyBypass => &counters.policy_bypasses,
124 };
125 counter.fetch_add(1, Ordering::Relaxed);
126}
127
128fn record_ctx_read_outcome_into(counters: &ReuseCounters, outcome: ReuseOutcome) {
129 bump_outcome(counters, outcome);
130 if outcome.is_ctx_read_eligible() {
131 counters.eligible_ctx_reads.fetch_add(1, Ordering::Relaxed);
132 if outcome.is_render_reuse() {
133 counters.reused_renderings.fetch_add(1, Ordering::Relaxed);
134 }
135 }
136}
137
138pub fn record_ctx_read_outcome(outcome: ReuseOutcome) {
140 record_ctx_read_outcome_into(&REUSE_COUNTERS, outcome);
141}
142
143fn record_search_content_read_into(counters: &ReuseCounters, hit: bool) {
144 counters
145 .eligible_search_reads
146 .fetch_add(1, Ordering::Relaxed);
147 bump_outcome(
148 counters,
149 if hit {
150 ReuseOutcome::DiskContentHit
151 } else {
152 ReuseOutcome::Cold
153 },
154 );
155}
156
157pub fn record_search_content_read(hit: bool) {
159 record_search_content_read_into(&REUSE_COUNTERS, hit);
160}
161
162#[must_use]
164pub fn reuse_snapshot() -> ReuseSnapshot {
165 snapshot_from(&REUSE_COUNTERS)
166}
167
168fn snapshot_from(counters: &ReuseCounters) -> ReuseSnapshot {
169 ReuseSnapshot {
170 cold: counters.cold.load(Ordering::Relaxed),
171 disk_content_hits: counters.disk_content_hits.load(Ordering::Relaxed),
172 render_cache_hits: counters.render_cache_hits.load(Ordering::Relaxed),
173 unchanged_stubs: counters.unchanged_stubs.load(Ordering::Relaxed),
174 cross_file_refs: counters.cross_file_refs.load(Ordering::Relaxed),
175 fresh_bypasses: counters.fresh_bypasses.load(Ordering::Relaxed),
176 stale: counters.stale.load(Ordering::Relaxed),
177 variant_evicted: counters.variant_evicted.load(Ordering::Relaxed),
178 policy_bypasses: counters.policy_bypasses.load(Ordering::Relaxed),
179 eligible_ctx_reads: counters.eligible_ctx_reads.load(Ordering::Relaxed),
180 reused_renderings: counters.reused_renderings.load(Ordering::Relaxed),
181 eligible_search_reads: counters.eligible_search_reads.load(Ordering::Relaxed),
182 }
183}
184
185pub fn file_mtime(path: &str) -> Option<SystemTime> {
186 retry_while_transient(|| std::fs::metadata(path).and_then(|m| m.modified())).ok()
192}
193
194pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
195 let current = file_mtime(path);
196 match (cached_mtime, current) {
197 (None, None) => false,
199 (Some(_), None) | (None, Some(_)) => true,
201 (Some(cached), Some(current)) => current != cached,
204 }
205}
206
207const VERIFY_HASH_CAP_BYTES: u64 = 8 * 1024 * 1024;
210
211fn cache_verify_enabled() -> bool {
212 std::env::var("LEAN_CTX_CACHE_VERIFY").map_or(true, |v| v != "0")
213}
214
215fn io_error_may_be_transient(error: &std::io::Error) -> bool {
225 error.kind() != std::io::ErrorKind::NotFound
226}
227
228fn retry_while_transient<T>(mut op: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
236 const ATTEMPTS: usize = 3;
237 const BACKOFF: std::time::Duration = std::time::Duration::from_millis(15);
238
239 let mut result = op();
240 for _ in 1..ATTEMPTS {
241 match result {
242 Err(ref error) if io_error_may_be_transient(error) => {
243 std::thread::sleep(BACKOFF);
244 result = op();
245 }
246 _ => return result,
249 }
250 }
251 result
252}
253
254pub fn is_cache_entry_stale_verified(
277 path: &str,
278 cached_mtime: Option<SystemTime>,
279 cached_hash: &str,
280) -> bool {
281 let mtime_stale = is_cache_entry_stale(path, cached_mtime);
282
283 if !mtime_stale && (cached_hash.is_empty() || !cache_verify_enabled()) {
284 return false;
285 }
286
287 if cached_hash.is_empty() {
288 return mtime_stale;
289 }
290
291 let Ok(meta) = retry_while_transient(|| std::fs::metadata(path)) else {
292 return true;
293 };
294 if meta.len() > VERIFY_HASH_CAP_BYTES {
295 return mtime_stale;
296 }
297 match retry_while_transient(|| std::fs::read(path)) {
298 Ok(bytes) => compute_md5(&String::from_utf8_lossy(&bytes)) != cached_hash,
299 Err(_) => true,
300 }
301}
302pub(super) fn compute_md5(content: &str) -> String {
303 let mut hasher = Md5::new();
304 hasher.update(content.as_bytes());
305 crate::core::agent_identity::hex_encode(&hasher.finalize())
306}
307
308#[cfg(test)]
309mod tests {
310 use super::{
311 ReuseCounters, ReuseOutcome, ReuseSnapshot, record_ctx_read_outcome_into,
312 record_search_content_read_into, snapshot_from,
313 };
314
315 #[test]
316 fn every_read_records_one_exclusive_terminal_outcome() {
317 let counters = ReuseCounters::new();
318 record_ctx_read_outcome_into(&counters, ReuseOutcome::RenderCacheHit);
319 record_ctx_read_outcome_into(&counters, ReuseOutcome::UnchangedStub);
320 record_ctx_read_outcome_into(&counters, ReuseOutcome::FreshBypass);
321 record_ctx_read_outcome_into(&counters, ReuseOutcome::PolicyBypass);
322 record_search_content_read_into(&counters, true);
323 record_search_content_read_into(&counters, false);
324
325 let snapshot = snapshot_from(&counters);
326 assert_eq!(snapshot.render_cache_hits, 1);
327 assert_eq!(snapshot.unchanged_stubs, 1);
328 assert_eq!(snapshot.fresh_bypasses, 1);
329 assert_eq!(snapshot.policy_bypasses, 1);
330 assert_eq!(snapshot.disk_content_hits, 1);
331 assert_eq!(snapshot.cold, 1);
332 assert_eq!(snapshot.eligible_ctx_reads, 2);
333 assert_eq!(snapshot.reused_renderings, 2);
334 assert_eq!(snapshot.eligible_search_reads, 2);
335 }
336
337 #[test]
338 fn reuse_rates_keep_read_and_disk_denominators_separate() {
339 let metrics = ReuseSnapshot {
340 reused_renderings: 3,
341 eligible_ctx_reads: 4,
342 disk_content_hits: 2,
343 eligible_search_reads: 5,
344 ..ReuseSnapshot::default()
345 };
346
347 assert_eq!(metrics.read_reuse_rate(), 75.0);
348 assert_eq!(metrics.disk_reuse_rate(), 40.0);
349 }
350
351 #[test]
352 fn only_render_reuse_outcomes_count_for_ctx_read_reuse() {
353 assert!(ReuseOutcome::RenderCacheHit.is_render_reuse());
354 assert!(ReuseOutcome::UnchangedStub.is_render_reuse());
355 assert!(ReuseOutcome::CrossFileRef.is_render_reuse());
356 assert!(!ReuseOutcome::DiskContentHit.is_render_reuse());
357 assert!(!ReuseOutcome::FreshBypass.is_render_reuse());
358 assert!(!ReuseOutcome::Stale.is_render_reuse());
359 }
360
361 #[test]
362 fn bypasses_are_not_eligible_for_read_reuse() {
363 assert!(!ReuseOutcome::FreshBypass.is_ctx_read_eligible());
364 assert!(!ReuseOutcome::PolicyBypass.is_ctx_read_eligible());
365 assert!(ReuseOutcome::Stale.is_ctx_read_eligible());
366 }
367
368 #[test]
369 fn stale_mtime_but_same_hash_is_not_stale() {
370 use std::io::Write;
371 let dir = tempfile::tempdir().unwrap();
372 let p = dir.path().join("poll.txt");
373 std::fs::write(&p, "terminal output line 1").unwrap();
374
375 let mtime1 = std::fs::metadata(&p).unwrap().modified().unwrap();
376 let hash = super::compute_md5("terminal output line 1");
377
378 std::thread::sleep(std::time::Duration::from_millis(50));
379 {
380 let mut f = std::fs::OpenOptions::new()
381 .write(true)
382 .truncate(true)
383 .open(&p)
384 .unwrap();
385 f.write_all(b"terminal output line 1").unwrap();
386 }
387 let mtime2 = std::fs::metadata(&p).unwrap().modified().unwrap();
388 assert_ne!(mtime1, mtime2, "mtime must differ after rewrite");
389
390 let stale = super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime1), &hash);
391 assert!(
392 !stale,
393 "same content with different mtime must NOT be stale"
394 );
395 }
396
397 #[test]
398 fn stale_mtime_with_changed_content_is_stale() {
399 let dir = tempfile::tempdir().unwrap();
400 let p = dir.path().join("changed.txt");
401 std::fs::write(&p, "version 1").unwrap();
402
403 let mtime1 = std::fs::metadata(&p).unwrap().modified().unwrap();
404 let hash = super::compute_md5("version 1");
405
406 std::thread::sleep(std::time::Duration::from_millis(50));
407 std::fs::write(&p, "version 2 with new content").unwrap();
408
409 let stale = super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime1), &hash);
410 assert!(stale, "changed content must be stale");
411 }
412
413 #[test]
414 fn same_mtime_same_hash_is_not_stale() {
415 let dir = tempfile::tempdir().unwrap();
416 let p = dir.path().join("stable.txt");
417 std::fs::write(&p, "stable content").unwrap();
418
419 let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
420 let hash = super::compute_md5("stable content");
421
422 let stale = super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash);
423 assert!(!stale, "unchanged file must not be stale");
424 }
425
426 #[test]
433 fn a_transient_failure_is_retried_before_giving_up() {
434 let attempts = std::cell::Cell::new(0_u32);
435 let result = super::retry_while_transient(|| {
436 let n = attempts.get() + 1;
437 attempts.set(n);
438 if n < 3 {
439 Err(std::io::Error::new(
440 std::io::ErrorKind::PermissionDenied,
441 "file is locked by another handle",
442 ))
443 } else {
444 Ok(42_u32)
445 }
446 });
447 assert_eq!(result.unwrap(), 42);
448 assert_eq!(attempts.get(), 3, "must keep trying until it succeeds");
449 }
450
451 #[test]
454 fn a_missing_file_is_not_retried() {
455 let attempts = std::cell::Cell::new(0_u32);
456 let result: std::io::Result<()> = super::retry_while_transient(|| {
457 attempts.set(attempts.get() + 1);
458 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"))
459 });
460 assert!(result.is_err());
461 assert_eq!(attempts.get(), 1, "NotFound must not be retried");
462 }
463
464 #[test]
469 fn content_that_never_becomes_readable_is_still_stale() {
470 let dir = tempfile::tempdir().unwrap();
471 let missing = dir.path().join("never-existed.txt");
472 let stale = super::is_cache_entry_stale_verified(
473 missing.to_str().unwrap(),
474 Some(std::time::SystemTime::now()),
475 &super::compute_md5("whatever was cached"),
476 );
477 assert!(stale, "unverifiable content must stay fail-closed");
478 }
479
480 #[test]
481 fn empty_hash_falls_back_to_mtime_only() {
482 let dir = tempfile::tempdir().unwrap();
483 let p = dir.path().join("nohash.txt");
484 std::fs::write(&p, "content").unwrap();
485
486 let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
487 assert!(
488 !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), ""),
489 "same mtime + empty hash = not stale"
490 );
491
492 std::thread::sleep(std::time::Duration::from_millis(50));
493 std::fs::write(&p, "content").unwrap();
494 assert!(
495 super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), ""),
496 "different mtime + empty hash = stale (no hash to rescue)"
497 );
498 }
499
500 #[test]
501 fn large_file_uses_mtime_only() {
502 let dir = tempfile::tempdir().unwrap();
503 let p = dir.path().join("big.txt");
504 let big = "x".repeat(9 * 1024 * 1024);
505 std::fs::write(&p, &big).unwrap();
506
507 let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
508 let hash = super::compute_md5(&big);
509
510 assert!(
511 !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
512 "same mtime on large file = not stale"
513 );
514
515 std::thread::sleep(std::time::Duration::from_millis(50));
516 std::fs::write(&p, &big).unwrap();
517 assert!(
518 super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
519 "large files skip hash check even if content matches"
520 );
521 }
522
523 #[test]
524 fn deleted_file_is_stale() {
525 let dir = tempfile::tempdir().unwrap();
526 let p = dir.path().join("ephemeral.txt");
527 std::fs::write(&p, "temp").unwrap();
528
529 let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
530 let hash = super::compute_md5("temp");
531
532 std::fs::remove_file(&p).unwrap();
533
534 assert!(
535 super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
536 "deleted file must be stale"
537 );
538 }
539
540 #[test]
541 fn empty_file_hash_matches_correctly() {
542 let dir = tempfile::tempdir().unwrap();
543 let p = dir.path().join("empty.txt");
544 std::fs::write(&p, "").unwrap();
545
546 let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
547 let hash = super::compute_md5("");
548
549 std::thread::sleep(std::time::Duration::from_millis(50));
550 std::fs::write(&p, "").unwrap();
551
552 assert!(
553 !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
554 "empty file rewritten with same content must not be stale"
555 );
556 }
557
558 #[test]
559 fn rapid_touch_cycles_stay_cached() {
560 let dir = tempfile::tempdir().unwrap();
561 let p = dir.path().join("rapid.txt");
562 let content = "rapid polling content that stays stable";
563 std::fs::write(&p, content).unwrap();
564
565 let mtime_initial = std::fs::metadata(&p).unwrap().modified().unwrap();
566 let hash = super::compute_md5(content);
567
568 for _ in 0..10 {
569 std::thread::sleep(std::time::Duration::from_millis(10));
570 std::fs::write(&p, content).unwrap();
571
572 let stale = super::is_cache_entry_stale_verified(
573 p.to_str().unwrap(),
574 Some(mtime_initial),
575 &hash,
576 );
577 assert!(
578 !stale,
579 "repeated rewrites of same content must never be stale"
580 );
581 }
582 }
583
584 #[test]
585 fn binary_content_hash_works() {
586 let dir = tempfile::tempdir().unwrap();
587 let p = dir.path().join("binary.bin");
588 let data: Vec<u8> = (0..=255).collect();
589 std::fs::write(&p, &data).unwrap();
590
591 let mtime = std::fs::metadata(&p).unwrap().modified().unwrap();
592 let hash = super::compute_md5(&String::from_utf8_lossy(&data));
593
594 std::thread::sleep(std::time::Duration::from_millis(50));
595 std::fs::write(&p, &data).unwrap();
596
597 assert!(
598 !super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
599 "binary content with same bytes must not be stale"
600 );
601
602 let mut changed = data.clone();
603 changed[100] = 0;
604 std::fs::write(&p, &changed).unwrap();
605 assert!(
606 super::is_cache_entry_stale_verified(p.to_str().unwrap(), Some(mtime), &hash),
607 "binary content with 1 byte changed must be stale"
608 );
609 }
610}