Skip to main content

byteflow/vm/
machine.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use crate::bytecode::{Chunk, Instruction, Opcode, Value};
5
6use super::fault::Fault;
7use super::frame::Frame;
8use super::native::NativeTable;
9use super::result::VmResult;
10
11/// Hard limit on call nesting. Frames are heap-allocated, so
12/// unbounded recursion would grow the Flow's memory instead of crashing
13/// the worker thread's native stack — which is worse, not better, without a
14/// limit. `4096` comfortably covers real recursive algorithms while keeping
15/// a runaway `fn f() { f() }` a `Fault`, not an OOM.
16pub const MAX_CALL_DEPTH: usize = 4096;
17
18/// One virtual Flow's execution state: call stack + registers. Cheap
19/// enough to construct that spawning a Flow is a handful of small heap
20/// allocations, not a native thread/stack (contrast: a `std::thread` reserves
21/// megabytes of stack whether it uses them or not).
22///
23/// `Vm` owns no scheduler, mailbox, or thread handle — see [`VmResult`] for
24/// why that separation is the whole point.
25pub struct Vm {
26    chunk: Arc<Chunk>,
27    natives: Arc<NativeTable>,
28    frames: Vec<Frame>,
29    /// Lifetime instruction counter, exposed for `FlowMetrics` (design
30    /// notes §26).
31    instructions_executed: u64,
32}
33
34impl Vm {
35    /// Construct a `Vm` ready to run `function` (an index into
36    /// `chunk.functions`) with the given arguments loaded into `r0..argc`.
37    /// `natives` is the FFI table `Opcode::CallNative` dispatches through —
38    /// pass [`NativeTable::empty`] if the chunk never calls out to Rust.
39    pub fn new(chunk: Arc<Chunk>, natives: Arc<NativeTable>, function: u32, args: &[Value]) -> Result<Self, Fault> {
40        let def = chunk
41            .function(function)
42            .ok_or(Fault::BadFunction { index: function, table_size: chunk.functions.len() as u32 })?;
43        let mut frame = Frame::new(function, def.num_registers, None);
44        frame.pc = def.entry as usize;
45        // Checked, not `frame.registers[i] = ...`: the loop is bounded by
46        // `arity` while the register file is sized by `num_registers`, and a
47        // chunk declaring `arity > num_registers` is structurally possible.
48        // `crate::bytecode::verify` now rejects that statically, but `Vm::new`
49        // is public and reachable without it — and an index panic here fires
50        // on the *caller's* thread (the embedder's, or a worker's via
51        // `Opcode::Spawn`), outside the `catch_unwind` that isolates
52        // `Vm::run`. A fault keeps it a flow-level failure.
53        for (i, arg) in args.iter().enumerate().take(def.arity as usize) {
54            match frame.registers.get_mut(i) {
55                Some(slot) => *slot = arg.clone(),
56                None => {
57                    return Err(Fault::RegisterOutOfRange {
58                        reg: i as u8,
59                        frame_size: def.num_registers,
60                    })
61                }
62            }
63        }
64        Ok(Vm { chunk, natives, frames: vec![frame], instructions_executed: 0 })
65    }
66
67    pub fn instructions_executed(&self) -> u64 {
68        self.instructions_executed
69    }
70
71    /// Index into `chunk.functions` for the active (top) call frame.
72    /// Useful for diagnostics / supervisor logs when a Flow traps.
73    pub fn current_function(&self) -> u32 {
74        // Category D: frames must be non-empty while the VM is runnable.
75        // We still avoid `.expect` — return 0 as a diagnostic fallback so a
76        // broken invariant cannot panic a worker; the next `run` will trap.
77        debug_assert!(!self.frames.is_empty(), "frames empty while running");
78        match self.frames.last() {
79            Some(frame) => frame.function,
80            None => 0,
81        }
82    }
83
84    /// A cheap `Arc` clone of the chunk this VM is executing. Used by the
85    /// scheduler to construct a child `Vm` for `Opcode::Spawn` without
86    /// needing to know anything about `Chunk`'s internals — every Flow
87    /// spawned (transitively) from the same top-level `spawn()` call shares
88    /// one immutable chunk in memory, never copies it.
89    pub fn chunk_arc(&self) -> Arc<Chunk> {
90        self.chunk.clone()
91    }
92
93    /// A cheap `Arc` clone of this VM's native function table, for the same
94    /// reason as [`Vm::chunk_arc`]: a `Spawn`-created child must dispatch
95    /// `CallNative` through the identical table its parent uses.
96    pub fn natives_arc(&self) -> Arc<NativeTable> {
97        self.natives.clone()
98    }
99
100    /// Deliver a value the scheduler produced on our behalf (a **Cap** from
101    /// `Spawn` / `SelfPid`, or a dequeued mailbox message from a `Receive`)
102    /// into the register the instruction that suspended us was targeting,
103    /// ahead of the next [`Vm::run`] call. A no-op is never valid to skip:
104    /// calling `run` without this after a `Spawn`/`Receive` result leaves the
105    /// destination register holding its previous (stale) value.
106    #[inline]
107    pub fn resume_with(&mut self, dest_reg: u8, value: Value) -> Result<(), Fault> {
108        self.set_reg(dest_reg, value)
109    }
110
111    /// Program counter of the active frame — used by the optional JIT hook.
112    pub fn current_pc(&self) -> Option<usize> {
113        self.frames.last().map(|f| f.pc)
114    }
115
116    /// Number of registers in the active frame.
117    pub fn current_num_registers(&self) -> Option<u8> {
118        self.frames.last().map(|f| f.registers.len() as u8)
119    }
120
121    /// Read-only view of the active register file.
122    pub fn top_registers(&self) -> Option<&[Value]> {
123        self.frames.last().map(|f| f.registers.as_slice())
124    }
125
126    /// Mutable view of the active register file (JIT sync path).
127    pub fn top_registers_mut(&mut self) -> Option<&mut [Value]> {
128        self.frames.last_mut().map(|f| f.registers.as_mut_slice())
129    }
130
131    /// Set the active frame's program counter.
132    pub fn set_pc(&mut self, pc: usize) {
133        if let Some(frame) = self.frames.last_mut() {
134            frame.pc = pc;
135        }
136    }
137
138    /// Write one register in the active frame (JIT sync path).
139    pub fn set_register(&mut self, reg: u8, value: Value) -> Result<(), Fault> {
140        self.set_reg(reg, value)
141    }
142
143    /// Deliver a return value through the call stack.
144    pub fn return_value(&mut self, value: Value) -> Result<Option<VmResult>, Fault> {
145        self.pop_frame(value)
146    }
147
148    /// Top call frame. Empty stack is a broken invariant (category D) —
149    /// returned as [`Fault::Invariant`], never as `unwrap`/`expect`.
150    #[inline]
151    fn current(&mut self) -> Result<&mut Frame, Fault> {
152        debug_assert!(!self.frames.is_empty(), "frames empty while running");
153        self.frames
154            .last_mut()
155            .ok_or(Fault::Invariant("empty frame stack while running"))
156    }
157
158    #[inline]
159    fn get_reg(&self, reg: u8) -> Result<Value, Fault> {
160        let frame = self
161            .frames
162            .last()
163            .ok_or(Fault::Invariant("empty frame stack while running"))?;
164        frame
165            .registers
166            .get(reg as usize)
167            .cloned()
168            .ok_or(Fault::RegisterOutOfRange {
169                reg,
170                frame_size: frame.registers.len() as u8,
171            })
172    }
173
174    #[inline]
175    fn set_reg(&mut self, reg: u8, value: Value) -> Result<(), Fault> {
176        let frame = self.current()?;
177        let len = frame.registers.len() as u8;
178        match frame.registers.get_mut(reg as usize) {
179            Some(slot) => {
180                *slot = value;
181                Ok(())
182            }
183            None => Err(Fault::RegisterOutOfRange { reg, frame_size: len }),
184        }
185    }
186
187    /// Fetch the next instruction and advance `pc`.
188    ///
189    /// `Ok(None)` if control fell off the end of the function body without an
190    /// explicit `Return`/`Halt` — treated as an implicit `Return Unit`
191    /// (friendlier to hand-written bytecode that omits a trailing return).
192    /// `Err` if the frame stack is empty (invariant break).
193    ///
194    /// Borrows are split on purpose: hold `pc`, look up `chunk.code`, then
195    /// write `pc+1` — a single `&mut Frame` across `self.chunk` would not
196    /// compile.
197    #[inline]
198    fn fetch(&mut self) -> Result<Option<Instruction>, Fault> {
199        let pc = self.current()?.pc;
200        let instr = self.chunk.code.get(pc).copied();
201        if instr.is_some() {
202            self.current()?.pc = pc + 1;
203        }
204        Ok(instr)
205    }
206
207    fn numeric_binop(&mut self, op: Opcode, dst: u8, lhs: u8, rhs: u8) -> Result<(), Fault> {
208        let a = self.get_reg(lhs)?;
209        let b = self.get_reg(rhs)?;
210        let result = match (op, &a, &b) {
211            (Opcode::Add, Value::Int(x), Value::Int(y)) => Value::Int(x.wrapping_add(*y)),
212            (Opcode::Add, _, _) => Value::Float(as_f64(&a)? + as_f64(&b)?),
213            (Opcode::Sub, Value::Int(x), Value::Int(y)) => Value::Int(x.wrapping_sub(*y)),
214            (Opcode::Sub, _, _) => Value::Float(as_f64(&a)? - as_f64(&b)?),
215            (Opcode::Mul, Value::Int(x), Value::Int(y)) => Value::Int(x.wrapping_mul(*y)),
216            (Opcode::Mul, _, _) => Value::Float(as_f64(&a)? * as_f64(&b)?),
217            (Opcode::Div, Value::Int(x), Value::Int(y)) => {
218                if *y == 0 {
219                    return Err(Fault::DivideByZero);
220                }
221                Value::Int(x.wrapping_div(*y))
222            }
223            (Opcode::Div, _, _) => {
224                let denom = as_f64(&b)?;
225                Value::Float(as_f64(&a)? / denom)
226            }
227            (Opcode::Mod, Value::Int(x), Value::Int(y)) => {
228                if *y == 0 {
229                    return Err(Fault::DivideByZero);
230                }
231                Value::Int(x.wrapping_rem(*y))
232            }
233            (Opcode::Eq, _, _) => Value::Bool(a == b),
234            (Opcode::Lt, Value::Int(x), Value::Int(y)) => Value::Bool(x < y),
235            (Opcode::Lt, _, _) => Value::Bool(as_f64(&a)? < as_f64(&b)?),
236            (Opcode::Le, Value::Int(x), Value::Int(y)) => Value::Bool(x <= y),
237            (Opcode::Le, _, _) => Value::Bool(as_f64(&a)? <= as_f64(&b)?),
238            _ => {
239                return Err(Fault::Invariant(
240                    "numeric_binop called with a non-arithmetic opcode",
241                ))
242            }
243        };
244        self.set_reg(dst, result)
245    }
246
247    /// Run at most `budget` instructions (cooperative-preemption quantum,
248    /// design notes §10-11), or until the Flow completes / needs an
249    /// effect the scheduler must perform / faults.
250    ///
251    /// Every exit path is captured by [`VmResult`] — this function itself
252    /// never panics on malformed *verified* bytecode; faults are returned,
253    /// not thrown, so a buggy Flow can't take a worker thread down.
254    pub fn run(&mut self, budget: u32) -> VmResult {
255        for _ in 0..budget {
256            self.instructions_executed += 1;
257            let instr = match self.fetch() {
258                Ok(Some(i)) => i,
259                Ok(None) => {
260                    // Fell off the end of a function: implicit `return Unit`.
261                    match self.pop_frame(Value::Unit) {
262                        Ok(Some(result)) => return result,
263                        Ok(None) => continue,
264                        Err(fault) => return VmResult::Trap(fault),
265                    }
266                }
267                Err(fault) => return VmResult::Trap(fault),
268            };
269
270            macro_rules! trap {
271                ($e:expr) => {
272                    match $e {
273                        Ok(v) => v,
274                        Err(fault) => return VmResult::Trap(fault),
275                    }
276                };
277            }
278
279            match instr.op {
280                Opcode::Halt => {
281                    let v = trap!(self.get_reg(0));
282                    return VmResult::Complete(v);
283                }
284                Opcode::Nop => {}
285                Opcode::LoadConst => {
286                    let idx = instr.imm as u32;
287                    let konst = match self.chunk.constant(idx) {
288                        Some(v) => v.clone(),
289                        None => {
290                            return VmResult::Trap(Fault::BadConstant {
291                                index: idx,
292                                pool_size: self.chunk.constants.len() as u32,
293                            })
294                        }
295                    };
296                    trap!(self.set_reg(instr.a, konst));
297                }
298                Opcode::LoadImm => {
299                    trap!(self.set_reg(instr.a, Value::Int(instr.imm as i64)));
300                }
301                Opcode::Move => {
302                    let v = trap!(self.get_reg(instr.b));
303                    trap!(self.set_reg(instr.a, v));
304                }
305                Opcode::Add | Opcode::Sub | Opcode::Mul | Opcode::Div | Opcode::Mod
306                | Opcode::Eq | Opcode::Lt | Opcode::Le => {
307                    trap!(self.numeric_binop(instr.op, instr.a, instr.b, instr.c));
308                }
309                Opcode::Neg => {
310                    let v = trap!(self.get_reg(instr.b));
311                    let negated = match v {
312                        Value::Int(x) => Value::Int(-x),
313                        Value::Float(x) => Value::Float(-x),
314                        other => {
315                            return VmResult::Trap(Fault::TypeMismatch {
316                                expected: "int or float",
317                                got: other.type_name(),
318                            })
319                        }
320                    };
321                    trap!(self.set_reg(instr.a, negated));
322                }
323                Opcode::Jump => {
324                    let frame = trap!(self.current());
325                    let target = frame.pc as i64 + instr.imm as i64;
326                    frame.pc = target as usize;
327                }
328                Opcode::Branch => {
329                    let cond = trap!(self.get_reg(instr.a));
330                    if !cond.is_truthy() {
331                        let frame = trap!(self.current());
332                        let target = frame.pc as i64 + instr.imm as i64;
333                        frame.pc = target as usize;
334                    }
335                }
336                Opcode::Call => {
337                    let function = instr.imm as u32;
338                    let argc = instr.b;
339                    let dst = instr.a;
340                    if self.frames.len() >= MAX_CALL_DEPTH {
341                        return VmResult::Trap(Fault::CallStackOverflow { depth: self.frames.len() });
342                    }
343                    let def = match self.chunk.function(function) {
344                        Some(d) => d.clone(),
345                        None => {
346                            return VmResult::Trap(Fault::BadFunction {
347                                index: function,
348                                table_size: self.chunk.functions.len() as u32,
349                            })
350                        }
351                    };
352                    let mut args = Vec::with_capacity(argc as usize);
353                    for i in 0..argc {
354                        args.push(trap!(self.get_reg(trap!(reg_at(dst, u16::from(i))))));
355                    }
356                    let mut new_frame = Frame::new(function, def.num_registers, Some(dst));
357                    new_frame.pc = def.entry as usize;
358                    // See `Vm::new` on why this is a checked write.
359                    for (i, a) in args.into_iter().enumerate().take(def.arity as usize) {
360                        match new_frame.registers.get_mut(i) {
361                            Some(slot) => *slot = a,
362                            None => {
363                                return VmResult::Trap(Fault::RegisterOutOfRange {
364                                    reg: i as u8,
365                                    frame_size: def.num_registers,
366                                })
367                            }
368                        }
369                    }
370                    self.frames.push(new_frame);
371                }
372                Opcode::CallNative => {
373                    let native_index = instr.imm as u32;
374                    let argc = instr.b;
375                    let dst = instr.a;
376                    let native_fn = match self.natives.get(native_index) {
377                        Some(f) => f.clone(),
378                        None => {
379                            return VmResult::Trap(Fault::BadNative {
380                                index: native_index,
381                                table_size: self.natives.len() as u32,
382                            })
383                        }
384                    };
385                    let mut args = Vec::with_capacity(argc as usize);
386                    for i in 0..argc {
387                        args.push(trap!(self.get_reg(trap!(reg_at(dst, u16::from(i))))));
388                    }
389                    // Runs inline on this worker thread — see
390                    // `NativeFn`'s doc comment on why natives must not
391                    // block. This is the actual FFI boundary (design
392                    // notes §30-31): plain Rust on one side, bytecode
393                    // registers on the other, with `Fault::NativeError`
394                    // as the only channel for a native-side failure to
395                    // become a Flow fault instead of a host panic.
396                    match native_fn(&args) {
397                        Ok(value) => trap!(self.set_reg(dst, value)),
398                        Err(fault) => return VmResult::Trap(fault),
399                    }
400                }
401                Opcode::Return => {
402                    let v = trap!(self.get_reg(instr.a));
403                    match self.pop_frame(v) {
404                        Ok(Some(result)) => return result,
405                        Ok(None) => {}
406                        Err(fault) => return VmResult::Trap(fault),
407                    }
408                }
409                Opcode::Spawn => {
410                    let argc = instr.b;
411                    let mut args = Vec::with_capacity(argc as usize);
412                    for i in 0..argc {
413                        // Args live at r[a+1 .. a+1+argc] — deliberately
414                        // offset from `a` itself, which the scheduler will
415                        // overwrite with a Cap to the child once it exists
416                        // (see Opcode::Spawn / FlowCap).
417                        //
418                        // The `+1` is why this one needs `reg_at` most: with
419                        // `a = 255` the very first index already leaves the
420                        // register space, before any bounds check gets a say.
421                        args.push(trap!(
422                            self.get_reg(trap!(reg_at(instr.a, u16::from(i) + 1)))
423                        ));
424                    }
425                    return VmResult::Spawn { function: instr.imm as u32, args, dest_reg: instr.a };
426                }
427                Opcode::Yield => return VmResult::Yield,
428                Opcode::Sleep => {
429                    let millis = trap!(self.get_reg(instr.a));
430                    let ms = match millis.as_int() {
431                        Some(ms) if ms >= 0 => ms as u64,
432                        _ => {
433                            return VmResult::Trap(Fault::TypeMismatch {
434                                expected: "non-negative int",
435                                got: millis.type_name(),
436                            })
437                        }
438                    };
439                    return VmResult::Sleep(Duration::from_millis(ms));
440                }
441                Opcode::Exit => {
442                    let v = trap!(self.get_reg(instr.a));
443                    return VmResult::Complete(v);
444                }
445                Opcode::SelfPid => {
446                    return VmResult::SelfPid { dest_reg: instr.a };
447                }
448                Opcode::Send => {
449                    let target = trap!(self.get_reg(instr.a));
450                    let message = trap!(self.get_reg(instr.b));
451                    let cap = match target.as_cap() {
452                        Some(c) => c,
453                        None => {
454                            return VmResult::Trap(Fault::TypeMismatch {
455                                expected: "cap",
456                                got: target.type_name(),
457                            })
458                        }
459                    };
460                    if message.as_message().is_none() {
461                        return VmResult::Trap(Fault::TypeMismatch {
462                            expected: "message",
463                            got: message.type_name(),
464                        });
465                    }
466                    return VmResult::Send {
467                        target_cap: cap,
468                        message,
469                    };
470                }
471                Opcode::Receive => {
472                    return VmResult::Receive {
473                        dest_reg: instr.a,
474                        timeout: None,
475                        match_tag: None,
476                    };
477                }
478                Opcode::ReceiveTimeout => {
479                    let millis = trap!(self.get_reg(instr.b));
480                    let ms = match millis.as_int() {
481                        Some(n) if n >= 0 => n as u64,
482                        _ => 0,
483                    };
484                    return VmResult::Receive {
485                        dest_reg: instr.a,
486                        timeout: Some(Duration::from_millis(ms)),
487                        match_tag: None,
488                    };
489                }
490                Opcode::ReceiveMatch => {
491                    let tag_v = trap!(self.get_reg(instr.b));
492                    let tag = match tag_from_value(&tag_v) {
493                        Ok(t) => t,
494                        Err(f) => return VmResult::Trap(f),
495                    };
496                    return VmResult::Receive {
497                        dest_reg: instr.a,
498                        timeout: None,
499                        match_tag: Some(tag),
500                    };
501                }
502                Opcode::ReceiveMatchImm => {
503                    let tag = match u16::try_from(instr.imm) {
504                        Ok(t) if instr.imm >= 0 => t,
505                        _ => {
506                            return VmResult::Trap(Fault::TypeMismatch {
507                                expected: "tag u16",
508                                got: "imm-out-of-range",
509                            })
510                        }
511                    };
512                    return VmResult::Receive {
513                        dest_reg: instr.a,
514                        timeout: None,
515                        match_tag: Some(tag),
516                    };
517                }
518                Opcode::Ask => {
519                    let target = trap!(self.get_reg(instr.b));
520                    let request = trap!(self.get_reg(instr.c));
521                    let cap = match target.as_cap() {
522                        Some(c) => c,
523                        None => {
524                            return VmResult::Trap(Fault::TypeMismatch {
525                                expected: "cap",
526                                got: target.type_name(),
527                            })
528                        }
529                    };
530                    if request.as_message().is_none() {
531                        return VmResult::Trap(Fault::TypeMismatch {
532                            expected: "message",
533                            got: request.type_name(),
534                        });
535                    }
536                    return VmResult::Ask {
537                        dest_reg: instr.a,
538                        target_cap: cap,
539                        request,
540                        timeout: None,
541                    };
542                }
543                Opcode::AskTimeout => {
544                    let target = trap!(self.get_reg(instr.b));
545                    let request = trap!(self.get_reg(instr.c));
546                    let millis_reg = match u8::try_from(instr.imm) {
547                        Ok(r) => r,
548                        Err(_) => {
549                            return VmResult::Trap(Fault::TypeMismatch {
550                                expected: "millis register",
551                                got: "imm-out-of-range",
552                            })
553                        }
554                    };
555                    let millis = trap!(self.get_reg(millis_reg));
556                    let ms = match millis.as_int() {
557                        Some(n) if n >= 0 => n as u64,
558                        _ => 0,
559                    };
560                    let cap = match target.as_cap() {
561                        Some(c) => c,
562                        None => {
563                            return VmResult::Trap(Fault::TypeMismatch {
564                                expected: "cap",
565                                got: target.type_name(),
566                            })
567                        }
568                    };
569                    if request.as_message().is_none() {
570                        return VmResult::Trap(Fault::TypeMismatch {
571                            expected: "message",
572                            got: request.type_name(),
573                        });
574                    }
575                    return VmResult::Ask {
576                        dest_reg: instr.a,
577                        target_cap: cap,
578                        request,
579                        timeout: Some(Duration::from_millis(ms)),
580                    };
581                }
582                Opcode::Monitor => {
583                    let target = trap!(self.get_reg(instr.b));
584                    let cap = match target.as_cap() {
585                        Some(c) => c,
586                        None => {
587                            return VmResult::Trap(Fault::TypeMismatch {
588                                expected: "cap",
589                                got: target.type_name(),
590                            })
591                        }
592                    };
593                    return VmResult::Monitor {
594                        dest_reg: instr.a,
595                        target_cap: cap,
596                    };
597                }
598                Opcode::Demonitor => {
599                    return VmResult::Demonitor {
600                        monitor_reg: instr.a,
601                    };
602                }
603                Opcode::Link => {
604                    let target = trap!(self.get_reg(instr.b));
605                    let cap = match target.as_cap() {
606                        Some(c) => c,
607                        None => {
608                            return VmResult::Trap(Fault::TypeMismatch {
609                                expected: "cap",
610                                got: target.type_name(),
611                            })
612                        }
613                    };
614                    return VmResult::Link {
615                        dest_reg: instr.a,
616                        target_cap: cap,
617                    };
618                }
619                Opcode::Unlink => {
620                    return VmResult::Unlink {
621                        link_reg: instr.a,
622                    };
623                }
624                Opcode::Trap => return VmResult::Trap(Fault::Explicit(instr.imm)),
625            }
626        }
627        VmResult::Yield
628    }
629
630    /// Pop the current frame, delivering `value` to the caller (or
631    /// finishing the Flow if this was the outermost frame). Returns
632    /// `Ok(Some(VmResult::Complete(_)))` only in the latter case.
633    fn pop_frame(&mut self, value: Value) -> Result<Option<VmResult>, Fault> {
634        let finished = self
635            .frames
636            .pop()
637            .ok_or(Fault::Invariant("pop_frame on empty stack"))?;
638        match finished.dest_reg {
639            Some(dest) => {
640                // Caller frame still on the stack; ignore an out-of-range
641                // dest (shouldn't happen for verified bytecode emitted by
642                // this crate's own builder, but we don't want to panic on
643                // foreign bytecode) by trapping instead.
644                if self.set_reg(dest, value).is_err() {
645                    let frame_size = match self.frames.last() {
646                        Some(f) => f.registers.len() as u8,
647                        None => 0,
648                    };
649                    return Ok(Some(VmResult::Trap(Fault::RegisterOutOfRange {
650                        reg: dest,
651                        frame_size,
652                    })));
653                }
654                Ok(None)
655            }
656            None => Ok(Some(VmResult::Complete(value))),
657        }
658    }
659}
660
661/// The register index `base + offset`, or [`Fault::RegisterIndexOverflow`]
662/// if that sum leaves the register index space.
663///
664/// Every multi-operand opcode gathers its arguments from consecutive
665/// registers. Written as a plain `base + offset` on `u8`, that addition
666/// panics in debug and **wraps** in release — so a release build silently
667/// reads the wrong register instead of failing, which is the worst of the
668/// two outcomes and the one a debug-mode test suite never sees. The sum is
669/// therefore computed in a wider type and narrowed explicitly.
670#[inline]
671fn reg_at(base: u8, offset: u16) -> Result<u8, Fault> {
672    match u8::try_from(u32::from(base) + u32::from(offset)) {
673        Ok(reg) => Ok(reg),
674        Err(_) => Err(Fault::RegisterIndexOverflow {
675            base,
676            offset: match u8::try_from(offset) {
677                Ok(o) => o,
678                Err(_) => u8::MAX,
679            },
680        }),
681    }
682}
683
684#[inline]
685fn as_f64(v: &Value) -> Result<f64, Fault> {
686    match v {
687        Value::Int(i) => Ok(*i as f64),
688        Value::Float(f) => Ok(*f),
689        other => Err(Fault::TypeMismatch { expected: "int or float", got: other.type_name() }),
690    }
691}
692
693/// Decode a Message tag from a register value (`Int` in `0..=u16::MAX`).
694#[inline]
695fn tag_from_value(v: &Value) -> Result<u16, Fault> {
696    match v.as_int() {
697        Some(i) if (0..=i64::from(u16::MAX)).contains(&i) => Ok(i as u16),
698        Some(_) => Err(Fault::TypeMismatch {
699            expected: "tag u16",
700            got: "int-out-of-range",
701        }),
702        None => Err(Fault::TypeMismatch {
703            expected: "int",
704            got: v.type_name(),
705        }),
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use crate::bytecode::builder::ChunkBuilder;
713
714    type TestResult = Result<(), Box<dyn std::error::Error>>;
715
716    /// `Spawn a=255` reads its arguments from `a+1`, so the first index
717    /// already leaves the register space. This used to panic in debug and —
718    /// far worse — wrap around to `r0` in release, silently spawning with the
719    /// wrong argument.
720    #[test]
721    fn spawn_from_the_last_register_traps_instead_of_wrapping() -> TestResult {
722        let mut b = ChunkBuilder::new("t");
723        b.begin_function("main", 0, 2);
724        b.emit_spawn(255, 0, 1);
725        b.emit_return(0);
726        let mut vm = Vm::new(Arc::new(b.finish()), NativeTable::empty(), 0, &[])?;
727        match vm.run(10) {
728            VmResult::Trap(Fault::RegisterIndexOverflow {
729                base: 255,
730                offset: 1,
731            }) => Ok(()),
732            other => Err(format!("expected RegisterIndexOverflow, got {other:?}").into()),
733        }
734    }
735
736    /// `Vm::new` is public and reachable without `verify`, and its panic
737    /// landed on the caller's thread — the embedder's on `Runtime::spawn`, or
738    /// a worker's on bytecode `Spawn`, outside the `catch_unwind`.
739    #[test]
740    fn entering_a_function_with_too_few_registers_faults() -> TestResult {
741        let mut b = ChunkBuilder::new("t");
742        b.begin_function("main", 3, 1);
743        b.emit_return(0);
744        let args = [Value::Int(1), Value::Int(2), Value::Int(3)];
745        match Vm::new(Arc::new(b.finish()), NativeTable::empty(), 0, &args) {
746            Err(Fault::RegisterOutOfRange {
747                reg: 1,
748                frame_size: 1,
749            }) => Ok(()),
750            Err(e) => Err(format!("unexpected fault: {e}").into()),
751            Ok(_) => Err("three arguments cannot be loaded into one register".into()),
752        }
753    }
754
755    #[test]
756    fn calling_a_function_with_too_few_registers_traps() -> TestResult {
757        let mut b = ChunkBuilder::new("t");
758        let callee = b.begin_function("callee", 3, 1);
759        b.emit_return(0);
760        let main = b.begin_function("main", 0, 4);
761        b.emit_load_imm(0, 7);
762        b.emit_load_imm(1, 8);
763        b.emit_load_imm(2, 9);
764        b.emit_call(0, callee, 3);
765        b.emit_return(0);
766        let mut vm = Vm::new(Arc::new(b.finish()), NativeTable::empty(), main, &[])?;
767        match vm.run(50) {
768            VmResult::Trap(Fault::RegisterOutOfRange {
769                reg: 1,
770                frame_size: 1,
771            }) => Ok(()),
772            other => Err(format!("expected RegisterOutOfRange, got {other:?}").into()),
773        }
774    }
775
776    /// The boundary case that must keep working: gathering right up to the
777    /// last register is legal, it is only going *past* it that faults.
778    #[test]
779    fn gathering_up_to_the_last_register_still_works() -> TestResult {
780        let mut b = ChunkBuilder::new("t");
781        let callee = b.begin_function("callee", 1, 1);
782        b.emit_return(0);
783        let main = b.begin_function("main", 0, 255);
784        b.emit_load_imm(254, 5);
785        // Argument at r254 — the highest index a 255-register frame has.
786        b.emit_call(254, callee, 1);
787        b.emit_return(254);
788        let mut vm = Vm::new(Arc::new(b.finish()), NativeTable::empty(), main, &[])?;
789        match vm.run(100) {
790            VmResult::Complete(_) => Ok(()),
791            other => Err(format!("expected completion, got {other:?}").into()),
792        }
793    }
794}