lattice_plugin_host/decoration_task.rs
1//! PH7.9b — the per-plugin actor bridge for decoration providers.
2//!
3//! The decoration analogue of `completion_task.rs`: a dedicated async task owns
4//! the plugin's `Store<PluginState>` for life (the Store is `!Sync`), a
5//! `DecorationCall` crosses an mpsc channel with a `oneshot` reply, and the
6//! `Send + Sync` [`DecorationClient`] serializes calls onto the single-consumer
7//! loop. `PluginHost::spawn_decoration_source` instantiates the
8//! `decorations-plugin` world under the plugin's grant and returns
9//! `(DecorationClient, DecorationActor)`; the caller drives
10//! [`DecorationActor::run`] on its multi-thread runtime (the lib owns no runtime).
11//!
12//! Like completion (PH7.6), this is a **producer**, host-called OFF the render
13//! path — the host calls `produce` on a trigger (edit / scroll / diagnostic
14//! change), caches the result, and the renderer reads the cache (never WASM on
15//! the tick, paramount #1). The picker / completion / decoration actors are
16//! near-identical request/reply bridges; generalising the loop over the bindings
17//! type is deferred until a real need (the `completion_task` rule-of-three note).
18
19use std::sync::Arc;
20
21use futures::StreamExt;
22use futures::channel::{mpsc, oneshot};
23use lattice_runtime::EventBus;
24use wasmtime::Store;
25
26use crate::decoration_host::bindings::DecorationsPlugin;
27use crate::{
28 Component, PluginBudget, PluginHost, PluginHostError, PluginId, PluginManifest, PluginState,
29 TrustTier, arm_store,
30};
31
32// The decoration WIT records the bridge's public API traffics in — the
33// `with:`-mapped `types` mirrors (`decoration_host.rs`), i.e. the SAME Rust types
34// `WitBoundary` round-trips; the native↔WIT conversion is the caller's job
35// (`boundary_decoration.rs`). Re-exported `pub` (they appear in the
36// `DecorationClient` method signatures).
37pub use crate::lattice::plugin_host::types::{DecorationContext, GutterDecoration};
38
39/// See `completion_task::CallResult`.
40type CallResult<T> = Result<T, PluginHostError>;
41
42/// A request sent from a [`DecorationClient`] to its [`DecorationActor`].
43enum DecorationCall {
44 /// `decorations.gutter-decorations(ctx)` — produce the per-line gutter
45 /// decorations for a buffer. Replies the guest's `result<list<gutter-
46 /// decoration>, string>` (or a host trap).
47 Produce {
48 ctx: Box<DecorationContext>,
49 reply: oneshot::Sender<CallResult<Result<Vec<GutterDecoration>, String>>>,
50 },
51}
52
53/// The `Send + Sync` handle a caller holds. Cloning is cheap (an mpsc `Sender`
54/// clone); every clone talks to the same actor / `Store`, so calls serialize on
55/// the single-consumer loop the `!Sync` `Store` needs.
56#[derive(Clone, Debug)]
57pub struct DecorationClient {
58 tx: mpsc::UnboundedSender<DecorationCall>,
59 id: PluginId,
60}
61
62impl DecorationClient {
63 /// The host-issued identity of the plugin behind this client.
64 pub fn id(&self) -> PluginId {
65 self.id
66 }
67
68 /// Call the guest's `gutter-decorations(ctx)`. The outer result is the host
69 /// surface; the inner `Result<_, String>` is the guest's own WIT `result` (an
70 /// `Err` string is a provider that produced nothing for this trigger — logged,
71 /// the cached snapshot keeps its prior value).
72 pub async fn produce(
73 &self,
74 ctx: DecorationContext,
75 ) -> CallResult<Result<Vec<GutterDecoration>, String>> {
76 let (reply, rx) = oneshot::channel();
77 self.tx
78 .unbounded_send(DecorationCall::Produce {
79 ctx: Box::new(ctx),
80 reply,
81 })
82 .map_err(|_| PluginHostError::PluginGone {
83 func: "gutter-decorations",
84 })?;
85 rx.await.map_err(|_| PluginHostError::PluginGone {
86 func: "gutter-decorations",
87 })?
88 }
89}
90
91/// The per-plugin actor: owns the `Store` + decoration bindings for the plugin's
92/// life and serves calls off the channel until every [`DecorationClient`] drops.
93pub struct DecorationActor {
94 store: Store<PluginState>,
95 bindings: DecorationsPlugin,
96 budget: PluginBudget,
97 rx: mpsc::UnboundedReceiver<DecorationCall>,
98 id: PluginId,
99 /// Crash-quarantine (PH7.12): the first `gutter-decorations` trap trips this,
100 /// fires one `PluginCrashed`, and every later call returns `Quarantined`.
101 quarantine: crate::Quarantine,
102 /// PO.2: the boundary tracer, wired by the loader via with_tracer; None in tests / pre-wire.
103 tracer: Option<crate::trace::PluginTracerHandle>,
104}
105
106impl DecorationActor {
107 /// The host-issued identity of this plugin.
108 pub fn id(&self) -> PluginId {
109 self.id
110 }
111
112 /// PO.2: attach the boundary tracer (the loader calls this before spawning
113 /// run()). Off the hot path — the seam is async.
114 pub fn with_tracer(mut self, tracer: Option<crate::trace::PluginTracerHandle>) -> Self {
115 self.tracer = tracer;
116 self
117 }
118
119 /// Drive the actor to completion — see `completion_task::CompletionActor::run`.
120 pub async fn run(mut self) {
121 while let Some(call) = self.rx.next().await {
122 match call {
123 DecorationCall::Produce { ctx, reply } => {
124 let _ = reply.send(self.call_produce(&ctx).await);
125 }
126 }
127 }
128 }
129
130 async fn call_produce(
131 &mut self,
132 ctx: &DecorationContext,
133 ) -> CallResult<Result<Vec<GutterDecoration>, String>> {
134 if self.quarantine.is_tripped() {
135 return Err(PluginHostError::Quarantined {
136 func: "gutter-decorations",
137 });
138 }
139 arm_store(&mut self.store, self.budget)?;
140 let __trace_start = std::time::Instant::now();
141 let result = self
142 .bindings
143 .lattice_plugin_host_decorations()
144 .call_gutter_decorations(&mut self.store, ctx)
145 .await;
146 crate::trip_and_map_traced(
147 self.tracer.as_ref(),
148 self.id.0,
149 crate::PluginSeam::Decorations,
150 &mut self.quarantine,
151 "gutter-decorations",
152 __trace_start,
153 result,
154 )
155 }
156}
157
158impl PluginHost {
159 /// Instantiate a `decorations-plugin` component under its capability grant and
160 /// return the bridge: a `Send + Sync` [`DecorationClient`] plus the
161 /// [`DecorationActor`] the caller drives. Grant / data-dir / WASI are identical
162 /// to `instantiate_plugin` (shared `build_plugin_wasi` + `new_store`), and the
163 /// actor is *not* spawned here (the lib owns no runtime). Mirror of
164 /// [`spawn_completion_source`](Self::spawn_completion_source).
165 pub async fn spawn_decoration_source(
166 &self,
167 component: &Component,
168 manifest: &PluginManifest,
169 tier: TrustTier,
170 budget: PluginBudget,
171 bus: &Arc<EventBus>,
172 ) -> Result<(DecorationClient, DecorationActor), PluginHostError> {
173 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
174 for denied in &outcome.denied {
175 tracing::warn!(
176 plugin = %manifest.id,
177 capability = ?denied,
178 "decoration plugin loaded with a withheld capability (reduced function)"
179 );
180 }
181 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
182 let bindings = DecorationsPlugin::instantiate_async(&mut store, component, &self.linker)
183 .await
184 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
185 let id = self.alloc_id();
186 // PO.5: route this plugin's `logging` calls into the tracer (Layer 2).
187 store.data_mut().log_ctx = self.log_ctx_for(id);
188 let (tx, rx) = mpsc::unbounded();
189 let client = DecorationClient { tx, id };
190 let actor = DecorationActor {
191 store,
192 bindings,
193 budget,
194 rx,
195 id,
196 quarantine: crate::Quarantine::new(id, Arc::clone(bus)),
197 tracer: None,
198 };
199 Ok((client, actor))
200 }
201}