Skip to main content

lattice_plugin_host/
trampoline.rs

1//! The §4.1 trampoline + §4.3 result-carrier (plugin-host.md, PH7.3d).
2//!
3//! §4.1 — closures cannot cross the boundary, so a contribution's `apply`
4//! becomes "the guest *exports* a function; the host stores `(id → export)` and
5//! calls it by id, projecting the context in and mapping the returned WIT
6//! `effect` back to native." The *production* trampoline (the shim closure the
7//! grammar/picker dispatcher calls) is world-specific and lands with the
8//! `grammar` / `picker-source` worlds (PH7.4/7.7); this slice proves the
9//! mechanism end-to-end against a minimal `wasm32-wasip2` fixture guest — a real
10//! guest↔host canonical-ABI call — retiring §14's highest risk (the whole
11//! `effect` mirror actually crosses).
12//!
13//! §4.3 — a plugin's `Future`/`Stream` result cannot cross either. The carrier
14//! re-expresses it as "guest returns batches; host owns the loop / `Future` /
15//! `mpsc`." [`collect_batches`] is that host-owned loop: it pulls batches from a
16//! (world-specific) guest export until one comes back empty. The guest never
17//! names a tokio type.
18
19/// Drive a batch-returning guest export to exhaustion, aggregating every batch
20/// into one owned `Vec` (§4.3). `next` wraps the guest's `next-batch`-style
21/// call; an **empty** batch is the exhausted sentinel. The host owns this loop —
22/// the guest only ever returns data. A `next` that errors aborts the drive with
23/// that error (a trapped/fuel-exhausted batch call never yields a partial-but-
24/// silent result).
25pub fn collect_batches<T, E>(mut next: impl FnMut() -> Result<Vec<T>, E>) -> Result<Vec<T>, E> {
26    let mut all = Vec::new();
27    loop {
28        let batch = next()?;
29        if batch.is_empty() {
30            return Ok(all);
31        }
32        all.extend(batch);
33    }
34}
35
36#[cfg(test)]
37mod tests {
38    #![allow(clippy::unwrap_used, clippy::panic)]
39
40    use super::*;
41
42    #[test]
43    fn collect_batches_aggregates_until_empty() {
44        let batches = [vec![1, 2], vec![3], vec![]];
45        let mut i = 0;
46        let out = collect_batches(|| {
47            let b = batches[i].clone();
48            i += 1;
49            Ok::<_, ()>(b)
50        })
51        .unwrap();
52        assert_eq!(out, vec![1, 2, 3]);
53    }
54
55    #[test]
56    fn collect_batches_propagates_an_error() {
57        let mut calls = 0;
58        let out: Result<Vec<i32>, &str> = collect_batches(|| {
59            calls += 1;
60            if calls == 2 { Err("boom") } else { Ok(vec![1]) }
61        });
62        assert_eq!(out, Err("boom"));
63    }
64}
65
66/// The real guest↔host trampoline proof (§4.1 + §4.3) against the
67/// `wasm32-wasip2` fixture. A unit-test module (not an integration test) so its
68/// second `bindgen!` can **reuse** the host's already-generated `types` — the
69/// guest-returned `Effect` is then the *same* Rust type `WitBoundary::from_wit`
70/// consumes, so the round-trip maps back to native. Skips (not fails) when the
71/// fixture wasn't built (no `wasm32-wasip2` target — see `build.rs`).
72#[cfg(test)]
73mod fixture {
74    #![allow(clippy::unwrap_used, clippy::panic)]
75
76    use super::collect_batches;
77    use crate::WitBoundary;
78    use lattice_grammar::effect::Effect as NativeEffect;
79    use wasmtime::component::{Component, Linker};
80    use wasmtime::{Engine, Store};
81    use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
82
83    // Second bindgen for the fixture world. `with` reuses the host's generated
84    // `types` module (from the `plugin`-world bindgen in `lib.rs`) so the
85    // guest-returned `effect`/`args` are the SAME Rust types the host boundary
86    // round-trips — not a fresh, incompatible copy. Sync exports: the test
87    // drives the calls directly without a tokio runtime.
88    wasmtime::component::bindgen!({
89        world: "trampoline-fixture",
90        path: "../lattice-wit/wit",
91        with: {
92            "lattice:plugin-host/types": crate::lattice::plugin_host::types,
93        },
94    });
95
96    /// Store state for the fixture guest (it imports WASI as a `wasm32-wasip2`
97    /// component).
98    struct FixtureState {
99        wasi: WasiCtx,
100        table: ResourceTable,
101    }
102    impl WasiView for FixtureState {
103        fn ctx(&mut self) -> WasiCtxView<'_> {
104            WasiCtxView {
105                ctx: &mut self.wasi,
106                table: &mut self.table,
107            }
108        }
109    }
110
111    /// Instantiate the fixture, or `None` when it wasn't built.
112    fn instantiate() -> Option<(Store<FixtureState>, TrampolineFixture)> {
113        let path = env!("TRAMPOLINE_GUEST_WASM");
114        if path.is_empty() {
115            eprintln!("SKIP: trampoline fixture guest not built (add the wasm32-wasip2 target)");
116            return None;
117        }
118        let engine = Engine::new(wasmtime::Config::new().wasm_component_model(true)).unwrap();
119        let component = Component::from_file(&engine, path).unwrap();
120        let mut linker: Linker<FixtureState> = Linker::new(&engine);
121        wasmtime_wasi::p2::add_to_linker_sync(&mut linker).unwrap();
122        let mut store = Store::new(
123            &engine,
124            FixtureState {
125                wasi: WasiCtxBuilder::new().build(),
126                table: ResourceTable::new(),
127            },
128        );
129        let bindings = TrampolineFixture::instantiate(&mut store, &component, &linker).unwrap();
130        Some((store, bindings))
131    }
132
133    /// §4.1: a `String` arg flows in and comes back as an `Effect::Echo` — the
134    /// whole context→WIT→guest→effect→native path, through a real guest.
135    #[test]
136    fn apply_effect_round_trips_a_payload_arm_through_the_guest() {
137        let Some((mut store, guest)) = instantiate() else {
138            return;
139        };
140        let wit = guest
141            .call_apply_effect(&mut store, &Args::String("hi".to_string()))
142            .unwrap();
143        let native = NativeEffect::from_wit(wit).expect("from_wit");
144        match native {
145            NativeEffect::Echo { level, text } => {
146                assert_eq!(text, "hi");
147                assert_eq!(level, lattice_grammar::effect::EchoLevel::Info);
148            }
149            other => panic!("expected Echo, got {other:?}"),
150        }
151    }
152
153    /// §4.1: a non-string arg returns a two-effect list → the host rebuilds
154    /// `Effect::Many` (the `list<effect>` seam) through a real guest.
155    #[test]
156    fn apply_effect_rebuilds_many_from_a_list() {
157        let Some((mut store, guest)) = instantiate() else {
158            return;
159        };
160        let wit = guest.call_apply_effect(&mut store, &Args::None).unwrap();
161        let native = NativeEffect::from_wit(wit).expect("from_wit");
162        match native {
163            NativeEffect::Many(list) => {
164                assert_eq!(list.len(), 2);
165                assert!(matches!(list[0], NativeEffect::RecordJump));
166                assert!(matches!(list[1], NativeEffect::SetColorscheme(ref s) if s == "nord"));
167            }
168            other => panic!("expected Many, got {other:?}"),
169        }
170    }
171
172    /// §4.3: the host drives `next-batch` to exhaustion, owning the loop; the
173    /// guest just returns batches (["a","b"], ["c"], []).
174    #[test]
175    fn next_batch_carrier_aggregates_host_side() {
176        let Some((mut store, guest)) = instantiate() else {
177            return;
178        };
179        let all = collect_batches(|| guest.call_next_batch(&mut store)).unwrap();
180        assert_eq!(all, vec!["a", "b", "c"]);
181    }
182}