1#[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#[cfg(feature = "heap-diag")]
49#[derive(Debug, Clone, Copy)]
50pub struct HeapStats {
51 pub allocation_count: u64,
54 pub total_allocation_count: u64,
60 pub allocated_bytes: u64,
61 pub fragment_count: u64,
62 pub available_bytes: u64,
63}
64
65const 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
83pub type HyperlightNubGuard = Nub;
87
88#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub struct NubOptions {
104 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 Local {
359 kernel: Kernel<LocalArch>,
360 cache: CacheDirectory,
361 },
362 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
379struct HyperlightDriver {
383 sandbox: MultiUseSandbox,
384 state_root_cache: CapHash,
385}
386
387impl Nub {
388 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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}