Skip to main content

oxicuda_ptx/
cache.rs

1//! Disk-based PTX kernel cache.
2//!
3//! [`PtxCache`] provides persistent caching of generated PTX text on disk,
4//! keyed by kernel name, parameter hash, and target architecture. This avoids
5//! redundant PTX generation for kernels that have already been compiled.
6//!
7//! The cache stores files at `~/.cache/oxicuda/ptx/` (or a fallback location
8//! under `std::env::temp_dir()` if the home directory is unavailable).
9//!
10//! # Example
11//!
12//! ```
13//! use oxicuda_ptx::cache::{PtxCache, PtxCacheKey};
14//! use oxicuda_ptx::arch::SmVersion;
15//!
16//! let cache = PtxCache::new().expect("cache init failed");
17//! let key = PtxCacheKey {
18//!     kernel_name: "vector_add".to_string(),
19//!     params_hash: 0x12345678,
20//!     sm_version: SmVersion::Sm80,
21//! };
22//!
23//! let ptx = cache.get_or_generate(&key, || {
24//!     Ok("// generated PTX".to_string())
25//! }).expect("generation failed");
26//! assert!(ptx.contains("generated PTX"));
27//! # cache.clear().ok();
28//! ```
29
30use std::collections::hash_map::DefaultHasher;
31use std::hash::{Hash, Hasher};
32use std::path::{Path, PathBuf};
33
34use crate::arch::SmVersion;
35use crate::error::PtxGenError;
36
37/// Disk-based PTX kernel cache.
38///
39/// Caches generated PTX text files on disk to avoid redundant code generation.
40/// Files are stored as `{kernel_name}_{sm}_{hash:016x}.ptx` in the cache
41/// directory.
42pub struct PtxCache {
43    /// The root cache directory.
44    cache_dir: PathBuf,
45}
46
47/// Cache lookup key for PTX kernels.
48///
49/// The key combines the kernel name, a hash of the generation parameters,
50/// and the target architecture to produce a unique filename.
51#[derive(Debug, Clone, Hash)]
52pub struct PtxCacheKey {
53    /// The kernel function name.
54    pub kernel_name: String,
55    /// A hash of the kernel generation parameters (tile sizes, precisions, etc.).
56    pub params_hash: u64,
57    /// The target GPU architecture.
58    pub sm_version: SmVersion,
59}
60
61impl PtxCacheKey {
62    /// Converts this key to a filename suitable for disk storage.
63    ///
64    /// Format: `{kernel_name}_{sm}_{combined_hash:016x}.ptx`
65    ///
66    /// The combined hash includes the generator version (`CARGO_PKG_VERSION`),
67    /// the `params_hash`, and the full key hash. Mixing the generator version
68    /// means a codegen upgrade automatically invalidates stale entries: the
69    /// same logical key hashes to a different filename after a version bump,
70    /// so previously-cached PTX from an older generator is never served.
71    #[must_use]
72    pub fn to_filename(&self) -> String {
73        let mut hasher = DefaultHasher::new();
74        // Generator version stamp: invalidates the cache across codegen upgrades.
75        env!("CARGO_PKG_VERSION").hash(&mut hasher);
76        self.hash(&mut hasher);
77        let full_hash = hasher.finish();
78        format!(
79            "{}_{}_{:016x}.ptx",
80            sanitize_filename(&self.kernel_name),
81            self.sm_version.as_ptx_str(),
82            full_hash
83        )
84    }
85}
86
87impl PtxCache {
88    /// Creates a new PTX cache, initializing the cache directory.
89    ///
90    /// The cache directory is `~/.cache/oxicuda/ptx/`. If the home directory
91    /// cannot be determined, falls back to `{temp_dir}/oxicuda_ptx_cache/`.
92    ///
93    /// # Errors
94    ///
95    /// Returns `std::io::Error` if the cache directory cannot be created.
96    pub fn new() -> Result<Self, std::io::Error> {
97        let cache_dir = resolve_cache_dir()?;
98        std::fs::create_dir_all(&cache_dir)?;
99        Ok(Self { cache_dir })
100    }
101
102    /// Creates a new PTX cache at a specific directory.
103    ///
104    /// Useful for testing or when a custom cache location is desired.
105    ///
106    /// # Errors
107    ///
108    /// Returns `std::io::Error` if the directory cannot be created.
109    pub fn with_dir(dir: PathBuf) -> Result<Self, std::io::Error> {
110        std::fs::create_dir_all(&dir)?;
111        Ok(Self { cache_dir: dir })
112    }
113
114    /// Returns the cache directory path.
115    #[must_use]
116    pub const fn cache_dir(&self) -> &PathBuf {
117        &self.cache_dir
118    }
119
120    /// Looks up a cached PTX string, or generates and caches it if not found.
121    ///
122    /// If the cache contains a file matching the key, its contents are returned
123    /// directly. Otherwise, the `generate` closure is called to produce the PTX
124    /// text, which is then written to the cache before being returned.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`PtxGenError`] if:
129    /// - The generate closure fails
130    /// - Disk I/O fails during read or write
131    pub fn get_or_generate<F>(&self, key: &PtxCacheKey, generate: F) -> Result<String, PtxGenError>
132    where
133        F: FnOnce() -> Result<String, PtxGenError>,
134    {
135        let path = self.cache_dir.join(key.to_filename());
136
137        // Try to read from cache
138        match std::fs::read_to_string(&path) {
139            Ok(contents) if !contents.is_empty() => return Ok(contents),
140            _ => {}
141        }
142
143        // Generate fresh PTX
144        let ptx = generate()?;
145
146        // Write to cache atomically (best-effort; cache write failure is
147        // non-fatal). The tmp+fsync+rename pattern guarantees that concurrent
148        // writers never observe a torn or interleaved file.
149        if let Err(e) = atomic_write(&path, &ptx) {
150            // Log the error but don't fail the generation
151            eprintln!(
152                "oxicuda-ptx: cache write failed for {}: {e}",
153                path.display()
154            );
155        }
156
157        Ok(ptx)
158    }
159
160    /// Retrieves cached PTX for the given key, if it exists.
161    ///
162    /// Returns `None` if no cached entry is found or the file is empty.
163    #[must_use]
164    pub fn get(&self, key: &PtxCacheKey) -> Option<String> {
165        let path = self.cache_dir.join(key.to_filename());
166        match std::fs::read_to_string(&path) {
167            Ok(contents) if !contents.is_empty() => Some(contents),
168            _ => None,
169        }
170    }
171
172    /// Stores PTX text in the cache under the given key.
173    ///
174    /// # Errors
175    ///
176    /// Returns `std::io::Error` if the write fails.
177    pub fn put(&self, key: &PtxCacheKey, ptx: &str) -> Result<(), std::io::Error> {
178        let path = self.cache_dir.join(key.to_filename());
179        atomic_write(&path, ptx)
180    }
181
182    /// Removes all cached PTX files from the cache directory.
183    ///
184    /// Only removes `.ptx` files; other files and subdirectories are left intact.
185    ///
186    /// # Errors
187    ///
188    /// Returns `std::io::Error` if directory listing or file removal fails.
189    pub fn clear(&self) -> Result<(), std::io::Error> {
190        let entries = std::fs::read_dir(&self.cache_dir)?;
191        for entry in entries {
192            let entry = entry?;
193            let path = entry.path();
194            if path.extension().and_then(|e| e.to_str()) == Some("ptx") {
195                std::fs::remove_file(&path)?;
196            }
197        }
198        Ok(())
199    }
200
201    /// Returns the number of cached PTX files.
202    ///
203    /// # Errors
204    ///
205    /// Returns `std::io::Error` if the directory cannot be read.
206    pub fn len(&self) -> Result<usize, std::io::Error> {
207        let entries = std::fs::read_dir(&self.cache_dir)?;
208        let count = entries
209            .filter_map(Result::ok)
210            .filter(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("ptx"))
211            .count();
212        Ok(count)
213    }
214
215    /// Returns `true` if the cache contains no PTX files.
216    ///
217    /// # Errors
218    ///
219    /// Returns `std::io::Error` if the directory cannot be read.
220    pub fn is_empty(&self) -> Result<bool, std::io::Error> {
221        self.len().map(|n| n == 0)
222    }
223}
224
225/// Resolves the cache directory path, with fallback.
226///
227/// Preference order:
228/// 1. `$XDG_CACHE_HOME/oxicuda/ptx` (when set to an absolute path)
229/// 2. `$HOME/.cache/oxicuda/ptx` (or `%USERPROFILE%\.cache\...` on Windows)
230/// 3. A hardened, per-user directory under the system temp dir
231///
232/// The temp fallback (3) is the security-sensitive path: a shared world
233/// directory such as `/tmp` with a predictable name is a kernel-poisoning
234/// vector, so [`temp_fallback_dir`] makes the directory per-user, creates it
235/// with `0o700`, and verifies ownership and permissions before returning it.
236///
237/// # Errors
238///
239/// Returns `std::io::Error` if the temp fallback directory cannot be created
240/// safely (e.g. it already exists but is not owned by the current user or is
241/// group/world-writable).
242fn resolve_cache_dir() -> std::io::Result<PathBuf> {
243    // Prefer XDG_CACHE_HOME (must be an absolute path per the XDG spec).
244    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
245        let xdg = PathBuf::from(xdg);
246        if xdg.is_absolute() {
247            return Ok(xdg.join("oxicuda").join("ptx"));
248        }
249    }
250
251    // Then ~/.cache/oxicuda/ptx/
252    if let Some(home) = home_dir() {
253        return Ok(home.join(".cache").join("oxicuda").join("ptx"));
254    }
255
256    // Hardened per-user temp fallback.
257    temp_fallback_dir()
258}
259
260/// Attempts to determine the user's home directory.
261///
262/// Checks `HOME` (Unix) and `USERPROFILE` (Windows) environment variables.
263fn home_dir() -> Option<PathBuf> {
264    std::env::var_os("HOME")
265        .or_else(|| std::env::var_os("USERPROFILE"))
266        .map(PathBuf::from)
267}
268
269/// Returns the current effective user id by probing a freshly created file.
270///
271/// A newly created file is owned by the process's effective uid, so its owner
272/// metadata reveals our euid without any C dependency.
273#[cfg(unix)]
274fn current_euid() -> Option<u32> {
275    use std::os::unix::fs::MetadataExt;
276
277    let nanos = std::time::SystemTime::now()
278        .duration_since(std::time::UNIX_EPOCH)
279        .map_or(0, |d| d.as_nanos());
280    let probe =
281        std::env::temp_dir().join(format!("oxicuda_uid_probe_{}_{nanos}", std::process::id()));
282    let uid = std::fs::OpenOptions::new()
283        .write(true)
284        .create_new(true)
285        .open(&probe)
286        .ok()
287        .and_then(|_| std::fs::symlink_metadata(&probe).ok())
288        .map(|m| m.uid());
289    let _ = std::fs::remove_file(&probe);
290    uid
291}
292
293/// Creates and validates a hardened per-user cache directory under the system
294/// temp directory.
295///
296/// The directory is named `oxicuda_ptx_cache_{uid}`, created with mode `0o700`,
297/// and then verified to be a real directory owned by the current user with no
298/// group- or other-write permission bits. This closes the predictable
299/// world-writable `/tmp` kernel-poisoning vector.
300#[cfg(unix)]
301fn temp_fallback_dir() -> std::io::Result<PathBuf> {
302    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
303
304    let uid = current_euid().unwrap_or(0);
305    let dir = std::env::temp_dir().join(format!("oxicuda_ptx_cache_{uid}"));
306
307    std::fs::DirBuilder::new()
308        .recursive(true)
309        .mode(0o700)
310        .create(&dir)?;
311
312    // Use symlink_metadata so a symlink planted at the path is not followed.
313    let meta = std::fs::symlink_metadata(&dir)?;
314    if !meta.is_dir() {
315        return Err(std::io::Error::new(
316            std::io::ErrorKind::AlreadyExists,
317            "cache fallback path exists but is not a directory",
318        ));
319    }
320    if meta.uid() != uid {
321        return Err(std::io::Error::new(
322            std::io::ErrorKind::PermissionDenied,
323            "cache fallback directory is not owned by the current user",
324        ));
325    }
326    if meta.permissions().mode() & 0o022 != 0 {
327        return Err(std::io::Error::new(
328            std::io::ErrorKind::PermissionDenied,
329            "cache fallback directory is group- or world-writable",
330        ));
331    }
332    Ok(dir)
333}
334
335/// Non-Unix temp fallback: the system temp directory is per-user on Windows,
336/// so a fixed subdirectory name is sufficient.
337#[cfg(not(unix))]
338fn temp_fallback_dir() -> std::io::Result<PathBuf> {
339    Ok(std::env::temp_dir().join("oxicuda_ptx_cache"))
340}
341
342/// Atomically writes `contents` to `path` via a temp file, `fsync`, and rename.
343///
344/// The temp file lives in the same directory (so the final `rename(2)` is
345/// atomic on the same filesystem) and is opened with `create_new` (`O_EXCL`),
346/// which refuses to follow a pre-planted symlink at the temp name. The rename
347/// replaces any destination symlink instead of following it, so concurrent
348/// writers never observe a torn or interleaved file and a symlink at the final
349/// path cannot redirect the write. On any failure the temp file is removed.
350///
351/// # Errors
352///
353/// Returns `std::io::Error` if the temp file cannot be created/written/synced
354/// or the rename fails.
355fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
356    use std::io::Write as _;
357
358    let dir = path.parent().ok_or_else(|| {
359        std::io::Error::new(
360            std::io::ErrorKind::InvalidInput,
361            "cache path has no parent directory",
362        )
363    })?;
364    let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("cache");
365
366    // Per-writer temp name (process + thread) with a `.tmp` extension so the
367    // `.ptx`-only `clear`/`len` scans never count it as a cache entry.
368    let tmp = dir.join(format!(
369        ".{file_name}.{}.{:?}.tmp",
370        std::process::id(),
371        std::thread::current().id()
372    ));
373
374    let write_result = (|| -> std::io::Result<()> {
375        let mut file = std::fs::OpenOptions::new()
376            .write(true)
377            .create_new(true)
378            .open(&tmp)?;
379        file.write_all(contents.as_bytes())?;
380        file.sync_all()?;
381        Ok(())
382    })();
383
384    if let Err(e) = write_result {
385        let _ = std::fs::remove_file(&tmp);
386        return Err(e);
387    }
388
389    if let Err(e) = std::fs::rename(&tmp, path) {
390        let _ = std::fs::remove_file(&tmp);
391        return Err(e);
392    }
393    Ok(())
394}
395
396/// Sanitizes a string for use as part of a filename.
397///
398/// Replaces any character that is not alphanumeric, underscore, or hyphen
399/// with an underscore.
400fn sanitize_filename(name: &str) -> String {
401    name.chars()
402        .map(|c| {
403            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
404                c
405            } else {
406                '_'
407            }
408        })
409        .collect()
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    /// Returns a unique temp directory for a test, using a counter to avoid collisions.
417    fn test_cache_dir_named(name: &str) -> PathBuf {
418        std::env::temp_dir()
419            .join("oxicuda_ptx_cache_test")
420            .join(format!("{}_{}", name, std::process::id()))
421    }
422
423    fn cleanup(dir: &PathBuf) {
424        let _ = std::fs::remove_dir_all(dir);
425    }
426
427    #[test]
428    fn cache_key_to_filename() {
429        let key = PtxCacheKey {
430            kernel_name: "vector_add".to_string(),
431            params_hash: 0xDEAD_BEEF,
432            sm_version: SmVersion::Sm80,
433        };
434        let filename = key.to_filename();
435        assert!(filename.starts_with("vector_add_sm_80_"));
436        assert!(
437            std::path::Path::new(&filename)
438                .extension()
439                .is_some_and(|ext| ext.eq_ignore_ascii_case("ptx"))
440        );
441    }
442
443    #[test]
444    fn cache_key_sanitization() {
445        let key = PtxCacheKey {
446            kernel_name: "my.kernel/v2".to_string(),
447            params_hash: 42,
448            sm_version: SmVersion::Sm90,
449        };
450        let filename = key.to_filename();
451        assert!(
452            !filename.contains('.')
453                || std::path::Path::new(&filename)
454                    .extension()
455                    .is_some_and(|ext| ext.eq_ignore_ascii_case("ptx"))
456        );
457        // The kernel name part should not contain dots or slashes
458        let prefix = filename.split("_sm_90_").next().unwrap_or("");
459        assert!(!prefix.contains('/'));
460    }
461
462    #[test]
463    fn cache_new_and_clear() {
464        let dir = test_cache_dir_named("new_and_clear");
465        cleanup(&dir);
466
467        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
468        assert!(cache.is_empty().expect("should check empty"));
469
470        let key = PtxCacheKey {
471            kernel_name: "test".to_string(),
472            params_hash: 1,
473            sm_version: SmVersion::Sm80,
474        };
475        cache.put(&key, "// test ptx").expect("put should succeed");
476        assert!(!cache.is_empty().expect("should check non-empty"));
477        assert_eq!(cache.len().expect("len"), 1);
478
479        cache.clear().expect("clear should succeed");
480        assert!(cache.is_empty().expect("should be empty after clear"));
481
482        cleanup(&dir);
483    }
484
485    #[test]
486    fn get_or_generate_caches_result() {
487        let dir = test_cache_dir_named("get_or_generate");
488        cleanup(&dir);
489
490        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
491
492        let key = PtxCacheKey {
493            kernel_name: "cached_kernel".to_string(),
494            params_hash: 42,
495            sm_version: SmVersion::Sm80,
496        };
497
498        let mut call_count = 0u32;
499
500        // First call should generate
501        let ptx1 = cache
502            .get_or_generate(&key, || {
503                call_count += 1;
504                Ok("// generated ptx v1".to_string())
505            })
506            .expect("should generate");
507        assert_eq!(ptx1, "// generated ptx v1");
508        assert_eq!(call_count, 1);
509
510        // Second call should hit cache
511        let ptx2 = cache
512            .get_or_generate(&key, || {
513                call_count += 1;
514                Ok("// should not be called".to_string())
515            })
516            .expect("should cache hit");
517        assert_eq!(ptx2, "// generated ptx v1");
518        assert_eq!(call_count, 1);
519
520        cleanup(&dir);
521    }
522
523    #[test]
524    fn get_nonexistent_returns_none() {
525        let dir = test_cache_dir_named("get_nonexistent");
526        cleanup(&dir);
527
528        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
529        let key = PtxCacheKey {
530            kernel_name: "nonexistent".to_string(),
531            params_hash: 0,
532            sm_version: SmVersion::Sm80,
533        };
534        assert!(cache.get(&key).is_none());
535
536        cleanup(&dir);
537    }
538
539    #[test]
540    fn sanitize_filename_fn() {
541        assert_eq!(sanitize_filename("hello_world"), "hello_world");
542        assert_eq!(sanitize_filename("foo.bar/baz"), "foo_bar_baz");
543        assert_eq!(sanitize_filename("a b c"), "a_b_c");
544    }
545
546    // -------------------------------------------------------------------------
547    // P7: PTX disk cache round-trip tests
548    // -------------------------------------------------------------------------
549
550    /// Store a PTX string, retrieve it, and verify it is byte-for-byte identical.
551    #[test]
552    fn test_cache_round_trip() {
553        let dir = test_cache_dir_named("round_trip");
554        cleanup(&dir);
555
556        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
557        let key = PtxCacheKey {
558            kernel_name: "round_trip_kernel".to_string(),
559            params_hash: 0xABCD_1234,
560            sm_version: SmVersion::Sm80,
561        };
562        let original = "// round-trip PTX content\n.version 8.0\n.target sm_80\n";
563
564        cache.put(&key, original).expect("put should succeed");
565        let retrieved = cache.get(&key).expect("get should return cached value");
566        assert_eq!(
567            original, retrieved,
568            "retrieved PTX must be identical to stored"
569        );
570
571        cleanup(&dir);
572    }
573
574    /// The same key always retrieves the same content.
575    #[test]
576    fn test_cache_same_key_same_content() {
577        let dir = test_cache_dir_named("same_key");
578        cleanup(&dir);
579
580        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
581        let key = PtxCacheKey {
582            kernel_name: "stable_kernel".to_string(),
583            params_hash: 0x1111_2222,
584            sm_version: SmVersion::Sm90,
585        };
586        let ptx = "// stable content";
587
588        cache.put(&key, ptx).expect("first put should succeed");
589        let first = cache.get(&key).expect("first get should succeed");
590        let second = cache.get(&key).expect("second get should succeed");
591        assert_eq!(
592            first, second,
593            "same key must return identical content on repeated lookups"
594        );
595
596        cleanup(&dir);
597    }
598
599    /// Different keys store and retrieve independent content.
600    #[test]
601    fn test_cache_different_keys() {
602        let dir = test_cache_dir_named("diff_keys");
603        cleanup(&dir);
604
605        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
606        let key_a = PtxCacheKey {
607            kernel_name: "kernel_a".to_string(),
608            params_hash: 0x0000_0001,
609            sm_version: SmVersion::Sm80,
610        };
611        let key_b = PtxCacheKey {
612            kernel_name: "kernel_b".to_string(),
613            params_hash: 0x0000_0002,
614            sm_version: SmVersion::Sm80,
615        };
616
617        cache
618            .put(&key_a, "// PTX for kernel A")
619            .expect("put A should succeed");
620        cache
621            .put(&key_b, "// PTX for kernel B")
622            .expect("put B should succeed");
623
624        let content_a = cache.get(&key_a).expect("get A should succeed");
625        let content_b = cache.get(&key_b).expect("get B should succeed");
626
627        assert_eq!(content_a, "// PTX for kernel A");
628        assert_eq!(content_b, "// PTX for kernel B");
629        assert_ne!(
630            content_a, content_b,
631            "different keys must retrieve different content"
632        );
633
634        cleanup(&dir);
635    }
636
637    /// A cache hit must avoid calling the generation closure a second time.
638    #[test]
639    fn test_cache_hit_avoids_regeneration() {
640        let dir = test_cache_dir_named("hit_avoids_regen");
641        cleanup(&dir);
642
643        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
644        let key = PtxCacheKey {
645            kernel_name: "hit_kernel".to_string(),
646            params_hash: 0xCAFE_BABE,
647            sm_version: SmVersion::Sm80,
648        };
649
650        let mut call_count: u32 = 0;
651
652        // First call — cache miss, generation runs
653        let ptx_first = cache
654            .get_or_generate(&key, || {
655                call_count += 1;
656                Ok("// generated".to_string())
657            })
658            .expect("first generation should succeed");
659        assert_eq!(
660            call_count, 1,
661            "generation closure must be called on cache miss"
662        );
663
664        // Second call — cache hit, generation must NOT run
665        let ptx_second = cache
666            .get_or_generate(&key, || {
667                call_count += 1;
668                Ok("// should not be called".to_string())
669            })
670            .expect("second call should hit cache");
671        assert_eq!(
672            call_count, 1,
673            "generation closure must not be called on cache hit"
674        );
675        assert_eq!(
676            ptx_first, ptx_second,
677            "cache hit must return original content"
678        );
679
680        cleanup(&dir);
681    }
682
683    /// Concurrent writers of the same key must never leave a torn file: the
684    /// final content must be exactly one of the written payloads.
685    #[test]
686    fn test_atomic_write_no_torn_file() {
687        let dir = test_cache_dir_named("atomic_concurrent");
688        cleanup(&dir);
689        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
690
691        let key = PtxCacheKey {
692            kernel_name: "atomic_kernel".to_string(),
693            params_hash: 0x5151_5151,
694            sm_version: SmVersion::Sm80,
695        };
696
697        // A large distinctive payload makes a torn/interleaved write obvious.
698        let payload_a = "// PAYLOAD_A ".repeat(4096);
699        let payload_b = "// PAYLOAD_B ".repeat(4096);
700
701        std::thread::scope(|scope| {
702            for _ in 0..8 {
703                let cache_ref = &cache;
704                let key_ref = &key;
705                let a = payload_a.as_str();
706                let b = payload_b.as_str();
707                scope.spawn(move || {
708                    for i in 0..20 {
709                        let payload = if i % 2 == 0 { a } else { b };
710                        let _ = cache_ref.put(key_ref, payload);
711                    }
712                });
713            }
714        });
715
716        // Whatever won the race, the file must equal exactly one payload — never
717        // a mix of the two.
718        let final_content = cache.get(&key).expect("a value must be present");
719        assert!(
720            final_content == payload_a || final_content == payload_b,
721            "cache file is torn: length {} is neither payload length ({} / {})",
722            final_content.len(),
723            payload_a.len(),
724            payload_b.len()
725        );
726
727        // Temp files must not be counted as cache entries.
728        assert_eq!(
729            cache.len().expect("len"),
730            1,
731            "only the .ptx file should count"
732        );
733
734        cleanup(&dir);
735    }
736
737    /// A cache miss for an unknown key always triggers the generation closure.
738    #[test]
739    fn test_cache_miss_for_new_key() {
740        let dir = test_cache_dir_named("miss_new_key");
741        cleanup(&dir);
742
743        let cache = PtxCache::with_dir(dir.clone()).expect("cache creation should succeed");
744
745        let mut call_count: u32 = 0;
746
747        // Each distinct key must produce a cache miss
748        for i in 0u64..3 {
749            let key = PtxCacheKey {
750                kernel_name: format!("miss_kernel_{i}"),
751                params_hash: i,
752                sm_version: SmVersion::Sm80,
753            };
754            cache
755                .get_or_generate(&key, || {
756                    call_count += 1;
757                    Ok(format!("// ptx for key {i}"))
758                })
759                .expect("generation should succeed");
760        }
761
762        assert_eq!(
763            call_count, 3,
764            "each new key must trigger one generation call"
765        );
766
767        cleanup(&dir);
768    }
769}