Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

21. A Host trait decouples native functions from the execution engine

  • Status: Accepted
  • Date: 2026-09-04

Context

Milestone 3 adds a second execution engine: a bytecode compiler and virtual machine (ADR 0004 always planned for one). The tree-walking interpreter is not being replaced. It stays permanently, as the oracle a differential test suite compares the VM against, which means Korrin will have two engines running the same programs indefinitely.

Everything written in Rust rather than Korrin sits between them. The builtins (crates/korrin/src/builtins.rs), the methods on str / list / map (crates/korrin/src/interpreter/methods.rs), and the standard library (crates/korrin/src/stdlib/) were all written against one concrete type:

pub type NativeFnPtr = fn(&mut Interpreter, &[Value], Span) -> Exec<Value>;

Left alone, that signature forces one of two bad outcomes: a second copy of every builtin written against &mut Vm, or a VM that constructs an Interpreter it does not otherwise need just to satisfy a type. The first guarantees the two copies drift, which is precisely the drift the differential suite exists to detect, so the suite would be testing our copy-paste discipline instead of the VM. The second is a lie in the type system.

What the natives actually need from an engine turns out to be very little. An audit of all 8 builtins, all 30 built-in methods, and all 11 standard-library functions found exactly three uses: print and input write output, io.args() reads the command-line arguments, and input reads a line of stdin (which it did by calling std::io::stdin() directly, bypassing the interpreter altogether). Every other native touches nothing but its own arguments. A fourth capability, calling back into Korrin, is used by nothing today but is the whole point of a higher-order native such as a sort that takes a comparison function.

Decision

Native functions receive &mut dyn Host, never a concrete engine.

pub trait Host {
    fn emit_output(&mut self, text: &str);
    fn script_args(&self) -> &[String];
    fn read_line(&mut self) -> Option<String>;
    fn call_value(&mut self, callee: Value, args: Vec<Value>, span: Span) -> Exec<Value>;
}
  • NativeFnPtr becomes fn(&mut dyn Host, &[Value], Span) -> Exec<Value> and NativeMethodFn becomes fn(&mut dyn Host, &Value, &[Value], Span) -> Exec<Value>.
  • The trait lives in crates/korrin/src/interpreter/host.rs and is implemented by every engine. Interpreter implements it; the VM will.
  • These four methods are the complete list of what native code can reach outside its own arguments. Adding a fifth is a decision, not a detail: it widens what every engine must provide and what a builtin is able to do.
  • The input builtin now reads through Host::read_line rather than touching std::io::stdin() itself, so the last piece of native code that reached past its engine no longer does.
  • Errors need nothing from the host. A native builds them from free functions (runtime_error, Signal::error), and the only variant it originates is Signal::Error.

Consequences

  • One implementation of every builtin, method, and standard-library function serves both engines. When the VM lands, math and io need no VM-specific work at all.
  • crates/korrin/src/builtins.rs and crates/korrin/src/stdlib/ no longer name any engine, and no longer import one. They should not know which engines exist, and now they cannot.
  • The trait is the audit surface. "What can a builtin do?" is answered by reading four method signatures, which is also where a sandbox would attach if Korrin ever grows one.
  • Method resolution moved: emit_output, script_args and call_value are no longer inherent methods on Interpreter, so a caller needs Host in scope. Inside the crate that is a one-line import. It does slightly widen the public API, since these are now reachable through a public trait rather than being pub(crate).
  • call_value is on the trait despite having no caller today. This is deliberate: adding it later would break every implementation of the trait, and the first higher-order native (list.sort(by:) is the obvious candidate) will need it. The cost of carrying it unused is one method body per engine.
  • Native calls now go through a vtable rather than a direct call. This is one indirect jump at the boundary of a function that was about to do real work, and it is not on the hot path of either engine's inner loop.
  • Interpreter keeps its own private machinery (call_function, instantiate, import_module, the module registry) off the trait. Nothing native ever needed it, and a VM's equivalents will not have the same shapes.

Alternatives considered

  • An enum Engine<'a> { Tree(&'a mut Interpreter), Vm(&'a mut Vm) } passed to natives. Avoids the vtable, but makes builtins.rs and stdlib/ name and import every engine that exists, and every added engine edits the enum and every match on it. The dependency points the wrong way: the generic code would depend on the specific.
  • Generic natives, fn<H: Host>(&mut H, ...). Monomorphised and so marginally faster, but a generic function has no single function-pointer type, and the builtin tables (BUILTINS, STR_METHODS, and friends) are arrays of function pointers. It would mean boxing closures or a registry per engine, for a saving that does not exist at this granularity.
  • Duplicating the natives per engine. Rejected outright. Two implementations of str.split that must agree forever is exactly the failure the differential test suite is built to catch, and it would be catching a problem we chose to create.
  • Passing the capabilities as separate arguments (an output sink, an args slice, a callback). The signature grows with every capability, every native pays for what it does not use, and there is no longer one name for "what an engine offers".