Skip to main content

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", &params, &cached);
630        assert!(
631            super::lookup("runtime", "pipeline_input", &params).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", &params).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            &params,
664            &VmValue::String(arcstr::ArcStr::from("stale")),
665            dispatch_epoch,
666        );
667
668        assert!(
669            super::lookup("project", "metadata_get", &params).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", &params, &value);
685        assert!(
686            super::lookup("session", "active_roots", &params).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", &params, &value);
703        assert_eq!(
704            super::lookup("runtime", "pipeline_input", &params).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", &params).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", &params).is_none(),
715            "a name without a capability separator must not panic or match"
716        );
717    }
718}