harn_vm/stdlib/host/turn_cache.rs
1//! Per-turn memoization of turn-stable host capability reads.
2//!
3//! Context assembly repeatedly reads the same host facts per agent-loop
4//! iteration. harn#5190 measured ~20 identical `runtime.pipeline_input`
5//! round-trips per turn; Burin H-063 later measured roughly 164 repeated
6//! `project.metadata_get` calls in an ordinary context build. This module
7//! front-runs the thread-local `HOST_CALL_BRIDGE` with a per-turn memo so those
8//! reads collapse to one host round-trip per exact argument set, leaving every
9//! call site unchanged. The allowlist ([`is_turn_stable`]) is deliberately
10//! narrow, and metadata mutations invalidate the whole memo before and after
11//! dispatch so inherited read-after-write values cannot be stale.
12//!
13//! The memoized value is stable only *within* a turn: the host re-projects
14//! `runtime.pipeline_input` each turn (e.g. so a mid-session model switch is
15//! observed on the next prompt). The memo is therefore cleared at each
16//! agent-loop iteration boundary (`iteration_start`, wired in
17//! `__host_agent_emit_event`) and at run/embedder boundaries via [`reset`].
18
19use std::cell::RefCell;
20use std::collections::HashMap;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23use crate::value::{DictMap, VmError, VmValue};
24
25/// Monotonic turn counter, bumped by [`reset`] at every turn boundary.
26///
27/// Deliberately process-global rather than per-session. A turn boundary in one
28/// session therefore also invalidates a concurrently-running session's memo,
29/// which costs that session one extra round-trip per crossed boundary. That is
30/// the conservative direction: the failure it forecloses is serving *stale*
31/// turn-stable state, and the worst case degrades toward the uncached behaviour
32/// this module replaced rather than toward incorrectness. Keying by session
33/// would recover those hits, but `reset` is driven from an agent-loop event that
34/// carries no session identity here, so it would be inferred rather than known.
35///
36/// The memo below is thread-local, but turn boundaries are not guaranteed to be
37/// observed on the same thread that populated it — `reset` runs where the
38/// agent-loop event is emitted, while a `host_call` may be served from
39/// elsewhere. Storing the epoch alongside each entry makes a stale entry
40/// *unreadable* rather than merely unlikely, so correctness no longer depends on
41/// a reset reaching any particular thread; thread-locality is then a pure
42/// performance choice. Without this, a missed reset would silently serve last
43/// turn's `runtime.pipeline_input` — which is exactly the mid-session `/model`
44/// switch that hosts re-project it per turn to observe.
45static TURN_EPOCH: AtomicU64 = AtomicU64::new(0);
46
47thread_local! {
48 /// Turn-scoped memo keyed by [`cache_key`], each entry tagged with the
49 /// [`TURN_EPOCH`] it was written in. Non-authoritative and always
50 /// resettable, so it is safe to keep thread-private.
51 static TURN_STABLE_HOST_CACHE: RefCell<HashMap<String, (u64, VmValue)>> =
52 RefCell::new(HashMap::new());
53}
54
55fn current_epoch() -> u64 {
56 TURN_EPOCH.load(Ordering::Acquire)
57}
58
59/// Cache semantics for a canonical host operation.
60///
61/// This is the single owner of both admission and invalidation: adding a
62/// stable read without naming its mutators (or adding a mutator in a separate
63/// string table) is therefore visible in one exhaustive match.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65enum TurnCacheDisposition {
66 StableRead,
67 Invalidates,
68 Live,
69}
70
71fn disposition(capability: &str, operation: &str) -> TurnCacheDisposition {
72 match (capability, operation) {
73 ("runtime", "pipeline_input") | ("project", "metadata_get") => {
74 TurnCacheDisposition::StableRead
75 }
76 ("project", "metadata_set" | "metadata_save" | "metadata_refresh_hashes") => {
77 TurnCacheDisposition::Invalidates
78 }
79 _ => TurnCacheDisposition::Live,
80 }
81}
82
83/// True for host capabilities whose result is stable for the duration of a
84/// single agent-loop iteration: pure, side-effect-free reads that project the
85/// current turn's host input.
86///
87/// Membership is an explicit allowlist, and the bar for adding an entry is a
88/// producer-side citation that no host mutates the value *within* a turn — not
89/// merely that it "looks stable." The default is NOT to cache, so a write, an
90/// interactive prompt, or any value a host can change mid-turn is never served
91/// stale.
92///
93/// The qualifying reads are:
94/// - `runtime.pipeline_input`: every Burin host recomputes it per turn from
95/// per-turn-stable inputs (model selection, task, dry-run), so a mid-turn
96/// re-read never diverges.
97/// - `project.metadata_get`: directory metadata is coherent for a turn unless
98/// the canonical metadata mutation operations change it. Those writes
99/// invalidate the global epoch on both sides of dispatch; parameterized keys
100/// keep directories and namespaces distinct.
101///
102/// Deliberately excluded after auditing the producers:
103/// - `session.active_roots` — the IDE host serves it live from the mutable
104/// workspace root set (also used live for path validation), so a user adding
105/// a root mid-turn would be served stale within the turn.
106/// - `project.metadata_inspect` / `metadata_stale` — their freshness fields can
107/// change when workspace files change, independently of metadata mutations.
108/// - `runtime.task` / `runtime.dry_run` / `runtime.approved_plan` — not served
109/// as standalone host ops by the Burin hosts at all; their values ride inside
110/// `pipeline_input` (already cached here), so caching the standalone op buys
111/// nothing.
112fn is_turn_stable(capability: &str, operation: &str) -> bool {
113 disposition(capability, operation) == TurnCacheDisposition::StableRead
114}
115
116/// True when a host operation can change a memoized fact.
117///
118/// Metadata resolution is hierarchical: writing an ancestor can change a
119/// descendant read, and saving can make host-managed metadata visible through
120/// a different backend. Exact-key eviction would therefore be unsound. The
121/// caller opens a fresh global epoch both before and after these operations,
122/// which also makes concurrent cross-thread refills conservative rather than
123/// stale. Writes are rare, so invalidating `runtime.pipeline_input` alongside
124/// metadata is a cheaper and safer seam than a second metadata-only epoch.
125fn invalidates_turn_stable_reads(capability: &str, operation: &str) -> bool {
126 disposition(capability, operation) == TurnCacheDisposition::Invalidates
127}
128
129/// Invalidates turn-stable reads around a host mutation, including early
130/// returns and errors from the canonical dispatcher.
131pub(crate) struct InvalidationScope {
132 invalidates: bool,
133}
134
135impl Drop for InvalidationScope {
136 fn drop(&mut self) {
137 if self.invalidates {
138 reset();
139 }
140 }
141}
142
143pub(crate) fn invalidation_scope(capability: &str, operation: &str) -> InvalidationScope {
144 let invalidates = invalidates_turn_stable_reads(capability, operation);
145 if invalidates {
146 reset();
147 }
148 InvalidationScope { invalidates }
149}
150
151/// Cache key for a turn-stable host call. Keyed on capability, operation, and a
152/// canonical fingerprint of the params so `project.metadata_get` caches per
153/// distinct directory/namespace while no-arg reads retain the cheap fast path.
154/// serde_json's `Map` is key-sorted here (no `preserve_order` feature), so the
155/// fingerprint is stable.
156fn cache_key(capability: &str, operation: &str, params: &DictMap) -> String {
157 if params.is_empty() {
158 return format!("{capability}.{operation}");
159 }
160 let json = crate::llm::helpers::vm_value_to_json(&VmValue::dict(params.clone()));
161 format!(
162 "{capability}.{operation}#{}",
163 serde_json::to_string(&json).unwrap_or_default()
164 )
165}
166
167/// Serve `(capability, operation, params)` from the per-turn memo when it is a
168/// turn-stable read, otherwise run `dispatch` verbatim. A successful
169/// `Ok(Some(value))` from a turn-stable read is memoized for the rest of the
170/// turn; `Ok(None)` (the bridge declined) and errors are never cached.
171pub(crate) async fn cached_or<F, Fut>(
172 capability: &str,
173 operation: &str,
174 params: &DictMap,
175 dispatch: F,
176) -> Result<Option<VmValue>, VmError>
177where
178 F: FnOnce() -> Fut,
179 Fut: std::future::Future<Output = Result<Option<VmValue>, VmError>>,
180{
181 if !is_turn_stable(capability, operation) {
182 return dispatch().await;
183 }
184 if let Some(cached) = lookup(capability, operation, params) {
185 return Ok(Some(cached));
186 }
187 let dispatch_epoch = current_epoch();
188 let result = dispatch().await?;
189 if let Some(value) = &result {
190 store_at_epoch(capability, operation, params, value, dispatch_epoch);
191 }
192 Ok(result)
193}
194
195/// Read a turn-stable host fact from the current turn's memo.
196///
197/// Returns `None` for anything not on the [`is_turn_stable`] allowlist, for a
198/// cold memo, and for an entry written in an earlier turn.
199///
200/// Read API for the turn memo. Prefer going through canonical
201/// [`super::dispatch_host_operation`]; this exists for tests that seed or
202/// inspect the memo directly (harn#5190 / harn#5523).
203pub fn lookup(capability: &str, operation: &str, params: &DictMap) -> Option<VmValue> {
204 if !is_turn_stable(capability, operation) {
205 return None;
206 }
207 let key = cache_key(capability, operation, params);
208 let epoch = current_epoch();
209 TURN_STABLE_HOST_CACHE.with(|cache| {
210 cache
211 .borrow()
212 .get(&key)
213 .filter(|(written, _)| *written == epoch)
214 .map(|(_, value)| value.clone())
215 })
216}
217
218/// Memoize a turn-stable host fact for the remainder of the current turn.
219/// Non-allowlisted `(capability, operation)` pairs are ignored, so a caller
220/// cannot widen the allowlist by calling this directly. See [`lookup`].
221pub fn store(capability: &str, operation: &str, params: &DictMap, value: &VmValue) {
222 store_at_epoch(capability, operation, params, value, current_epoch());
223}
224
225/// Store a value only in the epoch in which its host dispatch began.
226///
227/// A read can be in flight while a metadata mutation opens a new epoch. It may
228/// still return its result to that original caller, but tagging the memo entry
229/// with the captured epoch makes the stale refill unreadable. Loading the
230/// current epoch and then storing with it would let a slow pre-write read poison
231/// the post-write cache after the mutator's trailing reset.
232fn store_at_epoch(
233 capability: &str,
234 operation: &str,
235 params: &DictMap,
236 value: &VmValue,
237 dispatch_epoch: u64,
238) {
239 if !is_turn_stable(capability, operation) {
240 return;
241 }
242 if current_epoch() != dispatch_epoch {
243 return;
244 }
245 let key = cache_key(capability, operation, params);
246 TURN_STABLE_HOST_CACHE.with(|cache| {
247 cache
248 .borrow_mut()
249 .insert(key, (dispatch_epoch, value.clone()));
250 });
251}
252
253/// Split a dotted `capability.operation` host-call name and [`lookup`] it.
254/// Convenience for embedder `host_call` implementations, which receive the
255/// dotted wire name rather than a split pair.
256pub fn lookup_by_name(name: &str, params: &DictMap) -> Option<VmValue> {
257 let (capability, operation) = name.split_once('.')?;
258 lookup(capability, operation, params)
259}
260
261/// Dotted-name counterpart to [`store`]. See [`lookup_by_name`].
262pub fn store_by_name(name: &str, params: &DictMap, value: &VmValue) {
263 if let Some((capability, operation)) = name.split_once('.') {
264 store(capability, operation, params, value);
265 }
266}
267
268/// Open a new turn: every entry written before this call becomes unreadable,
269/// on this thread and every other.
270///
271/// Called at each agent-loop iteration boundary so a turn re-reads turn-stable
272/// host facts exactly once, and at bridge install/teardown so a memo can never
273/// leak across embedders on a reused thread. Bumping a global epoch rather than
274/// clearing the thread-local map means a turn boundary observed on one thread
275/// invalidates entries cached on every thread — see [`TURN_EPOCH`].
276pub(crate) fn reset() {
277 TURN_EPOCH.fetch_add(1, Ordering::AcqRel);
278 reset_local();
279}
280
281/// Drop this thread's entries without opening a new turn.
282///
283/// For `reset_host_state`, reached from `reset_stdlib_state` and in turn from
284/// [`crate::reset_thread_local_state`] — whose contract is to reset *this
285/// thread*, and which runs between VM runs on a reused thread rather than at any
286/// turn boundary. [`TURN_EPOCH`] is process-global, so bumping it from there
287/// reaches past that contract: every VM run that ended anywhere would invalidate
288/// the live memo of every concurrently-running session, costing each an extra
289/// round-trip. A thread-local reset should clear thread-local state only.
290///
291/// This is exactly the pre-epoch behaviour of [`reset`], so the call sites moved
292/// here keep the semantics they already had; only genuine turn boundaries and
293/// bridge swaps gained cross-thread reach.
294pub(crate) fn reset_local() {
295 TURN_STABLE_HOST_CACHE.with(|cache| cache.borrow_mut().clear());
296}
297
298/// Serializes tests that bump [`TURN_EPOCH`] against tests that rely on a memo
299/// entry surviving between a `store` and a `lookup`.
300///
301/// The epoch is process-global, so without this a bridge swap in one test
302/// invalidates another test's entry mid-assertion. Mirrors the
303/// `LONG_RUNNING_TEST_LOCK` convention in `stdlib::fs::tests` for the same
304/// reason: process-global state needs process-global test exclusion.
305#[cfg(test)]
306pub(crate) fn epoch_test_lock() -> &'static std::sync::Mutex<()> {
307 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
308 LOCK.get_or_init(|| std::sync::Mutex::new(()))
309}
310
311#[cfg(test)]
312mod tests {
313 use std::sync::{Arc, Mutex};
314
315 use super::super::{
316 clear_host_call_bridge, dispatch_host_operation, reset_host_state, set_host_call_bridge,
317 HostCallBridge,
318 };
319 use super::reset;
320 use crate::value::{DictMap, VmValue};
321
322 /// [`TURN_EPOCH`] is process-global, so these tests mutate shared state: a
323 /// `reset` in one invalidates entries another is mid-assertion about. Cargo
324 /// runs them on separate threads by default, which made that a real
325 /// cross-talk failure rather than a theoretical one. Serialize them.
326 /// Bridge that counts dispatches per `(capability, operation)` and answers
327 /// every op, so a test can assert how many times the host was actually hit.
328 struct CountingRuntimeBridge {
329 counts: Arc<Mutex<std::collections::HashMap<(String, String), usize>>>,
330 }
331
332 struct VersionedMetadataBridge {
333 generation: Arc<std::sync::atomic::AtomicUsize>,
334 counts: Arc<Mutex<std::collections::HashMap<(String, String), usize>>>,
335 }
336
337 impl HostCallBridge for VersionedMetadataBridge {
338 fn dispatch<'a>(
339 &'a self,
340 capability: &'a str,
341 operation: &'a str,
342 params: &'a DictMap,
343 ) -> super::super::HostCallDispatchFuture<'a> {
344 *self
345 .counts
346 .lock()
347 .unwrap()
348 .entry((capability.to_string(), operation.to_string()))
349 .or_insert(0) += 1;
350 if capability == "project" && operation == "metadata_set" {
351 self.generation
352 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
353 return super::super::host_call_ready(Ok(Some(VmValue::Nil)));
354 }
355 let generation = self.generation.load(std::sync::atomic::Ordering::SeqCst);
356 let dir = params.get("dir").map(VmValue::display).unwrap_or_default();
357 let namespace = params
358 .get("namespace")
359 .map(VmValue::display)
360 .unwrap_or_default();
361 super::super::host_call_ready(Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
362 "v{generation}:{dir}:{namespace}"
363 ))))))
364 }
365 }
366
367 impl HostCallBridge for CountingRuntimeBridge {
368 fn dispatch<'a>(
369 &'a self,
370 capability: &'a str,
371 operation: &'a str,
372 _params: &'a DictMap,
373 ) -> super::super::HostCallDispatchFuture<'a> {
374 *self
375 .counts
376 .lock()
377 .unwrap()
378 .entry((capability.to_string(), operation.to_string()))
379 .or_insert(0) += 1;
380 super::super::host_call_ready(Ok(Some(VmValue::String(arcstr::ArcStr::from(format!(
381 "{capability}.{operation}"
382 ))))))
383 }
384 }
385
386 fn run_async<F, Fut>(test: F)
387 where
388 F: FnOnce() -> Fut,
389 Fut: std::future::Future<Output = ()>,
390 {
391 let rt = tokio::runtime::Builder::new_current_thread()
392 .enable_all()
393 .build()
394 .expect("runtime");
395 rt.block_on(async {
396 let local = tokio::task::LocalSet::new();
397 local.run_until(test()).await;
398 });
399 }
400
401 #[test]
402 fn turn_stable_host_capability_is_fetched_once_per_turn() {
403 let _guard = super::epoch_test_lock()
404 .lock()
405 .unwrap_or_else(|e| e.into_inner());
406 run_async(|| async {
407 reset_host_state();
408 let counts = Arc::new(Mutex::new(std::collections::HashMap::new()));
409 set_host_call_bridge(Arc::new(CountingRuntimeBridge {
410 counts: counts.clone(),
411 }));
412
413 let count = |cap: &str, op: &str| -> usize {
414 counts
415 .lock()
416 .unwrap()
417 .get(&(cap.to_string(), op.to_string()))
418 .copied()
419 .unwrap_or(0)
420 };
421
422 // Many reads within one turn collapse to a single host round-trip.
423 for _ in 0..20 {
424 dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
425 .await
426 .expect("pipeline_input");
427 }
428 assert_eq!(
429 count("runtime", "pipeline_input"),
430 1,
431 "20 same-turn reads must hit the host exactly once"
432 );
433
434 // A non-allowlisted op is never memoized: every call reaches the host.
435 for _ in 0..3 {
436 dispatch_host_operation("runtime", "record_run", &DictMap::new())
437 .await
438 .expect("record_run");
439 }
440 assert_eq!(
441 count("runtime", "record_run"),
442 3,
443 "writes/non-stable ops must never be served from the turn memo"
444 );
445
446 // The next turn boundary re-reads the turn-stable fact exactly once,
447 // so a mid-session change (e.g. a model switch) is observed.
448 reset();
449 for _ in 0..20 {
450 dispatch_host_operation("runtime", "pipeline_input", &DictMap::new())
451 .await
452 .expect("pipeline_input");
453 }
454 assert_eq!(
455 count("runtime", "pipeline_input"),
456 2,
457 "a new turn must re-fetch once, not serve the prior turn's value"
458 );
459
460 clear_host_call_bridge();
461 });
462 }
463
464 #[test]
465 fn metadata_reads_are_parameterized_and_writes_invalidate_inherited_values() {
466 let _guard = super::epoch_test_lock()
467 .lock()
468 .unwrap_or_else(|e| e.into_inner());
469 run_async(|| async {
470 reset_host_state();
471 let counts = Arc::new(Mutex::new(std::collections::HashMap::new()));
472 let generation = Arc::new(std::sync::atomic::AtomicUsize::new(0));
473 set_host_call_bridge(Arc::new(VersionedMetadataBridge {
474 generation,
475 counts: counts.clone(),
476 }));
477
478 let count = |op: &str| -> usize {
479 counts
480 .lock()
481 .unwrap()
482 .get(&("project".to_string(), op.to_string()))
483 .copied()
484 .unwrap_or(0)
485 };
486 let descendant_facts = DictMap::from_iter([
487 (
488 crate::value::intern_key("dir"),
489 VmValue::String(arcstr::ArcStr::from("src/nested")),
490 ),
491 (
492 crate::value::intern_key("namespace"),
493 VmValue::String(arcstr::ArcStr::from("facts")),
494 ),
495 ]);
496 let descendant_test = DictMap::from_iter([
497 (
498 crate::value::intern_key("dir"),
499 VmValue::String(arcstr::ArcStr::from("src/nested")),
500 ),
501 (
502 crate::value::intern_key("namespace"),
503 VmValue::String(arcstr::ArcStr::from("test")),
504 ),
505 ]);
506
507 for _ in 0..100 {
508 let value = dispatch_host_operation("project", "metadata_get", &descendant_facts)
509 .await
510 .expect("metadata_get");
511 assert_eq!(value.display(), "v0:src/nested:facts");
512 }
513 assert_eq!(
514 count("metadata_get"),
515 1,
516 "100 exact reads must dispatch once"
517 );
518
519 dispatch_host_operation("project", "metadata_get", &descendant_test)
520 .await
521 .expect("parameter-distinct metadata_get");
522 assert_eq!(
523 count("metadata_get"),
524 2,
525 "a distinct namespace must retain its own memo entry"
526 );
527
528 let ancestor_write = DictMap::from_iter([
529 (
530 crate::value::intern_key("dir"),
531 VmValue::String(arcstr::ArcStr::from("src")),
532 ),
533 (
534 crate::value::intern_key("namespace"),
535 VmValue::String(arcstr::ArcStr::from("facts")),
536 ),
537 (
538 crate::value::intern_key("value"),
539 VmValue::dict(DictMap::new()),
540 ),
541 ]);
542 dispatch_host_operation("project", "metadata_set", &ancestor_write)
543 .await
544 .expect("metadata_set");
545 assert_eq!(count("metadata_set"), 1);
546
547 let refreshed = dispatch_host_operation("project", "metadata_get", &descendant_facts)
548 .await
549 .expect("read after ancestor write");
550 assert_eq!(
551 refreshed.display(),
552 "v1:src/nested:facts",
553 "an ancestor write must invalidate a cached descendant read"
554 );
555 assert_eq!(count("metadata_get"), 3);
556
557 reset();
558 dispatch_host_operation("project", "metadata_get", &descendant_facts)
559 .await
560 .expect("next-turn metadata_get");
561 assert_eq!(count("metadata_get"), 4, "the next turn must re-read once");
562
563 clear_host_call_bridge();
564 });
565 }
566
567 #[test]
568 fn every_canonical_metadata_mutator_invalidates_turn_stable_reads() {
569 for operation in ["metadata_set", "metadata_save", "metadata_refresh_hashes"] {
570 assert!(
571 super::invalidates_turn_stable_reads("project", operation),
572 "project.{operation} must invalidate the metadata read memo"
573 );
574 }
575 for operation in ["metadata_get", "metadata_inspect", "metadata_stale"] {
576 assert!(
577 !super::invalidates_turn_stable_reads("project", operation),
578 "read-only project.{operation} must not open a new epoch"
579 );
580 }
581 }
582
583 #[test]
584 fn mutation_scope_invalidates_before_and_after_every_return_path() {
585 let _guard = super::epoch_test_lock()
586 .lock()
587 .unwrap_or_else(|e| e.into_inner());
588 let before = super::current_epoch();
589 {
590 let _scope = super::invalidation_scope("project", "metadata_set");
591 assert!(
592 super::current_epoch() > before,
593 "mutation must invalidate before dispatch"
594 );
595 }
596 let after_mutation = super::current_epoch();
597 assert!(
598 after_mutation > before + 1,
599 "scope drop must invalidate after dispatch"
600 );
601
602 {
603 let _scope = super::invalidation_scope("project", "metadata_get");
604 }
605 assert_eq!(
606 super::current_epoch(),
607 after_mutation,
608 "read-only dispatch must not invalidate the memo"
609 );
610 }
611
612 /// A turn boundary observed on a *different* thread must still invalidate
613 /// entries cached here.
614 ///
615 /// This is the property that makes it safe for an embedder to front its own
616 /// `host_call` with [`super::lookup`] / [`super::store`]: `reset` runs where
617 /// the agent-loop event is emitted, which is not guaranteed to be the thread
618 /// that populated the memo. Before epoch tagging, a reset that landed
619 /// elsewhere left this thread serving the previous turn's
620 /// `runtime.pipeline_input` — silently defeating the per-turn re-projection
621 /// hosts rely on to observe a mid-session `/model` switch.
622 #[test]
623 fn turn_boundary_on_another_thread_invalidates_this_thread() {
624 let _guard = super::epoch_test_lock()
625 .lock()
626 .unwrap_or_else(|e| e.into_inner());
627 let params = DictMap::new();
628 let cached = VmValue::String(arcstr::ArcStr::from("turn-1"));
629 super::store("runtime", "pipeline_input", ¶ms, &cached);
630 assert!(
631 super::lookup("runtime", "pipeline_input", ¶ms).is_some(),
632 "same-turn read must hit"
633 );
634
635 std::thread::spawn(reset).join().expect("reset thread");
636
637 assert!(
638 super::lookup("runtime", "pipeline_input", ¶ms).is_none(),
639 "a turn boundary observed on another thread must invalidate this thread's entry"
640 );
641 }
642
643 /// A host read that began before a mutation may complete after the
644 /// mutator's trailing reset. The result is valid for its original caller,
645 /// but it must not refill the new epoch's memo.
646 #[test]
647 fn pre_mutation_read_cannot_poison_the_post_mutation_epoch() {
648 let _guard = super::epoch_test_lock()
649 .lock()
650 .unwrap_or_else(|e| e.into_inner());
651 reset();
652 let params = DictMap::from_iter([(
653 crate::value::intern_key("dir"),
654 VmValue::String(arcstr::ArcStr::from("src")),
655 )]);
656 let dispatch_epoch = super::current_epoch();
657
658 // Models a metadata write completing while this read is in flight.
659 reset();
660 super::store_at_epoch(
661 "project",
662 "metadata_get",
663 ¶ms,
664 &VmValue::String(arcstr::ArcStr::from("stale")),
665 dispatch_epoch,
666 );
667
668 assert!(
669 super::lookup("project", "metadata_get", ¶ms).is_none(),
670 "an old dispatch result must not become the new epoch's cached value"
671 );
672 }
673
674 /// `store` cannot be used to widen the allowlist: a non-turn-stable op is
675 /// dropped rather than memoized, so an embedder wiring these in cannot
676 /// accidentally cache a write or a live read.
677 #[test]
678 fn store_ignores_non_turn_stable_operations() {
679 let _guard = super::epoch_test_lock()
680 .lock()
681 .unwrap_or_else(|e| e.into_inner());
682 let params = DictMap::new();
683 let value = VmValue::String(arcstr::ArcStr::from("live"));
684 super::store("session", "active_roots", ¶ms, &value);
685 assert!(
686 super::lookup("session", "active_roots", ¶ms).is_none(),
687 "non-allowlisted reads must never be served from the memo"
688 );
689 }
690
691 /// The dotted-name helpers an embedder uses must resolve to the same entry
692 /// as the split-pair API, or the two `host_call` routes would keep separate
693 /// memos and the ACP path would still pay every round-trip.
694 #[test]
695 fn dotted_name_helpers_share_the_split_pair_entry() {
696 let _guard = super::epoch_test_lock()
697 .lock()
698 .unwrap_or_else(|e| e.into_inner());
699 reset();
700 let params = DictMap::new();
701 let value = VmValue::String(arcstr::ArcStr::from("shared"));
702 super::store_by_name("runtime.pipeline_input", ¶ms, &value);
703 assert_eq!(
704 super::lookup("runtime", "pipeline_input", ¶ms).map(|v| v.display()),
705 Some("shared".to_string()),
706 "store_by_name must populate the entry lookup() reads"
707 );
708 assert_eq!(
709 super::lookup_by_name("runtime.pipeline_input", ¶ms).map(|v| v.display()),
710 Some("shared".to_string()),
711 "lookup_by_name must read it back"
712 );
713 assert!(
714 super::lookup_by_name("no-separator", ¶ms).is_none(),
715 "a name without a capability separator must not panic or match"
716 );
717 }
718}