lattice_plugin_host/context_task.rs
1//! TC.2 — the per-plugin actor bridge for sticky-context providers.
2//!
3//! The context analogue of `decoration_task.rs`: a dedicated async task owns the
4//! plugin's `Store<PluginState>` for life (the Store is `!Sync`), a
5//! [`ContextCall`] crosses an mpsc channel with a `oneshot` reply, and the
6//! `Send + Sync` [`ContextClient`] serializes calls onto the single-consumer
7//! loop. `PluginHost::spawn_context_source` instantiates the `context-plugin`
8//! world under the plugin's grant and returns `(ContextClient, ContextActor)`;
9//! the caller drives [`ContextActor::run`] on its multi-thread runtime (the lib
10//! owns no runtime).
11//!
12//! **The tree crosses as a call-scoped borrow.** Unlike the other async seams,
13//! `context-scopes` takes `option<borrow<tree-snapshot>>`. The actor pushes an
14//! owned `TreeSnapshotResource` into the store's table, lends a non-owning
15//! borrow to the guest, and reclaims the owned entry after the call — the
16//! `grammar_trampoline` pattern (TS.1), which is what keeps the
17//! `tree-sitter` capability meaning "the tree you were handed" rather than "any
18//! buffer's tree, any time". The owned entry lives across the guest's
19//! suspension; the host owns it throughout, and any `node` the guest derives is
20//! guest-owned and dropped before it returns.
21
22use std::sync::Arc;
23
24use futures::StreamExt;
25use futures::channel::{mpsc, oneshot};
26use lattice_runtime::EventBus;
27use lattice_syntax::SyntaxSnapshot;
28use wasmtime::Store;
29use wasmtime::component::Resource;
30
31use crate::context_host::bindings::ContextPlugin;
32use crate::tree_resource::TreeSnapshotResource;
33use crate::{
34 Component, PluginBudget, PluginHost, PluginHostError, PluginId, PluginManifest, PluginState,
35 TrustTier, arm_store,
36};
37
38// The context WIT records the bridge's public API traffics in — the
39// `with:`-mapped `types` mirrors (`context_host.rs`), i.e. the SAME Rust types
40// `WitBoundary` round-trips; the native↔WIT conversion is the caller's job
41// (`boundary_context.rs`).
42pub use crate::lattice::plugin_host::types::{ContextRequest, ContextScope};
43
44/// See `completion_task::CallResult`.
45type CallResult<T> = Result<T, PluginHostError>;
46
47/// A request sent from a [`ContextClient`] to its [`ContextActor`].
48enum ContextCall {
49 /// `context.context-scopes(req, tree)` — produce the structural scopes for a
50 /// buffer. Replies the guest's `result<list<context-scope>, string>` (or a
51 /// host trap).
52 Produce {
53 req: Box<ContextRequest>,
54 /// The buffer's parse snapshot, or `None` when it has no tree. Crosses
55 /// to the guest as a call-scoped borrow.
56 tree: Option<Arc<SyntaxSnapshot>>,
57 reply: oneshot::Sender<CallResult<Result<Vec<ContextScope>, String>>>,
58 },
59}
60
61/// The `Send + Sync` handle a caller holds. Cloning is cheap (an mpsc `Sender`
62/// clone); every clone talks to the same actor / `Store`, so calls serialize on
63/// the single-consumer loop the `!Sync` `Store` needs.
64#[derive(Clone, Debug)]
65pub struct ContextClient {
66 tx: mpsc::UnboundedSender<ContextCall>,
67 id: PluginId,
68}
69
70impl ContextClient {
71 /// The host-issued identity of the plugin behind this client.
72 pub fn id(&self) -> PluginId {
73 self.id
74 }
75
76 /// Call the guest's `context-scopes(req, tree)`. The outer result is the
77 /// host surface; the inner `Result<_, String>` is the guest's own WIT
78 /// `result` (an `Err` string means this refresh produced nothing — logged,
79 /// and the caller KEEPS the buffer's prior cached scopes).
80 pub async fn produce(
81 &self,
82 req: ContextRequest,
83 tree: Option<Arc<SyntaxSnapshot>>,
84 ) -> CallResult<Result<Vec<ContextScope>, String>> {
85 let (reply, rx) = oneshot::channel();
86 self.tx
87 .unbounded_send(ContextCall::Produce {
88 req: Box::new(req),
89 tree,
90 reply,
91 })
92 .map_err(|_| PluginHostError::PluginGone {
93 func: "context-scopes",
94 })?;
95 rx.await.map_err(|_| PluginHostError::PluginGone {
96 func: "context-scopes",
97 })?
98 }
99}
100
101/// The per-plugin actor: owns the `Store` + context bindings for the plugin's
102/// life and serves calls off the channel until every [`ContextClient`] drops.
103pub struct ContextActor {
104 store: Store<PluginState>,
105 bindings: ContextPlugin,
106 budget: PluginBudget,
107 rx: mpsc::UnboundedReceiver<ContextCall>,
108 id: PluginId,
109 /// Crash-quarantine (PH7.12): the first `context-scopes` trap trips this,
110 /// fires one `PluginCrashed`, and every later call returns `Quarantined`.
111 quarantine: crate::Quarantine,
112 /// PO.2: the boundary tracer, wired by the loader via `with_tracer`; `None`
113 /// in tests / pre-wire.
114 tracer: Option<crate::trace::PluginTracerHandle>,
115 /// TS.1: whether this component holds the `tree-sitter` editor capability.
116 /// Without it the producer is handed `none` for its snapshot, exactly as a
117 /// buffer with no parse would be.
118 tree_sitter_granted: bool,
119}
120
121impl ContextActor {
122 /// The host-issued identity of this plugin.
123 pub fn id(&self) -> PluginId {
124 self.id
125 }
126
127 /// PO.2: attach the boundary tracer (the loader calls this before spawning
128 /// `run()`). Off the hot path — the seam is async.
129 pub fn with_tracer(mut self, tracer: Option<crate::trace::PluginTracerHandle>) -> Self {
130 self.tracer = tracer;
131 self
132 }
133
134 /// Drive the actor to completion — see `completion_task::CompletionActor::run`.
135 pub async fn run(mut self) {
136 while let Some(call) = self.rx.next().await {
137 match call {
138 ContextCall::Produce { req, tree, reply } => {
139 let _ = reply.send(self.call_produce(&req, tree.as_ref()).await);
140 }
141 }
142 }
143 }
144
145 async fn call_produce(
146 &mut self,
147 req: &ContextRequest,
148 tree: Option<&Arc<SyntaxSnapshot>>,
149 ) -> CallResult<Result<Vec<ContextScope>, String>> {
150 if self.quarantine.is_tripped() {
151 return Err(PluginHostError::Quarantined {
152 func: "context-scopes",
153 });
154 }
155 arm_store(&mut self.store, self.budget)?;
156
157 // Lend the snapshot as a borrow: push an owned entry, hand the guest a
158 // non-owning handle, reclaim after the call. Only mint one when the
159 // buffer actually HAS a parse — otherwise the guest gets `none` and is
160 // expected to return an empty list (a normal state, not an error).
161 let owned_tree = match tree {
162 // TS.1 parity: the tree-sitter seam is gated on the `tree-sitter`
163 // editor capability, so a producer WITHOUT the grant gets `none`
164 // for its handle — the same answer as a buffer with no parse, and
165 // the same expected response (an empty list, not an error).
166 //
167 // The grammar seam has always done this; the context seam claimed
168 // to and did not, lending the snapshot to any component that asked.
169 // A capability that is documented, denied at load, warned about,
170 // and then not enforced is worse than no capability at all.
171 Some(snap) if snap.tree().is_some() && self.tree_sitter_granted => Some(
172 self.store
173 .data_mut()
174 .table
175 .push(TreeSnapshotResource::new(snap.clone()))
176 .map_err(|e| PluginHostError::Instantiate(e.into()))?,
177 ),
178 _ => None,
179 };
180 let tree_borrow = owned_tree.as_ref().map(|o| Resource::new_borrow(o.rep()));
181
182 let __trace_start = std::time::Instant::now();
183 let result = self
184 .bindings
185 .lattice_plugin_host_context()
186 .call_context_scopes(&mut self.store, req, tree_borrow)
187 .await;
188
189 // Reclaim the owned entry whether the call succeeded, erred, or trapped
190 // — a trapped guest must not leak a table entry into the next call.
191 if let Some(owned_tree) = owned_tree {
192 let _ = self.store.data_mut().table.delete(owned_tree);
193 }
194
195 crate::trip_and_map_traced(
196 self.tracer.as_ref(),
197 self.id.0,
198 crate::PluginSeam::Context,
199 &mut self.quarantine,
200 "context-scopes",
201 __trace_start,
202 result,
203 )
204 }
205}
206
207impl PluginHost {
208 /// Instantiate a `context-plugin` component under its capability grant and
209 /// return the bridge: a `Send + Sync` [`ContextClient`] plus the
210 /// [`ContextActor`] the caller drives. Grant / data-dir / WASI are identical
211 /// to `instantiate_plugin` (shared `build_plugin_wasi` + `new_store`), and
212 /// the actor is *not* spawned here (the lib owns no runtime). Mirror of
213 /// [`spawn_decoration_source`](Self::spawn_decoration_source).
214 pub async fn spawn_context_source(
215 &self,
216 component: &Component,
217 manifest: &PluginManifest,
218 tier: TrustTier,
219 budget: PluginBudget,
220 bus: &Arc<EventBus>,
221 config: Option<&Arc<lattice_config::ConfigRegistry>>,
222 ) -> Result<(ContextClient, ContextActor), PluginHostError> {
223 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
224 for denied in &outcome.denied {
225 tracing::warn!(
226 plugin = %manifest.id,
227 capability = ?denied,
228 "context plugin loaded with a withheld capability (reduced function)"
229 );
230 }
231 let tree_sitter_granted = outcome
232 .grant
233 .editor
234 .contains(lattice_mode::CapabilitySet::TREE_SITTER);
235 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
236 let bindings = ContextPlugin::instantiate_async(&mut store, component, &self.linker)
237 .await
238 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
239 let id = self.alloc_id();
240 // PO.5: route this plugin's `logging` calls into the tracer (Layer 2).
241 store.data_mut().log_ctx = self.log_ctx_for(id);
242 // The producer reads its OWN options through `get-option`
243 // (`max-file-lines`, `disabled-languages`). Without the registry on
244 // this store every such read returns `None` and the guest silently
245 // falls back to its compiled defaults — so the options resolve in
246 // `:customize`, report a value to `:set …?`, and change nothing.
247 // `spawn_config_plugin` wires this for the config seam; the context
248 // seam runs in its own store and needs it too.
249 if let Some(registry) = config {
250 store.data_mut().config_registry = Some(Arc::clone(registry));
251 }
252 let (tx, rx) = mpsc::unbounded();
253 let client = ContextClient { tx, id };
254 let actor = ContextActor {
255 tree_sitter_granted,
256 store,
257 bindings,
258 budget,
259 rx,
260 id,
261 quarantine: crate::Quarantine::new(id, Arc::clone(bus)),
262 tracer: None,
263 };
264 Ok((client, actor))
265 }
266}