Skip to main content

zsh/extensions/
autoload_cache.rs

1//! rkyv-backed bytecode cache for autoload functions.
2//!
3//! Single-file shard at `~/.zshrs/autoloads.rkyv`, keyed by function
4//! name. **zshrs-original — no C counterpart**, so the design rule here
5//! is correctness first: an entry that cannot be PROVEN to describe the
6//! body about to be installed is a miss.
7//!
8//! Storage layout (rkyv archived):
9//!   AutoloadShard {
10//!     header: { magic, format_version, zshrs_version, pointer_width, built_at_secs },
11//!     entries: HashMap<function_name, AutoloadEntry>,
12//!   }
13//!
14//! Inner `chunk_blob` is bincode-encoded `fusevm::Chunk` (same constraint as
15//! [`crate::script_cache`] module — `fusevm::Chunk` is upstream and only derives serde).
16//!
17//! # What identifies an entry
18//!
19//! Three things have to match before a chunk may be run, because a chunk
20//! is a function of all three:
21//!
22//!   * **the producing binary**, as `(mtime, len)` of `current_exe()`.
23//!     Bytecode is not a stable interchange format: the builtin index
24//!     table and the opcode lowering both move between builds, so a
25//!     chunk emitted by a different `zshrs` is meaningless here even
26//!     when `zshrs_version` matches. It routinely does match — a debug
27//!     build and the installed release build share `0.12.36` — which is
28//!     why the version string cannot carry this check.
29//!   * **the resolved fpath directory**, because the same function name
30//!     lives in several directories on a real `$fpath` and the cache is
31//!     keyed by name alone.
32//!   * **the exact definition text**, as a SHA-256 of the very string
33//!     that will be compiled. Not a `stat` of `<dir>/<name>`: the body
34//!     the loader installs can come out of a `dir.zwc` digest instead of
35//!     that file, and stamping a path that is not the source is how a
36//!     chunk built from one text gets served for another.
37//!
38//! The previous scheme stamped `(mtime, len)` of `<dir>/<name>` and
39//! treated the binary check as one-directional (`cached < current` =
40//! stale), so a chunk written by a NEWER binary was served to an older
41//! one. On the corpus this was built for that meant `_megacomplete`
42//! being "installed" from a chunk that never defined it, and every
43//! `<TAB>` producing `_megacomplete: function not defined by file` and
44//! zero matches.
45//!
46//! Bulk-write: compinit prewarms 16k+ autoload bytecodes in one go. Per-batch
47//! shard rewrites (the SQLite-era pattern) would re-serialize 16k entries
48//! 160 times. Instead `put_many` accumulates all entries in memory and
49//! writes the shard once. The single-add `put_one` path remains for the
50//! cold-start case where one autoload at a time is compiled by the
51//! interactive shell.
52//!
53//! The on-disk shape mirrors [`ScriptShard`](crate::script_cache::ScriptShard) — same header,
54//! same magic-version-pointer_width discipline, same atomic-rename writes.
55
56use std::collections::HashMap;
57use std::fs::File;
58use std::path::{Path, PathBuf};
59use std::sync::OnceLock;
60use std::time::{SystemTime, UNIX_EPOCH};
61
62use memmap2::Mmap;
63use parking_lot::Mutex;
64use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
65use std::os::unix::fs::MetadataExt;
66
67/// "ZRAL" little-endian.
68pub const SHARD_MAGIC: u32 = 0x5A52414C;
69/// `SHARD_FORMAT_VERSION` constant.
70///
71/// v2 stamped an entry with `(mtime, len)` of `<loaddir>/<name>` and
72/// accepted any chunk not strictly older than the running binary. Both
73/// tests could pass for a chunk compiled from different text by a
74/// different build, so v2 entries are not trustworthy and this bump
75/// discards them. v3 stamps the resolved directory, a SHA-256 of the
76/// exact definition text, and the producing binary's identity.
77/// v4: token chars moved to the Private Use Area (crate::token_char); v3
78/// chunks hold the old U+0084..=U+00A2 tokens.
79/// v5: an entry can carry its [`ResolvedLoad`], so a load is served by name
80/// with no `$fpath` search.
81/// v7: [`ResolvedLoad`] carries the `.zwc` pipeline line table.
82pub const SHARD_FORMAT_VERSION: u32 = 7;
83/// `ShardHeader` — see fields for layout.
84#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
85#[archive(check_bytes)]
86pub struct ShardHeader {
87    /// `magic` field.
88    pub magic: u32,
89    /// `format_version` field.
90    pub format_version: u32,
91    /// `zshrs_version` field.
92    pub zshrs_version: String,
93    /// `pointer_width` field.
94    pub pointer_width: u32,
95    /// `built_at_secs` field.
96    pub built_at_secs: u64,
97}
98/// `AutoloadEntry` — see fields for layout.
99#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
100#[archive(check_bytes)]
101pub struct AutoloadEntry {
102    /// mtime of the `zshrs` binary that emitted `chunk_blob`.
103    pub binary_mtime_at_cache: i64,
104    /// Byte length of that binary. Paired with the mtime because two
105    /// different builds can land in the same second, and a debug build
106    /// and a release build differ enormously in size.
107    pub binary_len_at_cache: u64,
108    /// `cached_at_secs` field.
109    pub cached_at_secs: i64,
110    /// The fpath directory the definition was resolved from. The cache
111    /// is keyed by function NAME, and a real `$fpath` has the same name
112    /// in several directories, so the winner has to be recorded.
113    pub source_dir: String,
114    /// SHA-256 of the exact definition text that produced `chunk_blob`
115    /// — `name() { <body> }` as the loader builds it. Hashing the text
116    /// rather than stat-ing a file is what makes a `.zwc`-digest body
117    /// and a plain-file body distinguishable.
118    pub source_sha: [u8; 32],
119    /// bincode of the `fusevm::Chunk` for the definition program.
120    pub chunk_blob: Vec<u8>,
121    /// Everything the `$fpath` search and the file read produced for this
122    /// function, so the next load needs neither (see [`try_resolve`]).
123    /// `None` for an entry written without it; that entry is still served by
124    /// `(dir, sha)` after a search.
125    pub resolved: Option<ResolvedLoad>,
126}
127
128/// The outcome of resolving and reading one autoload, as `loadautofn` and
129/// the registration step computed it: the body `getfpfunc` led to and the
130/// definition text that body registers as.
131#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone, PartialEq)]
132#[archive(check_bytes)]
133pub struct ResolvedLoad {
134    /// The body `loadautofn` installs: the file text, or a `.zwc` deparse.
135    pub body: String,
136    /// The definition text the body registers as (the `name() { … }` wrap
137    /// decision applied). Its SHA-256 is the entry's `source_sha`.
138    pub registered: String,
139    /// The dump's ksh style (`try_dump_file`'s `*ksh`), or -1 for a plain file.
140    pub dump_ksh: i32,
141    /// The body is a `.zwc` deparse.
142    pub from_wordcode: bool,
143    /// The registration parse rejected the body.
144    pub parse_failed: bool,
145    /// `autoload_is_ksh_style` when the body was registered; the wrap
146    /// decision depends on it.
147    pub ksh_style: bool,
148    /// Every file in `source_dir` the resolution could have read
149    /// ([`stamp_candidates`]), as it was then.
150    pub stamps: Vec<FileStamp>,
151    /// The `.zwc` pipeline line table of a deparsed body
152    /// (`crate::zwc_lineno`); empty for a source-file body.
153    pub zwc_lines: Vec<crate::zwc_lineno::PipeLine>,
154}
155
156/// One file's identity at resolution time.
157#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone, PartialEq)]
158#[archive(check_bytes)]
159pub struct FileStamp {
160    /// Absolute path.
161    pub path: String,
162    /// Whether it existed.
163    pub exists: bool,
164    /// mtime in nanoseconds.
165    pub mtime_ns: i64,
166    /// Size in bytes.
167    pub len: u64,
168}
169/// `AutoloadShard` — see fields for layout.
170#[derive(Archive, RkyvDeserialize, RkyvSerialize, Debug, Clone)]
171#[archive(check_bytes)]
172pub struct AutoloadShard {
173    /// `header` field.
174    pub header: ShardHeader,
175    /// `entries` field.
176    pub entries: HashMap<String, AutoloadEntry>,
177}
178/// `MmappedShard` — see fields for layout.
179pub struct MmappedShard {
180    /// `_mmap` field.
181    _mmap: Mmap,
182    /// `archived` field.
183    archived: *const ArchivedAutoloadShard,
184}
185
186unsafe impl Send for MmappedShard {}
187unsafe impl Sync for MmappedShard {}
188
189impl MmappedShard {
190    /// `open` — see implementation.
191    pub fn open(path: &Path) -> Option<Self> {
192        let file = File::open(path).ok()?;
193        let mmap = unsafe { Mmap::map(&file).ok()? };
194        let archived = rkyv::check_archived_root::<AutoloadShard>(&mmap[..]).ok()?;
195        let archived_ptr = archived as *const ArchivedAutoloadShard;
196        Some(Self {
197            _mmap: mmap,
198            archived: archived_ptr,
199        })
200    }
201
202    fn shard(&self) -> &ArchivedAutoloadShard {
203        unsafe { &*self.archived }
204    }
205
206    fn header_ok(&self) -> bool {
207        let h = &self.shard().header;
208        let magic: u32 = h.magic.into();
209        let fv: u32 = h.format_version.into();
210        let pw: u32 = h.pointer_width.into();
211        magic == SHARD_MAGIC
212            && fv == SHARD_FORMAT_VERSION
213            && pw as usize == std::mem::size_of::<usize>()
214            && h.zshrs_version.as_str() == env!("CARGO_PKG_VERSION")
215    }
216
217    fn lookup(&self, name: &str) -> Option<&ArchivedAutoloadEntry> {
218        self.shard().entries.get(name)
219    }
220}
221
222/// Was this entry emitted by the binary that is running right now?
223///
224/// EXACT equality, not "not older". A chunk from any other build is
225/// unusable whichever direction the timestamps point, and the older
226/// `<` test was what let a newer build's bytecode be executed by an
227/// older one.
228fn entry_binary_matches(entry: &ArchivedAutoloadEntry) -> bool {
229    let Some((mtime, len)) = current_binary_identity() else {
230        // No `current_exe()` — nothing can be proven, so nothing is used.
231        return false;
232    };
233    let cached_mtime: i64 = entry.binary_mtime_at_cache.into();
234    let cached_len: u64 = entry.binary_len_at_cache.into();
235    cached_mtime == mtime && cached_len == len
236}
237
238/// `AutoloadCache` — see fields for layout.
239pub struct AutoloadCache {
240    /// `path` field.
241    path: PathBuf,
242    /// `lock_path` field.
243    lock_path: PathBuf,
244    /// `mmap` field.
245    mmap: Mutex<Option<MmappedShard>>,
246    /// Cold-start writes not yet folded into the shard on disk.
247    ///
248    /// `put_one` is called once per autoload compile. Folding each one
249    /// in immediately costs a full read + `bytecheck` + deserialize +
250    /// re-serialize + write of the WHOLE file, so a single completion
251    /// run that autoloads N helpers was O(N x shard). On a 46k-completer
252    /// `$fpath` the shard reaches 40 MB and one `<TAB>` autoloads dozens
253    /// of `_*` helpers, which is how a keypress came to cost 30+ s.
254    /// Entries accumulate here and leave in ONE `put_many`.
255    pending: Mutex<Vec<(String, Vec<u8>, String, [u8; 32])>>,
256    /// Resolutions waiting for their entry's write ([`note_resolved`]). The
257    /// next write of that name's entry takes the one stored here.
258    resolved_pending: Mutex<HashMap<String, ResolvedLoad>>,
259}
260
261/// Cap on un-flushed [`AutoloadCache::pending`] entries.
262///
263/// A MEMORY bound, not a latency knob: the flush that matters is the one
264/// at the next prompt (or at exit), and that is where the batching win
265/// comes from. This only stops a script that autoloads without ever
266/// reaching a prompt from growing the buffer without limit.
267const PENDING_FLUSH_MAX: usize = 256;
268
269impl AutoloadCache {
270    /// `open` — see implementation.
271    pub fn open(path: &Path) -> std::io::Result<Self> {
272        if let Some(parent) = path.parent() {
273            std::fs::create_dir_all(parent)?;
274        }
275        let parent = path.parent().unwrap_or_else(|| Path::new("/tmp"));
276        let lock_path = parent.join(format!(
277            "{}.lock",
278            path.file_name()
279                .and_then(|s| s.to_str())
280                .unwrap_or("autoloads.rkyv")
281        ));
282        Ok(Self {
283            path: path.to_path_buf(),
284            lock_path,
285            mmap: Mutex::new(None),
286            pending: Mutex::new(Vec::new()),
287            resolved_pending: Mutex::new(HashMap::new()),
288        })
289    }
290
291    fn ensure_mmap(&self) {
292        let mut guard = self.mmap.lock();
293        if guard.is_none() {
294            *guard = MmappedShard::open(&self.path);
295        }
296    }
297
298    fn invalidate_mmap(&self) {
299        let mut guard = self.mmap.lock();
300        *guard = None;
301    }
302    /// Raw probe: the chunk for `name` with only the producing-binary
303    /// check applied. `dbview autoloads <name>` uses it to report
304    /// whether an entry exists. NOT for execution — use
305    /// [`AutoloadCache::get_for_source`], which also proves the entry
306    /// describes the definition text about to be installed.
307    pub fn get(&self, name: &str) -> Option<Vec<u8>> {
308        // An empty chunk is a probe-only entry (`record_port_probe`): no
309        // bytecode.
310        if let Some(blob) = self.pending_lookup(name, None, None) {
311            return (!blob.is_empty()).then_some(blob);
312        }
313        self.ensure_mmap();
314        let guard = self.mmap.lock();
315        let shard = guard.as_ref()?;
316        if !shard.header_ok() {
317            return None;
318        }
319        let entry = shard.lookup(name)?;
320        if !entry_binary_matches(entry) {
321            return None;
322        }
323        (!entry.chunk_blob.is_empty()).then(|| entry.chunk_blob.as_slice().to_vec())
324    }
325
326    /// The chunk for `name`, but only if this exact binary compiled it
327    /// from this exact definition text found in this exact directory.
328    /// Anything else — an edited file, a `.zwc` digest body, a chunk
329    /// from another build, no entry at all — is a miss, and a miss just
330    /// means "parse it yourself".
331    pub fn get_for_source(
332        &self,
333        name: &str,
334        source_dir: &str,
335        source_sha: &[u8; 32],
336    ) -> Option<Vec<u8>> {
337        if let Some(blob) = self.pending_lookup(name, Some(source_dir), Some(source_sha)) {
338            return Some(blob);
339        }
340        self.ensure_mmap();
341        let guard = self.mmap.lock();
342        let shard = guard.as_ref()?;
343        if !shard.header_ok() {
344            return None;
345        }
346        let entry = shard.lookup(name)?;
347        if !entry_binary_matches(entry) {
348            return None;
349        }
350        if entry.source_dir.as_str() != source_dir {
351            return None;
352        }
353        if entry.source_sha.as_slice() != source_sha.as_slice() {
354            return None;
355        }
356        Some(entry.chunk_blob.as_slice().to_vec())
357    }
358
359    /// Read the shard for mutation, discarding one whose header this
360    /// build cannot write into.
361    fn owned_shard_for_write(&self) -> AutoloadShard {
362        match read_owned_shard(&self.path) {
363            Some(s)
364                if s.header.zshrs_version == env!("CARGO_PKG_VERSION")
365                    && s.header.pointer_width as usize == std::mem::size_of::<usize>()
366                    && s.header.format_version == SHARD_FORMAT_VERSION =>
367            {
368                s
369            }
370            _ => fresh_shard(),
371        }
372    }
373
374    /// Buffer one entry from the cold-start path, where a function is
375    /// autoloaded before compinit pre-warm has cached it.
376    ///
377    /// This used to call `put_many` with a single entry, which rewrote
378    /// the entire shard per autoload. See [`AutoloadCache::pending`] for
379    /// why that is quadratic in practice. The write now happens in
380    /// [`AutoloadCache::flush_pending`], called at the next prompt and
381    /// again on exit.
382    pub fn put_one(
383        &self,
384        name: &str,
385        chunk_blob: Vec<u8>,
386        source_dir: &str,
387        source_sha: [u8; 32],
388    ) -> Result<(), String> {
389        {
390            let mut pending = self.pending.lock();
391            pending.push((
392                name.to_string(),
393                chunk_blob,
394                source_dir.to_string(),
395                source_sha,
396            ));
397            if pending.len() < PENDING_FLUSH_MAX {
398                return Ok(());
399            }
400        }
401        self.flush_pending()
402    }
403
404    /// Write every buffered entry in one shard rewrite. A no-op when
405    /// nothing is buffered, so it is cheap to call on every prompt.
406    ///
407    /// On failure the batch is NOT put back: a shard write that failed
408    /// once (read-only home, full disk) will fail again, and retrying it
409    /// at every prompt would turn a broken cache into a stall. Dropping
410    /// the batch only costs a recompile.
411    ///
412    /// The lock is taken WITHOUT blocking. This runs before every prompt,
413    /// and the holder may be another process rewriting a shard that has
414    /// reached 1 GB, which a debug build takes minutes to do; a blocking
415    /// `flock` here left a new shell with no prompt for that long. When
416    /// the lock is busy the batch goes back into the buffer for the next
417    /// prompt.
418    pub fn flush_pending(&self) -> Result<(), String> {
419        let batch = {
420            let mut pending = self.pending.lock();
421            // A resolution can be waiting without a chunk: the chunk was
422            // already cached, so nothing called `put_one`.
423            if pending.is_empty() && self.resolved_pending.lock().is_empty() {
424                return Ok(());
425            }
426            std::mem::take(&mut *pending)
427        };
428        let Some(lock) = try_acquire_lock(&self.lock_path) else {
429            self.requeue(batch);
430            return Ok(());
431        };
432        self.put_many_locked(&batch, lock)
433    }
434
435    /// Put a batch whose flush found the lock busy back in front of
436    /// anything buffered since, so `pending_lookup`'s last-write-wins
437    /// order holds. Bounded by [`PENDING_FLUSH_MAX`]: past it the oldest
438    /// entries are dropped, which only costs a recompile.
439    fn requeue(&self, mut batch: Vec<(String, Vec<u8>, String, [u8; 32])>) {
440        let mut pending = self.pending.lock();
441        batch.append(&mut pending);
442        let excess = batch.len().saturating_sub(PENDING_FLUSH_MAX);
443        batch.drain(..excess);
444        *pending = batch;
445    }
446
447    /// Serve an entry that is buffered but not yet on disk.
448    ///
449    /// Without this a lookup between a `put_one` and the next flush
450    /// would miss and recompile a body this very process just compiled.
451    /// The binary-identity check the on-disk path applies is trivially
452    /// true here — this process wrote these.
453    fn pending_lookup(
454        &self,
455        name: &str,
456        source_dir: Option<&str>,
457        source_sha: Option<&[u8; 32]>,
458    ) -> Option<Vec<u8>> {
459        let pending = self.pending.lock();
460        // Reverse: last write for a name wins, matching the repeated
461        // `entries.insert` in `put_many`.
462        pending.iter().rev().find_map(|(n, blob, dir, sha)| {
463            if n != name {
464                return None;
465            }
466            if source_dir.is_some_and(|d| d != dir) {
467                return None;
468            }
469            if source_sha.is_some_and(|s| s != sha) {
470                return None;
471            }
472            Some(blob.clone())
473        })
474    }
475
476    /// Insert many entries in one read + one write of the shard.
477    ///
478    /// The bulk path for `zshrs --prewarm-autoloads`: compiling 46k
479    /// completers one entry at a time would re-serialize the whole
480    /// shard 46k times. Existing entries not named here are preserved,
481    /// so a prewarm of one fpath dir does not discard the rest.
482    pub fn put_many(&self, entries: &[(String, Vec<u8>, String, [u8; 32])]) -> Result<(), String> {
483        if entries.is_empty() && self.resolved_pending.lock().is_empty() {
484            return Ok(());
485        }
486        let Some(lock) = acquire_lock(&self.lock_path) else {
487            return Ok(());
488        };
489        self.put_many_locked(entries, lock)
490    }
491
492    /// The read-modify-write of [`AutoloadCache::put_many`], for a caller
493    /// that already holds the shard lock. The lock is released on return.
494    fn put_many_locked(
495        &self,
496        entries: &[(String, Vec<u8>, String, [u8; 32])],
497        _lock: nix::fcntl::Flock<File>,
498    ) -> Result<(), String> {
499        let mut shard = self.owned_shard_for_write();
500        let (bin_mtime, bin_len) = current_binary_identity().unwrap_or((0, 0));
501        let now = now_secs();
502        for (name, chunk_blob, source_dir, source_sha) in entries {
503            // A resolution noted for this name describes this write only if
504            // it registers the same text; otherwise keep the one already on
505            // disk when the entry itself is unchanged.
506            // A probe-only write (`record_port_probe`: no chunk) never replaces
507            // a compiled entry; its resolution is attached below instead.
508            if chunk_blob.is_empty() && shard.entries.contains_key(name) {
509                continue;
510            }
511            let noted = self
512                .resolved_pending
513                .lock()
514                .remove(name)
515                .filter(|r| r.registered.is_empty() || registered_digest(r) == *source_sha);
516            let prior = shard
517                .entries
518                .get(name)
519                .filter(|e| e.source_dir == *source_dir && e.source_sha == *source_sha)
520                .and_then(|e| e.resolved.clone());
521            shard.entries.insert(
522                name.clone(),
523                AutoloadEntry {
524                    binary_mtime_at_cache: bin_mtime,
525                    binary_len_at_cache: bin_len,
526                    cached_at_secs: now,
527                    source_dir: source_dir.clone(),
528                    source_sha: *source_sha,
529                    chunk_blob: chunk_blob.clone(),
530                    resolved: noted.or(prior),
531                },
532            );
533        }
534        // Resolutions for entries this batch did not rewrite: attach each to
535        // the entry it registers, when that entry still holds the same text
536        // from the same directory.
537        for (name, r) in std::mem::take(&mut *self.resolved_pending.lock()) {
538            if let Some(e) = shard.entries.get_mut(&name) {
539                let same_dir = r
540                    .stamps
541                    .first()
542                    .is_some_and(|s| s.path == format!("{}/{}", e.source_dir, name));
543                // A probe resolution (empty `registered`) only fills an entry
544                // that has none; a load's resolution must register its text.
545                let fits = if r.registered.is_empty() {
546                    e.resolved.is_none()
547                } else {
548                    registered_digest(&r) == e.source_sha
549                };
550                if same_dir && fits {
551                    e.resolved = Some(r);
552                }
553            }
554        }
555        shard.header.built_at_secs = now as u64;
556        write_shard_atomic(&self.path, &shard)?;
557        self.invalidate_mmap();
558        Ok(())
559    }
560
561    /// Drop the entry for `name`, if there is one.
562    ///
563    /// The loader calls this when a cached chunk ran without defining
564    /// the function it was stored under — proof the entry is wrong. It
565    /// has to go, or every later process pays the same failure before
566    /// falling back.
567    pub fn remove(&self, name: &str) -> Result<(), String> {
568        // Drop any buffered copy first, or the next flush would write
569        // back the very entry just proven wrong.
570        self.pending.lock().retain(|(n, _, _, _)| n != name);
571        self.resolved_pending.lock().remove(name);
572        let _lock = match acquire_lock(&self.lock_path) {
573            Some(l) => l,
574            None => return Ok(()),
575        };
576        let mut shard = self.owned_shard_for_write();
577        if shard.entries.remove(name).is_none() {
578            return Ok(());
579        }
580        shard.header.built_at_secs = now_secs() as u64;
581        write_shard_atomic(&self.path, &shard)?;
582        self.invalidate_mmap();
583        Ok(())
584    }
585    /// `entry_count` — see implementation.
586    pub fn entry_count(&self) -> usize {
587        self.ensure_mmap();
588        let guard = self.mmap.lock();
589        guard.as_ref().map(|s| s.shard().entries.len()).unwrap_or(0)
590    }
591
592    /// Set of cached function names — caller can subtract this from "all
593    /// known autoload names" to compute the missing-bytecode set without a
594    /// SQL JOIN.
595    pub fn cached_names(&self) -> std::collections::HashSet<String> {
596        self.ensure_mmap();
597        let guard = self.mmap.lock();
598        let Some(shard) = guard.as_ref() else {
599            return std::collections::HashSet::new();
600        };
601        shard
602            .shard()
603            .entries
604            .keys()
605            .map(|k| k.as_str().to_string())
606            .collect()
607    }
608    /// `stats` — see implementation.
609    pub fn stats(&self) -> (i64, i64) {
610        self.ensure_mmap();
611        let guard = self.mmap.lock();
612        let Some(shard) = guard.as_ref() else {
613            return (0, 0);
614        };
615        let count = shard.shard().entries.len() as i64;
616        let bytes: i64 = shard
617            .shard()
618            .entries
619            .values()
620            .map(|e| e.chunk_blob.len() as i64)
621            .sum();
622        (count, bytes)
623    }
624    /// `clear` — see implementation.
625    pub fn clear(&self) -> std::io::Result<()> {
626        let _lock = acquire_lock(&self.lock_path);
627        let res = match std::fs::remove_file(&self.path) {
628            Ok(()) => Ok(()),
629            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
630            Err(e) => Err(e),
631        };
632        crate::atomic_write::reap_orphan_temps(&self.path);
633        self.invalidate_mmap();
634        res
635    }
636}
637
638fn acquire_lock(path: &Path) -> Option<nix::fcntl::Flock<File>> {
639    let f = File::options()
640        .read(true)
641        .write(true)
642        .create(true)
643        .truncate(false)
644        .open(path)
645        .ok()?;
646    nix::fcntl::Flock::lock(f, nix::fcntl::FlockArg::LockExclusive).ok()
647}
648
649/// [`acquire_lock`] that returns `None` instead of waiting when another
650/// process holds the lock.
651fn try_acquire_lock(path: &Path) -> Option<nix::fcntl::Flock<File>> {
652    let f = File::options()
653        .read(true)
654        .write(true)
655        .create(true)
656        .truncate(false)
657        .open(path)
658        .ok()?;
659    nix::fcntl::Flock::lock(f, nix::fcntl::FlockArg::LockExclusiveNonblock).ok()
660}
661
662fn fresh_shard() -> AutoloadShard {
663    AutoloadShard {
664        header: ShardHeader {
665            magic: SHARD_MAGIC,
666            format_version: SHARD_FORMAT_VERSION,
667            zshrs_version: env!("CARGO_PKG_VERSION").to_string(),
668            pointer_width: std::mem::size_of::<usize>() as u32,
669            built_at_secs: now_secs() as u64,
670        },
671        entries: HashMap::new(),
672    }
673}
674
675fn read_owned_shard(path: &Path) -> Option<AutoloadShard> {
676    let bytes = std::fs::read(path).ok()?;
677    let archived = rkyv::check_archived_root::<AutoloadShard>(&bytes[..]).ok()?;
678    archived.deserialize(&mut rkyv::Infallible).ok()
679}
680
681fn write_shard_atomic(path: &Path, shard: &AutoloadShard) -> Result<(), String> {
682    let bytes = rkyv::to_bytes::<_, 4096>(shard).map_err(|e| format!("rkyv serialize: {}", e))?;
683    crate::atomic_write::write_bytes_atomic(path, &bytes)
684}
685
686fn now_secs() -> i64 {
687    SystemTime::now()
688        .duration_since(UNIX_EPOCH)
689        .map(|d| d.as_secs() as i64)
690        .unwrap_or(0)
691}
692
693/// `(mtime_secs, len)` of the running `zshrs` binary — the identity a
694/// cached chunk is stamped with. Read once; the executable does not
695/// change under a live process.
696fn current_binary_identity() -> Option<(i64, u64)> {
697    static BIN_ID: OnceLock<Option<(i64, u64)>> = OnceLock::new();
698    *BIN_ID.get_or_init(|| {
699        let exe = std::env::current_exe().ok()?;
700        let meta = std::fs::metadata(&exe).ok()?;
701        Some((meta.mtime(), meta.len()))
702    })
703}
704
705/// SHA-256 of the definition text a chunk was compiled from.
706///
707/// Both writers — the interactive loader and `--prewarm-autoloads` —
708/// hash the string they are about to hand the compiler, so an entry is
709/// only reused for byte-identical input. That is the whole guarantee:
710/// no stat, no path, no assumption about where the bytes came from.
711pub fn source_digest(text: &str) -> [u8; 32] {
712    use sha2::Digest;
713    let mut hasher = sha2::Sha256::new();
714    hasher.update(text.as_bytes());
715    hasher.finalize().into()
716}
717/// `default_cache_path` — see implementation.
718///
719/// All zshrs state lives under `$ZSHRS_HOME` (default `~/.zshrs`),
720/// matching the daemon's `CachePaths` convention (daemon/paths.rs)
721/// and the `~/.zinit`/`~/.zpwr`/`~/.oh-my-zsh` precedent. Project
722/// policy forbids `~/.cache/zshrs/` and `~/Library/Caches/zshrs/`
723/// — both of which `dirs::cache_dir()` resolves to.
724pub fn default_cache_path() -> PathBuf {
725    let root = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
726        PathBuf::from(custom)
727    } else {
728        dirs::home_dir()
729            .unwrap_or_else(|| PathBuf::from("/tmp"))
730            .join(".zshrs")
731    };
732    root.join("autoloads.rkyv")
733}
734/// `cache_enabled` — see implementation. Honors the process-local
735/// `script_cache::CACHE_DISABLED` AtomicBool first so parity-mode
736/// init can disable caches without exporting `ZSHRS_CACHE=0` (which
737/// would otherwise leak into `${(k)parameters}`).
738pub fn cache_enabled() -> bool {
739    if crate::extensions::script_cache::CACHE_DISABLED.load(std::sync::atomic::Ordering::Relaxed) {
740        return false;
741    }
742    !matches!(
743        std::env::var("ZSHRS_CACHE").as_deref(),
744        Ok("0") | Ok("false") | Ok("no")
745    )
746}
747/// `CACHE` static.
748pub static CACHE: once_cell::sync::Lazy<Option<AutoloadCache>> = once_cell::sync::Lazy::new(|| {
749    if !cache_enabled() {
750        return None;
751    }
752    AutoloadCache::open(&default_cache_path()).ok()
753});
754/// Raw presence probe for `dbview autoloads <name>`. See
755/// [`AutoloadCache::get`].
756pub fn try_load(name: &str) -> Option<Vec<u8>> {
757    let cache = CACHE.as_ref()?;
758    cache.get(name)
759}
760
761/// Execution-path lookup: the compiled definition program for `name`,
762/// valid only for this exact directory and definition text.
763pub fn try_load_for_source(name: &str, source_dir: &str, source_sha: &[u8; 32]) -> Option<Vec<u8>> {
764    let cache = CACHE.as_ref()?;
765    cache.get_for_source(name, source_dir, source_sha)
766}
767
768/// Write-through after a real autoload compile.
769pub fn try_save_one(
770    name: &str,
771    chunk_blob: &[u8],
772    source_dir: &str,
773    source_sha: [u8; 32],
774) -> Result<(), String> {
775    let Some(cache) = CACHE.as_ref() else {
776        return Ok(());
777    };
778    cache.put_one(name, chunk_blob.to_vec(), source_dir, source_sha)
779}
780
781/// The identity of every file `getfpfunc` could have read for `name` in
782/// `dir`: the plain file, the per-function dump and the directory digest
783/// (c:Src/parse.c:3725 `try_dump_file` tries `<dir>.zwc` and
784/// `<dir>/<name>.zwc` before `<dir>/<name>`).
785pub fn stamp_candidates(dir: &str, name: &str) -> Vec<FileStamp> {
786    [
787        format!("{dir}/{name}"),
788        format!("{dir}/{name}.zwc"),
789        format!("{dir}.zwc"),
790    ]
791    .into_iter()
792    .map(|path| match std::fs::metadata(&path) {
793        Ok(m) => FileStamp {
794            exists: true,
795            mtime_ns: m.mtime() * 1_000_000_000 + m.mtime_nsec(),
796            len: m.len(),
797            path,
798        },
799        Err(_) => FileStamp {
800            path,
801            exists: false,
802            mtime_ns: 0,
803            len: 0,
804        },
805    })
806    .collect()
807}
808
809/// The `source_sha` an entry for `r` carries: a `.zwc` deparse is hashed with
810/// a `\0zwc-lines\0` prefix (vm_helper `autoload_source_key`), so it never shares
811/// a key with a plain file of the same text.
812fn registered_digest(r: &ResolvedLoad) -> [u8; 32] {
813    if r.from_wordcode {
814        source_digest(&format!("\0zwc-lines\0{}", r.registered))
815    } else {
816        source_digest(&r.registered)
817    }
818}
819
820fn stamps_fresh(stamps: &[FileStamp]) -> bool {
821    stamps.iter().all(|s| match std::fs::metadata(&s.path) {
822        Ok(m) => {
823            s.exists && m.mtime() * 1_000_000_000 + m.mtime_nsec() == s.mtime_ns && m.len() == s.len
824        }
825        Err(_) => !s.exists,
826    })
827}
828
829impl AutoloadCache {
830    /// The stored resolution for `name`, if it is still the one a search
831    /// would produce. See [`try_resolve`].
832    fn resolve(&self, name: &str, fpath: &[String]) -> Option<(String, ResolvedLoad)> {
833        let pending_dir = self
834            .pending
835            .lock()
836            .iter()
837            .rev()
838            .find(|(n, ..)| n == name)
839            .map(|(_, _, dir, _)| dir.clone());
840        let (dir, resolved) = match pending_dir.and_then(|d| {
841            self.resolved_pending.lock().get(name).cloned().map(|r| (d, r))
842        }) {
843            Some(hit) => hit,
844            None => {
845                self.ensure_mmap();
846                let guard = self.mmap.lock();
847                let shard = guard.as_ref()?;
848                if !shard.header_ok() {
849                    return None;
850                }
851                let entry = shard.lookup(name)?;
852                if !entry_binary_matches(entry) {
853                    return None;
854                }
855                let resolved: ResolvedLoad =
856                    entry.resolved.as_ref()?.deserialize(&mut rkyv::Infallible).ok()?;
857                (entry.source_dir.as_str().to_string(), resolved)
858            }
859        };
860        (fpath.iter().any(|d| *d == dir) && stamps_fresh(&resolved.stamps)).then_some((dir, resolved))
861    }
862}
863
864/// The directory and resolved body for `name`, read from the shard instead
865/// of searching `$fpath` and reading the file.
866///
867/// !!! RUST-ONLY — NO C COUNTERPART !!! C's `loadautofn` runs `getfpfunc`
868/// (c:Src/exec.c:5759) on every first call of an autoload in every
869/// process. The entry records which directory that search chose and the
870/// state of every file it could have read there, so a hit costs three
871/// `stat`s: the directory must still be on `fpath`, and those files must be
872/// unchanged. A file added to an EARLIER `fpath` directory, which would now
873/// shadow this one, is not seen until the entry is rewritten (a prewarm, or
874/// a load after this entry goes stale).
875pub fn try_resolve(name: &str, fpath: &[String]) -> Option<(String, ResolvedLoad)> {
876    // A probe resolution (`record_port_probe`) has no registration to install.
877    CACHE.as_ref()?.resolve(name, fpath).filter(|(_, r)| !r.registered.is_empty())
878}
879
880/// The directory and file text for a native completer `name`, read from the
881/// shard: what a `getfpfunc` existence probe plus a read of `<dir>/<name>`
882/// would return. `None` for a `.zwc`-resolved entry (its body is a deparse,
883/// not the file) and for anything stale.
884pub fn try_port_body(name: &str, fpath: &[String]) -> Option<(String, String)> {
885    let (dir, r) = CACHE.as_ref()?.resolve(name, fpath)?;
886    (!r.from_wordcode).then_some((dir, r.body))
887}
888
889/// Record what a native-completer probe found: `name` lives in `dir` and its
890/// file reads `body`. Stored as a resolution with no registration and no
891/// chunk, which [`try_port_body`] and [`try_source_dir`] serve and the loader
892/// ignores.
893pub fn record_port_probe(name: &str, dir: &str, body: &str) {
894    let Some(cache) = CACHE.as_ref() else {
895        return;
896    };
897    note_resolved(
898        name,
899        ResolvedLoad {
900            body: body.to_string(),
901            registered: String::new(),
902            dump_ksh: -1,
903            from_wordcode: false,
904            parse_failed: false,
905            ksh_style: false,
906            stamps: stamp_candidates(dir, name),
907            zwc_lines: Vec::new(),
908        },
909    );
910    let _ = cache.put_one(name, Vec::new(), dir, [0u8; 32]);
911}
912
913/// The `$fpath` directory the entry for `name` was resolved from, while
914/// that directory is still on `fpath` and still holds the file. The
915/// shard-backed answer to a `getfpfunc` existence probe (test_only), with
916/// the same caveat as [`try_resolve`] about a later shadowing file.
917pub fn try_source_dir(name: &str, fpath: &[String]) -> Option<String> {
918    let cache = CACHE.as_ref()?;
919    if let Some((dir, _)) = cache.resolve(name, fpath) {
920        return Some(dir);
921    }
922    cache.ensure_mmap();
923    let guard = cache.mmap.lock();
924    let shard = guard.as_ref()?;
925    if !shard.header_ok() {
926        return None;
927    }
928    let dir = shard.lookup(name)?.source_dir.as_str().to_string();
929    let on_fpath = fpath.iter().any(|d| *d == dir);
930    (on_fpath && std::path::Path::new(&format!("{dir}/{name}")).exists()).then_some(dir)
931}
932
933/// Remember `resolved` for `name`; it is written with the next write of
934/// that name's entry (`put_one` / `put_many`).
935pub fn note_resolved(name: &str, resolved: ResolvedLoad) {
936    if let Some(cache) = CACHE.as_ref() {
937        cache.resolved_pending.lock().insert(name.to_string(), resolved);
938    }
939}
940
941/// What `loadautofn` resolved for one load, held until the registration
942/// step adds the definition text (see [`stage_search`], [`commit_search`]).
943struct StagedSearch {
944    dir: String,
945    body: String,
946    dump_ksh: i32,
947    from_wordcode: bool,
948}
949
950thread_local! {
951    static STAGED_SEARCH: std::cell::RefCell<HashMap<String, StagedSearch>> =
952        std::cell::RefCell::new(HashMap::new());
953    static STAGED_HIT: std::cell::RefCell<HashMap<String, ResolvedLoad>> =
954        std::cell::RefCell::new(HashMap::new());
955}
956
957/// `loadautofn` searched `$fpath` and read `body` out of `dir`.
958pub fn stage_search(name: &str, dir: &str, body: &str, dump_ksh: Option<i32>, from_wordcode: bool) {
959    STAGED_SEARCH.with(|s| {
960        s.borrow_mut().insert(
961            name.to_string(),
962            StagedSearch {
963                dir: dir.to_string(),
964                body: body.to_string(),
965                dump_ksh: dump_ksh.unwrap_or(-1),
966                from_wordcode,
967            },
968        )
969    });
970}
971
972/// The registration step turned the staged body into `registered`; record
973/// the whole resolution for the entry that compile is about to write.
974pub fn commit_search(name: &str, body: &str, registered: &str, parse_failed: bool, ksh_style: bool) {
975    let Some(s) = STAGED_SEARCH.with(|s| s.borrow_mut().remove(name)) else {
976        return;
977    };
978    if s.body != body {
979        return;
980    }
981    let stamps = stamp_candidates(&s.dir, name);
982    // The dump's pipeline lines, recorded by `loadautofn` when it installed
983    // the deparse rather than the source file.
984    let zwc_lines = if s.from_wordcode {
985        crate::zwc_lineno::autoload_lines(name).unwrap_or_default()
986    } else {
987        Vec::new()
988    };
989    note_resolved(
990        name,
991        ResolvedLoad {
992            body: s.body,
993            registered: registered.to_string(),
994            dump_ksh: s.dump_ksh,
995            from_wordcode: s.from_wordcode,
996            parse_failed,
997            ksh_style,
998            stamps,
999            zwc_lines,
1000        },
1001    );
1002}
1003
1004/// `loadautofn` installed `resolved.body` from the shard.
1005pub fn stage_hit(name: &str, resolved: ResolvedLoad) {
1006    STAGED_HIT.with(|s| s.borrow_mut().insert(name.to_string(), resolved));
1007}
1008
1009/// The stored registration for a body `loadautofn` took from the shard:
1010/// `(registered, from_wordcode, parse_failed)`. `None` unless `body` and
1011/// the ksh-style decision are the ones it was registered under.
1012pub fn take_hit(name: &str, body: &str, ksh_style: bool) -> Option<(String, bool, bool)> {
1013    let r = STAGED_HIT.with(|s| s.borrow_mut().remove(name))?;
1014    (r.body == body && r.ksh_style == ksh_style).then_some((r.registered, r.from_wordcode, r.parse_failed))
1015}
1016
1017/// Bulk write-through for the prewarm path. See
1018/// [`AutoloadCache::put_many`].
1019pub fn try_put_many(entries: &[(String, Vec<u8>, String, [u8; 32])]) -> Result<(), String> {
1020    let Some(cache) = CACHE.as_ref() else {
1021        return Ok(());
1022    };
1023    cache.put_many(entries)
1024}
1025
1026/// Write out everything `put_one` buffered. See
1027/// [`AutoloadCache::flush_pending`]. Called from `preprompt()` (the
1028/// batch boundary for an interactive shell — every autoload a command
1029/// or a `<TAB>` triggered lands in one write) and from `zexit` (the
1030/// boundary for a script, which never reaches a prompt).
1031pub fn try_flush_pending() {
1032    if let Some(cache) = CACHE.as_ref() {
1033        if let Err(e) = cache.flush_pending() {
1034            // Same TLS hazard as the reap log in `atomic_write`: this runs from
1035            // the `atexit` hook below, where `tracing`'s thread-local buffer may
1036            // be gone.
1037            if !crate::atexit_teardown::active() {
1038                tracing::warn!(error = %e, "autoload: could not flush cache");
1039            }
1040        }
1041    }
1042}
1043
1044/// libc `atexit` hook: flush whatever `put_one` buffered.
1045///
1046/// `preprompt()` covers an interactive shell and `zexit` covers a shell that
1047/// unwinds normally, but NEITHER runs on the paths that actually end a
1048/// one-shot: `zshrs -c SCRIPT` leaves through `std::process::exit` in
1049/// `bins/zshrs.rs`, and the `exit` builtin terminates the process directly.
1050/// `std::process::exit` runs no Rust destructors, so without this every
1051/// `zshrs -c` recompiled its autoloads and wrote nothing back — the cache was
1052/// dead for scripts.
1053///
1054/// libc atexit runs AFTER the Rust runtime begins tearing down thread-locals,
1055/// and `tracing::*` touches TLS, so the body is wrapped in `catch_unwind`: an
1056/// unwinding panic out of an `extern "C"` function aborts the process. Same
1057/// hazard and same mitigation as `recorder::atexit_finalize`.
1058///
1059/// `catch_unwind` alone is not enough, which is why `atexit_teardown::mark()`
1060/// comes first. `catch_unwind` runs after the panic hook, so by the time it
1061/// catches anything `panicked at ...` is already on the terminal the user is
1062/// exiting; the unwind then skips the rest of the flush (`invalidate_mmap`,
1063/// and any further work added here later). The mark tells the `tracing` sites
1064/// on this path to stay quiet so the panic never happens at all; the
1065/// `catch_unwind` stays as the backstop for anything else TLS-backed.
1066extern "C" fn atexit_flush_pending() {
1067    crate::atexit_teardown::mark();
1068    let _ = std::panic::catch_unwind(try_flush_pending);
1069    // The deparse cache buffers the same way and exits by the same paths.
1070    let _ = std::panic::catch_unwind(crate::deparse_cache::try_flush_pending);
1071    let _ = std::panic::catch_unwind(crate::script_cache::try_flush_pending);
1072}
1073
1074/// Register [`atexit_flush_pending`]. Idempotent; call once from `main`.
1075pub fn install_atexit_flush() {
1076    static ONCE: std::sync::Once = std::sync::Once::new();
1077    ONCE.call_once(|| {
1078        // SAFETY: `atexit_flush_pending` is a plain `extern "C"` fn with the
1079        // signature libc requires.
1080        unsafe {
1081            libc::atexit(atexit_flush_pending);
1082        }
1083    });
1084}
1085
1086/// Drop a proven-wrong entry. See [`AutoloadCache::remove`].
1087pub fn try_remove(name: &str) {
1088    if let Some(cache) = CACHE.as_ref() {
1089        if let Err(e) = cache.remove(name) {
1090            tracing::warn!(name, error = %e, "autoload: could not drop bad cache entry");
1091        }
1092    }
1093}
1094
1095/// `cached_names` — see implementation.
1096pub fn cached_names() -> std::collections::HashSet<String> {
1097    CACHE.as_ref().map(|c| c.cached_names()).unwrap_or_default()
1098}
1099/// `entry_count` — see implementation.
1100pub fn entry_count() -> usize {
1101    CACHE.as_ref().map(|c| c.entry_count()).unwrap_or(0)
1102}
1103/// `stats` — see implementation.
1104pub fn stats() -> Option<(i64, i64)> {
1105    CACHE.as_ref().map(|c| c.stats())
1106}
1107/// `clear` — see implementation.
1108pub fn clear() -> bool {
1109    CACHE.as_ref().map(|c| c.clear().is_ok()).unwrap_or(false)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use tempfile::tempdir;
1116
1117    const DIR: &str = "/some/fpath/dir";
1118
1119    #[test]
1120    fn round_trip_one() {
1121        let _g = crate::test_util::global_state_lock();
1122        let dir = tempdir().unwrap();
1123        let cache_path = dir.path().join("autoloads.rkyv");
1124        let cache = AutoloadCache::open(&cache_path).unwrap();
1125        cache
1126            .put_one("foo", vec![1, 2, 3], DIR, source_digest("body"))
1127            .unwrap();
1128        // Served from the pending buffer, before any shard write.
1129        assert_eq!(cache.get("foo"), Some(vec![1, 2, 3]));
1130        cache.flush_pending().unwrap();
1131        assert_eq!(cache.get("foo"), Some(vec![1, 2, 3]));
1132        assert_eq!(cache.entry_count(), 1);
1133    }
1134
1135    /// A load is served by name, with no `$fpath` search, only while the
1136    /// recorded resolution is current: its directory still on `fpath`, its
1137    /// files unchanged. A flushed entry carries it into a new handle.
1138    #[test]
1139    fn resolution_served_by_name_until_its_inputs_change() {
1140        let _g = crate::test_util::global_state_lock();
1141        let tmp = tempdir().unwrap();
1142        let fdir = tmp.path().join("fns");
1143        std::fs::create_dir(&fdir).unwrap();
1144        let file = fdir.join("_foo");
1145        std::fs::write(&file, "print one\n").unwrap();
1146        let fdir_s = fdir.to_string_lossy().to_string();
1147        let fpath = vec!["/elsewhere".to_string(), fdir_s.clone()];
1148        let resolved = ResolvedLoad {
1149            body: "print one\n".to_string(),
1150            registered: "_foo() {\nprint one\n}".to_string(),
1151            dump_ksh: -1,
1152            from_wordcode: false,
1153            parse_failed: false,
1154            ksh_style: false,
1155            stamps: stamp_candidates(&fdir_s, "_foo"),
1156            zwc_lines: Vec::new(),
1157        };
1158        let cache_path = tmp.path().join("autoloads.rkyv");
1159        let cache = AutoloadCache::open(&cache_path).unwrap();
1160        cache.resolved_pending.lock().insert("_foo".to_string(), resolved.clone());
1161        cache
1162            .put_one("_foo", vec![1], &fdir_s, registered_digest(&resolved))
1163            .unwrap();
1164        cache.flush_pending().unwrap();
1165
1166        let fresh = AutoloadCache::open(&cache_path).unwrap();
1167        assert_eq!(fresh.resolve("_foo", &fpath), Some((fdir_s.clone(), resolved)));
1168        assert_eq!(fresh.resolve("_foo", &["/elsewhere".to_string()]), None, "dir left fpath");
1169        std::fs::write(&file, "print two!\n").unwrap();
1170        assert_eq!(fresh.resolve("_foo", &fpath), None, "file edited");
1171    }
1172
1173    #[test]
1174    fn source_text_mismatch_is_a_miss() {
1175        // The whole point of the digest: an edited definition file must
1176        // not be served from a chunk compiled off the old bytes, and
1177        // neither must a same-named function from another directory.
1178        let _g = crate::test_util::global_state_lock();
1179        let dir = tempdir().unwrap();
1180        let cache_path = dir.path().join("autoloads.rkyv");
1181        let cache = AutoloadCache::open(&cache_path).unwrap();
1182        let sha = source_digest("foo() {\nprint one\n}");
1183        cache.put_one("foo", vec![1, 2, 3], DIR, sha).unwrap();
1184        cache.flush_pending().unwrap();
1185        assert_eq!(cache.get_for_source("foo", DIR, &sha), Some(vec![1, 2, 3]));
1186        // Edited body.
1187        let edited = source_digest("foo() {\nprint two\n}");
1188        assert!(cache.get_for_source("foo", DIR, &edited).is_none());
1189        // Same text, different fpath directory.
1190        assert!(cache.get_for_source("foo", "/other/dir", &sha).is_none());
1191    }
1192
1193    #[test]
1194    fn an_entry_from_another_binary_is_never_served() {
1195        // The regression that produced `function not defined by file`:
1196        // a chunk emitted by a DIFFERENT build must be refused whichever
1197        // way the timestamps point, because bytecode is not portable
1198        // between builds even at the same `zshrs_version`.
1199        let _g = crate::test_util::global_state_lock();
1200        let dir = tempdir().unwrap();
1201        let cache_path = dir.path().join("autoloads.rkyv");
1202        let cache = AutoloadCache::open(&cache_path).unwrap();
1203        let sha = source_digest("foo() {\nprint one\n}");
1204        cache.put_one("foo", vec![1, 2, 3], DIR, sha).unwrap();
1205        cache.flush_pending().unwrap();
1206
1207        // Rewrite the entry as if a newer build had produced it.
1208        let mut shard = read_owned_shard(&cache_path).expect("shard readable");
1209        let entry = shard.entries.get_mut("foo").expect("entry present");
1210        entry.binary_mtime_at_cache += 10_000;
1211        write_shard_atomic(&cache_path, &shard).unwrap();
1212
1213        let reopened = AutoloadCache::open(&cache_path).unwrap();
1214        assert!(
1215            reopened.get_for_source("foo", DIR, &sha).is_none(),
1216            "a chunk from a newer build was accepted",
1217        );
1218        assert!(reopened.get("foo").is_none());
1219    }
1220
1221    #[test]
1222    fn remove_drops_the_entry() {
1223        let _g = crate::test_util::global_state_lock();
1224        let dir = tempdir().unwrap();
1225        let cache_path = dir.path().join("autoloads.rkyv");
1226        let cache = AutoloadCache::open(&cache_path).unwrap();
1227        let sha = source_digest("body");
1228        cache.put_one("foo", vec![1], DIR, sha).unwrap();
1229        cache.put_one("bar", vec![2], DIR, sha).unwrap();
1230        cache.remove("foo").unwrap();
1231        assert!(cache.get("foo").is_none());
1232        assert_eq!(cache.get("bar"), Some(vec![2]));
1233    }
1234
1235    #[test]
1236    fn cached_names_returns_keys() {
1237        let _g = crate::test_util::global_state_lock();
1238        let dir = tempdir().unwrap();
1239        let cache_path = dir.path().join("autoloads.rkyv");
1240        let cache = AutoloadCache::open(&cache_path).unwrap();
1241        let sha = source_digest("body");
1242        cache.put_one("alpha", vec![1], DIR, sha).unwrap();
1243        cache.put_one("beta", vec![2], DIR, sha).unwrap();
1244        cache.flush_pending().unwrap();
1245        let names = cache.cached_names();
1246        assert!(names.contains("alpha"));
1247        assert!(names.contains("beta"));
1248        assert_eq!(names.len(), 2);
1249    }
1250
1251    #[test]
1252    fn corrupt_shard_returns_none() {
1253        let _g = crate::test_util::global_state_lock();
1254        let dir = tempdir().unwrap();
1255        let cache_path = dir.path().join("autoloads.rkyv");
1256        std::fs::write(&cache_path, b"garbage").unwrap();
1257        let cache = AutoloadCache::open(&cache_path).unwrap();
1258        assert!(cache.get("anything").is_none());
1259        assert_eq!(cache.entry_count(), 0);
1260    }
1261
1262    #[test]
1263    fn clear_removes_file() {
1264        let _g = crate::test_util::global_state_lock();
1265        let dir = tempdir().unwrap();
1266        let cache_path = dir.path().join("autoloads.rkyv");
1267        let cache = AutoloadCache::open(&cache_path).unwrap();
1268        cache
1269            .put_one("x", vec![1], DIR, source_digest("body"))
1270            .unwrap();
1271        cache.flush_pending().unwrap();
1272        assert!(cache_path.exists());
1273        cache.clear().unwrap();
1274        assert!(!cache_path.exists());
1275    }
1276
1277    #[test]
1278    fn a_write_leaves_no_temp_file_behind() {
1279        // 517 MB of `autoloads.rkyv.tmp.<pid>.<ns>` orphans came from
1280        // writes that never reached the rename.
1281        let _g = crate::test_util::global_state_lock();
1282        let dir = tempdir().unwrap();
1283        let cache_path = dir.path().join("autoloads.rkyv");
1284        let cache = AutoloadCache::open(&cache_path).unwrap();
1285        cache
1286            .put_one("x", vec![1], DIR, source_digest("body"))
1287            .unwrap();
1288        // Must be a REAL write, or this regression test goes vacuous.
1289        cache.flush_pending().unwrap();
1290        let temps: Vec<_> = std::fs::read_dir(dir.path())
1291            .unwrap()
1292            .flatten()
1293            .map(|e| e.file_name().into_string().unwrap())
1294            .filter(|n| n.contains(".tmp."))
1295            .collect();
1296        assert!(temps.is_empty(), "temp files left behind: {temps:?}");
1297    }
1298
1299    #[test]
1300    fn put_one_defers_the_shard_write_until_flush() {
1301        // The whole point of buffering: N autoloads must not cost N
1302        // rewrites of a shard that reaches 40 MB on a real $fpath.
1303        let _g = crate::test_util::global_state_lock();
1304        let dir = tempdir().unwrap();
1305        let cache_path = dir.path().join("autoloads.rkyv");
1306        let cache = AutoloadCache::open(&cache_path).unwrap();
1307        let sha = source_digest("body");
1308
1309        for i in 0..8 {
1310            cache.put_one(&format!("f{i}"), vec![i], DIR, sha).unwrap();
1311        }
1312        assert!(
1313            !cache_path.exists(),
1314            "put_one wrote the shard before the flush boundary"
1315        );
1316        // Still readable while buffered — otherwise this process would
1317        // recompile a body it just compiled.
1318        assert_eq!(cache.get_for_source("f3", DIR, &sha), Some(vec![3]));
1319
1320        cache.flush_pending().unwrap();
1321        assert_eq!(cache.entry_count(), 8);
1322        assert_eq!(cache.get_for_source("f7", DIR, &sha), Some(vec![7]));
1323    }
1324
1325    #[test]
1326    fn flush_preserves_entries_written_by_another_process() {
1327        // Concurrent shells share one shard; the flush is a
1328        // read-modify-write under lock, so a peer's entries survive.
1329        let _g = crate::test_util::global_state_lock();
1330        let dir = tempdir().unwrap();
1331        let cache_path = dir.path().join("autoloads.rkyv");
1332        let sha = source_digest("body");
1333
1334        let peer = AutoloadCache::open(&cache_path).unwrap();
1335        peer.put_one("peer_fn", vec![9], DIR, sha).unwrap();
1336        peer.flush_pending().unwrap();
1337
1338        let mine = AutoloadCache::open(&cache_path).unwrap();
1339        mine.put_one("my_fn", vec![1], DIR, sha).unwrap();
1340        mine.flush_pending().unwrap();
1341
1342        assert_eq!(mine.get_for_source("peer_fn", DIR, &sha), Some(vec![9]));
1343        assert_eq!(mine.get_for_source("my_fn", DIR, &sha), Some(vec![1]));
1344    }
1345
1346    #[test]
1347    fn remove_drops_a_buffered_entry_before_it_reaches_disk() {
1348        // The loader calls remove() when a cached chunk ran without
1349        // defining its function. If the buffer kept it, the next flush
1350        // would write back the entry just proven wrong.
1351        let _g = crate::test_util::global_state_lock();
1352        let dir = tempdir().unwrap();
1353        let cache_path = dir.path().join("autoloads.rkyv");
1354        let cache = AutoloadCache::open(&cache_path).unwrap();
1355        let sha = source_digest("body");
1356
1357        cache.put_one("bad", vec![1], DIR, sha).unwrap();
1358        cache.remove("bad").unwrap();
1359        cache.flush_pending().unwrap();
1360
1361        assert!(cache.get("bad").is_none());
1362        let reopened = AutoloadCache::open(&cache_path).unwrap();
1363        assert!(reopened.get("bad").is_none());
1364    }
1365
1366    #[test]
1367    fn flush_with_the_lock_held_elsewhere_returns_and_keeps_the_batch() {
1368        // flush_pending runs before every prompt. Another process holding
1369        // the shard lock (a long rewrite) must not stall the prompt, and
1370        // the batch must survive for the next one.
1371        let _g = crate::test_util::global_state_lock();
1372        let dir = tempdir().unwrap();
1373        let cache_path = dir.path().join("autoloads.rkyv");
1374        let cache = std::sync::Arc::new(AutoloadCache::open(&cache_path).unwrap());
1375        let sha = source_digest("body");
1376        cache.put_one("f", vec![1], DIR, sha).unwrap();
1377
1378        // flock locks belong to the open file description, so a second
1379        // open in this process contends exactly like another process.
1380        let held = acquire_lock(&cache.lock_path).unwrap();
1381        let (tx, rx) = std::sync::mpsc::channel();
1382        let flusher = std::sync::Arc::clone(&cache);
1383        // Detached rather than scoped: a flush that blocks must fail the
1384        // test, not hang it. The panic drops `held`, which frees it.
1385        std::thread::spawn(move || {
1386            let _ = tx.send(flusher.flush_pending());
1387        });
1388        let res = rx
1389            .recv_timeout(std::time::Duration::from_secs(5))
1390            .expect("flush_pending blocked on a lock held elsewhere");
1391        assert!(res.is_ok());
1392        assert!(!cache_path.exists(), "wrote the shard without the lock");
1393        assert_eq!(cache.get_for_source("f", DIR, &sha), Some(vec![1]));
1394
1395        drop(held);
1396        cache.flush_pending().unwrap();
1397        assert!(cache_path.exists());
1398        assert_eq!(cache.entry_count(), 1);
1399    }
1400}