Skip to main content

javm/
vm.rs

1//! The v3 `Vm` driver.
2//!
3//! Composes:
4//! - The call stack (`crate::callstack::CallStack`).
5//! - The kernel-assist hook (`crate::kernel_assist::KernelAssist`).
6//! - The image bytecode cache (`crate::image_cache::ImageCache`).
7//!
8//! Top-level verbs:
9//! - [`Vm::invoke_cached`] — resolve an Instance hash from a caller-
10//!   supplied `CacheDirectory`, push a working `InstanceEntry`, drive
11//!   `javm_exec::Interpreter::run` to completion, return a
12//!   [`CallResult`]. The cache holds the Cap::Instance + Cap::Image
13//!   content; the Vm holds only the call-stack-side working copy and
14//!   ephemeral kernel state.
15//! - [`Vm::call_resume`] — resume a Paused stack after a yield.
16//!
17//! The CacheDirectory is borrowed per invocation (not owned by the Vm) so the
18//! same cache can serve both pre-publish (the caller publishes caps
19//! into it) and in-flight resolution (host calls read referenced caps
20//! by their `CapHashOrRef` target).
21
22use javm_cap::{CacheDirectory, Cap, CapHash, CapHashOrRef, SlotIdx};
23use javm_exec::{Access, CopyingMemory, ExitReason, GasCounter, Interpreter, Mem, Regs};
24
25use crate::callstack::{CallStack, DEFAULT_MAX_DEPTH, Entry, EntryStatus, InstanceEntry};
26use crate::ecall::{CachedEcallHandler, host_op};
27use crate::error::VmError;
28use crate::image_cache::ImageCache;
29use crate::kernel_assist::{KernelAssist, KernelImage, kernel_image_hash};
30
31/// Result of a top-level `invoke_cached` / `call_resume`.
32///
33/// Mirrors v3 spec §5 "Apply terminations":
34/// - `Halt`: REPLY-style termination; `return_value = φ[7]`.
35/// - `Faulted`: Trap / Panic / PageFault / OOG hard-fault.
36/// - `Paused`: yielded.
37#[derive(Debug)]
38pub enum CallResult {
39    Halt {
40        /// φ\[7\] (A0) at REPLY time.
41        return_value: u64,
42        /// Settled hash of the post-HALT Instance state. Identifies a
43        /// fresh `Cap::Instance` blob in the cache.
44        post_instance_hash: CapHash,
45        /// The reflected slot\[0\] target (target's slot\[0\] at HALT).
46        /// `None` if the slot was empty.
47        reflected_slot0: Option<CapHashOrRef>,
48        /// Gas consumed by the apply.
49        gas_used: u64,
50    },
51    Faulted {
52        reason: ExitReason,
53        /// Reflected slot\[0\] target at fault point.
54        reflected_slot0: Option<CapHashOrRef>,
55        gas_used: u64,
56    },
57    Paused {
58        /// Marker payload — the cap target read from the yielding
59        /// Instance's marker slot at yield time.
60        marker_payload: Option<CapHashOrRef>,
61        gas_used: u64,
62    },
63}
64
65/// The v3 VM driver. Parameterized over a `KernelAssist` impl so the
66/// integration crate can be tested with the in-process default while
67/// jar-kernel-v3 swaps in a σ-aware implementation.
68pub struct Vm<K: KernelAssist> {
69    pub stack: CallStack,
70    pub kernel_assist: K,
71    pub image_cache: ImageCache,
72}
73
74impl<K: KernelAssist> Vm<K> {
75    pub fn new(kernel_assist: K) -> Self {
76        Self::with_max_depth(kernel_assist, DEFAULT_MAX_DEPTH)
77    }
78
79    pub fn with_max_depth(kernel_assist: K, max_depth: usize) -> Self {
80        Self {
81            stack: CallStack::new(max_depth),
82            kernel_assist,
83            image_cache: ImageCache::new(),
84        }
85    }
86
87    /// CacheDirectory-driven entry point: look up a published `Cap::Instance`
88    /// in `cache` by hash, pull its referenced `Cap::Image` from the
89    /// same cache, predecode bytecode (cached by `image_hash`), seed
90    /// regs + memory + gas, push a working `InstanceEntry`, drive the
91    /// interpreter to a termination.
92    ///
93    /// The cache stays caller-owned and is borrowed for the duration
94    /// of the call (host calls walk back through it to resolve nested
95    /// cap targets).
96    pub fn invoke_cached(
97        &mut self,
98        cache: &mut CacheDirectory,
99        instance_hash: CapHash,
100        endpoint_idx: u8,
101        args: [u64; 4],
102        gas_budget: u64,
103    ) -> Result<CallResult, VmError> {
104        // 1. Resolve the Cap::Instance + Cap::Image from the cache and
105        //    capture the predecode-relevant bits up front so we can
106        //    release the borrow before mutating cache via call paths.
107        let (entry, mem, regs, gas, gas_initial) = self.build_entry(
108            cache,
109            CapHashOrRef::Hash(instance_hash),
110            endpoint_idx,
111            args,
112            gas_budget,
113        )?;
114
115        // 2. Push and drive.
116        let pushed_pos = self.stack.entries().len();
117        self.stack.push_instance(entry)?;
118        self.drive_and_translate(cache, regs, mem, gas, gas_initial, pushed_pos)
119    }
120
121    /// Build an [`InstanceEntry`] + initial registers/memory/gas from
122    /// the published cap at `inst_ref`. Used by both `invoke_cached`
123    /// and `derive_spawn`/host_call paths that need to push a child
124    /// instance.
125    pub(crate) fn build_entry(
126        &mut self,
127        cache: &CacheDirectory,
128        inst_ref: CapHashOrRef,
129        endpoint_idx: u8,
130        args: [u64; 4],
131        gas_budget: u64,
132    ) -> Result<(InstanceEntry, Mem, Regs, GasCounter, u64), VmError> {
133        let instance_cap = cache
134            .get(inst_ref.clone())
135            .ok_or(VmError::InstanceNotFound)?;
136        let inst = match &*instance_cap {
137            Cap::Instance(i) => i.clone(),
138            _ => return Err(VmError::InstanceNotFound),
139        };
140        let image_cap = cache
141            .get(CapHashOrRef::Hash(inst.image_hash))
142            .ok_or(VmError::ImageNotFound)?;
143        let img = match &*image_cap {
144            Cap::Image(i) => i.clone(),
145            _ => return Err(VmError::ImageNotFound),
146        };
147
148        // Snapshot the working root cnode.
149        let root_cnode_cap = cache
150            .get(inst.root_cnode.clone())
151            .ok_or(VmError::Invariant("instance root_cnode missing in cache"))?;
152        let root_cnode = match &*root_cnode_cap {
153            Cap::CNode(cn) => cn.clone(),
154            _ => {
155                return Err(VmError::Invariant(
156                    "root_cnode does not point at Cap::CNode",
157                ));
158            }
159        };
160
161        // Predecode the image bytecode (cache hit when seen before).
162        let unpacked_bitmask = javm_exec::unpack_bitmask(img.bitmask.as_slice(), img.code.len());
163        let program = self.image_cache.get_or_decode(
164            inst.image_hash,
165            img.code.as_slice().to_vec(),
166            unpacked_bitmask,
167            img.jump_table.as_slice().to_vec(),
168        )?;
169
170        // Locate the endpoint definition (dense array, sentinel =
171        // entry_pc == 0).
172        let endpoint = img
173            .endpoints
174            .get(endpoint_idx as usize)
175            .ok_or(VmError::Invariant("endpoint index out of range"))?;
176
177        // Memory layout: base RW region sized to instance.mem_size,
178        // plus per-overlay regions.
179        let mut mem = CopyingMemory::new();
180        let mem_size_pages = page_round_up_u64(inst.mem_size as u64);
181        if mem_size_pages > 0 {
182            mem.map_region(0, mem_size_pages, Access::ReadWrite, None)
183                .map_err(VmError::MapRegion)?;
184        }
185        for overlay_entry in inst.rw_overlays.iter() {
186            overlay_into(
187                &mut mem,
188                overlay_entry.start,
189                overlay_entry.bytes.as_slice(),
190                Access::ReadWrite,
191            )?;
192        }
193
194        // Regs: endpoint baseline → instance persisted regs (non-zero
195        // wins) → caller args at φ[7..=10].
196        let mut regs = Regs::new();
197        regs.pc = endpoint.entry_pc;
198        regs.gpr = endpoint.initial_regs;
199        for (i, v) in inst.regs.iter().enumerate() {
200            if *v != 0 {
201                regs.gpr[i] = *v;
202            }
203        }
204        for (i, v) in args.iter().enumerate() {
205            regs.gpr[7 + i] = *v;
206        }
207
208        // Gas counter seeded directly from gas_budget. (Gas slot
209        // tracking on the Image moved to InstanceCap.gas_remaining in
210        // the new model; tests can still observe per-call totals via
211        // the local counter.)
212        let gas = GasCounter::new(gas_budget);
213        let gas_initial = gas_budget;
214
215        // CacheDirectory image-side metadata on the entry for fast host-call
216        // lookups (pinned check, yield routing).
217        let pinned_slots: Vec<SlotIdx> = img.pinned.iter().map(|e| e.slot).collect();
218
219        let entry = InstanceEntry {
220            instance_ref: inst_ref,
221            image_hash_chain: inst.image_hash_chain,
222            image_hash: inst.image_hash,
223            program,
224            root_cnode,
225            yield_marker_slot: img.yield_marker_slot,
226            pinned_slots,
227            regs: Regs::new(),       // placeholder; live regs are in `regs`
228            mem: Mem::new(),         // placeholder
229            gas: GasCounter::new(0), // placeholder
230            status: EntryStatus::Waiting,
231        };
232        Ok((entry, mem, regs, gas, gas_initial))
233    }
234
235    /// Resume the top `ReferenceEntry`: pop it, re-enter the
236    /// interpreter on the InstanceEntry it points at (which already
237    /// has its saved regs/mem/gas from the yield site), and translate
238    /// the next termination.
239    ///
240    /// Optionally reflects `scratchpad` into the resumed Instance's
241    /// slot\[0\] before re-entering — the spec's CALL_RESUME(payload)
242    /// pattern.
243    ///
244    /// Errors:
245    /// - `VmError::Invariant` if the top isn't a `ReferenceEntry`.
246    /// - `VmError::CallStackEmpty` if the resolved target Instance is
247    ///   missing.
248    pub fn call_resume(
249        &mut self,
250        cache: &mut CacheDirectory,
251        scratchpad: Option<CapHashOrRef>,
252    ) -> Result<CallResult, VmError> {
253        // 1. Verify and pop the top ReferenceEntry.
254        match self.stack.running() {
255            Some(Entry::Reference(_)) => {}
256            _ => {
257                return Err(VmError::Invariant(
258                    "call_resume: top is not a ReferenceEntry",
259                ));
260            }
261        }
262        self.stack.pop().ok_or(VmError::CallStackEmpty)?;
263
264        // 2. Find the now-running InstanceEntry's position; reflect
265        //    scratchpad into its slot[0] if supplied.
266        let pos = self.stack.entries().len() - 1;
267        if let Some(target) = scratchpad {
268            let inst = self
269                .stack
270                .running_instance_mut()
271                .ok_or(VmError::Invariant("call_resume: no instance after pop"))?;
272            inst.root_cnode.set(SlotIdx(0), Some(target))?;
273        }
274
275        // 3. Take the resumed Instance's saved regs/mem/gas out into
276        //    locals (replacing with placeholders) for driving the
277        //    interpreter.
278        let (regs, mem, gas, gas_initial) = {
279            let target = self
280                .stack
281                .running_instance_mut()
282                .ok_or(VmError::Invariant("call_resume: no instance"))?;
283            let regs = core::mem::replace(&mut target.regs, Regs::new());
284            let mem = core::mem::replace(&mut target.mem, Mem::new());
285            let gas = core::mem::replace(&mut target.gas, GasCounter::new(0));
286            let gas_initial = gas.remaining();
287            (regs, mem, gas, gas_initial)
288        };
289
290        self.drive_and_translate(cache, regs, mem, gas, gas_initial, pos)
291    }
292
293    /// Stub for DROP_PAUSED. Lands with the σ-resident Paused state
294    /// machine (Stage 4).
295    pub fn drop_paused(&mut self, _target_slot: javm_cap::SlotPath) -> Result<(), VmError> {
296        Err(VmError::Invariant(
297            "DROP_PAUSED requires σ-resident Paused state (Stage 4)",
298        ))
299    }
300
301    /// Drive `Interpreter::run` on the InstanceEntry at `pushed_pos`
302    /// using the supplied regs/mem/gas, then translate the
303    /// termination.
304    ///
305    /// Paused-on-yield is detected by a structural side-effect:
306    /// `host_yield` (Stage 3.8) pushes a `ReferenceEntry` on top of
307    /// the yielder. After `Interpreter::run` returns, if the stack is
308    /// taller than `pushed_pos + 1`, a yield occurred — leave the
309    /// stack in that shape and return `CallResult::Paused`. Otherwise
310    /// pop the entry and translate Halt / Fault as usual.
311    fn drive_and_translate(
312        &mut self,
313        cache: &mut CacheDirectory,
314        mut regs: Regs,
315        mut mem: Mem,
316        mut gas: GasCounter,
317        gas_initial: u64,
318        pushed_pos: usize,
319    ) -> Result<CallResult, VmError> {
320        // `cur_pos` tracks which InstanceEntry the interpreter is
321        // driving right now. Starts at `pushed_pos` (the entry
322        // `invoke_cached`/`call_resume` pushed); grows by 1 on each
323        // nested HOST_CALL, shrinks by 1 on each nested HALT. The
324        // loop exits when the entry at `pushed_pos` itself terminates
325        // (Halt/Fault) or yields (host_yield pushes a ReferenceEntry
326        // above us — detected post-loop).
327        let mut cur_pos = pushed_pos;
328        let exit = loop {
329            let program = match &self.stack.entries()[cur_pos] {
330                Entry::Instance(e) => e.program.clone(),
331                _ => return Err(VmError::Invariant("cur_pos points at non-Instance")),
332            };
333            let mut handler = CachedEcallHandler { vm: self, cache };
334            let exit = Interpreter::run(
335                program.as_ref(),
336                &mut regs,
337                &mut mem,
338                &mut gas,
339                &mut handler,
340            );
341
342            // SET_IMAGE re-entry: the running entry's image+program
343            // were swapped by `dispatch_set_image_cached`; re-enter
344            // `Interpreter::run` on the same frame with the same
345            // live state.
346            if matches!(exit, ExitReason::HostCall(op) if op == host_op::SET_IMAGE) {
347                continue;
348            }
349
350            // HOST_CALL push: `dispatch_host_call_cached` pushed a
351            // child InstanceEntry above us. Save the parent's live
352            // regs/mem into its entry so we can restore them when the
353            // child halts; take the child's stashed initial state.
354            // Gas stays threaded (shared pool).
355            if matches!(exit, ExitReason::HostCall(op) if op == host_op::HOST_CALL) {
356                if self.stack.entries().len() != cur_pos + 2 {
357                    return Err(VmError::Invariant(
358                        "HOST_CALL exit without expected child push",
359                    ));
360                }
361                if let Some(Entry::Instance(parent)) = self.stack.entries_mut().get_mut(cur_pos) {
362                    parent.regs = regs;
363                    parent.mem = mem;
364                }
365                cur_pos += 1;
366                let child = self
367                    .stack
368                    .running_instance_mut()
369                    .ok_or(VmError::Invariant("no child after HOST_CALL push"))?;
370                regs = core::mem::replace(&mut child.regs, Regs::new());
371                mem = core::mem::replace(&mut child.mem, Mem::new());
372                continue;
373            }
374
375            // Nested HALT: a child halted while a parent waits below.
376            // Take the child's slot[0] (the spec's reflected
377            // scratchpad), pop the child, restore the parent's
378            // regs/mem, plant slot[0] in the parent.
379            if matches!(exit, ExitReason::Halt) && cur_pos > pushed_pos {
380                let child_slot0 = self
381                    .stack
382                    .running_instance_mut()
383                    .ok_or(VmError::Invariant("no child to halt"))?
384                    .root_cnode
385                    .take(SlotIdx(0))
386                    .ok()
387                    .flatten();
388                self.stack.pop();
389                cur_pos -= 1;
390                let parent = self
391                    .stack
392                    .running_instance_mut()
393                    .ok_or(VmError::Invariant("parent gone after child pop"))?;
394                regs = core::mem::replace(&mut parent.regs, Regs::new());
395                mem = core::mem::replace(&mut parent.mem, Mem::new());
396                if let Some(s0) = child_slot0 {
397                    parent.root_cnode.set(SlotIdx(0), Some(s0))?;
398                }
399                continue;
400            }
401
402            break exit;
403        };
404
405        let gas_used = gas_initial.saturating_sub(gas.remaining());
406
407        // OOG: reconcile the meter to 0 and try to route a synthetic
408        // OogMarker yield. On match the stack grows (push_reference)
409        // and `oog_marker_payload` carries the `Gas{meter_id}` cap
410        // target that the catcher receives as its payload. On no
411        // match, leave the stack untouched and let the Faulted arm
412        // handle it as a hard OOG.
413        let oog_marker_payload = if matches!(exit, ExitReason::OutOfGas) {
414            self.reconcile_and_route_oog(pushed_pos)
415        } else {
416            None
417        };
418
419        // Did host_yield (or OOG routing) push a ReferenceEntry above us?
420        let yielded = self.stack.entries().len() > pushed_pos + 1
421            && matches!(self.stack.running(), Some(Entry::Reference(_)));
422
423        if yielded {
424            // Read marker payload. For a synthetic OOG yield, the
425            // payload is the Gas{meter_id} cap. For ordinary
426            // host_yield, read from the yielder's slot referenced by
427            // φ[7] at yield time.
428            let marker_payload = if let Some(p) = oog_marker_payload {
429                Some(p)
430            } else {
431                let marker_slot = SlotIdx((regs.gpr[7] & 0xFF) as u32);
432                let yielder = match &self.stack.entries()[pushed_pos] {
433                    Entry::Instance(e) => e.as_ref(),
434                    _ => return Err(VmError::Invariant("yielder is not an Instance")),
435                };
436                yielder.root_cnode.get(marker_slot)
437            };
438
439            // Save live state back into the yielder InstanceEntry.
440            let yielder = match &mut self.stack.entries_mut()[pushed_pos] {
441                Entry::Instance(e) => e.as_mut(),
442                _ => return Err(VmError::Invariant("yielder is not an Instance")),
443            };
444            yielder.regs = regs;
445            yielder.mem = mem;
446            yielder.gas = gas;
447
448            return Ok(CallResult::Paused {
449                marker_payload,
450                gas_used,
451            });
452        }
453
454        // Halt / Fault path: top of stack is the InstanceEntry we drove.
455        if let Some(top) = self.stack.running_instance_mut() {
456            top.regs = regs;
457            top.mem = mem;
458            top.gas = gas;
459        }
460
461        // Pop the running entry. We need to compute the post-instance
462        // hash by settling the working state back into the cache;
463        // this captures the cnode + overlays as a fresh blob.
464        let popped = self
465            .stack
466            .pop()
467            .ok_or(VmError::Invariant("stack empty after Interpreter::run"))?;
468
469        let (entry, slot0_target) = match popped {
470            Entry::Instance(e) => {
471                let mut e = *e;
472                let slot0 = e.root_cnode.take(SlotIdx(0)).ok().flatten();
473                (e, slot0)
474            }
475            _ => return Err(VmError::Invariant("popped a non-Instance entry")),
476        };
477
478        let post_instance_hash = if matches!(exit, ExitReason::Halt) {
479            self.settle_post_instance(cache, &entry)?
480        } else {
481            // Non-Halt terminations don't produce a fresh published
482            // post-instance; surface the original hash if it was
483            // hash-resolved (else zero).
484            match entry.instance_ref {
485                CapHashOrRef::Hash(h) => h,
486                CapHashOrRef::Ref(_) => [0u8; 32],
487            }
488        };
489
490        Ok(match exit {
491            ExitReason::Halt => CallResult::Halt {
492                return_value: entry.regs.gpr[7],
493                post_instance_hash,
494                reflected_slot0: slot0_target,
495                gas_used,
496            },
497            ExitReason::HostCall(_) | ExitReason::Ecall => CallResult::Paused {
498                marker_payload: slot0_target,
499                gas_used,
500            },
501            ExitReason::Trap
502            | ExitReason::Panic
503            | ExitReason::OutOfGas
504            | ExitReason::PageFault(_) => CallResult::Faulted {
505                reason: exit,
506                reflected_slot0: slot0_target,
507                gas_used,
508            },
509        })
510    }
511
512    /// Publish the post-HALT working state of `entry` back into the
513    /// cache as a fresh Cap::Instance blob. Returns the new hash. The
514    /// new entry references the same image and a freshly-published
515    /// cnode (so the cache stores the cnode snapshot too).
516    fn settle_post_instance(
517        &mut self,
518        cache: &mut CacheDirectory,
519        entry: &InstanceEntry,
520    ) -> Result<CapHash, VmError> {
521        // Build the working cnode as a Cap<Global> and put it. We only
522        // flatten the materialized entries; unmaterialized (`Missing`)
523        // slots aren't valid mid-execution and shouldn't appear here.
524        let cnode_hash = {
525            let mut cnode = javm_cap::CNodeCap::new(entry.root_cnode.size_log)?;
526            for (idx, mo) in entry.root_cnode.slots.iter() {
527                if let ssz::MissingOr::Materialized(t) = mo {
528                    cnode.set(SlotIdx(idx as u32), Some(t.clone()))?;
529                }
530            }
531            cache.put_cap(&Cap::CNode(cnode))?
532        };
533
534        // Collect rw_overlay bytes from the live mem. We don't have
535        // first-class knowledge of which mappings count as overlays
536        // post-halt; for V1 we simply read each mapping at its
537        // declared start/size from the image. Image references stay
538        // valid as long as the cap is in the cache.
539        let image_cap = cache
540            .get(CapHashOrRef::Hash(entry.image_hash))
541            .ok_or(VmError::ImageNotFound)?;
542        let img = match &*image_cap {
543            Cap::Image(i) => i.clone(),
544            _ => return Err(VmError::ImageNotFound),
545        };
546        let mut overlay_bufs: Vec<(u32, Vec<u8>)> = Vec::new();
547        for m in img.mappings.iter() {
548            // V1: snapshot the live mem [start, start + size) into an
549            // overlay buffer if the read succeeds.
550            let start = m.start as u32;
551            let len = m.size as usize;
552            if let Ok(bytes) = entry.mem.read(start, len) {
553                overlay_bufs.push((start, bytes));
554            }
555        }
556        let overlays_borrowed: Vec<(u32, &[u8])> = overlay_bufs
557            .iter()
558            .map(|(s, b)| (*s, b.as_slice()))
559            .collect();
560
561        let mem_size = if let Some(last) = img.mappings.last() {
562            (last.start + last.size) as u32
563        } else {
564            0
565        };
566
567        let hash = cache.put_cap(&Cap::instance_with_overlays(
568            entry.image_hash_chain,
569            entry.image_hash,
570            cnode_hash,
571            &overlays_borrowed,
572            mem_size,
573            entry.regs.gpr,
574            entry.regs.pc,
575            entry.gas.remaining(),
576        ))?;
577        Ok(hash)
578    }
579
580    /// Reconcile the gas meter to 0 (the local counter has just been
581    /// exhausted) and attempt to route a synthetic OogMarker yield
582    /// through the call stack. On match: push a ReferenceEntry at
583    /// the catcher's position and return the Gas{meter_id} cap from
584    /// the yielder's gas-cap slot (the marker_payload). On no match:
585    /// return None — caller surfaces this as a hard fault.
586    fn reconcile_and_route_oog(&mut self, yielder_pos: usize) -> Option<CapHashOrRef> {
587        // 1. Find the Gas{meter_id} cap target. In the new model we
588        //    don't have an image-declared "gas_slot"; the meter id
589        //    convention is encoded directly on the Instance's
590        //    persisted regs[12] (placeholder convention) OR not at
591        //    all. For V1 we route OOG only when the catcher chain
592        //    explicitly registers the OogMarker hash.
593        let oog_hash = kernel_image_hash(KernelImage::OogMarker);
594
595        // 2. Walk stack top→bottom (skipping the yielder itself) for
596        //    a YieldCatcher catching the OogMarker hash.
597        let stack_len = self.stack.entries().len();
598        let mut target_pos: Option<usize> = None;
599        for pos in (0..yielder_pos).rev() {
600            let ie = match &self.stack.entries()[pos] {
601                Entry::Instance(ie) => ie.as_ref(),
602                Entry::Reference(_) => continue,
603            };
604            let Some(catcher_slot) = ie.yield_marker_slot else {
605                continue;
606            };
607            // Catcher hash is the image_hash_chain at the catcher
608            // slot. Per the legacy model we looked up Cap::Instance;
609            // here we just key on the slot target's hash form
610            // (CapHashOrRef::Hash) — that's the marker template hash.
611            let catcher_hash = match ie.root_cnode.get(catcher_slot) {
612                Some(CapHashOrRef::Hash(h)) => h,
613                _ => continue,
614            };
615            let markers = self.kernel_assist.yield_catcher_markers(catcher_hash);
616            if markers.contains(&oog_hash) {
617                target_pos = Some(pos);
618                break;
619            }
620        }
621
622        // 3. On match, push the reference. The marker payload is the
623        //    well-known oog_hash itself (carried as Hash form).
624        let _ = stack_len;
625        match target_pos {
626            Some(pos) => {
627                self.stack.push_reference(pos).ok()?;
628                Some(CapHashOrRef::Hash(oog_hash))
629            }
630            None => None,
631        }
632    }
633}
634
635/// Round up a u64 byte count to PAGE_SIZE granularity.
636fn page_round_up_u64(n: u64) -> u64 {
637    let p = javm_exec::PAGE_SIZE as u64;
638    n.div_ceil(p) * p
639}
640
641/// Lay `data` into mem at `start` with `access`, page-rounding the
642/// size. No-op if `data` is empty.
643fn overlay_into(
644    mem: &mut CopyingMemory,
645    start: u32,
646    data: &[u8],
647    access: Access,
648) -> Result<(), VmError> {
649    if data.is_empty() {
650        return Ok(());
651    }
652    let size = page_round_up_u64(data.len() as u64);
653    mem.map_region(start as u64, size, access, Some(data))
654        .map_err(VmError::MapRegion)
655}
656
657impl<K: KernelAssist + std::fmt::Debug> std::fmt::Debug for Vm<K> {
658    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
659        f.debug_struct("Vm")
660            .field("stack", &self.stack)
661            .field("kernel_assist", &self.kernel_assist)
662            .field("image_cache_len", &self.image_cache.len())
663            .finish()
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use crate::kernel_assist::InProcessKernelAssist;
671    use javm_cap::image::Image;
672    use javm_cap::{CacheDirectory, Cap, NUM_REGS};
673    use std::collections::BTreeMap;
674
675    fn empty_image_with_code(code: Vec<u8>) -> Image {
676        let packed_bitmask = vec![0xFFu8; code.len().div_ceil(8)];
677        Image {
678            code,
679            packed_bitmask,
680            jump_table: Vec::new(),
681            endpoints: BTreeMap::new(),
682            memory_mappings: Vec::new(),
683            gas_slots: Vec::new(),
684            quota_slots: Vec::new(),
685            pinned_slots: BTreeMap::new(),
686            initial_slots: BTreeMap::new(),
687            yield_marker_slot: None,
688        }
689    }
690
691    /// Publish an Image + empty root cnode + a Cap::Instance referencing
692    /// them; return the instance hash and the cache.
693    fn publish_simple_instance(cache: &mut CacheDirectory, image: Image) -> CapHash {
694        let image_hash = cache
695            .put_cap(&Cap::image_with_slots(&image, &[], &[]).unwrap())
696            .unwrap();
697        let cnode_hash = cache.put_cap(&Cap::empty_cnode(8).unwrap()).unwrap();
698        cache
699            .put_cap(&Cap::instance_with_overlays(
700                [0xAA; 32],
701                image_hash,
702                cnode_hash,
703                &[],
704                0,
705                [0u64; NUM_REGS],
706                0,
707                0,
708            ))
709            .unwrap()
710    }
711
712    #[test]
713    fn new_constructs_empty_vm() {
714        let vm = Vm::new(InProcessKernelAssist::new());
715        assert!(vm.stack.is_empty());
716        assert!(vm.image_cache.is_empty());
717    }
718
719    #[test]
720    fn invoke_cached_trap_returns_faulted() {
721        // code = [trap (0)]
722        let img = empty_image_with_code(vec![0u8]);
723        let mut cache = CacheDirectory::new();
724        let inst_hash = publish_simple_instance(&mut cache, img);
725
726        let mut vm = Vm::new(InProcessKernelAssist::new());
727        let r = vm
728            .invoke_cached(&mut cache, inst_hash, 0, [0; 4], 1000)
729            .unwrap();
730        assert!(matches!(
731            r,
732            CallResult::Faulted {
733                reason: ExitReason::Trap,
734                ..
735            }
736        ));
737        assert!(vm.stack.is_empty());
738    }
739
740    #[test]
741    fn invoke_cached_ecalli_zero_halts() {
742        // ecalli 0 → Halt. Bytecode = [10, 0]; bitmask [1, 0] (op + 1
743        // imm byte).
744        let mut img = empty_image_with_code(vec![10u8, 0]);
745        img.packed_bitmask = vec![0b01u8];
746        let mut cache = CacheDirectory::new();
747        let inst_hash = publish_simple_instance(&mut cache, img);
748
749        let mut vm = Vm::new(InProcessKernelAssist::new());
750        let r = vm
751            .invoke_cached(&mut cache, inst_hash, 0, [0; 4], 1000)
752            .unwrap();
753        assert!(matches!(
754            r,
755            CallResult::Halt {
756                return_value: 0,
757                ..
758            }
759        ));
760        assert!(vm.stack.is_empty());
761    }
762
763    #[test]
764    fn invoke_cached_load_imm_then_reply() {
765        // load_imm_64 φ[7] = 42 (opcode 20, OneRegExtImm: [20, 7, 42, 0..])
766        // ecalli 0 (opcode 10): [10, 0]
767        let mut code = Vec::new();
768        let mut bitmask_unpacked = Vec::new();
769        code.extend_from_slice(&[20u8, 7]);
770        bitmask_unpacked.extend_from_slice(&[1u8, 0]);
771        for i in 0..8 {
772            code.push(if i == 0 { 42 } else { 0 });
773            bitmask_unpacked.push(0);
774        }
775        code.extend_from_slice(&[10u8, 0]);
776        bitmask_unpacked.extend_from_slice(&[1u8, 0]);
777
778        // Pack the bitmask: one bit per code byte, LSB first.
779        let mut packed = vec![0u8; bitmask_unpacked.len().div_ceil(8)];
780        for (i, b) in bitmask_unpacked.iter().enumerate() {
781            if *b != 0 {
782                packed[i / 8] |= 1 << (i % 8);
783            }
784        }
785
786        let img = Image {
787            code,
788            packed_bitmask: packed,
789            jump_table: Vec::new(),
790            endpoints: BTreeMap::new(),
791            memory_mappings: Vec::new(),
792            gas_slots: Vec::new(),
793            quota_slots: Vec::new(),
794            pinned_slots: BTreeMap::new(),
795            initial_slots: BTreeMap::new(),
796            yield_marker_slot: None,
797        };
798
799        let mut cache = CacheDirectory::new();
800        let inst_hash = publish_simple_instance(&mut cache, img);
801
802        let mut vm = Vm::new(InProcessKernelAssist::new());
803        let r = vm
804            .invoke_cached(&mut cache, inst_hash, 0, [0; 4], 1000)
805            .unwrap();
806        match r {
807            CallResult::Halt {
808                return_value,
809                gas_used,
810                ..
811            } => {
812                assert_eq!(return_value, 42);
813                assert!(gas_used > 0);
814            }
815            other => panic!("expected Halt, got {:?}", other),
816        }
817    }
818
819    /// End-to-end at the byte-PVM level: M does `host_call(slot=9,
820    /// endpoint=0)` to enter S, S halts, M halts. Verifies the
821    /// `drive_and_translate` loop's HOST_CALL push + nested HALT pop
822    /// arms wire up correctly. The return value lands on M's φ[7],
823    /// which M never wrote to — i.e., whatever HOST_CALL set it to
824    /// (the slot index 9). The point of the test is that the chain
825    /// terminates without infinite looping or trapping.
826    #[test]
827    fn invoke_cached_host_call_into_child_then_halts() {
828        // S's bytecode: `load_imm φ[7] = 42; ecalli 0` (same shape as
829        // invoke_cached_load_imm_then_reply).
830        let s_img = {
831            let mut code = Vec::new();
832            let mut bm = Vec::new();
833            code.extend_from_slice(&[20u8, 7]);
834            bm.extend_from_slice(&[1u8, 0]);
835            for i in 0..8 {
836                code.push(if i == 0 { 42 } else { 0 });
837                bm.push(0);
838            }
839            code.extend_from_slice(&[10u8, 0]);
840            bm.extend_from_slice(&[1u8, 0]);
841            let mut packed = vec![0u8; bm.len().div_ceil(8)];
842            for (i, b) in bm.iter().enumerate() {
843                if *b != 0 {
844                    packed[i / 8] |= 1 << (i % 8);
845                }
846            }
847            Image {
848                code,
849                packed_bitmask: packed,
850                jump_table: Vec::new(),
851                endpoints: BTreeMap::new(),
852                memory_mappings: Vec::new(),
853                gas_slots: Vec::new(),
854                quota_slots: Vec::new(),
855                pinned_slots: BTreeMap::new(),
856                initial_slots: BTreeMap::new(),
857                yield_marker_slot: None,
858            }
859        };
860
861        // M's bytecode: `load_imm φ[7] = 9; load_imm φ[8] = 0;
862        // ecalli 26 (HOST_CALL); ecalli 0 (HALT)`.
863        let m_img = {
864            let mut code = Vec::new();
865            let mut bm = Vec::new();
866            // load_imm φ[7] = 9
867            code.extend_from_slice(&[20u8, 7]);
868            bm.extend_from_slice(&[1u8, 0]);
869            for i in 0..8 {
870                code.push(if i == 0 { 9 } else { 0 });
871                bm.push(0);
872            }
873            // load_imm φ[8] = 0
874            code.extend_from_slice(&[20u8, 8]);
875            bm.extend_from_slice(&[1u8, 0]);
876            for _ in 0..8 {
877                code.push(0);
878                bm.push(0);
879            }
880            // ecalli 26 (HOST_CALL)
881            code.extend_from_slice(&[10u8, 26]);
882            bm.extend_from_slice(&[1u8, 0]);
883            // ecalli 0 (HALT)
884            code.extend_from_slice(&[10u8, 0]);
885            bm.extend_from_slice(&[1u8, 0]);
886            let mut packed = vec![0u8; bm.len().div_ceil(8)];
887            for (i, b) in bm.iter().enumerate() {
888                if *b != 0 {
889                    packed[i / 8] |= 1 << (i % 8);
890                }
891            }
892            Image {
893                code,
894                packed_bitmask: packed,
895                jump_table: Vec::new(),
896                endpoints: BTreeMap::new(),
897                memory_mappings: Vec::new(),
898                gas_slots: Vec::new(),
899                quota_slots: Vec::new(),
900                pinned_slots: BTreeMap::new(),
901                initial_slots: BTreeMap::new(),
902                yield_marker_slot: None,
903            }
904        };
905
906        // Publish S as a complete Cap::Instance.
907        let mut cache = CacheDirectory::new();
908        let s_inst_hash = publish_simple_instance(&mut cache, s_img);
909
910        // Publish M with a root cnode that has slot 9 = Hash(S_inst).
911        let m_image_hash = cache
912            .put_cap(&Cap::image_with_slots(&m_img, &[], &[]).unwrap())
913            .unwrap();
914        let m_cnode_hash = {
915            let mut cn = javm_cap::CNodeCap::new(8).unwrap();
916            cn.set(SlotIdx(9), Some(CapHashOrRef::Hash(s_inst_hash)))
917                .unwrap();
918            cache.put_cap(&Cap::CNode(cn)).unwrap()
919        };
920        let m_inst_hash = cache
921            .put_cap(&Cap::instance_with_overlays(
922                [0xAA; 32],
923                m_image_hash,
924                m_cnode_hash,
925                &[],
926                0,
927                [0u64; NUM_REGS],
928                0,
929                0,
930            ))
931            .unwrap();
932
933        let mut vm = Vm::new(InProcessKernelAssist::new());
934        let r = vm
935            .invoke_cached(&mut cache, m_inst_hash, 0, [0; 4], 100_000)
936            .unwrap();
937        assert!(
938            matches!(r, CallResult::Halt { .. }),
939            "expected Halt, got {:?}",
940            r,
941        );
942        assert!(vm.stack.is_empty());
943    }
944
945    #[test]
946    fn invoke_cached_oog_returns_faulted() {
947        // Lots of fallthroughs with a tiny budget. Opcode 1 = fallthrough.
948        let code = vec![1u8; 50];
949        let mut packed = vec![0xFFu8; code.len().div_ceil(8)];
950        if !code.len().is_multiple_of(8) {
951            // mask off the last byte's unused high bits
952            let used = code.len() % 8;
953            let last = packed.len() - 1;
954            packed[last] = (1u8 << used) - 1;
955        }
956        let img = Image {
957            code,
958            packed_bitmask: packed,
959            jump_table: Vec::new(),
960            endpoints: BTreeMap::new(),
961            memory_mappings: Vec::new(),
962            gas_slots: Vec::new(),
963            quota_slots: Vec::new(),
964            pinned_slots: BTreeMap::new(),
965            initial_slots: BTreeMap::new(),
966            yield_marker_slot: None,
967        };
968        let mut cache = CacheDirectory::new();
969        let inst_hash = publish_simple_instance(&mut cache, img);
970        let mut vm = Vm::new(InProcessKernelAssist::new());
971        let r = vm
972            .invoke_cached(&mut cache, inst_hash, 0, [0; 4], 3)
973            .unwrap();
974        assert!(matches!(
975            r,
976            CallResult::Faulted {
977                reason: ExitReason::OutOfGas,
978                ..
979            }
980        ));
981    }
982}