Skip to main content

nub_host_kvm/sandbox/
initialized_multi_use.rs

1/*
2Copyright 2025  The Hyperlight Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8    http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17use std::sync::atomic::{Ordering, fence};
18use std::sync::{Arc, Condvar, Mutex};
19use std::thread::{self, JoinHandle};
20use std::time::{Duration, Instant};
21
22use javm_cap::cap::Cap;
23use nub_arch_x86_abi::{
24    CapHash as AbiCapHash, FN_ID_NUB_INVOKE_WORKER, FN_ID_NUB_PUT_CAP, InvocationResult,
25    InvokePacket, PARALLEL_INVOKE_STATUS_DONE, PARALLEL_INVOKE_STATUS_EMPTY,
26    PARALLEL_INVOKE_STATUS_EVICT_JIT_READY, PARALLEL_INVOKE_STATUS_READY,
27    PARALLEL_INVOKE_STATUS_RUNNING, PARALLEL_INVOKE_STATUS_STARTING, PARALLEL_INVOKE_STATUS_STOP,
28};
29use nub_host_common::rpc::{ArchivedResponse, Request};
30use rkyv::util::AlignedVec;
31use std::collections::HashSet;
32use tracing::{Span, instrument};
33
34use super::host_funcs::FunctionRegistry;
35use crate::HyperlightError;
36use crate::Result;
37use crate::hypervisor::InterruptHandle;
38use crate::hypervisor::hyperlight_vm::HyperlightVm;
39use crate::hypervisor::virtual_machine::VcpuLane;
40use crate::mem::mgr::SandboxMemoryManager;
41use crate::mem::shared_mem::HostSharedMemory;
42use crate::metrics::{
43    METRIC_GUEST_ERROR, METRIC_GUEST_ERROR_LABEL_CODE, maybe_time_and_emit_guest_call,
44};
45
46/// A fully initialized sandbox that can execute guest functions multiple times.
47///
48/// Guest functions can be called repeatedly while maintaining state between calls.
49///
50/// Post-Stage-F: the upstream `snapshot()` / `restore()` / `map_file_cow()`
51/// rollback machinery is gone along with the CoW PT marking that backed it.
52/// If a guest call fails for any reason, drop the sandbox and build a new
53/// one — that's the only recovery path now (and the one `nub` already used).
54pub struct MultiUseSandbox {
55    /// Unique identifier for this sandbox instance
56    id: u64,
57    pub(crate) host_funcs: Arc<Mutex<FunctionRegistry>>,
58    pub(crate) mem_mgr: Arc<Mutex<SandboxMemoryManager<HostSharedMemory>>>,
59    vm: Arc<HyperlightVm>,
60    control_lock: Mutex<()>,
61    invoke_workers: Mutex<Option<Arc<ParallelInvokeWorkers>>>,
62    /// Host-side record of every blob hash this sandbox has successfully
63    /// published, so `put_cap_with_hash` can short-circuit an idempotent
64    /// re-put without a roundtrip + merkle walk through the guest.
65    ///
66    /// This *replaces* an earlier design that directly dereferenced the
67    /// guest's heap-resident `CacheDirectory` hashbrown table from the host
68    /// (the deleted `GuestCacheReader`). That was unsound: the guest is built
69    /// for `x86_64-unknown-none` (no SSE2 → hashbrown's generic **8-byte**
70    /// `Group`) while the host has SSE2 (**16-byte** `Group`). The host's
71    /// probe read 8 control bytes *past* the guest's control array, so once
72    /// the table grew beyond one group an absent-key lookup could walk off the
73    /// end ("went past end of probe sequence") or, worse, silently match the
74    /// wrong entry. A hashbrown table simply cannot be shared by direct memory
75    /// access across two binaries with different SIMD `Group` widths.
76    ///
77    /// The host set is correct because the blobs tier is **monotonic**: a cap
78    /// is keyed by content hash and `CacheDirectory::put_cap` only ever
79    /// `entry().or_insert()`s — blobs are never evicted (only the *instances*
80    /// tier is swept). A miss falls through to the idempotent `put_cap` RPC,
81    /// so even blobs the guest published on its own (e.g. via `derive_spawn`)
82    /// are handled correctly — just without the short-circuit.
83    published_blobs: Mutex<HashSet<AbiCapHash>>,
84}
85
86impl MultiUseSandbox {
87    /// Move an `UninitializedSandbox` into a new `MultiUseSandbox` instance.
88    ///
89    /// This function is not equivalent to doing an `evolve` from uninitialized
90    /// to initialized, and is purposely not exposed publicly outside the crate
91    /// (as a `From` implementation would be)
92    #[instrument(skip_all, parent = Span::current(), level = "Trace")]
93    pub(super) fn from_uninit(
94        host_funcs: Arc<Mutex<FunctionRegistry>>,
95        mgr: SandboxMemoryManager<HostSharedMemory>,
96        vm: HyperlightVm,
97    ) -> MultiUseSandbox {
98        Self {
99            id: super::snapshot::SANDBOX_CONFIGURATION_COUNTER.fetch_add(1, Ordering::Relaxed),
100            host_funcs,
101            mem_mgr: Arc::new(Mutex::new(mgr)),
102            vm: Arc::new(vm),
103            control_lock: Mutex::new(()),
104            invoke_workers: Mutex::new(None),
105            published_blobs: Mutex::new(HashSet::new()),
106        }
107    }
108
109    /// Returns this sandbox's unique id.
110    pub fn id(&self) -> u64 {
111        self.id
112    }
113
114    /// Fixed vCPU pool size configured for this sandbox.
115    pub fn vcpu_count(&self) -> Result<usize> {
116        let mem_mgr = self
117            .mem_mgr
118            .lock()
119            .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
120        Ok(mem_mgr.layout.get_vcpu_count())
121    }
122
123    /// Call a guest function by `fn_id` with a raw byte payload.
124    /// Returns the response payload bytes on success.
125    ///
126    /// Wire format: the host serialises a
127    /// [`nub_host_common::rpc::Request`] (rkyv) carrying `fn_id` and
128    /// `payload`, ships it via the input data ring, the guest decodes
129    /// + dispatches + writes a `Response` to the output ring, and we
130    /// read + check `status` before returning the inner payload.
131    ///
132    /// Changes made to the sandbox during execution are persisted.
133    /// On failure the sandbox should be dropped and rebuilt.
134    #[instrument(err(Debug), skip(self, payload), parent = Span::current())]
135    pub fn call_raw(&self, fn_id: u32, payload: &[u8]) -> Result<Vec<u8>> {
136        maybe_time_and_emit_guest_call("call_raw", || {
137            let mut workers = self
138                .invoke_workers
139                .lock()
140                .map_err(|_| crate::new_error!("parallel invoke worker mutex poisoned"))?;
141            self.stop_invoke_workers_locked(&mut workers)?;
142            let _control = self
143                .control_lock
144                .lock()
145                .map_err(|_| crate::new_error!("sandbox control mutex poisoned"))?;
146            self.call_guest_function_by_id_on_locked(VcpuLane::PRIMARY, fn_id, payload)
147        })
148    }
149
150    /// Serialized control-plane call on a selected vCPU lane. This still uses
151    /// the legacy shared input/output rings and therefore must not be used as
152    /// the concurrent invoke mechanism; it exists to validate and bootstrap
153    /// non-primary lanes. Concurrent invokes use the per-lane worker slots.
154    #[instrument(err(Debug), skip(self, payload), parent = Span::current())]
155    pub fn call_raw_on_vcpu(
156        &self,
157        vcpu_index: usize,
158        fn_id: u32,
159        payload: &[u8],
160    ) -> Result<Vec<u8>> {
161        let lane = VcpuLane::new(vcpu_index);
162        maybe_time_and_emit_guest_call("call_raw_on_vcpu", || {
163            let mut workers = self
164                .invoke_workers
165                .lock()
166                .map_err(|_| crate::new_error!("parallel invoke worker mutex poisoned"))?;
167            self.stop_invoke_workers_locked(&mut workers)?;
168            let _control = self
169                .control_lock
170                .lock()
171                .map_err(|_| crate::new_error!("sandbox control mutex poisoned"))?;
172            self.call_guest_function_by_id_on_locked(lane, fn_id, payload)
173        })
174    }
175
176    fn call_guest_function_by_id_on_locked(
177        &self,
178        lane: VcpuLane,
179        fn_id: u32,
180        payload: &[u8],
181    ) -> Result<Vec<u8>> {
182        // ===== KILL() TIMING POINT 1 =====
183        // Clear any stale cancellation from a previous guest function call or if kill() was called too early.
184        // Any kill() that completed (even partially) BEFORE this line has NO effect on this call.
185        self.vm.clear_cancel();
186
187        let res = (|| {
188            let req = Request {
189                fn_id,
190                payload: payload.to_vec(),
191            };
192            let req_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&req)
193                .map_err(|e| crate::new_error!("rkyv-serialize Request: {e}"))?;
194
195            let mut mem_mgr = self
196                .mem_mgr
197                .lock()
198                .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
199
200            mem_mgr.write_guest_function_call_raw(req_bytes.as_slice())?;
201
202            let dispatch_res = if lane == VcpuLane::PRIMARY {
203                self.vm
204                    .dispatch_call_from_host(&mut mem_mgr, &self.host_funcs)
205            } else {
206                self.vm
207                    .dispatch_call_from_host_on(lane, &mut mem_mgr, &self.host_funcs)
208            };
209
210            if let Err(e) = dispatch_res {
211                let (error, _should_poison) = e.promote();
212                return Err(error);
213            }
214
215            let raw_resp = mem_mgr.read_guest_function_call_result_raw()?;
216
217            let mut aligned = AlignedVec::<16>::with_capacity(raw_resp.len());
218            aligned.extend_from_slice(&raw_resp);
219
220            let resp = rkyv::access::<ArchivedResponse, rkyv::rancor::Error>(aligned.as_slice())
221                .map_err(|e| crate::new_error!("rkyv-access Response: {e}"))?;
222
223            let status = resp.status.to_native();
224            if status != 0 {
225                let msg = resp
226                    .error_msg
227                    .as_ref()
228                    .map(|s| s.as_str().to_string())
229                    .unwrap_or_else(|| format!("guest fn_id={fn_id} returned status {status}"));
230                metrics::counter!(
231                    METRIC_GUEST_ERROR,
232                    METRIC_GUEST_ERROR_LABEL_CODE => status.to_string()
233                )
234                .increment(1);
235                return Err(HyperlightError::GuestError(
236                    hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode::GuestError,
237                    msg,
238                ));
239            }
240
241            Ok(resp.payload.as_slice().to_vec())
242        })();
243
244        // Clear partial abort bytes so they don't leak across calls.
245        let mut mem_mgr = self
246            .mem_mgr
247            .lock()
248            .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
249        mem_mgr.abort_buffer.clear();
250
251        if res.is_err() {
252            mem_mgr.clear_io_buffers();
253        }
254
255        res
256    }
257
258    /// Submit an invoke packet through the per-lane parallel worker slots.
259    ///
260    /// Workers are started lazily and remain hot for subsequent invoke calls.
261    /// The legacy raw RPC channel remains the serialized control plane.
262    /// User job waits deliberately have no host-side timeout; without a
263    /// cancellation API, the lane must stay reserved until the guest reports
264    /// completion or the worker exits.
265    pub fn invoke_cached_parallel(
266        &self,
267        job_id: u64,
268        packet: &InvokePacket,
269    ) -> Result<InvocationResult> {
270        let (workers, lane) = loop {
271            let workers = self.ensure_invoke_workers()?;
272            if let Some(lane) = workers.try_acquire_lane()? {
273                break (workers, lane);
274            }
275            if let Some(lane_idx) = workers.reserve_unstarted_lane()? {
276                match self.start_invoke_worker_lane(lane_idx) {
277                    Ok(handle) => workers.install_started_lane(lane_idx, handle)?,
278                    Err(e) => {
279                        workers.release_start_reservation(lane_idx)?;
280                        return Err(e);
281                    }
282                }
283                continue;
284            }
285            if let Some(lane) = workers.acquire_lane()? {
286                break (workers, lane);
287            }
288        };
289        let lane_idx = lane.index();
290
291        let slot = {
292            let mem_mgr = self
293                .mem_mgr
294                .lock()
295                .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
296            mem_mgr.parallel_invoke_slot_host_ptr(lane_idx)?
297        };
298
299        // SAFETY: `lane` is a live `LaneLease`, so this host thread has
300        // exclusive host ownership of the slot until the function returns. The
301        // guest worker communicates through the ABI atomics in the same slot.
302        unsafe {
303            let status = (*slot).status.load(Ordering::Acquire);
304            if status != PARALLEL_INVOKE_STATUS_EMPTY {
305                return Err(crate::new_error!(
306                    "parallel invoke lane {} was not empty before submit (status={})",
307                    lane_idx,
308                    status
309                ));
310            }
311            (*slot).job_id.store(job_id, Ordering::Relaxed);
312            core::ptr::addr_of_mut!((*slot).packet).write_volatile(*packet);
313            fence(Ordering::Release);
314            (*slot)
315                .status
316                .store(PARALLEL_INVOKE_STATUS_READY, Ordering::Release);
317        }
318
319        loop {
320            let done = unsafe {
321                match (*slot).status.load(Ordering::Acquire) {
322                    PARALLEL_INVOKE_STATUS_DONE => {
323                        fence(Ordering::Acquire);
324                        let result = core::ptr::addr_of!((*slot).result).read_volatile();
325                        (*slot)
326                            .status
327                            .store(PARALLEL_INVOKE_STATUS_EMPTY, Ordering::Release);
328                        Some(result)
329                    }
330                    PARALLEL_INVOKE_STATUS_READY | PARALLEL_INVOKE_STATUS_RUNNING => None,
331                    other => {
332                        return Err(crate::new_error!(
333                            "parallel invoke lane {} entered unexpected status {}",
334                            lane_idx,
335                            other
336                        ));
337                    }
338                }
339            };
340            if let Some(result) = done {
341                return Ok(result);
342            }
343            if let Some(worker_result) = workers.take_finished_result(lane_idx) {
344                let detail = match worker_result {
345                    Ok(()) => "clean worker exit".to_string(),
346                    Err(e) => e,
347                };
348                return Err(crate::new_error!(
349                    "parallel invoke worker lane {} exited while job {} was pending: {}",
350                    lane_idx,
351                    job_id,
352                    detail
353                ));
354            }
355            thread::yield_now();
356        }
357    }
358
359    /// Bench-only: evict guest JIT caches while keeping the hot invoke worker
360    /// pool alive. The legacy raw RPC path stops workers before entering the
361    /// shared control ring; cold benchmarks call this every iteration, so using
362    /// the worker slot protocol avoids measuring worker teardown/startup.
363    ///
364    /// All lanes are reserved first. That preserves the eviction invariant: no
365    /// frame runtime can be live while image arenas and templates are dropped.
366    pub fn evict_jit_all_parallel(&self) -> Result<()> {
367        maybe_time_and_emit_guest_call("evict_jit_all_parallel", || {
368            let workers = self.ensure_invoke_workers()?;
369            let lanes = workers.acquire_all_lanes()?;
370            let Some(control_lane) = lanes.first().map(LaneLease::index) else {
371                return Ok(());
372            };
373
374            let slot = {
375                let mem_mgr = self
376                    .mem_mgr
377                    .lock()
378                    .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
379                mem_mgr.parallel_invoke_slot_host_ptr(control_lane)?
380            };
381
382            // SAFETY: `lanes` contains a live lease for every started lane, so
383            // no host invoke can use `control_lane` while this control command
384            // is in flight. The guest worker observes the ABI atomic status.
385            unsafe {
386                let status = (*slot).status.load(Ordering::Acquire);
387                if status != PARALLEL_INVOKE_STATUS_EMPTY {
388                    return Err(crate::new_error!(
389                        "parallel control lane {} was not empty before evict (status={})",
390                        control_lane,
391                        status
392                    ));
393                }
394                (*slot).job_id.store(0, Ordering::Relaxed);
395                fence(Ordering::Release);
396                (*slot)
397                    .status
398                    .store(PARALLEL_INVOKE_STATUS_EVICT_JIT_READY, Ordering::Release);
399            }
400
401            loop {
402                let done = unsafe {
403                    match (*slot).status.load(Ordering::Acquire) {
404                        PARALLEL_INVOKE_STATUS_DONE => {
405                            fence(Ordering::Acquire);
406                            (*slot)
407                                .status
408                                .store(PARALLEL_INVOKE_STATUS_EMPTY, Ordering::Release);
409                            true
410                        }
411                        PARALLEL_INVOKE_STATUS_EVICT_JIT_READY | PARALLEL_INVOKE_STATUS_RUNNING => {
412                            false
413                        }
414                        other => {
415                            return Err(crate::new_error!(
416                                "parallel control lane {} entered unexpected status {}",
417                                control_lane,
418                                other
419                            ));
420                        }
421                    }
422                };
423                if done {
424                    return Ok(());
425                }
426                if let Some(worker_result) = workers.take_finished_result(control_lane) {
427                    let detail = match worker_result {
428                        Ok(()) => "clean worker exit".to_string(),
429                        Err(e) => e,
430                    };
431                    return Err(crate::new_error!(
432                        "parallel invoke worker lane {} exited during evict_jit_all: {}",
433                        control_lane,
434                        detail
435                    ));
436                }
437                thread::yield_now();
438            }
439        })
440    }
441
442    fn ensure_invoke_workers(&self) -> Result<Arc<ParallelInvokeWorkers>> {
443        let mut guard = self
444            .invoke_workers
445            .lock()
446            .map_err(|_| crate::new_error!("parallel invoke worker mutex poisoned"))?;
447        if let Some(workers) = guard.as_ref().cloned() {
448            return Ok(workers);
449        }
450
451        let vcpu_count = self.vcpu_count()?;
452        let first_handle = self.start_invoke_worker_lane(0)?;
453        let workers = Arc::new(ParallelInvokeWorkers::new(vcpu_count, 0, first_handle));
454        *guard = Some(workers.clone());
455        Ok(workers)
456    }
457
458    fn stop_invoke_workers_locked(
459        &self,
460        guard: &mut Option<Arc<ParallelInvokeWorkers>>,
461    ) -> Result<()> {
462        let Some(workers) = guard.as_ref().cloned() else {
463            return Ok(());
464        };
465
466        let started_lanes = workers.mark_stopping_and_wait_idle()?;
467        for lane in started_lanes {
468            {
469                let mem_mgr = self
470                    .mem_mgr
471                    .lock()
472                    .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
473                mem_mgr.write_parallel_invoke_status(lane, PARALLEL_INVOKE_STATUS_STOP)?;
474            }
475            workers
476                .join_lane(lane, Duration::from_secs(5))?
477                .map_err(|e| crate::new_error!("parallel invoke worker lane {lane}: {e}"))?;
478        }
479
480        *guard = None;
481        Ok(())
482    }
483
484    fn start_invoke_worker_lane(&self, lane: usize) -> Result<InvokeWorkerHandle> {
485        let _control = self
486            .control_lock
487            .lock()
488            .map_err(|_| crate::new_error!("sandbox control mutex poisoned"))?;
489
490        {
491            let mut mem_mgr = self
492                .mem_mgr
493                .lock()
494                .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
495            mem_mgr.write_parallel_invoke_status(lane, PARALLEL_INVOKE_STATUS_STARTING)?;
496            let req = Request {
497                fn_id: FN_ID_NUB_INVOKE_WORKER,
498                payload: (lane as u32).to_le_bytes().to_vec(),
499            };
500            let req_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&req)
501                .map_err(|e| crate::new_error!("rkyv-serialize worker Request: {e}"))?;
502            mem_mgr.write_guest_function_call_raw(req_bytes.as_slice())?;
503        }
504
505        let vm = self.vm.clone();
506        let mem_mgr = self.mem_mgr.clone();
507        let host_funcs = self.host_funcs.clone();
508        let handle = thread::Builder::new()
509            .name(format!("nub-vcpu-worker-{lane}"))
510            .spawn(move || {
511                let lane = VcpuLane::new(lane);
512                vm.dispatch_call_from_host_on_shared(lane, &mem_mgr, &host_funcs)
513                    .map_err(|e| format!("dispatch worker lane {}: {e}", lane.index()))?;
514                let mut mem_mgr = mem_mgr
515                    .lock()
516                    .map_err(|_| "sandbox memory manager mutex poisoned".to_string())?;
517                let _ = mem_mgr
518                    .read_guest_function_call_result_raw()
519                    .map_err(|e| format!("read worker shutdown response: {e}"))?;
520                Ok(())
521            })
522            .map_err(|e| crate::new_error!("spawn invoke worker lane {lane}: {e}"))?;
523
524        let deadline = Instant::now() + Duration::from_secs(5);
525        loop {
526            let status = {
527                let mem_mgr = self
528                    .mem_mgr
529                    .lock()
530                    .map_err(|_| crate::new_error!("sandbox memory manager mutex poisoned"))?;
531                mem_mgr.read_parallel_invoke_status(lane)?
532            };
533            if status == PARALLEL_INVOKE_STATUS_EMPTY {
534                return Ok(handle);
535            }
536            if handle.is_finished() {
537                let worker_result = handle
538                    .join()
539                    .map_err(|_| crate::new_error!("invoke worker lane {lane} panicked"))?;
540                return Err(crate::new_error!(
541                    "invoke worker lane {} exited during startup: {}",
542                    lane,
543                    worker_result
544                        .err()
545                        .unwrap_or_else(|| "clean exit before startup handshake".to_string())
546                ));
547            }
548            if Instant::now() >= deadline {
549                return Err(crate::new_error!(
550                    "invoke worker lane {} timed out during startup (status={})",
551                    lane,
552                    status
553                ));
554            }
555            thread::yield_now();
556        }
557    }
558
559    /// Publish a [`Cap`] into the guest's heap-resident cap
560    /// directory via the [`FN_ID_NUB_PUT_CAP`] RPC.
561    ///
562    /// rkyv-encodes `cap` directly via [`rkyv::to_bytes`]; the
563    /// resulting bytes are shipped via [`Self::call_raw`] and the
564    /// guest-computed `CapHash` is read back. On the guest side, the
565    /// cap is inserted into the `nub_arch_x86::state_cache::DIRECTORY`
566    /// map, keyed by hash.
567    ///
568    /// Caps whose graph still holds a `CapHashOrRef::Ref` target
569    /// (cache-local lifetime handles with no resolution on the
570    /// receive side) fail at rkyv-encode with a typed
571    /// [`CapHasRefError`](javm_cap::CapHasRefError) wrapped in the
572    /// rancor error chain. Other encode/decode failures are surfaced
573    /// as `HyperlightError::Error`. A sentinel response (all-`0xFF`
574    /// hash) from the guest is also turned into an error.
575    pub fn put_cap(&self, cap: &Cap) -> Result<AbiCapHash> {
576        let cap_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(cap)
577            .map_err(|e| crate::new_error!("put_cap: rkyv encode (or Ref present): {e}"))?;
578        let resp = self.call_raw(FN_ID_NUB_PUT_CAP, cap_bytes.as_slice())?;
579        if resp.len() != 32 {
580            return Err(crate::new_error!(
581                "put_cap: expected 32-byte hash response, got {}",
582                resp.len()
583            ));
584        }
585        let mut hash: AbiCapHash = [0u8; 32];
586        hash.copy_from_slice(&resp);
587        // Guest's `nub_put_cap` returns `0xFF * 32` on decode/conv
588        // failure. Surface as a typed error so callers don't observe
589        // a fake hash.
590        if hash == [0xFFu8; 32] {
591            return Err(crate::new_error!(
592                "put_cap: guest reported decode/conversion failure (sentinel response)"
593            ));
594        }
595        Ok(hash)
596    }
597
598    /// Pre-hashed put: idempotent fast path that short-circuits the
599    /// full [`Self::put_cap`] RPC when this sandbox has already
600    /// published `hash`.
601    ///
602    /// Behaviour:
603    ///
604    /// - If `hash` is in the host-side `published_blobs` set,
605    ///   return immediately — we already shipped this cap and the blobs
606    ///   tier never evicts, so the guest still holds it. We skip the
607    ///   rkyv encode + VMEXIT + guest decode + merkle walk + directory
608    ///   insert. This is the hot path for bench loops that re-publish
609    ///   the same cap graph every iteration.
610    /// - Otherwise, ship `put_cap(cap)`, debug-assert the returned hash
611    ///   matches `hash`, and record it.
612    ///
613    /// We deliberately do **not** check the guest's directory directly:
614    /// the guest's `CacheDirectory` is a hashbrown table built with a
615    /// different SIMD `Group` width than the host's hashbrown (see
616    /// `published_blobs`), so a host-side deref of it is unsound.
617    pub fn put_cap_with_hash(&self, hash: AbiCapHash, cap: &Cap) -> Result<()> {
618        {
619            let published_blobs = self
620                .published_blobs
621                .lock()
622                .map_err(|_| crate::new_error!("published blob set mutex poisoned"))?;
623            if published_blobs.contains(&hash) {
624                return Ok(());
625            }
626        }
627
628        let got = self.put_cap(cap)?;
629        debug_assert_eq!(
630            got, hash,
631            "put_cap_with_hash: guest-computed hash differs from claimed hash"
632        );
633
634        self.published_blobs
635            .lock()
636            .map_err(|_| crate::new_error!("published blob set mutex poisoned"))?
637            .insert(hash);
638        Ok(())
639    }
640
641    /// Returns a handle for interrupting guest execution.
642    pub fn interrupt_handle(&self) -> Arc<dyn InterruptHandle> {
643        self.vm.interrupt_handle()
644    }
645}
646
647type InvokeWorkerResult = std::result::Result<(), String>;
648type InvokeWorkerHandle = JoinHandle<InvokeWorkerResult>;
649
650struct ParallelInvokeWorkers {
651    lane_count: usize,
652    state: Mutex<ParallelInvokeWorkerState>,
653    ready: Condvar,
654    handles: Mutex<Vec<Option<InvokeWorkerHandle>>>,
655}
656
657struct ParallelInvokeWorkerState {
658    available: Vec<usize>,
659    started: Vec<bool>,
660    stopping: bool,
661}
662
663struct LaneLease {
664    lane: usize,
665    workers: Arc<ParallelInvokeWorkers>,
666}
667
668impl ParallelInvokeWorkers {
669    fn new(lane_count: usize, first_lane: usize, first_handle: InvokeWorkerHandle) -> Self {
670        let mut handles = Vec::with_capacity(lane_count);
671        handles.resize_with(lane_count, || None);
672        handles[first_lane] = Some(first_handle);
673        let mut started = vec![false; lane_count];
674        started[first_lane] = true;
675        Self {
676            lane_count,
677            state: Mutex::new(ParallelInvokeWorkerState {
678                available: vec![first_lane],
679                started,
680                stopping: false,
681            }),
682            ready: Condvar::new(),
683            handles: Mutex::new(handles),
684        }
685    }
686
687    fn try_acquire_lane(self: &Arc<Self>) -> Result<Option<LaneLease>> {
688        let mut state = self
689            .state
690            .lock()
691            .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
692        if state.stopping {
693            return Ok(None);
694        }
695        Ok(state.available.pop().map(|lane| LaneLease {
696            lane,
697            workers: self.clone(),
698        }))
699    }
700
701    fn acquire_lane(self: &Arc<Self>) -> Result<Option<LaneLease>> {
702        let mut state = self
703            .state
704            .lock()
705            .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
706        loop {
707            if state.stopping {
708                return Ok(None);
709            }
710            if let Some(lane) = state.available.pop() {
711                return Ok(Some(LaneLease {
712                    lane,
713                    workers: self.clone(),
714                }));
715            }
716            state = self
717                .ready
718                .wait(state)
719                .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
720        }
721    }
722
723    fn reserve_unstarted_lane(&self) -> Result<Option<usize>> {
724        let mut state = self
725            .state
726            .lock()
727            .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
728        if state.stopping {
729            return Ok(None);
730        }
731        for lane in 0..self.lane_count {
732            if !state.started[lane] {
733                state.started[lane] = true;
734                return Ok(Some(lane));
735            }
736        }
737        Ok(None)
738    }
739
740    fn install_started_lane(&self, lane: usize, handle: InvokeWorkerHandle) -> Result<()> {
741        {
742            let mut handles = self
743                .handles
744                .lock()
745                .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
746            if lane >= handles.len() || handles[lane].is_some() {
747                return Err(crate::new_error!(
748                    "parallel invoke worker lane {} already has a handle",
749                    lane
750                ));
751            }
752            handles[lane] = Some(handle);
753        }
754        self.release_lane(lane);
755        Ok(())
756    }
757
758    fn release_start_reservation(&self, lane: usize) -> Result<()> {
759        let mut state = self
760            .state
761            .lock()
762            .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
763        if lane < state.started.len() {
764            state.started[lane] = false;
765        }
766        self.ready.notify_all();
767        Ok(())
768    }
769
770    fn acquire_all_lanes(self: &Arc<Self>) -> Result<Vec<LaneLease>> {
771        let mut state = self
772            .state
773            .lock()
774            .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
775        loop {
776            if state.stopping {
777                return Err(crate::new_error!("parallel invoke workers are stopping"));
778            }
779            let started_count = state.started.iter().filter(|&&started| started).count();
780            if state.available.len() == started_count {
781                return Ok(state
782                    .available
783                    .drain(..)
784                    .map(|lane| LaneLease {
785                        lane,
786                        workers: self.clone(),
787                    })
788                    .collect());
789            }
790            state = self
791                .ready
792                .wait(state)
793                .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
794        }
795    }
796
797    fn release_lane(&self, lane: usize) {
798        let mut state = self.state.lock().expect("parallel worker mutex poisoned");
799        state.available.push(lane);
800        self.ready.notify_all();
801    }
802
803    fn mark_stopping_and_wait_idle(&self) -> Result<Vec<usize>> {
804        let mut state = self
805            .state
806            .lock()
807            .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
808        state.stopping = true;
809        self.ready.notify_all();
810        let started_count = state.started.iter().filter(|&&started| started).count();
811        while state.available.len() != started_count {
812            state = self
813                .ready
814                .wait(state)
815                .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?;
816        }
817        Ok(state
818            .started
819            .iter()
820            .enumerate()
821            .filter_map(|(lane, &started)| started.then_some(lane))
822            .collect())
823    }
824
825    fn take_finished_result(&self, lane: usize) -> Option<InvokeWorkerResult> {
826        let mut handles = self.handles.lock().expect("parallel worker mutex poisoned");
827        let handle = handles.get_mut(lane)?.take_if(|h| h.is_finished())?;
828        Some(match handle.join() {
829            Ok(result) => result,
830            Err(_) => Err("worker thread panicked".to_string()),
831        })
832    }
833
834    fn join_lane(&self, lane: usize, timeout: Duration) -> Result<InvokeWorkerResult> {
835        let handle = self
836            .handles
837            .lock()
838            .map_err(|_| crate::new_error!("parallel worker mutex poisoned"))?
839            .get_mut(lane)
840            .and_then(Option::take);
841        let Some(handle) = handle else {
842            return Ok(Ok(()));
843        };
844        join_invoke_worker_handle(lane, handle, timeout)
845    }
846}
847
848fn join_invoke_worker_handle(
849    lane: usize,
850    handle: InvokeWorkerHandle,
851    timeout: Duration,
852) -> Result<InvokeWorkerResult> {
853    let deadline = Instant::now() + timeout;
854    while !handle.is_finished() {
855        if Instant::now() >= deadline {
856            return Err(crate::new_error!(
857                "parallel invoke worker lane {} timed out during stop",
858                lane
859            ));
860        }
861        thread::yield_now();
862    }
863
864    Ok(match handle.join() {
865        Ok(result) => result,
866        Err(_) => Err("worker thread panicked".to_string()),
867    })
868}
869
870impl LaneLease {
871    fn index(&self) -> usize {
872        self.lane
873    }
874}
875
876impl Drop for LaneLease {
877    fn drop(&mut self) {
878        self.workers.release_lane(self.lane);
879    }
880}
881
882impl std::fmt::Debug for MultiUseSandbox {
883    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
884        f.debug_struct("MultiUseSandbox").finish()
885    }
886}