Skip to main content

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}