Skip to main content

harn_vm/
module_source.rs

1//! Process-wide owner of module source bytes and everything derived from them.
2//!
3//! One spawn of a large pipeline visits every file in the transitive import
4//! graph at least twice: once while folding the entry chunk's cache key, and
5//! again while the VM actually loads each module. Every one of those sites
6//! needs the same three facts — the text, its content digests, and its import
7//! list — and each used to derive them independently, so a single module source
8//! was read from disk twice, held in memory three times, and hashed three times
9//! per process.
10//!
11//! [`ModuleSource`] owns those bytes and derives each fact at most once.
12//! Instances are memoized by the file's stat identity `(len, mtime_ns)`, so an
13//! on-disk edit yields a fresh entry and a stale one is never reused: a
14//! long-lived worker still observes edited pipelines exactly as a cold process
15//! would. The derived bytes are identical to what independent derivation
16//! produced, so cache keys are unchanged.
17
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex, OnceLock};
22
23use sha2::{Digest, Sha256};
24
25/// One module's source text plus the facts callers derive from it.
26///
27/// The digest and the import list are computed lazily because no single caller
28/// needs both: the import-graph walk folds the raw text and reads the imports,
29/// while the caches key on the digest.
30#[derive(Debug)]
31pub struct ModuleSource {
32    text: Arc<str>,
33    sha256: OnceLock<[u8; 32]>,
34    imports: OnceLock<Vec<Arc<str>>>,
35}
36
37impl ModuleSource {
38    /// Wrap already-in-memory source text. Used for sources that never came
39    /// from a readable path — embedded stdlib modules, `-e` snippets, and
40    /// package bytes whose authority is an execution guard rather than the
41    /// filesystem.
42    pub fn from_text(text: impl Into<Arc<str>>) -> Self {
43        Self {
44            text: text.into(),
45            sha256: OnceLock::new(),
46            imports: OnceLock::new(),
47        }
48    }
49
50    /// The shared source text. Cloning the returned handle shares the bytes
51    /// rather than copying them.
52    pub(crate) fn text(&self) -> &Arc<str> {
53        &self.text
54    }
55
56    pub(crate) fn as_str(&self) -> &str {
57        &self.text
58    }
59
60    /// SHA-256 over the source bytes — the `source_hash` of the entry-chunk,
61    /// module-artifact, and prepared-module cache keys, and the digest a
62    /// [`crate::context_manifest::ManifestFile`] records.
63    ///
64    /// One digest for all of them on purpose: a warm module load would otherwise
65    /// hash the same 5.7 MB of source twice.
66    pub(crate) fn sha256(&self) -> [u8; 32] {
67        *self.sha256.get_or_init(|| {
68            let mut hasher = Sha256::new();
69            hasher.update(self.text.as_bytes());
70            hasher.finalize().into()
71        })
72    }
73
74    /// User (non-stdlib) import paths mentioned by this source, in source
75    /// order. See [`collect_user_imports`].
76    pub(crate) fn imports(&self) -> &[Arc<str>] {
77        self.imports.get_or_init(|| {
78            collect_user_imports(&self.text)
79                .into_iter()
80                .map(Arc::from)
81                .collect()
82        })
83    }
84}
85
86type MemoKey = (PathBuf, u64, i128);
87type Memo = Mutex<std::collections::HashMap<MemoKey, Arc<ModuleSource>>>;
88
89fn memo() -> &'static Memo {
90    static MEMO: OnceLock<Memo> = OnceLock::new();
91    MEMO.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
92}
93
94/// Identity of the file version currently on disk. Any change to either
95/// component invalidates the memo entry.
96///
97/// Also the unit the entry-chunk context manifest re-checks, so that the
98/// in-process memo and the cross-process manifest agree on what "unchanged"
99/// means by construction rather than by convention.
100pub(crate) fn stat_identity(path: &Path) -> Option<(u64, i128)> {
101    let meta = fs::metadata(path).ok()?;
102    let len = meta.len();
103    // Nanosecond mtime where available; fall back to coarse seconds.
104    let mtime_ns = meta
105        .modified()
106        .ok()
107        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
108        .map(|d| d.as_nanos() as i128)
109        .unwrap_or(0);
110    Some((len, mtime_ns))
111}
112
113/// Wall clock now, in [`stat_identity`]'s units and epoch.
114///
115/// Comparable with a recorded `mtime_ns` by construction, which is the whole
116/// reason it lives here rather than at each call site.
117pub(crate) fn now_ns() -> i128 {
118    std::time::SystemTime::now()
119        .duration_since(std::time::UNIX_EPOCH)
120        .map(|d| d.as_nanos() as i128)
121        .unwrap_or(0)
122}
123
124/// Coarsest file-timestamp granularity any filesystem we run on may quantize
125/// `mtime` to.
126///
127/// ext4 and APFS store nanoseconds, NTFS stores 100ns units but is fed by a
128/// system clock that ticks about every 15.6ms, HFS+ and older NFS store whole
129/// seconds, and FAT/exFAT store two-second units. The bound has to cover the
130/// coarsest of those, because [`mtime_predates_capture`] is only sound when it
131/// is not smaller than the granularity actually in force.
132///
133/// Assuming the worst case everywhere over-classifies on a nanosecond
134/// filesystem: a tree checked out and first walked within two seconds has every
135/// entry racily clean. That was measured rather than assumed — on a 377-module,
136/// 4.9MB graph the content path costs 1.94ms against 0.27ms for stats, still
137/// 3x cheaper than the 5.98ms walk it replaces, and it stops as soon as the
138/// re-stamped capture clears the window. Probing each filesystem's real
139/// granularity would narrow it, at the cost of a soundness argument that
140/// depends on the probe. See `docs/perf/bytecode-cache.md`.
141pub(crate) const TIMESTAMP_GRANULARITY_NS: i128 = 2_000_000_000;
142
143/// Whether a file whose recorded mtime is `mtime_ns` was already settled when
144/// an observation that began at `captured_ns` read it — that is, whether no
145/// write landing after that observation could leave the same mtime behind.
146///
147/// This is git's "racily clean" rule. A write that happens after the observing
148/// process stat'ed the file necessarily happens after `captured_ns`, so its
149/// mtime quantizes to at least the tick containing `captured_ns`. An mtime a
150/// full granularity older than `captured_ns` therefore cannot be reproduced by
151/// any later write, and stats alone are proof the file is unchanged. Anything
152/// newer — including an mtime *ahead* of the clock, as a skewed network
153/// filesystem can produce — sits in the window where a same-length rewrite
154/// inside one timestamp tick is invisible to stats, and can only be settled by
155/// looking at the content.
156///
157/// The comparison is only sound if `captured_ns` was sampled *before* the
158/// stats it guards; a capture taken afterwards would call a write that landed
159/// in between settled.
160pub(crate) fn mtime_predates_capture(mtime_ns: i128, captured_ns: i128) -> bool {
161    mtime_ns.saturating_add(TIMESTAMP_GRANULARITY_NS) <= captured_ns
162}
163
164/// Stable identity for a module file, memoizing `Path::canonicalize`.
165///
166/// Relative imports resolve to unnormalized paths, so one file is reached under
167/// many spellings in a single graph — `mode/../lib/runtime/./x.harn` and
168/// `mode/../lib/host/../runtime/x.harn` name the same bytes. Keying anything by
169/// the spelling instead of the file therefore misses constantly on a real
170/// pipeline tree. The import-graph walk canonicalizes
171/// the same resolved module paths hundreds of times across a cold `from_source`
172/// fan-out, and each call is a `realpath(3)` syscall. A successful
173/// canonicalization is stable for the process lifetime (the pipeline tree is not
174/// moved mid-run), so it is memoized. A *failed* canonicalization (the path does
175/// not exist yet) is NOT memoized: a file that later appears — or a symlink that
176/// is created — must canonicalize freshly so the folded path key matches what a
177/// cold process would produce. This keeps the memo a pure speed optimization with
178/// byte-identical output.
179pub(crate) fn canonical_identity(path: &Path) -> PathBuf {
180    use std::sync::OnceLock;
181    static MEMO: OnceLock<std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>> =
182        OnceLock::new();
183    let memo = MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
184    if let Some(hit) = memo.lock().unwrap().get(path).cloned() {
185        return hit;
186    }
187    match path.canonicalize() {
188        Ok(canonical) => {
189            memo.lock()
190                .unwrap()
191                .insert(path.to_path_buf(), canonical.clone());
192            canonical
193        }
194        // Unresolved path: fall back to the input, but do not memoize, so a file
195        // that appears later canonicalizes correctly on the next walk.
196        Err(_) => path.to_path_buf(),
197    }
198}
199
200// A module resolved through the link table and one resolved by reading its file
201// produce the same module — the recorded digest describes the bytes on disk, so
202// nothing in the result tells them apart. Only whether the file was consulted
203// differs, and this counts it. Thread-local so tests running in parallel cannot
204// perturb each other's counts.
205#[cfg(test)]
206thread_local! {
207    pub(crate) static SOURCE_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
208}
209
210/// Read `path`'s module source, reusing the process-wide memo when the file is
211/// unchanged since it was last read.
212///
213/// Entries are keyed by [`canonical_identity`] so every spelling of one file
214/// shares one read. Every call re-stats the file, so an edit is picked up
215/// exactly as a direct read would pick it up — the memo only removes redundant
216/// reads of a file version this process has already seen. I/O errors are never
217/// memoized: a transient failure must not become sticky.
218pub(crate) fn read(path: &Path) -> io::Result<Arc<ModuleSource>> {
219    #[cfg(test)]
220    SOURCE_READS.with(|c| c.set(c.get() + 1));
221
222    let path = canonical_identity(path);
223    let Some((len, mtime_ns)) = stat_identity(&path) else {
224        // No stat (the file vanished between resolve and read): read directly
225        // so behavior matches the un-memoized path exactly.
226        return Ok(Arc::new(ModuleSource::from_text(fs::read_to_string(
227            &path,
228        )?)));
229    };
230    let key = (path.clone(), len, mtime_ns);
231    if let Some(hit) = memo().lock().unwrap().get(&key).cloned() {
232        return Ok(hit);
233    }
234    let source = Arc::new(ModuleSource::from_text(fs::read_to_string(&path)?));
235    memo().lock().unwrap().insert(key, Arc::clone(&source));
236    Ok(source)
237}
238
239/// Lightweight regex-free scan that surfaces user imports without paying
240/// a full lex+parse. False positives only increase cache churn, never
241/// correctness; comments and string literals are skipped so neither a
242/// commented-out import nor a `"import …"` value appearing inside an
243/// unrelated string gates the hash.
244///
245/// Comments are skipped in place rather than scrubbed into a rewritten copy
246/// first. The entry-chunk cache key runs this over every transitively
247/// reachable file — several megabytes of source per spawn — and materializing
248/// a comment-free copy cost more than the scan it fed, and more than the
249/// SHA-256 the scan's output is folded into.
250fn collect_user_imports(source: &str) -> Vec<String> {
251    let bytes = source.as_bytes();
252    let mut out: Vec<String> = Vec::new();
253    let mut i = 0;
254    while i < bytes.len() {
255        if let Some(end) = comment_end(bytes, i) {
256            i = end;
257            continue;
258        }
259        if bytes[i] == b'"' {
260            // Skip past any string literal so identifiers inside string
261            // values cannot trigger the keyword match below.
262            i = string_literal_end(bytes, i).unwrap_or(i + 1);
263            continue;
264        }
265        if !matches_keyword(bytes, i, b"import") {
266            i += 1;
267            continue;
268        }
269        // Skip past `import` and any selective `{ ... } from` clause; we
270        // only need the source-position of the path string literal.
271        let mut j = i + b"import".len();
272        let mut depth = 0i32;
273        while j < bytes.len() {
274            // A comment may sit between the keyword and the path, and a block
275            // comment may carry the newline that would otherwise end the
276            // clause. Stepping over it whole keeps both cases intact.
277            if let Some(end) = comment_end(bytes, j) {
278                j = end;
279                continue;
280            }
281            match bytes[j] {
282                b'"' => {
283                    if let Some((path, end)) = read_string_literal(bytes, j) {
284                        if !path.starts_with("std/") {
285                            out.push(path);
286                        }
287                        i = end;
288                        break;
289                    }
290                    j += 1;
291                }
292                b'{' => {
293                    depth += 1;
294                    j += 1;
295                }
296                b'}' => {
297                    depth -= 1;
298                    j += 1;
299                }
300                b'\n' if depth == 0 => {
301                    // No string literal on this logical line; bail and
302                    // continue scanning after the keyword to avoid an
303                    // infinite loop.
304                    i = j;
305                    break;
306                }
307                _ => j += 1,
308            }
309        }
310        if j >= bytes.len() {
311            break;
312        }
313        if i < j {
314            // Defensive: ensure forward progress when the inner loop
315            // exited without setting `i`.
316            i = j;
317        }
318    }
319    out
320}
321
322/// End of the comment starting at `at`, or `None` if none starts there.
323///
324/// An unterminated block comment runs to end of input, matching how the rest
325/// of this scan degrades: it can only hide imports, and a missed import only
326/// costs a cache miss.
327fn comment_end(bytes: &[u8], at: usize) -> Option<usize> {
328    if bytes[at] != b'/' || at + 1 >= bytes.len() {
329        return None;
330    }
331    match bytes[at + 1] {
332        b'/' => {
333            let mut i = at + 2;
334            while i < bytes.len() && bytes[i] != b'\n' {
335                i += 1;
336            }
337            // Stop *on* the newline: the import clause below treats it as the
338            // end of a logical line.
339            Some(i)
340        }
341        b'*' => {
342            let mut i = at + 2;
343            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
344                i += 1;
345            }
346            Some((i + 2).min(bytes.len()))
347        }
348        _ => None,
349    }
350}
351
352/// End of the string literal starting at `at`, without decoding it.
353///
354/// [`read_string_literal`] allocates the unescaped value, which the scan
355/// throws away everywhere except the import path itself.
356fn string_literal_end(bytes: &[u8], at: usize) -> Option<usize> {
357    debug_assert_eq!(bytes[at], b'"');
358    let mut i = at + 1;
359    while i < bytes.len() {
360        match bytes[i] {
361            b'"' => return Some(i + 1),
362            b'\\' if i + 1 < bytes.len() => i += 2,
363            b'\\' => return None,
364            b'\n' => return None,
365            _ => i += 1,
366        }
367    }
368    None
369}
370
371fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
372    let end = at + keyword.len();
373    if end > bytes.len() {
374        return false;
375    }
376    if &bytes[at..end] != keyword {
377        return false;
378    }
379    if at > 0 && is_ident_char(bytes[at - 1]) {
380        return false;
381    }
382    if end < bytes.len() && is_ident_char(bytes[end]) {
383        return false;
384    }
385    true
386}
387
388fn is_ident_char(b: u8) -> bool {
389    b.is_ascii_alphanumeric() || b == b'_'
390}
391
392fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
393    debug_assert_eq!(bytes[at], b'"');
394    let mut out = String::new();
395    let mut i = at + 1;
396    while i < bytes.len() {
397        match bytes[i] {
398            b'"' => return Some((out, i + 1)),
399            b'\\' => {
400                if i + 1 >= bytes.len() {
401                    return None;
402                }
403                match bytes[i + 1] {
404                    b'"' => out.push('"'),
405                    b'\\' => out.push('\\'),
406                    b'n' => out.push('\n'),
407                    b'r' => out.push('\r'),
408                    b't' => out.push('\t'),
409                    other => out.push(other as char),
410                }
411                i += 2;
412            }
413            b'\n' => return None,
414            byte => {
415                out.push(byte as char);
416                i += 1;
417            }
418        }
419    }
420    None
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn collect_user_imports_ignores_stdlib_and_comments() {
429        let source = r#"
430            // import "comment/should/be/ignored"
431            import "std/agents"
432            import { foo } from "pkg/bar"
433            import "./relative/path"
434        "#;
435        let imports = collect_user_imports(source);
436        assert_eq!(imports, vec!["pkg/bar", "./relative/path"]);
437    }
438
439    #[test]
440    fn import_path_inside_string_literal_is_ignored() {
441        let source = r#"
442            const payload = "import { foo } from \"./other\""
443            import "./real"
444        "#;
445        assert_eq!(collect_user_imports(source), vec!["./real".to_string()]);
446    }
447
448    #[test]
449    fn comments_around_and_inside_an_import_clause_are_stepped_over() {
450        // The scan skips comments in place rather than scrubbing them out of a
451        // rewritten copy first, so every position a comment can occupy has to
452        // be handled by the scan itself — including between the keyword and its
453        // path, where a block comment can also carry the newline that would
454        // otherwise end the clause.
455        let source = concat!(
456            "/* leading */ import \"./before\"\n",
457            "import /* between */ \"./between\"\n",
458            "import /* spans\na line */ \"./across\"\n",
459            "import \"./trailing\" // after\n",
460            "/* import \"./blocked/out\" */\n",
461        );
462        assert_eq!(
463            collect_user_imports(source),
464            vec!["./before", "./between", "./across", "./trailing"],
465            "a block comment must not hide an import, or expose a commented-out one"
466        );
467    }
468
469    #[test]
470    fn an_escaped_quote_does_not_end_the_string_it_appears_in() {
471        // Skipping a string literal no longer decodes it, so the skip has to
472        // honour escapes on its own: reading `\"` as the closing quote would
473        // leave the scan inside string data and surface the fake import.
474        let source = "const s = \"a \\\" import \\\"./fake\\\"\"\nimport \"./real\"\n";
475        assert_eq!(collect_user_imports(source), vec!["./real".to_string()]);
476    }
477
478    #[test]
479    fn an_unterminated_block_comment_hides_the_rest_of_the_file() {
480        // Defines the degradation rather than leaving it to chance. Hiding an
481        // import can only cost a cache miss; the file does not parse anyway.
482        let source = "import \"./seen\"\n/* unterminated\nimport \"./hidden\"\n";
483        assert_eq!(collect_user_imports(source), vec!["./seen".to_string()]);
484    }
485
486    #[test]
487    fn derived_facts_are_computed_once_and_match_direct_derivation() {
488        let source = ModuleSource::from_text("import \"./dep\"\npub fn v() -> int { return 1 }\n");
489        let text = source.as_str().to_string();
490
491        assert_eq!(source.sha256(), source.sha256());
492        assert_eq!(
493            source.sha256(),
494            <[u8; 32]>::from(Sha256::digest(text.as_bytes())),
495            "the memoized digest must equal a direct SHA-256 of the same bytes"
496        );
497        assert_eq!(source.imports(), [Arc::<str>::from("./dep")]);
498    }
499
500    #[test]
501    fn repeated_reads_of_an_unchanged_file_share_one_allocation() {
502        let tmp = tempfile::tempdir().unwrap();
503        let path = tmp.path().join("module.harn");
504        std::fs::write(&path, "import \"./first\"\nimport \"./second\"\n").unwrap();
505
506        let first = read(&path).unwrap();
507        let second = read(&path).unwrap();
508
509        assert!(
510            Arc::ptr_eq(&first, &second),
511            "a memo hit must reuse the source instead of reading and copying it again"
512        );
513        assert_eq!(first.imports().len(), 2);
514    }
515
516    #[test]
517    fn every_spelling_of_one_file_shares_a_single_read() {
518        // Relative imports resolve to unnormalized paths, so a real pipeline
519        // tree reaches one file under many spellings. Keying by the spelling
520        // makes the memo miss on nearly every edge of a large graph.
521        let tmp = tempfile::tempdir().unwrap();
522        std::fs::create_dir_all(tmp.path().join("lib/runtime")).unwrap();
523        std::fs::create_dir_all(tmp.path().join("mode")).unwrap();
524        let direct = tmp.path().join("lib/runtime/util.harn");
525        std::fs::write(&direct, "pub fn v() -> int { return 1 }\n").unwrap();
526
527        std::fs::create_dir_all(tmp.path().join("lib/host")).unwrap();
528        let spellings = [
529            tmp.path().join("mode/../lib/runtime/util.harn"),
530            tmp.path().join("lib/runtime/./util.harn"),
531            tmp.path().join("lib/host/../runtime/util.harn"),
532        ];
533
534        let first = read(&direct).unwrap();
535        for spelling in &spellings {
536            assert!(
537                Arc::ptr_eq(&first, &read(spelling).unwrap()),
538                "{} names the same file and must share its single read",
539                spelling.display()
540            );
541        }
542    }
543
544    #[test]
545    fn a_same_length_edit_is_re_read_in_a_warm_process() {
546        // The hardest case for a `(len, mtime_ns)` key is an edit that
547        // preserves byte length: only the mtime distinguishes the versions.
548        let tmp = tempfile::tempdir().unwrap();
549        let path = tmp.path().join("leaf.harn");
550        std::fs::write(&path, "pub fn x() -> int { return 111 }\n").unwrap();
551        let before = read(&path).unwrap();
552
553        std::fs::write(&path, "pub fn x() -> int { return 222 }\n").unwrap();
554        // Push the rewritten file's mtime deterministically into the future
555        // instead of sleeping out the coarsest plausible mtime granularity.
556        let future = std::fs::metadata(&path).unwrap().modified().unwrap()
557            + std::time::Duration::from_secs(10);
558        std::fs::OpenOptions::new()
559            .write(true)
560            .open(&path)
561            .unwrap()
562            .set_times(std::fs::FileTimes::new().set_modified(future))
563            .unwrap();
564        assert_eq!(
565            std::fs::metadata(&path).unwrap().len(),
566            33,
567            "the two versions must be the same byte length for this test to \
568             exercise the mtime path"
569        );
570
571        let after = read(&path).unwrap();
572        assert_ne!(
573            before.as_str(),
574            after.as_str(),
575            "a same-length edit must be re-read rather than served from the memo"
576        );
577        assert_ne!(before.sha256(), after.sha256());
578    }
579
580    #[test]
581    fn only_an_mtime_a_full_granularity_older_than_the_capture_counts_as_settled() {
582        // The boundary is where the rule is either sound or not: a filesystem
583        // quantizing to `TIMESTAMP_GRANULARITY_NS` can place a write up to one
584        // whole tick before the capture and still record it under the tick the
585        // capture fell in, so only an mtime at least a full granularity older
586        // is beyond a later write's reach.
587        let capture = 1_000 * TIMESTAMP_GRANULARITY_NS;
588        assert!(mtime_predates_capture(
589            capture - TIMESTAMP_GRANULARITY_NS,
590            capture
591        ));
592        assert!(!mtime_predates_capture(
593            capture - TIMESTAMP_GRANULARITY_NS + 1,
594            capture
595        ));
596        assert!(!mtime_predates_capture(capture, capture));
597        assert!(
598            !mtime_predates_capture(capture + TIMESTAMP_GRANULARITY_NS, capture),
599            "an mtime ahead of the clock, as a skewed network filesystem \
600             produces, must never be treated as settled"
601        );
602        assert!(
603            !mtime_predates_capture(i128::MAX, capture),
604            "the granularity offset must not overflow into a false settle"
605        );
606    }
607
608    #[test]
609    fn a_missing_file_reports_the_io_error_without_memoizing_it() {
610        let tmp = tempfile::tempdir().unwrap();
611        let path = tmp.path().join("appears-later.harn");
612
613        let missing = read(&path).unwrap_err();
614        assert_eq!(missing.kind(), io::ErrorKind::NotFound);
615
616        std::fs::write(&path, "pub fn v() -> int { return 1 }\n").unwrap();
617        assert_eq!(
618            read(&path).unwrap().as_str(),
619            "pub fn v() -> int { return 1 }\n",
620            "a file that appears after a failed read must be readable"
621        );
622    }
623}