lattice_plugin_host/multibuffer_view_task.rs
1//! MV.1 — the per-plugin actor bridge for multibuffer-view sources.
2//!
3//! The `picker_task.rs` shape, and deliberately so: a dedicated async task owns
4//! the plugin's `Store<PluginState>` for life (the `Store` is `!Sync`), a
5//! [`ViewCall`] crosses an mpsc channel with a `oneshot` reply, and the
6//! `Send + Sync` [`MultibufferViewClient`] serializes calls onto the single-
7//! consumer loop. [`PluginHost::spawn_multibuffer_view_source`] instantiates the
8//! `multibuffer-view-plugin` world under the plugin's grant and returns
9//! `(client, actor)`; the caller drives [`MultibufferViewActor::run`] on its
10//! multi-thread runtime (the lib owns no runtime).
11//!
12//! This is the fourth actor of this shape (picker, completion, agenda, view).
13//! The rule-of-three trigger to generalise the loop over the bindings type has
14//! fired, and generalising it is its own refactor rather than a thing to attempt
15//! inside the slice that adds the fourth — noted here so the next one does not
16//! have to rediscover the count.
17
18use std::sync::Arc;
19
20use futures::StreamExt;
21use futures::channel::{mpsc, oneshot};
22use lattice_runtime::EventBus;
23use wasmtime::Store;
24
25use crate::multibuffer_view_host::bindings::MultibufferViewPlugin;
26use crate::{
27 Component, PluginBudget, PluginHost, PluginHostError, PluginId, PluginManifest, PluginState,
28 TrustTier, arm_store,
29};
30
31pub use crate::lattice::plugin_host::types::{MultibufferViewResult, MultibufferViewSpec};
32
33/// See `picker_task::CallResult`.
34type CallResult<T> = Result<T, PluginHostError>;
35
36/// A request sent from a [`MultibufferViewClient`] to its actor.
37enum ViewCall {
38 /// `register-multibuffer-views()` — drive the guest's registration export,
39 /// then hand back every view it declared through the imported
40 /// `register-multibuffer-view`.
41 RegisterViews {
42 reply: oneshot::Sender<CallResult<Vec<MultibufferViewSpec>>>,
43 },
44 /// `multibuffer-view-source.build(view, args)` — produce one view's
45 /// excerpts. Replies the guest's `result<multibuffer-view-result, string>`
46 /// (or a host trap).
47 Build {
48 view: String,
49 args: Vec<String>,
50 reply: oneshot::Sender<CallResult<Result<MultibufferViewResult, String>>>,
51 },
52}
53
54/// The `Send + Sync` handle the provider holds. Cloning is cheap (an mpsc
55/// `Sender` clone); every clone talks to the same actor / `Store`, so calls
56/// serialize on the single-consumer loop — the guarantee the `!Sync` `Store`
57/// needs. Dropping the last clone ends the actor loop (teardown).
58#[derive(Clone)]
59pub struct MultibufferViewClient {
60 tx: mpsc::UnboundedSender<ViewCall>,
61 id: PluginId,
62}
63
64impl MultibufferViewClient {
65 /// The host-issued identity of the plugin behind this client.
66 pub fn id(&self) -> PluginId {
67 self.id
68 }
69
70 /// Drive the guest's `register-multibuffer-views()` and collect every view
71 /// it declared.
72 ///
73 /// An empty list is not an error: a plugin that provides the seam and
74 /// declares nothing registers nothing, which is what it asked for.
75 pub async fn register_views(&self) -> CallResult<Vec<MultibufferViewSpec>> {
76 let (reply, rx) = oneshot::channel();
77 self.dispatch(
78 ViewCall::RegisterViews { reply },
79 rx,
80 "register-multibuffer-views",
81 )
82 .await
83 }
84
85 /// Call the guest's `build(view, args)`. The outer result is the host
86 /// surface; the inner `Result<_, String>` is the guest's own WIT `result`,
87 /// whose `Err` **declines** the view with the guest's message rather than
88 /// opening an empty one.
89 pub async fn build(
90 &self,
91 view: String,
92 args: Vec<String>,
93 ) -> CallResult<Result<MultibufferViewResult, String>> {
94 let (reply, rx) = oneshot::channel();
95 self.dispatch(ViewCall::Build { view, args, reply }, rx, "build")
96 .await
97 }
98
99 /// Shared send-then-await-reply. A closed channel or a dropped reply sender
100 /// both surface as [`PluginGone`](PluginHostError::PluginGone) — the caller
101 /// stays live.
102 async fn dispatch<T>(
103 &self,
104 call: ViewCall,
105 rx: oneshot::Receiver<CallResult<T>>,
106 func: &'static str,
107 ) -> CallResult<T> {
108 self.tx
109 .unbounded_send(call)
110 .map_err(|_| PluginHostError::PluginGone { func })?;
111 rx.await.map_err(|_| PluginHostError::PluginGone { func })?
112 }
113}
114
115/// The per-plugin actor: owns the `Store` + view bindings for the plugin's whole
116/// life and serves calls until every client is dropped.
117pub struct MultibufferViewActor {
118 store: Store<PluginState>,
119 bindings: MultibufferViewPlugin,
120 budget: PluginBudget,
121 rx: mpsc::UnboundedReceiver<ViewCall>,
122 id: PluginId,
123 quarantine: crate::Quarantine,
124 tracer: Option<crate::trace::PluginTracerHandle>,
125}
126
127impl MultibufferViewActor {
128 /// The host-issued identity of this plugin.
129 pub fn id(&self) -> PluginId {
130 self.id
131 }
132
133 /// PO.2: attach the boundary tracer before spawning `run`.
134 pub fn with_tracer(mut self, tracer: Option<crate::trace::PluginTracerHandle>) -> Self {
135 self.tracer = tracer;
136 self
137 }
138
139 /// Drive the actor to completion. A trap does not end the loop — the
140 /// `Store` survives a clean fuel/epoch trap, and quarantine handles the
141 /// rest. The loop ends when the channel closes, dropping the `Store`.
142 pub async fn run(mut self) {
143 while let Some(call) = self.rx.next().await {
144 match call {
145 ViewCall::RegisterViews { reply } => {
146 let _ = reply.send(self.call_register_views().await);
147 }
148 ViewCall::Build { view, args, reply } => {
149 let _ = reply.send(self.call_build(&view, &args).await);
150 }
151 }
152 }
153 }
154
155 /// Drive `register-multibuffer-views`, then drain what the guest declared.
156 ///
157 /// The drain reads `PluginState` AFTER the export returns — the
158 /// `register-grammar` shape, because a guest registers by *calling*, so the
159 /// specs do not exist until its body has run.
160 async fn call_register_views(&mut self) -> CallResult<Vec<MultibufferViewSpec>> {
161 if self.quarantine.is_tripped() {
162 return Err(PluginHostError::Quarantined {
163 func: "register-multibuffer-views",
164 });
165 }
166 arm_store(&mut self.store, self.budget)?;
167 let __trace_start = std::time::Instant::now();
168 let result = self
169 .bindings
170 .call_register_multibuffer_views(&mut self.store)
171 .await;
172 crate::trip_and_map_traced(
173 self.tracer.as_ref(),
174 self.id.0,
175 crate::PluginSeam::MultibufferViewSource,
176 &mut self.quarantine,
177 "register-multibuffer-views",
178 __trace_start,
179 result,
180 )?;
181 Ok(std::mem::take(
182 &mut self.store.data_mut().multibuffer_view_contributions.specs,
183 ))
184 }
185
186 async fn call_build(
187 &mut self,
188 view: &str,
189 args: &[String],
190 ) -> CallResult<Result<MultibufferViewResult, String>> {
191 if self.quarantine.is_tripped() {
192 return Err(PluginHostError::Quarantined { func: "build" });
193 }
194 arm_store(&mut self.store, self.budget)?;
195 let __trace_start = std::time::Instant::now();
196 let result = self
197 .bindings
198 .lattice_plugin_host_multibuffer_view_source()
199 .call_build(&mut self.store, view, args)
200 .await;
201 crate::trip_and_map_traced(
202 self.tracer.as_ref(),
203 self.id.0,
204 crate::PluginSeam::MultibufferViewSource,
205 &mut self.quarantine,
206 "build",
207 __trace_start,
208 result,
209 )
210 }
211}
212
213impl PluginHost {
214 /// Instantiate a `multibuffer-view-plugin` component under its capability
215 /// grant and return the bridge. Grant / data-dir / WASI are identical to
216 /// `instantiate_plugin`; the actor is *not* spawned here (the lib owns no
217 /// runtime). Mirror of [`spawn_picker_source`](Self::spawn_picker_source).
218 pub async fn spawn_multibuffer_view_source(
219 &self,
220 component: &Component,
221 manifest: &PluginManifest,
222 tier: TrustTier,
223 budget: PluginBudget,
224 bus: &Arc<EventBus>,
225 config: Option<&Arc<lattice_config::ConfigRegistry>>,
226 ) -> Result<(MultibufferViewClient, MultibufferViewActor), PluginHostError> {
227 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
228 for denied in &outcome.denied {
229 tracing::warn!(
230 plugin = %manifest.id,
231 capability = ?denied,
232 "multibuffer-view plugin loaded with a withheld capability (reduced function)"
233 );
234 }
235 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
236 let bindings =
237 MultibufferViewPlugin::instantiate_async(&mut store, component, &self.linker)
238 .await
239 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
240 let id = self.alloc_id();
241 store.data_mut().log_ctx = self.log_ctx_for(id);
242 // MV.1: the config registry. The SEVENTH seam to need this line, and six
243 // of the previous ones shipped without it — each answering `none` to
244 // `get-option` while looking perfectly wired. A view's contents very
245 // often depend on an option (which directory, which filter), so it is
246 // stamped here rather than waiting for the bug report.
247 if let Some(registry) = config {
248 store.data_mut().config_registry = Some(Arc::clone(registry));
249 }
250 let (tx, rx) = mpsc::unbounded();
251 let client = MultibufferViewClient { tx, id };
252 let actor = MultibufferViewActor {
253 store,
254 bindings,
255 budget,
256 rx,
257 id,
258 quarantine: crate::Quarantine::new(id, Arc::clone(bus)),
259 tracer: None,
260 };
261 Ok((client, actor))
262 }
263}