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