Bytecode cache
Short-lived harn invocations spend the bulk of their wall time before
the VM executes a single instruction: read the source, lex it, parse it,
run the type checker, compile the AST to a bytecode chunk. Cold-start
for the kind of subcommand an IDE host is porting into .harn
(keys list, status, diagnose) is dominated by
that pipeline — the LLM/HTTP/IO work the script eventually performs
goes through the same builtins on every run.
The bytecode cache eliminates that fixed cost when nothing in the input
graph has changed. The runtime hashes the entry source + every
transitively-imported user file, looks for a .harnbc artifact whose
header matches, and on a hit goes straight from "load bytecode" to
"start VM."
File format#
Little-endian throughout. Every cache file starts with this header:
magic : [u8; 8] = "HARNBC\0\0"
schema_ver : u32 = SCHEMA_VERSION
version_len : u32
harn_version : [u8; version_len]
fp_len : u32
codegen_fp : [u8; fp_len] CODEGEN_FINGERPRINT of the producing build
compiler_tag : u8 bitmask of active CompilerOptions
kind : u8 1 = entry chunk, 2 = module artifact
source_hash : [u8; 32] sha256(entry source)
context_hash : [u8; 32] sha256(sorted import graph contents)
payload : postcard-serialized payload (Chunk or ModuleArtifact, per kind)
Mismatch on any of magic / schema / harn_version / codegen_fp /
compiler_tag / source_hash triggers a silent recompile and rewrite, as
does a context_hash mismatch wherever the loader has computed one. A
future Harn release that bumps the schema can simply increment
SCHEMA_VERSION in crates/harn-vm/src/bytecode_cache.rs; older binaries
reject the file as a header mismatch instead of attempting to decode an
incompatible payload.
codegen_fp is in the header rather than only inside context_hash
because the entry fast path (below) deliberately does not compute a
context hash, and a stale-compiler check that costs a graph walk is one
the fast path cannot make.
Cache directory#
Resolution order:
$HARN_CACHE_DIR(explicit override; used by tests + CI). The value must be a non-empty absolute path. An empty or relative override is a startup error — Harn will not silently write compiled bytecode next to the working directory.$XDG_CACHE_HOME/harn/bytecodewhen$XDG_CACHE_HOMEis a non-empty absolute path. An empty or relative XDG value is ignored (XDG spec).$HOME/.cache/harn/bytecode.
When none of those resolve, the cache is off for that process. There is no working-directory-relative fallback.
The directory is created lazily on the first cache write. The cache is process-local; there is no IPC, no shared lock file, and no need for one — atomic rename gives the runtime concurrent-safe writes without a mutex.
Concurrent invocations of the same script race on the rename: the last writer wins, but every reader sees a consistent file because rename is atomic on every supported filesystem.
Cache key#
The on-disk filename is <hex(source_hash)>.harnbc. We key by the
content of the entry file alone so two invocations from different
PATH-relative locations share one cache entry; the in-file
context_hash then guards against stale reuse when an imported file
changes but the entry stays identical.
Because the filename carries no graph and no location, a candidate found this way may have been written by an entirely different entry that happens to have identical bytes — two checkouts of one repository, say, where only one has local edits. Everything that distinguishes them lives inside the file, so nothing may be trusted before the header and the manifest have both agreed.
source_hash is sha256 of the entry file's bytes.
context_hash is sha256 of the canonical path + content of every user
file transitively reachable through import declarations. std/…
imports are excluded because the embedded harn_version covers them.
Unresolved imports still contribute a fixed sentinel so dropping a
matching file into place later invalidates the cache.
The import scan is a lightweight string walk, not a full lex/parse:
it strips comments and looks for import "path" and
import { … } from "path" patterns. False positives (e.g. an unrelated
string starting with import inside a heredoc) only churn the cache;
they never produce an incorrect bytecode load.
Loader / writer flow#
harn run script.harn resolves the cache like this:
- Read the source from disk (always — needed for runtime error
reporting via
vm.set_source_info). - Compute the cache key.
- Look for an adjacent
script.harnbc(shipped artifacts win over the shared cache so release builds avoid touching$HOME). - Look for
$HARN_CACHE_DIR/<source_hash>.harnbc. - Decide whether the candidate is valid (below).
- On a hit, deserialize the payload and execute. Parse, type-check, and compile are all skipped: the writer ran them.
- On a miss, parse + type-check + compile, then atomically write
the artifact back into the shared cache. Write failures are
best-effort and silent unless
HARN_BYTECODE_CACHE_DEBUG=1.
Deciding an entry chunk is valid#
Recomputing context_hash means re-reading and re-hashing the whole
import graph — a cold-path algorithm on the warm path. So an entry chunk
carries a [ContextManifest]: the entry it was walked from, plus each
reachable file's (len, mtime_ns) and the negative facts the graph
depends on (imports that resolved to nothing, paths that failed to read).
Re-checking that is stats only, and a chunk whose manifest re-checks
clean is served without any walk at all.
The manifest has to establish two separate things, and neither implies the other:
- that it describes this graph — the recorded paths are absolute and re-check clean from anywhere, so without the anchor a manifest proves only that some graph is unchanged. That is what let one entry run another's bytecode (#5591).
- that this build emitted the chunk — from the header's
codegen_fp, since the fast path never computes thecontext_hashthe fingerprint otherwise hides in (#5610). - that no recorded file moved — from
(len, mtime_ns)where that is proof, and from the recorded content digest where it is not. Filesystems quantizemtime— two seconds on FAT, one second on HFS+ and older NFS, a ~15.6ms clock tick on NTFS — so two writes inside one tick record onemtime, and a second write preserving length leaves an identity identical to the first. Each manifest therefore records when its capture began, and an entry whosemtimeis not a full granularity older than that is racily clean in git's sense: decided by re-reading its content (#5582). A hit that needed such a read writes the artifact back with a fresh capture, so the entry returns to the stats-only path next spawn rather than paying a read forever.
Anything a manifest cannot describe (a file that will not stat, a manifest that was never written, an anchor that does not match) falls back to the full walk, which recomputes the key from scratch. A manifest can only ever save work; it can never decide a hit on its own.
When the walk does run and finds the key unchanged — a touched mtime, a restored checkout — the artifact is rewritten with fresh observations, so the next spawn is back on the fast path instead of walking forever.
What the racy window costs#
The assumed granularity is the coarsest a supported filesystem can have (two seconds, FAT/exFAT), not the one in force. On ext4 or APFS, which keep nanoseconds, that over-classifies: a graph checked out and first walked inside the same two seconds — CI cloning and running immediately — has every entry racily clean, and the first warm lookups read the whole graph rather than stat it.
That is the intended trade, and it is bounded in both size and time. Measured on Linux/ext4 over a 377-module, 4.9 MB graph (release build):
| warm lookup | cost |
|---|---|
| settled — stats only | 0.27 ms |
| racily clean — content read | 1.94 ms |
| the full walk both avoid | 5.98 ms |
So the worst case still resolves about 3x faster than the walk it replaces, and it is not permanent: raciness is judged against the manifest's own capture, so re-stamping on a racy hit is what ends it. Once wall time passes the files' mtime by the granularity, the next lookup re-stamps to a settled capture and every later spawn is stats only. Only lookups landing within about two seconds of the source mtimes pay anything. Recording each entry's digest also costs the cold walk one SHA-256 pass per file — 5.98 ms to 6.76 ms on the same graph.
Probing each filesystem for its actual granularity would narrow the window, at the price of a rule whose soundness depends on the probe. The measured cost does not justify that.
Each import the VM executes at runtime follows the same protocol
for the .harnmod family: read source, look for an adjacent
<lib>.harnmod, then $HARN_CACHE_DIR/<source_hash>.harnmod. A hit
returns a [ModuleArtifact] (compiled init chunk + per-function
chunks + import list); the loader then runs the init chunk and mints
fresh closures bound to a per-process module env.
Embedded stdlib artifacts#
A stdlib module has no source file to sit beside, so without help a
first run on a new machine compiles every stdlib module it imports and
writes each one into the shared cache. An agent-stack script wrote 148
module artifacts on a cold cache (harn#9652). A host that links the
harn-stdlib-artifacts crate and calls harn_stdlib_artifacts::register()
at startup answers those imports from its binary instead: in a release
build that first run took 3.0 s without the table and 0.27 s with it,
against a 0.18 s warm floor, and wrote no module artifact. That crate's
build script compiles the stdlib catalog with the harn-vm source the host
links, so the table carries the host's own Harn version and compiler
fingerprint. It does so only with the crate's embed feature, because the
build script links a second harn-vm: a cold 12-core build of harn went
from 625 s to 664 s in release and from 337 s to 390 s in dev, with about
40% more compiler CPU. The harn CLI exposes it as embedded-stdlib,
which every distributed binary enables and the release smoke enforces;
from source, cargo install harn-cli --features embedded-stdlib. An
embedder selects embed plus the same harn-vm builtin families its own
harn-vm resolves to (full, or e.g. sqlite and native-keyring),
because the builtin contract is part of every key.
The table is read before the shared cache, never written, and judged by
the same header check as a file. register refuses a table from another
build (Harn version, compiler fingerprint or builtin contracts) with
ForeignBuild. A per-module mismatch, such as another
HARN_DISABLE_OPTIMIZATIONS setting, is a miss, and the import falls back
to the shared cache and then a compile. HARN_BYTECODE_CACHE=0 turns it off with every other artifact
read. Stdlib keys contain no path, so one table is valid on every OS.
Skipping the module source read#
That per-import read exists for one reason: to derive the module's
source_hash. Everything else in a module key is process-global. And a
manifest that re-checked clean already recorded exactly that digest for
every reachable file, because the walk that wrote it read and hashed them
all.
So a decided lookup hands the VM a GraphLinkTable — canonical path to
recorded digest — and module loading goes straight to the artifact. On a
377-module graph that removes the second read of 5.7 MB of source and the
key derivation that motivated it. The table is only built where a manifest
decided the lookup; when the walk ran it has already pulled every file
into module_source's memo, so a table there would save no read.
A table is a shortcut, never an authority:
- it names an artifact, and a module whose artifact was evicted falls back to being read and compiled;
- a module the graph never reached is not in the table, and is read;
- guard-verified package bytes are excluded — they are their own authority and deliberately bypass every memo.
The digest doing this work is the same SHA-256 that keys the entry chunk, the module artifact, and the prepared-module cache. Converging them means a warm module load hashes its source once rather than twice.
harn precompile <path> runs the same compile path and writes three
artifact families directly to disk: <name>.harnbc (entry chunk),
<name>.harnmod (module artifact), and <name>.harnifc (imported interfaces).
Shipping these means the same file
hits the cache whether the user runs it (harn run lib.harn) or
imports it from another script. Pass a directory to walk it; otherwise
it compiles a single file. --out DIR mirrors the input layout under
DIR; without --out, artifacts land adjacent to each source. Burin
Code's release pipeline runs harn precompile against its bundled
Sources/BurinCore/Resources/pipelines/ so the shipped DMG already
contains the artifacts for every script the user might run.
Directory precompile treats the walked tree as a relocatable artifact set:
entry keys identify imported files by their path relative to each entrypoint,
so moving an unchanged complete tree to another checkout or machine keeps its
adjacent artifacts valid. Those relative paths are spelled with / on
every host, so a tree precompiled on macOS or Linux stays valid on
Windows. A single-file precompile keeps the ordinary
host-path-anchored key because Harn cannot prove that an independently moved
dependency graph is complete. Build tooling can query the exact compatibility
token with harn precompile --artifact-contract; it must not infer artifact
relocatability from a Harn version string.
Builds embed LF source text on every platform, including stdlib scripts and diagnostic explanations. Git attributes preserve those bytes during checkout because compiler fingerprints and the stdlib digest remain content proofs; changing those inputs still invalidates artifacts. Binary assets retain their bytes.
Shipped module interfaces#
The adjacent .harnifc stores the entry's imported names and kinds and those
of every dependency in its graph. Dependency labels are relative to the entry,
with / separators; package dependencies use their place within the package,
independent of install generation. The payload contains no absolute paths or timestamps.
The header uses the same relocatable graph identity as an adjacent entry chunk,
including source content, resolution, embedded stdlib, compiler build, and options.
The package manager attaches declared root aliases and the reachable lock digest;
entry hits revalidate both against the current project before using bytecode.
Entry loading and module preparation read valid adjacent interfaces before the shared interface cache. Validation scans and hashes the import closure without parsing it, then attaches the stored typed contexts to a fresh local manifest. The manifest can supply module keys and validate later in-process reuse. Shipped interfaces are never refreshed or copied into the shared cache during loading. Missing, stale, or foreign artifacts fall back to ordinary interface derivation.
An unchanged complete precompiled host tree therefore needs no shared cache write for its user modules. A host also needs matching embedded stdlib artifacts to avoid shared writes for stdlib imports. This is a cache-artifact guarantee; sessions and other runtime state have their own persistence contracts.
Preparing an installed tree#
harn precompile --warm-cache main.harn fills the runtime's shared entry,
module, and interface caches without executing the program. It follows the
complete import graph, including installed packages and stdlib. Module keys use
the entrypoint's compiler authority, as runtime imports do. Package sources stay
untouched, including sealed installed generations. A failed write fails the
command instead of reporting successful preparation.
Use this mode after installing or relocating a host tree when its dependencies
are outside the directory walked for adjacent artifacts, or when it imports a
package under another compiler authority. Set HARN_CACHE_DIR to the host's
cache, and repeat after source, package, or compiler changes. These keys belong
to the installed paths; adjacent artifacts remain the portable packaging form.
Artifacts also identify the linked builtin contracts. A host with a lean or
extended builtin catalog is a different compiler backend from the standalone
CLI. Such hosts prepare through harn_vm::bytecode_cache::CachePrecompiler
linked into their executing backend. Register that backend's builtins, call
prepare for each source root, and call prepare_stdlib for roots loaded
dynamically by native capabilities. Static stdlib imports are included
automatically. prepare_stdlib_catalog requires all stdlib integrations to be
linked. Hosts invoking standalone
CLI helpers also prepare those entrypoints through the CLI's backend. The helper derives each graph's typed
contexts once, prepares modules and their individual dependency proofs, and
returns the entry context and store identity. The host owns entry compilation
and package authority; a host that loads files as modules need not persist its
inline entry chunk. Preparation never instantiates a module or calls a model.
Toggles and environment#
HARN_CACHE_DIR=<absolute-path>— relocate the cache directory. Empty and relative values are rejected at startup.HARN_BYTECODE_CACHE=0— disable both reads and writes (compiler debugging, deterministic eval reruns).HARN_BYTECODE_CACHE_DEBUG=1— surface cache write failures.
Type-check warnings on cache hit#
Cache hits skip parse + type-check, which means non-fatal type-check
warnings (e.g. deprecated-builtin notices) are not re-emitted from a
cached invocation. The warning was emitted once when the cache wrote
the artifact, and it re-emits whenever the cache busts. harn check
remains the canonical surface for the complete diagnostic list — use
it if you need every warning every time, or set
HARN_BYTECODE_CACHE=0 to force a fresh compile.
What gets cached#
Three disk artifact families share the same header but use distinct file extensions so they coexist in one directory:
- Entry chunks (
.harnbc) — the compiled [Chunk] for the script passed toharn run. The shortcut: cache hit, skip parse + typecheck and compile, go straight to VM. - Module artifacts (
.harnmod) — the [ModuleArtifact] for each imported user file or stdlib module. The shortcut: cache hit, skip parse + per-function compile of the imported module; the loader still has to run the module'sinitchunk and mint per-process closures. Module caching is what closes the cold-start gap for pipelines whose cost is dominated byimports rather than the entry source itself. - Imported interfaces (
.harnifc): typed imported names and kinds with graph validation. Precompile emits relocatable adjacent payloads; ordinary execution stores host-anchored manifests in the shared cache.
Stdlib modules use the same artifact format as user modules; the
STDLIB_MODULE_ARTIFACT_CACHE runs per process, with the on-disk artifact
across processes.
A single precompiled .harn source produces three adjacent files.
Out of scope#
- JIT (LLVM/Cranelift). Interpreted bytecode plus the cache is enough for the cold-start gate behind the IDE-host CLI-porting effort.
- Cross-process shared cache / IPC.
- Standalone artifact loading without source. The current loader recomputes the key from the on-disk source, so the source has to exist. Shipping bytecode without source would require dropping the rehash and trusting the embedded hash — a follow-on if an IDE host's release pipeline grows that constraint.