Skip to main content

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}