Skip to main content

javm_cap/
cache.rs

1//! `CacheDirectory<S>` — two-tier cap store.
2//!
3//! - **`blobs: HashMap<CapHash, Arc<C>>`** — content-addressed
4//!   immutable resident caps. Pure cache: the host populates it; the
5//!   kernel reads. If a lookup misses, the host hasn't published the cap yet.
6//!
7//! - **`instances: HashMap<u64, (CapRef, Arc<C>)>`** — identity-keyed
8//!   mutable working state. The stored `CapRef` is the directory's
9//!   self-reference; its `Arc::strong_count` is the number of live
10//!   external holders + 1.
11//!
12//! Two callers exist: the Nub local backend (host's `Global`) and the
13//! Nub Hyperlight backend (guest's `Global` via talc). Both wrap
14//! `CacheDirectory<S>` in their own static / field; the directory's
15//! interior is `spin::Mutex`-protected so every public method takes
16//! `&self`.
17//!
18//! ## Cow + lazy promote
19//!
20//! Promotion (blob → instance) is a cheap `Arc::clone`:
21//!
22//! ```ignore
23//! let arc = blobs[&hash].clone();        // RC bump; no Cap copy.
24//! let id = self.next_ref;
25//! self.next_ref += 1;
26//! let capref = CapRef::new(id);
27//! instances.insert(id, (capref.clone(), arc));   // RC bump on capref.
28//! capref
29//! ```
30//!
31//! Mutation uses `Arc::make_mut`:
32//!
33//! ```ignore
34//! let mut arc = cache.get_instance(&capref).unwrap();
35//! let cap_mut = Arc::make_mut(&mut arc);   // clones iff strong > 1.
36//! // ... mutate cap_mut ...
37//! cache.set_instance(&capref, arc);
38//! ```
39//!
40//! `Arc::make_mut` subsumes the legacy "sole-owner move-promote vs
41//! shared shallow-clone" branch — same decision, in fewer lines.
42//!
43//! ## GC sweep
44//!
45//! `sweep_instances` reclaims entries whose stored `CapRef.strong_count`
46//! is 1 (i.e., the directory is the sole holder). Removal drops the
47//! entry's `Arc<C>`; if that was the last strong ref to the resident cap, the
48//! Cap drops. Caps no longer hold `Ref(CapRef)` slot targets (a cnode
49//! slot is a `Hash` or an inline `Owned` cap), so a drop never cascades
50//! into other instance entries — the sweep is a single self-contained
51//! pass per orphan. The loop is retained as a cheap fixed-point guard.
52//!
53//! The instances tier is currently **dormant**: the recompiler keeps
54//! sub-VMs inline as `Owned` caps and never publishes a `CapRef`. The
55//! tier (and `CapRef`) survive as the host-side key for the future
56//! deferred-persist path.
57//!
58//! ## blob retention
59//!
60//! V0 blobs accumulate; the host pre-publishes every cap the invocation
61//! needs and lookups never miss. Future design: missing-blob lookups
62//! pause the kernel and ask the host to publish. Until that lands,
63//! `get_blob` returning `None` is treated as a hard failure by the
64//! caller.
65
66use alloc::boxed::Box;
67use alloc::sync::Arc;
68use alloc::vec::Vec;
69use core::hash::BuildHasher;
70
71use hashbrown::{DefaultHashBuilder, HashMap};
72use spin::Mutex;
73
74use super::cap::image::ImageConvertError;
75use super::cap::{Cap, CapHash};
76
77/// Cache-local lifetime handle to a working `Cap::Instance` in
78/// `CacheDirectory.instances`.
79///
80/// `Clone` bumps an inner `Arc` refcount; `Drop` decrements it. The
81/// directory owns one `CapRef` per live entry alongside the data; when
82/// external holders all drop their clones, [`CacheDirectory::sweep_instances`]
83/// finds entries whose stored handle has `strong_count == 1` and removes
84/// them. No callback-on-drop, no deadlock discipline.
85///
86/// Two separate `CacheDirectory` instances produce independent id
87/// namespaces — `CapRef`s must not cross caches.
88///
89/// The constructor is module-private: every handle in production traces
90/// back to [`CacheDirectory::put_instance`].
91#[derive(Clone, Debug)]
92pub struct CapRef {
93    id: u64,
94    /// Refcount tracker. The Arc's strong count is the number of
95    /// live `CapRef` holders for this id (including the directory's
96    /// own self-reference).
97    rc: Arc<()>,
98}
99
100impl CapRef {
101    fn new(id: u64) -> Self {
102        Self {
103            id,
104            rc: Arc::new(()),
105        }
106    }
107
108    /// The id this handle resolves to inside `CacheDirectory.instances`.
109    pub fn id(&self) -> u64 {
110        self.id
111    }
112
113    /// Number of live `CapRef` clones for this id, including the
114    /// directory's own self-reference. `sweep_instances` reclaims
115    /// entries whose stored handle has `strong_count == 1`.
116    pub fn strong_count(&self) -> usize {
117        Arc::strong_count(&self.rc)
118    }
119}
120
121impl PartialEq for CapRef {
122    fn eq(&self, other: &Self) -> bool {
123        self.id == other.id
124    }
125}
126impl Eq for CapRef {}
127
128impl core::hash::Hash for CapRef {
129    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
130        self.id.hash(state)
131    }
132}
133
134mod sealed {
135    pub trait Sealed {}
136    impl Sealed for alloc::boxed::Box<crate::cap::Cap> {}
137}
138
139/// Marker for `CapHashOrRef::Owned` payloads that participate in the
140/// content-addressed **wire** form. Implemented only for `Box<Cap>` (the
141/// default payload).
142///
143/// It is a deliberately *leaf* marker — implemented directly for
144/// `Box<Cap>` with no supertrait that recurses into `Cap: Archive` — so
145/// gating the wire impls (`HashTreeRoot` / `Encode` / `Decode` / rkyv
146/// `Archive` / `Serialize`) of `CapHashOrRef<O>` on `O: WireOwned`
147/// resolves by a single lookup, exactly as the original non-generic
148/// impls did (which required nothing of the inline `Box<Cap>`). Gating on
149/// `O: rkyv::Archive` instead would re-introduce the cyclic
150/// `Box<Cap>: Archive → Cap: Archive` bound and overflow the solver.
151///
152/// Engine-private cache payloads (e.g. `Box<CachedCap>`) deliberately do
153/// **not** implement it, so a cnode carrying one has no wire impl and
154/// cannot be hashed or serialised — a compile error, strictly stronger
155/// than the runtime `Owned` panic.
156///
157/// Sealed: only `javm-cap` implements it.
158pub trait WireOwned: sealed::Sealed {}
159impl WireOwned for Box<Cap> {}
160
161/// Slot/field reference: a content-addressed blob in `cache.blobs`
162/// (`Hash`), or a single-owner cap held **inline** by the running kernel
163/// frame (`Owned`).
164///
165/// **`Owned(Box<Cap>)`** is the zero-copy ownership form: a cap the
166/// kernel frame owns outright and moves between cnode slots (and between
167/// frames, at HALT) with no cache round-trip and no data copy — the move
168/// is a `Box` pointer swap. It is **runtime-only**: it never crosses the
169/// wire and is never hashed. The recompiler mints it on `derive_spawn`
170/// and moves it through `host_call`; it never `settle`s one (the
171/// host-side `settle` arm folds it into a blob for the deferred persist
172/// path).
173///
174/// **SSZ note**: `CapHashOrRef`'s `HashTreeRoot` impl is hand-rolled,
175/// not derived. The pass-through semantics — `Hash(h)` hashes to `h` —
176/// let a freshly-published cap substitute for a content reference without
177/// changing the hash of any cap that holds it. The `Owned` arm panics:
178/// callers must `settle` a cap graph before hashing it. `Encode` mirrors
179/// `HashTreeRoot` (panic on the runtime-only arm); `Decode` only ever
180/// produces `Hash` (the wire carries selector 0).
181///
182/// **Generic over the owned payload `O`** (default `Box<Cap>`). The wire
183/// form (the cnode inside a serialised `Cap`) is always
184/// `CapHashOrRef<Box<Cap>>`, so `Cap` and its hash are unaffected. An
185/// engine may instantiate a *running frame's* cnode with a richer,
186/// deliberately non-wire payload (e.g. `Box<CachedCap>`); the
187/// serialisation impls below are gated on `O: rkyv::Archive`, so such a
188/// payload makes the cnode non-hashable and non-serialisable at **compile
189/// time** (a strictly stronger guarantee than the runtime `Owned` panic).
190///
191/// **Not `Copy`**: the `Owned(O)` arm carries the (usually heap-allocated)
192/// payload, so the enum is `Clone`-only (when `O: Clone`).
193///
194/// **`PartialEq`/`Eq`/`Hash` are hand-written**: a `Box<Cap>` payload
195/// blocks the derive (`Cap` is not `Eq`/`Hash`). The `Hash` arm
196/// compares/hashes its 32-byte digest by value; the `Owned` arm uses
197/// pointer identity of the inline payload (sound — `Owned` is single-owner
198/// runtime state, never content-compared — and `O`-agnostic, so it needs no
199/// bound).
200#[derive(Clone, Debug)]
201pub enum CapHashOrRef<O = Box<Cap>> {
202    Hash(CapHash),
203    Owned(O),
204}
205
206impl<O> PartialEq for CapHashOrRef<O> {
207    fn eq(&self, other: &Self) -> bool {
208        match (self, other) {
209            (CapHashOrRef::Hash(a), CapHashOrRef::Hash(b)) => a == b,
210            // Owned is single-owner and never content-compared; pointer
211            // identity of the inline payload is reflexive (Eq-sound) and
212            // consistent with the pointer-keyed `Hash` impl below.
213            (CapHashOrRef::Owned(a), CapHashOrRef::Owned(b)) => core::ptr::eq(a, b),
214            _ => false,
215        }
216    }
217}
218impl<O> Eq for CapHashOrRef<O> {}
219
220impl<O> core::hash::Hash for CapHashOrRef<O> {
221    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
222        match self {
223            CapHashOrRef::Hash(h) => {
224                0u8.hash(state);
225                h.hash(state);
226            }
227            CapHashOrRef::Owned(o) => {
228                1u8.hash(state);
229                (o as *const O).hash(state);
230            }
231        }
232    }
233}
234
235// Gated on `O: WireOwned` — the wire-payload marker. `Box<Cap>` (the
236// content-addressed default) implements it; a non-wire payload such as
237// `Box<CachedCap>` does not, so a cache-carrying cnode has no `HashTreeRoot`
238// impl and cannot be content-hashed (a compile error, not a runtime panic).
239impl<O: WireOwned> ssz::HashTreeRoot for CapHashOrRef<O> {
240    fn hash_tree_root<D: ::ssz::digest::Digest<OutputSize = ::ssz::digest::typenum::U32>>(
241        &self,
242    ) -> [u8; 32] {
243        match self {
244            CapHashOrRef::Hash(h) => *h,
245            CapHashOrRef::Owned(_) => {
246                panic!("cap_hash: in-flight Owned cap in cap graph; settle first")
247            }
248        }
249    }
250}
251
252impl<O: WireOwned> ssz::Encode for CapHashOrRef<O> {
253    fn is_ssz_fixed_len() -> bool {
254        false
255    }
256    fn ssz_fixed_len() -> usize {
257        ssz::BYTES_PER_LENGTH_OFFSET
258    }
259    fn ssz_bytes_len(&self) -> usize {
260        match self {
261            CapHashOrRef::Hash(_) => 1 + 32,
262            // Owned must be settled before serialisation; matches the
263            // `HashTreeRoot` contract above. Reached only by buggy code.
264            CapHashOrRef::Owned(_) => {
265                panic!("ssz_bytes_len: in-flight Owned cap in cap graph; settle first")
266            }
267        }
268    }
269    fn ssz_append(&self, buf: &mut Vec<u8>) {
270        match self {
271            CapHashOrRef::Hash(h) => {
272                buf.push(0);
273                buf.extend_from_slice(h);
274            }
275            CapHashOrRef::Owned(_) => {
276                panic!("ssz_append: in-flight Owned cap in cap graph; settle first")
277            }
278        }
279    }
280}
281
282impl<O: WireOwned> ssz::Decode for CapHashOrRef<O> {
283    fn is_ssz_fixed_len() -> bool {
284        false
285    }
286    fn ssz_fixed_len() -> usize {
287        ssz::BYTES_PER_LENGTH_OFFSET
288    }
289    fn from_ssz_bytes(bytes: &[u8]) -> Result<Self, ssz::DecodeError> {
290        if bytes.is_empty() {
291            return Err(ssz::DecodeError::UnexpectedEof {
292                expected: 1,
293                actual: 0,
294            });
295        }
296        match bytes[0] {
297            0 => {
298                if bytes.len() != 1 + 32 {
299                    return Err(ssz::DecodeError::UnexpectedEof {
300                        expected: 1 + 32,
301                        actual: bytes.len(),
302                    });
303                }
304                let mut h = [0u8; 32];
305                h.copy_from_slice(&bytes[1..1 + 32]);
306                Ok(CapHashOrRef::Hash(h))
307            }
308            // Selector 0 (`Hash`) is the only wire form; `Owned` is
309            // runtime-only and never serialises, so any other selector is
310            // invalid wire bytes.
311            v => Err(ssz::DecodeError::InvalidSelector(v)),
312        }
313    }
314}
315
316// --- rkyv: hand-rolled. ---
317//
318// `CapHashOrRef`'s archived form is the same as `CapHash`'s — a plain
319// 32-byte digest. Serialize errors out on `Ref` (cache-local lifetime
320// handles have no wire form); resolve panics defensively for the path
321// where someone hand-built a Resolver and called resolve without going
322// through Serialize. Deserialize always produces `Hash` because the
323// archived form structurally can't carry a `Ref`.
324
325/// Error returned by `<CapHashOrRef as rkyv::Serialize<_>>::serialize`
326/// when the cap graph still holds a runtime-only target — a
327/// [`CapHashOrRef::Owned`]. Callers must `settle` (or otherwise rewrite
328/// the target to a hash) before rkyv-encoding the cap.
329#[derive(Debug)]
330pub struct CapHasRefError;
331
332impl core::fmt::Display for CapHasRefError {
333    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
334        f.write_str(
335            "cap holds a runtime-only CapHashOrRef::Owned target; settle before rkyv encode",
336        )
337    }
338}
339
340impl core::error::Error for CapHasRefError {}
341
342impl<O: WireOwned> rkyv::Archive for CapHashOrRef<O> {
343    type Archived = <CapHash as rkyv::Archive>::Archived;
344    type Resolver = <CapHash as rkyv::Archive>::Resolver;
345
346    fn resolve(&self, resolver: Self::Resolver, out: rkyv::Place<Self::Archived>) {
347        match self {
348            CapHashOrRef::Hash(h) => <CapHash as rkyv::Archive>::resolve(h, resolver, out),
349            // Unreachable if Serialize was called first (it errors on the
350            // runtime-only arm). Defensive panic for the "hand-built
351            // resolver" path.
352            CapHashOrRef::Owned(_) => {
353                panic!(
354                    "CapHashOrRef::Owned in archive resolve; Serialize should have rejected first"
355                )
356            }
357        }
358    }
359}
360
361impl<O, S> rkyv::Serialize<S> for CapHashOrRef<O>
362where
363    O: WireOwned,
364    S: rkyv::rancor::Fallible + ?Sized,
365    <S as rkyv::rancor::Fallible>::Error: rkyv::rancor::Source,
366    CapHash: rkyv::Serialize<S>,
367{
368    fn serialize(
369        &self,
370        serializer: &mut S,
371    ) -> Result<Self::Resolver, <S as rkyv::rancor::Fallible>::Error> {
372        match self {
373            CapHashOrRef::Hash(h) => <CapHash as rkyv::Serialize<S>>::serialize(h, serializer),
374            CapHashOrRef::Owned(_) => Err(rkyv::rancor::Source::new(CapHasRefError)),
375        }
376    }
377}
378
379// Use the concrete archived type `[u8; 32]` rather than
380// `<CapHash as Archive>::Archived` to avoid a coherence-checker false
381// conflict with rkyv's blanket `Deserialize for With<F, W>` impl
382// (associated-type opacity).
383impl<O, D> rkyv::Deserialize<CapHashOrRef<O>, D> for [u8; 32]
384where
385    D: rkyv::rancor::Fallible + ?Sized,
386{
387    fn deserialize(
388        &self,
389        _deserializer: &mut D,
390    ) -> Result<CapHashOrRef<O>, <D as rkyv::rancor::Fallible>::Error> {
391        Ok(CapHashOrRef::Hash(*self))
392    }
393}
394
395#[derive(Debug, thiserror::Error)]
396pub enum CacheError {
397    #[error("blob not found for hash")]
398    BlobMissing,
399    #[error("instance not found for ref {0}")]
400    InstanceMissing(u64),
401    #[error("image conversion failed: {0}")]
402    ImageConvertFailed(#[from] ImageConvertError),
403    #[error("paged data: page length mismatch (expected={expected}, got={got})")]
404    PageSizeMismatch { expected: u32, got: usize },
405    #[error("cnode slot index out of range")]
406    SlotOutOfRange,
407}
408
409/// Payload stored by a [`CacheDirectory`]. The public/wire directory stores
410/// plain [`Cap`]; engines may store a resident wrapper that carries derived
411/// runtime caches while still exposing the underlying wire cap for hashing and
412/// inspection.
413pub trait ResidentCap {
414    /// Wrap a public wire cap for resident storage.
415    fn from_cap(cap: Cap) -> Self;
416    /// Borrow the public wire cap.
417    fn as_cap(&self) -> &Cap;
418    /// Consume the resident payload back into its wire cap.
419    fn into_cap(self) -> Cap;
420}
421
422impl ResidentCap for Cap {
423    fn from_cap(cap: Cap) -> Self {
424        cap
425    }
426
427    fn as_cap(&self) -> &Cap {
428        self
429    }
430
431    fn into_cap(self) -> Cap {
432        self
433    }
434}
435
436pub struct CacheDirectory<S = DefaultHashBuilder, C = Cap> {
437    inner: Mutex<DirectoryInner<S, C>>,
438}
439
440struct DirectoryInner<S, C> {
441    blobs: HashMap<CapHash, Arc<C>, S>,
442    instances: HashMap<u64, (CapRef, Arc<C>), S>,
443    next_ref: u64,
444}
445
446impl CacheDirectory<DefaultHashBuilder, Cap> {
447    /// Construct an empty cache using the default per-process-randomized
448    /// hasher.
449    pub fn new() -> Self {
450        Self::with_hasher(DefaultHashBuilder::default())
451    }
452}
453
454impl Default for CacheDirectory<DefaultHashBuilder, Cap> {
455    fn default() -> Self {
456        Self::new()
457    }
458}
459
460impl<S: BuildHasher, C> CacheDirectory<S, C> {
461    /// Construct an empty cache with an explicit hasher.
462    pub fn with_hasher(hasher: S) -> Self
463    where
464        S: Clone,
465    {
466        Self {
467            inner: Mutex::new(DirectoryInner {
468                blobs: HashMap::with_hasher(hasher.clone()),
469                instances: HashMap::with_hasher(hasher),
470                // CapRef id 0 is reserved; ids start at 1.
471                next_ref: 1,
472            }),
473        }
474    }
475}
476
477impl<S, C> CacheDirectory<S, C> {
478    /// `const fn` constructor for static initialisation. Used by the
479    /// guest's `state_cache::CACHE` static. Takes both hashers
480    /// separately because `const fn` can't call `S::clone()` and not
481    /// every `BuildHasher` (notably `foldhash::fast::FixedState`)
482    /// implements `Copy`. Callers normally pass two identically-seeded
483    /// instances so both maps hash to the same buckets.
484    pub const fn new_const(blobs_hasher: S, instances_hasher: S) -> Self {
485        Self {
486            inner: Mutex::new(DirectoryInner {
487                blobs: HashMap::with_hasher(blobs_hasher),
488                instances: HashMap::with_hasher(instances_hasher),
489                next_ref: 1,
490            }),
491        }
492    }
493}
494
495impl<S: BuildHasher, C> CacheDirectory<S, C> {
496    /// Number of blob entries.
497    pub fn blob_count(&self) -> usize {
498        self.inner.lock().blobs.len()
499    }
500    /// Number of instance entries (live or unswept).
501    pub fn instance_count(&self) -> usize {
502        self.inner.lock().instances.len()
503    }
504
505    /// Whether the blobs tier holds an entry under `hash`.
506    pub fn contains_blob(&self, hash: &CapHash) -> bool {
507        self.inner.lock().blobs.contains_key(hash)
508    }
509
510    /// Get an `Arc::clone` of the blob cap at `hash`, or `None` if
511    /// absent.
512    pub fn get_blob(&self, hash: &CapHash) -> Option<Arc<C>> {
513        self.inner.lock().blobs.get(hash).cloned()
514    }
515
516    /// Get an `Arc::clone` of the instance cap at `capref`, or `None`
517    /// if absent.
518    pub fn get_instance(&self, capref: &CapRef) -> Option<Arc<C>> {
519        self.inner
520            .lock()
521            .instances
522            .get(&capref.id())
523            .map(|(_, arc)| arc.clone())
524    }
525
526    /// Snapshot the blob tier into a `Vec<(CapHash, Arc<C>)>`. Order
527    /// is unspecified (HashMap iteration); callers that need
528    /// deterministic order (state-root computations) sort by hash.
529    pub fn iter_blobs(&self) -> Vec<(CapHash, Arc<C>)> {
530        self.inner
531            .lock()
532            .blobs
533            .iter()
534            .map(|(h, arc)| (*h, arc.clone()))
535            .collect()
536    }
537
538    /// Polymorphic lookup that dispatches on the `CapHashOrRef` arm.
539    /// Returns an `Arc::clone` of the matching cap.
540    pub fn get(&self, key: CapHashOrRef) -> Option<Arc<C>> {
541        match key {
542            CapHashOrRef::Hash(h) => self.get_blob(&h),
543            // `Owned` lives inline on the kernel frame, not in the
544            // directory; the holder dereferences the `Box` directly.
545            CapHashOrRef::Owned(_) => None,
546        }
547    }
548
549    /// Replace the instance at `capref` with a fresh `Arc<C>`. The
550    /// old `Arc<C>` drops outside the lock (so any cascading
551    /// `Cap::drop → CapRef::drop` chain doesn't try to re-enter the
552    /// directory while we hold the guard).
553    pub fn set_instance(&self, capref: &CapRef, new_arc: Arc<C>) -> Result<(), CacheError> {
554        let _old = {
555            let mut g = self.inner.lock();
556            let entry = g
557                .instances
558                .get_mut(&capref.id())
559                .ok_or_else(|| CacheError::InstanceMissing(capref.id()))?;
560            // Swap in the new Arc, keeping the existing self-ref CapRef.
561            // The old Arc<C> is returned and dropped after the lock guard.
562            core::mem::replace(&mut entry.1, new_arc)
563        };
564        Ok(())
565    }
566
567    /// Hash + insert into blobs. Idempotent: re-puts of identical
568    /// content are a no-op. Returns the content hash.
569    pub fn put_cap(&self, cap: &Cap) -> Result<CapHash, CacheError>
570    where
571        C: ResidentCap,
572    {
573        let hash = cap.cap_hash();
574        self.put_cap_with_hash(hash, cap)?;
575        Ok(hash)
576    }
577
578    /// Pre-hashed insert. Debug-asserts the claimed hash matches the
579    /// cap; release trusts the caller (the SSZ merkleize is the hot
580    /// cost on the publish path).
581    pub fn put_cap_with_hash(&self, hash: CapHash, cap: &Cap) -> Result<(), CacheError>
582    where
583        C: ResidentCap,
584    {
585        debug_assert_eq!(
586            cap.cap_hash(),
587            hash,
588            "put_cap_with_hash: claimed hash does not match cap content",
589        );
590        let mut g = self.inner.lock();
591        g.blobs
592            .entry(hash)
593            .or_insert_with(|| Arc::new(C::from_cap(cap.clone())));
594        Ok(())
595    }
596
597    /// Insert a freshly-built `Cap` as a new instance entry. Returns
598    /// the `CapRef` handle. The directory keeps its own clone of the
599    /// returned handle internally as the entry's self-reference; when
600    /// all external clones drop, `sweep_instances` will reclaim the
601    /// entry.
602    pub fn put_instance(&self, cap: Cap) -> CapRef
603    where
604        C: ResidentCap,
605    {
606        self.put_instance_arc(Arc::new(C::from_cap(cap)))
607    }
608
609    /// `put_instance` variant that takes a pre-built `Arc<C>`. Used
610    /// internally by [`Self::promote_blob_to_instance`] to share the
611    /// blob's Arc rather than deep-copying.
612    fn put_instance_arc(&self, arc: Arc<C>) -> CapRef {
613        let mut g = self.inner.lock();
614        let id = g.next_ref;
615        g.next_ref = g.next_ref.checked_add(1).expect("CapRef space exhausted");
616        let capref = CapRef::new(id);
617        g.instances.insert(id, (capref.clone(), arc));
618        capref
619    }
620
621    /// Lazily promote a blob to a fresh instance entry. The blob and
622    /// the new instance entry share the same `Arc<C>` (no resident cap
623    /// data deep-copy); the next `Arc::make_mut` call on either side
624    /// clones-on-write if both still hold the Arc.
625    ///
626    /// Returns `None` if the blob isn't published.
627    pub fn promote_blob_to_instance(&self, hash: &CapHash) -> Option<CapRef> {
628        let arc = self.get_blob(hash)?;
629        Some(self.put_instance_arc(arc))
630    }
631
632    /// **GC pass.** Walk the instances tier and remove every entry
633    /// whose stored `CapRef` has `strong_count == 1` (the directory is
634    /// the sole holder — no external `CapRef` clone exists). Loop
635    /// until a pass finds nothing to remove, so cascading drops can
636    /// orphan more entries which then get reclaimed in the next
637    /// iteration.
638    ///
639    /// Cycles are structurally impossible (data-flow principle), so
640    /// the loop is guaranteed to terminate.
641    pub fn sweep_instances(&self) {
642        loop {
643            let dead: Vec<u64> = {
644                let g = self.inner.lock();
645                g.instances
646                    .iter()
647                    .filter(|(_, (sr, _))| sr.strong_count() == 1)
648                    .map(|(k, _)| *k)
649                    .collect()
650            };
651            if dead.is_empty() {
652                break;
653            }
654            for id in dead {
655                let _removed = {
656                    let mut g = self.inner.lock();
657                    g.instances.remove(&id)
658                };
659                // _removed drops here, outside the lock. If its
660                // Arc<C> was the last strong ref, Cap::drop runs and
661                // cascades: nested CapRef::drop calls decrement other
662                // entries' refcounts. Those entries get reclaimed on
663                // the next pass.
664            }
665        }
666    }
667
668    /// Settle an inline `Owned` cap to a content hash: recursively rewrite
669    /// its nested `Owned` slot targets to `Hash`, flush a dirty `Data` cap's
670    /// CoW overlay, content-address it into `blobs`, and return the hash.
671    ///
672    /// For `Hash`-keyed input, returns it unchanged.
673    pub fn settle(&self, key: CapHashOrRef) -> Result<CapHash, CacheError>
674    where
675        C: ResidentCap,
676    {
677        match key {
678            CapHashOrRef::Hash(h) => Ok(h),
679            CapHashOrRef::Owned(b) => self.settle_owned(*b),
680        }
681    }
682
683    /// Settle an inline `Owned` cap: recursively rewrite its nested slot
684    /// targets (`Owned`) to `Hash`, flush a `Data` cap's CoW overlay so it
685    /// is hashable, then content-address it into `blobs` and return the
686    /// hash.
687    ///
688    /// The recompiler never calls this — it *moves* `Owned` caps and
689    /// drops them at frame pop. It exists for the host-side deferred
690    /// persist path (turn a finished frame's owned cap into a blob).
691    fn settle_owned(&self, mut cap: Cap) -> Result<CapHash, CacheError>
692    where
693        C: ResidentCap,
694    {
695        // 1. Settle every nested slot target to a Hash, in place.
696        self.settle_targets_in(&mut cap)?;
697        // 2. Hashing requires an empty overlay; fold a dirty Data cap.
698        if let Cap::Data(d) = &cap
699            && !d.overlay.is_empty()
700        {
701            cap = Cap::Data(d.flush());
702        }
703        // 3. Content-address into blobs.
704        let hash = cap.cap_hash();
705        self.put_cap_with_hash(hash, &cap)?;
706        Ok(hash)
707    }
708
709    /// Rewrite every direct slot target of `cap` (CNode slots, Instance
710    /// `root_cnode`) from a runtime-only `Owned` to its settled `Hash`,
711    /// recursing into nested `Owned` caps.
712    fn settle_targets_in(&self, cap: &mut Cap) -> Result<(), CacheError>
713    where
714        C: ResidentCap,
715    {
716        match cap {
717            Cap::CNode(cn) => {
718                for (_, mo) in cn.slots.iter_mut() {
719                    if let ssz::MissingOr::Materialized(t) = mo {
720                        self.settle_target(t)?;
721                    }
722                }
723            }
724            Cap::Instance(inst) => self.settle_target(&mut inst.root_cnode)?,
725            Cap::Data(_) | Cap::Image(_) => {}
726        }
727        Ok(())
728    }
729
730    /// Settle one slot target in place: `Hash` unchanged, `Owned`
731    /// recursively via [`Self::settle_owned`].
732    fn settle_target(&self, t: &mut CapHashOrRef) -> Result<(), CacheError>
733    where
734        C: ResidentCap,
735    {
736        match t {
737            CapHashOrRef::Hash(_) => Ok(()),
738            CapHashOrRef::Owned(_) => {
739                let CapHashOrRef::Owned(b) = core::mem::replace(t, CapHashOrRef::Hash([0u8; 32]))
740                else {
741                    unreachable!("matched Owned above")
742                };
743                let h = self.settle_owned(*b)?;
744                *t = CapHashOrRef::Hash(h);
745                Ok(())
746            }
747        }
748    }
749}