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