1use std::collections::HashMap;
35use std::fs::File;
36use std::io::Write as IoWrite;
37use std::path::{Path, PathBuf};
38use std::sync::OnceLock;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41use memmap2::Mmap;
42use parking_lot::Mutex;
43use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
44use serde::{Deserialize, Deserializer, Serialize, Serializer};
45
46use crate::ast::Program;
47use crate::bytecode::Chunk;
48use crate::error::{PerlError, PerlResult};
49use crate::value::StrykeValue;
50
51pub const SHARD_MAGIC: u32 = 0x53545259; pub const SHARD_FORMAT_VERSION: u32 = 1;
55
56#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
59#[archive(check_bytes)]
60pub struct ShardHeader {
61 pub magic: u32,
62 pub format_version: u32,
63 pub stryke_version: String,
64 pub pointer_width: u32,
65 pub built_at_secs: u64,
66}
67
68#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
69#[archive(check_bytes)]
70pub struct ScriptEntry {
71 pub mtime_secs: i64,
72 pub mtime_nsecs: i64,
73 pub binary_mtime_at_cache: i64,
74 pub cached_at_secs: i64,
75 pub program_blob: Vec<u8>,
76 pub chunk_blob: Vec<u8>,
77}
78
79#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
80#[archive(check_bytes)]
81pub struct ScriptShard {
82 pub header: ShardHeader,
83 pub entries: HashMap<String, ScriptEntry>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96enum CacheConst {
97 Undef,
98 Int(i64),
99 Float(f64),
100 Str(String),
101}
102
103fn cache_const_from_perl(v: &StrykeValue) -> Result<CacheConst, String> {
104 if v.is_undef() {
105 return Ok(CacheConst::Undef);
106 }
107 if let Some(n) = v.as_integer() {
108 return Ok(CacheConst::Int(n));
109 }
110 if let Some(f) = v.as_float() {
111 return Ok(CacheConst::Float(f));
112 }
113 if let Some(s) = v.as_str() {
114 return Ok(CacheConst::Str(s.to_string()));
115 }
116 Err(format!(
117 "constant pool value cannot be cached (type {})",
118 v.ref_type()
119 ))
120}
121
122fn perl_from_cache_const(c: CacheConst) -> StrykeValue {
123 match c {
124 CacheConst::Undef => StrykeValue::UNDEF,
125 CacheConst::Int(n) => StrykeValue::integer(n),
126 CacheConst::Float(f) => StrykeValue::float(f),
127 CacheConst::Str(s) => StrykeValue::string(s),
128 }
129}
130
131pub mod constants_pool_codec {
132 use super::*;
133
134 pub fn serialize<S>(values: &Vec<StrykeValue>, ser: S) -> Result<S::Ok, S::Error>
135 where
136 S: Serializer,
137 {
138 let mut out = Vec::with_capacity(values.len());
139 for v in values {
140 let c = cache_const_from_perl(v).map_err(serde::ser::Error::custom)?;
141 out.push(c);
142 }
143 out.serialize(ser)
144 }
145
146 pub fn deserialize<'de, D>(de: D) -> Result<Vec<StrykeValue>, D::Error>
147 where
148 D: Deserializer<'de>,
149 {
150 let v: Vec<CacheConst> = <Vec<CacheConst> as Deserialize>::deserialize(de)?;
151 Ok(v.into_iter().map(perl_from_cache_const).collect())
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct CachedScript {
158 pub program: Program,
159 pub chunk: Chunk,
160}
161
162pub struct MmappedShard {
167 _mmap: Mmap,
168 archived: *const ArchivedScriptShard,
169}
170
171unsafe impl Send for MmappedShard {}
174unsafe impl Sync for MmappedShard {}
175
176impl MmappedShard {
177 pub fn open(path: &Path) -> Option<Self> {
180 let file = File::open(path).ok()?;
181 let mmap = unsafe { Mmap::map(&file).ok()? };
182 let archived = rkyv::check_archived_root::<ScriptShard>(&mmap[..]).ok()?;
183 let archived_ptr = archived as *const ArchivedScriptShard;
184 Some(Self {
185 _mmap: mmap,
186 archived: archived_ptr,
187 })
188 }
189
190 fn shard(&self) -> &ArchivedScriptShard {
191 unsafe { &*self.archived }
193 }
194
195 fn header_ok(&self) -> bool {
197 let h = &self.shard().header;
198 let magic: u32 = h.magic.into();
199 let fv: u32 = h.format_version.into();
200 let pw: u32 = h.pointer_width.into();
201 magic == SHARD_MAGIC
202 && fv == SHARD_FORMAT_VERSION
203 && pw as usize == std::mem::size_of::<usize>()
204 && h.stryke_version.as_str() == env!("CARGO_PKG_VERSION")
205 }
206
207 fn lookup(&self, path: &str) -> Option<&ArchivedScriptEntry> {
208 self.shard().entries.get(path)
209 }
210
211 fn entry_count(&self) -> usize {
212 self.shard().entries.len()
213 }
214}
215
216pub struct ScriptCache {
220 path: PathBuf,
221 lock_path: PathBuf,
222 mmap: Mutex<Option<MmappedShard>>,
223}
224
225impl ScriptCache {
226 pub fn open(path: &Path) -> std::io::Result<Self> {
229 if let Some(parent) = path.parent() {
230 std::fs::create_dir_all(parent)?;
231 }
232 let parent = path.parent().unwrap_or_else(|| Path::new("/tmp"));
233 let lock_path = parent.join(format!(
234 "{}.lock",
235 path.file_name()
236 .and_then(|s| s.to_str())
237 .unwrap_or("scripts.rkyv")
238 ));
239 Ok(Self {
240 path: path.to_path_buf(),
241 lock_path,
242 mmap: Mutex::new(None),
243 })
244 }
245
246 fn ensure_mmap(&self) {
247 let mut guard = self.mmap.lock();
248 if guard.is_none() {
249 *guard = MmappedShard::open(&self.path);
250 }
251 }
252
253 fn invalidate_mmap(&self) {
254 let mut guard = self.mmap.lock();
255 *guard = None;
256 }
257
258 pub fn get(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<CachedScript> {
261 self.ensure_mmap();
262 let guard = self.mmap.lock();
263 let shard = guard.as_ref()?;
264 if !shard.header_ok() {
265 return None;
266 }
267 let entry = shard.lookup(path)?;
268
269 let entry_mtime_s: i64 = entry.mtime_secs.into();
270 let entry_mtime_ns: i64 = entry.mtime_nsecs.into();
271 if entry_mtime_s != mtime_secs || entry_mtime_ns != mtime_nsecs {
272 return None;
273 }
274
275 if let Some(bin_mtime) = current_binary_mtime_secs() {
276 let cached_bin_mtime: i64 = entry.binary_mtime_at_cache.into();
277 if cached_bin_mtime < bin_mtime {
278 return None;
279 }
280 }
281
282 let program_bytes: &[u8] = entry.program_blob.as_slice();
283 let chunk_bytes: &[u8] = entry.chunk_blob.as_slice();
284 let program: Program = bincode::deserialize(program_bytes).ok()?;
285 let chunk: Chunk = bincode::deserialize(chunk_bytes).ok()?;
286 Some(CachedScript { program, chunk })
287 }
288
289 pub fn put(
291 &self,
292 path: &str,
293 mtime_secs: i64,
294 mtime_nsecs: i64,
295 program: &Program,
296 chunk: &Chunk,
297 ) -> PerlResult<()> {
298 let program_bytes =
299 bincode::serialize(program).map_err(|e| PerlError::runtime(e.to_string(), 0))?;
300 let chunk_bytes =
301 bincode::serialize(chunk).map_err(|e| PerlError::runtime(e.to_string(), 0))?;
302
303 let _lock = match acquire_lock(&self.lock_path) {
304 Some(l) => l,
305 None => return Ok(()),
306 };
307
308 let mut shard = match read_owned_shard(&self.path) {
309 Some(s)
310 if s.header.stryke_version == env!("CARGO_PKG_VERSION")
311 && s.header.pointer_width as usize == std::mem::size_of::<usize>()
312 && s.header.format_version == SHARD_FORMAT_VERSION =>
313 {
314 s
315 }
316 _ => fresh_shard(),
317 };
318
319 let bin_mtime = current_binary_mtime_secs().unwrap_or(0);
320 let entry = ScriptEntry {
321 mtime_secs,
322 mtime_nsecs,
323 binary_mtime_at_cache: bin_mtime,
324 cached_at_secs: now_secs(),
325 program_blob: program_bytes,
326 chunk_blob: chunk_bytes,
327 };
328 shard.entries.insert(path.to_string(), entry);
329 shard.header.built_at_secs = now_secs() as u64;
330
331 write_shard_atomic(&self.path, &shard)?;
332 self.invalidate_mmap();
333 Ok(())
334 }
335
336 pub fn stats(&self) -> (i64, i64) {
338 self.ensure_mmap();
339 let guard = self.mmap.lock();
340 let Some(shard) = guard.as_ref() else {
341 return (0, 0);
342 };
343 let count = shard.entry_count() as i64;
344 let bytes: i64 = shard
345 .shard()
346 .entries
347 .values()
348 .map(|e| (e.program_blob.len() + e.chunk_blob.len()) as i64)
349 .sum();
350 (count, bytes)
351 }
352
353 pub fn list_scripts(&self) -> Vec<(String, f64, f64, String, String)> {
356 self.ensure_mmap();
357 let guard = self.mmap.lock();
358 let Some(shard) = guard.as_ref() else {
359 return Vec::new();
360 };
361 let v = shard.shard().header.stryke_version.as_str().to_string();
362 let mut out: Vec<(String, f64, f64, String, String, i64)> = shard
363 .shard()
364 .entries
365 .iter()
366 .map(|(k, e)| {
367 let prog_kb = e.program_blob.len() as f64 / 1024.0;
368 let chunk_kb = e.chunk_blob.len() as f64 / 1024.0;
369 let cached_at: i64 = e.cached_at_secs.into();
370 let ts = format_local_ts(cached_at);
371 (
372 k.as_str().to_string(),
373 prog_kb,
374 chunk_kb,
375 v.clone(),
376 ts,
377 cached_at,
378 )
379 })
380 .collect();
381 out.sort_by_key(|x| std::cmp::Reverse(x.5));
382 out.into_iter()
383 .map(|(p, pk, ck, ver, ts, _)| (p, pk, ck, ver, ts))
384 .collect()
385 }
386
387 pub fn evict_stale(&self) -> usize {
390 let _lock = match acquire_lock(&self.lock_path) {
391 Some(l) => l,
392 None => return 0,
393 };
394 let mut shard = match read_owned_shard(&self.path) {
395 Some(s) => s,
396 None => return 0,
397 };
398 let before = shard.entries.len();
399 shard.entries.retain(|p, e| match file_mtime(Path::new(p)) {
400 Some((s, ns)) => s == e.mtime_secs && ns == e.mtime_nsecs,
401 None => false,
402 });
403 let evicted = before - shard.entries.len();
404 if evicted > 0 {
405 let _ = write_shard_atomic(&self.path, &shard);
406 self.invalidate_mmap();
407 }
408 evicted
409 }
410
411 pub fn clear(&self) -> std::io::Result<()> {
413 let _lock = acquire_lock(&self.lock_path);
414 let res = match std::fs::remove_file(&self.path) {
415 Ok(()) => Ok(()),
416 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
417 Err(e) => Err(e),
418 };
419 self.invalidate_mmap();
420 res
421 }
422}
423
424fn acquire_lock(path: &Path) -> Option<nix::fcntl::Flock<File>> {
429 let f = File::options()
430 .read(true)
431 .write(true)
432 .create(true)
433 .truncate(false)
434 .open(path)
435 .ok()?;
436 nix::fcntl::Flock::lock(f, nix::fcntl::FlockArg::LockExclusive).ok()
437}
438
439fn fresh_shard() -> ScriptShard {
440 ScriptShard {
441 header: ShardHeader {
442 magic: SHARD_MAGIC,
443 format_version: SHARD_FORMAT_VERSION,
444 stryke_version: env!("CARGO_PKG_VERSION").to_string(),
445 pointer_width: std::mem::size_of::<usize>() as u32,
446 built_at_secs: now_secs() as u64,
447 },
448 entries: HashMap::new(),
449 }
450}
451
452fn read_owned_shard(path: &Path) -> Option<ScriptShard> {
453 let bytes = std::fs::read(path).ok()?;
454 let archived = rkyv::check_archived_root::<ScriptShard>(&bytes[..]).ok()?;
455 archived.deserialize(&mut rkyv::Infallible).ok()
456}
457
458fn write_shard_atomic(path: &Path, shard: &ScriptShard) -> PerlResult<()> {
459 let bytes = rkyv::to_bytes::<_, 4096>(shard)
460 .map_err(|e| PerlError::runtime(format!("rkyv serialize: {}", e), 0))?;
461
462 let parent = path.parent().expect("cache path has parent");
463 let _ = std::fs::create_dir_all(parent);
464
465 let pid = std::process::id();
466 let nanos = SystemTime::now()
467 .duration_since(UNIX_EPOCH)
468 .map(|d| d.as_nanos())
469 .unwrap_or(0);
470 let tmp_path = parent.join(format!(
471 "{}.tmp.{}.{}",
472 path.file_name()
473 .and_then(|s| s.to_str())
474 .unwrap_or("scripts.rkyv"),
475 pid,
476 nanos
477 ));
478
479 {
480 let mut f = File::create(&tmp_path).map_err(|e| PerlError::runtime(e.to_string(), 0))?;
481 f.write_all(&bytes)
482 .map_err(|e| PerlError::runtime(e.to_string(), 0))?;
483 f.sync_all()
484 .map_err(|e| PerlError::runtime(e.to_string(), 0))?;
485 }
486
487 std::fs::rename(&tmp_path, path).map_err(|e| PerlError::runtime(e.to_string(), 0))?;
488 Ok(())
489}
490
491fn now_secs() -> i64 {
492 SystemTime::now()
493 .duration_since(UNIX_EPOCH)
494 .map(|d| d.as_secs() as i64)
495 .unwrap_or(0)
496}
497
498fn format_local_ts(secs: i64) -> String {
499 let dt = chrono::DateTime::<chrono::Local>::from(
500 UNIX_EPOCH + std::time::Duration::from_secs(secs.max(0) as u64),
501 );
502 dt.format("%Y-%m-%d %H:%M:%S").to_string()
503}
504
505pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
509 use std::os::unix::fs::MetadataExt;
510 let meta = std::fs::metadata(path).ok()?;
511 Some((meta.mtime(), meta.mtime_nsec()))
512}
513
514fn current_binary_mtime_secs() -> Option<i64> {
516 static BIN_MTIME: OnceLock<Option<i64>> = OnceLock::new();
517 *BIN_MTIME.get_or_init(|| {
518 let exe = std::env::current_exe().ok()?;
519 let (secs, _) = file_mtime(&exe)?;
520 Some(secs)
521 })
522}
523
524pub fn default_cache_path() -> PathBuf {
526 dirs::home_dir()
527 .unwrap_or_else(|| PathBuf::from("/tmp"))
528 .join(".stryke/scripts.rkyv")
529}
530
531pub fn cache_enabled() -> bool {
533 !matches!(
534 std::env::var("STRYKE_CACHE").as_deref(),
535 Ok("0") | Ok("false") | Ok("no")
536 )
537}
538
539pub static CACHE: once_cell::sync::Lazy<Option<ScriptCache>> = once_cell::sync::Lazy::new(|| {
544 if !cache_enabled() {
545 return None;
546 }
547 ScriptCache::open(&default_cache_path()).ok()
548});
549
550pub fn try_load(path: &Path) -> Option<CachedScript> {
552 let cache = CACHE.as_ref()?;
553 let canonical = path.canonicalize().ok()?;
554 let path_str = canonical.to_string_lossy();
555 let (mtime_s, mtime_ns) = file_mtime(&canonical)?;
556 cache.get(&path_str, mtime_s, mtime_ns)
557}
558
559pub fn try_save(path: &Path, program: &Program, chunk: &Chunk) -> PerlResult<()> {
561 let Some(cache) = CACHE.as_ref() else {
562 return Ok(());
563 };
564 let canonical = match path.canonicalize() {
565 Ok(p) => p,
566 Err(_) => return Ok(()),
567 };
568 let path_str = canonical.to_string_lossy();
569 let (mtime_s, mtime_ns) = match file_mtime(&canonical) {
570 Some(m) => m,
571 None => return Ok(()),
572 };
573 cache.put(&path_str, mtime_s, mtime_ns, program, chunk)
574}
575
576pub fn stats() -> Option<(i64, i64)> {
578 CACHE.as_ref().map(|c| c.stats())
579}
580
581pub fn evict_stale() -> usize {
583 CACHE.as_ref().map(|c| c.evict_stale()).unwrap_or(0)
584}
585
586pub fn clear() -> bool {
588 CACHE.as_ref().map(|c| c.clear().is_ok()).unwrap_or(false)
589}
590
591#[cfg(test)]
594mod tests {
595 use super::*;
596 use tempfile::tempdir;
597
598 #[test]
599 fn round_trip() {
600 let dir = tempdir().unwrap();
601 let cache_path = dir.path().join("scripts.rkyv");
602 let cache = ScriptCache::open(&cache_path).unwrap();
603
604 let script_path = dir.path().join("test.stk");
605 std::fs::write(&script_path, "p 42").unwrap();
606
607 let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
608 let path_str = script_path.to_string_lossy().to_string();
609
610 let program = crate::parse("p 42").unwrap();
611 let chunk = crate::compiler::Compiler::new()
612 .compile_program(&program)
613 .unwrap();
614
615 cache
616 .put(&path_str, mtime_s, mtime_ns, &program, &chunk)
617 .unwrap();
618
619 let loaded = cache.get(&path_str, mtime_s, mtime_ns).unwrap();
620 assert_eq!(loaded.chunk.ops.len(), chunk.ops.len());
621
622 let (count, _bytes) = cache.stats();
623 assert_eq!(count, 1);
624 }
625
626 #[test]
627 fn mtime_invalidation() {
628 let dir = tempdir().unwrap();
629 let cache_path = dir.path().join("scripts.rkyv");
630 let cache = ScriptCache::open(&cache_path).unwrap();
631
632 let script_path = dir.path().join("test.stk");
633 std::fs::write(&script_path, "p 42").unwrap();
634
635 let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
636 let path_str = script_path.to_string_lossy().to_string();
637
638 let program = crate::parse("p 42").unwrap();
639 let chunk = crate::compiler::Compiler::new()
640 .compile_program(&program)
641 .unwrap();
642
643 cache
644 .put(&path_str, mtime_s, mtime_ns, &program, &chunk)
645 .unwrap();
646
647 assert!(cache.get(&path_str, mtime_s + 1, mtime_ns).is_none());
648 }
649
650 #[test]
651 fn second_put_replaces_first() {
652 let dir = tempdir().unwrap();
653 let cache_path = dir.path().join("scripts.rkyv");
654 let cache = ScriptCache::open(&cache_path).unwrap();
655
656 let p1 = dir.path().join("a.stk");
657 let p2 = dir.path().join("b.stk");
658 std::fs::write(&p1, "1").unwrap();
659 std::fs::write(&p2, "2").unwrap();
660
661 let (m1s, m1n) = file_mtime(&p1).unwrap();
662 let (m2s, m2n) = file_mtime(&p2).unwrap();
663
664 let prog1 = crate::parse("1").unwrap();
665 let chunk1 = crate::compiler::Compiler::new()
666 .compile_program(&prog1)
667 .unwrap();
668 let prog2 = crate::parse("2").unwrap();
669 let chunk2 = crate::compiler::Compiler::new()
670 .compile_program(&prog2)
671 .unwrap();
672
673 cache
674 .put(&p1.to_string_lossy(), m1s, m1n, &prog1, &chunk1)
675 .unwrap();
676 cache
677 .put(&p2.to_string_lossy(), m2s, m2n, &prog2, &chunk2)
678 .unwrap();
679
680 let (count, _) = cache.stats();
681 assert_eq!(count, 2);
682 assert!(cache.get(&p1.to_string_lossy(), m1s, m1n).is_some());
683 assert!(cache.get(&p2.to_string_lossy(), m2s, m2n).is_some());
684 }
685
686 #[test]
687 fn corrupt_file_returns_no_mmap() {
688 let dir = tempdir().unwrap();
689 let cache_path = dir.path().join("scripts.rkyv");
690 std::fs::write(&cache_path, b"this is not a valid rkyv archive").unwrap();
691 let cache = ScriptCache::open(&cache_path).unwrap();
692 assert!(cache.get("/nope", 0, 0).is_none());
696 }
697
698 #[test]
699 fn clear_removes_file() {
700 let dir = tempdir().unwrap();
701 let cache_path = dir.path().join("scripts.rkyv");
702 let cache = ScriptCache::open(&cache_path).unwrap();
703
704 let script_path = dir.path().join("test.stk");
705 std::fs::write(&script_path, "p 42").unwrap();
706 let (mtime_s, mtime_ns) = file_mtime(&script_path).unwrap();
707 let program = crate::parse("p 42").unwrap();
708 let chunk = crate::compiler::Compiler::new()
709 .compile_program(&program)
710 .unwrap();
711 cache
712 .put(
713 &script_path.to_string_lossy(),
714 mtime_s,
715 mtime_ns,
716 &program,
717 &chunk,
718 )
719 .unwrap();
720 assert!(cache_path.exists());
721
722 cache.clear().unwrap();
723 assert!(!cache_path.exists());
724 }
725}