Skip to main content

nub/
lib.rs

1//! Nub: the JAR v3 microkernel — uniform caller-facing handle.
2//!
3//! The [`Nub`] handle is the API callers (chain runtime, tests, RPC,
4//! `jar-apply`) link against. It hides the choice of substrate behind
5//! a single invoke surface, dispatching to one of two backends:
6//!
7//! - **Local**: runs the PVM2 (RISC-V) interpreter directly in-process via
8//!   `nub_arch_local::run_instance`. Used for tests, deterministic
9//!   replay, and any host that doesn't need real ring-0 isolation.
10//! - **Hyperlight**: ships the invocation as an RPC into a
11//!   `nub-arch-x86` guest binary running inside a Hyperlight
12//!   sandbox. The actual `Kernel<HyperlightArch>` lives guest-side;
13//!   the host holds only the sandbox + a state cache.
14//!
15//! Both backends share the same typed publish/invoke surface — the
16//! caller publishes a `Cap::Image` (and optionally a `Cap::CNode`),
17//! publishes a `Cap::Instance` referencing them, and then invokes by
18//! the resulting instance hash.
19
20#[cfg(feature = "test-support")]
21pub mod test_support;
22
23use std::collections::VecDeque;
24use std::num::NonZeroUsize;
25use std::panic::AssertUnwindSafe;
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{Arc, Condvar, Mutex};
28use std::thread::{self, JoinHandle};
29
30use anyhow::Result;
31use javm_cap::{CacheDirectory, CapHashOrRef, cap::Cap};
32use nub_arch_local::LocalArch;
33use nub_host_kvm::sandbox::{
34    GuestBinary, MultiUseSandbox, SandboxConfiguration, UninitializedSandbox,
35};
36use nub_kernel::Kernel;
37
38#[cfg(feature = "heap-diag")]
39use nub_arch_x86_abi::FN_ID_NUB_HEAP_STATS;
40use nub_arch_x86_abi::InvokePacket;
41pub use nub_arch_x86_abi::{CapHash as AbiCapHash, InvocationResult, SCRATCHPAD_HEAD_LEN};
42pub use nub_kernel::{CapHash, InstanceRef, InvokeOptions, InvokeOutcome};
43
44pub const MAX_HYPERLIGHT_VCPUS: usize = nub_arch_x86_abi::MAX_EXECUTION_LANES;
45
46/// Snapshot of the guest's talc allocation state. Returned by
47/// [`Nub::heap_stats`].
48#[cfg(feature = "heap-diag")]
49#[derive(Debug, Clone, Copy)]
50pub struct HeapStats {
51    /// Live allocation count (incremented on alloc, decremented on
52    /// free) — a non-zero per-invoke drift here is a leak.
53    pub allocation_count: u64,
54    /// Cumulative allocations ever performed (monotonic, never
55    /// decremented) — its per-invoke delta is the allocation *churn*,
56    /// the right yardstick for "this CALL allocated nothing but a
57    /// `KernelFrame`" even when the transient allocations are freed
58    /// again before the next snapshot.
59    pub total_allocation_count: u64,
60    pub allocated_bytes: u64,
61    pub fragment_count: u64,
62    pub available_bytes: u64,
63}
64
65/// Path to the cross-compiled Hyperlight guest blob. Set by
66/// `build.rs` via [`nub_build::build`].
67const NUB_ARCH_X86_BLOB_PATH: &str = env!("NUB_ARCH_X86_BLOB");
68
69#[derive(Clone, Copy)]
70pub(crate) struct HyperlightBlob {
71    pub(crate) label: &'static str,
72    pub(crate) path: &'static str,
73}
74
75struct HyperlightSingleton {
76    blob: HyperlightBlob,
77    options: NubOptions,
78    nub: Nub,
79}
80
81static HYPERLIGHT_NUB: Mutex<Option<HyperlightSingleton>> = Mutex::new(None);
82
83/// Compatibility alias for older tests/benches that named the returned
84/// Hyperlight singleton borrow. `Nub` is now a cloneable handle; synchronization
85/// lives inside the handle instead of in an outer mutex guard.
86pub type HyperlightNubGuard = Nub;
87
88/// Uniform handle to the nub microkernel.
89#[derive(Clone)]
90pub struct Nub {
91    inner: Arc<NubInner>,
92}
93
94struct NubInner {
95    backend: Mutex<Backend>,
96    next_job_id: AtomicU64,
97    invoke_executor: Arc<InvokeExecutor>,
98    invoke_worker_count: usize,
99}
100
101/// Options used when constructing the process-wide Hyperlight Nub singleton.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub struct NubOptions {
104    /// Fixed vCPU pool size for the backing sandbox. Multi-vCPU Hyperlight
105    /// sandboxes keep one hot worker per lane and route top-level invokes
106    /// through those workers.
107    pub vcpu_count: usize,
108}
109
110impl NubOptions {
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    pub fn with_vcpu_count(mut self, vcpu_count: usize) -> Self {
116        self.vcpu_count = vcpu_count.max(1);
117        self
118    }
119
120    fn validate(&self) -> Result<()> {
121        if self.vcpu_count == 0 {
122            return Err(anyhow::anyhow!("NubOptions.vcpu_count must be at least 1"));
123        }
124        if self.vcpu_count > MAX_HYPERLIGHT_VCPUS {
125            return Err(anyhow::anyhow!(
126                "NubOptions.vcpu_count={} exceeds guest lane capacity {}",
127                self.vcpu_count,
128                MAX_HYPERLIGHT_VCPUS
129            ));
130        }
131        Ok(())
132    }
133}
134
135impl Default for NubOptions {
136    fn default() -> Self {
137        let default = thread::available_parallelism()
138            .map(NonZeroUsize::get)
139            .unwrap_or(1)
140            .clamp(1, 8);
141        let vcpu_count = std::env::var("JAR_NUB_VCPUS")
142            .ok()
143            .and_then(|s| s.parse::<usize>().ok())
144            .filter(|&n| n > 0)
145            .unwrap_or(default);
146        Self { vcpu_count }
147    }
148}
149
150#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub struct InvokeRequest {
152    pub instance_hash: AbiCapHash,
153    pub endpoint_idx: u8,
154    pub args: [u64; 4],
155    pub initial_gas: u64,
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
159pub struct InvokeJobId(pub u64);
160
161pub struct InvokeJob {
162    id: InvokeJobId,
163    state: Arc<InvokeJobState>,
164}
165
166struct InvokeJobState {
167    result: Mutex<Option<Result<InvocationResult, String>>>,
168    ready: Condvar,
169}
170
171struct QueuedInvoke {
172    nub: Nub,
173    id: InvokeJobId,
174    request: InvokeRequest,
175    state: Arc<InvokeJobState>,
176}
177
178struct InvokeExecutor {
179    state: Mutex<InvokeExecutorState>,
180    ready: Condvar,
181    handles: Mutex<Vec<JoinHandle<()>>>,
182}
183
184struct InvokeExecutorState {
185    queue: VecDeque<QueuedInvoke>,
186    stopping: bool,
187}
188
189impl InvokeJob {
190    pub fn id(&self) -> InvokeJobId {
191        self.id
192    }
193
194    pub fn try_wait(&self) -> Option<Result<InvocationResult>> {
195        let guard = self
196            .state
197            .result
198            .lock()
199            .expect("InvokeJob result mutex poisoned");
200        guard.as_ref().map(|r| match r {
201            Ok(v) => Ok(*v),
202            Err(e) => Err(anyhow::anyhow!(e.clone())),
203        })
204    }
205
206    pub fn wait(self) -> Result<InvocationResult> {
207        let mut guard = self
208            .state
209            .result
210            .lock()
211            .expect("InvokeJob result mutex poisoned");
212        while guard.is_none() {
213            guard = self
214                .state
215                .ready
216                .wait(guard)
217                .expect("InvokeJob result mutex poisoned");
218        }
219        match guard.take().expect("checked is_some") {
220            Ok(v) => Ok(v),
221            Err(e) => Err(anyhow::anyhow!(e)),
222        }
223    }
224}
225
226impl InvokeJobState {
227    fn new() -> Self {
228        Self {
229            result: Mutex::new(None),
230            ready: Condvar::new(),
231        }
232    }
233
234    fn complete(&self, result: Result<InvocationResult, String>) {
235        let mut guard = self.result.lock().expect("InvokeJob result mutex poisoned");
236        *guard = Some(result);
237        self.ready.notify_all();
238    }
239}
240
241impl InvokeExecutor {
242    fn new() -> Self {
243        Self {
244            state: Mutex::new(InvokeExecutorState {
245                queue: VecDeque::new(),
246                stopping: false,
247            }),
248            ready: Condvar::new(),
249            handles: Mutex::new(Vec::new()),
250        }
251    }
252
253    fn ensure_started(self: &Arc<Self>, worker_count: usize) -> Result<()> {
254        let mut handles = self
255            .handles
256            .lock()
257            .expect("InvokeExecutor handles mutex poisoned");
258        if !handles.is_empty() {
259            return Ok(());
260        }
261
262        for worker in 0..worker_count.max(1) {
263            let executor = self.clone();
264            let handle = thread::Builder::new()
265                .name(format!("nub-invoke-worker-{worker}"))
266                .spawn(move || executor.worker_loop())
267                .map_err(|e| anyhow::anyhow!("submit_invoke: spawn worker: {e}"))?;
268            handles.push(handle);
269        }
270        Ok(())
271    }
272
273    fn enqueue(&self, job: QueuedInvoke) -> Result<()> {
274        let mut state = self
275            .state
276            .lock()
277            .expect("InvokeExecutor state mutex poisoned");
278        if state.stopping {
279            return Err(anyhow::anyhow!(
280                "submit_invoke: Nub invoke executor is stopping"
281            ));
282        }
283        state.queue.push_back(job);
284        self.ready.notify_one();
285        Ok(())
286    }
287
288    fn worker_loop(self: Arc<Self>) {
289        while let Some(job) = self.next_job() {
290            let id = job.id.0;
291            let nub = job.nub;
292            let request = job.request;
293            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
294                nub.invoke_request_blocking(request, id)
295            }))
296            .map_err(|_| "invoke worker panicked".to_string())
297            .and_then(|r| r.map_err(|e| format!("{e:#}")));
298            job.state.complete(result);
299        }
300    }
301
302    fn next_job(&self) -> Option<QueuedInvoke> {
303        let mut state = self
304            .state
305            .lock()
306            .expect("InvokeExecutor state mutex poisoned");
307        loop {
308            if let Some(job) = state.queue.pop_front() {
309                return Some(job);
310            }
311            if state.stopping {
312                return None;
313            }
314            state = self
315                .ready
316                .wait(state)
317                .expect("InvokeExecutor state mutex poisoned");
318        }
319    }
320
321    fn stop_and_join(&self) {
322        {
323            let mut state = self
324                .state
325                .lock()
326                .expect("InvokeExecutor state mutex poisoned");
327            state.stopping = true;
328            self.ready.notify_all();
329        }
330
331        let current = thread::current().id();
332        let handles = {
333            let mut handles = self
334                .handles
335                .lock()
336                .expect("InvokeExecutor handles mutex poisoned");
337            core::mem::take(&mut *handles)
338        };
339        for handle in handles {
340            if handle.thread().id() == current {
341                continue;
342            }
343            let _ = handle.join();
344        }
345    }
346}
347
348impl Drop for NubInner {
349    fn drop(&mut self) {
350        self.invoke_executor.stop_and_join();
351    }
352}
353
354enum Backend {
355    /// In-process backend: the PVM2 (RISC-V) interpreter plus its own
356    /// cap directory. `cache` is the source of truth for caps published
357    /// via `Nub::put_cap*` and resolved by `Nub::invoke_cached`.
358    Local {
359        kernel: Kernel<LocalArch>,
360        cache: CacheDirectory,
361    },
362    /// Hyperlight backend: the cap directory lives guest-side as a
363    /// `static CacheDirectory<FixedState>` in `nub-arch-x86`; the host
364    /// writes via the `FN_ID_NUB_PUT_CAP` RPC and tracks published blob
365    /// hashes host-side to short-circuit idempotent re-puts (it does
366    /// *not* dereference the guest's hashbrown — see
367    /// `MultiUseSandbox::published_blobs` for why that is unsound).
368    Hyperlight(Arc<HyperlightDriver>),
369}
370
371enum InvokeBackendTarget {
372    Local {
373        inst: Box<javm_cap::InstanceCap>,
374        img: Box<javm_cap::ImageCap>,
375    },
376    Hyperlight(Arc<HyperlightDriver>),
377}
378
379/// Host-side RPC stub for the Hyperlight backend. The real kernel
380/// lives guest-side; this wrapper just ships invocations into the
381/// sandbox.
382struct HyperlightDriver {
383    sandbox: MultiUseSandbox,
384    state_root_cache: CapHash,
385}
386
387impl Nub {
388    /// Construct a Nub backed by the in-process [`LocalArch`].
389    pub fn new_local() -> Self {
390        let invoke_worker_count = thread::available_parallelism()
391            .map(NonZeroUsize::get)
392            .unwrap_or(2)
393            .clamp(2, 8);
394        Self {
395            inner: Arc::new(NubInner {
396                backend: Mutex::new(Backend::Local {
397                    kernel: Kernel::new(LocalArch::new()),
398                    cache: CacheDirectory::new(),
399                }),
400                next_job_id: AtomicU64::new(1),
401                invoke_executor: Arc::new(InvokeExecutor::new()),
402                invoke_worker_count,
403            }),
404        }
405    }
406
407    /// Borrow the process-wide Hyperlight-backed Nub loaded from the
408    /// `nub-arch-x86` guest blob.
409    pub fn hyperlight() -> Result<HyperlightNubGuard> {
410        Self::hyperlight_with_options(NubOptions::default())
411    }
412
413    pub fn hyperlight_with_options(options: NubOptions) -> Result<HyperlightNubGuard> {
414        Self::hyperlight_with_blob(
415            HyperlightBlob {
416                label: "production",
417                path: NUB_ARCH_X86_BLOB_PATH,
418            },
419            options,
420        )
421    }
422
423    pub(crate) fn hyperlight_with_blob(blob: HyperlightBlob, options: NubOptions) -> Result<Nub> {
424        options.validate()?;
425        let mut guard = HYPERLIGHT_NUB
426            .lock()
427            .map_err(|_| anyhow::anyhow!("Hyperlight Nub singleton mutex poisoned"))?;
428        match guard.as_ref() {
429            Some(existing) if existing.blob.path == blob.path && existing.options == options => {}
430            Some(existing) if existing.blob.path == blob.path => {
431                return Err(anyhow::anyhow!(
432                    "Hyperlight Nub singleton already initialized with {} vCPU(s); \
433                     cannot reconfigure it to {} vCPU(s)",
434                    existing.options.vcpu_count,
435                    options.vcpu_count,
436                ));
437            }
438            Some(existing) => {
439                return Err(anyhow::anyhow!(
440                    "Hyperlight Nub singleton already initialized with {} guest ({:?}); \
441                     cannot switch to {} guest ({:?})",
442                    existing.blob.label,
443                    existing.blob.path,
444                    blob.label,
445                    blob.path,
446                ));
447            }
448            None => {
449                let nub = Self::create_hyperlight_with_blob_path(blob.path, options)?;
450                *guard = Some(HyperlightSingleton { blob, options, nub });
451            }
452        }
453        Ok(guard
454            .as_ref()
455            .expect("Hyperlight Nub singleton initialized")
456            .nub
457            .clone())
458    }
459
460    /// Create the backing sandbox for the process-wide Hyperlight singleton
461    /// from an arbitrary guest ELF on disk.
462    ///
463    /// This is intentionally private: high-level callers must go through the
464    /// process singleton returned by [`Self::hyperlight`].
465    fn create_hyperlight_with_blob_path(path: &str, options: NubOptions) -> Result<Self> {
466        let mut cfg = SandboxConfiguration::default();
467        cfg.set_vcpu_count(options.vcpu_count);
468        cfg.set_scratch_size(512 * 1024 * 1024);
469        cfg.set_input_data_size(16 * 1024 * 1024);
470        cfg.set_output_data_size(16 * 1024 * 1024);
471        cfg.set_heap_size(256 * 1024 * 1024);
472        let uninit = UninitializedSandbox::new(GuestBinary::FilePath(path.to_string()), Some(cfg))?;
473        let sandbox = uninit.evolve()?;
474        Ok(Self {
475            inner: Arc::new(NubInner {
476                backend: Mutex::new(Backend::Hyperlight(Arc::new(HyperlightDriver {
477                    sandbox,
478                    state_root_cache: [0; 32],
479                }))),
480                next_job_id: AtomicU64::new(1),
481                invoke_executor: Arc::new(InvokeExecutor::new()),
482                invoke_worker_count: options.vcpu_count.max(1),
483            }),
484        })
485    }
486
487    /// Current state root.
488    pub fn state_root(&self) -> CapHash {
489        let backend = self
490            .inner
491            .backend
492            .lock()
493            .expect("Nub backend mutex poisoned");
494        match &*backend {
495            Backend::Local { kernel, .. } => kernel.state_root(),
496            Backend::Hyperlight(h) => h.state_root_cache,
497        }
498    }
499
500    /// Bench-only: clear the guest's JIT compile cache so the next
501    /// `invoke_cached` pays a full recompile. No-op on the Local
502    /// backend (which uses the interpreter and has no JIT cache).
503    pub fn evict_jit_all(&self) -> Result<()> {
504        let mut backend = self
505            .inner
506            .backend
507            .lock()
508            .expect("Nub backend mutex poisoned");
509        match &mut *backend {
510            Backend::Local { .. } => Ok(()),
511            Backend::Hyperlight(h) => {
512                h.sandbox.evict_jit_all_parallel()?;
513                Ok(())
514            }
515        }
516    }
517
518    /// Diagnostic: read the guest's talc allocation counters.
519    /// Hyperlight backend only. Requires the `heap-diag` feature.
520    #[cfg(feature = "heap-diag")]
521    pub fn heap_stats(&self) -> Result<HeapStats> {
522        let mut backend = self
523            .inner
524            .backend
525            .lock()
526            .expect("Nub backend mutex poisoned");
527        match &mut *backend {
528            Backend::Local { .. } => Err(anyhow::anyhow!(
529                "heap_stats: Local backend has no guest heap"
530            )),
531            Backend::Hyperlight(h) => {
532                let raw: Vec<u8> = h.sandbox.call_raw(FN_ID_NUB_HEAP_STATS, &[])?;
533                if raw.len() != 40 {
534                    return Err(anyhow::anyhow!(
535                        "heap_stats: expected 40 bytes, got {}",
536                        raw.len()
537                    ));
538                }
539                let parse = |off: usize| u64::from_le_bytes(raw[off..off + 8].try_into().unwrap());
540                Ok(HeapStats {
541                    allocation_count: parse(0),
542                    total_allocation_count: parse(8),
543                    allocated_bytes: parse(16),
544                    fragment_count: parse(24),
545                    available_bytes: parse(32),
546                })
547            }
548        }
549    }
550
551    // --- New publish surface (caller-built `Cap`) ---
552
553    /// Put a caller-built `Cap` into the active cache. Computes
554    /// the cap's content hash and either clones the cap on first put or
555    /// bumps refcount on idempotent re-put. Returns the cap's content hash.
556    pub fn put_cap(&self, cap: &javm_cap::Cap) -> Result<AbiCapHash> {
557        let mut backend = self
558            .inner
559            .backend
560            .lock()
561            .expect("Nub backend mutex poisoned");
562        match &mut *backend {
563            Backend::Local { cache, .. } => cache
564                .put_cap(cap)
565                .map_err(|e| anyhow::anyhow!("put_cap (local): {e}")),
566            Backend::Hyperlight(h) => h
567                .sandbox
568                .put_cap(cap)
569                .map_err(|e| anyhow::anyhow!("put_cap: {e}")),
570        }
571    }
572
573    /// Pre-hashed variant. Caller computed `ssz::hash_tree_root(cap)`
574    /// at warmup and passes it explicitly; on the hot idempotent
575    /// path this lets both backends skip the SSZ merkleize entirely.
576    /// Debug-asserts the claimed hash matches the cap; release trusts
577    /// the caller.
578    ///
579    /// Hyperlight backend: short-circuits on a host-side set of blob
580    /// hashes this sandbox has already published. On a hit, no RPC
581    /// roundtrip and no guest-side merkle walk — the typical bench /
582    /// replay workload re-publishes the same cap graph every iteration
583    /// and pays only one host-side `HashSet::contains`. (The host does
584    /// not read the guest's `CacheDirectory` directly: it is a hashbrown
585    /// table built with a different SIMD `Group` width than the host's,
586    /// so a cross-binary deref is unsound — see
587    /// `nub-host-kvm::MultiUseSandbox::published_blobs`.)
588    pub fn put_cap_with_hash(&self, hash: AbiCapHash, cap: &javm_cap::Cap) -> Result<()> {
589        let mut backend = self
590            .inner
591            .backend
592            .lock()
593            .expect("Nub backend mutex poisoned");
594        match &mut *backend {
595            Backend::Local { cache, .. } => cache
596                .put_cap_with_hash(hash, cap)
597                .map_err(|e| anyhow::anyhow!("put_cap_with_hash (local): {e}")),
598            Backend::Hyperlight(h) => h
599                .sandbox
600                .put_cap_with_hash(hash, cap)
601                .map_err(|e| anyhow::anyhow!("put_cap_with_hash: {e}")),
602        }
603    }
604
605    /// Submit an invocation to the singleton Nub and return a job handle. Jobs
606    /// are queued onto a fixed Nub-owned host executor; Hyperlight execution then
607    /// runs on the sandbox's fixed vCPU worker lanes.
608    pub fn submit_invoke(&self, request: InvokeRequest) -> Result<InvokeJob> {
609        let id = InvokeJobId(self.inner.next_job_id.fetch_add(1, Ordering::Relaxed));
610        let state = Arc::new(InvokeJobState::new());
611        self.inner
612            .invoke_executor
613            .ensure_started(self.inner.invoke_worker_count)?;
614        self.inner.invoke_executor.enqueue(QueuedInvoke {
615            nub: self.clone(),
616            id,
617            request,
618            state: state.clone(),
619        })?;
620        Ok(InvokeJob { id, state })
621    }
622
623    /// Invoke a previously-published `Cap::Instance` by hash. V0 args
624    /// are 4 u64s laid into φ[7..=10] on top of the published
625    /// endpoint's `initial_regs` baseline.
626    pub fn invoke_cached(
627        &self,
628        instance_hash: AbiCapHash,
629        endpoint_idx: u8,
630        args: [u64; 4],
631        initial_gas: u64,
632    ) -> Result<InvocationResult> {
633        // The blocking API can go straight to the KVM lane pool: each caller
634        // blocks on its own lane lease. `submit_invoke` keeps the host-side job
635        // queue for callers that explicitly want an async handle.
636        let id = self.inner.next_job_id.fetch_add(1, Ordering::Relaxed);
637        self.invoke_request_blocking(
638            InvokeRequest {
639                instance_hash,
640                endpoint_idx,
641                args,
642                initial_gas,
643            },
644            id,
645        )
646    }
647
648    fn invoke_request_blocking(
649        &self,
650        request: InvokeRequest,
651        job_id: u64,
652    ) -> Result<InvocationResult> {
653        self.invoke_cached_raw(
654            job_id,
655            request.instance_hash,
656            request.endpoint_idx,
657            request.args,
658            request.initial_gas,
659        )
660    }
661
662    /// The backend dispatch for [`Self::invoke_cached`].
663    fn invoke_cached_raw(
664        &self,
665        job_id: u64,
666        instance_hash: AbiCapHash,
667        endpoint_idx: u8,
668        args: [u64; 4],
669        initial_gas: u64,
670    ) -> Result<InvocationResult> {
671        let target = {
672            let mut backend = self
673                .inner
674                .backend
675                .lock()
676                .expect("Nub backend mutex poisoned");
677            match &mut *backend {
678                Backend::Local { cache, .. } => {
679                    // Resolve the instance + image from the in-process
680                    // cache and drive the PVM2 (RISC-V) interpreter.
681                    let instance_cap = cache
682                        .get(CapHashOrRef::Hash(instance_hash))
683                        .ok_or_else(|| anyhow::anyhow!("invoke_cached: instance not published"))?;
684                    let inst = match &*instance_cap {
685                        Cap::Instance(i) => i.clone(),
686                        _ => {
687                            return Err(anyhow::anyhow!(
688                                "invoke_cached: cap at hash is not an Instance"
689                            ));
690                        }
691                    };
692                    let image_cap = cache
693                        .get(CapHashOrRef::Hash(inst.image_hash))
694                        .ok_or_else(|| anyhow::anyhow!("invoke_cached: image not in cache"))?;
695                    let img = match &*image_cap {
696                        Cap::Image(i) => i.clone(),
697                        _ => {
698                            return Err(anyhow::anyhow!(
699                                "invoke_cached: cap at image_hash is not an Image"
700                            ));
701                        }
702                    };
703                    InvokeBackendTarget::Local {
704                        inst: Box::new(inst),
705                        img: Box::new(img),
706                    }
707                }
708                Backend::Hyperlight(h) => InvokeBackendTarget::Hyperlight(h.clone()),
709            }
710        };
711
712        let hyperlight = match target {
713            InvokeBackendTarget::Local { inst, img } => {
714                return Ok(nub_arch_local::run_instance(
715                    &inst,
716                    &img,
717                    endpoint_idx,
718                    args,
719                    initial_gas,
720                ));
721            }
722            InvokeBackendTarget::Hyperlight(h) => h,
723        };
724
725        // No host-side pin/unpin — the cap is owned by the guest's
726        // heap-resident DIRECTORY; there's nothing for the host to lock against
727        // (the guest doesn't evict). Hyperlight invokes always go through the
728        // fixed per-lane worker pool; serialized `call_raw` remains only for the
729        // control plane and stops idle workers before using the legacy RPC ring.
730        let packet = InvokePacket {
731            instance_hash,
732            endpoint_idx: endpoint_idx as u32,
733            _pad: 0,
734            args,
735            initial_gas,
736        };
737
738        hyperlight
739            .sandbox
740            .invoke_cached_parallel(job_id, &packet)
741            .map_err(|e| anyhow::anyhow!("invoke_cached_parallel: {e}"))
742    }
743}