1pub 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#[cfg(feature = "heap-diag")]
51#[derive(Debug, Clone, Copy)]
52pub struct HeapStats {
53 pub allocation_count: u64,
56 pub total_allocation_count: u64,
62 pub allocated_bytes: u64,
63 pub fragment_count: u64,
64 pub available_bytes: u64,
65}
66
67pub struct Nub<P: Personality> {
70 inner: Arc<NubInner<P>>,
71}
72
73impl<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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub struct NubOptions {
92 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 Local(P::Local),
348 Hyperlight(Arc<HyperlightDriver>),
355}
356
357struct HyperlightDriver {
361 sandbox: MultiUseSandbox,
362 state_root_cache: CapHash,
363}
364
365impl<P: Personality> Nub<P> {
366 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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}