lattice_host/wasm_decorations.rs
1//! PL8.E — WASM gutter decorations: producer → per-buffer cache → renderer.
2//!
3//! The one hot-path-sensitive plugin seam. A decoration plugin's producer runs
4//! OFF the render path (paramount goal #1); the renderer reads only a native
5//! per-buffer cache. This module owns:
6//!
7//! - [`WasmGutterDecorationCache`] — the per-buffer cache value (the merged
8//! marks + the document version they were produced against).
9//! - [`WasmDecorationState`] — the cohesive bundle of decoration wiring the
10//! [`Editor`](crate::dispatch::Editor) holds (one field, so the boot struct
11//! literal grows by one line): the cache, the producer registry handle, the
12//! off-keystroke paint generation, and the single-flight / registry-epoch
13//! bookkeeping.
14//! - [`Editor::maybe_refresh_wasm_decorations`] — the per-tick refresh pump,
15//! modelled on `maybe_request_inlay_hint`: version/registry-gated,
16//! single-flight, spawns producers off the actor thread, writes the cache via
17//! `insert_for`, bumps the paint generation, and wakes the render pipeline.
18//!
19//! The producer trait + registry themselves live in `lattice-mode`
20//! ([`AsyncGutterDecorationSource`](lattice_mode::AsyncGutterDecorationSource))
21//! so this crate — and the renderers reading the cache — never depend on
22//! `lattice-plugin-host`. The loader (`drain_decorations`) registers the WASM
23//! producer into the registry service; the host reads it here.
24//!
25//! Slice plan: `docs/dev/operations/slice-plans/plugin-loader.md` (PL8.E).
26
27use std::collections::HashMap;
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, Ordering};
30
31use lattice_core::BufferId;
32use lattice_mode::{GutterDecoration, GutterDecorationSourceRegistryHandle};
33
34use crate::editor::Editor;
35use crate::per_buffer_cache::{PerBufferCache, PerBufferCacheExt};
36
37/// Per-buffer cache of a decoration plugin's gutter marks.
38///
39/// The producer task writes this via `insert_for`; the renderer reads it
40/// wait-free via `rs.wasm_gutter_decorations.get_for(buffer_id)` and merges
41/// `decorations` into the same partition it walks for `Mode::gutter_decorations`.
42#[derive(Debug, Clone, Default)]
43pub struct WasmGutterDecorationCache {
44 /// Document version the marks were produced against — the staleness key
45 /// (`maybe_refresh_wasm_decorations` refetches when it moves).
46 pub document_version: u64,
47 /// Merged decorations from every registered producer for this buffer.
48 pub decorations: Vec<GutterDecoration>,
49}
50
51/// The [`Editor`]'s cohesive WASM-decoration wiring (PL8.E). Bundled into one
52/// field so the boot struct literal grows by a single line and the state stays
53/// together. Every field defaults (empty cache / no registry / generation 0),
54/// so `Editor::default()` test fixtures get an inert decoration seam.
55#[derive(Debug, Default)]
56pub struct WasmDecorationState {
57 /// Per-buffer cache the producer tasks write and the renderer reads. Cloned
58 /// into the published `RenderState` so off-render-path writes are observed
59 /// without republishing the snapshot.
60 pub cache: PerBufferCache<WasmGutterDecorationCache>,
61 /// The registered async decoration producers — a clone of the boot
62 /// [`GutterDecorationSourceRegistryHandle`] service the loader RCU-registers
63 /// into. `None` in `Editor::default()`; the refresh then no-ops.
64 pub registry: Option<GutterDecorationSourceRegistryHandle>,
65 /// Off-keystroke paint gate. A producer task bumps this on every cache
66 /// write; [`Editor::compute_paint_revision`](crate::dispatch) folds it in so
67 /// a decoration arrival with no keystroke in flight repaints the gutter.
68 pub generation: Arc<AtomicU64>,
69 /// Single-flight guard: the `(buffer, version)` a refetch is already in
70 /// flight for, so a burst of ticks doesn't spawn duplicate producers.
71 pending: Option<(BufferId, u64)>,
72 /// Pointer identity of the last registry snapshot the refresh drove. The
73 /// registry `ArcSwap` swaps its `Arc` on every register/unregister, so a
74 /// changed epoch means "producers were added/removed" — forcing an
75 /// immediate refresh (a `:plugin-load`ed producer paints without waiting for
76 /// an edit; an unloaded one's marks clear).
77 last_registry_epoch: usize,
78 /// OA.30: the guest-driven refresh counter, and the value this pump last
79 /// acted on. A `refresh-decorations` call bumps the counter; a difference
80 /// here forces a refetch exactly as a changed registry does.
81 ///
82 /// Without it a producer whose answer depends on its OWN state — the
83 /// agenda's bulk marks — is asked once and cached forever: the document
84 /// version never moves in a read-only view, and the registry never changes
85 /// while nothing loads.
86 decoration_epoch: Option<lattice_mode::DecorationEpochHandle>,
87 last_decoration_epoch: u64,
88}
89
90impl WasmDecorationState {
91 /// Construct the decoration state wired to the boot producer registry — the
92 /// boot path. Keeps the single-flight / epoch bookkeeping private (they
93 /// start zeroed); `Editor::default()` uses the `Default` impl (no registry).
94 pub fn with_registry(registry: GutterDecorationSourceRegistryHandle) -> Self {
95 Self {
96 registry: Some(registry),
97 ..Default::default()
98 }
99 }
100
101 /// SG.1 / DR.2: force the next tick to re-ask every producer.
102 ///
103 /// `:redraw` / `<C-l>` is the user's escape hatch for a display that has
104 /// gone wrong, and it already re-derives every visible pane from a clean
105 /// slate. A cached decoration set is part of that display: without this a
106 /// stale sign — the one thing `<C-l>` exists to fix — survives the redraw,
107 /// because the pump's two ordinary triggers (registry, document version)
108 /// are both unchanged by a redraw.
109 ///
110 /// A no-op when no counter is wired, like every other degradation here.
111 pub fn request_refresh(&self) {
112 if let Some(epoch) = self.decoration_epoch.as_ref() {
113 epoch.bump();
114 }
115 }
116
117 /// OA.30: attach the guest-driven refresh counter. Separate from
118 /// [`with_registry`](Self::with_registry) because a harness may wire one
119 /// without the other, and neither is a precondition for the other working.
120 pub fn with_decoration_epoch(mut self, epoch: lattice_mode::DecorationEpochHandle) -> Self {
121 self.last_decoration_epoch = epoch.get();
122 self.decoration_epoch = Some(epoch);
123 self
124 }
125}
126
127impl Editor {
128 /// PL8.E per-tick decoration refresh pump — the off-render-path drive.
129 ///
130 /// Called from `run_tick_pending` next to the `maybe_request_*` LSP pumps.
131 /// Cheap when nothing changed (registry-epoch + cache-version gated). When a
132 /// refresh is due it spawns the registered producers on the background
133 /// runtime (NOT the actor thread), each writing the merged result into the
134 /// per-buffer cache via `insert_for`, bumping the paint generation and
135 /// waking the render pipeline. NO per-frame WASM — the renderer reads only
136 /// the cache this fills.
137 ///
138 /// Graceful / no-flicker (§8): a producer whose call errs (trap, quarantine,
139 /// or a benign "empty buffer" `Err`) contributes nothing; the cache is
140 /// overwritten only when at least one producer answered, so an all-error
141 /// refresh keeps the prior marks painted rather than blanking them. (With a
142 /// single producer — the common case — this is exactly the
143 /// `WasmDecorationSource` doc contract: `Err` ⇒ keep prior.)
144 pub fn maybe_refresh_wasm_decorations(&mut self) {
145 let Some(registry) = self.wasm_decorations.registry.clone() else {
146 return;
147 };
148 let snapshot_reg = registry.load_full();
149 let epoch = Arc::as_ptr(&snapshot_reg) as usize;
150 let registry_changed = epoch != self.wasm_decorations.last_registry_epoch;
151 // OA.30: a guest said its answer changed. Treated exactly as a changed
152 // registry is — force a refetch — because the two mean the same thing
153 // to this pump: "what you cached is no longer what the producer would
154 // say."
155 let guest_epoch = self
156 .wasm_decorations
157 .decoration_epoch
158 .as_ref()
159 .map(|e| e.get())
160 .unwrap_or(0);
161 let guest_asked = guest_epoch != self.wasm_decorations.last_decoration_epoch;
162 let registry_changed = registry_changed || guest_asked;
163 let sources = snapshot_reg.sources();
164
165 if sources.is_empty() {
166 // Every producer unloaded: clear the stale cache so unloaded marks
167 // stop painting, then record the epoch so we don't loop. Only when
168 // the registry actually changed (steady-state no-producer editors —
169 // the overwhelming majority — take the cheap early return above via
170 // the empty snapshot without touching the cache).
171 if registry_changed {
172 self.wasm_decorations
173 .cache
174 .store(Arc::new(
175 HashMap::<BufferId, Arc<WasmGutterDecorationCache>>::new(),
176 ));
177 self.wasm_decorations
178 .generation
179 .fetch_add(1, Ordering::Relaxed);
180 self.wasm_decorations.last_registry_epoch = epoch;
181 self.wasm_decorations.pending = None;
182 }
183 return;
184 }
185
186 let buffer_id = self.document_buffer_id;
187 let snapshot = self.document.snapshot();
188 let version = snapshot.version;
189 // CV.3: content space — decorations address real lines.
190 let line_count = snapshot.buffer.content_line_count();
191
192 // Up to date only when the producer set is unchanged AND this buffer's
193 // cache matches the current document version. A changed registry always
194 // forces a refetch (new/removed producer).
195 let cache_current = self
196 .wasm_decorations
197 .cache
198 .get_for(buffer_id)
199 .map(|c| c.document_version == version)
200 .unwrap_or(false);
201 if !registry_changed && cache_current {
202 return;
203 }
204 // Single-flight: skip re-spawning for a (buffer, version) already in
205 // flight — unless the registry changed (the in-flight batch used the
206 // stale producer set and must be superseded).
207 if !registry_changed && self.wasm_decorations.pending == Some((buffer_id, version)) {
208 return;
209 }
210
211 self.wasm_decorations.last_registry_epoch = epoch;
212 // Recorded only once the refetch is actually being spawned, so a bump
213 // that arrives while an early return is taken is not lost — it forces
214 // the refetch on the next tick instead.
215 self.wasm_decorations.last_decoration_epoch = guest_epoch;
216 self.wasm_decorations.pending = Some((buffer_id, version));
217
218 let path = self.buffers.document_path(buffer_id);
219 let cache_slot = self.wasm_decorations.cache.clone();
220 let async_landed = self.async_landed.clone();
221 let generation = self.wasm_decorations.generation.clone();
222
223 // Off the actor thread: the editor actor runs a current-thread runtime,
224 // so a plain `tokio::spawn` would land here. The shared background
225 // runtime hosts this channel round-trip to the plugin's decoration actor
226 // (which runs the guest), exactly like the LSP request pumps.
227 lattice_runtime::runtime::spawn_on_lsp_runtime(async move {
228 let mut merged: Vec<GutterDecoration> = Vec::new();
229 let mut any_ok = false;
230 for source in sources {
231 match source
232 .produce(buffer_id.0 as u64, path.clone(), line_count)
233 .await
234 {
235 Ok(decorations) => {
236 any_ok = true;
237 merged.extend(decorations);
238 }
239 Err(reason) => {
240 // Graceful skip: keep this producer's prior contribution
241 // (no clear). Debug, not info — a per-refresh event.
242 tracing::debug!(
243 source = source.source_id(),
244 error = %reason,
245 "decoration producer errored; keeping prior marks"
246 );
247 }
248 }
249 }
250 // No-flicker: only overwrite when a producer answered. An all-error
251 // refresh leaves the last-good snapshot in place.
252 if any_ok {
253 cache_slot.insert_for(
254 buffer_id,
255 WasmGutterDecorationCache {
256 document_version: version,
257 decorations: merged,
258 },
259 );
260 generation.fetch_add(1, Ordering::Relaxed);
261 async_landed.notify_one();
262 }
263 });
264 }
265}