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.
59const SCHEMA: u64 = 9;
60
61/// The outer, rkyv-archived shard: a flat list of (key, bincode-blob) entries.
62#[derive(Archive, RkyvSer, RkyvDe, Default)]
63#[archive(check_bytes)]
64struct Shard {
65    entries: Vec<Entry>,
66}
67
68#[derive(Archive, RkyvSer, RkyvDe)]
69#[archive(check_bytes)]
70struct Entry {
71    key: u64,
72    /// A second, independent hash of the source. A cache hit requires BOTH `key`
73    /// and `verify` to match, so an `FxHash` collision on `key` can never return
74    /// a different program's bytecode (which would silently produce wrong
75    /// results — far worse than a cache miss).
76    verify: u64,
77    /// The [`build_id`] that wrote this entry. Every key already mixes the build
78    /// id in, so an entry from a DIFFERENT build can never be hit again — it is
79    /// dead weight from the moment the binary is rebuilt. Recording it lets
80    /// `store` drop those entries instead of accumulating one full copy of the
81    /// shard per rebuild, which matters because `load_shard` reads and
82    /// deserializes the WHOLE file on every lookup.
83    build: u64,
84    blob: Vec<u8>,
85}
86
87/// The inner, serde/bincode form of a compiled program.
88#[derive(Serialize, Deserialize)]
89struct CProg {
90    main: Chunk,
91    functions: Vec<(String, FuncDef)>,
92    tries: Vec<TryDef>,
93}
94
95/// The release this binary was built as, hashed into every cache key so a shard
96/// written by one release can never be read by another.
97const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
98
99/// An identity for the BINARY doing the lookup — its own mtime, read once.
100///
101/// `SCHEMA` and `BUILD_VERSION` are both bumped BY HAND, and a codegen change
102/// that forgets either ships a binary that silently replays the previous
103/// build's bytecode for every script already run once. That failure is
104/// invisible: the right answer for the old program, no error, and the symptom
105/// is "my change did not take". Measured on this crate: with the key depending
106/// on `(SCHEMA, src)` alone, lowering `**` to a deliberately wrong opcode and
107/// rebuilding still printed the OLD result for a previously-cached script,
108/// while a byte-different script printed the new (wrong) one — the binary had
109/// changed and the cache had not noticed.
110///
111/// The mtime changes on every rebuild without anyone having to remember, and is
112/// stable for an installed binary, so it costs one `stat` per process and
113/// nothing else. `0` when the path or metadata is unreadable, which degrades to
114/// the previous behavior rather than failing the run.
115fn build_id() -> u64 {
116    use std::sync::OnceLock;
117    static ID: OnceLock<u64> = OnceLock::new();
118    *ID.get_or_init(|| {
119        std::env::current_exe()
120            .and_then(|p| p.metadata())
121            .and_then(|m| m.modified())
122            .ok()
123            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
124            .map(|d| d.as_nanos() as u64)
125            .unwrap_or(0)
126    })
127}
128
129/// A stable content key for a source string (fast `FxHash`, used for lookup).
130pub fn key_for(src: &str) -> u64 {
131    let mut h = rustc_hash::FxHasher::default();
132    SCHEMA.hash(&mut h);
133    BUILD_VERSION.hash(&mut h);
134    build_id().hash(&mut h);
135    src.hash(&mut h);
136    h.finish()
137}
138
139/// An independent verification hash (std `DefaultHasher`/SipHash), so a hit
140/// requires both hashes to agree — collision-proof for correctness.
141fn verify_for(src: &str) -> u64 {
142    use std::collections::hash_map::DefaultHasher;
143    let mut h = DefaultHasher::new();
144    SCHEMA.hash(&mut h);
145    BUILD_VERSION.hash(&mut h);
146    build_id().hash(&mut h);
147    src.len().hash(&mut h);
148    src.hash(&mut h);
149    h.finish()
150}
151
152fn shard_path() -> Option<PathBuf> {
153    let dir = dirs::home_dir()?.join(".node-js");
154    let _ = std::fs::create_dir_all(&dir);
155    Some(dir.join("scripts.rkyv"))
156}
157
158fn load_shard() -> Shard {
159    let Some(path) = shard_path() else {
160        return Shard::default();
161    };
162    let Ok(bytes) = std::fs::read(&path) else {
163        return Shard::default();
164    };
165    rkyv::from_bytes::<Shard>(&bytes).unwrap_or_default()
166}
167
168fn write_shard(shard: &Shard) -> Result<(), String> {
169    let path = shard_path().ok_or("no home dir for cache")?;
170    let bytes = rkyv::to_bytes::<_, 4096>(shard).map_err(|e| format!("cache serialize: {e}"))?;
171    // Atomic replace (write temp + rename) so a concurrent reader — up to 16
172    // instances run against the shared shard — never sees a torn file. A losing
173    // concurrent writer just drops its entry (recompiled next run); it can never
174    // corrupt the shard. The temp name is unique per WRITE (pid + a monotonic
175    // counter), not just per process, so concurrent writers within one process
176    // (e.g. parallel test threads) never clobber each other's temp file.
177    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
178    let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
179    let tmp = path.with_extension(format!("rkyv.tmp.{}.{n}", std::process::id()));
180    std::fs::write(&tmp, &bytes).map_err(|e| format!("cache write: {e}"))?;
181    std::fs::rename(&tmp, &path).map_err(|e| {
182        let _ = std::fs::remove_file(&tmp);
183        format!("cache rename: {e}")
184    })
185}
186
187/// The shard, resident in memory for the life of the process.
188///
189/// It used to be read and fully deserialized from disk on every `load`, and
190/// read-modify-WRITTEN on every `store`. That was affordable only because
191/// exactly one lookup happened per run — the top-level script. Caching each
192/// `require`d module makes it 118 lookups for an express tree, and measured on
193/// a 3 MB shard a single `load` costs 67-347 ms, so the disk-per-call design
194/// would have turned a 138 ms saving into a 16 SECOND regression. Reading once
195/// and writing once is what makes per-module caching possible at all.
196#[derive(Default)]
197struct ShardMem {
198    entries: rustc_hash::FxHashMap<u64, (u64, Vec<u8>)>,
199    /// Whether this process added anything, so an all-hits run writes nothing.
200    dirty: bool,
201}
202
203thread_local! {
204    static SHARD: std::cell::RefCell<Option<ShardMem>> = const { std::cell::RefCell::new(None) };
205}
206
207/// Run `f` against the resident shard, loading it from disk on first use.
208fn with_shard<T>(f: impl FnOnce(&mut ShardMem) -> T) -> T {
209    SHARD.with(|c| {
210        let mut slot = c.borrow_mut();
211        let mem = slot.get_or_insert_with(|| {
212            let build = build_id();
213            let mut mem = ShardMem::default();
214            for e in load_shard().entries {
215                // Entries from another build can never be hit (the build id is
216                // part of every key), so they are not worth holding in memory
217                // and are dropped on the next write.
218                if e.build == build {
219                    mem.entries.insert(e.key, (e.verify, e.blob));
220                }
221            }
222            mem
223        });
224        f(mem)
225    })
226}
227
228/// Look up a compiled program for `src`, if present and current.
229pub fn load(src: &str) -> Option<Program> {
230    let key = key_for(src);
231    let verify = verify_for(src);
232    let blob = with_shard(|m| {
233        m.entries
234            .get(&key)
235            .filter(|(v, _)| *v == verify)
236            .map(|(_, b)| b.clone())
237    })?;
238    let cp: CProg = bincode::deserialize(&blob).ok()?;
239    Some(Program {
240        main: cp.main,
241        functions: cp.functions,
242        tries: cp.tries,
243    })
244}
245
246/// Record `prog` (compiled from `src`) in the resident shard. Reaches disk at
247/// [`flush`], not here.
248pub fn store(src: &str, prog: &Program) -> Result<(), String> {
249    let cp = CProg {
250        main: prog.main.clone(),
251        functions: prog.functions.clone(),
252        tries: prog.tries.clone(),
253    };
254    let blob = bincode::serialize(&cp).map_err(|e| format!("cache encode: {e}"))?;
255    let key = key_for(src);
256    let verify = verify_for(src);
257    with_shard(|m| {
258        m.entries.insert(key, (verify, blob));
259        m.dirty = true;
260    });
261    Ok(())
262}
263
264/// Write the resident shard back, once, at the end of the run.
265///
266/// The on-disk shard is re-read and MERGED rather than overwritten: up to 16
267/// instances share it, and a plain overwrite would drop whatever a peer stored
268/// while this process was running. A losing writer still only loses entries
269/// (they recompile next run); it can never corrupt the file, since the write
270/// itself is a temp-plus-rename.
271pub fn flush() {
272    let build = build_id();
273    let pending = SHARD.with(|c| {
274        let mut slot = c.borrow_mut();
275        match slot.as_mut() {
276            Some(m) if m.dirty => {
277                m.dirty = false;
278                Some(m.entries.clone())
279            }
280            _ => None,
281        }
282    });
283    let Some(mut merged) = pending else { return };
284    for e in load_shard().entries {
285        if e.build == build {
286            merged.entry(e.key).or_insert((e.verify, e.blob));
287        }
288    }
289    let shard = Shard {
290        entries: merged
291            .into_iter()
292            .map(|(key, (verify, blob))| Entry {
293                key,
294                verify,
295                build,
296                blob,
297            })
298            .collect(),
299    };
300    let _ = write_shard(&shard);
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    /// Both cache hashes must depend on the BUILD, not on `SCHEMA` alone.
308    ///
309    /// `SCHEMA` and `BUILD_VERSION` are bumped by hand, so the codegen change
310    /// that forgets one would otherwise read the previous build's bytecode out
311    /// of the shared shard and run the wrong program with no error. This was a
312    /// real, reproduced failure: with the key depending on `(SCHEMA, src)`
313    /// alone, lowering `**` to a wrong opcode and rebuilding still printed the
314    /// OLD answer for an already-cached script.
315    ///
316    /// Each hash is recomputed here with a component left out and required to
317    /// differ, so DELETING any one of the four `hash` lines in `key_for` /
318    /// `verify_for` fails this test rather than silently restoring the bug.
319    #[test]
320    fn cache_keys_depend_on_the_build_not_just_the_schema() {
321        use std::collections::hash_map::DefaultHasher;
322        let src = "console.log(1)\n";
323
324        // key_for without the version+build id.
325        let mut bare = rustc_hash::FxHasher::default();
326        SCHEMA.hash(&mut bare);
327        src.hash(&mut bare);
328        assert_ne!(
329            key_for(src),
330            bare.finish(),
331            "key_for must hash the build identity, not just SCHEMA"
332        );
333
334        // key_for with the version but WITHOUT the per-build id: this is what
335        // the fleet's version-only design hashes, and it is what leaves two dev
336        // builds of one version sharing a shard.
337        let mut version_only = rustc_hash::FxHasher::default();
338        SCHEMA.hash(&mut version_only);
339        BUILD_VERSION.hash(&mut version_only);
340        src.hash(&mut version_only);
341        assert_ne!(
342            key_for(src),
343            version_only.finish(),
344            "key_for must hash the per-build id, so two dev builds of one \
345             version cannot share cached bytecode"
346        );
347
348        // verify_for, same two omissions.
349        let mut bare = DefaultHasher::new();
350        SCHEMA.hash(&mut bare);
351        src.len().hash(&mut bare);
352        src.hash(&mut bare);
353        assert_ne!(
354            verify_for(src),
355            bare.finish(),
356            "verify_for must hash the build identity, not just SCHEMA"
357        );
358
359        let mut version_only = DefaultHasher::new();
360        SCHEMA.hash(&mut version_only);
361        BUILD_VERSION.hash(&mut version_only);
362        src.len().hash(&mut version_only);
363        src.hash(&mut version_only);
364        assert_ne!(
365            verify_for(src),
366            version_only.finish(),
367            "verify_for must hash the per-build id"
368        );
369
370        // The version that is hashed is THIS build's, so a release bump rotates
371        // the whole shard.
372        assert_eq!(BUILD_VERSION, env!("CARGO_PKG_VERSION"));
373        // A `0` build id means the stat failed and the per-build guarantee is
374        // gone; under `cargo test` the binary is always readable.
375        assert_ne!(build_id(), 0, "build_id must read the running binary");
376        // Distinct sources still land on distinct keys.
377        assert_ne!(key_for(src), key_for("console.log(2)\n"));
378    }
379}