Skip to main content

nub/
lib.rs

1//! Nub: the JAR v3 microkernel substrate — uniform caller-facing handle.
2//!
3//! The [`Nub`] handle hides the choice of substrate behind a single
4//! publish/invoke surface, generic over a kernel [`Personality`] `P`
5//! (the pluggable semantics layer: what published objects mean, how
6//! invocations resolve a root object). Two backends:
7//!
8//! - **Local**: the personality's in-process kernel
9//!   ([`Personality::Local`], a [`LocalKernel`] impl). Used for
10//!   tests, deterministic replay, and any host that doesn't need real
11//!   ring-0 isolation.
12//! - **Hyperlight**: ships invocations as RPCs into a bare-metal
13//!   guest binary (the personality's guest crate over the generic
14//!   `nub-arch-x86` kernel lib) running inside a Hyperlight sandbox.
15//!   The wire protocol is personality-agnostic: opaque bytes +
16//!   32-byte [`ObjHash`] keys.
17//!
18//! Nub itself owns no guest blob and no singleton policy — a
19//! personality entrypoint crate (e.g. `rust/javm` for JAVM) builds
20//! its guest blob, defines the typed publish surface, and constructs
21//! handles via [`Nub::new_local`] / [`Nub::create_hyperlight`].
22
23pub mod personality;
24#[cfg(feature = "test-support")]
25pub mod test_support;
26
27use std::collections::VecDeque;
28use std::num::NonZeroUsize;
29use std::panic::AssertUnwindSafe;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::{Arc, Condvar, Mutex};
32use std::thread::{self, JoinHandle};
33
34use anyhow::Result;
35use nub_host_kvm::sandbox::{
36    GuestBinary, MultiUseSandbox, SandboxConfiguration, UninitializedSandbox,
37};
38
39#[cfg(feature = "heap-diag")]
40use nub_arch_x86_abi::FN_ID_NUB_HEAP_STATS;
41use nub_arch_x86_abi::InvokePacket;
42pub use nub_arch_x86_abi::{CapHash as AbiCapHash, InvocationResult, SCRATCHPAD_HEAD_LEN};
43pub use nub_kernel::{CapHash, InstanceRef, InvokeOptions, InvokeOutcome, ObjHash};
44pub use personality::{LocalKernel, Personality};
45
46pub const MAX_HYPERLIGHT_VCPUS: usize = nub_arch_x86_abi::MAX_EXECUTION_LANES;
47
48/// Snapshot of the guest's talc allocation state. Returned by
49/// [`Nub::heap_stats`].
50#[cfg(feature = "heap-diag")]
51#[derive(Debug, Clone, Copy)]
52pub struct HeapStats {
53    /// Live allocation count (incremented on alloc, decremented on
54    /// free) — a non-zero per-invoke drift here is a leak.
55    pub allocation_count: u64,
56    /// Cumulative allocations ever performed (monotonic, never
57    /// decremented) — its per-invoke delta is the allocation *churn*,
58    /// the right yardstick for "this CALL allocated nothing but a
59    /// `KernelFrame`" even when the transient allocations are freed
60    /// again before the next snapshot.
61    pub total_allocation_count: u64,
62    pub allocated_bytes: u64,
63    pub fragment_count: u64,
64    pub available_bytes: u64,
65}
66
67/// Uniform handle to the nub microkernel substrate, generic over the
68/// kernel personality.
69pub struct Nub<P: Personality> {
70    inner: Arc<NubInner<P>>,
71}
72
73// Hand-written: `#[derive(Clone)]` would wrongly bound `P: Clone`.
74impl<P: Personality> Clone for Nub<P> {
75    fn clone(&self) -> Self {
76        Self {
77            inner: self.inner.clone(),
78        }
79    }
80}
81
82struct NubInner<P: Personality> {
83    backend: Mutex<Backend<P>>,
84    next_job_id: AtomicU64,
85    invoke_executor: Arc<InvokeExecutor<P>>,
86    invoke_worker_count: usize,
87}
88
89/// Options used when constructing a Hyperlight-backed Nub.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub struct NubOptions {
92    /// Fixed vCPU pool size for the backing sandbox. Multi-vCPU Hyperlight
93    /// sandboxes keep one hot worker per lane and route top-level invokes
94    /// through those workers.
95    pub vcpu_count: usize,
96}
97
98impl NubOptions {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    pub fn with_vcpu_count(mut self, vcpu_count: usize) -> Self {
104        self.vcpu_count = vcpu_count.max(1);
105        self
106    }
107
108    fn validate(&self) -> Result<()> {
109        if self.vcpu_count == 0 {
110            return Err(anyhow::anyhow!("NubOptions.vcpu_count must be at least 1"));
111        }
112        if self.vcpu_count > MAX_HYPERLIGHT_VCPUS {
113            return Err(anyhow::anyhow!(
114                "NubOptions.vcpu_count={} exceeds guest lane capacity {}",
115                self.vcpu_count,
116                MAX_HYPERLIGHT_VCPUS
117            ));
118        }
119        Ok(())
120    }
121}
122
123impl Default for NubOptions {
124    fn default() -> Self {
125        let default = thread::available_parallelism()
126            .map(NonZeroUsize::get)
127            .unwrap_or(1)
128            .clamp(1, 8);
129        let vcpu_count = std::env::var("JAR_NUB_VCPUS")
130            .ok()
131            .and_then(|s| s.parse::<usize>().ok())
132            .filter(|&n| n > 0)
133            .unwrap_or(default);
134        Self { vcpu_count }
135    }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct InvokeRequest {
140    pub root: AbiCapHash,
141    pub endpoint_idx: u8,
142    pub args: [u64; 4],
143    pub initial_gas: u64,
144}
145
146#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
147pub struct InvokeJobId(pub u64);
148
149pub struct InvokeJob {
150    id: InvokeJobId,
151    state: Arc<InvokeJobState>,
152}
153
154struct InvokeJobState {
155    result: Mutex<Option<Result<InvocationResult, String>>>,
156    ready: Condvar,
157}
158
159struct QueuedInvoke<P: Personality> {
160    nub: Nub<P>,
161    id: InvokeJobId,
162    request: InvokeRequest,
163    state: Arc<InvokeJobState>,
164}
165
166struct InvokeExecutor<P: Personality> {
167    state: Mutex<InvokeExecutorState<P>>,
168    ready: Condvar,
169    handles: Mutex<Vec<JoinHandle<()>>>,
170}
171
172struct InvokeExecutorState<P: Personality> {
173    queue: VecDeque<QueuedInvoke<P>>,
174    stopping: bool,
175}
176
177impl InvokeJob {
178    pub fn id(&self) -> InvokeJobId {
179        self.id
180    }
181
182    pub fn try_wait(&self) -> Option<Result<InvocationResult>> {
183        let guard = self
184            .state
185            .result
186            .lock()
187            .expect("InvokeJob result mutex poisoned");
188        guard.as_ref().map(|r| match r {
189            Ok(v) => Ok(*v),
190            Err(e) => Err(anyhow::anyhow!(e.clone())),
191        })
192    }
193
194    pub fn wait(self) -> Result<InvocationResult> {
195        let mut guard = self
196            .state
197            .result
198            .lock()
199            .expect("InvokeJob result mutex poisoned");
200        while guard.is_none() {
201            guard = self
202                .state
203                .ready
204                .wait(guard)
205                .expect("InvokeJob result mutex poisoned");
206        }
207        match guard.take().expect("checked is_some") {
208            Ok(v) => Ok(v),
209            Err(e) => Err(anyhow::anyhow!(e)),
210        }
211    }
212}
213
214impl InvokeJobState {
215    fn new() -> Self {
216        Self {
217            result: Mutex::new(None),
218            ready: Condvar::new(),
219        }
220    }
221
222    fn complete(&self, result: Result<InvocationResult, String>) {
223        let mut guard = self.result.lock().expect("InvokeJob result mutex poisoned");
224        *guard = Some(result);
225        self.ready.notify_all();
226    }
227}
228
229impl<P: Personality> InvokeExecutor<P> {
230    fn new() -> Self {
231        Self {
232            state: Mutex::new(InvokeExecutorState {
233                queue: VecDeque::new(),
234                stopping: false,
235            }),
236            ready: Condvar::new(),
237            handles: Mutex::new(Vec::new()),
238        }
239    }
240
241    fn ensure_started(self: &Arc<Self>, worker_count: usize) -> Result<()> {
242        let mut handles = self
243            .handles
244            .lock()
245            .expect("InvokeExecutor handles mutex poisoned");
246        if !handles.is_empty() {
247            return Ok(());
248        }
249
250        for worker in 0..worker_count.max(1) {
251            let executor = self.clone();
252            let handle = thread::Builder::new()
253                .name(format!("nub-invoke-worker-{worker}"))
254                .spawn(move || executor.worker_loop())
255                .map_err(|e| anyhow::anyhow!("submit_invoke: spawn worker: {e}"))?;
256            handles.push(handle);
257        }
258        Ok(())
259    }
260
261    fn enqueue(&self, job: QueuedInvoke<P>) -> Result<()> {
262        let mut state = self
263            .state
264            .lock()
265            .expect("InvokeExecutor state mutex poisoned");
266        if state.stopping {
267            return Err(anyhow::anyhow!(
268                "submit_invoke: Nub invoke executor is stopping"
269            ));
270        }
271        state.queue.push_back(job);
272        self.ready.notify_one();
273        Ok(())
274    }
275
276    fn worker_loop(self: Arc<Self>) {
277        while let Some(job) = self.next_job() {
278            let id = job.id.0;
279            let nub = job.nub;
280            let request = job.request;
281            let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
282                nub.invoke_request_blocking(request, id)
283            }))
284            .map_err(|_| "invoke worker panicked".to_string())
285            .and_then(|r| r.map_err(|e| format!("{e:#}")));
286            job.state.complete(result);
287        }
288    }
289
290    fn next_job(&self) -> Option<QueuedInvoke<P>> {
291        let mut state = self
292            .state
293            .lock()
294            .expect("InvokeExecutor state mutex poisoned");
295        loop {
296            if let Some(job) = state.queue.pop_front() {
297                return Some(job);
298            }
299            if state.stopping {
300                return None;
301            }
302            state = self
303                .ready
304                .wait(state)
305                .expect("InvokeExecutor state mutex poisoned");
306        }
307    }
308
309    fn stop_and_join(&self) {
310        {
311            let mut state = self
312                .state
313                .lock()
314                .expect("InvokeExecutor state mutex poisoned");
315            state.stopping = true;
316            self.ready.notify_all();
317        }
318
319        let current = thread::current().id();
320        let handles = {
321            let mut handles = self
322                .handles
323                .lock()
324                .expect("InvokeExecutor handles mutex poisoned");
325            core::mem::take(&mut *handles)
326        };
327        for handle in handles {
328            if handle.thread().id() == current {
329                continue;
330            }
331            let _ = handle.join();
332        }
333    }
334}
335
336impl<P: Personality> Drop for NubInner<P> {
337    fn drop(&mut self) {
338        self.invoke_executor.stop_and_join();
339    }
340}
341
342enum Backend<P: Personality> {
343    /// In-process backend: the personality's [`LocalKernel`] (object
344    /// store + interpreter wiring). Source of truth for objects
345    /// published via [`Nub::put_object`] and resolved by
346    /// [`Nub::invoke_cached`].
347    Local(P::Local),
348    /// Hyperlight backend: the object store lives guest-side in the
349    /// personality's guest binary; the host writes via the
350    /// `FN_ID_NUB_PUT_CAP` RPC and tracks published blob hashes
351    /// host-side to short-circuit idempotent re-puts (it does *not*
352    /// dereference the guest's hashbrown — see
353    /// `MultiUseSandbox::published_blobs` for why that is unsound).
354    Hyperlight(Arc<HyperlightDriver>),
355}
356
357/// Host-side RPC stub for the Hyperlight backend. The real kernel
358/// lives guest-side; this wrapper just ships invocations into the
359/// sandbox.
360struct HyperlightDriver {
361    sandbox: MultiUseSandbox,
362    state_root_cache: CapHash,
363}
364
365impl<P: Personality> Nub<P> {
366    /// Construct a Nub backed by the personality's in-process kernel.
367    pub fn new_local() -> Self {
368        let invoke_worker_count = thread::available_parallelism()
369            .map(NonZeroUsize::get)
370            .unwrap_or(2)
371            .clamp(2, 8);
372        Self {
373            inner: Arc::new(NubInner {
374                backend: Mutex::new(Backend::Local(P::Local::default())),
375                next_job_id: AtomicU64::new(1),
376                invoke_executor: Arc::new(InvokeExecutor::new()),
377                invoke_worker_count,
378            }),
379        }
380    }
381
382    /// Create a Hyperlight-backed Nub from a guest ELF on disk.
383    ///
384    /// **At most one per process.** The KVM substrate supports a
385    /// single live Hyperlight sandbox per process: the guest-VA
386    /// window is one process-wide fixed reservation, and every
387    /// sandbox `MAP_FIXED`-overlays its kernel-shadow at the same VA
388    /// inside it. A second construction — concurrent or sequential
389    /// (the guard is never released, even after dropping the first
390    /// sandbox) — fails loudly with
391    /// `nub_host_kvm::HyperlightError::SandboxAlreadyCreated` instead
392    /// of silently corrupting the live sandbox's guest memory.
393    /// Personality entrypoint crates own the blob paths and typically
394    /// wrap this constructor in a process-wide singleton (e.g.
395    /// `javm::Nub::hyperlight`), which reuses the one sandbox across
396    /// callers.
397    pub fn create_hyperlight(path: &str, options: NubOptions) -> Result<Self> {
398        options.validate()?;
399        let mut cfg = SandboxConfiguration::default();
400        cfg.set_vcpu_count(options.vcpu_count);
401        cfg.set_scratch_size(512 * 1024 * 1024);
402        cfg.set_input_data_size(16 * 1024 * 1024);
403        cfg.set_output_data_size(16 * 1024 * 1024);
404        cfg.set_heap_size(256 * 1024 * 1024);
405        let uninit = UninitializedSandbox::new(GuestBinary::FilePath(path.to_string()), Some(cfg))
406            .map_err(|e| anyhow::anyhow!("create_hyperlight[{}]: {path}: {e}", P::NAME))?;
407        let sandbox = uninit
408            .evolve()
409            .map_err(|e| anyhow::anyhow!("create_hyperlight[{}]: evolve: {e}", P::NAME))?;
410        Ok(Self {
411            inner: Arc::new(NubInner {
412                backend: Mutex::new(Backend::Hyperlight(Arc::new(HyperlightDriver {
413                    sandbox,
414                    state_root_cache: [0; 32],
415                }))),
416                next_job_id: AtomicU64::new(1),
417                invoke_executor: Arc::new(InvokeExecutor::new()),
418                invoke_worker_count: options.vcpu_count.max(1),
419            }),
420        })
421    }
422
423    /// Current state root.
424    pub fn state_root(&self) -> CapHash {
425        let backend = self
426            .inner
427            .backend
428            .lock()
429            .expect("Nub backend mutex poisoned");
430        match &*backend {
431            Backend::Local(local) => local.state_root(),
432            Backend::Hyperlight(h) => h.state_root_cache,
433        }
434    }
435
436    /// Bench-only: clear the guest's JIT compile cache so the next
437    /// `invoke_cached` pays a full recompile. No-op on the Local
438    /// backend (which uses the interpreter and has no JIT cache).
439    pub fn evict_jit_all(&self) -> Result<()> {
440        let mut backend = self
441            .inner
442            .backend
443            .lock()
444            .expect("Nub backend mutex poisoned");
445        match &mut *backend {
446            Backend::Local(_) => Ok(()),
447            Backend::Hyperlight(h) => {
448                h.sandbox.evict_jit_all_parallel()?;
449                Ok(())
450            }
451        }
452    }
453
454    /// Diagnostic: read the guest's talc allocation counters.
455    /// Hyperlight backend only. Requires the `heap-diag` feature.
456    #[cfg(feature = "heap-diag")]
457    pub fn heap_stats(&self) -> Result<HeapStats> {
458        let mut backend = self
459            .inner
460            .backend
461            .lock()
462            .expect("Nub backend mutex poisoned");
463        match &mut *backend {
464            Backend::Local(_) => Err(anyhow::anyhow!(
465                "heap_stats: Local backend has no guest heap"
466            )),
467            Backend::Hyperlight(h) => {
468                let raw: Vec<u8> = h.sandbox.call_raw(FN_ID_NUB_HEAP_STATS, &[])?;
469                if raw.len() != 40 {
470                    return Err(anyhow::anyhow!(
471                        "heap_stats: expected 40 bytes, got {}",
472                        raw.len()
473                    ));
474                }
475                let parse = |off: usize| u64::from_le_bytes(raw[off..off + 8].try_into().unwrap());
476                Ok(HeapStats {
477                    allocation_count: parse(0),
478                    total_allocation_count: parse(8),
479                    allocated_bytes: parse(16),
480                    fragment_count: parse(24),
481                    available_bytes: parse(32),
482                })
483            }
484        }
485    }
486
487    // --- Generic publish surface (personality-encoded bytes) ---
488
489    /// Put a personality-encoded object into the active store. The
490    /// personality decodes, validates, and content-hashes the bytes;
491    /// the returned [`ObjHash`] is the content-addressed key.
492    pub fn put_object(&self, bytes: &[u8]) -> Result<ObjHash> {
493        let mut backend = self
494            .inner
495            .backend
496            .lock()
497            .expect("Nub backend mutex poisoned");
498        match &mut *backend {
499            Backend::Local(local) => local.put_object(bytes),
500            Backend::Hyperlight(h) => h
501                .sandbox
502                .put_object(bytes)
503                .map_err(|e| anyhow::anyhow!("put_object: {e}")),
504        }
505    }
506
507    /// Pre-hashed variant. The caller already knows the content hash;
508    /// on the hot idempotent path this skips encode + hash entirely.
509    ///
510    /// Hyperlight backend: short-circuits on a host-side set of blob
511    /// hashes this sandbox has already published — on a hit, `bytes`
512    /// is never called: no encode, no RPC roundtrip, no guest-side
513    /// merkle walk (see
514    /// `nub-host-kvm::MultiUseSandbox::put_object_with_hash`).
515    pub fn put_object_with_hash(
516        &self,
517        hash: ObjHash,
518        bytes: impl FnOnce() -> std::result::Result<Vec<u8>, String>,
519    ) -> Result<()> {
520        let mut backend = self
521            .inner
522            .backend
523            .lock()
524            .expect("Nub backend mutex poisoned");
525        match &mut *backend {
526            Backend::Local(local) => {
527                let bytes =
528                    bytes().map_err(|e| anyhow::anyhow!("put_object_with_hash: encode: {e}"))?;
529                local.put_object_with_hash(hash, &bytes)
530            }
531            Backend::Hyperlight(h) => h
532                .sandbox
533                .put_object_with_hash(hash, bytes)
534                .map_err(|e| anyhow::anyhow!("put_object_with_hash: {e}")),
535        }
536    }
537
538    /// Typed escape hatch: run `f` against the personality's
539    /// [`LocalKernel`] under the backend lock. Returns `None` on the
540    /// Hyperlight backend. Personality entrypoint crates use this to
541    /// keep their typed publish paths encode-free on Local.
542    pub fn with_local<R>(&self, f: impl FnOnce(&mut P::Local) -> R) -> Option<R> {
543        let mut backend = self
544            .inner
545            .backend
546            .lock()
547            .expect("Nub backend mutex poisoned");
548        match &mut *backend {
549            Backend::Local(local) => Some(f(local)),
550            Backend::Hyperlight(_) => None,
551        }
552    }
553
554    /// Submit an invocation and return a job handle. Jobs are queued
555    /// onto a fixed Nub-owned host executor; Hyperlight execution then
556    /// runs on the sandbox's fixed vCPU worker lanes.
557    pub fn submit_invoke(&self, request: InvokeRequest) -> Result<InvokeJob> {
558        let id = InvokeJobId(self.inner.next_job_id.fetch_add(1, Ordering::Relaxed));
559        let state = Arc::new(InvokeJobState::new());
560        self.inner
561            .invoke_executor
562            .ensure_started(self.inner.invoke_worker_count)?;
563        self.inner.invoke_executor.enqueue(QueuedInvoke {
564            nub: self.clone(),
565            id,
566            request,
567            state: state.clone(),
568        })?;
569        Ok(InvokeJob { id, state })
570    }
571
572    /// Invoke the object graph rooted at a previously-published
573    /// `root` hash. V0 args are 4 u64s overlaid per the personality's
574    /// register ABI.
575    pub fn invoke_cached(
576        &self,
577        root: ObjHash,
578        endpoint_idx: u8,
579        args: [u64; 4],
580        initial_gas: u64,
581    ) -> Result<InvocationResult> {
582        // The blocking API can go straight to the KVM lane pool: each caller
583        // blocks on its own lane lease. `submit_invoke` keeps the host-side job
584        // queue for callers that explicitly want an async handle.
585        let id = self.inner.next_job_id.fetch_add(1, Ordering::Relaxed);
586        self.invoke_request_blocking(
587            InvokeRequest {
588                root,
589                endpoint_idx,
590                args,
591                initial_gas,
592            },
593            id,
594        )
595    }
596
597    fn invoke_request_blocking(
598        &self,
599        request: InvokeRequest,
600        job_id: u64,
601    ) -> Result<InvocationResult> {
602        self.invoke_cached_raw(
603            job_id,
604            request.root,
605            request.endpoint_idx,
606            request.args,
607            request.initial_gas,
608        )
609    }
610
611    /// The backend dispatch for [`Self::invoke_cached`].
612    fn invoke_cached_raw(
613        &self,
614        job_id: u64,
615        root: ObjHash,
616        endpoint_idx: u8,
617        args: [u64; 4],
618        initial_gas: u64,
619    ) -> Result<InvocationResult> {
620        let hyperlight = {
621            let mut backend = self
622                .inner
623                .backend
624                .lock()
625                .expect("Nub backend mutex poisoned");
626            match &mut *backend {
627                // NOTE: the Local kernel runs the invocation under the
628                // backend lock, so concurrent Local invokes serialize.
629                // Local is the test/replay backend; all its callers
630                // assert results, not concurrency.
631                Backend::Local(local) => {
632                    return local.invoke(root, endpoint_idx as u32, args, initial_gas);
633                }
634                Backend::Hyperlight(h) => h.clone(),
635            }
636        };
637
638        // No host-side pin/unpin — the object is owned by the guest's
639        // heap-resident store; there's nothing for the host to lock against
640        // (the guest doesn't evict). Hyperlight invokes always go through the
641        // fixed per-lane worker pool; serialized `call_raw` remains only for the
642        // control plane and stops idle workers before using the legacy RPC ring.
643        let packet = InvokePacket {
644            root_hash: root,
645            endpoint_idx: endpoint_idx as u32,
646            _pad: 0,
647            args,
648            initial_gas,
649        };
650
651        hyperlight
652            .sandbox
653            .invoke_cached_parallel(job_id, &packet)
654            .map_err(|e| anyhow::anyhow!("invoke_cached_parallel: {e}"))
655    }
656}