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;