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}