Skip to main content

harn_vm/
bytecode_cache.rs

1//! Content-addressed on-disk cache for compiled `.harn` pipelines.
2//!
3//! Cold-start `harn run` re-parses, type-checks, and compiles the entry
4//! pipeline before the VM gets a single instruction to execute. For short
5//! Harn subcommands that wrap a few `llm_call`s in a small pipeline, that
6//! compile cost dominates wall-clock time.
7//!
8//! This module persists [`Chunk`] bytecode under
9//! `$HARN_CACHE_DIR/<source-hash>.harnbc` (XDG-aware). The cache key is
10//! derived from the source plus its compilation context. Entry chunks include
11//! the content of every transitively-imported user file because they compile
12//! the complete program. Module artifacts compile exactly one file and retain
13//! unresolved import specs, so their context includes compiler and embedded
14//! stdlib identity but deliberately excludes user dependencies. Any change to
15//! an artifact's actual compilation inputs flips the key and recompiles it.
16//!
17//! File layout — little-endian throughout:
18//!
19//! ```text
20//! magic        : [u8; 8]   = "HARNBC\0\0"
21//! schema_ver   : u32       = SCHEMA_VERSION
22//! version_len  : u32
23//! harn_version : [u8; version_len]
24//! fp_len       : u32
25//! codegen_fp   : [u8; fp_len]   CODEGEN_FINGERPRINT of the producing build
26//! compiler_tag : u8        bitmask of active CompilerOptions
27//! kind         : u8        1 = entry chunk, 2 = module artifact
28//! source_hash  : [u8; 32]
29//! context_hash : [u8; 32]
30//! payload      : postcard-serialized payload for `kind`
31//! ```
32//!
33//! The header lets a stale binary detect a future-version artifact
34//! without crashing: a magic mismatch, schema mismatch, or version
35//! mismatch is returned as `Ok(None)` so the caller transparently
36//! recompiles. Real I/O errors propagate.
37//!
38//! Concurrency: writes go through [`crate::atomic_io`] (write-tmp, fsync,
39//! rename, fsync parent dir), and parallel invocations on a cache miss race
40//! safely — the last writer wins, but every reader observes a consistent file
41//! because the rename is atomic on every supported filesystem.
42
43use std::fs;
44use std::io::{self, Read as _};
45use std::path::{Path, PathBuf};
46use std::sync::Arc;
47
48use serde::{de::DeserializeOwned, Serialize};
49use sha2::{Digest, Sha256};
50
51use crate::chunk::{CachedChunk, Chunk};
52use crate::compiler::CompilerOptions;
53use crate::context_manifest::{
54    ContextManifest, GraphLinkTable, ManifestCheck, ManifestFile, ManifestUnreadable,
55    ManifestUnresolved,
56};
57use crate::module_artifact::ModuleArtifact;
58use crate::module_source::{self, ModuleSource};
59
60/// Header magic for all bytecode-cache artifact families.
61pub const MAGIC: &[u8; 8] = b"HARNBC\0\0";
62
63/// On-disk format version. Bump when [`CachedChunk`] or the header
64/// layout changes in a backwards-incompatible way.
65/// v5: `ModuleArtifact` gained `public_type_names` (`pub type` exports).
66/// v6: payload encoding replaced with postcard.
67/// v7: exported type schemas moved from eager JSON strings to an initializer
68/// chunk that resolves imported aliases in the module environment.
69/// v7: `ModuleArtifact` replaced split name sets with the typed public export
70/// contract shared by the module graph and runtime.
71/// v8: entry-chunk payload carries a [`ContextManifest`] so a warm lookup can
72/// prove the import graph is unchanged with stats instead of re-walking it.
73/// v9: the manifest records the entry it was walked from, so it cannot vouch
74/// for a different entry that happens to have identical source bytes (#5591);
75/// the header carries [`CODEGEN_FINGERPRINT`], which the manifest fast path
76/// needs in order to reject a chunk built by another compiler at the same
77/// version (#5610); and manifest entries carry a content digest, and the
78/// manifest a capture time, so a rewrite inside the filesystem's timestamp
79/// granularity cannot present itself as unchanged (#5582).
80pub const SCHEMA_VERSION: u32 = 9;
81
82/// Compile-time Harn release. Cache files written by a different release
83/// are rejected on load.
84pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
85
86/// Build-time fingerprint of the compiler front-end — the lexer, parser, IR,
87/// and code generator — computed in `build.rs` from those crates' source and
88/// baked in via `cargo:rustc-env`. Folded into the cache key so a compiler
89/// change that alters emitted bytecode for unchanged source invalidates stale
90/// entries automatically, within a single version, with no manual cache wipe.
91/// `HARN_VERSION` only busts the cache across release bumps; this closes the
92/// same gap for the within-version compiler edits that masked #2610. See #2621.
93///
94/// It reaches a lookup two ways. The header comparison is what *rejects* a
95/// stale artifact, and is the only one the entry fast path can afford, since
96/// that path proves its graph from a manifest and never recomputes the context
97/// hash (#5610). Folding it into the context hash as well is what keeps two
98/// builds' module artifacts on distinct filenames rather than overwriting each
99/// other, since `module_filename` is derived from that hash.
100pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
101
102/// Conventional extension for entry-chunk cache files.
103pub const CACHE_EXTENSION: &str = "harnbc";
104
105/// Conventional extension for module-artifact cache files. Distinct from
106/// [`CACHE_EXTENSION`] so the same `.harn` source can have both shipped
107/// adjacent if needed (e.g. when a file is both an executable entry and
108/// imported by other files).
109pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
110
111/// On-disk discriminant for a [`Chunk`] payload.
112const KIND_ENTRY_CHUNK: u8 = 1;
113/// On-disk discriminant for a [`ModuleArtifact`] payload.
114const KIND_MODULE_ARTIFACT: u8 = 2;
115
116/// Environment override for the cache directory. When set, takes
117/// precedence over the XDG and home-directory fallbacks.
118pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
119
120/// Environment override that turns the cache off entirely. Setting this
121/// to `0`, `false`, `no`, or `off` skips both reads and writes; useful
122/// when debugging compiler changes.
123pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
124
125/// Result of a cache lookup. Carries the precomputed key so the caller
126/// can write it back on a miss without rehashing.
127pub struct LookupOutcome {
128    pub key: CacheKey,
129    pub chunk: Option<Chunk>,
130    /// Graph observations to persist alongside the chunk, so the next spawn can
131    /// re-check them with stats instead of walking. `None` when the graph holds
132    /// something stats cannot describe.
133    pub manifest: Option<ContextManifest>,
134    /// The graph's link table, present only when this lookup proved a stored
135    /// manifest current. Hand it to the VM and module loading resolves every
136    /// module the table names without reading its source.
137    ///
138    /// Deliberately absent whenever the walk ran, even though the walk's
139    /// observations are just as accurate: the walk has already read every file
140    /// into [`module_source`]'s memo, so a table would save nothing and cost a
141    /// map to build.
142    pub link_table: Option<Arc<GraphLinkTable>>,
143}
144
145impl LookupOutcome {
146    /// Persist `chunk` under the key this lookup computed, with the manifest it
147    /// observed.
148    ///
149    /// The pairing is the point: the key and the manifest describe one walk of
150    /// one graph, and storing a chunk against a manifest from a different walk
151    /// would let a later spawn prove the wrong thing. Callers cannot get that
152    /// pairing wrong if they never have to assemble it.
153    pub fn store(&self, chunk: &Chunk) -> io::Result<()> {
154        store(&self.key, chunk, self.manifest.as_ref())
155    }
156}
157
158/// Cache key components for a single pipeline source. Equality of all
159/// fields is necessary and sufficient for cache reuse.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct CacheKey {
162    pub source_hash: [u8; 32],
163    pub context_hash: [u8; 32],
164    pub harn_version: &'static str,
165    /// Compact tag for active [`CompilerOptions`]. Flipping
166    /// `HARN_DISABLE_OPTIMIZATIONS` between runs would otherwise reuse a
167    /// chunk compiled under the wrong setting.
168    pub compiler_tag: u8,
169}
170
171impl CacheKey {
172    /// Compute the cache key for a `.harn` source file plus its transitive
173    /// user imports. `source` is the entry-file contents; the import
174    /// graph is walked from disk relative to `source_path`.
175    pub fn from_source(source_path: &Path, source: &str) -> Self {
176        let source_hash = sha256(source.as_bytes());
177        let context_hash = hash_transitive_user_imports(source_path, source);
178        Self {
179            source_hash,
180            context_hash,
181            harn_version: HARN_VERSION,
182            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
183        }
184    }
185
186    /// Compute the cache key for one independently-compiled module artifact.
187    ///
188    /// A [`ModuleArtifact`] stores unresolved import specs and never compiles
189    /// dependency contents into the parent artifact. Walking the transitive
190    /// graph here therefore adds cold-start I/O without protecting correctness:
191    /// the runtime loads every dependency under its own source-local key.
192    /// Diagnostic paths are rebound when the artifact is loaded, so adjacent
193    /// and packaged artifacts remain relocatable without aliasing attribution.
194    pub fn from_module_source(source: &ModuleSource) -> Self {
195        Self::from_module_content_hash(source.sha256())
196    }
197
198    /// As [`from_module_source`](Self::from_module_source), but from a digest
199    /// recorded earlier instead of bytes in hand.
200    ///
201    /// The digest is the only part of a module key that depends on the file;
202    /// every other component is process-global. That asymmetry is what lets a
203    /// validated [`GraphLinkTable`] name a module's artifact without the file
204    /// being read again.
205    pub fn from_module_content_hash(content_hash: [u8; 32]) -> Self {
206        Self {
207            source_hash: content_hash,
208            context_hash: module_compilation_context_hash(),
209            harn_version: HARN_VERSION,
210            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
211        }
212    }
213
214    /// Entry-chunk filename for this key. We hash by source content
215    /// alone so two invocations of the same source from different paths
216    /// share a cache entry; the header's compilation-context hash still gates
217    /// reuse on a per-load basis.
218    pub fn filename(&self) -> String {
219        format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
220    }
221
222    /// Module-artifact filename for this complete compilation key. Diagnostic
223    /// source paths are rebound at load time, so identical source and compiler
224    /// inputs share one relocatable artifact across paths.
225    pub fn module_filename(&self) -> String {
226        let mut hasher = Sha256::new();
227        hasher.update(self.source_hash);
228        hasher.update(self.context_hash);
229        hasher.update(self.harn_version.as_bytes());
230        hasher.update([self.compiler_tag]);
231        let identity: [u8; 32] = hasher.finalize().into();
232        format!("{}.{}", hex(&identity), MODULE_CACHE_EXTENSION)
233    }
234}
235
236/// Returns the directory the shared cache lives in. Honors
237/// `$HARN_CACHE_DIR`, then `$XDG_CACHE_HOME/harn/bytecode`, then
238/// `$HOME/.cache/harn/bytecode`. The directory is *not* created here —
239/// [`store`] creates it lazily on write so read-only environments don't
240/// pay an mkdir cost.
241pub fn cache_dir() -> PathBuf {
242    if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
243        return PathBuf::from(custom);
244    }
245    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
246        let xdg = PathBuf::from(xdg);
247        if !xdg.as_os_str().is_empty() {
248            return xdg.join("harn").join("bytecode");
249        }
250    }
251    if let Some(home) = crate::user_dirs::home_dir() {
252        return home.join(".cache").join("harn").join("bytecode");
253    }
254    // Final fallback: a directory beside the binary's working dir. Mostly
255    // hit in tests that scrub HOME from the environment.
256    PathBuf::from(".harn-cache").join("bytecode")
257}
258
259/// Root for `.harnpack` archives unpacked by `harn run <bundle.harnpack>`.
260/// Each verified bundle is replayed into `<root>/<sanitized-bundle-hash>/`
261/// so re-runs reuse the unpacked tree. Honors `$HARN_CACHE_DIR/packs`
262/// when set, otherwise XDG / `$HOME/.cache/harn/packs`.
263pub fn packs_cache_dir() -> PathBuf {
264    if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
265        return PathBuf::from(custom).join("packs");
266    }
267    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
268        let xdg = PathBuf::from(xdg);
269        if !xdg.as_os_str().is_empty() {
270            return xdg.join("harn").join("packs");
271        }
272    }
273    if let Some(home) = crate::user_dirs::home_dir() {
274        return home.join(".cache").join("harn").join("packs");
275    }
276    PathBuf::from(".harn-cache").join("packs")
277}
278
279/// True when the cache is enabled by the current environment.
280pub fn cache_enabled() -> bool {
281    match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
282        Some(value) => !matches!(
283            value.to_ascii_lowercase().as_str(),
284            "0" | "false" | "no" | "off"
285        ),
286        None => true,
287    }
288}
289
290/// Try to load a cached chunk for `source_path` whose contents are
291/// `source`. Returns the key alongside the (optional) chunk so callers
292/// avoid recomputing the key on miss.
293pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
294    // Only the entry file's own hash is needed to find candidates. The context
295    // hash — the expensive half — is deferred until a candidate actually asks
296    // for it, because a candidate carrying a still-valid manifest never does.
297    let mut key = CacheKey {
298        source_hash: sha256(source.as_bytes()),
299        context_hash: [0u8; 32],
300        harn_version: HARN_VERSION,
301        compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
302    };
303    let mut walk = GraphWalk::new(source_path, source);
304
305    if !cache_enabled() {
306        let (context_hash, manifest) = walk.finish();
307        key.context_hash = context_hash;
308        return LookupOutcome {
309            key,
310            chunk: None,
311            manifest,
312            link_table: None,
313        };
314    }
315
316    let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
317    if let Some(adjacent) = adjacent_cache_path(source_path) {
318        candidates.push(adjacent);
319    }
320    candidates.push(cache_dir().join(key.filename()));
321
322    // Candidates are found by entry source hash alone, so a candidate may have
323    // been written by a *different* entry with byte-identical source. Its
324    // manifest has to say it describes this one before its observations mean
325    // anything here.
326    let entry = module_source::canonical_identity(source_path);
327
328    for path in candidates {
329        let Ok(Some(candidate)) = read_entry_candidate(&path, &key) else {
330            continue;
331        };
332        match candidate
333            .manifest
334            .as_ref()
335            .map(|manifest| manifest.check(&entry))
336        {
337            // The graph is provably unchanged, so the stored context hash is
338            // still the one this source would produce.
339            Some(ManifestCheck::Valid) => {
340                key.context_hash = candidate.context_hash;
341                return LookupOutcome {
342                    key,
343                    chunk: Some(candidate.chunk),
344                    link_table: candidate.manifest.as_ref().map(link_table_for),
345                    manifest: candidate.manifest,
346                };
347            }
348            // Same answer, but it cost a content read because some entry was
349            // still inside the racy window when this manifest was captured.
350            // Writing the re-stamped manifest back settles those entries, so
351            // the read is paid once rather than on every later spawn.
352            Some(ManifestCheck::ValidAfterRecheck { refreshed }) => {
353                key.context_hash = candidate.context_hash;
354                let _ = write_atomic_chunk(&path, &key, &candidate.chunk, Some(&refreshed));
355                return LookupOutcome {
356                    key,
357                    chunk: Some(candidate.chunk),
358                    link_table: Some(link_table_for(&refreshed)),
359                    manifest: Some(refreshed),
360                };
361            }
362            Some(ManifestCheck::Stale) | None => {}
363        }
364        if walk.context_hash() != candidate.context_hash {
365            continue;
366        }
367        // The graph moved in a way that does not change the key — a touched
368        // mtime, a restored checkout. Refresh the artifact so the next spawn
369        // gets the fast path back instead of re-walking forever.
370        key.context_hash = candidate.context_hash;
371        let manifest = walk.manifest().cloned();
372        let _ = write_atomic_chunk(&path, &key, &candidate.chunk, manifest.as_ref());
373        return LookupOutcome {
374            key,
375            chunk: Some(candidate.chunk),
376            manifest,
377            link_table: None,
378        };
379    }
380
381    let (context_hash, manifest) = walk.finish();
382    key.context_hash = context_hash;
383    LookupOutcome {
384        key,
385        chunk: None,
386        manifest,
387        link_table: None,
388    }
389}
390
391/// Index `manifest` for the module loader. Called only where a re-check has
392/// just proven the manifest current, which is the whole basis for loading the
393/// artifacts it names without reading their sources.
394fn link_table_for(manifest: &ContextManifest) -> Arc<GraphLinkTable> {
395    Arc::new(GraphLinkTable::from_validated(manifest))
396}
397
398/// The import-graph walk, run at most once per lookup and only when a
399/// candidate cannot prove itself with its manifest.
400struct GraphWalk<'a> {
401    source_path: &'a Path,
402    source: &'a str,
403    result: Option<([u8; 32], Option<ContextManifest>)>,
404}
405
406impl<'a> GraphWalk<'a> {
407    fn new(source_path: &'a Path, source: &'a str) -> Self {
408        Self {
409            source_path,
410            source,
411            result: None,
412        }
413    }
414
415    fn run(&mut self) -> &([u8; 32], Option<ContextManifest>) {
416        self.result.get_or_insert_with(|| {
417            hash_transitive_user_imports_with_manifest(self.source_path, self.source)
418        })
419    }
420
421    fn context_hash(&mut self) -> [u8; 32] {
422        self.run().0
423    }
424
425    fn manifest(&mut self) -> Option<&ContextManifest> {
426        self.run().1.as_ref()
427    }
428
429    fn finish(mut self) -> ([u8; 32], Option<ContextManifest>) {
430        self.run();
431        self.result.expect("the walk was just run")
432    }
433}
434
435/// Persist `chunk` to the shared cache directory under `key`. Atomic: a
436/// temp file is written then renamed into place. Concurrent invocations
437/// on the same key race safely.
438pub fn store(key: &CacheKey, chunk: &Chunk, manifest: Option<&ContextManifest>) -> io::Result<()> {
439    if !cache_enabled() {
440        return Ok(());
441    }
442    let dir = cache_dir();
443    fs::create_dir_all(&dir)?;
444    write_atomic_chunk(&dir.join(key.filename()), key, chunk, manifest)
445}
446
447/// Write a precompiled entry-chunk artifact to an explicit path, for
448/// use by the `harn precompile` subcommand. The header still records
449/// the key, so adjacent artifacts shipped with source are validated
450/// like any other cache hit.
451pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
452    ensure_parent_dir(path)?;
453    write_atomic_chunk(path, key, chunk, None)
454}
455
456/// Look up the [`ModuleArtifact`] for `source_path` (whose contents are
457/// `source`). Mirrors [`load`] but for the `.harnmod` family.
458pub fn load_module(source_path: &Path, source: &ModuleSource) -> ModuleLookupOutcome {
459    load_module_for_key(source_path, CacheKey::from_module_source(source))
460}
461
462/// As [`load_module`], but for a key already known without reading the source.
463///
464/// `artifact` is `None` when nothing is stored under `key` — including when it
465/// was evicted from the shared cache directory. A known key is a shortcut to an
466/// artifact, not a promise that one exists, so a caller that gets `None` must
467/// fall back to reading and compiling.
468pub fn load_module_for_key(source_path: &Path, key: CacheKey) -> ModuleLookupOutcome {
469    if !cache_enabled() {
470        return ModuleLookupOutcome {
471            key,
472            artifact: None,
473        };
474    }
475    let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
476    if let Some(adjacent) = adjacent_module_cache_path(source_path) {
477        candidates.push(adjacent);
478    }
479    candidates.push(cache_dir().join(key.module_filename()));
480    for path in candidates {
481        match read_module_if_matches(&path, &key, source_path) {
482            Ok(Some(artifact)) => {
483                return ModuleLookupOutcome {
484                    key,
485                    artifact: Some(artifact),
486                }
487            }
488            Ok(None) => continue,
489            Err(_) => continue,
490        }
491    }
492    ModuleLookupOutcome {
493        key,
494        artifact: None,
495    }
496}
497
498/// Persist `artifact` to the shared cache under `key`. Atomic;
499/// concurrent invocations race safely.
500pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
501    if !cache_enabled() {
502        return Ok(());
503    }
504    let dir = cache_dir();
505    fs::create_dir_all(&dir)?;
506    write_atomic_module(&dir.join(key.module_filename()), key, artifact)
507}
508
509/// Write a module artifact to an explicit path.
510pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
511    ensure_parent_dir(path)?;
512    write_atomic_module(path, key, artifact)
513}
514
515/// Result of a [`load_module`] lookup. Carries the precomputed key so
516/// the caller can write it back on a miss without rehashing.
517pub struct ModuleLookupOutcome {
518    pub key: CacheKey,
519    pub artifact: Option<ModuleArtifact>,
520}
521
522/// Path to the adjacent precompiled entry-chunk artifact for
523/// `source_path`. `foo.harn` → `foo.harnbc`.
524pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
525    adjacent_path_with_extension(source_path, CACHE_EXTENSION)
526}
527
528/// Path to the adjacent precompiled module-artifact for `source_path`.
529/// `foo.harn` → `foo.harnmod`.
530pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
531    adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
532}
533
534fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
535    let stem = source_path.file_stem()?;
536    if stem.is_empty() {
537        return None;
538    }
539    let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
540    let mut out = parent.join(stem);
541    out.set_extension(ext);
542    Some(out)
543}
544
545fn ensure_parent_dir(path: &Path) -> io::Result<()> {
546    if let Some(parent) = path.parent() {
547        if !parent.as_os_str().is_empty() {
548            fs::create_dir_all(parent)?;
549        }
550    }
551    Ok(())
552}
553
554fn write_atomic_chunk(
555    target: &Path,
556    key: &CacheKey,
557    chunk: &Chunk,
558    manifest: Option<&ContextManifest>,
559) -> io::Result<()> {
560    let buf = serialize_chunk_artifact_with_manifest(key, chunk, manifest)?;
561    crate::atomic_io::atomic_write(target, &buf)
562}
563
564fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
565    let buf = serialize_module_artifact(key, artifact)?;
566    crate::atomic_io::atomic_write(target, &buf)
567}
568
569/// Serialize an entry-chunk artifact (header + payload) to bytes. The
570/// resulting buffer is byte-identical to the file [`store_at`] would
571/// have written for the same `(key, chunk)`. Use this when packaging
572/// artifacts into a container (e.g. `harn pack`) without going through
573/// the filesystem.
574pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
575    serialize_chunk_artifact_with_manifest(key, chunk, None)
576}
577
578/// As [`serialize_chunk_artifact`], but records `manifest` so a later lookup
579/// can prove the graph unchanged without walking it.
580///
581/// Callers producing *relocatable* artifacts (`harn pack`, `harn precompile`)
582/// pass `None`: a manifest names absolute paths on the machine that built it,
583/// which say nothing on the machine that runs it. Those artifacts stay on the
584/// walk, which is correct everywhere.
585pub fn serialize_chunk_artifact_with_manifest(
586    key: &CacheKey,
587    chunk: &Chunk,
588    manifest: Option<&ContextManifest>,
589) -> io::Result<Vec<u8>> {
590    let payload = serialize_cache_payload(&EntryPayload {
591        manifest: manifest.cloned(),
592        chunk: chunk.freeze_for_cache(),
593    })?;
594    Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
595}
596
597/// Serialize a module artifact (header + payload) to bytes. Companion
598/// to [`serialize_chunk_artifact`] for the `.harnmod` family.
599pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
600    let payload = serialize_cache_payload(artifact)?;
601    Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
602}
603
604/// Entry-chunk payload. The manifest rides with the chunk so one atomic write
605/// keeps them consistent: a chunk can never be paired with a manifest that
606/// describes a different graph.
607#[derive(serde::Serialize, serde::Deserialize)]
608struct EntryPayload {
609    manifest: Option<ContextManifest>,
610    chunk: CachedChunk,
611}
612
613fn serialize_cache_payload<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
614    postcard::to_allocvec(value)
615        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
616}
617
618fn deserialize_cache_payload<T: DeserializeOwned>(payload: &[u8]) -> Result<T, String> {
619    let (value, remaining) = postcard::take_from_bytes(payload).map_err(|err| err.to_string())?;
620    if remaining.is_empty() {
621        Ok(value)
622    } else {
623        Err("cache payload contains trailing bytes".to_string())
624    }
625}
626
627fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
628    encode_artifact_fingerprinted(key, kind, payload, CODEGEN_FINGERPRINT)
629}
630
631/// Inner form of [`encode_artifact`] parameterized on the compiler fingerprint
632/// so tests can write an artifact as if a different build had produced it;
633/// production always passes [`CODEGEN_FINGERPRINT`].
634fn encode_artifact_fingerprinted(
635    key: &CacheKey,
636    kind: u8,
637    payload: &[u8],
638    codegen_fingerprint: &str,
639) -> Vec<u8> {
640    let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
641    buf.extend_from_slice(MAGIC);
642    buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
643    let version_bytes = HARN_VERSION.as_bytes();
644    buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
645    buf.extend_from_slice(version_bytes);
646    let fingerprint_bytes = codegen_fingerprint.as_bytes();
647    buf.extend_from_slice(&(fingerprint_bytes.len() as u32).to_le_bytes());
648    buf.extend_from_slice(fingerprint_bytes);
649    buf.push(key.compiler_tag);
650    buf.push(kind);
651    buf.extend_from_slice(&key.source_hash);
652    buf.extend_from_slice(&key.context_hash);
653    buf.extend_from_slice(payload);
654    buf
655}
656
657/// Reads `len` bytes and reports whether they equal `expected`.
658///
659/// `len` comes off disk, so it is bounded before it becomes an allocation: a
660/// corrupted or hostile file must not be able to ask for an unbounded read.
661/// A length that cannot match `expected` is rejected without reading at all.
662fn read_length_prefixed_match(file: &mut fs::File, len: usize, expected: &[u8]) -> bool {
663    if len > 256 || len != expected.len() {
664        return false;
665    }
666    let mut buf = vec![0u8; len];
667    file.read_exact(&mut buf).is_ok() && buf == expected
668}
669
670/// Parsed cache header. Read by both the chunk and module loaders so the
671/// header-validation logic stays in one place.
672struct ParsedHeader {
673    kind: u8,
674    context_hash: [u8; 32],
675    payload: Vec<u8>,
676}
677
678/// Read and validate a header.
679///
680/// `expected_context` is `None` for entry chunks, which decide validity from
681/// the artifact's own manifest before they are willing to pay for the
682/// context hash. Every other field is checked the same way for both families.
683fn read_header_if_matches(
684    path: &Path,
685    key: &CacheKey,
686    expected_context: Option<&[u8; 32]>,
687) -> io::Result<Option<ParsedHeader>> {
688    let mut file = match fs::File::open(path) {
689        Ok(f) => f,
690        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
691        Err(err) => return Err(err),
692    };
693    let mut header = [0u8; 8 + 4 + 4];
694    if file.read_exact(&mut header).is_err() {
695        return Ok(None);
696    }
697    if &header[..8] != MAGIC {
698        return Ok(None);
699    }
700    let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
701    if schema != SCHEMA_VERSION {
702        return Ok(None);
703    }
704    let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
705    if !read_length_prefixed_match(&mut file, version_len, key.harn_version.as_bytes()) {
706        return Ok(None);
707    }
708    // Which build produced this artifact, checkable without computing anything.
709    // The entry fast path proves its graph unchanged from a manifest and never
710    // recomputes the context hash, so a fingerprint carried only inside that
711    // hash would go unexamined and a chunk from a previous build of the same
712    // release would be replayed. See #5610.
713    let mut fingerprint_len_bytes = [0u8; 4];
714    if file.read_exact(&mut fingerprint_len_bytes).is_err() {
715        return Ok(None);
716    }
717    let fingerprint_len = u32::from_le_bytes(fingerprint_len_bytes) as usize;
718    if !read_length_prefixed_match(&mut file, fingerprint_len, CODEGEN_FINGERPRINT.as_bytes()) {
719        return Ok(None);
720    }
721    let mut compiler_and_kind = [0u8; 2];
722    if file.read_exact(&mut compiler_and_kind).is_err() {
723        return Ok(None);
724    }
725    if compiler_and_kind[0] != key.compiler_tag {
726        return Ok(None);
727    }
728    let kind = compiler_and_kind[1];
729    let mut hashes = [0u8; 64];
730    if file.read_exact(&mut hashes).is_err() {
731        return Ok(None);
732    }
733    if hashes[..32] != key.source_hash {
734        return Ok(None);
735    }
736    let mut context_hash = [0u8; 32];
737    context_hash.copy_from_slice(&hashes[32..]);
738    if expected_context.is_some_and(|expected| *expected != context_hash) {
739        return Ok(None);
740    }
741    let mut payload = Vec::new();
742    if file.read_to_end(&mut payload).is_err() {
743        return Ok(None);
744    }
745    Ok(Some(ParsedHeader {
746        kind,
747        context_hash,
748        payload,
749    }))
750}
751
752/// A candidate entry artifact whose header matches everything except the
753/// context hash, which the caller decides about.
754struct CandidateEntry {
755    context_hash: [u8; 32],
756    manifest: Option<ContextManifest>,
757    chunk: Chunk,
758}
759
760fn read_entry_candidate(path: &Path, key: &CacheKey) -> io::Result<Option<CandidateEntry>> {
761    let Some(header) = read_header_if_matches(path, key, None)? else {
762        return Ok(None);
763    };
764    if header.kind != KIND_ENTRY_CHUNK {
765        return Ok(None);
766    }
767    let payload: EntryPayload = match deserialize_cache_payload(&header.payload) {
768        Ok(p) => p,
769        Err(_) => return Ok(None),
770    };
771    Ok(Some(CandidateEntry {
772        context_hash: header.context_hash,
773        manifest: payload.manifest,
774        chunk: Chunk::from_cached(payload.chunk),
775    }))
776}
777
778fn read_module_if_matches(
779    path: &Path,
780    key: &CacheKey,
781    source_path: &Path,
782) -> io::Result<Option<ModuleArtifact>> {
783    let Some(header) = read_header_if_matches(path, key, Some(&key.context_hash))? else {
784        return Ok(None);
785    };
786    if header.kind != KIND_MODULE_ARTIFACT {
787        return Ok(None);
788    }
789    match deserialize_cache_payload::<ModuleArtifact>(&header.payload) {
790        Ok(mut artifact) => {
791            artifact.bind_source_file(source_path);
792            Ok(Some(artifact))
793        }
794        Err(_) => Ok(None),
795    }
796}
797
798/// Compact representation of [`CompilerOptions`] for the cache header.
799/// Independent flags get distinct bits so adding a new flag never
800/// silently changes existing keys when an old binary reads a new
801/// artifact — the header check will fail-closed before we get there
802/// anyway, but mapping to bits also keeps the tag a stable function
803/// of the option set.
804fn compiler_options_tag(options: CompilerOptions) -> u8 {
805    let mut tag: u8 = 0;
806    if options.optimizations_enabled() {
807        tag |= 0b0000_0001;
808    }
809    if options.legacy_ambient_capabilities() {
810        tag |= 0b0000_0010;
811    }
812    tag
813}
814
815fn sha256(bytes: &[u8]) -> [u8; 32] {
816    let mut hasher = Sha256::new();
817    hasher.update(bytes);
818    hasher.finalize().into()
819}
820
821fn hex(bytes: &[u8]) -> String {
822    let mut out = String::with_capacity(bytes.len() * 2);
823    for byte in bytes {
824        out.push_str(&format!("{byte:02x}"));
825    }
826    out
827}
828
829/// Stable digest over every embedded stdlib source. Folded into the
830/// user-file cache key so that bumping a stdlib module (changing its
831/// embedded `.harn` content) invalidates cached user bytecode that may
832/// reference stale function-pool layouts from a prior stdlib snapshot.
833/// `HARN_VERSION` already busts the cache across release bumps; this
834/// closes the same gap for within-version stdlib edits (a frequent
835/// pattern during local development).
836///
837/// Cached in a `OnceLock` because `STDLIB_SOURCES` is a static `const`
838/// slice — the digest is identical for the lifetime of the process.
839fn embedded_stdlib_digest() -> &'static [u8; 32] {
840    use std::sync::OnceLock;
841    static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
842    DIGEST.get_or_init(|| {
843        let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
844            .iter()
845            .map(|src| (src.module, src.source))
846            .collect();
847        entries.sort_by(|a, b| a.0.cmp(b.0));
848        let mut hasher = Sha256::new();
849        for (module, source) in entries {
850            hasher.update(module.as_bytes());
851            hasher.update(b"\0");
852            hasher.update(source.as_bytes());
853            hasher.update(b"\0");
854        }
855        hasher.finalize().into()
856    })
857}
858
859/// Stable compilation context for a source-local module artifact.
860///
861/// Module compilation does not inspect user dependencies. Artifact-local
862/// compiler and stdlib identity belongs in the key; the source path does not,
863/// because it is load context and is rebound after deserialization.
864fn module_compilation_context_hash() -> [u8; 32] {
865    module_compilation_context_hash_fingerprinted(CODEGEN_FINGERPRINT)
866}
867
868fn module_compilation_context_hash_fingerprinted(codegen_fingerprint: &str) -> [u8; 32] {
869    let mut hasher = Sha256::new();
870    hasher.update(b"module-artifact-source-local-v3\0");
871    hasher.update(b"stdlib-digest\0");
872    hasher.update(embedded_stdlib_digest());
873    hasher.update(b"\0codegen-fingerprint\0");
874    hasher.update(codegen_fingerprint.as_bytes());
875    hasher.finalize().into()
876}
877
878// Test seam: how many times the import-graph walk has actually run on this
879// thread.
880//
881// The manifest fast path and the walk agree on results *by construction* —
882// both trust the same `(len, mtime_ns)` identity — so no observable output can
883// tell them apart. Only the work done differs, and this counts it. Thread-local
884// so tests running in parallel cannot perturb each other.
885#[cfg(test)]
886thread_local! {
887    pub(crate) static WALKS_PERFORMED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
888}
889
890/// Walk the user-import graph rooted at `source_path` and produce a
891/// stable hash of every transitively-reachable file. The hash is
892/// order-independent: each visited file is keyed by canonical path and
893/// emitted in sorted order, so reordering imports inside a file does
894/// not invalidate the cache while changing any file's content does.
895///
896/// Embedded stdlib content is folded into the hash too — `collect_user_imports`
897/// deliberately skips `std/*` paths (they resolve to in-binary sources, not
898/// disk files), so without this fold a stdlib edit between development
899/// builds would leave user-file caches pinned to a stale stdlib snapshot.
900fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
901    hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT).0
902}
903
904/// As [`hash_transitive_user_imports`], but also returns the manifest that
905/// proves the walk's observations, for callers that will persist it.
906fn hash_transitive_user_imports_with_manifest(
907    source_path: &Path,
908    source: &str,
909) -> ([u8; 32], Option<ContextManifest>) {
910    hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
911}
912
913/// Inner form of [`hash_transitive_user_imports`] parameterized on the compiler
914/// fingerprint so tests can vary it; production always passes
915/// [`CODEGEN_FINGERPRINT`].
916fn hash_transitive_user_imports_fingerprinted(
917    source_path: &Path,
918    source: &str,
919    codegen_fingerprint: &str,
920) -> ([u8; 32], Option<ContextManifest>) {
921    #[cfg(test)]
922    WALKS_PERFORMED.with(|c| c.set(c.get() + 1));
923
924    let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
925        std::collections::BTreeMap::new();
926    let entry = ModuleSource::from_text(source);
927    let mut frontier: Vec<(PathBuf, Arc<str>)> = entry
928        .imports()
929        .iter()
930        .map(|import| (source_path.to_path_buf(), Arc::clone(import)))
931        .collect();
932    // Built alongside the hash: the same observations, in a form a later
933    // process can re-check with stats instead of repeating this walk. Anchored
934    // at the entry, because that is what the observations are relative to, and
935    // stamped before the first file is stat'ed, so entries observed inside a
936    // timestamp tick are recognizable as such later. Set to `None` the moment
937    // the graph contains something stats cannot describe.
938    let mut manifest = Some(ContextManifest::begin(module_source::canonical_identity(
939        source_path,
940    )));
941
942    while let Some((anchor, import)) = frontier.pop() {
943        let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
944            // Unresolved imports get a sentinel keyed by their resolution
945            // anchor so that dropping a real file under that anchor later
946            // produces a different key.
947            let sentinel = anchor.join(format!("__unresolved__/{import}"));
948            if let std::collections::btree_map::Entry::Vacant(slot) = visited.entry(sentinel) {
949                slot.insert(ImportNode::Unresolved {
950                    import: Arc::clone(&import),
951                });
952                if let Some(m) = manifest.as_mut() {
953                    m.unresolved.push(ManifestUnresolved {
954                        anchor: anchor.clone(),
955                        import: import.to_string(),
956                    });
957                }
958            }
959            continue;
960        };
961        let canonical = module_source::canonical_identity(&resolved);
962        if visited.contains_key(&canonical) {
963            continue;
964        }
965        // The read and the import scan are owned by [`module_source`], which
966        // memoizes both by the file's stat identity. The same handful of core
967        // library modules (`lib/host/*`, `lib/runtime/*`, ...) sit on the import
968        // graph of nearly every module, and the VM's module loader reads every
969        // one of these files again — so without a shared owner a single spawn
970        // re-reads and re-scans the same sources many times over.
971        match module_source::read(&resolved) {
972            Ok(module) => {
973                visited.insert(
974                    canonical.clone(),
975                    ImportNode::Resolved {
976                        content: Arc::clone(module.text()),
977                    },
978                );
979                match ManifestFile::observe(&canonical, &module) {
980                    Some(file) => {
981                        if let Some(m) = manifest.as_mut() {
982                            m.files.push(file);
983                        }
984                    }
985                    // Read succeeded but the file cannot be stat'ed now. Rather
986                    // than record a fact we could not re-check, drop the
987                    // manifest and leave this graph on the walk.
988                    None => manifest = None,
989                }
990                for nested_import in module.imports() {
991                    frontier.push((resolved.clone(), Arc::clone(nested_import)));
992                }
993            }
994            Err(error) => {
995                let unreadable_path = canonical.clone();
996                visited.insert(
997                    canonical,
998                    ImportNode::IoError {
999                        kind: error.kind().to_string(),
1000                    },
1001                );
1002                // Real trees contain these — an `import "./types"` where
1003                // `types/` is a directory resolves, then fails to read. Dropping
1004                // the manifest for them would silently disable the fast path on
1005                // exactly the graphs it exists for.
1006                if let Some(m) = manifest.as_mut() {
1007                    m.unreadable.push(ManifestUnreadable {
1008                        path: unreadable_path,
1009                        kind: error.kind().to_string(),
1010                    });
1011                }
1012            }
1013        }
1014    }
1015
1016    let mut hasher = Sha256::new();
1017    hasher.update(b"stdlib-digest\0");
1018    hasher.update(embedded_stdlib_digest());
1019    hasher.update(b"\0");
1020    // Fold in the compiler's code-generation fingerprint so a compiler change
1021    // that alters emitted bytecode for unchanged source busts stale cache
1022    // entries within a single version — the gap that masked the #2610 fix until
1023    // the cache was cleared by hand. See `build.rs` and `CODEGEN_FINGERPRINT`.
1024    hasher.update(b"codegen-fingerprint\0");
1025    hasher.update(codegen_fingerprint.as_bytes());
1026    hasher.update(b"\0");
1027    for (path, node) in &visited {
1028        hasher.update(path.to_string_lossy().as_bytes());
1029        hasher.update(b"\0");
1030        match node {
1031            ImportNode::Resolved { content } => {
1032                hasher.update(b"resolved\0");
1033                hasher.update(content.as_bytes());
1034            }
1035            ImportNode::Unresolved { import } => {
1036                hasher.update(b"unresolved\0");
1037                hasher.update(import.as_bytes());
1038            }
1039            ImportNode::IoError { kind } => {
1040                hasher.update(b"ioerror\0");
1041                hasher.update(kind.as_bytes());
1042            }
1043        }
1044        hasher.update(b"\0");
1045    }
1046    // Sorted so one graph always serializes to one byte sequence, whatever
1047    // order the frontier happened to pop.
1048    if let Some(m) = manifest.as_mut() {
1049        m.files.sort_by(|a, b| a.path.cmp(&b.path));
1050        m.unresolved
1051            .sort_by(|a, b| (&a.anchor, &a.import).cmp(&(&b.anchor, &b.import)));
1052        m.unreadable.sort_by(|a, b| a.path.cmp(&b.path));
1053    }
1054    (hasher.finalize().into(), manifest)
1055}
1056
1057enum ImportNode {
1058    Resolved { content: Arc<str> },
1059    Unresolved { import: Arc<str> },
1060    IoError { kind: String },
1061}
1062
1063#[cfg(test)]
1064#[path = "bytecode_cache_tests.rs"]
1065mod tests;