Skip to main content

nodejs/
cache.rs

1//! rkyv-backed bytecode cache for compiled JS scripts (mirrors the fleet's
2//! pythonrs/zshrs/rubylang design). Every ordinary `node foo.js` run is
3//! transparently cached: the source is hashed, the shard consulted, and on a hit
4//! the compiled `fusevm::Chunk`s run directly — lex/parse/lower are skipped
5//! entirely. On a miss the program is compiled, stored, then run. `node --build`
6//! warms the same shard ahead of time.
7//!
8//! Layout: a single shard at `~/.node-js/scripts.rkyv`. The *outer* container is
9//! a zero-copy rkyv archive (`Shard`), validated on load; each *inner* entry blob
10//! is a bincode-encoded `CProg` (the compiled `fusevm::Chunk`s + func/try
11//! tables), because `fusevm::Chunk` is serde-owned, not `rkyv::Archive`. The key
12//! is a 64-bit hash of the source plus a schema version, the release version and
13//! an identity for the running BINARY, so a source, format, release or codegen
14//! change misses cleanly instead of loading stale bytecode.
15
16use crate::compiler::Program;
17use crate::host::{FuncDef, TryDef};
18use fusevm::Chunk;
19use rkyv::{Archive, Deserialize as RkyvDe, Serialize as RkyvSer};
20use serde::{Deserialize, Serialize};
21use std::hash::{Hash, Hasher};
22use std::path::PathBuf;
23
24/// Bump on any incompatible change to `CProg` / the lowering / the shard layout.
25/// v1: initial JS bytecode cache — a Chunk/func/try layout change here must miss
26///     cleanly so an older cached `.js` never loads incompatible bytecode.
27/// v2: BigInt/RegExp/tagged-template/for-await lowering — new builtin ops
28///     (MKBIGINT/MKREGEX/NUM_STEP/TAG_TMPL/…) and the type-preserving `++`/`--`
29///     codegen; old cached bytecode would run the stale POS-based increment.
30/// v6: `FuncDef.is_method` (a method owns no `prototype`) and the class-body
31///     emission order (methods before static fields). A v5 blob deserializes
32///     with `is_method: false` and replays the old source order, so every class
33///     and every object method would report the wrong own-property set.
34/// v7: NamedEvaluation (10.2.9 SetFunctionName) at every site the grammar calls
35///     for it — assignment to an identifier, object property definitions and
36///     concise methods/accessors, class fields, and destructuring/parameter
37///     defaults — plus the new `NAMED_EVAL` builtin and `DEF_FIELD`'s fourth
38///     argument. A v6 blob calls `DEF_FIELD` with three arguments and emits no
39///     naming, so every affected function would keep the empty `.name` and the
40///     field's flag would be read off the wrong stack slot. v7 also carries the
41///     class-body environment (15.7.14 step 17), whose `PUSH_SCOPE`/`DECLARE`
42///     pair a v6 blob does not emit, so a static initializer reading the class
43///     by name would still throw `ReferenceError`.
44/// v8: `**` lowers to `CallBuiltin(ops::POW, 2)` instead of the native
45///     `Op::Pow`. fusevm's native op is IEEE-754 `pow`, which answers 1 for
46///     `(-1) ** Infinity` and `1 ** NaN` where the spec says NaN; a v7 blob
47///     still carries `Op::Pow` and would keep replaying the IEEE answer from
48///     cache long after the source fix. (The `Math.*` additions in the same
49///     change need no bump: a `Math.f(..)` call emits the name as a constant
50///     and dispatches on the string at run time, so `--dump-bytecode` for a
51///     known and an unknown method name is byte-identical.)
52/// v9: locals that no closure can reach are addressed as fusevm frame slots
53///     (`Op::GetSlot`/`SetSlot`) instead of `CallBuiltin(GETLOCAL)` by name —
54///     see `crate::slots`. A v8 blob is still CORRECT, since it carries the
55///     name-lookup form and nothing else changed about it; it is simply the
56///     slow bytecode, and a cache that kept replaying it would hide the whole
57///     change from every script already run once. The bump is what makes the
58///     speedup reach existing scripts.
59/// v10: the entry carries the compiler's SIDE TABLES (call-site texts, yield-site
60///     iterator depths). They live in thread-local registries that only
61///     `finish_chunk` fills, so every cache hit ran without them: a generator's
62///     parked `for…of`/`yield*` iterators were not closed when a `.return()` or
63///     `.throw()` was injected, and their `finally` never ran — the same script
64///     printed one thing on its first run and another on its second. A v9 blob
65///     has no tables to restore, so it must not be replayed.
66/// v11: assignment to a PROPERTY evaluates its target reference before the
67///     right-hand side (13.15.2). `o[k()] = v()` now emits the object, then the
68///     key, then the value, and calls `SETATTR`/`SETITEM` on the result directly
69///     instead of the old `value`-first sequence with its `Dup`/`Rot`/`Pop`. A
70///     v10 blob still carries that sequence, so every cached script would keep
71///     running its side effects in the wrong order — the exact bug the change
72///     fixes, replayed from disk.
73/// v12: a `FuncDef` carries its source `span`/`script`, and `MKCLASS` takes a
74///     fourth argument naming the FuncDef that holds the class's span. A v11
75///     blob has neither, so every function would print the `[code]` placeholder
76///     from `Function.prototype.toString`.
77const SCHEMA: u64 = 12;
78
79/// The outer, rkyv-archived shard: a flat list of (key, bincode-blob) entries.
80#[derive(Archive, RkyvSer, RkyvDe, Default)]
81#[archive(check_bytes)]
82struct Shard {
83    entries: Vec<Entry>,
84}
85
86#[derive(Archive, RkyvSer, RkyvDe)]
87#[archive(check_bytes)]
88struct Entry {
89    key: u64,
90    /// A second, independent hash of the source. A cache hit requires BOTH `key`
91    /// and `verify` to match, so an `FxHash` collision on `key` can never return
92    /// a different program's bytecode (which would silently produce wrong
93    /// results — far worse than a cache miss).
94    verify: u64,
95    /// The [`build_id`] that wrote this entry. Every key already mixes the build
96    /// id in, so an entry from a DIFFERENT build can never be hit again — it is
97    /// dead weight from the moment the binary is rebuilt. Recording it lets
98    /// `store` drop those entries instead of accumulating one full copy of the
99    /// shard per rebuild, which matters because `load_shard` reads and
100    /// deserializes the WHOLE file on every lookup.
101    build: u64,
102    blob: Vec<u8>,
103}
104
105/// The inner, serde/bincode form of a compiled program.
106#[derive(Serialize, Deserialize)]
107struct CProg {
108    main: Chunk,
109    functions: Vec<(String, FuncDef)>,
110    tries: Vec<TryDef>,
111    /// The compiler's side tables — call-site texts and yield-site iterator
112    /// depths — which a cache hit would otherwise never build. See
113    /// [`crate::host::SiteTables`] for what silently degrades without them.
114    #[serde(default)]
115    sites: crate::host::SiteTables,
116    /// Whether the program's top level is strict. `#[serde(default)]` so a
117    /// shard written before this field existed still decodes — as sloppy,
118    /// which is what those entries were compiled as anyway.
119    #[serde(default)]
120    strict: bool,
121}
122
123/// The release this binary was built as, hashed into every cache key so a shard
124/// written by one release can never be read by another.
125const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
126
127/// An identity for the BINARY doing the lookup — its own mtime, read once.
128///
129/// `SCHEMA` and `BUILD_VERSION` are both bumped BY HAND, and a codegen change
130/// that forgets either ships a binary that silently replays the previous
131/// build's bytecode for every script already run once. That failure is
132/// invisible: the right answer for the old program, no error, and the symptom
133/// is "my change did not take". Measured on this crate: with the key depending
134/// on `(SCHEMA, src)` alone, lowering `**` to a deliberately wrong opcode and
135/// rebuilding still printed the OLD result for a previously-cached script,
136/// while a byte-different script printed the new (wrong) one — the binary had
137/// changed and the cache had not noticed.
138///
139/// The mtime changes on every rebuild without anyone having to remember, and is
140/// stable for an installed binary, so it costs one `stat` per process and
141/// nothing else. `0` when the path or metadata is unreadable, which degrades to
142/// the previous behavior rather than failing the run.
143fn build_id() -> u64 {
144    use std::sync::OnceLock;
145    static ID: OnceLock<u64> = OnceLock::new();
146    *ID.get_or_init(|| {
147        std::env::current_exe()
148            .and_then(|p| p.metadata())
149            .and_then(|m| m.modified())
150            .ok()
151            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
152            .map(|d| d.as_nanos() as u64)
153            .unwrap_or(0)
154    })
155}
156
157/// A stable content key for a source string (fast `FxHash`, used for lookup).
158pub fn key_for(src: &str) -> u64 {
159    let mut h = rustc_hash::FxHasher::default();
160    SCHEMA.hash(&mut h);
161    BUILD_VERSION.hash(&mut h);
162    build_id().hash(&mut h);
163    src.hash(&mut h);
164    h.finish()
165}
166
167/// An independent verification hash (std `DefaultHasher`/SipHash), so a hit
168/// requires both hashes to agree — collision-proof for correctness.
169fn verify_for(src: &str) -> u64 {
170    use std::collections::hash_map::DefaultHasher;
171    let mut h = DefaultHasher::new();
172    SCHEMA.hash(&mut h);
173    BUILD_VERSION.hash(&mut h);
174    build_id().hash(&mut h);
175    src.len().hash(&mut h);
176    src.hash(&mut h);
177    h.finish()
178}
179
180fn shard_path() -> Option<PathBuf> {
181    let dir = dirs::home_dir()?.join(".node-js");
182    let _ = std::fs::create_dir_all(&dir);
183    Some(dir.join("scripts.rkyv"))
184}
185
186/// The shard file's identity as `(mtime, len)` — what "unchanged since we read
187/// it" is decided on. `None` when there is no file (or it cannot be stat'd),
188/// which compares equal to a later `None` and so still means "unchanged".
189type Stamp = Option<(std::time::SystemTime, u64)>;
190
191fn shard_stamp() -> Stamp {
192    let path = shard_path()?;
193    let md = std::fs::metadata(&path).ok()?;
194    Some((md.modified().ok()?, md.len()))
195}
196
197fn load_shard() -> Shard {
198    let Some(path) = shard_path() else {
199        return Shard::default();
200    };
201    let Ok(bytes) = std::fs::read(&path) else {
202        return Shard::default();
203    };
204    rkyv::from_bytes::<Shard>(&bytes).unwrap_or_default()
205}
206
207fn write_shard(shard: &Shard) -> Result<(), String> {
208    let path = shard_path().ok_or("no home dir for cache")?;
209    let bytes = rkyv::to_bytes::<_, 4096>(shard).map_err(|e| format!("cache serialize: {e}"))?;
210    // Atomic replace (write temp + rename) so a concurrent reader — up to 16
211    // instances run against the shared shard — never sees a torn file. A losing
212    // concurrent writer just drops its entry (recompiled next run); it can never
213    // corrupt the shard. The temp name is unique per WRITE (pid + a monotonic
214    // counter), not just per process, so concurrent writers within one process
215    // (e.g. parallel test threads) never clobber each other's temp file.
216    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
217    let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
218    let tmp = path.with_extension(format!("rkyv.tmp.{}.{n}", std::process::id()));
219    std::fs::write(&tmp, &bytes).map_err(|e| format!("cache write: {e}"))?;
220    std::fs::rename(&tmp, &path).map_err(|e| {
221        let _ = std::fs::remove_file(&tmp);
222        format!("cache rename: {e}")
223    })
224}
225
226/// The shard, resident in memory for the life of the process.
227///
228/// It used to be read and fully deserialized from disk on every `load`, and
229/// read-modify-WRITTEN on every `store`. That was affordable only because
230/// exactly one lookup happened per run — the top-level script. Caching each
231/// `require`d module makes it 118 lookups for an express tree, and measured on
232/// a 3 MB shard a single `load` costs 67-347 ms, so the disk-per-call design
233/// would have turned a 138 ms saving into a 16 SECOND regression. Reading once
234/// and writing once is what makes per-module caching possible at all.
235#[derive(Default)]
236struct ShardMem {
237    /// The shard file exactly as read, kept resident so an entry's blob can be
238    /// BORROWED out of it rather than copied.
239    ///
240    /// This used to be a map of owned blobs built by deserializing the whole
241    /// archive, which meant a run paid to materialize EVERY cached program in
242    /// order to look up the one it was about to execute — and `load` then cloned
243    /// the blob a second time. rkyv is a zero-copy format, so the archive is
244    /// instead indexed in place and only the matching entry is decoded. Measured
245    /// on a 227 KB shard of 300 scripts (debug build), a cache-hit run went from
246    /// 15.3 ms to 11.4 ms against an 8.7 ms floor for `--version`, which touches
247    /// no cache at all; the cost it removes grows with the shard, so a 3 MB one
248    /// pays it back roughly thirteen times over.
249    backing: Vec<u8>,
250    /// `key -> (verify, blob range within `backing`)` for entries read from disk.
251    disk: rustc_hash::FxHashMap<u64, (u64, std::ops::Range<usize>)>,
252    /// Entries stored by THIS process, which are not in `backing`.
253    added: rustc_hash::FxHashMap<u64, (u64, Vec<u8>)>,
254    /// Whether this process added anything, so an all-hits run writes nothing.
255    dirty: bool,
256    /// The file's `(mtime, len)` when this process read it. [`flush`] re-reads
257    /// the shard only when this no longer matches — i.e. when a peer actually
258    /// wrote while we were running.
259    stamp: Stamp,
260}
261
262impl ShardMem {
263    /// The blob for `key`, borrowed from wherever it lives.
264    fn get(&self, key: u64) -> Option<(u64, &[u8])> {
265        if let Some((v, b)) = self.added.get(&key) {
266            return Some((*v, b.as_slice()));
267        }
268        let (v, r) = self.disk.get(&key)?;
269        Some((*v, &self.backing[r.clone()]))
270    }
271
272    /// Every live entry as `(key, verify, blob)`, this process's own additions
273    /// shadowing the on-disk copy of the same key.
274    fn iter(&self) -> impl Iterator<Item = (u64, u64, &[u8])> {
275        self.added
276            .iter()
277            .map(|(k, (v, b))| (*k, *v, b.as_slice()))
278            .chain(
279                self.disk
280                    .iter()
281                    .filter(|(k, _)| !self.added.contains_key(k))
282                    .map(|(k, (v, r))| (*k, *v, &self.backing[r.clone()])),
283            )
284    }
285}
286
287/// Read the shard and index it WITHOUT deserializing it: the archive is
288/// validated once, then each entry contributes only its key and the byte range
289/// its blob occupies inside `backing`.
290///
291/// Entries from another build can never be hit (the build id is part of every
292/// key), so they are skipped here and dropped on the next write.
293fn load_shard_indexed() -> ShardMem {
294    let mut mem = ShardMem {
295        stamp: shard_stamp(),
296        ..ShardMem::default()
297    };
298    let Some(path) = shard_path() else {
299        return mem;
300    };
301    let Ok(bytes) = std::fs::read(&path) else {
302        return mem;
303    };
304    let build = build_id();
305    let Ok(shard) = rkyv::check_archived_root::<Shard>(&bytes) else {
306        // A corrupt or older-layout shard is simply not readable; every lookup
307        // misses and the next write replaces it.
308        return mem;
309    };
310    let base = bytes.as_ptr() as usize;
311    for e in shard.entries.iter() {
312        if u64::from(e.build) != build {
313            continue;
314        }
315        // The archived blob points INTO `bytes`, so its offset is the difference
316        // between the two addresses — no copy, and the range stays valid for as
317        // long as `backing` holds those bytes.
318        let blob: &[u8] = &e.blob;
319        let off = blob.as_ptr() as usize - base;
320        mem.disk
321            .insert(e.key.into(), (e.verify.into(), off..off + blob.len()));
322    }
323    mem.backing = bytes;
324    mem
325}
326
327thread_local! {
328    static SHARD: std::cell::RefCell<Option<ShardMem>> = const { std::cell::RefCell::new(None) };
329}
330
331/// Run `f` against the resident shard, loading it from disk on first use.
332fn with_shard<T>(f: impl FnOnce(&mut ShardMem) -> T) -> T {
333    SHARD.with(|c| {
334        let mut slot = c.borrow_mut();
335        // Stamped BEFORE the read (inside `load_shard_indexed`): a peer writing
336        // between the two makes the stamp look older than the bytes we hold,
337        // which only costs `flush` a re-read it did not need. The other
338        // direction — a stamp newer than the content — would silently drop a
339        // peer's entries, and cannot happen this way round.
340        let mem = slot.get_or_insert_with(load_shard_indexed);
341        f(mem)
342    })
343}
344
345/// Look up a compiled program for `src`, if present and current.
346pub fn load(src: &str) -> Option<Program> {
347    let key = key_for(src);
348    let verify = verify_for(src);
349    // Decoded INSIDE the borrow, straight out of the resident shard bytes. The
350    // blob used to be cloned out first, which copied the whole program a second
351    // time for no reason.
352    let cp: CProg = with_shard(|m| {
353        let (v, blob) = m.get(key)?;
354        if v != verify {
355            return None;
356        }
357        bincode::deserialize(blob).ok()
358    })?;
359    let mut prog = Program {
360        main: cp.main,
361        functions: cp.functions,
362        tries: cp.tries,
363        strict: cp.strict,
364        source: Some(src.into()),
365    };
366    // `Chunk::op_hash` is `#[serde(skip)]` in fusevm — it is a CACHE of the
367    // hash of ops+constants, computed by `ChunkBuilder::build`, so every chunk
368    // that comes back from a blob carries 0. Anything keyed by it then looks up
369    // the wrong entry: the compiler's side tables below, and fusevm's own JIT
370    // cache, which would see every cached chunk as the same key. Recomputing it
371    // with `build`'s own algorithm is what makes a loaded chunk indistinguishable
372    // from a compiled one.
373    rehash(&mut prog);
374    // A hit skips lex/parse/lower, and with it every `register_*` the compiler
375    // would have run — so the tables come back from the entry instead.
376    crate::host::restore_site_tables(&cp.sites);
377    Some(prog)
378}
379
380/// Recompute `op_hash` on every chunk of `prog`, exactly as
381/// `fusevm::ChunkBuilder::build` does: `DefaultHasher` over `ops` then
382/// `constants`.
383///
384/// The two must stay in step; a blob is only ever read back by the binary that
385/// wrote it (the cache key carries `BUILD_VERSION` and the binary's own mtime),
386/// so the hasher cannot change underneath an entry.
387fn rehash(prog: &mut Program) {
388    fn one(c: &mut Chunk) {
389        use std::collections::hash_map::DefaultHasher;
390        use std::hash::{Hash, Hasher};
391        let mut h = DefaultHasher::new();
392        c.ops.hash(&mut h);
393        c.constants.hash(&mut h);
394        c.op_hash = h.finish();
395    }
396    one(&mut prog.main);
397    for (_, f) in &mut prog.functions {
398        one(&mut f.chunk);
399    }
400    for t in &mut prog.tries {
401        one(&mut t.block);
402        if let Some((_, h)) = &mut t.handler {
403            one(h);
404        }
405        if let Some(f) = &mut t.finalizer {
406            one(f);
407        }
408    }
409}
410
411/// Record `prog` (compiled from `src`) in the resident shard. Reaches disk at
412/// [`flush`], not here.
413pub fn store(src: &str, prog: &Program) -> Result<(), String> {
414    let cp = CProg {
415        main: prog.main.clone(),
416        functions: prog.functions.clone(),
417        tries: prog.tries.clone(),
418        // Taken after the compile that produced `prog`, so the entry carries
419        // what that compile registered.
420        sites: crate::host::site_tables(),
421        strict: prog.strict,
422    };
423    let blob = bincode::serialize(&cp).map_err(|e| format!("cache encode: {e}"))?;
424    let key = key_for(src);
425    let verify = verify_for(src);
426    with_shard(|m| {
427        m.added.insert(key, (verify, blob));
428        m.dirty = true;
429    });
430    Ok(())
431}
432
433/// Write the resident shard back, once, at the end of the run.
434///
435/// The on-disk shard is re-read and MERGED rather than overwritten: up to 16
436/// instances share it, and a plain overwrite would drop whatever a peer stored
437/// while this process was running. A losing writer still only loses entries
438/// (they recompile next run); it can never corrupt the file, since the write
439/// itself is a temp-plus-rename.
440pub fn flush() {
441    let build = build_id();
442    // Materialized here and nowhere else: writing is the one operation that
443    // genuinely needs owned blobs, and it happens at most once per run.
444    let pending = SHARD.with(|c| {
445        let mut slot = c.borrow_mut();
446        match slot.as_mut() {
447            Some(m) if m.dirty => {
448                m.dirty = false;
449                Some(
450                    m.iter()
451                        .map(|(k, v, b)| (k, (v, b.to_vec())))
452                        .collect::<rustc_hash::FxHashMap<u64, (u64, Vec<u8>)>>(),
453                )
454            }
455            _ => None,
456        }
457    });
458    let Some(mut merged) = pending else { return };
459    // Re-read the shard only if it CHANGED since this process read it.
460    //
461    // The merge exists for the concurrent case — up to 16 instances share the
462    // file — but the common case is that nobody else wrote, and there the
463    // re-read deserializes and validates the whole shard a second time for a
464    // result already resident in `merged`. Measured on a 2 MB shard (debug
465    // build), that second `load_shard` is ~0.4 s of a ~2 s run, paid by every
466    // run that compiles anything. An unchanged `(mtime, len)` means no peer
467    // committed a write (the writer renames a temp file into place, so any
468    // commit moves both), and the entries we would merge back are exactly the
469    // ones we already hold.
470    let stamp = SHARD.with(|c| c.borrow().as_ref().and_then(|m| m.stamp));
471    if shard_stamp() != stamp {
472        for e in load_shard().entries {
473            if e.build == build {
474                merged.entry(e.key).or_insert((e.verify, e.blob));
475            }
476        }
477    }
478    let shard = Shard {
479        entries: merged
480            .into_iter()
481            .map(|(key, (verify, blob))| Entry {
482                key,
483                verify,
484                build,
485                blob,
486            })
487            .collect(),
488    };
489    let _ = write_shard(&shard);
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    /// Both cache hashes must depend on the BUILD, not on `SCHEMA` alone.
497    ///
498    /// `SCHEMA` and `BUILD_VERSION` are bumped by hand, so the codegen change
499    /// that forgets one would otherwise read the previous build's bytecode out
500    /// of the shared shard and run the wrong program with no error. This was a
501    /// real, reproduced failure: with the key depending on `(SCHEMA, src)`
502    /// alone, lowering `**` to a wrong opcode and rebuilding still printed the
503    /// OLD answer for an already-cached script.
504    ///
505    /// Each hash is recomputed here with a component left out and required to
506    /// differ, so DELETING any one of the four `hash` lines in `key_for` /
507    /// `verify_for` fails this test rather than silently restoring the bug.
508    #[test]
509    fn cache_keys_depend_on_the_build_not_just_the_schema() {
510        use std::collections::hash_map::DefaultHasher;
511        let src = "console.log(1)\n";
512
513        // key_for without the version+build id.
514        let mut bare = rustc_hash::FxHasher::default();
515        SCHEMA.hash(&mut bare);
516        src.hash(&mut bare);
517        assert_ne!(
518            key_for(src),
519            bare.finish(),
520            "key_for must hash the build identity, not just SCHEMA"
521        );
522
523        // key_for with the version but WITHOUT the per-build id: this is what
524        // the fleet's version-only design hashes, and it is what leaves two dev
525        // builds of one version sharing a shard.
526        let mut version_only = rustc_hash::FxHasher::default();
527        SCHEMA.hash(&mut version_only);
528        BUILD_VERSION.hash(&mut version_only);
529        src.hash(&mut version_only);
530        assert_ne!(
531            key_for(src),
532            version_only.finish(),
533            "key_for must hash the per-build id, so two dev builds of one \
534             version cannot share cached bytecode"
535        );
536
537        // verify_for, same two omissions.
538        let mut bare = DefaultHasher::new();
539        SCHEMA.hash(&mut bare);
540        src.len().hash(&mut bare);
541        src.hash(&mut bare);
542        assert_ne!(
543            verify_for(src),
544            bare.finish(),
545            "verify_for must hash the build identity, not just SCHEMA"
546        );
547
548        let mut version_only = DefaultHasher::new();
549        SCHEMA.hash(&mut version_only);
550        BUILD_VERSION.hash(&mut version_only);
551        src.len().hash(&mut version_only);
552        src.hash(&mut version_only);
553        assert_ne!(
554            verify_for(src),
555            version_only.finish(),
556            "verify_for must hash the per-build id"
557        );
558
559        // The version that is hashed is THIS build's, so a release bump rotates
560        // the whole shard.
561        assert_eq!(BUILD_VERSION, env!("CARGO_PKG_VERSION"));
562        // A `0` build id means the stat failed and the per-build guarantee is
563        // gone; under `cargo test` the binary is always readable.
564        assert_ne!(build_id(), 0, "build_id must read the running binary");
565        // Distinct sources still land on distinct keys.
566        assert_ne!(key_for(src), key_for("console.log(2)\n"));
567    }
568}