22. Implementation status
The compiler is in Rust (workspace at crates/).
Execution model - two tiers. A single front end (lex → parse → name-resolve → type → effect-check → desugar) lowers to a stack-based bytecode IR (crates/hanki-compiler/src/bytecode.rs) consumed by two backends:
- The bytecode interpreter (
crates/hanki-compiler/src/vm.rs) is the interactive and compile-time tier:hanki run,hanki check,hanki test, REPL evaluation,meta/@deriveexecution, and sandboxed evaluation of.config.hkfiles (§17). The step-budget fuel (§16) bounds untrusted code. - The LLVM AOT backend (
crates/hanki-aot-codegen/src/llvm_lower.rs) is the release-build tier thathanki builddrives - type-driven unboxed lowering throughinkwell, linked againsthanki-runtimefor the actor scheduler, mailbox, and memory management. It is enabled by default (thellvm-aotCargo feature); a--no-default-featuresbuild is bytecode-only, andhanki buildthen reports the disabled backend. The full OS-capability seam (§6io/fs/net/db/process/time/env/random) is implemented on both tiers.
Both backends consume the same bytecode IR.
Tier parity is a hard requirement. The two tiers are two production execution modes, not a dev tier and a release tier: bytecode (hanki run) is the portable, instant-start, fuel-sandboxable mode - scripts, embedding, latency-sensitive work, and any host with no LLVM toolchain - and AOT (hanki build) is the maximum-throughput mode. A program must be observationally indistinguishable between them - same stdout, same errors, same effects, same exit code - differing only in time and memory. The alignment is enforced by three cross-tier harnesses: the differential corpus (crates/hanki-cli/tests/differential.rs) over every examples/ program, the REPL-vs-run parity harness (repl_parity.rs), and the OS-capability harness (aot_sys_io.rs) that pins the fs / net / process / stdin (io.read_line!) seam - those touch the filesystem, network, and stdin, so they run in isolated tempdirs outside the example corpus. Any intentional divergence must be justified here and is deliberately driven toward zero.
In a correct program there is no language-level behavioural divergence. Each case where the tiers could otherwise differ is closed:
mainmust return()or an integer; any other return type (abool, a float, astring, a struct/sum, …) is rejected at compile time (mainmust return()or an integer) on both tiers, since it has no defined process exit code. An integermainreturn is not printed by either tier; it becomes the process exit code's low byte (()yields 0). Standard output is only ever an explicitio.print!.- A fatal fault carries the same exit code on both tiers: an uncaught throw exits 254, an unsupervised actor death 253, and a
crash!reaching the root (inmain!itself, not a spawned actor) 252 (the AOT tier exits these rather thanabort-ing to SIGABRT). A deadlock detected under--deterministic(§6, Debugging & observability) exits 251. The bytecode tier additionally bounds how much a run may execute:hanki runexceeding a whole-program resource bound - its step budget (--max-steps) or cumulative-allocation ceiling (--max-bytes, total bytes allocated rather than live heap - §23), or the default a--deny/--allowsandbox applies (§23) - exits 250. That bound outranks the fault codes above: the budget is whole-program, so a spawned actor spending the last of it dies of the fault and the run still exits 250, not the 253 its (unsupervised) death would otherwise carry - the death is only how the exhausted budget surfaced. This is the fuel-sandboxable bytecode mode named in the parity note above; the maximum-throughput AOT tier does not impose it (a documented, scoped divergence being driven toward parity). Because of that gap, every untrusted-input decoder carries its own inherent, tier-agnostic bound independent of the step budget -json.parse!'sTooDeep/TooManyKeys, the genericDecodepath's recursion-depth limit (§16,TooDeeppast 128), theList/Mapelement-count-vs-remaining-bytes check, andhttp's header cap - so hostile deeply-nested or oversized input is rejected on the AOT tier too, where no fuel fault would catch it. dbg!works on both tiers (it renders any value structurally, noDisplayneeded) and is not gated out of built artifacts - keeping it out of shipped code is the author's discipline, like any stderr write. This holds even for an opaque non-data value - a closure, aFuture, or a resource handle - which renders an identical placeholder on both tiers (<closure>/<future>/<kind resource>): the value's raw machine word is not reconstructible (under AOT a closure decodes to a function-pointer integer and a future/resource to<unrenderable>), so the compiler bakes the placeholder into the value's dbg shape and the renderer prints it from the shape rather than the word. Every data value (primitives incl.bool/float, structs, sums, lists, strings, bytes) renders identically too.- The structural equality of a float nested in an aggregate is closed by the
Eqrule (§20). Because theEqderivation requires every part to beEqandf64is not, an aggregate containing anf64has noEq, so==/!=on such a value is a compile error - the comparison never happens, so the tiers cannot differ on it. (Top-levelf64 ==is unaffected - it lowers tofcmp.) - Setting
dbg!aside, the only route to a process-level abort is a bug in the runtime or codegen: user code is memory-safe (no raw pointers, runtime-managed values), so a fundamental fault is our defect - fatal on both tiers, unreachable in a correct program. It is an implementation invariant, not a language divergence. - A failed write to stdout or stderr - the everyday
prog | head, where the reader closes the pipe - is a runtime fault: the OS refused an effect the language gives no typed surface, so user code can neither catch it nor return it. Both tiers report which write failed and why, then die exactly as any other fault does - the actor goes down, so a failure in a spawned handler is an unsupervised death (253, or anactor_diedcast to its supervisor) while one inmain!ends the program with 1. That 1 sits deliberately outside the 250-254 block, which is reserved for language faults; it is the generic error exithanki runhas always given for a runtime error. Neither tier aborts toSIGABRT, and neither dies ofSIGPIPE: an AOT binary's emitted Cmainbypasses the Rust startup that setsSIGPIPEto ignore, so its entry hook installs that disposition itself, which is what lets the write returnEPIPEfor the seam to report rather than killing the process outright. A failed flush reports identically to a failed write - for a write with no trailing newline the flush is where the bytes actually reach the OS, so treating it as nothing let a closed-pipeio.print!("> ")exit 0 having written nothing at all. The one case with no diagnostic is a closed stderr, where the report has nowhere left to go: the message is dropped rather than written, and the exit code carries the fault alone. Reporting it any other way would mean writing to the stream that just failed, which is a panic, not a diagnostic.
Dynamic module loading and hot reload are one surface that behaves identically on both tiers - module.load!<T: trait>(path) / module.unload! / module.reload!<T> - and the path is a Hanki source file, never a pre-built artifact: the user never manages .sos. The bytecode tier compiles the source to bytecode and loads/swaps it; the AOT tier turns it into running code via a backend that is statically linked into the binary - so a load!-using build either links successfully or fails at build time, and backend availability is never a runtime surprise the caller must handle (load!'s ModuleLoadError stays about the module, not the environment). That backend is linked in only when the program uses load!/reload! (the build knows statically): a program with neither keeps the lean, LLVM-free AOT baseline and pays nothing.
It is an embedded bytecode interpreter: a load!-using build statically links the LLVM-free compile-to-bytecode pipeline plus the VM, so a loaded source is compiled to bytecode and run on the same interpreter as the hanki run tier. An interpreter rather than an in-process LLVM JIT: it adds on the order of 8 MB to a load!-using binary and no runtime dependency, whereas a JIT must either static-link LLVM (~170 MB, roughly ten times the baseline binary) or load libLLVM.so dynamically and so reintroduce the very backend-availability failure a static link exists to remove. The budgets are fixed: at most a 12 MB binary-size delta to enable load! (zero for a program that uses neither), at most 0.5 ms added cold-start, and loaded-code throughput explicitly ungated - the load path is not a throughput goal, so code that needs native speed is compiled into the main build rather than loaded.
The boundary between an AOT host and an interpreted loaded module is observationally transparent: calling into a loaded module is indistinguishable from single-tier execution save in time/memory and via explicit, opt-in reflection - the same parity bar the two tiers hold to each other, and nearly free here because both tiers are codegen strategies over one shared runtime core (hanki-rtcore: heap, reference-counted memory management, scheduler, mailbox, RtValue, the numeric algebra, exit/death). A mixed-tier differential test - an AOT host plus an interpreted module, checked against the all-bytecode run - enforces it; passing opaque non-data values (closures, futures, resource handles) across the boundary is a documented scoped limitation (these non-inspectable handles are not marshallable data). Runtime-resolved backends (a JIT shipped as a sidecar .so, or shelling out to the toolchain) are disfavoured: they would turn "is the backend present?" into a per-call error every dynamic-module program must handle, and break fully-static deployment.
A per-actor migrate(old: T) hook drives state migration; the actor-aware coordination - the in-flight handler finishing under the old code, every subsequent message under the new, plus migration (or a clean StateMigrationRequired) on a layout change - works on the bytecode tier through module.reload!<T> (§14). reload! of a function-mode handle - the ADR's degenerate case, re-pointing the vtable at the recompiled code with no actor to coordinate - works on both tiers, so an AOT host can hot-swap everything it can load; actor-mode reload! under an AOT host waits on loaded-actor support there, a single unified cross-tier surface is not yet implemented.
What works today. Both tiers load source modules: the bytecode VM directly, and the AOT tier by linking the embedded interpreter into a load!-using binary (measured release size delta ~3 MB, zero for programs that do not load!; the binary stays LLVM-free).
Data crosses the boundary by deep-copy marshalling, for every data type: scalars, strings, the arbitrary-precision numerics and bytes (through their RootKind), and every struct/sum aggregate, including the applied-type generics List/Map/Option/Result and user generics - a sum/struct heap object carries its own layout, so the self-describing pointer walk reconstructs it with no type information. The bytecode tier's arguments are already RtValues and need no marshalling at all. Opaque non-data values - closures, futures, resource handles, and a Module<T> handle passed as an argument - are the documented scoped limitation.
An effectful loaded method behaves identically on both tiers. On the bytecode tier it performs io/fs/net and actor sends, its yields serviced by the host scheduler exactly as inline code's are; the AOT-embedded loader services the same OS-seam host-calls (io/fs/net/env/process, clock reads, random draws) through the same intrinsic table, and a mixed-tier differential test pins an io.print!-ing loaded method's output and exit code across the two. A loaded method's actor sends are the exception: bytecode tier only.
A loaded module may declare the actor it exposes. When the host trait T's ! methods name-and-signature match a loaded actor's on handlers, module.load!<T>(path) spawns that actor and the returned Module<T> is its reference: m.method!(args) is a blocking send, the actor stays live and stateful between load! and unload!, and unload! stops it. Each dispatch resolves the actor's handlers against its own loaded module, which is what module.reload!<T> swaps over - on the bytecode tier a loaded actor hot-reloads from recompiled source at its next safe point, carrying or migrate-ing its state (§14).
A throw escaping a loaded method is reconciled into the host's throw-type space by type name on both tiers (the per-module tags do not agree across the boundary) and re-raised at the call site, so a loaded def! export's throws E surfaces at the host's matching catch e: E; the AOT-embedded loader recovers the host's tag from the same throw-type table the host registers at startup. A loaded actor handler that throws instead goes down like any actor - its throws E does not cross the send (§15) - and the host observes the death as actor.SendFailed::Died. The error type must be shared or structurally identical across the two modules: the thrown value's variant tags are type-local and so already agree, but they are not remapped, which makes a divergent redefinition that reorders the type's variants the documented unsupported case.
Not yet implemented: actor operations (spawn/send/await) on the AOT-embedded loader; loaded actors on the AOT-embedded loader, which the bytecode tier spawns today; host-to-loaded actor supervision, so a loaded entry actor's uncaught death escalates to root; more than one exposed entry actor per module; actor-mode reload! under an AOT host, which waits on loaded actors there; and a single unified cross-tier reload!.
The REPL is analyzer-backed. hanki repl evaluates through the same typed session pipeline as hanki run (string interpolation included), and its per-keystroke UX queries the live checker: Tab completion is type-aware - xs.<Tab> lists the receiver's actual members, working for literal receivers, chained calls, module names, and type names alike - shown as an aligned list whose signature column renders from the typed signatures (dimmed when colours are on; plain under NO_COLOR); inline ghost hints after the cursor show a complete line's inferred type ([1,2] → list.List<int>) or a trailing member's receiver-instantiated signature ([1,2].map → (f: (int) -> U) -> list.List<U>), while a prop hints its type. :hints on|off toggles the ambient hints (default on; HANKI_REPL_HINTS=0 starts a session off; hints are automatically absent when styling is off, so piped output never carries hint text). The queries are check-only against a cached session assembly (warm p95 under 7 ms on the reference dev box) and never print - an unresolvable receiver simply offers the plain name completion. help(target) (and its command spelling :doc target) looks documentation up in the live session: a session-defined item renders its own doc comment, a module name (help(list)) its prose and item index, a type its docs and members, and the dotted forms - help(json.parse), help(List.empty), help([1,2].map) - resolve through the same analyzer the completion uses, so a receiver expression's method docs are one call away. A session-defined help shadows the special form.
Other current limitations worth noting:
@encapsulatedenforces all three rules (§6). Rule 2 is realized as a parameter-immutability check rather than a flow-sensitive binding-origin analysis: because adefcannot capture an enclosing binding (defs do not nest) and there is no ambient global mutable state, immutable parameters are sufficient and necessary for "the body only mutates locally-allocated state", so the dataflow collapses to "no deeply-mutable parameter". No purity-based optimization treats an@encapsulated defas pure yet (theda45ticket) - effect rows are erased before lowering and the AOT backend sets no purity attributes - so optimizer trust is not yet load-bearing. The first stdlib use iscodec.to_bytes/from_bytes(the pure-callable serialization entry points overEncode/Decode); they work from ordinary code but not yet at compile time - the comptime VM has theBytesBuilder/BytesReaderintrinsics (a builder-based@encapsulateddef folds), but their encode/decode path also reaches intrinsics that are not comptime yet (i32.to_u32,string.to_bytes,bytes.length), so calling one from ametaconst is a cleanIntrinsicNotComptimeerror.