22. Implementation status
The compiler is in Rust (workspace at crates/).
The execution model has two tiers. A single front end, lex, parse, name-resolve, type, effect-check and 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 bounded 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 tierhanki builddrives: type-driven unboxed lowering throughinkwell, linked againsthanki-runtimefor the actor scheduler, mailbox and memory management. It is enabled by default, through thellvm-aotCargo feature, and a--no-default-featuresbuild is bytecode-only,hanki buildthen reporting the disabled backend. The full OS-capability seam (§6,io,fs,net,db,process,time,env,random) is implemented on both tiers.
Both backends consume the same bytecode IR. The LLVM backend's optimization policy is selected when the toolchain is built: Rust debug assertions select the development policy, LLVM O1 with the O1 optimizing backend; without debug assertions it uses the release policy, O2 with the default optimizing backend. Both verify generated IR and eliminate unreachable code. HANKI_TIMINGS=1 identifies the selected pipeline, and the native object cache includes the complete policy in its key. Build the toolchain with cargo build --release and use its release runtime archives for application throughput measurements. HANKI_RUNTIME_DIR selects the linked runtime independently of the backend policy.
Planned typed value ABI: small scalar structs must support allocation-free construction, copying, and transport through direct calls, generic functions, trait dispatch, and stored callbacks on both tiers, independently of inlining. The initial class is a finite product of at most four raw machine-word lanes after generic substitution and recursive field flattening; fixed-width integers, bools and floats each use one lane, and unit uses zero. Opaque products retain their privacy and constructor checks. Larger, recursive and managed-field products have separate representations. Eligible scalar fields are inline in enclosing storage. Closure environments, containers and actor envelopes may allocate their own storage, with no additional scalar box.
The same planned layout model supports an explicit discriminant and inline payload for Option<T> over every T. Construction and matching add no wrapper allocation; managed payloads retain their own representation and ownership. Nested options remain distinct, and recursive nominal payloads retain finite indirect storage. General tagged-union layouts support later Result work, while the selected migration covers Option. Value semantics, actor isolation, resource restrictions, float bits and bytecode allocation metering remain enforced. These are implementation targets: native execution currently uses one word per value and only local scalar stack promotion is implemented. The staged ABI must update every producer, consumer, observer and artifact format before activation.
Rust applications can also use the supported bytecode-only full-program embedding boundary in hanki_runtime::embed. A host explicitly supplies a capability ceiling, finite whole-program step and cumulative-allocation limits, a deterministic seed, owned user-effect provider callbacks, string inputs and a cancellation token; compilation prints nothing and returns structured diagnostics, while execution returns a structured outcome. Defaults grant no authority and use finite bounds, and an in-program provide block cannot shadow an installed callback. A compiled program is opaque, process-local, and tied to the Host (or one of its clones) that proved its authority and provider routes. Every outcome terminates and joins remaining actors before returning. Provider callbacks are trusted Rust outside VM metering, and cancellation waits for an in-progress callback to return. Reachable dbg! and dynamic source loading are refused. This is an implementation API and no new language syntax or semantics; the complete SemVer, threading, ownership, and v1-scope contract is in docs/design/full-program-embedding.md.
Tier parity is a hard requirement. The two tiers are two production execution modes, and no dev tier and release tier. Bytecode, hanki run, is the portable, instant-start, fuel-bounded mode, for scripts, embedding, latency-sensitive work, and any host with no LLVM toolchain. AOT, hanki build, is the maximum-throughput mode. A program must be observationally indistinguishable between them, with the same stdout, the same errors, the same effects and the 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-against-run parity harness (repl_parity.rs), and the OS-capability harness (aot_sys_io.rs), which pins the fs, net, process and stdin (io.read_line!) seam; those touch the filesystem, network and stdin, and they therefore run in isolated tempdirs outside the example corpus. Any intentional divergence must be justified here, and is driven toward zero.
In a correct program there is no language-level behavioural divergence. Each case where the tiers could otherwise differ is closed, and the one that is not yet closed is diagnostic text and no behaviour. It is listed last:
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 has 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 and not in a spawned actor, 252; the AOT tier exits these in place ofabort-ing toSIGABRT. A deadlock detected under--deterministic(§6, Debugging and 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), its cumulative-allocation ceiling (--max-bytes, total bytes allocated and not live heap, §23), or the default a restricted--denyor--allowrun applies (§23), exits 250. That bound outranks the fault codes above: the budget is whole-program, and a spawned actor spending the last of it dies of the fault while the run still exits 250 and not the 253 its unsupervised death would otherwise carry, the death being only how the exhausted budget surfaced. This is the fuel-bounded bytecode mode named in the parity note above, and the maximum-throughput AOT tier does not impose it, a documented and scoped divergence being driven toward parity. Standard parsers and codecs provide tier-independent input guards: thejsonparsers'TooDeepandTooManyKeys, standard containers and derivedDecodebodies' recursion-depth limit (§16,TooDeeppast 128), theListandMapcount-against-remaining-bytes checks, andhttp's header cap. Each guard covers its stated dimension. They impose no shared cumulative decode budget; custom decoders must enforce their own limits.
The registry in one place, each code paired with the hanki-rtcore constant that defines it (crates/hanki-rtcore/src/exit.rs). The prose above is the explanation, and this table is what a test checks the constants against, which leaves the two cannot drift:
| Code | Constant | Fault |
|---|---|---|
| 254 | UNCAUGHT_THROW_EXIT_CODE | an uncaught throw propagating past main! |
| 253 | UNSUPERVISED_DEATH_EXIT_CODE | an actor death reaching the root with no supervisor |
| 252 | ROOT_CRASH_EXIT_CODE | a crash! reaching the root, in main! itself |
| 251 | DETERMINISTIC_DEADLOCK_EXIT_CODE | a deadlock detected under --deterministic |
| 250 | RESOURCE_EXHAUSTED_EXIT_CODE | a whole-program resource bound exceeded (bytecode tier only) |
| 1 | RUNTIME_FAULT_EXIT_CODE | an OS-level runtime fault: a failed stdout/stderr write, or a spawn the OS refuses (no descriptors for the actor's wakeup pipe, no thread) |
- An escalating actor death outranks the faults it causes. One death can present as two of the codes above: the actor escalates to root, 253, while the
SendFailed::Diedhanded to a sender that reached the now-dead actor propagates pastmain!uncaught, 254. The death is the reason the program ended, 253 wins, and its report is written first. This is a rule and no ordering that happens to hold: AOT runs actors on real OS threads and both faults race for the exit, and without a decision the exit code would depend on machine load, which is how the two tiers came to disagree on a busy CI box while agreeing on an idle one. The claim is made before the death becomes observable, and it therefore stands whatever the interleaving. dbg!works on both tiers, rendering any value structurally with noDisplayneeded. Restricted bytecode execution and every native build fail closed on a reachable site unless--allow-dbgis explicit (§6); once enabled, both tiers render identically. This applies even to an opaque non-data value, a closure, aFutureor a resource handle, which renders an identical placeholder on both tiers,<closure>,<future>or<kind resource>. The value's raw machine word is not reconstructible, a closure decoding under AOT to a function-pointer integer and a future or resource to<unrenderable>, and the compiler therefore bakes the placeholder into the value's dbg layout and the renderer prints it from that layout in place of from the word. Every data value, primitives includingbooland float, structs, sums, lists, arrays, strings and bytes, renders identically too.- The structural equality of a float nested in an aggregate is closed by the
Eqrule (§20). TheEqderivation requires every part to beEqandf64is not, an aggregate containing anf64therefore has noEq, and==and!=on such a value are a compile error: the comparison never happens, and the tiers cannot differ on it. Top-levelf64 ==is unaffected, lowering 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, with no raw pointers and runtime-managed values, and a fundamental fault is therefore our own defect: fatal on both tiers, and unreachable in a correct program. It is an implementation invariant and no language divergence. - A lost consumer and a failed write on stdout or stderr are different events, and only the second is a fault. The everyday
prog | head, where the reader closes the pipe, is the reader's ordinary termination, closing the pipe being success forhead, and the program therefore ends with exit 0 through the normal shutdown path, writing no diagnostic. Every other write failure,ENOSPC,EIO,EDQUOT, means output was lost and nobody wanted it lost, and remains a runtime fault: both tiers report which write failed and why, then die as any other fault does. The actor goes down, and a failure in a spawned handler is therefore an unsupervised death, 253, or anactor_diedcast to its supervisor, while one inmain!ends the program with 1. That 1 sits outside the 250-254 block, which is reserved for language faults. A failed flush is classified as a failed write is. For a write with no trailing newline the flush is where the bytes reach the OS, and treating it as nothing let a closed-pipeio.print!("> ")exit 0 having written nothing at all. Neither tier aborts toSIGABRT, and neither dies ofSIGPIPE. An AOT binary's emitted Cmainbypasses the Rust startup that setsSIGPIPEto ignore, and its entry hook installs that disposition itself, which is what lets the write returnEPIPEfor the seam to classify in place of killing the process outright. That is also why a lost consumer exits 0 and not the coreutils 141: a kernel-deliveredSIGPIPEwould skip theatexithook that restores the terminal (§17), and this runtime therefore cannot die of that signal. An exit code of 141 would then claim a signal death that did not happen, indistinguishable to a shell and a plain exit to anything readingwaitpid. 141 is also a reachablemainreturn here, an integer return becoming the exit code's low byte. Exit 0 additionally leavesprog | headsucceeding underset -o pipefail, where 141 would push callers toward a|| truethat swallows the genuine write failures above along with it. A lost consumer ends the program and no single actor: a spawned writer does not die and no supervisor observes one, nothing reading the output being a fact about the whole program in place of about whichever actor noticed first. For the rare program that must outlive its reader, a daemon that has to notice its consumer leaving and carry on,io.write!(s) -> Result<(), sys.WriteFailure>, andio.ewrite!, oversys.stdout_write_checked!andsys.stderr_write_checked!, is the same write with both outcomes as values,ConsumerGoneandFailed(msg). Every rule in this paragraph is the same for a bytes payload as for a string one:io.print_bytes!andio.eprint_bytes!take the unit-returning rule, andio.write_bytes!andio.ewrite_bytes!the checked one. Thebytesface exists becausebytes.to_string()returns aResultand a program relaying output it did not produce has nowhere to put the failure.io.print!remains unit-returning and ergonomic, and the escape hatch is therefore not a toll every caller pays. The one case with no diagnostic among the faults is a closed stderr, where the report has nowhere left to go: the message is dropped in place of written, and the exit code reports the fault alone. Reporting it any other way would mean writing to the stream that just failed, which is a panic and no diagnostic. - Resource metering is a bytecode-tier facility.
--max-stepsand--max-bytes, and the defaults a restricted--denyor--allowrun applies, are metered by the bytecode VM, and exit 250 is therefore a bytecode-tier outcome;hanki buildaccepts neither flag and writes no ceiling into the binary. The in-language form,with_budget(bytes, steps) … end(§21), is bytecode-tier for the same reason, the AOT tier having no metering to carve a sub-quota from, and the AOT backend refuses to lower it in place of ignoring it, and a program that meters itself therefore fails at build time in place of running unbounded. That scope is intentional and no gap waiting to be closed: metering exists to bound running untrusted or just-written code, the how-much companion to--denyand--allow's what, which is a development and resource-bounding job, and paying for it on the release tier would mean a check on every allocation and every call, which the AOT tier exists not to pay. The consequence to know: a shipped binary is unbounded, and a program that must survive hostile input bounds that input itself in place of relying on a ceiling. - Cooperative native-loop interruption is an AOT tier gap. Bytecode actors yield after each fixed 16,384 taken back-edges, preserving the active handler while letting deterministic peers run and observing actor-local or program shutdown at that point (§15). Native AOT loops have no such poll. Its required back-edge countdown and scheduler handoff regressed tight-loop and allocation-heavy benchmarks before the unwind-safe root spill, failing the net-performance-positive gate; the AOT backend omits it. An AOT actor still stops at every shared safe point, between handlers and in cancellable blocking seams, but a handler wedged in pure native compute does not; its
shutdown!future may time out. This divergence is explicit and driven toward zero; no parity claim hides it. - Recursion depth is bounded by the OS stack on the AOT tier, and reports in place of crashing. The AOT tier compiles a Hanki call to a native call, and a deep enough recursion therefore exhausts the thread's stack, where the bytecode tier, recursing on its own heap-allocated frames, continues until memory runs out. Past that bound an AOT program writes
hanki-runtime: stack overflownaming the cause and exits 1, a runtime fault: an OS-level limit the language gives no typed surface, and it can be neither caught nor returned. It is nothrow, is not catchable, and is nocrash!state dump. The bound is program-dependent, being frames times frame size, and no depth is promised, and an actor thread reaches it roughly four times sooner than the program thread, which getsRLIMIT_STACKwhere an actor gets a 2 MiB default. This is a real divergence and no hidden one: a recursion deep enough to matter should be a loop, and the report says so. What is closed is the failure mode: an AOT binary enters through an emitted Cmainand therefore never ran the startup that installs a stack-overflow handler, and the runtime now installs one per thread itself. - The actor-death report agrees in full, source span included.
hanki_aborttakes a(loc_ptr, loc_len)pair, the backend bakes the location beside the faulting call, and both tiers render it through one sharedbytecode::span_ref, and the spelling therefore cannot drift between them.tests/aot/aot_actor_death_parity.rscompares the two reports byte for byte, with no normalisation. The spelling ispath:line:col, as in(at main.hk:4:5), resolved against the module's source map. It has to be resolved where the error is built and not where it is printed: aDisplayimpl takes only&self, and the source map is on the module. A span whose file is not in the map, a REPL line or a synthetic module, falls back to<unknown>:<start>-<end>in place of inventing a location.
Dynamic module loading and hot reload behave identically on both tiers. The surface itself, module.load!, unload! and reload!, the source-file rule, and the annotation that supplies T, is specified in §14. The bytecode tier compiles the source to bytecode and loads or swaps it; the AOT tier turns it into running code through a backend that is statically linked into the binary, and a load!-using build therefore either links successfully or fails at build time, and backend availability is never a runtime surprise the caller must handle, load!'s ModuleLoadError remaining about the module and not the environment. That backend is linked in only where the program uses load! or reload!, which the build knows statically, and a program with neither retains 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, and a loaded source is therefore compiled to bytecode and run on the same interpreter as the hanki run tier. It is an interpreter and no in-process LLVM JIT: it adds on the order of 8 MB to a load!-using binary and no runtime dependency, where a JIT must either static-link LLVM, around 170 MB, roughly ten times the baseline binary, or load libLLVM.so dynamically and thereby reintroduce the 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 being no throughput goal, and code that needs native speed is compiled into the main build in place of loaded.
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, a measured release size delta of about 3 MB and zero for programs that do not load!, the binary remaining LLVM-free. reload! of a function-mode handle, re-pointing the vtable at the recompiled code with no actor to coordinate, works on both tiers, and an AOT host can therefore hot-swap everything it can load. The actor-aware reload! coordination and loaded-actor hot-reload, at the actor's next safe point, carrying or migrate-ing its state, are bytecode-tier today. The capability gate at the load boundary is on both tiers (§23): an AOT host has its own declared surface baked in and refuses a loaded module that exceeds it, as hanki run does with no flags. The resource metering of loaded code, --max-steps, --max-bytes and with_budget, remains bytecode-tier, the AOT tier having no step counter.
Not yet implemented: actor operations, spawn, send and await, on the AOT-embedded loader; loaded actors on the AOT-embedded loader, which the bytecode tier spawns today; host-to-loaded actor supervision, where 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, and piped output therefore never has 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 offers the plain name completion instead. 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, and a receiver expression's method docs are therefore one call away. A session-defined help shadows the special form.
Other current limitations worth noting:
- A closure cannot capture an actor state field (
H0210). Inside anonhandler, referencing a state field from a closure body is rejected, a bare write to one (total = x) included: a closure captures by value (§13), a state slot has no representation inside one, and there is nothing correct to compile it to. Copy the field into a differently-named local first and close over that,snapshot = total; a baretotal = totalreassigns the field in place of binding a copy (§12). This is a limitation of the lowering and no statement about actors, and it is checked in place of left to lowering, which is what gives it a span.
@encapsulatedenforces all three rules (§6). Rule 2 is realized as a parameter-immutability check and no flow-sensitive binding-origin analysis. Adefcannot capture an enclosing binding, defs not nesting, and there is no ambient global mutable state, and immutable parameters are therefore sufficient and necessary for the body mutating only locally-allocated state, and the dataflow collapses to no deeply-mutable parameter. That check runs twice, a signature being unable to answer it alone: on the declared parameter types (H0609), and again on every bounded generic where a call instantiates it (H0629), a bareTreading as immutable and otherwise letting a caller smuggle its own builder in under the purity certificate. The second half rides on the trait-dictionary path, which every call site of a bounded generic must take or miscompile, and it therefore cannot miss one. No purity-based optimization treats an@encapsulated defas pure yet: effect rows are erased before lowering and the AOT backend sets no purity attributes, and no optimizer yet relies on it. The first stdlib use iscodec.to_bytesandfrom_bytes, the pure-callable serialization entry points overEncodeandDecode, and both they andjson.parse,parse_bytes,parse_strictandparse_bytes_strictfold at compile time. The fold set covers the byte and string conversions their paths reach,u32.count_onesandu32.to_intbeing the last two to join the §16 table, and a top-level constant may therefore hold a parsed JSON document or a codec round-trip. Each module has a fold-probe binding that fails the build if the set regresses.xml.parseandparse_bytessatisfy the three rules unchanged and are the obvious next conversion.- Closure purity is enforced through function effect rows (§6). Every source action has an implicit
localeffect, and the two process-globalsys.log_*intrinsics declareruntime_state. A closure such as|| b.push!(1u8)has a non-empty row and cannot enter a pure() -> ()slot or be invoked from a puredef,List.fold, a bare-dotpropread, or the comptime evaluator.assert!declares[Crash], ascrash!does. A closure that asserts therefore has a non-empty row; a puredefor@encapsulated defcannot assert. An action that asserts must declare[Crash], while atestblock permits it without an effect annotation. Effect rows are erased before lowering, and no optimizer relies on purity yet.