harn-vm 0.8.35

Async bytecode virtual machine for the Harn programming language
Documentation
use crate::runtime_limits::RuntimeLimits;
use crate::stdlib::register_vm_stdlib;
use crate::{VmError, VmValue};

use super::Vm;

fn execute_with_limits(source: &str, limits: RuntimeLimits) -> (Vm, Result<VmValue, VmError>) {
    let chunk = crate::compile_source(source).expect("source compiles");
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime builds");

    rt.block_on(async {
        let local = tokio::task::LocalSet::new();
        local
            .run_until(async {
                let mut vm = Vm::new();
                register_vm_stdlib(&mut vm);
                vm.runtime_limits = limits;
                let result = vm.execute(&chunk).await;
                (vm, result)
            })
            .await
    })
}

fn limits_with_max_frames(max_vm_frames: usize) -> RuntimeLimits {
    RuntimeLimits {
        max_vm_frames,
        ..RuntimeLimits::default()
    }
}

fn assert_stack_overflow(error: &VmError) {
    let message = error.to_string();
    assert!(
        message.contains("Stack overflow: too many nested calls"),
        "expected stack overflow, got {message}"
    );
}

#[test]
fn non_tail_recursion_fails_with_bounded_vm_stack_trace() {
    let limits = limits_with_max_frames(16);
    let (vm, result) = execute_with_limits(
        r#"
pipeline main() {
  fn dive(n: int) -> int {
    if n <= 0 {
      return 0
    }
    return 1 + dive(n - 1)
  }
  dive(128)
}
"#,
        limits,
    );

    let error = result.expect_err("non-tail recursion must hit VM frame limit");
    assert_stack_overflow(&error);
    assert!(
        vm.error_stack_trace.len() <= limits.max_vm_frames,
        "stack trace grew past VM frame limit: {:?}",
        vm.error_stack_trace
    );
    assert!(
        vm.error_stack_trace
            .iter()
            .any(|(name, _, _, _)| name == "dive"),
        "stack trace should identify the recursive function: {:?}",
        vm.error_stack_trace
    );
}

#[test]
fn tail_recursive_countdown_runs_beyond_frame_limit() {
    let (vm, result) = execute_with_limits(
        r#"
pipeline main() {
  fn countdown(n: int, acc: int) -> int {
    if n <= 0 {
      return acc
    }
    return countdown(n - 1, acc + 1)
  }
  log(countdown(200, 0))
}
"#,
        limits_with_max_frames(8),
    );

    result.expect("tail recursion should reuse the current frame");
    assert_eq!(vm.output().trim(), "[harn] 200");
}

#[test]
fn tracked_step_tail_recursion_uses_explicit_frames() {
    let (vm, result) = execute_with_limits(
        r#"
@step(name: "tracked_countdown")
fn tracked_countdown(n: int) -> int {
  if n <= 0 {
    return 0
  }
  return tracked_countdown(n - 1)
}

pipeline main() {
  tracked_countdown(64)
}
"#,
        limits_with_max_frames(12),
    );

    let error = result.expect_err("tracked functions must preserve lifecycle frame boundaries");
    assert_stack_overflow(&error);
    assert!(
        vm.error_stack_trace
            .iter()
            .any(|(name, _, _, _)| name == "tracked_countdown"),
        "stack trace should identify the tracked recursive function: {:?}",
        vm.error_stack_trace
    );
}

#[test]
fn callback_methods_do_not_accumulate_frames_per_item() {
    let (vm, result) = execute_with_limits(
        r#"
pipeline main() {
  let xs = range(0, 256).to_list()
  let mapped = xs.map({ x -> x + 1 })
  let filtered = mapped.filter({ x -> x % 64 == 0 })
  let dict = {a: 1, b: 2, c: 3, d: 4}
    .map_values({ v -> v + 10 })
    .filter({ v -> v > 12 })
  let set_out = set(xs)
    .map({ x -> x % 11 })
    .filter({ x -> x < 3 })

  log(len(mapped))
  log(filtered[0])
  log(dict.count())
  log(set_out.count())
}
"#,
        limits_with_max_frames(8),
    );

    result.expect("callback-heavy collection dispatch should stay within the frame budget");
    assert_eq!(
        vm.output().trim(),
        "[harn] 256\n[harn] 64\n[harn] 2\n[harn] 3"
    );
}

#[test]
fn recursive_callback_body_uses_same_frame_budget() {
    let (_vm, result) = execute_with_limits(
        r#"
pipeline main() {
  fn recurse(n: int) -> int {
    if n <= 0 {
      return 0
    }
    return 1 + recurse(n - 1)
  }
  range(0, 3).to_list().map({ x -> recurse(64) })
}
"#,
        limits_with_max_frames(12),
    );

    let error = result.expect_err("recursive callback bodies should hit the VM frame budget");
    assert_stack_overflow(&error);
}