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, so a source or format
13//! change misses cleanly instead of loading stale bytecode.
14
15use crate::compiler::Program;
16use crate::host::{FuncDef, TryDef};
17use fusevm::Chunk;
18use rkyv::{Archive, Deserialize as RkyvDe, Serialize as RkyvSer};
19use serde::{Deserialize, Serialize};
20use std::hash::{Hash, Hasher};
21use std::path::PathBuf;
22
23/// Bump on any incompatible change to `CProg` / the lowering / the shard layout.
24/// v1: initial JS bytecode cache — a Chunk/func/try layout change here must miss
25///     cleanly so an older cached `.js` never loads incompatible bytecode.
26/// v2: BigInt/RegExp/tagged-template/for-await lowering — new builtin ops
27///     (MKBIGINT/MKREGEX/NUM_STEP/TAG_TMPL/…) and the type-preserving `++`/`--`
28///     codegen; old cached bytecode would run the stale POS-based increment.
29const SCHEMA: u64 = 2;
30
31/// The outer, rkyv-archived shard: a flat list of (key, bincode-blob) entries.
32#[derive(Archive, RkyvSer, RkyvDe, Default)]
33#[archive(check_bytes)]
34struct Shard {
35    entries: Vec<Entry>,
36}
37
38#[derive(Archive, RkyvSer, RkyvDe)]
39#[archive(check_bytes)]
40struct Entry {
41    key: u64,
42    /// A second, independent hash of the source. A cache hit requires BOTH `key`
43    /// and `verify` to match, so an `FxHash` collision on `key` can never return
44    /// a different program's bytecode (which would silently produce wrong
45    /// results — far worse than a cache miss).
46    verify: u64,
47    blob: Vec<u8>,
48}
49
50/// The inner, serde/bincode form of a compiled program.
51#[derive(Serialize, Deserialize)]
52struct CProg {
53    main: Chunk,
54    functions: Vec<(String, FuncDef)>,
55    tries: Vec<TryDef>,
56}
57
58/// A stable content key for a source string (fast `FxHash`, used for lookup).
59pub fn key_for(src: &str) -> u64 {
60    let mut h = rustc_hash::FxHasher::default();
61    SCHEMA.hash(&mut h);
62    src.hash(&mut h);
63    h.finish()
64}
65
66/// An independent verification hash (std `DefaultHasher`/SipHash), so a hit
67/// requires both hashes to agree — collision-proof for correctness.
68fn verify_for(src: &str) -> u64 {
69    use std::collections::hash_map::DefaultHasher;
70    let mut h = DefaultHasher::new();
71    SCHEMA.hash(&mut h);
72    src.len().hash(&mut h);
73    src.hash(&mut h);
74    h.finish()
75}
76
77fn shard_path() -> Option<PathBuf> {
78    let dir = dirs::home_dir()?.join(".node-js");
79    let _ = std::fs::create_dir_all(&dir);
80    Some(dir.join("scripts.rkyv"))
81}
82
83fn load_shard() -> Shard {
84    let Some(path) = shard_path() else {
85        return Shard::default();
86    };
87    let Ok(bytes) = std::fs::read(&path) else {
88        return Shard::default();
89    };
90    rkyv::from_bytes::<Shard>(&bytes).unwrap_or_default()
91}
92
93fn write_shard(shard: &Shard) -> Result<(), String> {
94    let path = shard_path().ok_or("no home dir for cache")?;
95    let bytes = rkyv::to_bytes::<_, 4096>(shard).map_err(|e| format!("cache serialize: {e}"))?;
96    // Atomic replace (write temp + rename) so a concurrent reader — up to 16
97    // instances run against the shared shard — never sees a torn file. A losing
98    // concurrent writer just drops its entry (recompiled next run); it can never
99    // corrupt the shard. The temp name is unique per WRITE (pid + a monotonic
100    // counter), not just per process, so concurrent writers within one process
101    // (e.g. parallel test threads) never clobber each other's temp file.
102    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
103    let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
104    let tmp = path.with_extension(format!("rkyv.tmp.{}.{n}", std::process::id()));
105    std::fs::write(&tmp, &bytes).map_err(|e| format!("cache write: {e}"))?;
106    std::fs::rename(&tmp, &path).map_err(|e| {
107        let _ = std::fs::remove_file(&tmp);
108        format!("cache rename: {e}")
109    })
110}
111
112/// Look up a compiled program for `src`, if present and current.
113pub fn load(src: &str) -> Option<Program> {
114    let key = key_for(src);
115    let verify = verify_for(src);
116    let shard = load_shard();
117    let entry = shard
118        .entries
119        .iter()
120        .find(|e| e.key == key && e.verify == verify)?;
121    let cp: CProg = bincode::deserialize(&entry.blob).ok()?;
122    Some(Program {
123        main: cp.main,
124        functions: cp.functions,
125        tries: cp.tries,
126    })
127}
128
129/// Store `prog` (compiled from `src`) into the shard, replacing any prior entry.
130pub fn store(src: &str, prog: &Program) -> Result<(), String> {
131    let key = key_for(src);
132    let verify = verify_for(src);
133    let cp = CProg {
134        main: prog.main.clone(),
135        functions: prog.functions.clone(),
136        tries: prog.tries.clone(),
137    };
138    let blob = bincode::serialize(&cp).map_err(|e| format!("cache encode: {e}"))?;
139    let mut shard = load_shard();
140    shard.entries.retain(|e| e.key != key);
141    shard.entries.push(Entry { key, verify, blob });
142    write_shard(&shard)
143}