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