Skip to main content

tensor_wasm_jit/
cache.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! Compiled-kernel cache.
4//!
5//! The cache is keyed by `(blueprint_fingerprint, sm_version)`. LRU eviction
6//! caps the entry count. On a cache hit the PTX text is reused without
7//! re-emitting (cheap) and without re-compiling via `ptxas`/`cust` (expensive
8//! — 10-50 ms for non-trivial kernels).
9//!
10//! Storage: a [`dashmap::DashMap`] holds the actual `(CacheKey, CachedKernel)`
11//! entries — `get` / `put` / `len` go straight through the lock-free
12//! per-shard locks so the hot path is uncontended under multi-threaded
13//! dispatch. A separate `parking_lot::Mutex<LruCache<CacheKey, ()>>` is the
14//! eviction queue — touched on `put` (insert/promote) and on `get` (promote)
15//! and used to compute which key to evict when the soft cap is exceeded.
16//! Splitting storage from policy means lookups never block on the eviction
17//! mutex, only inserts that need to evict do. The `get` read path is
18//! lock-free on contention; LRU promotion is best-effort under load —
19//! contended promotions are skipped, so eviction order is approximately
20//! (not strictly) LRU when many readers race.
21//!
22//! `Mutex` poisoning recovery: every lock acquisition uses `into_inner` on a
23//! poisoned guard (after emitting a `tracing::error!`) rather than the prior
24//! `.expect("cache poisoned")` panic — a single panic on any thread used to
25//! poison the entire cache for the rest of the process.
26
27use std::num::NonZeroUsize;
28use std::path::PathBuf;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::Arc;
31
32use dashmap::DashMap;
33use lru::LruCache;
34use parking_lot::Mutex;
35use tensor_wasm_artifacts::{ArtifactError, ArtifactStore, ContentHash, DiskArtifactStore};
36use tensor_wasm_core::types::TenantId;
37use zeroize::Zeroizing;
38
39use crate::ir::TensorWasmKernelBlueprint;
40use crate::ptx_emit::EmittedPtx;
41#[cfg(feature = "kernel-registry")]
42use crate::registry::{BlueprintResolver, KernelRegistry};
43
44/// Cache key.
45///
46/// `tenant_id` is the first field so that it dominates the derived `Hash`
47/// (field-order) and lexicographic `Ord` orderings. Keeping the cache keyed
48/// by tenant is the only thing preventing tenant A from looking up — and on
49/// the CUDA path executing — a compiled kernel that tenant B installed
50/// (exec S-7, cross-tenant confused-deputy). Every host-side `get` / `put`
51/// MUST therefore include the calling tenant; constructing a key without
52/// one (e.g. directly from guest-supplied bytes) is the bug we are fixing.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
54pub struct CacheKey {
55    /// Owning tenant. Cache lookups MUST be scoped to the caller's tenant —
56    /// see the type-level docs. Use [`CacheKey::for_tenant`] to construct.
57    pub tenant_id: u64,
58    /// `TensorWasmKernelBlueprint::fingerprint()`.
59    pub blueprint: u64,
60    /// CUDA compute capability (e.g. 80 for sm_80, 89 for sm_89).
61    pub sm_version: u32,
62    /// Hash of the full [`crate::ptx_emit::EmitConfig`] used at emit time
63    /// (jit S-2). `sm_version` covers the compute capability number but NOT
64    /// the architecture suffix (e.g. `"sm_80"` vs `"sm_80a"`), the PTX
65    /// language version, or the `launch_bounds` flag — two `EmitConfig`s
66    /// that differ in any of those produce non-interchangeable PTX. Without
67    /// this field two such configs would collide on the same key and the
68    /// second caller would silently get the first caller's PTX.
69    ///
70    /// Callers that don't have an `EmitConfig` (rewriter pre-population,
71    /// benches) pass `0`. Construct via [`CacheKey::for_tenant`] (defaults
72    /// to `0`) or [`CacheKey::for_tenant_with_emit_config`] (computes a
73    /// stable hash over the config).
74    pub emit_config_hash: u64,
75}
76
77impl CacheKey {
78    /// Construct a tenant-scoped cache key with no `EmitConfig` hash.
79    ///
80    /// Equivalent to passing `emit_config_hash: 0` — appropriate for the
81    /// rewriter and bench paths that use the default emitter config. The
82    /// `tenant_id` MUST come from trusted store state (e.g.
83    /// `InstanceState::tenant_id`), never from guest-supplied fingerprint
84    /// bytes. See the [`CacheKey`] docs for the confused-deputy primitive
85    /// this guards against.
86    pub fn for_tenant(tenant_id: TenantId, blueprint: u64, sm_version: u32) -> Self {
87        Self {
88            tenant_id: tenant_id.get(),
89            blueprint,
90            sm_version,
91            emit_config_hash: 0,
92        }
93    }
94
95    /// Construct a tenant-scoped cache key that also covers the emitter
96    /// config. Use this when the lookup must distinguish between
97    /// PTX-version variants, target-architecture suffixes, or
98    /// launch-bounds settings (jit S-2).
99    ///
100    /// Cost note: each call builds a fresh `blake3::Hasher` and finalises
101    /// over a handful of bytes. The amortised wall cost is ~µs on a
102    /// modern x86-64 box — negligible compared to a kernel dispatch but
103    /// non-zero on the hot path. Callers that resolve the same
104    /// `EmitConfig` for every lookup (the typical pattern: emit-config is
105    /// pinned at instance-spawn time) should hash it once at spawn and
106    /// reuse the [`CacheKey`] rather than re-deriving it for every
107    /// dispatch. The hasher itself is intentionally inline here (not
108    /// memoised) so the function stays pure and `Send`-friendly for the
109    /// rewriter's `rayon::par_iter` callers.
110    pub fn for_tenant_with_emit_config(
111        tenant_id: TenantId,
112        blueprint: u64,
113        sm_version: u32,
114        cfg: &crate::ptx_emit::EmitConfig,
115    ) -> Self {
116        let emit_config_hash = blake3::Hasher::new()
117            .update(b"tensor-wasm-jit::EmitConfig::v1\0")
118            .update(cfg.target.as_bytes())
119            .update(b"\0")
120            .update(cfg.ptx_version.as_bytes())
121            .update(b"\0")
122            .update(&[u8::from(cfg.launch_bounds)])
123            .finalize();
124        let bytes = emit_config_hash.as_bytes();
125        let mut buf = [0u8; 8];
126        buf.copy_from_slice(&bytes[..8]);
127        Self {
128            tenant_id: tenant_id.get(),
129            blueprint,
130            sm_version,
131            emit_config_hash: u64::from_le_bytes(buf),
132        }
133    }
134}
135
136/// Default cache capacity (kernels).
137///
138/// Memory-ceiling note: each entry holds an `Arc<EmittedPtx>` whose `text`
139/// is the emitted PTX string. Typical kernels emit ~5-15 KB of PTX (a
140/// vector-add lands around 2 KB; a small fused matmul around 12 KB), so at
141/// 256 entries the steady-state L1 footprint is on the order of ~2.5 MB
142/// (~10 KB PTX × 256) plus the per-entry `BLAKE3` hash (32 B) and the
143/// LRU policy queue (one `CacheKey` per entry, 24 B). Hostile or
144/// pathological blueprints could push individual entries into the
145/// multi-MB range — a deliberately unrolled blueprint emitting 10 MB of
146/// PTX would push the 256-slot cache to ~2.5 GB. Operators expecting
147/// adversarial workloads should clamp this via
148/// [`KernelCache::with_capacity`] (lower) and pair it with the on-disk
149/// L2 cache so cold lookups still hit a persisted path. The cache does
150/// NOT enforce a per-entry byte limit; the count cap is the only knob.
151pub const DEFAULT_CAPACITY: usize = 256;
152
153/// Construction-time configuration for [`KernelCache`].
154///
155/// Holds the small set of policy knobs the cache supports today: capacity
156/// (count cap) and whether to recompute the per-entry BLAKE3 integrity
157/// hash on every `get`. Future knobs (per-byte cap, eviction policy
158/// choice) will land here without breaking the existing
159/// [`KernelCache::with_capacity`] / [`KernelCache::with_disk_persistence`]
160/// shorthands — those construct an equivalent `KernelCacheConfig` under
161/// the hood.
162///
163/// Construct via [`KernelCacheConfig::default`] (which mirrors the
164/// historical defaults — capacity [`DEFAULT_CAPACITY`], verify-on-get
165/// `true`) and refine with the `with_*` builders, then hand the config
166/// to [`KernelCache::with_config`].
167#[derive(Clone)]
168pub struct KernelCacheConfig {
169    /// Soft maximum L1 entry count. Clamped to `>= 1` inside the cache.
170    pub capacity: usize,
171    /// Optional soft maximum on the *total* bytes of cached PTX text held
172    /// in L1, summed across every live entry (each entry contributes
173    /// `cached.ptx.text.len()`). When `Some(cap)`, [`KernelCache::put`]
174    /// evicts LRU entries — using the same eviction queue the count cap
175    /// drives — until the running byte total fits under `cap`, *after*
176    /// admitting the new entry. The count cap ([`Self::capacity`]) still
177    /// applies independently; whichever cap binds first wins.
178    ///
179    /// Why this exists: the count cap alone is a DoS vector. A 256-slot
180    /// cache fed adversarial multi-MB PTX blueprints (a deliberately
181    /// unrolled kernel can emit 10 MB of PTX) reaches ~2.5 GB of resident
182    /// L1 — see the memory-ceiling note on [`DEFAULT_CAPACITY`]. The byte
183    /// cap bounds the worst case regardless of per-entry size.
184    ///
185    /// A single entry larger than `cap` is still admitted (the cache never
186    /// refuses an insert outright — it would otherwise wedge the dispatch
187    /// path) but it will evict every other entry first; the byte total may
188    /// transiently exceed `cap` by at most one such oversized entry.
189    ///
190    /// Default `None` (preserves the historical count-only behaviour). Set
191    /// via [`Self::with_max_total_bytes`]. Track the live total via
192    /// [`KernelCache::total_bytes`].
193    pub max_total_bytes: Option<u64>,
194    /// When `true` (the default), [`KernelCache::get`] recomputes a
195    /// BLAKE3 over the cached `ptx.text` on every L1 hit and compares
196    /// the result against the entry's stored `integrity_hash` (jit S-3
197    /// in-mem poisoning defence). When `false`, the recompute is skipped
198    /// — the cache still refuses entries whose stored hash is all-zero
199    /// (the construction signal for "built without [`CachedKernel::new`]")
200    /// as defence-in-depth.
201    ///
202    /// The recompute costs ~10 µs over a typical multi-KB PTX blob;
203    /// skipping it shaves that off every L1 hit at the cost of widening
204    /// the in-memory poisoning window from "one `get` call" to "the
205    /// lifetime of the entry in L1". Operators on a high-QPS path with
206    /// multi-MB PTX where the recompute dominates can opt out, but the
207    /// safe default is verify-on-get.
208    pub verify_on_get: bool,
209
210    #[cfg(feature = "kernel-registry")]
211    /// Optional registry consulted on L1+L2 miss. v0.4 path: caller resolves
212    /// (tenant, blueprint, sm_version) → (name, version) via an external
213    /// lookup, then `KernelCache::get_with_registry_fallback` consults the
214    /// registry by that pair. v0.3.8 ships a `resolve_by_blueprint_hint`
215    /// trait method on the cache config for the resolver step; the
216    /// in-memory test impl resolves blueprint fingerprint → name@version
217    /// directly via a `HashMap`.
218    pub registry: Option<Arc<dyn KernelRegistry>>,
219}
220
221impl Default for KernelCacheConfig {
222    fn default() -> Self {
223        Self {
224            capacity: DEFAULT_CAPACITY,
225            max_total_bytes: None,
226            verify_on_get: true,
227            #[cfg(feature = "kernel-registry")]
228            registry: None,
229        }
230    }
231}
232
233impl KernelCacheConfig {
234    /// Override the L1 entry count cap. Clamped to `>= 1` at cache
235    /// construction time; values below 1 are silently raised.
236    #[must_use]
237    pub fn with_capacity(mut self, capacity: usize) -> Self {
238        self.capacity = capacity;
239        self
240    }
241
242    /// Set the soft total-bytes cap on resident L1 PTX. `None` (the
243    /// default) preserves the historical count-only behaviour; `Some(cap)`
244    /// makes [`KernelCache::put`] evict LRU entries until the running PTX
245    /// byte total fits under `cap`. See the field-level docs on
246    /// [`Self::max_total_bytes`] for the DoS-mitigation rationale and the
247    /// single-oversized-entry caveat.
248    #[must_use]
249    pub fn with_max_total_bytes(mut self, max_total_bytes: u64) -> Self {
250        self.max_total_bytes = Some(max_total_bytes);
251        self
252    }
253
254    /// Toggle the per-`get` BLAKE3 recompute. Default `true`; setting
255    /// `false` is the high-QPS opt-out. See the field-level docs on
256    /// [`Self::verify_on_get`] for the threat-model trade-off.
257    #[must_use]
258    pub fn with_verify_on_get(mut self, on: bool) -> Self {
259        self.verify_on_get = on;
260        self
261    }
262
263    /// Attach a [`KernelRegistry`] as the L3 fallback consulted by
264    /// [`KernelCache::get_with_registry_fallback`] on an L1+L2 miss.
265    /// Default is `None` (registry path disabled). See the field-level
266    /// docs on [`Self::registry`] for the resolution contract.
267    #[cfg(feature = "kernel-registry")]
268    #[must_use]
269    pub fn with_registry(mut self, reg: Arc<dyn KernelRegistry>) -> Self {
270        self.registry = Some(reg);
271        self
272    }
273}
274
275// Manual `Debug` impl: `Arc<dyn KernelRegistry>` does not implement
276// `Debug` (the trait deliberately does not require it so embedder
277// backends like `InMemoryRegistry` — whose interior `Mutex<HashMap>`
278// is awkward to debug-format — stay easy to write). Render the
279// registry field as a presence-only marker so `{:?}` on a cache
280// config still surfaces whether the L3 path is wired without forcing
281// every registry impl to derive `Debug`.
282impl std::fmt::Debug for KernelCacheConfig {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        let mut d = f.debug_struct("KernelCacheConfig");
285        d.field("capacity", &self.capacity);
286        d.field("max_total_bytes", &self.max_total_bytes);
287        d.field("verify_on_get", &self.verify_on_get);
288        #[cfg(feature = "kernel-registry")]
289        d.field(
290            "registry",
291            &self.registry.as_ref().map(|_| "<dyn KernelRegistry>"),
292        );
293        d.finish()
294    }
295}
296
297/// Cached PTX module entry.
298///
299/// `integrity_hash` is a BLAKE3 over `ptx.text` computed at construction
300/// time and re-verified on every [`KernelCache::get`] (jit S-3). It defends
301/// against in-memory poisoning by a sibling holder of a mutable reference
302/// and forms the basis of the integrity tag stored in the on-disk
303/// persistence layer (see [`DiskCacheConfig`]). The field is `pub` so
304/// downstream tests and observability can inspect the recorded hash, but
305/// construction via [`CachedKernel::new`] is the only path that produces a
306/// correct-by-construction value — `KernelCache::put` calls `verify`
307/// before accepting the entry, so a hand-crafted `CachedKernel` with a
308/// mismatched hash will be rejected.
309#[derive(Debug, Clone)]
310pub struct CachedKernel {
311    /// The blueprint that produced this PTX (for diagnostics).
312    pub fingerprint: u64,
313    /// The emitted PTX text.
314    pub ptx: Arc<EmittedPtx>,
315    /// The cuda module handle is only meaningful when the `cuda` feature is
316    /// on; for the stub path we keep `()`.
317    pub compiled: CompiledHandle,
318    /// BLAKE3 over `ptx.text` (jit S-3). Recomputed and compared on every
319    /// `get`. See the type-level doc for the threat model.
320    ///
321    /// The field is `pub` for backward compatibility with v0.1 struct-literal
322    /// callers and so the forged-blob regression tests can hand-craft a
323    /// `CachedKernel` with a deliberately wrong hash and assert
324    /// [`KernelCache::put`] rejects it. It is hidden from generated docs and
325    /// excluded from the stable surface; prefer [`Self::integrity_hash`] for
326    /// read access and [`Self::new`] for construction.
327    #[doc(hidden)]
328    pub integrity_hash: [u8; 32],
329}
330
331impl CachedKernel {
332    /// Borrow the stored BLAKE3 integrity hash over `ptx.text`. Prefer this
333    /// over reaching for the (hidden) field directly — the field is
334    /// retained as `pub` for source compatibility only and may be sealed
335    /// to `pub(crate)` in a future minor release.
336    #[must_use]
337    pub fn integrity_hash(&self) -> &[u8; 32] {
338        &self.integrity_hash
339    }
340
341    /// Construct a `CachedKernel`, computing `integrity_hash` from
342    /// `ptx.text`. This is the only way to obtain a correct-by-
343    /// construction value; the older struct-literal pattern still works
344    /// (the field is `pub` for backward compatibility) but the literal
345    /// must provide a matching hash or `KernelCache::put` will reject it.
346    pub fn new(fingerprint: u64, ptx: Arc<EmittedPtx>, compiled: CompiledHandle) -> Self {
347        let h = blake3::hash(ptx.text.as_bytes());
348        Self {
349            fingerprint,
350            ptx,
351            compiled,
352            integrity_hash: *h.as_bytes(),
353        }
354    }
355
356    /// Re-compute the integrity hash and compare it against the stored
357    /// value. Returns `true` iff the PTX text has not been tampered with
358    /// since [`Self::new`] ran.
359    #[must_use]
360    pub fn verify_integrity(&self) -> bool {
361        let h = blake3::hash(self.ptx.text.as_bytes());
362        h.as_bytes() == &self.integrity_hash
363    }
364}
365
366/// Compiled-module handle. On CUDA hosts this would hold
367/// `cust::module::Module`; for the no-CUDA path it is just `()`.
368#[derive(Debug, Clone, Default)]
369pub struct CompiledHandle {
370    #[allow(dead_code)]
371    private: (),
372}
373
374/// Thread-safe LRU cache of compiled kernels backed by [`dashmap::DashMap`].
375///
376/// `get` and `put` are O(1) and (under typical concurrent workloads) lock-
377/// free except for the per-shard `dashmap` lock and the eviction-policy
378/// `parking_lot::Mutex`. The eviction lock is taken only on `put`s that
379/// would push the cache over capacity; reads never touch it.
380#[derive(Clone)]
381pub struct KernelCache {
382    /// Lock-free storage of the cached values themselves.
383    ///
384    /// T20 perf: values are held as `Arc<CachedKernel>` so a cache hit only
385    /// bumps the strong-count refcount instead of cloning the wrapper
386    /// (32-byte integrity hash + fingerprint + an inner `Arc<EmittedPtx>`
387    /// refcount bump). The inner PTX text was already `Arc`-shared; this
388    /// extension closes the matching gap on the outer wrapper.
389    storage: Arc<DashMap<CacheKey, Arc<CachedKernel>>>,
390    /// LRU policy: keys ordered by recency. `Mutex` (parking_lot) for
391    /// fast, panic-safe contention. The value side is `()` — the real value
392    /// lives in `storage`.
393    lru: Arc<Mutex<LruCache<CacheKey, ()>>>,
394    /// Construction-time policy bag (capacity, verify-on-get). Held by
395    /// value (cheap to clone, `Copy`-ish payload) so the `get` hot path
396    /// can read `verify_on_get` without an extra `Arc` deref.
397    config: KernelCacheConfig,
398    /// Optional L2 on-disk cache. When present (configured via
399    /// [`KernelCache::with_disk_persistence`]), `put` writes each entry to
400    /// disk under an HMAC-keyed integrity tag, and `get` falls through to
401    /// disk on an L1 miss — verifying the HMAC before deserialising. See
402    /// [`DiskCacheConfig`] for the threat model that motivates the design
403    /// (jit S-3).
404    disk: Option<Arc<DiskCache>>,
405    /// Running sum of the PTX-text bytes of every entry currently resident
406    /// in `storage` (each entry contributes `cached.ptx.text.len()`).
407    /// Maintained incrementally on `put` (add the inserted entry, subtract
408    /// every eviction) so the byte cap in
409    /// [`KernelCacheConfig::max_total_bytes`] can be enforced without
410    /// re-summing the whole map. Exposed via [`Self::total_bytes`] for the
411    /// Prometheus gauge `tensor_wasm_jit_cache_bytes`. `Arc`-shared so
412    /// cache clones agree on the total.
413    ///
414    /// Accuracy note: this is a best-effort accounting that tracks the
415    /// entries this cache instance inserted and evicted. The `verify_on_get`
416    /// failure path and L2 disk-hit promotion also keep it in step. Under
417    /// pathological concurrent races on the same key it may drift by a
418    /// bounded amount, but it is never used as a correctness gate — only to
419    /// drive best-effort byte-based eviction — so a transient skew at most
420    /// delays an eviction by one `put`.
421    total_bytes: Arc<AtomicU64>,
422    /// Cumulative count of `get` calls that skipped the BLAKE3 recompute
423    /// because [`KernelCacheConfig::verify_on_get`] was `false`.
424    /// Exposed via [`Self::verify_skipped_total`] so operators can wire
425    /// it to the Prometheus counter
426    /// `tensor_wasm_jit_cache_verify_skipped_total`. Wrapped in an `Arc`
427    /// so clones of `KernelCache` share the same counter (the storage
428    /// and LRU policy are likewise `Arc`-shared).
429    verify_skipped_total: Arc<AtomicU64>,
430    /// Cumulative count of `get` calls that returned an entry (L1 hit, L2
431    /// disk hit, or L3 registry hit). Exposed via [`Self::cache_hits_total`]
432    /// for the Prometheus counter `tensor_wasm_jit_cache_hits_total`.
433    /// `Arc`-shared so cache clones agree on the count.
434    cache_hits_total: Arc<AtomicU64>,
435    /// Cumulative count of `get` calls that returned `None` (full miss after
436    /// L1, L2, and any registry fallback). Exposed via
437    /// [`Self::cache_misses_total`] for the Prometheus counter
438    /// `tensor_wasm_jit_cache_misses_total`. `Arc`-shared so cache clones
439    /// agree on the count.
440    cache_misses_total: Arc<AtomicU64>,
441    /// Cumulative count of entries refused on `get` because their
442    /// `integrity_hash` failed verification (L1 recompute mismatch, an
443    /// all-zero hash on the verify-skip path, or an L2 disk integrity
444    /// failure). These rejections also bump `cache_misses_total`, but this
445    /// dedicated counter lets operators distinguish ordinary cache misses
446    /// from *tamper attempts* — a rising
447    /// `tensor_wasm_jit_cache_integrity_reject_total` is an alarm signal,
448    /// not just a cold cache. Exposed via [`Self::integrity_reject_total`].
449    /// `Arc`-shared so cache clones agree on the count.
450    integrity_reject_total: Arc<AtomicU64>,
451}
452
453impl KernelCache {
454    /// Construct with default capacity.
455    pub fn new() -> Self {
456        Self::with_config(KernelCacheConfig::default())
457    }
458
459    /// Construct with explicit capacity. Anything below 1 is clamped to 1.
460    pub fn with_capacity(cap: usize) -> Self {
461        Self::with_config(KernelCacheConfig::default().with_capacity(cap))
462    }
463
464    /// Construct from a full [`KernelCacheConfig`]. Anything below 1 in
465    /// `config.capacity` is clamped to 1.
466    pub fn with_config(mut config: KernelCacheConfig) -> Self {
467        config.capacity = config.capacity.max(1);
468        // The eviction queue is sized to `cap` so the LRU crate's internal
469        // bucket pre-allocation is bounded (sizing it to `usize::MAX` triggers
470        // a hashbrown capacity-overflow panic). Storage eviction is still
471        // driven from the `storage.len() > capacity` check in `put`; both
472        // sides agree on the same `cap` so they stay in sync.
473        let nz = NonZeroUsize::new(config.capacity).expect(">0 (clamped above)");
474        Self {
475            storage: Arc::new(DashMap::with_capacity(config.capacity)),
476            lru: Arc::new(Mutex::new(LruCache::new(nz))),
477            config,
478            disk: None,
479            total_bytes: Arc::new(AtomicU64::new(0)),
480            verify_skipped_total: Arc::new(AtomicU64::new(0)),
481            cache_hits_total: Arc::new(AtomicU64::new(0)),
482            cache_misses_total: Arc::new(AtomicU64::new(0)),
483            integrity_reject_total: Arc::new(AtomicU64::new(0)),
484        }
485    }
486
487    /// Enable the on-disk L2 cache, persisting entries to `cfg.dir` under
488    /// an HMAC-keyed integrity tag (jit S-3).
489    ///
490    /// Construct via:
491    /// ```
492    /// # // Hidden doctest stub — real deployments must read the key from
493    /// # // an out-of-process secret store (Vault, KMS, sealed file under
494    /// # // mode 0400, etc.) and never embed it in source. This stub lets
495    /// # // the doctest compile without shipping a fake key literal that
496    /// # // operators might copy-paste verbatim.
497    /// # fn load_hmac_key_from_secret_store() -> Result<[u8; 32], Box<dyn std::error::Error>> {
498    /// #     Ok([0u8; 32])
499    /// # }
500    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
501    /// use std::path::PathBuf;
502    /// use tensor_wasm_jit::cache::{KernelCache, DiskCacheConfig};
503    /// let cache = KernelCache::new().with_disk_persistence(DiskCacheConfig {
504    ///     dir: PathBuf::from("/var/cache/tensor-wasm/kernels"),
505    ///     hmac_key: load_hmac_key_from_secret_store()?, /* loaded from secrets at startup */
506    /// });
507    /// # let _ = cache;
508    /// # Ok(())
509    /// # }
510    /// ```
511    ///
512    /// jit S-3 (T13): the example deliberately routes the key through a
513    /// `load_hmac_key_from_secret_store()` stub rather than an inline
514    /// literal — operators have a habit of copy-pasting rustdoc examples
515    /// verbatim, and an embedded `[0xAB; 32]` (or similar) would survive
516    /// into production deployments as a fixed, attacker-known key.
517    ///
518    /// The directory is created lazily on the first `put`. The HMAC key
519    /// MUST be stable across process restarts AND treated as a server-
520    /// side secret — possession of the key lets an attacker forge cache
521    /// entries that the loader will accept as authentic (and that would
522    /// then be handed to `cust::module::Module::from_ptx` as trusted GPU
523    /// code).
524    #[must_use]
525    pub fn with_disk_persistence(mut self, cfg: DiskCacheConfig) -> Self {
526        // Share the integrity-rejection counter with the disk layer so an
527        // L2 tamper rejection bumps the same
528        // `tensor_wasm_jit_cache_integrity_reject_total` as an L1 one.
529        self.disk = Some(Arc::new(DiskCache::new(
530            cfg,
531            Arc::clone(&self.integrity_reject_total),
532        )));
533        self
534    }
535
536    /// Byte cost a single entry contributes to [`Self::total_bytes`]: the
537    /// length of its PTX text. The 32-byte integrity hash, fingerprint, and
538    /// LRU bookkeeping are fixed-size per entry and already bounded by the
539    /// count cap, so the byte cap deliberately accounts only the variable-
540    /// size payload that the DoS note on [`DEFAULT_CAPACITY`] flags.
541    fn entry_bytes(kernel: &CachedKernel) -> u64 {
542        kernel.ptx.text.len() as u64
543    }
544
545    /// Remove a key from storage *and* decrement the byte total by the
546    /// removed entry's payload size. Centralised so every eviction site
547    /// keeps [`Self::total_bytes`] in step with `storage`. Returns the
548    /// removed entry (if any) for callers that want it.
549    fn evict_key(&self, key: &CacheKey) -> Option<Arc<CachedKernel>> {
550        if let Some((_, removed)) = self.storage.remove(key) {
551            self.total_bytes
552                .fetch_sub(Self::entry_bytes(&removed), Ordering::Relaxed);
553            Some(removed)
554        } else {
555            None
556        }
557    }
558
559    /// Byte-cap eviction (DoS mitigation — see
560    /// [`KernelCacheConfig::max_total_bytes`]). Reuse the SAME LRU policy
561    /// queue that drives the count cap: pop least-recently-used keys and
562    /// evict them until the running PTX-byte total fits under `cap`. We
563    /// never evict down to an empty cache — the loop stops once at most one
564    /// entry (the most-recently-used, i.e. typically the entry just
565    /// inserted) remains, so a single entry larger than `cap` is still
566    /// served rather than wedging dispatch.
567    ///
568    /// A no-op when no byte cap is configured. Factored out of [`Self::put`]
569    /// so EVERY L1 insert site — `put` and the L2 disk-hit promotion in
570    /// [`Self::get`] — binds the byte cap, not just `put`. Holds the `lru`
571    /// lock across the whole eviction burst (one acquisition for the loop)
572    /// exactly as the count-cap loop does; `evict_key` keeps
573    /// [`Self::total_bytes`] in step with each storage removal.
574    fn enforce_byte_cap(&self) {
575        if let Some(cap) = self.config.max_total_bytes {
576            if self.total_bytes.load(Ordering::Relaxed) > cap {
577                let mut lru = self.lru.lock();
578                while self.total_bytes.load(Ordering::Relaxed) > cap && self.storage.len() > 1 {
579                    match lru.pop_lru() {
580                        Some((evict_key, ())) => {
581                            self.evict_key(&evict_key);
582                        }
583                        None => break,
584                    }
585                }
586            }
587        }
588    }
589
590    /// Insert (or replace) a kernel. If the insert pushes the cache over
591    /// the count cap (or, when configured, the byte cap), evicts LRU
592    /// entries from storage and the policy queue until both caps are
593    /// satisfied.
594    ///
595    /// jit S-3: the kernel's `integrity_hash` is recomputed and compared
596    /// against the stored hash; a mismatch is treated as a programmer
597    /// error (`CachedKernel` constructed via struct-literal with a wrong
598    /// hash), logged at `error!`, and the entry is dropped rather than
599    /// admitted. Use [`CachedKernel::new`] for the correct-by-
600    /// construction path. The on-disk L2 also writes the entry when
601    /// configured.
602    pub fn put(&self, key: CacheKey, kernel: CachedKernel) {
603        if !kernel.verify_integrity() {
604            tracing::error!(
605                target: "tensor_wasm_jit::cache",
606                fingerprint = kernel.fingerprint,
607                tenant = key.tenant_id,
608                "refusing to cache kernel whose integrity hash does not match \
609                 its PTX text -- likely a struct-literal construction with a \
610                 stale hash; use CachedKernel::new"
611            );
612            return;
613        }
614        if let Some(disk) = &self.disk {
615            if let Err(e) = disk.put(&key, &kernel) {
616                tracing::warn!(
617                    target: "tensor_wasm_jit::cache",
618                    fingerprint = kernel.fingerprint,
619                    error = %e,
620                    "disk-cache put failed; entry remains in L1 only"
621                );
622            }
623        }
624        // T20 perf: storage holds `Arc<CachedKernel>` so cache hits return a
625        // refcount bump rather than a wrapper-clone. Account the new entry's
626        // bytes; if `insert` replaced an existing entry under the same key,
627        // subtract the displaced entry's bytes so the running total reflects
628        // a replace rather than an add.
629        let new_bytes = Self::entry_bytes(&kernel);
630        let replaced = self.storage.insert(key, Arc::new(kernel));
631        self.total_bytes.fetch_add(new_bytes, Ordering::Relaxed);
632        if let Some(old) = replaced {
633            self.total_bytes
634                .fetch_sub(Self::entry_bytes(&old), Ordering::Relaxed);
635        }
636        // `LruCache::push` returns `Some((evicted_key, ()))` when sizing the
637        // LRU triggers eviction of an older entry. We use this as the
638        // authoritative signal for storage eviction so the two stay in sync.
639        // Sized to `cap`, the LRU evicts exactly when we want storage to,
640        // and we get the evicted key directly without a second pop_lru call.
641        let evicted = self.lru.lock().push(key, ());
642        if let Some((evicted_key, ())) = evicted {
643            if evicted_key != key {
644                // Don't remove if `push` returned the just-inserted key
645                // (which happens when the cache already held it — push acts
646                // as a replace and returns the old `(K, V)`). `evict_key`
647                // keeps `total_bytes` in step with the storage removal.
648                self.evict_key(&evicted_key);
649            }
650        }
651        // Safety net for the rare burst case where two concurrent `put`s
652        // both insert without either triggering LRU's internal eviction
653        // (because they raced on the lock). Drive storage back under cap.
654        //
655        // Hold the `lru` lock across the entire eviction loop instead of
656        // taking and releasing it on every iteration: a tight burst of
657        // overflow eviction used to lock-unlock the mutex `N` times,
658        // letting unrelated readers/writers contend on each pass. One
659        // acquisition costs the same as one iteration's lock+unlock but
660        // dispatches the whole burst in a single critical section.
661        if self.storage.len() > self.config.capacity {
662            let mut lru = self.lru.lock();
663            while self.storage.len() > self.config.capacity {
664                match lru.pop_lru() {
665                    Some((evict_key, ())) => {
666                        self.evict_key(&evict_key);
667                    }
668                    None => {
669                        tracing::error!(
670                            target: "tensor_wasm_jit::cache",
671                            storage_len = self.storage.len(),
672                            capacity = self.config.capacity,
673                            "cache storage exceeds capacity but eviction queue is empty"
674                        );
675                        break;
676                    }
677                }
678            }
679        }
680        // Byte-cap eviction (DoS mitigation — see
681        // `KernelCacheConfig::max_total_bytes`). Shared with the L2
682        // disk-promotion path so both insert sites bind the byte cap.
683        self.enforce_byte_cap();
684    }
685
686    /// Look up a kernel; best-effort touches the LRU position.
687    ///
688    /// T20 perf: returns `Option<Arc<CachedKernel>>` rather than the
689    /// previous `Option<CachedKernel>` so a cache hit shares the wrapper
690    /// allocation via refcount bump instead of cloning the 32-byte
691    /// integrity hash + fingerprint + inner-`Arc` refcount bump. The
692    /// inner PTX text was already shared; this closes the matching
693    /// gap on the outer wrapper.
694    pub fn get(&self, key: &CacheKey) -> Option<Arc<CachedKernel>> {
695        // Promote in the policy queue if we can grab the lock uncontended;
696        // otherwise skip promotion this time so the read path stays
697        // contention-free. Eviction order is approximate (not strict) LRU
698        // under load — an acceptable trade for a lock-free hot path.
699        // The storage read is the single source of truth for "is this
700        // cached", so skipping promotion never affects correctness.
701        if let Some(mut lru) = self.lru.try_lock() {
702            // `get` on LruCache promotes if present.
703            let _ = lru.get(key);
704        }
705        if let Some(entry) = self.storage.get(key) {
706            // T20 perf: clone the Arc (refcount bump) rather than the
707            // inner `CachedKernel` value.
708            let kernel: Arc<CachedKernel> = Arc::clone(entry.value());
709            drop(entry); // release shard lock before the hash recompute
710                         // jit S-3: verify the in-memory entry hasn't been tampered
711                         // with since `put`. A mismatch should be impossible (the
712                         // `CachedKernel` is owned by the cache and never handed out
713                         // mutably) but failing closed costs ~µs over the public
714                         // PTX bytes and definitively closes the in-mem poisoning
715                         // path the audit flagged.
716                         //
717                         // The recompute can be opted out of via
718                         // [`KernelCacheConfig::verify_on_get`] for high-QPS callers
719                         // where the ~10 µs BLAKE3 cost over multi-MB PTX dominates.
720                         // Even on the opt-out path we keep a cheap defence-in-depth
721                         // check: refuse entries whose `integrity_hash` is all-zero,
722                         // because that is the signature of a `CachedKernel`
723                         // constructed via the `#[doc(hidden)]` struct-literal path
724                         // without [`CachedKernel::new`] having computed a real hash.
725                         // A real BLAKE3 over any PTX text is overwhelmingly unlikely
726                         // to collide with the zero hash (probability `2^-256`), so
727                         // the rejection is unambiguous.
728            if self.config.verify_on_get {
729                if !kernel.verify_integrity() {
730                    tracing::error!(
731                        target: "tensor_wasm_jit::cache",
732                        fingerprint = kernel.fingerprint,
733                        tenant = key.tenant_id,
734                        "L1 cache entry failed integrity verification on get; \
735                         evicting and refusing to return it"
736                    );
737                    self.evict_key(key);
738                    self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
739                    self.cache_misses_total.fetch_add(1, Ordering::Relaxed);
740                    return None;
741                }
742            } else {
743                // Defence-in-depth on the opt-out path: reject the
744                // "constructed-without-`new`" signal (all-zero hash) so
745                // hand-crafted `CachedKernel`s with a zeroed hash cannot
746                // slip through. Counter increment records that the user
747                // chose to skip the full recompute on this hit.
748                self.verify_skipped_total.fetch_add(1, Ordering::Relaxed);
749                if kernel.integrity_hash == [0u8; 32] {
750                    tracing::error!(
751                        target: "tensor_wasm_jit::cache",
752                        fingerprint = kernel.fingerprint,
753                        tenant = key.tenant_id,
754                        "L1 cache entry has zero integrity_hash on verify-skip get; \
755                         likely a struct-literal CachedKernel built without \
756                         CachedKernel::new — evicting and refusing to return it"
757                    );
758                    self.evict_key(key);
759                    self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
760                    self.cache_misses_total.fetch_add(1, Ordering::Relaxed);
761                    return None;
762                }
763            }
764            self.cache_hits_total.fetch_add(1, Ordering::Relaxed);
765            return Some(kernel);
766        }
767        // jit S-3 + audit P-4: L1 miss falls through to the optional L2
768        // on-disk cache. The disk path HMAC-verifies the entry before
769        // deserialising, so a tampered file on disk is rejected with the
770        // same "no such entry" outcome as a real miss.
771        if let Some(disk) = &self.disk {
772            match disk.get(key) {
773                Ok(Some(kernel)) => {
774                    // Promote the disk hit into L1 so subsequent lookups
775                    // stay on the fast path. Storage owns an `Arc<CachedKernel>`;
776                    // wrap once here and clone the Arc for the return value
777                    // so the caller and the cache share the allocation.
778                    let arc = Arc::new(kernel);
779                    let promoted_bytes = Self::entry_bytes(&arc);
780                    let replaced = self.storage.insert(*key, Arc::clone(&arc));
781                    self.total_bytes
782                        .fetch_add(promoted_bytes, Ordering::Relaxed);
783                    if let Some(old) = replaced {
784                        self.total_bytes
785                            .fetch_sub(Self::entry_bytes(&old), Ordering::Relaxed);
786                    }
787                    // Drive count-cap eviction via the policy queue exactly as
788                    // `put` does, keeping `total_bytes` in step through
789                    // `evict_key`.
790                    if let Some((evicted_key, ())) = self.lru.lock().push(*key, ()) {
791                        if evicted_key != *key {
792                            self.evict_key(&evicted_key);
793                        }
794                    }
795                    // Byte-cap parity: `put` enforces BOTH the count cap and the
796                    // byte cap on every insert, but this promotion path used to
797                    // run only count-cap eviction — so an L2 disk hit could push
798                    // `total_bytes` past `config.max_total_bytes` and leave it
799                    // over the cap until the next `put`. Run the shared byte-cap
800                    // eviction here too so both caps bind on every L1 insert.
801                    self.enforce_byte_cap();
802                    self.cache_hits_total.fetch_add(1, Ordering::Relaxed);
803                    return Some(arc);
804                }
805                Ok(None) => {}
806                Err(e) => {
807                    tracing::warn!(
808                        target: "tensor_wasm_jit::cache",
809                        tenant = key.tenant_id,
810                        error = %e,
811                        "disk-cache get failed; treating as miss"
812                    );
813                }
814            }
815        }
816        self.cache_misses_total.fetch_add(1, Ordering::Relaxed);
817        None
818    }
819
820    /// Look up by blueprint + sm_version for a given tenant; convenience
821    /// wrapper around [`Self::get`].
822    ///
823    /// T20 perf: returns `Option<Arc<CachedKernel>>` to mirror
824    /// [`Self::get`]; callers that only need to peek at fields go
825    /// through auto-deref unchanged.
826    pub fn get_for(
827        &self,
828        tenant_id: TenantId,
829        blueprint: &TensorWasmKernelBlueprint,
830        sm_version: u32,
831    ) -> Option<Arc<CachedKernel>> {
832        self.get(&CacheKey::for_tenant(
833            tenant_id,
834            blueprint.fingerprint(),
835            sm_version,
836        ))
837    }
838
839    /// L1 → L2 → L3(registry) resolution path. v0.3.8 scaffold: invokes the
840    /// registered resolver + registry on every miss; v0.4 may add a
841    /// resolver-level cache to amortise the (blueprint → name@version)
842    /// translation.
843    #[cfg(feature = "kernel-registry")]
844    pub fn get_with_registry_fallback(
845        &self,
846        key: &CacheKey,
847        resolver: &dyn BlueprintResolver,
848    ) -> Option<Arc<CachedKernel>> {
849        // L1 + L2 — `get` now already returns `Arc<CachedKernel>` (T20).
850        if let Some(hit) = self.get(key) {
851            return Some(hit);
852        }
853        // L3
854        let registry = self.config.registry.as_ref()?;
855        let (name, version) = resolver.resolve(key.blueprint, key.sm_version)?;
856        let entry = registry.get(&name, &version).ok()?;
857        // Promote into L1 via the standard put path (which re-checks
858        // integrity) so subsequent calls hit fast.
859        //
860        // Perf: `entry` is an `Arc<(KernelManifest, String)>` owned by the
861        // registry, so the PTX `String` cannot be moved out — exactly one
862        // owned copy into `EmittedPtx` is the irreducible minimum on this
863        // path. That copy is wrapped in an `Arc<EmittedPtx>` once; the L1
864        // promotion and the handle returned to the caller then share it via
865        // refcount bump, so the PTX bytes are never re-copied. The single
866        // `CachedKernel::new` below also hashes the PTX exactly once (its
867        // BLAKE3 integrity tag); the cheap wrapper `clone()` we hand to `put`
868        // copies only the 32-byte hash + fingerprint + an `Arc` refcount
869        // bump, not the PTX text.
870        let emitted = Arc::new(crate::ptx_emit::EmittedPtx {
871            text: entry.1.clone(),
872            // Thread the real launch geometry through from the manifest.
873            // `KernelManifest::launch_geometry` is an optional, unsigned
874            // hint (publishers set it via `KernelManifest::with_launch_geometry`);
875            // when present we promote it onto the L1 entry so a registry-sourced
876            // kernel carries the same geometry an L1/L2 hit would. When the
877            // manifest omits it (older blobs, or publishers that never set it) we
878            // fall back to `(0, 0)`, exactly as before — geometry is a launch
879            // hint, not a correctness invariant for resolution. The earlier
880            // `(0, 0)` hard-code lost geometry on every L3 hit (the bug the V2
881            // disk envelope fixed on L2); this closes the L3 gap without a signed-
882            // format break, since the hint rides outside the v2 HMAC envelope.
883            launch_geometry: entry.0.launch_geometry.unwrap_or((0, 0)),
884        });
885        // Fingerprint MUST match the cache key under which this entry is
886        // inserted. `put` keys L1 entries by `CacheKey` (whose `blueprint` is the
887        // blueprint fingerprint) and the L2 disk reader reconstructs with
888        // `fingerprint == key.blueprint` (see `DiskCache::get`). Using
889        // `manifest.digest_as_u64()` (the PTX digest) here made the promoted
890        // entry's `fingerprint` disagree with `key.blueprint`, breaking
891        // diagnostics/log correlation and the `OffloadedFunction.fingerprint`
892        // contract for registry-sourced entries. Key on `key.blueprint` so L1,
893        // L2, and L3 entries are all fingerprinted consistently.
894        let cached = CachedKernel::new(key.blueprint, emitted, CompiledHandle::default());
895        // Best-effort L1 promote; `put` is infallible-by-design (any
896        // integrity-mismatch case logs + drops the entry rather than
897        // returning an error), so there is nothing to propagate even if
898        // the registry-derived `cached` were somehow rejected — the
899        // caller still gets the verified `Arc<CachedKernel>` from this
900        // call, the next call simply pays another L3 round-trip.
901        //
902        // `put` keeps its by-value `CachedKernel` signature, so we hand it a
903        // wrapper clone (cheap — see above); the returned `Arc<CachedKernel>`
904        // shares the same inner `Arc<EmittedPtx>` as the L1 copy.
905        self.put(*key, cached.clone());
906        Some(Arc::new(cached))
907    }
908
909    /// Number of entries currently held.
910    pub fn len(&self) -> usize {
911        self.storage.len()
912    }
913
914    /// True if empty.
915    pub fn is_empty(&self) -> bool {
916        self.storage.is_empty()
917    }
918
919    /// Configured capacity.
920    pub fn capacity(&self) -> usize {
921        self.config.capacity
922    }
923
924    /// Running sum of resident PTX-text bytes across all L1 entries (each
925    /// entry contributes `cached.ptx.text.len()`). This is the quantity the
926    /// optional [`KernelCacheConfig::max_total_bytes`] cap bounds. Surface
927    /// on the Prometheus gauge `tensor_wasm_jit_cache_bytes` so operators
928    /// can watch the L1 footprint against the configured byte cap. Always
929    /// present (returns `0` for an empty cache, and tracks the live total
930    /// whether or not a byte cap is configured).
931    pub fn total_bytes(&self) -> u64 {
932        self.total_bytes.load(Ordering::Relaxed)
933    }
934
935    /// The configured byte cap, if any (mirror of
936    /// [`KernelCacheConfig::max_total_bytes`]). `None` means count-only
937    /// eviction. Useful for diagnostic endpoints that surface both the
938    /// live [`Self::total_bytes`] and the ceiling it is measured against.
939    pub fn max_total_bytes(&self) -> Option<u64> {
940        self.config.max_total_bytes
941    }
942
943    /// Fingerprint of the *active* on-disk HMAC key — the partition prefix
944    /// every file this cache writes lands under — or `None` if no L2 disk
945    /// cache is configured.
946    ///
947    /// Pass this into the `retain` set of [`Self::gc_disk`] to keep the
948    /// live generation (though `gc_disk` retains it unconditionally as a
949    /// safety net).
950    pub fn active_disk_key_fingerprint(&self) -> Option<KeyFingerprint> {
951        self.disk.as_ref().map(|d| d.active_key_fingerprint())
952    }
953
954    /// Enumerate the distinct HMAC-key fingerprints present in the on-disk
955    /// L2 cache directory (one per key generation that has written at least
956    /// one file). Returns an empty `Vec` when no disk cache is configured or
957    /// the directory does not yet exist.
958    ///
959    /// Use this to discover stale generations before a [`Self::gc_disk`]
960    /// sweep — anything in this list that is not the active fingerprint and
961    /// not a key you still want to honour is a rotation leftover.
962    pub fn disk_key_fingerprints(&self) -> std::io::Result<Vec<KeyFingerprint>> {
963        match &self.disk {
964            Some(d) => d.key_fingerprints_on_disk(),
965            None => Ok(Vec::new()),
966        }
967    }
968
969    /// Garbage-collect the on-disk L2 cache after an HMAC-key rotation:
970    /// remove every persisted entry whose key fingerprint is NOT in
971    /// `retain`, returning the number of files removed. A no-op returning
972    /// `Ok(0)` when no disk cache is configured.
973    ///
974    /// The *active* key's fingerprint is always retained — even if the
975    /// caller omits it from `retain` — so a sweep can never delete the live
976    /// generation's entries. Only stale-fingerprint files are removed, and
977    /// only files matching the cache's own naming scheme are considered
978    /// (unrelated files in the directory are left untouched).
979    ///
980    /// # Example
981    ///
982    /// ```
983    /// # fn load_hmac_key_from_secret_store() -> Result<[u8; 32], Box<dyn std::error::Error>> {
984    /// #     Ok([0u8; 32])
985    /// # }
986    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
987    /// use std::path::PathBuf;
988    /// use tensor_wasm_jit::cache::{KernelCache, DiskCacheConfig};
989    ///
990    /// // After rotating to a fresh HMAC key, stand up a cache under it…
991    /// let cache = KernelCache::new().with_disk_persistence(DiskCacheConfig {
992    ///     dir: PathBuf::from("/var/cache/tensor-wasm/kernels"),
993    ///     hmac_key: load_hmac_key_from_secret_store()?,
994    /// });
995    ///
996    /// // …then sweep every previous generation's files. Retaining only the
997    /// // active key (which `gc_disk` keeps regardless) drops all the rest.
998    /// let active = cache.active_disk_key_fingerprint().into_iter().collect::<Vec<_>>();
999    /// let removed = cache.gc_disk(&active)?;
1000    /// println!("swept {removed} stale-fingerprint cache files");
1001    /// # let _ = removed;
1002    /// # Ok(())
1003    /// # }
1004    /// ```
1005    pub fn gc_disk(&self, retain: &[KeyFingerprint]) -> std::io::Result<usize> {
1006        match &self.disk {
1007            Some(d) => d.gc(retain),
1008            None => Ok(0),
1009        }
1010    }
1011
1012    /// Borrow the construction-time [`KernelCacheConfig`]. Useful for
1013    /// tests and diagnostic endpoints that want to surface whether
1014    /// `verify_on_get` is on for this cache instance.
1015    pub fn config(&self) -> &KernelCacheConfig {
1016        &self.config
1017    }
1018
1019    /// Cumulative count of L1 `get` hits that skipped the BLAKE3
1020    /// integrity recompute because [`KernelCacheConfig::verify_on_get`]
1021    /// is `false`. Surface this on the Prometheus counter
1022    /// `tensor_wasm_jit_cache_verify_skipped_total` so operators can see
1023    /// how often the cache is trusting an L1 entry without re-hashing
1024    /// the PTX. The counter is always present (returns `0` when
1025    /// `verify_on_get` is on) so dashboards can scrape it unconditionally.
1026    pub fn verify_skipped_total(&self) -> u64 {
1027        self.verify_skipped_total.load(Ordering::Relaxed)
1028    }
1029
1030    /// Cumulative L1/L2/L3 cache hit count. Incremented once per `get`
1031    /// call that returns `Some`. Surface on the Prometheus counter
1032    /// `tensor_wasm_jit_cache_hits_total` so operators can compute the
1033    /// hit ratio against [`Self::cache_misses_total`]. (T20 perf.)
1034    pub fn cache_hits_total(&self) -> u64 {
1035        self.cache_hits_total.load(Ordering::Relaxed)
1036    }
1037
1038    /// Cumulative cache miss count. Incremented once per `get` call that
1039    /// returns `None` — including the rare path where an L1 entry failed
1040    /// integrity verification and was evicted. Surface on the Prometheus
1041    /// counter `tensor_wasm_jit_cache_misses_total`. (T20 perf.)
1042    pub fn cache_misses_total(&self) -> u64 {
1043        self.cache_misses_total.load(Ordering::Relaxed)
1044    }
1045
1046    /// Cumulative count of entries refused on `get` because they failed
1047    /// integrity verification — an L1 BLAKE3 recompute mismatch, an
1048    /// all-zero `integrity_hash` on the verify-skip path, or an L2 disk
1049    /// integrity failure (artifact-store HMAC/content-hash mismatch,
1050    /// inner-envelope magic/header/length/UTF-8 corruption). These also
1051    /// bump [`Self::cache_misses_total`], but this dedicated counter lets
1052    /// operators alarm on *tamper attempts* specifically rather than
1053    /// drown them in ordinary cold-cache misses. Surface on the
1054    /// Prometheus counter `tensor_wasm_jit_cache_integrity_reject_total`.
1055    pub fn integrity_reject_total(&self) -> u64 {
1056        self.integrity_reject_total.load(Ordering::Relaxed)
1057    }
1058
1059    /// Test-only insert that skips the `put`-side integrity check.
1060    ///
1061    /// `put` rejects any `CachedKernel` whose stored `integrity_hash`
1062    /// does not match a fresh BLAKE3 over its `ptx.text` (jit S-3).
1063    /// That is the correct production behaviour, but the
1064    /// `verify_on_get=false` regression test in
1065    /// `tests/cache_verify_opt_out.rs` needs to install a hand-crafted
1066    /// zero-hash entry to confirm the opt-out path still rejects it on
1067    /// `get`. This entry-point exists for that test only — it is
1068    /// `#[doc(hidden)]` (excluded from generated docs and rustdoc search)
1069    /// and named with a `__test_only_` prefix to broadcast "do NOT call
1070    /// this from production code". Integration tests under
1071    /// `crates/.../tests/` compile as external consumers of the library and
1072    /// therefore cannot see `cfg(test)` items, so this cannot be gated on
1073    /// `cfg(test)`. It is instead gated behind the dedicated
1074    /// `__unstable-test-internals` cargo feature (jit M2): without that
1075    /// feature the symbol does not exist, so a downstream crate cannot use
1076    /// it to install a `CachedKernel` with a forged/zeroed integrity hash
1077    /// and thereby bypass the S-3 integrity check. The double-underscore
1078    /// feature name signals it is unstable and not covered by semver.
1079    ///
1080    /// Production code MUST go through [`Self::put`].
1081    #[doc(hidden)]
1082    #[cfg(feature = "__unstable-test-internals")]
1083    pub fn __test_only_insert_unchecked(&self, key: CacheKey, kernel: CachedKernel) {
1084        // T20 perf: storage holds `Arc<CachedKernel>`; wrap on insert.
1085        let bytes = Self::entry_bytes(&kernel);
1086        let replaced = self.storage.insert(key, Arc::new(kernel));
1087        self.total_bytes.fetch_add(bytes, Ordering::Relaxed);
1088        if let Some(old) = replaced {
1089            self.total_bytes
1090                .fetch_sub(Self::entry_bytes(&old), Ordering::Relaxed);
1091        }
1092        let _ = self.lru.lock().push(key, ());
1093    }
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Disk persistence (audit P-4 + jit S-3)
1098// ---------------------------------------------------------------------------
1099
1100/// Configuration for the on-disk L2 cache.
1101///
1102/// The cache directory must be writable by the runtime user and SHOULD be
1103/// owned exclusively by that user (mode 0700 on Unix) — operators on a
1104/// hardened deployment can additionally `chattr +i` files after first
1105/// write so a parallel attacker process cannot tamper with cached
1106/// entries even with the same UID.
1107///
1108/// `hmac_key` is the secret that gates load: the writer HMACs each
1109/// persisted entry with the key, and the reader rejects any file whose
1110/// recomputed HMAC does not match. Without the key (or with a different
1111/// key) the loader treats every existing entry as a miss, so rotating
1112/// the key invalidates the disk cache without requiring an `rm -rf`.
1113///
1114/// The caller-supplied `hmac_key` field is plain `[u8; 32]` for ergonomic
1115/// construction; once handed to [`KernelCache::with_disk_persistence`] the
1116/// bytes are copied into a private `Zeroizing<[u8; 32]>` inside
1117/// `DiskCache` which wipes them on drop. The caller's own copy is the
1118/// caller's responsibility (e.g. construct via `Zeroizing::new` upstream
1119/// and let it drop after the call).
1120///
1121/// jit S-3 hardening (T13): `Debug` is implemented manually so the
1122/// `hmac_key` bytes are redacted in any `{:?}` formatting, panic message,
1123/// or `tracing` field expansion — the derived `Debug` would have dumped
1124/// the raw 32-byte array. `Drop` zeroizes `hmac_key` on drop so the
1125/// construction-time copy does not survive in freed memory.
1126#[derive(Clone)]
1127pub struct DiskCacheConfig {
1128    /// Directory where the L2 cache files live. Created lazily on first
1129    /// `put`.
1130    pub dir: PathBuf,
1131    /// 32-byte HMAC-keyed-BLAKE3 key. MUST be process-stable across the
1132    /// cache's lifetime and SHOULD be treated as a server-side secret.
1133    /// The long-lived copy held by the cache is zeroized on drop; this
1134    /// field is the construction-time hand-off only.
1135    ///
1136    /// jit S-3 (T13): redacted in the manual [`std::fmt::Debug`] impl
1137    /// below and zeroized in [`Drop`] so the construction-time copy
1138    /// does not linger in freed memory after the value is moved into
1139    /// the long-lived `DiskCache`.
1140    pub hmac_key: [u8; 32],
1141}
1142
1143// Manual `Debug` so `hmac_key` never appears verbatim in formatted output
1144// (jit S-3 T13). Any `{:?}` print, panic message, or `tracing::error!`
1145// field expansion that includes a `DiskCacheConfig` would otherwise dump
1146// the raw key bytes into the log stream — which is the exact server-side
1147// secret the disk cache is supposed to protect.
1148impl std::fmt::Debug for DiskCacheConfig {
1149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1150        f.debug_struct("DiskCacheConfig")
1151            .field("dir", &self.dir)
1152            .field("hmac_key", &"<redacted 32 bytes>")
1153            .finish()
1154    }
1155}
1156
1157// Zeroize the in-place HMAC key bytes when the config is dropped so the
1158// construction-time copy does not survive in freed memory once
1159// [`KernelCache::with_disk_persistence`] has consumed the value (jit S-3
1160// T13). The long-lived copy in [`DiskCache`] already lives inside
1161// `Zeroizing<[u8; 32]>`; this `Drop` plugs the matching gap for the
1162// caller-side hand-off struct.
1163impl Drop for DiskCacheConfig {
1164    fn drop(&mut self) {
1165        use zeroize::Zeroize;
1166        self.hmac_key.zeroize();
1167    }
1168}
1169
1170/// 16-hex-char fingerprint of an HMAC key, as it appears in on-disk
1171/// filenames.
1172///
1173/// The disk layout partitions every file by the first 8 bytes of
1174/// `blake3::hash(hmac_key)` rendered as 16 lowercase hex chars — the
1175/// sidecar prefix in `{fp}-{cache_key}.ptxbin` and the middle segment in
1176/// the artifact store's `{content_hash}.{fp}.bin` blobs both use the same
1177/// fingerprint. Rotating the HMAC key changes the fingerprint, so a
1178/// rotation leaves the previous generation's files behind under their old
1179/// fingerprint, trivially sweepable by [`KernelCache::gc_disk`].
1180///
1181/// This is *not* secret: it is `blake3(key)` truncated, already publicly
1182/// observable to anyone with directory-list access (it is in the
1183/// filename). It is a partitioning tag, not a confidentiality boundary —
1184/// the HMAC trailer on each blob is what actually gates load.
1185///
1186/// Obtain the fingerprint of the currently-active key via
1187/// [`KernelCache::active_disk_key_fingerprint`], and the set present on
1188/// disk via [`KernelCache::disk_key_fingerprints`].
1189#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1190pub struct KeyFingerprint(pub String);
1191
1192impl KeyFingerprint {
1193    /// The 16-hex-char fingerprint of `key` — the same value the disk
1194    /// layout stamps into filenames. Equivalent to the artifact store's
1195    /// own `key_fingerprint_hex`; kept in lock-step so a `KernelCache` and
1196    /// its underlying [`DiskArtifactStore`] always agree on the partition.
1197    #[must_use]
1198    pub fn of_key(key: &[u8; 32]) -> Self {
1199        let h = blake3::hash(&key[..]);
1200        let mut buf = [0u8; 16];
1201        hex::encode_to_slice(&h.as_bytes()[..8], &mut buf).expect("16 byte buf for 8 byte input");
1202        // `buf` is ASCII hex by construction, so the UTF-8 check never fails.
1203        Self(std::str::from_utf8(&buf).expect("hex is utf8").to_string())
1204    }
1205
1206    /// Borrow the underlying hex string.
1207    #[must_use]
1208    pub fn as_str(&self) -> &str {
1209        &self.0
1210    }
1211}
1212
1213impl std::fmt::Display for KeyFingerprint {
1214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1215        f.write_str(&self.0)
1216    }
1217}
1218
1219/// Disk-backed L2 cache.
1220///
1221/// ## T30 layering (current)
1222///
1223/// Stores each kernel as two cooperating on-disk artefacts under
1224/// [`DiskCacheConfig::dir`]:
1225///
1226/// 1. A *sidecar* file at [`Self::path_for`] (`*.ptxbin`) containing a
1227///    fixed-size record that maps the [`CacheKey`] to the
1228///    [`ContentHash`] of the underlying blob (16-byte magic
1229///    [`SIDECAR_MAGIC_V1`] + 32-byte content hash).
1230/// 2. A *blob* file under the
1231///    [`tensor_wasm_artifacts::DiskArtifactStore`] layout
1232///    (`*.<key-fp>.bin`) holding the streaming-encoded HMAC-SHA256 +
1233///    zstd envelope around the V2 kernel-manifest framing.
1234///
1235/// The V2 kernel-manifest framing (16-byte `TWJIT-KRNL-v2\0\0\0` magic +
1236/// length-prefixed body: blueprint, sm_version, grid_x, block_x,
1237/// ptx_len, ptx bytes) is built ABOVE the artifact store: a `put` first
1238/// serialises the kernel into a V2 envelope `Vec<u8>`, then hands that
1239/// to [`DiskArtifactStore::put`] which streams it through an HMAC-tee +
1240/// zstd encoder onto disk. A `get` reverses the layering: look up the
1241/// sidecar to get the [`ContentHash`], call [`DiskArtifactStore::get`]
1242/// which streaming-verifies the outer HMAC + decompresses, then parse
1243/// the inner V2 envelope to recover the kernel.
1244///
1245/// ## Why two files
1246///
1247/// [`DiskArtifactStore`] is content-addressed: lookups go through a
1248/// [`ContentHash`] derived from the payload bytes, not through a caller
1249/// key. The kernel cache needs lookups by tenant-scoped [`CacheKey`],
1250/// so the sidecar maps `CacheKey -> ContentHash` while the blob owns
1251/// the bytes. This preserves the streaming HMAC + zstd properties of
1252/// the store (T22) without baking the cache-key shape into the store
1253/// itself. Filenames stay partitioned by HMAC-key fingerprint on both
1254/// sides — the sidecar via `path_for`'s `{key_prefix}-…` prefix, the
1255/// blob via [`DiskArtifactStore`]'s own `{hash}.{key_fp}.bin` shape —
1256/// so two stores in the same directory under different keys cannot
1257/// race on the same path.
1258///
1259/// ## V2 envelope (current writer, body inside the artifact store)
1260///
1261/// ```text
1262/// [0..16)   magic = "TWJIT-KRNL-v2\0\0"
1263/// [16..24)  tenant_id (u64 LE)            <- tenant-binding defence-in-depth
1264/// [24..32)  blueprint fingerprint (u64 LE)
1265/// [32..36)  sm_version (u32 LE)
1266/// [36..40)  launch_geometry.grid_x (u32 LE)
1267/// [40..44)  launch_geometry.block_x (u32 LE)
1268/// [44..52)  ptx length (u64 LE)
1269/// [52..52+ptx_len)  PTX text (UTF-8, NOT null-terminated)
1270/// ```
1271///
1272/// ## Tenant binding (defence-in-depth)
1273///
1274/// The `tenant_id` word at `[16..24)` was added so the *persisted*
1275/// envelope binds the owning tenant, not merely the filename path
1276/// (`path_for` already folds `tenant_id` into the path hash, but the
1277/// path alone is the only thing scoping a blob to a tenant). Without
1278/// this, an attacker who can drop a well-formed, HMAC-valid sidecar +
1279/// blob at *another* tenant's `path_for` location would have it served
1280/// to that tenant on `get`. The reader now cross-checks
1281/// `tenant_on_disk == key.tenant_id` alongside the existing
1282/// fingerprint / sm_version defence-in-depth check and rejects a
1283/// mismatch as a miss.
1284///
1285/// The 16-byte magic stays `TWJIT-KRNL-v2\0\0\0` (the cross-version
1286/// compat invariant pinned by the artifact-store round-trip test); the
1287/// tenant word is an in-place, length-extending revision of the V2 body.
1288/// A genuinely pre-tenant V2 blob fed to this reader misaligns every
1289/// field and is rejected cleanly by the tenant / fingerprint / sm_version
1290/// cross-check (worst case a clean miss + rewrite on the next `put`) —
1291/// the L2 cache is regenerable, so no migration step is required.
1292///
1293/// The HMAC trailer that pre-T30 V2 sat at the end of the file is gone
1294/// from this layer — the artifact store provides streaming HMAC over
1295/// the entire envelope (T22), so a second per-envelope MAC would be
1296/// redundant.
1297///
1298/// ## V1 magic (read-only)
1299///
1300/// Pre-T30 V1/V2 files (magic `"TWJIT-KRNL-v1\0\0\0"` or
1301/// `"TWJIT-KRNL-v2\0\0\0"` at offset 0 of a `.ptxbin` file written by
1302/// the legacy raw-file writer) sit at a different path shape than the
1303/// new T30 sidecar and are silently invisible to the new reader: they
1304/// no longer occupy the post-T30 sidecar path, so a fresh `get` of
1305/// the corresponding key returns a clean miss and the next `put` rewrites
1306/// the entry as `(sidecar, blob)` under the unified store. This matches
1307/// the `cache.l2.miss.legacy_magic` behaviour of pre-T30 V1 detection
1308/// without any decoder support for the legacy raw layout.
1309struct DiskCache {
1310    /// Directory the cache writes to. Cloned out of the supplied
1311    /// [`DiskCacheConfig`] so we can drop the original config (and its
1312    /// plain `[u8; 32]` copy of the key) and keep only the zeroizing
1313    /// copy below as the long-lived owner.
1314    dir: PathBuf,
1315    /// 32-byte HMAC-keyed-BLAKE3 key, wiped on drop. `Zeroizing` Derefs
1316    /// to `[u8; 32]` so existing `&self.hmac_key[..]`-style call sites
1317    /// keep compiling. The artifact store holds an independent copy
1318    /// (also zeroising) — the duplication is intentional so the
1319    /// sidecar's path-prefix fingerprint and the store's own
1320    /// per-blob path-prefix fingerprint agree byte-for-byte without
1321    /// either side reaching into the other's private field.
1322    hmac_key: Zeroizing<[u8; 32]>,
1323    /// Cached 16-hex-char fingerprint of `hmac_key` — the `{key_prefix}`
1324    /// segment every `path_for` filename leads with.
1325    ///
1326    /// Perf: the HMAC-key fingerprint is `blake3(hmac_key)` truncated, which
1327    /// is constant for the cache's lifetime (the key never changes after
1328    /// construction). `path_for` used to recompute it via
1329    /// `blake3::hash(&self.hmac_key[..])` on *every* disk op; hoisting it
1330    /// here computes the digest exactly once in `new`. The per-cache-key
1331    /// digest in `path_for` genuinely varies per call and stays inline.
1332    /// This is *not* secret (it is already in every filename), so caching
1333    /// the rendered hex rather than the key bytes leaks nothing.
1334    key_prefix_hex: String,
1335    /// Underlying streaming content-addressed signed blob store.
1336    /// Holds the v2-envelope-wrapped kernel payloads. Wrapped in an
1337    /// `Arc` so the disk cache is cheaply clonable — `KernelCache`
1338    /// already holds the cache behind `Arc<DiskCache>`, this is the
1339    /// only field whose backend benefits from sharing.
1340    store: Arc<DiskArtifactStore>,
1341    /// Shared clone of the owning [`KernelCache`]'s integrity-rejection
1342    /// counter (backs `tensor_wasm_jit_cache_integrity_reject_total`).
1343    /// Bumped on the L2 read path when an entry is refused for a genuine
1344    /// integrity failure (artifact-store HMAC/content-hash mismatch or an
1345    /// inner-envelope magic mismatch) — i.e. a tamper signal, distinct
1346    /// from a benign miss (missing sidecar / legacy magic) which leaves
1347    /// this untouched.
1348    integrity_reject_total: Arc<AtomicU64>,
1349}
1350
1351/// V2 magic for the inner kernel-manifest envelope wrapped inside each
1352/// artifact-store blob. Unchanged across the T30 migration so cross-
1353/// version compat is preserved: an older reader extracting this body
1354/// from any future archive can still parse it.
1355const DISK_CACHE_MAGIC_V2: &[u8; 16] = b"TWJIT-KRNL-v2\0\0\0";
1356/// V2 header: magic + tenant_id + fingerprint + sm_version + grid_x +
1357/// block_x + ptx_len. The `tenant_id` word is the tenant-binding
1358/// defence-in-depth field — see the [`DiskCache`] type-level "Tenant
1359/// binding" note.
1360const DISK_CACHE_HEADER_LEN_V2: usize = 16 + 8 + 8 + 4 + 4 + 4 + 8;
1361
1362/// 16-byte sidecar magic. Stamped at the head of every `*.ptxbin`
1363/// sidecar so the reader can tell a T30 sidecar apart from any legacy
1364/// pre-T30 raw-V2 file that happens to live under the same filename
1365/// scheme. Pre-T30 files start with `TWJIT-KRNL-v2\0\0\0`; T30 sidecars
1366/// start with this distinct magic, so a stale legacy file is treated
1367/// as a miss and the next `put` overwrites it.
1368const SIDECAR_MAGIC_V1: &[u8; 16] = b"TWJIT-IDX-v1\0\0\0\0";
1369/// Sidecar total length: 16-byte magic + 32-byte BLAKE3 content hash.
1370const SIDECAR_LEN_V1: usize = 16 + 32;
1371
1372impl DiskCache {
1373    fn new(mut cfg: DiskCacheConfig, integrity_reject_total: Arc<AtomicU64>) -> Self {
1374        // Move the key bytes into a `Zeroizing` newtype so the long-lived
1375        // copy is wiped on `DiskCache::drop`. We cannot partially move
1376        // fields out of `cfg` directly because `DiskCacheConfig`
1377        // implements `Drop` (jit S-3 T13) — the language forbids
1378        // partial moves out of a `Drop` type. Instead we `mem::take`
1379        // each field, leaving the source struct in a default-valued
1380        // state before its `Drop::drop` runs and zeroizes the (now
1381        // already-defaulted) `hmac_key` array a second time.
1382        let dir = std::mem::take(&mut cfg.dir);
1383        // Wrap the moved-out key in `Zeroizing` *immediately* (jit L4) so the
1384        // stack copy is wiped when this function returns, rather than left as
1385        // a plain `[u8; 32]` lingering in the frame after both consumers have
1386        // taken their own copies.
1387        let key_bytes = Zeroizing::new(std::mem::take(&mut cfg.hmac_key));
1388        // The artifact store gets its own copy of the same key so its
1389        // streaming HMAC matches the one the sidecar's path-prefix
1390        // fingerprint will agree with. Both copies are wrapped in
1391        // `Zeroizing` (the artifact store wraps internally) so neither
1392        // construction-time bytes linger after drop. `*key_bytes` derefs the
1393        // `Zeroizing` to hand the store its by-value `[u8; 32]`.
1394        let store = Arc::new(DiskArtifactStore::new(dir.clone(), *key_bytes));
1395        // Perf: the HMAC-key fingerprint (`blake3(hmac_key)` truncated to 8
1396        // bytes → 16 hex chars) is constant for the cache's lifetime, so
1397        // compute it once here rather than on every `path_for` call. See the
1398        // `key_prefix_hex` field doc.
1399        let key_fp = blake3::hash(&key_bytes[..]);
1400        let mut key_prefix_buf = [0u8; 16];
1401        hex::encode_to_slice(&key_fp.as_bytes()[..8], &mut key_prefix_buf)
1402            .expect("16 byte buf for 8 byte input");
1403        let key_prefix_hex = std::str::from_utf8(&key_prefix_buf)
1404            .expect("hex is utf8")
1405            .to_string();
1406        Self {
1407            dir,
1408            hmac_key: key_bytes,
1409            key_prefix_hex,
1410            store,
1411            integrity_reject_total,
1412        }
1413    }
1414
1415    /// Build the on-disk path for a key. Hash the full key (including
1416    /// tenant_id and emit_config_hash) so two tenants cannot collide
1417    /// on the same blueprint+sm and so the file name itself does not
1418    /// leak the blueprint fingerprint to anyone with directory-list
1419    /// access.
1420    ///
1421    /// The filename is also prefixed with the first 8 bytes of
1422    /// `blake3::hash(hmac_key)` so two `KernelCache`s pointed at the
1423    /// same directory but configured with different HMAC keys produce
1424    /// *disjoint* paths. Without this prefix the two writers would
1425    /// race on the same final path (`tmp.persist` overwrites whichever
1426    /// landed first) and both readers would then fail the HMAC check
1427    /// on each other's writes — every put-then-get round-trip would
1428    /// look like a miss in steady state.
1429    ///
1430    /// The 8-byte HMAC-key fingerprint is NOT the key itself: it's
1431    /// `blake3::hash(key)` truncated, which is already publicly
1432    /// observable (anyone with directory-list access can read the
1433    /// filename). The actual MAC trailer still gates load — partitioning
1434    /// just avoids the inter-key collision, it is not a confidentiality
1435    /// boundary.
1436    ///
1437    /// Format: `{key_prefix:016x}-{cache_key_hex}.ptxbin`. The key
1438    /// prefix leads so `ls`-style directory listings group entries by
1439    /// the writing key — handy for operators rotating keys (each
1440    /// rotation lands under a new prefix and the old generation is
1441    /// trivially `rm`-able by prefix glob).
1442    fn path_for(&self, key: &CacheKey) -> PathBuf {
1443        let mut hasher = blake3::Hasher::new();
1444        hasher.update(b"tensor-wasm-jit::DiskCache::path::v1\0");
1445        hasher.update(&key.tenant_id.to_le_bytes());
1446        hasher.update(&key.blueprint.to_le_bytes());
1447        hasher.update(&key.sm_version.to_le_bytes());
1448        hasher.update(&key.emit_config_hash.to_le_bytes());
1449        let h = hasher.finalize();
1450        // First 16 bytes (32 hex chars) is plenty of entropy for filenames.
1451        //
1452        // T20 perf: render the digest into a fixed 32-byte stack buffer via
1453        // `hex::encode_to_slice` rather than 16× `format!("{b:02x}")` —
1454        // the per-byte `format!` path used to allocate 16 transient `String`s
1455        // (plus the `.collect()` target) on every disk-cache op. The slice
1456        // form writes ASCII hex directly into the buffer with no heap
1457        // traffic; `str::from_utf8` then borrows it as a `&str` for the
1458        // final `format!`-built filename.
1459        let digest = h.as_bytes();
1460        let mut cache_key_hex_buf = [0u8; 32];
1461        hex::encode_to_slice(&digest[..16], &mut cache_key_hex_buf)
1462            .expect("32 byte buf for 16 byte input");
1463        let cache_key_hex = std::str::from_utf8(&cache_key_hex_buf).expect("hex is utf8");
1464        // Perf: the HMAC-key fingerprint prefix is constant for the cache's
1465        // lifetime, so it was hoisted to a `key_prefix_hex` field computed
1466        // once in `DiskCache::new` rather than re-hashing `blake3(hmac_key)`
1467        // on every disk op. The per-cache-key digest above genuinely varies
1468        // per call and stays inline. See the `key_prefix_hex` field doc.
1469        let key_prefix_hex = &self.key_prefix_hex;
1470        self.dir
1471            .join(format!("{key_prefix_hex}-{cache_key_hex}.ptxbin"))
1472    }
1473
1474    /// Encode a kernel into the inner V2 envelope (the same byte layout
1475    /// pre-T30 used at the head of every `.ptxbin` file, minus the
1476    /// per-envelope HMAC trailer — the streaming HMAC is now the
1477    /// artifact store's job). The result is what gets handed to
1478    /// [`DiskArtifactStore::put`].
1479    fn encode_v2_envelope(key: &CacheKey, kernel: &CachedKernel) -> Vec<u8> {
1480        let ptx_bytes = kernel.ptx.text.as_bytes();
1481        let (grid_x, block_x) = kernel.ptx.launch_geometry;
1482        let mut buf = Vec::with_capacity(DISK_CACHE_HEADER_LEN_V2 + ptx_bytes.len());
1483        buf.extend_from_slice(DISK_CACHE_MAGIC_V2);
1484        // jit L (tenant-isolation defence-in-depth): bind the owning tenant
1485        // into the persisted envelope, not just the filename path. The reader
1486        // asserts this matches the requesting key so a sidecar+blob planted at
1487        // another tenant's `path_for` location is rejected. See the `DiskCache`
1488        // "Tenant binding" doc.
1489        buf.extend_from_slice(&key.tenant_id.to_le_bytes());
1490        buf.extend_from_slice(&key.blueprint.to_le_bytes());
1491        buf.extend_from_slice(&key.sm_version.to_le_bytes());
1492        // jit S-3 follow-up: persist `launch_geometry` so L2 hits round-trip
1493        // the (grid_x, block_x) hint that `ptx_emit` populates. Prior to V2
1494        // the reconstructor defaulted this to (0, 0) on every disk hit.
1495        buf.extend_from_slice(&grid_x.to_le_bytes());
1496        buf.extend_from_slice(&block_x.to_le_bytes());
1497        buf.extend_from_slice(&(ptx_bytes.len() as u64).to_le_bytes());
1498        buf.extend_from_slice(ptx_bytes);
1499        buf
1500    }
1501
1502    /// Write a kernel via the layered `(sidecar -> blob)` representation:
1503    ///
1504    /// 1. Build the inner V2 envelope `Vec<u8>` (cross-version-compat
1505    ///    format, T12-aligned).
1506    /// 2. Hand the envelope bytes to [`DiskArtifactStore::put`] which
1507    ///    streams HMAC + zstd onto disk under a content-addressed path
1508    ///    and returns the [`ContentHash`] of the envelope.
1509    /// 3. Write a small sidecar at [`Self::path_for`] mapping the
1510    ///    [`CacheKey`] to that [`ContentHash`] via an atomic temp-then-
1511    ///    rename, so a partial write never strands a half-formed
1512    ///    sidecar that a concurrent reader might trip over.
1513    ///
1514    /// The artifact store inherits T22's streaming property: the
1515    /// envelope's bytes are not re-buffered into another `Vec` before
1516    /// HMAC + zstd — the store's `put` tees through a `MacWriter` /
1517    /// zstd encoder pipeline straight to the file. The only buffered
1518    /// allocation here is the v2 envelope itself, which is bounded by
1519    /// the kernel's PTX size and lives just long enough to be consumed
1520    /// by `store.put`.
1521    fn put(&self, key: &CacheKey, kernel: &CachedKernel) -> std::io::Result<()> {
1522        use std::io::Write;
1523        std::fs::create_dir_all(&self.dir)?;
1524        // Step 1: build the inner V2 envelope (no per-envelope MAC —
1525        // the artifact store's HMAC covers it transitively).
1526        let envelope = Self::encode_v2_envelope(key, kernel);
1527        // Step 2: stream the envelope through the artifact store. The
1528        // store handles atomic temp-then-rename of the blob itself.
1529        let hash = self.store.put(&envelope).map_err(|e| {
1530            // Wrap as `io::Error` so the existing `Result<(), io::Error>`
1531            // signature of `DiskCache::put` (and the warn-level log path
1532            // in `KernelCache::put`) stays unchanged.
1533            std::io::Error::other(e.to_string())
1534        })?;
1535        // Step 3: stamp the sidecar atomically. The sidecar is small
1536        // (48 bytes) and lives at a path derived from the cache key, so
1537        // the lookup side only needs one read to find the content
1538        // hash and one more (through the artifact store) to fetch the
1539        // verified envelope bytes.
1540        let mut sidecar = Vec::with_capacity(SIDECAR_LEN_V1);
1541        sidecar.extend_from_slice(SIDECAR_MAGIC_V1);
1542        sidecar.extend_from_slice(hash.as_bytes());
1543        debug_assert_eq!(sidecar.len(), SIDECAR_LEN_V1);
1544        let sidecar_path = self.path_for(key);
1545        let mut tmp = tempfile::NamedTempFile::new_in(&self.dir)?;
1546        tmp.as_file_mut().write_all(&sidecar)?;
1547        tmp.persist(&sidecar_path).map_err(std::io::Error::other)?;
1548        Ok(())
1549    }
1550
1551    /// Read and verify a kernel from disk. Returns `Ok(None)` on a
1552    /// genuine miss (sidecar does not exist), `Err` on I/O failure,
1553    /// and `Ok(None)` (with a warn-level log) on any integrity failure
1554    /// — magic mismatch on the sidecar, blob lookup failure,
1555    /// artifact-store HMAC mismatch, or envelope header mismatch. The
1556    /// loader treats every integrity failure as "no such entry" so a
1557    /// poisoned file behaves identically to a fresh cache.
1558    fn get(&self, key: &CacheKey) -> std::io::Result<Option<CachedKernel>> {
1559        // ---- Sidecar lookup. ----
1560        let sidecar_path = self.path_for(key);
1561        let sidecar = match std::fs::read(&sidecar_path) {
1562            Ok(b) => b,
1563            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1564            Err(e) => return Err(e),
1565        };
1566        if sidecar.len() != SIDECAR_LEN_V1 {
1567            tracing::warn!(
1568                target: "tensor_wasm_jit::cache",
1569                file = %sidecar_path.display(),
1570                len = sidecar.len(),
1571                "disk-cache sidecar wrong length; treating as miss"
1572            );
1573            return Ok(None);
1574        }
1575        if &sidecar[..16] != SIDECAR_MAGIC_V1 {
1576            // Either a legacy pre-T30 raw-V2 record sitting at the same
1577            // path (TWJIT-KRNL-v2 magic) or unrelated garbage. Either
1578            // way the new reader does not understand it — treat as a
1579            // miss so the next `put` rewrites it under the T30 layout.
1580            tracing::info!(
1581                target: "tensor_wasm_jit::cache",
1582                file = %sidecar_path.display(),
1583                "cache.l2.miss.legacy_or_unknown_magic: sidecar will be rewritten on next put"
1584            );
1585            return Ok(None);
1586        }
1587        let mut hash_bytes = [0u8; 32];
1588        hash_bytes.copy_from_slice(&sidecar[16..48]);
1589        let content_hash = ContentHash::from_bytes(hash_bytes);
1590
1591        // ---- Streaming-verified blob fetch via the artifact store. ----
1592        //
1593        // The store handles HMAC-SHA256 verification (constant-time),
1594        // zstd decompression with a [`MAX_DECOMPRESSED_LEN`] cap, and
1595        // content-hash defence-in-depth. Any failure (NotFound,
1596        // BadHmac, HashMismatch, …) collapses to a miss here, mirroring
1597        // the pre-T30 reader's "log + return Ok(None)" convention so
1598        // the call site's `cache.misses_total` counter still ticks
1599        // correctly on integrity rejection.
1600        let envelope = match self.store.get(&content_hash) {
1601            Ok(bytes) => bytes,
1602            Err(ArtifactError::NotFound(_)) => {
1603                tracing::warn!(
1604                    target: "tensor_wasm_jit::cache",
1605                    file = %sidecar_path.display(),
1606                    "disk-cache sidecar references missing artifact blob; treating as miss"
1607                );
1608                return Ok(None);
1609            }
1610            Err(e) => {
1611                // Anything that is not a clean NotFound — BadHmac,
1612                // HashMismatch, decompression-bomb cap, … — is a genuine
1613                // integrity failure (tamper signal), so bump the dedicated
1614                // integrity-rejection counter in addition to the call
1615                // site's miss counter.
1616                self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1617                tracing::warn!(
1618                    target: "tensor_wasm_jit::cache",
1619                    file = %sidecar_path.display(),
1620                    error = %e,
1621                    "disk-cache artifact-store read failed; treating as miss"
1622                );
1623                return Ok(None);
1624            }
1625        };
1626
1627        // ---- Parse the inner V2 envelope. ----
1628        if envelope.len() < DISK_CACHE_HEADER_LEN_V2 {
1629            tracing::warn!(
1630                target: "tensor_wasm_jit::cache",
1631                file = %sidecar_path.display(),
1632                len = envelope.len(),
1633                "disk-cache V2 envelope too short; treating as miss"
1634            );
1635            return Ok(None);
1636        }
1637        if &envelope[..16] != DISK_CACHE_MAGIC_V2 {
1638            // The artifact store already HMAC-verified the blob, so a bad
1639            // envelope magic here means the *verified* bytes are not a
1640            // kernel envelope — corruption/tamper inside the signed
1641            // payload. Count it as an integrity rejection.
1642            self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1643            tracing::warn!(
1644                target: "tensor_wasm_jit::cache",
1645                file = %sidecar_path.display(),
1646                "disk-cache V2 envelope magic mismatch; treating as miss"
1647            );
1648            return Ok(None);
1649        }
1650        // Header integrity is implied by the artifact store's HMAC, but
1651        // cross-check tenant_id, fingerprint and sm_version against the
1652        // requested key as defence-in-depth — a sidecar that points at a
1653        // foreign blob (e.g. someone hand-edited the sidecar's content hash)
1654        // would still survive the store's MAC because each blob carries
1655        // its own self-consistent envelope; only this final check
1656        // refuses the mismatch.
1657        let mut tenant_bytes = [0u8; 8];
1658        tenant_bytes.copy_from_slice(&envelope[16..24]);
1659        let mut bp_bytes = [0u8; 8];
1660        bp_bytes.copy_from_slice(&envelope[24..32]);
1661        let mut sm_bytes = [0u8; 4];
1662        sm_bytes.copy_from_slice(&envelope[32..36]);
1663        let mut grid_x_bytes = [0u8; 4];
1664        grid_x_bytes.copy_from_slice(&envelope[36..40]);
1665        let mut block_x_bytes = [0u8; 4];
1666        block_x_bytes.copy_from_slice(&envelope[40..44]);
1667        let mut len_bytes = [0u8; 8];
1668        len_bytes.copy_from_slice(&envelope[44..52]);
1669        let tenant_on_disk = u64::from_le_bytes(tenant_bytes);
1670        let fingerprint_on_disk = u64::from_le_bytes(bp_bytes);
1671        let sm_version_on_disk = u32::from_le_bytes(sm_bytes);
1672        let grid_x_on_disk = u32::from_le_bytes(grid_x_bytes);
1673        let block_x_on_disk = u32::from_le_bytes(block_x_bytes);
1674        let ptx_len_on_disk = u64::from_le_bytes(len_bytes) as usize;
1675        // jit L (tenant-isolation defence-in-depth): the persisted tenant_id
1676        // MUST match the requesting key. The filename path already partitions
1677        // by tenant (path_for folds tenant_id into the hash), but binding the
1678        // tenant *inside* the signed envelope closes the gap where a planted
1679        // sidecar+blob at another tenant's path would otherwise be served to
1680        // that tenant. A genuine pre-tenant V2 blob also lands here (its
1681        // misaligned fields will not satisfy this check) and is rejected as a
1682        // clean miss to be rewritten on the next put.
1683        if tenant_on_disk != key.tenant_id
1684            || fingerprint_on_disk != key.blueprint
1685            || sm_version_on_disk != key.sm_version
1686        {
1687            // Fires only on a HMAC-verified blob whose header does not match
1688            // the requested key — a hand-edited sidecar pointing at a
1689            // foreign blob, or a blob planted under a foreign tenant's path.
1690            // Integrity rejection.
1691            self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1692            tracing::warn!(
1693                target: "tensor_wasm_jit::cache",
1694                file = %sidecar_path.display(),
1695                tenant = key.tenant_id,
1696                tenant_on_disk,
1697                "disk-cache V2 envelope header key mismatch (tenant/fingerprint/sm); treating as miss"
1698            );
1699            return Ok(None);
1700        }
1701        let ptx_start = DISK_CACHE_HEADER_LEN_V2;
1702        let ptx_end = ptx_start.saturating_add(ptx_len_on_disk);
1703        if ptx_end > envelope.len() {
1704            // Declared length overruns the verified envelope — structural
1705            // corruption inside the signed payload. Integrity rejection.
1706            self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1707            tracing::warn!(
1708                target: "tensor_wasm_jit::cache",
1709                file = %sidecar_path.display(),
1710                "disk-cache declared ptx_len overruns envelope; treating as miss"
1711            );
1712            return Ok(None);
1713        }
1714        let ptx_text = match std::str::from_utf8(&envelope[ptx_start..ptx_end]) {
1715            Ok(s) => s.to_string(),
1716            Err(_) => {
1717                // Non-UTF-8 PTX in a verified blob — corruption/tamper.
1718                self.integrity_reject_total.fetch_add(1, Ordering::Relaxed);
1719                tracing::warn!(
1720                    target: "tensor_wasm_jit::cache",
1721                    file = %sidecar_path.display(),
1722                    "disk-cache PTX bytes are not valid UTF-8; treating as miss"
1723                );
1724                return Ok(None);
1725            }
1726        };
1727        // Reconstruct the kernel via the integrity-aware constructor so
1728        // the L1 cache accepts it without further verification work.
1729        // V2 persists the emit-time `launch_geometry` hint; reading it
1730        // back here closes the lost-geometry bug (previously this defaulted
1731        // to (0, 0) and the dispatch path silently fell back to guest-
1732        // declared launch params for every L2 hit).
1733        let ptx = Arc::new(EmittedPtx {
1734            text: ptx_text,
1735            launch_geometry: (grid_x_on_disk, block_x_on_disk),
1736        });
1737        Ok(Some(CachedKernel::new(
1738            fingerprint_on_disk,
1739            ptx,
1740            CompiledHandle::default(),
1741        )))
1742    }
1743
1744    /// Fingerprint of this cache's *own* (currently-active) HMAC key — the
1745    /// partition prefix every file this cache writes lands under.
1746    fn active_key_fingerprint(&self) -> KeyFingerprint {
1747        KeyFingerprint::of_key(&self.hmac_key)
1748    }
1749
1750    /// Parse the key-fingerprint segment out of a cache file name.
1751    ///
1752    /// Two on-disk shapes carry the fingerprint:
1753    ///   * sidecars `{fp}-{cache_key}.ptxbin` — fingerprint is the segment
1754    ///     before the first `-`.
1755    ///   * artifact blobs `{content_hash}.{fp}.bin` — fingerprint is the
1756    ///     second-to-last `.`-delimited segment.
1757    ///
1758    /// Returns `None` for any name that does not match one of these shapes
1759    /// (so unrelated files in the directory are never touched by `gc`).
1760    fn fingerprint_of_filename(name: &str) -> Option<KeyFingerprint> {
1761        if let Some(rest) = name.strip_suffix(".ptxbin") {
1762            // `{fp}-{cache_key}` — split on the first '-'.
1763            let fp = rest.split('-').next()?;
1764            if fp.is_empty() || fp.len() != 16 {
1765                return None;
1766            }
1767            return Some(KeyFingerprint(fp.to_string()));
1768        }
1769        if let Some(rest) = name.strip_suffix(".bin") {
1770            // `{content_hash}.{fp}` — fingerprint is the trailing segment.
1771            let fp = rest.rsplit('.').next()?;
1772            if fp.len() != 16 {
1773                return None;
1774            }
1775            return Some(KeyFingerprint(fp.to_string()));
1776        }
1777        None
1778    }
1779
1780    /// Enumerate the distinct HMAC-key fingerprints with at least one file
1781    /// (sidecar or artifact blob) present in the cache directory. A missing
1782    /// directory yields an empty set (a fresh cache has nothing to sweep).
1783    fn key_fingerprints_on_disk(&self) -> std::io::Result<Vec<KeyFingerprint>> {
1784        let mut seen = std::collections::BTreeSet::new();
1785        let rd = match std::fs::read_dir(&self.dir) {
1786            Ok(rd) => rd,
1787            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1788                return Ok(Vec::new());
1789            }
1790            Err(e) => return Err(e),
1791        };
1792        for entry in rd {
1793            let entry = entry?;
1794            if let Some(name) = entry.file_name().to_str() {
1795                if let Some(fp) = Self::fingerprint_of_filename(name) {
1796                    seen.insert(fp);
1797                }
1798            }
1799        }
1800        Ok(seen.into_iter().collect())
1801    }
1802
1803    /// Garbage-collect stale-fingerprint files: remove every sidecar /
1804    /// artifact-blob whose key fingerprint is NOT in `retain`, returning
1805    /// the count of files removed.
1806    ///
1807    /// The active key's fingerprint is unconditionally retained even if a
1808    /// caller forgets to list it — this is the safety guarantee in the
1809    /// public-API docs: `gc` only sweeps *stale* generations, never the
1810    /// live one. Files that do not match a known cache-file shape (anything
1811    /// `fingerprint_of_filename` rejects) are left untouched.
1812    fn gc(&self, retain: &[KeyFingerprint]) -> std::io::Result<usize> {
1813        let active = self.active_key_fingerprint();
1814        let rd = match std::fs::read_dir(&self.dir) {
1815            Ok(rd) => rd,
1816            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1817                return Ok(0);
1818            }
1819            Err(e) => return Err(e),
1820        };
1821        let mut removed = 0usize;
1822        for entry in rd {
1823            let entry = entry?;
1824            let name_os = entry.file_name();
1825            let name = match name_os.to_str() {
1826                Some(n) => n,
1827                None => continue,
1828            };
1829            let fp = match Self::fingerprint_of_filename(name) {
1830                Some(fp) => fp,
1831                None => continue, // not one of ours — leave it alone
1832            };
1833            // Never sweep the active key's files; never sweep a retained one.
1834            if fp == active || retain.contains(&fp) {
1835                continue;
1836            }
1837            match std::fs::remove_file(entry.path()) {
1838                Ok(()) => removed += 1,
1839                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1840                    // Raced with another sweeper / writer; treat as already gone.
1841                }
1842                Err(e) => return Err(e),
1843            }
1844        }
1845        Ok(removed)
1846    }
1847}
1848
1849impl Default for KernelCache {
1850    fn default() -> Self {
1851        Self::new()
1852    }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858    use crate::ir::{ElemType, TensorWasmKernelBlueprint, TensorWasmOp};
1859    use crate::ptx_emit::EmittedPtx;
1860    use std::thread;
1861
1862    fn dummy_kernel(fp: u64) -> CachedKernel {
1863        // Route through `CachedKernel::new` so `integrity_hash` matches the
1864        // PTX text by construction; `KernelCache::put` rejects entries
1865        // whose hash disagrees with the text (jit S-3).
1866        CachedKernel::new(
1867            fp,
1868            Arc::new(EmittedPtx {
1869                text: String::new(),
1870                launch_geometry: (1, 1),
1871            }),
1872            CompiledHandle::default(),
1873        )
1874    }
1875
1876    #[test]
1877    fn put_then_get() {
1878        let cache = KernelCache::new();
1879        let key = CacheKey::for_tenant(TenantId(7), 1, 80);
1880        cache.put(key, dummy_kernel(1));
1881        assert_eq!(cache.get(&key).unwrap().fingerprint, 1);
1882    }
1883
1884    /// Build a kernel whose PTX text is exactly `bytes` long so byte-cap
1885    /// tests can reason about `total_bytes` precisely.
1886    fn sized_kernel(fp: u64, bytes: usize) -> CachedKernel {
1887        CachedKernel::new(
1888            fp,
1889            Arc::new(EmittedPtx {
1890                text: "x".repeat(bytes),
1891                launch_geometry: (1, 1),
1892            }),
1893            CompiledHandle::default(),
1894        )
1895    }
1896
1897    /// Byte-cap eviction: with a generous count cap but a tight byte cap,
1898    /// inserting oversized blueprints must keep the resident byte total
1899    /// bounded by evicting LRU entries, and the eviction order must be LRU.
1900    #[test]
1901    fn byte_cap_evicts_lru_and_bounds_total_bytes() {
1902        // Count cap of 100 (won't bind) but a 250-byte total cap. Each
1903        // entry is 100 bytes, so at most 2 entries fit (200 <= 250; a 3rd
1904        // would push to 300 > 250).
1905        let cache = KernelCache::with_config(
1906            KernelCacheConfig::default()
1907                .with_capacity(100)
1908                .with_max_total_bytes(250),
1909        );
1910        let k1 = CacheKey::for_tenant(TenantId(0), 1, 80);
1911        let k2 = CacheKey::for_tenant(TenantId(0), 2, 80);
1912        let k3 = CacheKey::for_tenant(TenantId(0), 3, 80);
1913
1914        cache.put(k1, sized_kernel(1, 100));
1915        cache.put(k2, sized_kernel(2, 100));
1916        assert_eq!(cache.total_bytes(), 200, "two 100-byte entries fit");
1917        assert_eq!(cache.len(), 2);
1918
1919        // Inserting the third 100-byte entry overflows the byte cap (300 >
1920        // 250). The LRU entry (k1) must be evicted, bringing the total back
1921        // to 200 and keeping k2 + k3.
1922        cache.put(k3, sized_kernel(3, 100));
1923        assert!(
1924            cache.total_bytes() <= 250,
1925            "byte total must stay bounded by the cap, got {}",
1926            cache.total_bytes()
1927        );
1928        assert_eq!(cache.total_bytes(), 200);
1929        assert_eq!(cache.len(), 2);
1930        assert!(
1931            cache.get(&k1).is_none(),
1932            "k1 is the LRU and must be evicted"
1933        );
1934        assert!(cache.get(&k2).is_some(), "k2 must survive");
1935        assert!(cache.get(&k3).is_some(), "k3 was just inserted");
1936    }
1937
1938    /// A single entry larger than the byte cap is still admitted (the cache
1939    /// never wedges dispatch by refusing an insert) but it evicts every
1940    /// other entry, so the byte total settles at just that one oversized
1941    /// entry.
1942    #[test]
1943    fn byte_cap_admits_single_oversized_entry() {
1944        let cache = KernelCache::with_config(
1945            KernelCacheConfig::default()
1946                .with_capacity(100)
1947                .with_max_total_bytes(50),
1948        );
1949        let small = CacheKey::for_tenant(TenantId(0), 1, 80);
1950        let big = CacheKey::for_tenant(TenantId(0), 2, 80);
1951        cache.put(small, sized_kernel(1, 40));
1952        cache.put(big, sized_kernel(2, 1000));
1953        // The oversized entry is served; the small one was evicted to make
1954        // room. Total transiently exceeds the cap by exactly that one entry.
1955        assert_eq!(cache.len(), 1);
1956        assert_eq!(cache.total_bytes(), 1000);
1957        assert!(
1958            cache.get(&big).is_some(),
1959            "oversized entry must still serve"
1960        );
1961        assert!(cache.get(&small).is_none(), "smaller LRU entry evicted");
1962    }
1963
1964    /// `total_bytes` must track replacement (same key re-inserted with a
1965    /// different-sized payload) as a delta, not an add.
1966    #[test]
1967    fn total_bytes_tracks_replacement() {
1968        let cache = KernelCache::new();
1969        let key = CacheKey::for_tenant(TenantId(0), 1, 80);
1970        cache.put(key, sized_kernel(1, 100));
1971        assert_eq!(cache.total_bytes(), 100);
1972        // Replace the same key with a larger payload — net delta is +50.
1973        cache.put(key, sized_kernel(1, 150));
1974        assert_eq!(cache.len(), 1);
1975        assert_eq!(cache.total_bytes(), 150);
1976    }
1977
1978    /// Default config keeps the byte cap disabled (count-only behaviour),
1979    /// while `total_bytes` still tracks the live total.
1980    #[test]
1981    fn byte_cap_default_is_none() {
1982        let cache = KernelCache::new();
1983        assert_eq!(cache.max_total_bytes(), None);
1984        cache.put(
1985            CacheKey::for_tenant(TenantId(0), 1, 80),
1986            sized_kernel(1, 512),
1987        );
1988        assert_eq!(cache.total_bytes(), 512);
1989    }
1990
1991    #[test]
1992    fn lru_evicts_oldest() {
1993        let cache = KernelCache::with_capacity(2);
1994        let k1 = CacheKey::for_tenant(TenantId(0), 1, 80);
1995        let k2 = CacheKey::for_tenant(TenantId(0), 2, 80);
1996        let k3 = CacheKey::for_tenant(TenantId(0), 3, 80);
1997        cache.put(k1, dummy_kernel(1));
1998        cache.put(k2, dummy_kernel(2));
1999        cache.put(k3, dummy_kernel(3));
2000        assert!(cache.get(&k1).is_none(), "k1 should have been evicted");
2001        assert!(cache.get(&k2).is_some());
2002        assert!(cache.get(&k3).is_some());
2003        assert_eq!(cache.len(), 2);
2004    }
2005
2006    #[test]
2007    fn lookup_by_blueprint() {
2008        let cache = KernelCache::new();
2009        let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
2010            elem: ElemType::F32,
2011            lanes: 4,
2012        });
2013        let tenant = TenantId(11);
2014        let key = CacheKey::for_tenant(tenant, bp.fingerprint(), 80);
2015        cache.put(key, dummy_kernel(bp.fingerprint()));
2016        assert!(cache.get_for(tenant, &bp, 80).is_some());
2017        assert!(
2018            cache.get_for(tenant, &bp, 89).is_none(),
2019            "different sm_version is a miss"
2020        );
2021        assert!(
2022            cache.get_for(TenantId(12), &bp, 80).is_none(),
2023            "different tenant is a miss — keys are tenant-scoped"
2024        );
2025    }
2026
2027    /// jit HIGH (finding 2): a kernel cached under an `f32x4.add` blueprint
2028    /// must NOT be returned for an `i32x4.add` blueprint. Before the
2029    /// element-type fix both blueprints fingerprinted identically, so the
2030    /// integer lookup would HIT the float kernel — cross-kernel cache
2031    /// aliasing serving a miscompiled kernel. The lookup now misses, which
2032    /// drives a correct (integer) emit.
2033    #[test]
2034    fn element_type_is_not_cache_aliased() {
2035        let cache = KernelCache::new();
2036        let tenant = TenantId(7);
2037        let f32_bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
2038            elem: ElemType::F32,
2039            lanes: 4,
2040        });
2041        let i32_bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
2042            elem: ElemType::I32,
2043            lanes: 4,
2044        });
2045        // Distinct cache keys (the rewrite-time pre-population keys on this).
2046        assert_ne!(f32_bp.fingerprint(), i32_bp.fingerprint());
2047
2048        // Populate ONLY the f32 kernel.
2049        let key = CacheKey::for_tenant(tenant, f32_bp.fingerprint(), 80);
2050        cache.put(key, dummy_kernel(f32_bp.fingerprint()));
2051
2052        // The f32 lookup hits; the i32 lookup must MISS (no aliasing).
2053        assert!(cache.get_for(tenant, &f32_bp, 80).is_some());
2054        assert!(
2055            cache.get_for(tenant, &i32_bp, 80).is_none(),
2056            "i32x4.add must not alias the f32x4.add cache slot"
2057        );
2058    }
2059
2060    #[test]
2061    fn capacity_floor_one() {
2062        let cache = KernelCache::with_capacity(0);
2063        assert_eq!(cache.capacity(), 1);
2064    }
2065
2066    #[test]
2067    fn empty_when_new() {
2068        let cache = KernelCache::new();
2069        assert!(cache.is_empty());
2070        assert_eq!(cache.len(), 0);
2071    }
2072
2073    #[test]
2074    fn cache_hit_returns_arc_shared_ptx() {
2075        use std::sync::Arc;
2076        let cache = KernelCache::new();
2077        let bp = TensorWasmKernelBlueprint::new("matmul").push(TensorWasmOp::MatMul {
2078            m: 16,
2079            n: 16,
2080            k: 16,
2081        });
2082        let key = CacheKey::for_tenant(TenantId(3), bp.fingerprint(), 80);
2083        let original = Arc::new(EmittedPtx {
2084            text: "// pre-emitted".into(),
2085            launch_geometry: (1, 128),
2086        });
2087        cache.put(
2088            key,
2089            CachedKernel::new(
2090                bp.fingerprint(),
2091                original.clone(),
2092                CompiledHandle::default(),
2093            ),
2094        );
2095        let hit = cache.get(&key).expect("cache hit");
2096        // The hit returns the same underlying allocation — no re-emit.
2097        assert!(Arc::ptr_eq(&hit.ptx, &original));
2098    }
2099
2100    /// Concurrent get/put across many threads must not corrupt the cache
2101    /// or drop entries that haven't been evicted. Uses a capacity large
2102    /// enough to hold every key inserted so we can assert all `get`s
2103    /// targeting still-present keys succeed.
2104    #[test]
2105    fn concurrent_get_put_dashmap_safe() {
2106        const N_THREADS: usize = 8;
2107        const KEYS_PER_THREAD: u64 = 32;
2108        let cache = KernelCache::with_capacity((N_THREADS as u64 * KEYS_PER_THREAD) as usize);
2109        let mut handles = Vec::new();
2110        for t in 0..N_THREADS {
2111            let cache = cache.clone();
2112            handles.push(thread::spawn(move || {
2113                for i in 0..KEYS_PER_THREAD {
2114                    let key =
2115                        CacheKey::for_tenant(TenantId(0), (t as u64) * KEYS_PER_THREAD + i, 80);
2116                    cache.put(key, dummy_kernel(key.blueprint));
2117                    // Interleave reads of own and (possibly absent) others.
2118                    let _ = cache.get(&key);
2119                }
2120            }));
2121        }
2122        for h in handles {
2123            h.join().expect("worker thread panicked");
2124        }
2125        // Every key written must still be retrievable.
2126        for t in 0..N_THREADS {
2127            for i in 0..KEYS_PER_THREAD {
2128                let key = CacheKey::for_tenant(TenantId(0), (t as u64) * KEYS_PER_THREAD + i, 80);
2129                assert!(
2130                    cache.get(&key).is_some(),
2131                    "missing key after concurrent inserts: ({t}, {i})"
2132                );
2133            }
2134        }
2135    }
2136
2137    /// T20 perf regression: `KernelCache::get` returns a shared
2138    /// `Arc<CachedKernel>` rather than a cloned wrapper value. Two
2139    /// successive hits on the same key must yield pointer-equal Arcs
2140    /// (same allocation, just a refcount bump per hit) — proving the
2141    /// hot path no longer clones the 32-byte integrity hash, the
2142    /// fingerprint word, and the inner `Arc<EmittedPtx>` refcount on
2143    /// every dispatch.
2144    #[test]
2145    fn cache_get_returns_arc_no_clone() {
2146        let cache = KernelCache::new();
2147        let key = CacheKey::for_tenant(TenantId(1), 0xABCD, 80);
2148        let original_ptx = Arc::new(EmittedPtx {
2149            text: ".visible .entry t20_arc(){}".into(),
2150            launch_geometry: (1, 32),
2151        });
2152        cache.put(
2153            key,
2154            CachedKernel::new(0xABCD, original_ptx.clone(), CompiledHandle::default()),
2155        );
2156        let first = cache.get(&key).expect("first hit");
2157        let second = cache.get(&key).expect("second hit");
2158        // The two `Arc<CachedKernel>` handles must point at the same
2159        // allocation. If `get` had reverted to cloning the wrapper, the
2160        // two handles would point at distinct allocations (still sharing
2161        // the inner `Arc<EmittedPtx>`, but that is a different invariant).
2162        assert!(
2163            Arc::ptr_eq(&first, &second),
2164            "cache.get must return refcount-bumped Arc handles, not cloned \
2165             wrappers — distinct allocations indicate the T20 perf fix \
2166             regressed (clone-on-hit reintroduced)"
2167        );
2168        // Bonus: the inner PTX is still shared with the original (this was
2169        // already pinned by `cache_hit_returns_arc_shared_ptx` above).
2170        assert!(Arc::ptr_eq(&first.ptx, &original_ptx));
2171    }
2172
2173    /// T20 perf: `get` increments `cache_hits_total` on every Some-returning
2174    /// call and `cache_misses_total` on every None-returning call. A miss
2175    /// followed by a hit must produce one of each, with neither counter
2176    /// double-counting.
2177    #[test]
2178    fn cache_hits_misses_counter() {
2179        let cache = KernelCache::new();
2180        assert_eq!(cache.cache_hits_total(), 0);
2181        assert_eq!(cache.cache_misses_total(), 0);
2182
2183        let key = CacheKey::for_tenant(TenantId(9), 0xC0FFEE, 80);
2184
2185        // Miss: nothing has been inserted yet.
2186        assert!(cache.get(&key).is_none(), "fresh cache must miss");
2187        assert_eq!(
2188            cache.cache_misses_total(),
2189            1,
2190            "first miss must bump the miss counter exactly once"
2191        );
2192        assert_eq!(
2193            cache.cache_hits_total(),
2194            0,
2195            "a miss must not bump the hit counter"
2196        );
2197
2198        // Hit: install then look up.
2199        cache.put(key, dummy_kernel(0xC0FFEE));
2200        assert!(cache.get(&key).is_some(), "post-put get must hit");
2201        assert_eq!(
2202            cache.cache_hits_total(),
2203            1,
2204            "first hit must bump the hit counter exactly once"
2205        );
2206        assert_eq!(
2207            cache.cache_misses_total(),
2208            1,
2209            "a hit must not bump the miss counter"
2210        );
2211
2212        // Second hit increments hits again.
2213        assert!(cache.get(&key).is_some());
2214        assert_eq!(cache.cache_hits_total(), 2);
2215        assert_eq!(cache.cache_misses_total(), 1);
2216    }
2217
2218    /// jit L (tenant-isolation defence-in-depth): the V2 disk envelope now
2219    /// binds `tenant_id`, and the L2 reader cross-checks it against the
2220    /// requesting key. A sidecar+blob planted at another tenant's
2221    /// `path_for` location (simulated here by writing under tenant A's key,
2222    /// then reading the *exact same path* with a key that differs only in
2223    /// `tenant_id`) must be rejected as a miss and bump the
2224    /// integrity-rejection counter, rather than being served cross-tenant.
2225    #[test]
2226    fn disk_envelope_binds_tenant_and_rejects_cross_tenant_blob() {
2227        let tmp = tempfile::TempDir::new().expect("tempdir");
2228        let dir = tmp.path().to_path_buf();
2229        let hmac_key = [0x5Au8; 32];
2230
2231        let cache = KernelCache::new().with_disk_persistence(DiskCacheConfig {
2232            dir: dir.clone(),
2233            hmac_key,
2234        });
2235        let disk = cache.disk.as_ref().expect("disk configured");
2236
2237        // Same blueprint/sm/emit_config for two tenants; the only difference
2238        // is `tenant_id`. `path_for` already folds tenant_id into the hash,
2239        // so a real put lands tenant A and tenant B at different paths — to
2240        // simulate a *planted* blob we deliberately write A's envelope to B's
2241        // path and confirm the in-envelope tenant check still rejects it.
2242        let key_a = CacheKey::for_tenant(TenantId(100), 0xAA, 80);
2243        let key_b = CacheKey::for_tenant(TenantId(200), 0xAA, 80);
2244
2245        let kernel = CachedKernel::new(
2246            0xAA,
2247            Arc::new(EmittedPtx {
2248                text: ".visible .entry tenant_bound(){}".into(),
2249                launch_geometry: (4, 8),
2250            }),
2251            CompiledHandle::default(),
2252        );
2253
2254        // Honest round-trip for tenant A succeeds and round-trips geometry.
2255        disk.put(&key_a, &kernel).expect("put A");
2256        let hit_a = disk.get(&key_a).expect("get A ok").expect("A present");
2257        assert_eq!(hit_a.fingerprint, 0xAA);
2258        assert_eq!(hit_a.ptx.launch_geometry, (4, 8));
2259
2260        // Plant A's blob at B's sidecar path: write A's envelope through the
2261        // artifact store, then stamp a sidecar at B's `path_for` pointing at
2262        // it. This mimics an attacker who can drop files at B's path.
2263        let envelope = DiskCache::encode_v2_envelope(&key_a, &kernel);
2264        let hash = disk.store.put(&envelope).expect("store put");
2265        let mut sidecar = Vec::with_capacity(SIDECAR_LEN_V1);
2266        sidecar.extend_from_slice(SIDECAR_MAGIC_V1);
2267        sidecar.extend_from_slice(hash.as_bytes());
2268        std::fs::write(disk.path_for(&key_b), &sidecar).expect("plant sidecar");
2269
2270        let before = cache.integrity_reject_total();
2271        // Reading as tenant B must reject: the envelope's bound tenant (A)
2272        // does not match B's key, so the cross-check fires.
2273        assert!(
2274            disk.get(&key_b).expect("get B ok").is_none(),
2275            "a blob bound to tenant A must not be served to tenant B"
2276        );
2277        assert_eq!(
2278            cache.integrity_reject_total(),
2279            before + 1,
2280            "cross-tenant blob must count as an integrity rejection"
2281        );
2282    }
2283
2284    /// jit S-3 T13 regression: `DiskCacheConfig`'s `Debug` impl MUST NOT
2285    /// dump the raw `hmac_key` bytes. A derived `Debug` would have
2286    /// formatted the array as `[222, 222, 222, …]` (or `[de, de, …]`
2287    /// under hex), leaking the server-side secret into any log line,
2288    /// panic message, or `tracing` field expansion that happens to
2289    /// include a `DiskCacheConfig`.
2290    ///
2291    /// We use the sentinel byte `0xDE` repeated 32 times because the
2292    /// derived `Debug` would have produced either `222` (decimal —
2293    /// stdlib default for `u8` arrays) or `de` (hex) at every position;
2294    /// asserting that neither pattern appears anywhere in the formatted
2295    /// output catches both cases. We also positively assert the
2296    /// "redacted" substring is present so the test fails informatively
2297    /// if someone replaces the manual impl with something else that
2298    /// happens to omit the bytes but also omits the redaction marker.
2299    #[test]
2300    fn disk_cache_config_debug_redacts_hmac_key() {
2301        let cfg = DiskCacheConfig {
2302            dir: PathBuf::from("/tmp/tensor-wasm-jit-debug-test"),
2303            hmac_key: [0xDEu8; 32],
2304        };
2305        let dbg = format!("{cfg:?}");
2306        let lowered = dbg.to_ascii_lowercase();
2307        // Negative: neither decimal nor hex representation of the key
2308        // bytes should appear in the formatted output. Derived `Debug`
2309        // for `[u8; 32]` would have produced `222` thirty-two times
2310        // (decimal) or `de` thirty-two times (hex/upper-hex
2311        // alternatives); a single occurrence of either is suspicious,
2312        // but to keep the assertion robust against incidental
2313        // substrings in `dir` we count occurrences instead.
2314        let decimal_hits = lowered.matches("222").count();
2315        let hex_hits = lowered.matches("de").count();
2316        assert!(
2317            decimal_hits < 32,
2318            "DiskCacheConfig Debug output appears to contain the raw key in \
2319             decimal form ({decimal_hits} occurrences of \"222\"): {dbg}"
2320        );
2321        assert!(
2322            hex_hits < 32,
2323            "DiskCacheConfig Debug output appears to contain the raw key in \
2324             hex form ({hex_hits} occurrences of \"de\"): {dbg}"
2325        );
2326        // Positive: the redaction marker is present.
2327        assert!(
2328            lowered.contains("redacted"),
2329            "DiskCacheConfig Debug output should mark hmac_key as redacted: {dbg}"
2330        );
2331    }
2332}