use std::collections::BTreeMap;
use std::sync::Arc;
use crate::atom::{Atom, AtomTable};
use crate::error::ExecError;
use crate::interpreter::{ExecutionResult, InstructionOutcome, NativeServices, opcodes};
use crate::mailbox::Mailbox;
use crate::module::{Module, ModuleRegistry};
use crate::process::{CodePosition, ExitReason, Process};
use crate::term::{Term, format::format_term};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReplayStepOutcome {
Continue,
Boundary(ExecutionResult),
}
#[derive(Clone, Debug)]
pub struct ProcessSnapshot {
instruction_count: usize,
process: Process,
}
impl ProcessSnapshot {
#[must_use]
pub const fn instruction_count(&self) -> usize {
self.instruction_count
}
#[must_use]
pub fn code_position(&self) -> Option<CodePosition> {
self.process.code_position()
}
#[must_use]
pub const fn process(&self) -> &Process {
&self.process
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RegisterInspection {
pub kind: RegisterKind,
pub index: usize,
pub term: Term,
pub formatted: String,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum RegisterKind {
X,
Y,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StackFrameInspection {
pub return_module: Atom,
pub return_module_name: String,
pub return_ip: usize,
pub function: Option<FunctionInspection>,
pub locals: Vec<RegisterInspection>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FunctionInspection {
pub function: Atom,
pub function_name: String,
pub arity: u8,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HeapInspection {
pub young_used: usize,
pub young_capacity: usize,
pub old_used: usize,
pub old_capacity: usize,
pub total_used: usize,
pub total_capacity: usize,
pub high_water_mark: usize,
pub boxed_objects_by_tag: BTreeMap<String, usize>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MailboxInspection {
pub message_count: usize,
pub scan_messages: Vec<String>,
pub arrival_count: usize,
}
pub struct ReplayDebugger {
process: Process,
initial_module: Arc<Module>,
registry: Option<Arc<ModuleRegistry>>,
services: NativeServices,
snapshot_granularity: usize,
instruction_count: usize,
snapshots: Vec<ProcessSnapshot>,
}
impl ReplayDebugger {
#[must_use]
pub fn new(process: Process, initial_module: Arc<Module>) -> Self {
Self::with_snapshot_granularity(process, initial_module, 1)
}
#[must_use]
pub fn with_snapshot_granularity(
process: Process,
initial_module: Arc<Module>,
snapshot_granularity: usize,
) -> Self {
let snapshot_granularity = snapshot_granularity.max(1);
let mut debugger = Self {
process,
initial_module,
registry: None,
services: empty_native_services(),
snapshot_granularity,
instruction_count: 0,
snapshots: Vec::new(),
};
debugger.record_snapshot();
debugger
}
#[must_use]
pub fn with_registry(mut self, registry: Arc<ModuleRegistry>) -> Self {
self.registry = Some(registry);
self
}
#[must_use]
pub fn with_native_services(mut self, services: NativeServices) -> Self {
self.services = services;
self
}
#[must_use]
pub const fn instruction_count(&self) -> usize {
self.instruction_count
}
#[must_use]
pub fn code_position(&self) -> Option<CodePosition> {
self.process.code_position()
}
#[must_use]
pub const fn process(&self) -> &Process {
&self.process
}
pub fn process_mut(&mut self) -> &mut Process {
&mut self.process
}
#[must_use]
pub fn snapshots(&self) -> &[ProcessSnapshot] {
&self.snapshots
}
pub fn step_forward(&mut self) -> Result<ReplayStepOutcome, ExecError> {
if self.process.code_position().is_none() {
self.process.set_code_position(Some(CodePosition {
module: self.initial_module.name,
instruction_pointer: 0,
}));
}
let position = self
.process
.code_position()
.ok_or(ExecError::InvalidOperand("code position"))?;
let module_arc = self.current_module_for_position(position)?;
let module = module_arc.as_ref();
let instruction = module
.code
.get(position.instruction_pointer)
.ok_or(ExecError::InvalidOperand("instruction pointer"))?;
let next_ip = position
.instruction_pointer
.checked_add(1)
.ok_or(ExecError::InvalidOperand("instruction pointer"))?;
let outcome = opcodes::dispatch_with_services(
&mut self.process,
module,
instruction,
next_ip,
&self.services,
self.registry.as_deref(),
)?;
let step_outcome = self.apply_outcome(module.name, next_ip, outcome);
self.instruction_count = self.instruction_count.saturating_add(1);
if self
.instruction_count
.is_multiple_of(self.snapshot_granularity)
|| matches!(step_outcome, ReplayStepOutcome::Boundary(_))
{
self.record_snapshot();
}
Ok(step_outcome)
}
pub fn step_backward(&mut self) -> Result<(), ExecError> {
if self.instruction_count == 0 {
return Ok(());
}
self.restore_instruction_count(self.instruction_count - 1)
}
pub fn restore_instruction_count(&mut self, target: usize) -> Result<(), ExecError> {
if target == self.instruction_count {
return Ok(());
}
let checkpoint_index = self
.snapshots
.iter()
.enumerate()
.rev()
.find_map(|(index, snapshot)| (snapshot.instruction_count <= target).then_some(index))
.ok_or(ExecError::InvalidOperand("snapshot checkpoint"))?;
let checkpoint = self.snapshots[checkpoint_index].clone();
self.process = checkpoint.process;
self.instruction_count = checkpoint.instruction_count;
self.snapshots.truncate(checkpoint_index + 1);
while self.instruction_count < target {
self.step_forward()?;
}
Ok(())
}
#[must_use]
pub fn inspect_registers(&self, atom_table: &AtomTable) -> Vec<RegisterInspection> {
let mut registers = self
.process
.x_regs()
.iter()
.enumerate()
.filter(|&(_index, term)| !term.is_nil())
.map(|(index, term)| inspect_register(RegisterKind::X, index, *term, atom_table))
.collect::<Vec<_>>();
if let Ok(frame) = self.process.stack().current_frame() {
registers.extend(
frame
.y_regs()
.enumerate()
.filter(|&(_index, term)| !term.is_nil())
.map(|(index, term)| {
inspect_register(RegisterKind::Y, index, *term, atom_table)
}),
);
}
registers
}
#[must_use]
pub fn inspect_stack(&self, atom_table: &AtomTable) -> Vec<StackFrameInspection> {
self.process
.stack()
.frames_from_top()
.map(|frame| {
let function = frame.pinned_module().function_at_ip(frame.return_ip()).map(
|(function, arity)| FunctionInspection {
function,
function_name: format_term(Term::atom(function), atom_table),
arity,
},
);
let locals = frame
.y_regs()
.enumerate()
.filter(|&(_index, term)| !term.is_nil())
.map(|(index, term)| {
inspect_register(RegisterKind::Y, index, *term, atom_table)
})
.collect();
StackFrameInspection {
return_module: frame.return_module(),
return_module_name: format_term(Term::atom(frame.return_module()), atom_table),
return_ip: frame.return_ip(),
function,
locals,
}
})
.collect()
}
#[must_use]
pub fn inspect_heap(&self) -> HeapInspection {
let heap = self.process.heap();
let mut boxed_objects_by_tag = BTreeMap::new();
heap.visit_boxed_objects(|_ptr, tag, _words| {
*boxed_objects_by_tag.entry(format!("{tag:?}")).or_insert(0) += 1;
});
HeapInspection {
young_used: heap.young_used(),
young_capacity: heap.young_capacity(),
old_used: heap.old_used(),
old_capacity: heap.old_capacity(),
total_used: heap.total_used(),
total_capacity: heap.total_capacity(),
high_water_mark: heap.high_water_mark(),
boxed_objects_by_tag,
}
}
#[must_use]
pub fn inspect_mailbox(&self, atom_table: &AtomTable) -> MailboxInspection {
inspect_mailbox(self.process.mailbox(), atom_table)
}
fn current_module_for_position(
&mut self,
position: CodePosition,
) -> Result<Arc<Module>, ExecError> {
if let Some(current) = self.process.current_module()
&& current.name == position.module
{
return Ok(Arc::clone(current));
}
let module = self
.registry
.as_deref()
.and_then(|registry| registry.lookup(position.module))
.or_else(|| {
(self.initial_module.name == position.module)
.then(|| Arc::clone(&self.initial_module))
})
.ok_or(ExecError::InvalidOperand("code position module"))?;
self.process.set_current_module(Arc::clone(&module));
Ok(module)
}
fn apply_outcome(
&mut self,
module: Atom,
next_ip: usize,
outcome: InstructionOutcome,
) -> ReplayStepOutcome {
match outcome {
InstructionOutcome::Continue => {
self.process.set_code_position(Some(CodePosition {
module,
instruction_pointer: next_ip,
}));
ReplayStepOutcome::Continue
}
InstructionOutcome::NativeContinuation => ReplayStepOutcome::Continue,
InstructionOutcome::Jump(target) => {
self.process.set_code_position(Some(target));
ReplayStepOutcome::Continue
}
InstructionOutcome::Yield => ReplayStepOutcome::Boundary(ExecutionResult::Yielded),
InstructionOutcome::Waiting => ReplayStepOutcome::Boundary(ExecutionResult::Waiting),
InstructionOutcome::Exit(reason) => {
self.process.set_code_position(None);
self.process.clear_current_module();
ReplayStepOutcome::Boundary(ExecutionResult::Exited(reason))
}
InstructionOutcome::OnLoadComplete => {
self.process.set_code_position(None);
self.process.clear_current_module();
ReplayStepOutcome::Boundary(ExecutionResult::Exited(ExitReason::Normal))
}
InstructionOutcome::DirtyCall {
entry,
args,
module,
function,
arity,
kind,
} => ReplayStepOutcome::Boundary(ExecutionResult::DirtyCall {
entry,
args,
module,
function,
arity,
kind,
}),
}
}
fn record_snapshot(&mut self) {
if self
.snapshots
.last()
.is_some_and(|snapshot| snapshot.instruction_count == self.instruction_count)
{
return;
}
self.process.mailbox_mut().drain_arrival();
self.snapshots.push(ProcessSnapshot {
instruction_count: self.instruction_count,
process: self.process.clone(),
});
}
}
fn inspect_register(
kind: RegisterKind,
index: usize,
term: Term,
atom_table: &AtomTable,
) -> RegisterInspection {
RegisterInspection {
kind,
index,
term,
formatted: format_term(term, atom_table),
}
}
fn inspect_mailbox(mailbox: &Mailbox, atom_table: &AtomTable) -> MailboxInspection {
let scan_messages = mailbox
.scan_iter()
.map(|term| format_term(*term, atom_table))
.collect::<Vec<_>>();
let message_count = mailbox.message_count();
let arrival_count = message_count.saturating_sub(scan_messages.len());
MailboxInspection {
message_count,
scan_messages,
arrival_count,
}
}
fn empty_native_services() -> NativeServices {
NativeServices::default()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::loader::Instruction;
use crate::loader::decode::Operand;
use crate::module::ModuleOrigin;
use crate::term::boxed::{Cons, Tuple};
fn module(code: Vec<Instruction>) -> Arc<Module> {
Arc::new(Module {
name: Atom::OK,
generation: 0,
origin: ModuleOrigin::Preloaded,
exports: HashMap::new(),
label_index: code
.iter()
.enumerate()
.filter_map(|(ip, instruction)| match instruction {
Instruction::Label { label } => Some((*label, ip)),
_ => None,
})
.collect(),
code,
literals: Vec::new(),
constant_pool: Default::default(),
resolved_imports: Vec::new(),
lambdas: Vec::new(),
string_table: Vec::new(),
function_table: vec![(0, Atom::OK, 0)],
line_table: Vec::new(),
line_info: Vec::new(),
})
}
fn move_int(value: i64) -> Instruction {
Instruction::Move {
source: Operand::Integer(value),
destination: Operand::X(0),
}
}
#[test]
fn step_forward_ten_and_back_five_restores_each_state() {
let code = (0..12).map(move_int).collect();
let module = module(code);
let process = Process::new(1, 233);
let mut debugger = ReplayDebugger::with_snapshot_granularity(process, module, 3);
for expected in 0..10 {
assert_eq!(debugger.step_forward(), Ok(ReplayStepOutcome::Continue));
assert_eq!(debugger.process().x_reg(0), Term::small_int(expected));
assert_eq!(debugger.instruction_count(), (expected + 1) as usize);
}
for expected in (4..=8).rev() {
assert!(debugger.step_backward().is_ok());
assert_eq!(debugger.process().x_reg(0), Term::small_int(expected));
}
assert_eq!(debugger.instruction_count(), 5);
assert!(
debugger
.snapshots()
.iter()
.any(|snapshot| snapshot.instruction_count() == 3)
);
}
#[test]
fn inspect_paused_state_reports_registers_stack_heap_and_mailbox() {
let module = module(vec![
move_int(41),
Instruction::Allocate {
stack_need: Operand::Unsigned(2),
live: Operand::Unsigned(1),
},
Instruction::Move {
source: Operand::Integer(7),
destination: Operand::Y(0),
},
Instruction::PutTuple2 {
destination: Operand::X(1),
elements: Operand::List(vec![Operand::X(0), Operand::Y(0)]),
},
]);
let mut process = Process::new(1, 233);
process
.mailbox_mut()
.push_owned_for_test(Term::small_int(99));
let mut debugger = ReplayDebugger::new(process, Arc::clone(&module));
for _ in 0..4 {
assert!(debugger.step_forward().is_ok());
}
let atoms = AtomTable::with_common_atoms();
let registers = debugger.inspect_registers(&atoms);
assert!(registers.iter().any(|register| {
register.kind == RegisterKind::X && register.index == 0 && register.formatted == "41"
}));
assert!(registers.iter().any(|register| {
register.kind == RegisterKind::Y && register.index == 0 && register.formatted == "7"
}));
let stack = debugger.inspect_stack(&atoms);
assert_eq!(stack.len(), 1);
assert_eq!(
stack[0].function.as_ref().map(|function| function.arity),
Some(0)
);
assert_eq!(stack[0].locals[0].formatted, "7");
let heap = debugger.inspect_heap();
assert_eq!(heap.total_used, 3);
assert_eq!(heap.boxed_objects_by_tag.get("Tuple"), Some(&1));
let mailbox = debugger.inspect_mailbox(&atoms);
assert_eq!(mailbox.message_count, 1);
assert_eq!(mailbox.scan_messages, vec!["99".to_owned()]);
}
#[test]
fn backward_restores_boxed_terms_to_snapshot_heap() {
let module = module(vec![
Instruction::PutTuple2 {
destination: Operand::X(0),
elements: Operand::List(vec![Operand::Integer(1)]),
},
Instruction::Move {
source: Operand::Integer(2),
destination: Operand::X(0),
},
]);
let process = Process::new(1, 233);
let mut debugger = ReplayDebugger::new(process, module);
assert!(debugger.step_forward().is_ok());
let tuple = debugger.process().x_reg(0);
assert_eq!(
Tuple::new(tuple).and_then(|tuple| tuple.get(0)),
Some(Term::small_int(1))
);
assert!(debugger.step_forward().is_ok());
assert!(debugger.step_backward().is_ok());
let restored = debugger.process().x_reg(0);
assert_eq!(
Tuple::new(restored).and_then(|tuple| tuple.get(0)),
Some(Term::small_int(1))
);
}
#[test]
fn backward_restores_cons_cells_with_rebased_tail_terms() {
let module = module(vec![
Instruction::PutList {
head: Operand::Integer(2),
tail: Operand::Atom(None),
destination: Operand::X(0),
},
Instruction::PutList {
head: Operand::Integer(1),
tail: Operand::X(0),
destination: Operand::X(0),
},
Instruction::Move {
source: Operand::Integer(3),
destination: Operand::X(0),
},
]);
let process = Process::new(1, 233);
let mut debugger = ReplayDebugger::new(process, module);
assert!(debugger.step_forward().is_ok());
assert!(debugger.step_forward().is_ok());
let list = debugger.process().x_reg(0);
let cons = Cons::new(list).expect("outer cons");
assert_eq!(cons.head(), Term::small_int(1));
let tail = Cons::new(cons.tail()).expect("inner cons");
assert_eq!(tail.head(), Term::small_int(2));
assert_eq!(tail.tail(), Term::NIL);
assert!(debugger.step_forward().is_ok());
assert!(debugger.step_backward().is_ok());
let restored = debugger.process().x_reg(0);
let cons = Cons::new(restored).expect("restored outer cons");
assert_eq!(cons.head(), Term::small_int(1));
let tail = Cons::new(cons.tail()).expect("restored inner cons");
assert_eq!(tail.head(), Term::small_int(2));
assert_eq!(tail.tail(), Term::NIL);
}
}