lattice_plugin_host/lib.rs
1//! Plugin host — the WASM Component Model extension substrate (Phase 7).
2//!
3//! Design fragment: `docs/dev/architecture/plugin-host.md`. Slice plan:
4//! `docs/dev/operations/slice-plans/plugin-host.md`. Spec: `design.md` §5.5.
5//!
6//! **PH7.1a — async runtime core.** The host now runs the canonical async
7//! ABI (`design.md` §5.5, fragment §3): the engine has `async_support`, so a
8//! plugin's lifecycle exports are `async` and a host call suspends the WASM
9//! stack rather than pinning an OS thread. Each call runs under two hard
10//! budgets — a **fuel** cap (total work) and an **epoch** deadline
11//! (wall-clock) — and either, on exhaustion, traps *cleanly*: the offending
12//! call returns a typed [`PluginHostError::Trap`], the [`Store`] is untouched
13//! by any other plugin, and the host stays live. A background epoch-ticker
14//! thread bumps the engine epoch so the wall-clock deadline actually fires.
15//!
16//! The lib owns no async runtime: methods are `async fn`, so the *caller*
17//! (the editor's multi-thread pool — never the `current_thread` actor)
18//! drives them. Running two plugins on two tasks runs them on two cores.
19//!
20//! **PH7.1b — module cache + lazy instantiation.** The AOT (Cranelift) compile
21//! of a component is cached on disk (via wasmtime's own cache, keyed on bytes +
22//! compiler config + target + wasmtime version), so a second launch reuses the
23//! cached module instead of recompiling. Lazy instantiation is *structural*
24//! here, not a new type: [`PluginHost::compile`] loads/caches a [`Component`]
25//! without instantiating it; the [`Store`] and instance are created only by an
26//! explicit [`PluginHost::instantiate`] call. When the contribution model lands
27//! (PH7.3+), that call is what a plugin's *first contribution invocation* will
28//! trigger.
29//!
30//! **PH7.2 — capability & security model.** Each plugin now instantiates
31//! under a [`CapabilityGrant`] computed from its [`PluginManifest`] and
32//! [`TrustTier`] (fragment §6). The grant is *enforced*, not advisory: each
33//! [`Store`]'s WASI view is built with exactly its granted filesystem preopens
34//! plus a private per-plugin data dir, so a plugin without an `fs:write` grant
35//! cannot reach a path outside its data dir at the WASI layer (WASI has no
36//! ambient authority). `net:http` / `proc:spawn` ride the grant as metadata for
37//! the capability-gated `host-services` seam (PH7.3+); they are deliberately
38//! *not* wired into the raw WASI view (see [`capability`]). The host also
39//! issues each plugin a monotonic [`PluginId`] and stamps
40//! [`SourceLayer::Plugin`] provenance from its own ground truth — a plugin
41//! cannot forge provenance (`lattice_grammar::source` has no public
42//! `SourceLocation` setter). See [`manifest`] and [`capability`].
43//!
44//! The end-to-end "a guest attempts a write and WASI denies it" proof lands at
45//! PH7.4 with the real `wasm32-wasip2` `fuzzy-finder` (the guest toolchain PH7.0
46//! deferred to that slice); PH7.2 proves the model at the host layer — grant
47//! computation, the grant→preopen mapping, provenance issuance — with the
48//! WASI-layer OS enforcement itself resting on wasmtime's tested guarantee.
49//!
50//! Still owned by later slices: every contribution seam (PH7.3+). The first
51//! consumer of the `plugin` lifecycle world is the user's `init.rs`; the no-op
52//! component the tests instantiate is the degenerate `init.rs`.
53
54pub mod boundary;
55pub mod boundary_app_effect;
56pub mod boundary_config;
57pub mod boundary_context;
58pub mod boundary_decoration;
59pub mod boundary_effect;
60pub mod boundary_event;
61pub mod boundary_grammar;
62pub mod boundary_picker;
63pub mod buffer;
64pub mod capability;
65pub mod completion_host;
66pub mod completion_source;
67pub mod completion_task;
68pub mod config_host;
69// TC.2 — the sticky-context producer seam. Trio mirroring `decoration_*`:
70// the bindgen world, the actor bridge, the native-trait adapter.
71pub mod context_host;
72pub mod context_source;
73pub mod context_task;
74/// CR.4: the `dashboard` guest→host section seam.
75pub mod dashboard_host;
76pub mod media_host;
77pub mod media_source;
78pub use crate::media_source::WasmMediaSource;
79pub mod decoration_host;
80pub mod decoration_source;
81pub mod decoration_task;
82// OM.A1: the `scanned-excerpt-source` guest→host producer seam.
83pub mod scan_host;
84pub mod scan_task;
85pub mod scanned_excerpt_source;
86pub use crate::scanned_excerpt_source::{WasmScannedExcerptSource, normalise_extensions};
87// XF.4: the fs:write gate for guest-returned effects, applied at the
88// boundary where the plugin's provenance is still known.
89pub mod effect_authorizer;
90pub use crate::effect_authorizer::EffectAuthorizer;
91pub mod error_parser_host;
92pub mod event_task;
93pub mod events_host;
94pub mod grammar_host;
95pub mod grammar_trampoline;
96/// CR.3: the `help` guest→host topic-registration seam.
97pub mod help_host;
98pub mod media_task;
99// LG.3c: the `language` guest→host registration seam.
100pub mod host_services;
101pub mod keymap_host;
102pub mod language_host;
103pub mod manifest;
104pub mod mode_host;
105pub mod multibuffer_view_host;
106pub mod multibuffer_view_task;
107pub mod picker_host;
108pub mod picker_source;
109pub mod picker_task;
110pub mod plugin_manager_host;
111pub mod scan_cache;
112// OR.1: the durable, plugin-scoped byte store the `host-services` `store-*`
113// calls act on. One per manifest id, shared across every seam's `Store`.
114pub mod plugin_store;
115pub mod teardown;
116// TR.2b — the `transient-source` guest→host keyed-menu seam. Trio mirroring
117// `picker_*`: the bindgen world, the actor bridge, the registry-builder adapter.
118pub mod transient_host;
119pub mod transient_source;
120pub mod transient_task;
121// TC.4 — the `theme` element-registration seam. Guest imports `register-element`
122// and the host inserts into the SAME registry builtins live in.
123pub mod sign_host;
124pub mod theme_host;
125pub mod trace;
126pub mod trampoline;
127pub mod tree_resource;
128// OR.2: the `host-services.watch` / `unwatch` seam — a debounced directory
129// watch whose batches are addressed to the plugin that armed them.
130pub mod ui_host;
131pub mod wake;
132mod watch_host;
133
134pub use boundary::WitBoundary;
135pub use capability::{
136 CapabilityGrant, FsGrant, GrantOutcome, PreopenSpec, TrustTier, build_wasi_ctx, grant,
137};
138pub use completion_source::WasmCompletionSource;
139pub use completion_task::{CompletionActor, CompletionClient};
140pub use context_source::WasmContextSource;
141pub use context_task::{ContextActor, ContextClient};
142pub use decoration_source::WasmDecorationSource;
143pub use decoration_task::{DecorationActor, DecorationClient};
144pub use manifest::{Capability, CapabilityParseError, ManifestError, PluginManifest, PluginSeam};
145pub use picker_source::WasmPickerSource;
146pub use picker_task::{PickerActor, PickerClient};
147pub use teardown::{PluginTeardown, TeardownRegistries, TeardownReport};
148pub use trace::{
149 Direction, HotGate, PluginTracePushed, PluginTraceRecord, PluginTracer, PluginTracerHandle,
150 TraceLevel, TraceOutcome,
151};
152pub use transient_source::{project_transient_context, spec_from_wit, transient_builder};
153pub use transient_task::{TransientActor, TransientClient};
154/// OC.2: the injected timer the wake seam sleeps on. The lib owns no runtime, so
155/// the caller supplies this (the loader's is backed by `tokio::time::sleep`).
156pub use wake::{Sleeper, SleeperHandle};
157/// The compiled component — the return type of [`PluginHost::compile`],
158/// re-exported so callers (the plugin loader) can name it without a direct
159/// `wasmtime` dependency.
160pub use wasmtime::component::Component;
161
162use std::path::{Path, PathBuf};
163use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
164use std::sync::{Arc, Mutex};
165use std::thread::JoinHandle;
166use std::time::Duration;
167
168use lattice_runtime::EventBus;
169
170use lattice_grammar::SourceLayer;
171use wasmtime::component::{HasSelf, Linker};
172use wasmtime::{Cache, CacheConfig, Config, Engine, Store};
173use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
174
175// Host bindings for the `plugin` lifecycle world, async per the canonical
176// ABI. The generated `Plugin` type carries async `call_activate` /
177// `call_deactivate` and an async `instantiate_async`.
178wasmtime::component::bindgen!({
179 world: "plugin",
180 path: "../lattice-wit/wit",
181 // Generate async `call_activate` / `call_deactivate` + `instantiate_async`
182 // for every export. (wasmtime 46 replaced the old top-level `async: true`
183 // with this per-function form; async is always available on the engine,
184 // so `Config::async_support` is a no-op and intentionally not called.)
185 exports: { default: async },
186 // NB: the `document` resource's host trait + `with`-mapping to
187 // `DocumentResource` land at PH7.3d, not here. bindgen only lets a `with`
188 // entry bind a resource that a *world function signature* references, and
189 // no signature takes a `document` until the picker-source `init(ctx)` seam
190 // (PH7.3d/PH7.4). `use buffer.{buffer-snapshot}` (in `plugin.wit`) emits the
191 // owned record mirror this slice projects into; the resource backing
192 // (`buffer::DocumentResource`) is ready to be `with`-mapped then.
193});
194
195/// Fuel granted for the (trivial) instantiation step, before per-call budgets
196/// take over. Generous so instantiation never trips the fuel trap; a real
197/// plugin's `activate` work is bounded by [`PluginBudget::fuel`] instead.
198const INSTANTIATION_FUEL: u64 = 1_000_000_000;
199
200/// How often the epoch-ticker thread bumps the engine epoch. The epoch
201/// deadline is expressed in ticks, so this is the wall-clock granularity of
202/// the deadline (≈1ms).
203const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(1);
204
205/// Per-call resource budget. Both limits are hard: whichever is hit first
206/// traps the call cleanly.
207#[derive(Debug, Clone, Copy)]
208pub struct PluginBudget {
209 /// Fuel (≈ units of work) a single lifecycle call may consume before it
210 /// traps with [`TrapKind::Fuel`].
211 pub fuel: u64,
212 /// Milliseconds of WALL CLOCK a single call may run before it traps with
213 /// [`TrapKind::Epoch`].
214 ///
215 /// OA.0b made that literal. It used to be enforced by counting deadline-
216 /// callback firings, which equals elapsed milliseconds only while the
217 /// guest executes guest code continuously — one epoch checkpoint crossed
218 /// per [`EPOCH_TICK_INTERVAL`]. A guest that calls host imports in a loop
219 /// is suspended for most of its wall clock and crosses far fewer, so the
220 /// budget stretched by the ratio of host time to guest time: an org agenda
221 /// scan ran 6.8 s against this set to 1_000 and never tripped.
222 ///
223 /// **The new accounting is stricter, and one consequence is worth
224 /// knowing.** Time the OS spends descheduling the thread now counts
225 /// against the call, where firing-count quietly forgave it. That is the
226 /// correct measure for the thing this guards — the editor was stalled
227 /// either way — but if a sync grammar contribution ever false-trips under
228 /// load, the fix is to raise [`Self::grammar`]'s deadline, NOT to go back
229 /// to counting firings. Fuel is the primary bound on that path by design.
230 pub epoch_deadline: u64,
231}
232
233impl Default for PluginBudget {
234 fn default() -> Self {
235 // Generous defaults: a well-behaved `activate` finishes far inside
236 // these. PH7.5 tightens them into CI-gated budgets.
237 //
238 // 5s for the same reason as `event()`: this covers the ASYNC seams
239 // (the scan producer, pickers, media, lifecycle `activate`), every one
240 // of which blocks in host imports whose duration it cannot control, and
241 // since OA.0b that time is charged to the guest. Fuel is the compute
242 // bound; this only has to stop a guest that blocks forever.
243 Self {
244 fuel: 1_000_000_000,
245 epoch_deadline: 5_000,
246 }
247 }
248}
249
250impl PluginBudget {
251 /// The Reflex-class budget for the **synchronous** grammar trampoline
252 /// (PH7.7c; plugin-host.md §7 + audit F1). Grammar `apply` / `parse_args`
253 /// run on the keystroke path (the PH7.7 fork), so — unlike the generous
254 /// lifecycle/async [`default`](Self::default) (~1s epoch) — a plugin
255 /// contribution must not stall the keystroke.
256 ///
257 /// **Fuel is the primary Reflex bound.** A grammar guest runs on the sync
258 /// linker with no async host import, so it cannot *block* (no I/O to await) —
259 /// it can only compute or spin, and both are fuel-bounded. `10M` fuel is
260 /// ~one display frame of compute (Cranelift-compiled), ample for a real
261 /// motion's arithmetic but a hard cap on a runaway loop; that is what keeps a
262 /// plugin motion off the keystroke's critical path.
263 ///
264 /// **Epoch is a jitter-proof wall-clock *backstop*, not the tripwire.** The
265 /// ticker granularity is 1ms ([`EPOCH_TICK_INTERVAL`]); a 2-tick deadline
266 /// false-positives when the OS deschedules the dispatch thread mid-call
267 /// (observed under a criterion warmup's millions of iterations). So the epoch
268 /// is set generously — it must never trip on scheduling jitter, and only
269 /// catch the pathological case fuel somehow misses (near-impossible for a
270 /// sync compute guest). A trap of either kind is caught by
271 /// the trampoline → the contribution is a no-op with a warn
272 /// ([`CommandError::Plugin`](lattice_grammar::CommandError::Plugin)), never a
273 /// hang. Armed before every guest call — distinct from the lifecycle/producer
274 /// budget by design (audit F1).
275 ///
276 /// **50ms was not jitter-proof, so this is 250.** A trivial org
277 /// `:org-clock-in` — read a line, rewrite it, emit an event — overran 50ms
278 /// and trapped whenever the machine was saturated (reproduced with one busy
279 /// loop per core). That is precisely the false-trip this deadline's own
280 /// prose said must not happen, and the fix it prescribed: raise the
281 /// deadline, never go back to counting firings.
282 ///
283 /// 250ms costs nothing real. Fuel remains the actual bound at ~one frame of
284 /// compute, so a runaway still dies on fuel in milliseconds; the epoch only
285 /// has to exceed the worst descheduling window, and 5× the observed failure
286 /// is that with room. The number is also no longer a cliff: since the strike
287 /// count in [`Quarantine::trip`], one overrun costs that call rather than
288 /// the plugin.
289 pub fn grammar() -> Self {
290 Self {
291 fuel: 10_000_000,
292 epoch_deadline: 250,
293 }
294 }
295
296 /// The budget for a plugin **event handler** (`on-event`, PH7.8c; §7 "major-
297 /// mode event handler"). Unlike grammar's Reflex budget, event delivery is
298 /// **off the keystroke path** (async, on the plugin's own actor task), and a
299 /// handler runs on the *async* linker — so it may legitimately `await` a
300 /// capability-gated `host-services` call. A tight sub-frame epoch would
301 /// false-trip such a suspend, so the **epoch is a generous backstop** (~1s,
302 /// the lifecycle default) and **fuel is the primary bound**: `100M` ≈ ~10
303 /// frames of compute, ample for a real hook (recolour a gutter, index a
304 /// symbol) yet a hard cap on a runaway loop. A trap is caught per-delivery by
305 /// the [`EventActor`](crate::event_task::EventActor) → the delivery is
306 /// skipped with a warn, the plugin stays subscribed, other subscribers are
307 /// untouched (§8). The marshalling+dispatch overhead itself is the CI-gated
308 /// `< 250µs p99` row (PH7.8d), distinct from this runaway guard.
309 ///
310 /// **5s, not 1s, and the reason is what the epoch measures.** Since OA.0b
311 /// the deadline counts wall time including the time the guest sits BLOCKED
312 /// in a host import — correct, because a guest looping over imports was
313 /// otherwise unbounded (an agenda scan ran 6.8 s against a 1 s deadline
314 /// without tripping). The cost is that a handler is now charged for host
315 /// work it cannot make faster: org's roam scan opens with one `walk` of the
316 /// corpus, and under a saturated machine that single import took 1106 ms —
317 /// so a healthy plugin trapped, and since a trap kills the instance
318 /// ([`Quarantine::trip`]) the whole index died with it.
319 ///
320 /// Raising it does not weaken what the guard is for. Fuel still caps guest
321 /// COMPUTE at ~10 frames, so a spinning handler dies in milliseconds
322 /// regardless; the epoch's remaining job is bounding a guest that blocks in
323 /// imports forever, and 5 s bounds that just as surely as 1 s. What it buys
324 /// is that legitimate work — which a guest already self-limits to 250 ms
325 /// per delivery — is 20× inside the budget instead of within noise of it.
326 pub fn event() -> Self {
327 Self {
328 fuel: 100_000_000,
329 epoch_deadline: 5_000,
330 }
331 }
332
333 /// The budget for a plugin **decoration producer** (`gutter-decorations`,
334 /// PH7.9). Like the event budget, decoration production is **off the render
335 /// path** (async, on the plugin's actor task, triggered by an edit / scroll /
336 /// diagnostic change) and the producer runs on the async linker (it may
337 /// `await` a `host-services` call — a git-gutter source reading the repo). So
338 /// the **epoch is a generous ~1 s backstop** and **fuel is the primary bound**
339 /// (`100M` ≈ ~10 frames of compute — ample for a real diff/annotate pass, a
340 /// hard cap on a runaway). A trap is caught per-call by the
341 /// [`DecorationActor`](crate::decoration_task::DecorationActor) → the trigger
342 /// yields no decorations and the cached snapshot keeps its prior value (§8, no
343 /// flicker). The §7 `< 50 µs p99` "segment update" gate is on the marshalling
344 /// + dispatch overhead, distinct from this runaway guard.
345 pub fn decoration() -> Self {
346 Self {
347 fuel: 100_000_000,
348 epoch_deadline: 1_000,
349 }
350 }
351
352 /// TC.2: budget for a `context-scopes` produce call. Deliberately the same
353 /// shape as [`Self::decoration`] and for the same reason — both are async
354 /// producers the host drives off the render path, so the guard is against a
355 /// runaway, not against latency. Context is the more expensive of the two
356 /// (a whole-buffer tree-sitter query rather than a per-line walk), and the
357 /// budget is generous enough that a legitimate query on a large file
358 /// finishes well inside it; `context.max-file-lines` is what bounds the
359 /// intended work, this is what bounds the unintended.
360 pub fn context() -> Self {
361 Self {
362 fuel: 100_000_000,
363 epoch_deadline: 1_000,
364 }
365 }
366}
367
368/// Why a lifecycle call trapped. Fuel/epoch are the *expected* runaway-guard
369/// outcomes; `Other` is any genuine wasm trap (unreachable, OOB, a guest
370/// panic).
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum TrapKind {
373 /// The call exhausted its fuel budget (a compute runaway).
374 Fuel,
375 /// The call ran past its epoch (wall-clock) deadline.
376 Epoch,
377 /// CG.4: the user pressed `<C-g>` while the call was running.
378 ///
379 /// Deliberately NOT a crash. Fuel/epoch/other mean the plugin
380 /// misbehaved and should be quarantined; this means the user changed
381 /// their mind, and quarantining a plugin for that would punish it for
382 /// being interruptible.
383 Cancelled,
384 /// Any other wasm trap (unreachable, out-of-bounds, guest panic).
385 Other,
386}
387
388impl TrapKind {
389 /// A short, stable machine label (`"fuel"` / `"epoch"` / `"trap"`) for the
390 /// [`lattice_protocol::Event::PluginCrashed`] payload and structured logs. Distinct from the
391 /// [`Display`](std::fmt::Display) impl's human sentence: subscribers match on
392 /// this without parsing prose, and it never changes with copy edits.
393 pub fn label(self) -> &'static str {
394 match self {
395 TrapKind::Fuel => "fuel",
396 TrapKind::Epoch => "epoch",
397 TrapKind::Cancelled => "cancelled",
398 TrapKind::Other => "trap",
399 }
400 }
401}
402
403impl std::fmt::Display for TrapKind {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 let s = match self {
406 TrapKind::Fuel => "out of fuel",
407 TrapKind::Epoch => "epoch deadline exceeded",
408 TrapKind::Cancelled => "cancelled by the user",
409 TrapKind::Other => "wasm trap",
410 };
411 f.write_str(s)
412 }
413}
414
415/// Typed error surface for the plugin host. No host path panics — every
416/// failure mode is a value here (the four-artefact graceful-error clause).
417/// `anyhow::Error` is wasmtime's error type; each variant carries it as
418/// `#[source]`.
419#[derive(Debug, thiserror::Error)]
420pub enum PluginHostError {
421 /// The wasmtime engine could not be built from the host config.
422 #[error("failed to build the wasmtime engine")]
423 Engine(#[source] anyhow::Error),
424
425 /// The epoch-ticker background thread could not be spawned (the OS refused a
426 /// thread). Surfaced rather than panicked — no host-construction path panics
427 /// (the "every failure mode is a value" invariant).
428 #[error("failed to spawn the epoch-ticker thread")]
429 EpochTicker(#[source] std::io::Error),
430
431 /// The component linker could not be populated with the WASI host
432 /// functions (PH7.2). A host-setup failure, surfaced rather than panicked.
433 #[error("failed to add WASI to the plugin component linker")]
434 Linker(#[source] anyhow::Error),
435
436 /// The on-disk module cache could not be initialised (bad directory,
437 /// I/O error). A caching failure must never fail the host — callers may
438 /// fall back to an uncached host — so this is surfaced, never panicked.
439 #[error("failed to initialise the plugin module cache")]
440 Cache(#[source] anyhow::Error),
441
442 /// Component bytes were malformed or failed AOT compilation.
443 #[error("failed to compile the plugin component")]
444 Compile(#[source] anyhow::Error),
445
446 /// The component compiled but could not be instantiated (a missing
447 /// import, or fuel exhausted during the start function).
448 #[error("failed to instantiate the plugin component")]
449 Instantiate(#[source] anyhow::Error),
450
451 /// A grammar plugin declared a malformed contribution spec (PH7.7c): an
452 /// `arg-spec` / `latency-class` / `surface-form` that could not cross the
453 /// boundary. Fails registration loudly (the spec is structurally wrong),
454 /// unlike a *runtime* `apply` failure, which degrades gracefully to a no-op.
455 #[error("plugin grammar spec is malformed: {0}")]
456 GrammarSpec(String),
457
458 /// A lifecycle export trapped. Fuel/epoch exhaustion and genuine wasm
459 /// traps all land here as a value — the call is a no-op with a typed
460 /// error and the host stays live.
461 #[error("plugin lifecycle export `{func}` trapped: {kind}")]
462 Trap {
463 /// The export that trapped (`"activate"` / `"deactivate"`).
464 func: &'static str,
465 /// What caused the trap (fuel / epoch / other).
466 kind: TrapKind,
467 /// The underlying wasmtime trap.
468 #[source]
469 source: anyhow::Error,
470 },
471
472 /// A guest call was routed to a per-plugin actor task
473 /// ([`picker_task`]) whose channel is closed — the task has ended and
474 /// dropped its `Store` (teardown, or a fatal error unwound the loop). The
475 /// call is a no-op with a typed error; the *caller* stays live. This is the
476 /// bridge's graceful surface for "the plugin is gone", distinct from
477 /// [`Trap`](Self::Trap) (the plugin ran but its call failed).
478 #[error("plugin actor for `{func}` is no longer running")]
479 PluginGone {
480 /// The export the caller was trying to reach (`"spec"` / `"init"` /
481 /// `"accept"`).
482 func: &'static str,
483 },
484
485 /// The plugin instance is quarantined (PH7.12): a prior lifecycle / callback
486 /// call trapped, tripping crash-quarantine, so this call short-circuits
487 /// *before* re-entering the dead `Store`. Distinct from [`Trap`](Self::Trap)
488 /// (the plugin ran and its call failed) and [`PluginGone`](Self::PluginGone)
489 /// (the actor's channel is closed): the actor is still alive, but the instance
490 /// is dead-until-reinstantiation. The `PluginCrashed` event already fired on
491 /// the first trap; this variant is the quiet no-op every subsequent call
492 /// returns until a reload (PH7.12b) mints a fresh instance.
493 #[error("plugin is quarantined; `{func}` short-circuited after an earlier crash")]
494 Quarantined {
495 /// The export the caller was trying to reach.
496 func: &'static str,
497 },
498
499 /// A value crossing the picker boundary could not be converted between its
500 /// WIT mirror and the native type — a malformed record the guest returned
501 /// (e.g. a non-UTF-8 path, §4.4), or a native value with no WIT
502 /// representation. Carries the boundary layer's message. Never lossy: a
503 /// conversion that cannot be represented is this typed error, not a silent
504 /// drop.
505 #[error("picker boundary conversion failed: {0}")]
506 Boundary(String),
507}
508
509/// Re-arm a `Store`'s fuel + epoch budget before a call, so each call gets a
510/// fresh allowance rather than sharing a running total. Shared by the
511/// [`LoadedPlugin`] lifecycle path and the [`picker_task`] actor loop.
512pub(crate) fn arm_store(
513 store: &mut Store<PluginState>,
514 budget: PluginBudget,
515) -> Result<(), PluginHostError> {
516 store
517 .set_fuel(budget.fuel)
518 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
519
520 // CG.4: refresh the token this call may be cancelled by, ONCE, here.
521 // The registry is behind a mutex; the token is a plain atomic. Taking
522 // the lock per call and polling the atomic per tick is the split
523 // `foreground_cancel`'s own docs prescribe.
524 //
525 // Snapshotting also fixes the semantics: a call is cancelled by the
526 // operation armed when it STARTED. A later `arm()` (a different user
527 // action) must not reach back and kill a call already in flight.
528 let state = store.data_mut();
529 state.epoch_spent = 0;
530 state.epoch_started = Some(std::time::Instant::now());
531 state.cancel_token = state.cancel.as_ref().and_then(|c| c.current_token());
532
533 // The deadline is re-armed ONE TICK AT A TIME so the callback runs
534 // every ~1ms and can poll the token. That moves enforcement of
535 // `budget.epoch_deadline` in here — wasmtime counts to 1 repeatedly,
536 // we count the total. Same ceiling, checked by us.
537 store.set_epoch_deadline(1);
538 store.epoch_deadline_callback(move |mut ctx| {
539 let state = ctx.data_mut();
540 if let Some(token) = &state.cancel_token
541 && token.is_cancelled()
542 {
543 // Traps the guest. `classify_trap` maps this back to
544 // `TrapKind::Cancelled` so the caller can tell "the user
545 // stopped it" from "the plugin ran away".
546 return Err(wasmtime::Error::msg(CANCELLED_TRAP));
547 }
548 state.epoch_spent += 1;
549 // OA.0b: measured against the clock, NOT against the number of times
550 // this callback has run. The two agree only while the guest is
551 // executing guest code continuously — one checkpoint crossed per
552 // tick, one firing per tick. A guest that calls host imports in a
553 // loop is suspended for most of its wall clock, and wasmtime reports
554 // an exceeded deadline ONCE on re-entry however many ticks passed. So
555 // the old firing count ran arbitrarily slower than real time, and the
556 // budget it enforced was not a time budget at all: an org agenda scan
557 // ran 6.8 s against a 1 s deadline without ever tripping it.
558 if epoch_budget_exceeded(state.epoch_started, budget.epoch_deadline) {
559 return Err(wasmtime::Error::msg(EPOCH_TRAP));
560 }
561 Ok(wasmtime::UpdateDeadline::Continue(1))
562 });
563 Ok(())
564}
565
566/// Has this call outrun `epoch_deadline` milliseconds since `started`?
567///
568/// Split out so the accounting is testable without a guest: the bug OA.0b
569/// fixed lived entirely in this decision, and reproducing it end to end needs
570/// a guest that blocks in a host import, which no fixture provides.
571///
572/// `None` (nothing armed) never trips — a call with no start time recorded is
573/// not one we can time, and trapping it would be worse than letting the fuel
574/// bound catch it.
575fn epoch_budget_exceeded(started: Option<std::time::Instant>, deadline_ms: u64) -> bool {
576 let Some(started) = started else {
577 return false;
578 };
579 started.elapsed() >= std::time::Duration::from_millis(deadline_ms)
580}
581
582/// Marker in the trap message for a call stopped by `<C-g>` (CG.4).
583///
584/// A string rather than a typed error because it has to survive
585/// `wasmtime::Error`'s round-trip through the guest trap — the same
586/// reason `classify_trap` matches on `wasmtime::Trap` variants rather
587/// than carrying host types across.
588pub(crate) const CANCELLED_TRAP: &str = "lattice: plugin call cancelled by the user";
589
590/// Marker for a call that outran `budget.epoch_deadline` (CG.4 moved
591/// this enforcement out of wasmtime's countdown and into our callback).
592pub(crate) const EPOCH_TRAP: &str = "lattice: plugin call exceeded its time budget";
593
594/// Classify a wasmtime call error into a [`TrapKind`] for reporting.
595pub(crate) fn classify_trap(err: &wasmtime::Error) -> TrapKind {
596 // CG.4: the epoch callback raises these two, so they arrive as plain
597 // errors rather than `wasmtime::Trap` variants — checked FIRST,
598 // because a callback-raised error may also carry a Trap in its
599 // chain and `Interrupt` would otherwise swallow a cancellation.
600 // `{:#}` renders the WHOLE anyhow chain. `to_string()` shows only the
601 // outermost context, and wasmtime wraps a callback-raised error in
602 // trap context — so the marker is never in the top line.
603 let text = format!("{err:#}");
604 if text.contains(CANCELLED_TRAP) {
605 return TrapKind::Cancelled;
606 }
607 if text.contains(EPOCH_TRAP) {
608 return TrapKind::Epoch;
609 }
610 match err.downcast_ref::<wasmtime::Trap>() {
611 Some(wasmtime::Trap::OutOfFuel) => TrapKind::Fuel,
612 Some(wasmtime::Trap::Interrupt) => TrapKind::Epoch,
613 _ => TrapKind::Other,
614 }
615}
616
617/// Per-instance crash-quarantine, shared by every repeated-call plugin surface
618/// (the four actors — event / picker / decoration / completion — and the
619/// grammar apply path). A component trap taints its instance irrecoverably:
620/// wasmtime offers no rollback, so a `Store` that trapped once will keep
621/// failing. The first trap trips quarantine here — [`lattice_protocol::Event::PluginCrashed`]
622/// fires exactly once on the bus, an `info!` records the death — and every
623/// later call short-circuits (via [`is_tripped`](Self::is_tripped)) *before*
624/// re-entering the dead `Store`. This turns today's "tainted instance keeps
625/// re-failing, each logged" behaviour into a clean one-shot crash signal plus a
626/// silent no-op. Reload (PH7.12b) mints a fresh instance with a fresh,
627/// untripped `Quarantine`.
628///
629/// Isolation is the guarantee: tripping touches only this instance's flag and
630/// publishes one event; the actor, bus, every other plugin, and the editor are
631/// untouched.
632pub(crate) struct Quarantine {
633 plugin: PluginId,
634 bus: Arc<EventBus>,
635 tripped: bool,
636}
637
638impl Quarantine {
639 pub(crate) fn new(plugin: PluginId, bus: Arc<EventBus>) -> Self {
640 Self {
641 plugin,
642 bus,
643 tripped: false,
644 }
645 }
646
647 /// Whether a prior trap has quarantined this instance. Callers short-circuit
648 /// when this is `true` rather than re-entering the dead `Store`.
649 pub(crate) fn is_tripped(&self) -> bool {
650 self.tripped
651 }
652
653 /// Trip quarantine on a trap. Idempotent: the first call publishes exactly
654 /// one [`lattice_protocol::Event::PluginCrashed`] and logs; a second call (a race, or a caller
655 /// that forgot to check [`is_tripped`](Self::is_tripped)) is a silent no-op —
656 /// the instance is already known dead and the event has already fired.
657 pub(crate) fn trip(&mut self, func: &'static str, kind: TrapKind) {
658 // CG.4: `<C-g>` is not a crash. Fuel / epoch / other mean the
659 // plugin misbehaved and the instance is dead; cancellation means
660 // the USER changed their mind, and the instance is perfectly
661 // healthy. Quarantining here would punish a plugin for being
662 // interruptible — press `<C-g>` on a slow picker and the plugin
663 // would be dead until reload.
664 if kind == TrapKind::Cancelled {
665 tracing::debug!(
666 plugin = self.plugin.0,
667 func,
668 "plugin call cancelled by the user; instance stays live"
669 );
670 return;
671 }
672 // Epoch is NOT softer than fuel here, and the evidence is worth
673 // keeping. An epoch overrun does say something weaker about the guest —
674 // it ran long on the wall clock, which under load happens to a healthy
675 // plugin — so a strike count that tolerated one overrun was tried.
676 //
677 // It does not work, because a wasm trap does not unwind: Rust
678 // destructors never run, so a trap taken inside a
679 // `RefCell::borrow_mut()` scope leaves that cell borrowed for the life
680 // of the instance and every later call panics on it. Letting a second
681 // delivery through produced exactly that — an `Epoch` trap followed
682 // immediately by a `kind=Other` guest trap on the next event.
683 //
684 // So the instance really is dead however it trapped, and the only
685 // useful lever is not trapping: see the budgets on `PluginBudget`.
686 if self.tripped {
687 return;
688 }
689 self.tripped = true;
690 // info!, not warn!: a plugin dying is a one-shot, user-actionable event
691 // (the notification / plugin-manager surface acts on it), not a
692 // per-keystroke diagnostic. Later short-circuits are silent.
693 tracing::info!(
694 plugin = self.plugin.0,
695 func,
696 kind = kind.label(),
697 "plugin quarantined after trap"
698 );
699 self.bus.publish(lattice_protocol::Event::PluginCrashed {
700 plugin: self.plugin.0,
701 func: func.to_string(),
702 kind: kind.label().to_string(),
703 });
704 }
705}
706
707/// Map a raw wasmtime call result for a repeated-call actor export: on a trap,
708/// trip `quarantine` (fires `PluginCrashed` once) and return a typed [`Trap`];
709/// otherwise pass the value through. The paired short-circuit —
710/// `if quarantine.is_tripped() { return Err(Quarantined { func }) }` at the top
711/// of each export — keeps a dead instance from ever re-entering its `Store`.
712/// Shared by the picker / decoration / completion actors (the event actor's
713/// `deliver` is `()`-returning, so it trips inline).
714///
715/// [`Trap`]: PluginHostError::Trap
716pub(crate) fn trip_and_map<T>(
717 quarantine: &mut Quarantine,
718 func: &'static str,
719 result: wasmtime::Result<T>,
720) -> Result<T, PluginHostError> {
721 match result {
722 Ok(v) => Ok(v),
723 Err(source) => {
724 let kind = classify_trap(&source);
725 quarantine.trip(func, kind);
726 Err(PluginHostError::Trap {
727 func,
728 kind,
729 source: source.into(),
730 })
731 }
732 }
733}
734
735/// PO.2 — the traced [`trip_and_map`]: map the guest result AND emit a boundary
736/// [`PluginTraceRecord`](crate::trace::PluginTraceRecord) into `tracer` (when a
737/// seam actor was wired with one). A successful call records at `Debug` — dropped
738/// by the default `Info` gate, so there is no per-call noise unless the plugin is
739/// raised to `debug`/`trace`; a trap records at `Error`, always kept. The seams
740/// are async (off the actor thread), and emission is a cheap gated push, so this
741/// stays off the editor hot path (design §4). `fuel_delta` is `0` for now — wall
742/// time is the primary signal; fuel accounting is a later refinement.
743pub(crate) fn trip_and_map_traced<T>(
744 tracer: Option<&crate::trace::PluginTracerHandle>,
745 plugin: u32,
746 seam: PluginSeam,
747 quarantine: &mut Quarantine,
748 func: &'static str,
749 start: std::time::Instant,
750 result: wasmtime::Result<T>,
751) -> Result<T, PluginHostError> {
752 let micros = start.elapsed().as_micros() as u64;
753 let mapped = trip_and_map(quarantine, func, result);
754 if let Some(tracer) = tracer {
755 use crate::trace::{Direction, PluginTraceRecord, TraceLevel, TraceOutcome};
756 let (level, outcome) = match &mapped {
757 Ok(_) => (
758 TraceLevel::Debug,
759 TraceOutcome::Ok {
760 micros,
761 fuel_delta: 0,
762 },
763 ),
764 Err(PluginHostError::Trap { kind, .. }) => (
765 TraceLevel::Error,
766 TraceOutcome::Trap {
767 kind: kind.label().to_string(),
768 func: func.to_string(),
769 },
770 ),
771 // A non-trap host error (e.g. a linker/encode failure) — surface it
772 // at Warn without a trap classification.
773 Err(_) => (
774 TraceLevel::Warn,
775 TraceOutcome::Ok {
776 micros,
777 fuel_delta: 0,
778 },
779 ),
780 };
781 tracer.trace(PluginTraceRecord {
782 plugin,
783 seam,
784 direction: Direction::GuestExport,
785 call: std::borrow::Cow::Borrowed(func),
786 level,
787 outcome,
788 detail: None,
789 });
790 }
791 mapped
792}
793
794#[cfg(test)]
795mod epoch_budget_tests {
796 #![allow(clippy::unwrap_used)]
797 use super::*;
798 use std::time::{Duration, Instant};
799
800 /// The property the old accounting did NOT have. It counted callback
801 /// firings, so the budget was spent at the rate the guest happened to
802 /// cross epoch checkpoints; a guest suspended in host imports crossed
803 /// them far more slowly than the clock ran. This is measured against the
804 /// clock, so it holds whatever the guest was doing in between.
805 #[test]
806 fn the_budget_is_spent_by_the_clock() {
807 let armed = Instant::now() - Duration::from_millis(1_500);
808 assert!(
809 epoch_budget_exceeded(Some(armed), 1_000),
810 "1.5s elapsed against a 1000ms budget must trip, regardless of \
811 how many times the callback happened to run"
812 );
813 assert!(
814 !epoch_budget_exceeded(Some(Instant::now()), 1_000),
815 "a call that just started has spent nothing"
816 );
817 }
818
819 /// The boundary is inclusive: a call that has used exactly its budget has
820 /// used it up. Stated because `>` vs `>=` here is the difference between
821 /// a budget of 0 meaning "never run" and meaning "unbounded".
822 #[test]
823 fn the_boundary_is_inclusive_so_a_zero_budget_is_not_unbounded() {
824 let armed = Instant::now() - Duration::from_millis(50);
825 assert!(epoch_budget_exceeded(Some(armed), 50));
826 assert!(
827 epoch_budget_exceeded(Some(Instant::now()), 0),
828 "a zero budget trips immediately rather than never"
829 );
830 }
831
832 /// Nothing armed cannot be timed, and trapping it would be worse than
833 /// leaving it to the fuel bound — which is a hard cap either way.
834 #[test]
835 fn an_unarmed_call_is_never_tripped_by_the_clock() {
836 assert!(!epoch_budget_exceeded(None, 0));
837 assert!(!epoch_budget_exceeded(None, 1_000));
838 }
839}
840
841#[cfg(test)]
842mod trip_and_map_traced_tests {
843 #![allow(clippy::unwrap_used)]
844 use std::sync::Arc;
845
846 use super::*;
847 use crate::trace::{PluginTracer, PluginTracerHandle, TraceLevel, TraceOutcome};
848
849 fn tracer() -> PluginTracerHandle {
850 // Trace-level default so per-call `Debug` records are kept in the test.
851 Arc::new(PluginTracer::new(TraceLevel::Trace, 16))
852 }
853
854 fn quarantine() -> Quarantine {
855 Quarantine::new(PluginId(7), Arc::new(lattice_runtime::EventBus::new()))
856 }
857
858 #[test]
859 fn a_successful_call_records_a_debug_ok() {
860 let t = tracer();
861 let mut q = quarantine();
862 let out: Result<u32, _> = trip_and_map_traced(
863 Some(&t),
864 7,
865 PluginSeam::Grammar,
866 &mut q,
867 "apply-motion",
868 std::time::Instant::now(),
869 Ok(42u32),
870 );
871 assert_eq!(out.unwrap(), 42);
872 let recs = t.snapshot_plugin(7);
873 assert_eq!(recs.len(), 1);
874 assert_eq!(recs[0].call, "apply-motion");
875 assert_eq!(recs[0].level, TraceLevel::Debug);
876 assert!(matches!(recs[0].outcome, TraceOutcome::Ok { .. }));
877 assert!(matches!(
878 recs[0].direction,
879 crate::trace::Direction::GuestExport
880 ));
881 }
882
883 #[test]
884 fn a_trap_records_an_error_trap_and_trips_quarantine() {
885 let t = tracer();
886 let mut q = quarantine();
887 let out: Result<u32, _> = trip_and_map_traced(
888 Some(&t),
889 7,
890 PluginSeam::PickerSource,
891 &mut q,
892 "init",
893 std::time::Instant::now(),
894 Err(wasmtime::Error::msg("boom")),
895 );
896 assert!(out.is_err());
897 assert!(q.is_tripped(), "a trap trips the quarantine");
898 let recs = t.snapshot_plugin(7);
899 assert_eq!(recs[0].level, TraceLevel::Error);
900 assert!(
901 matches!(&recs[0].outcome, TraceOutcome::Trap { func, kind } if func == "init" && kind == "trap"),
902 "a generic error classifies as a `trap`-kind Trap outcome"
903 );
904 }
905
906 /// A trapped instance is dead however it trapped — including on epoch.
907 ///
908 /// Worth pinning, because the opposite is intuitive and was tried: an epoch
909 /// overrun says only "this ran long", which under load happens to a healthy
910 /// plugin, so tolerating one looks obviously right. It is not. A wasm trap
911 /// does not unwind, so Rust destructors never run and a trap taken inside a
912 /// `RefCell::borrow_mut()` leaves the cell borrowed for the life of the
913 /// instance; the next call panics on it. Letting a second delivery through
914 /// produced an `Epoch` trap followed at once by a `kind=Other` guest trap.
915 ///
916 /// The lever is not trapping — see the budgets on `PluginBudget`.
917 #[test]
918 fn an_epoch_overrun_quarantines_like_any_other_trap() {
919 let mut q = quarantine();
920 q.trip("on-event", TrapKind::Epoch);
921 assert!(q.is_tripped());
922 }
923
924 /// Fuel is unchanged: the guest computed a runaway, and one is enough.
925 #[test]
926 fn a_fuel_overrun_still_quarantines_immediately() {
927 let mut q = quarantine();
928 q.trip("on-event", TrapKind::Fuel);
929 assert!(q.is_tripped());
930 }
931
932 /// So is a real wasm trap — unreachable, out-of-bounds, a guest panic.
933 /// Those say the instance's own invariants are gone.
934 #[test]
935 fn a_genuine_trap_still_quarantines_immediately() {
936 let mut q = quarantine();
937 q.trip("init", TrapKind::Other);
938 assert!(q.is_tripped());
939 }
940
941 #[test]
942 fn no_tracer_is_a_no_op_but_still_maps() {
943 let mut q = quarantine();
944 // Below-the-gate + no tracer: the mapping still happens, nothing recorded.
945 let out: Result<u32, _> = trip_and_map_traced(
946 None,
947 7,
948 PluginSeam::Grammar,
949 &mut q,
950 "apply-motion",
951 std::time::Instant::now(),
952 Ok(1u32),
953 );
954 assert_eq!(out.unwrap(), 1);
955 }
956}
957
958/// A background thread that bumps the engine epoch on a fixed interval, so
959/// per-store epoch deadlines actually fire. Stopped and joined on drop.
960struct EpochTicker {
961 stop: Arc<AtomicBool>,
962 handle: Option<JoinHandle<()>>,
963}
964
965impl EpochTicker {
966 fn spawn(engine: &Engine, interval: Duration) -> Result<Self, PluginHostError> {
967 let stop = Arc::new(AtomicBool::new(false));
968 let engine = engine.clone(); // `Engine` is a cheap Arc-backed handle.
969 let stop_flag = Arc::clone(&stop);
970 let handle = std::thread::Builder::new()
971 .name("lattice-plugin-epoch".into())
972 .spawn(move || {
973 while !stop_flag.load(Ordering::Relaxed) {
974 std::thread::sleep(interval);
975 engine.increment_epoch();
976 }
977 })
978 .map_err(PluginHostError::EpochTicker)?;
979 Ok(Self {
980 stop,
981 handle: Some(handle),
982 })
983 }
984}
985
986impl Drop for EpochTicker {
987 fn drop(&mut self) {
988 self.stop.store(true, Ordering::Relaxed);
989 if let Some(handle) = self.handle.take() {
990 // The ticker only sleeps + bumps an epoch; the join is prompt.
991 let _ = handle.join();
992 }
993 }
994}
995
996/// Per-`Store` host state. PH7.2 gives it the plugin's WASI view (built from
997/// its [`CapabilityGrant`], §6) and the resource table WASI resources live in.
998/// A plugin's `Store` reaches exactly the filesystem its grant preopened; the
999/// [`WasiView`] impl is what wires the WASI host functions to this state.
1000///
1001/// Later slices grow this with the plugin's document-handle / callback
1002/// resource tables (PH7.3) — they share this same `ResourceTable`.
1003struct PluginState {
1004 /// The scoped WASI context (granted filesystem preopens + nothing else).
1005 wasi: WasiCtx,
1006 /// The resource table WASI (and, at PH7.3d, the `document` handle)
1007 /// resources live in.
1008 table: ResourceTable,
1009 /// The plugin's effective capability grant (PH7.2). Carried in the `Store`
1010 /// state so guest→host `host-services` calls (PH7.4b) can enforce it: those
1011 /// run host-side with full host authority, so the grant — not the WASI
1012 /// sandbox — is what bounds them.
1013 grant: CapabilityGrant,
1014 /// Grammar contributions the guest declares through the `grammar` register
1015 /// API during `register-grammar` (PH7.7b). The `grammar::Host` impl below
1016 /// records into it; the host drains it after the registration export returns
1017 /// and builds native `*Spec`s with trampoline `apply`s (PH7.7c). Empty for a
1018 /// plugin that registers no grammar (picker/completion plugins, the scaffold).
1019 grammar_contributions: grammar_host::GrammarContributions,
1020 /// Event subscriptions the guest declares through the `events` `subscribe`
1021 /// API during `register-events` (PH7.8b). The `events::Host` impl below
1022 /// records into it; the host drains it after the registration export returns
1023 /// and wires each subscription to the native `EventBus` (PH7.8c). Empty for a
1024 /// plugin that observes no events.
1025 event_subscriptions: events_host::EventContributions,
1026 /// The publish-side handle for the `register-event` / `emit-event`
1027 /// host-services (PH7.8b.2) — the plugin's identity (for the `plugin:<id>`
1028 /// provenance) plus the bus it emits onto. `Some` on the two paths that are
1029 /// handed a bus: [`PluginHost::spawn_event_plugin`] (PH7.8b.2) and
1030 /// [`PluginHost::instantiate_grammar_plugin`] (OC.1 — a grammar action
1031 /// bridging a chord to the plugin's own async side). `None` elsewhere, in
1032 /// which case an `emit-event` is a warn + drop (the honest "no bus wired
1033 /// here" degradation).
1034 event_emit: Option<EventEmitCtx>,
1035 /// The `ConfigRegistry` a config plugin registers options into / reads via the
1036 /// `config` seam (PH7.10). `Some` only for a plugin spawned onto a registry
1037 /// ([`PluginHost::spawn_config_plugin`]); `None` otherwise, in which case a
1038 /// `register-option` returns `false` and `get-option` returns `none` (the
1039 /// honest "no registry wired" degradation — the host isn't boot-wired yet).
1040 config_registry: Option<Arc<lattice_config::ConfigRegistry>>,
1041 /// TC.4: the theme registry a `theme` plugin's `register-element` inserts
1042 /// into. Wired before `register-theme-elements` runs; `None` for every
1043 /// other world (the call then logs and registers nothing).
1044 theme_registry: Option<lattice_theme::ThemeRegistryHandle>,
1045 /// TC.4: namespaced element names this plugin registered — the teardown
1046 /// tokens, mirroring `config_contributions`.
1047 theme_contributions: Vec<String>,
1048 /// SG.3a: the sign registry a `signs` plugin's `define-sign` writes into.
1049 /// Wired before `register-signs` runs; `None` for every other world (the
1050 /// call then logs and defines nothing).
1051 sign_registry: Option<lattice_mode::SignRegistryHandle>,
1052 /// SG.3a: namespaced sign names this plugin declared — the teardown
1053 /// tokens, mirroring `theme_contributions`.
1054 sign_contributions: Vec<String>,
1055 /// CR.4: section specs this plugin declared through
1056 /// `dashboard.register-section`, drained by `spawn_dashboard_sections`
1057 /// after the export returns. The host then instantiates one live guest
1058 /// per spec — a section is a function, not data.
1059 dashboard_contributions: Vec<dashboard_host::DashboardSectionSpec>,
1060 /// CR.3: topics this plugin declared through `help.register-topic`,
1061 /// drained by `spawn_help_plugin` after the export returns. Plain data —
1062 /// the loader turns them into `HelpTopic`s, so this crate never depends
1063 /// on `lattice-help`.
1064 help_contributions: Vec<help_host::HelpTopicSpec>,
1065 /// LG.3c: languages the guest declared, drained by the loader.
1066 language_contributions: Vec<language_host::LanguageSpec>,
1067 /// The plugin's manifest id (e.g. `"auto-pair"`). Set by every spawn/
1068 /// instantiate path from the manifest. Used to **auto-namespace** the
1069 /// plugin's config options — a `register-option("style")` registers
1070 /// `auto-pair.style`, and `get`/`set-option` resolve the plugin's own
1071 /// namespace first (falling back to the raw name so core options stay
1072 /// readable). `None` in the minimal test constructor (no prefixing then).
1073 plugin_name: Option<String>,
1074 /// Names of options this plugin registered via `register-option` (PH7.10),
1075 /// recorded so the host can report them after `register-options` returns (and
1076 /// as the teardown seam PH7.12 will unregister). Empty for a non-config plugin.
1077 config_contributions: Vec<String>,
1078 /// Mode declarations the guest makes through `register-mode` during
1079 /// `register-modes` (PH7.11a). The `modes::Host` impl records into it; the
1080 /// host drains it after the registration export returns and registers each
1081 /// into the `ModeRegistry` (`spawn_mode_plugin`). Empty for a non-mode plugin.
1082 mode_contributions: mode_host::ModeContributions,
1083 /// The keymap handle + command-registry snapshot a keymap plugin binds user
1084 /// keybindings against via the `keymap` seam (PL8.D.1). `Some` only for a
1085 /// plugin spawned via [`PluginHost::spawn_keymap_plugin`]; `None` otherwise,
1086 /// in which case a `register-binding` returns `false` (the honest "no keymap
1087 /// wired" degradation).
1088 keymap_ctx: Option<keymap_host::KeymapBindCtx>,
1089 /// The user keybindings this plugin bound via `register-binding` (PL8.D.1),
1090 /// recorded as teardown tokens so the loader unbinds the `KeymapLayer::User`
1091 /// entries on unload (PL8.D.2). Empty for a non-keymap plugin.
1092 keymap_contributions: Vec<keymap_host::KeymapBindingToken>,
1093 /// PO.5: the guest `logging` seam's routing target — the plugin id + tracer.
1094 /// `Some` once an async instantiate/spawn path stamps it ([`LogCtx`]); `None`
1095 /// for the sync grammar guest and any pre-tracer path (a `log` debug-drops).
1096 log_ctx: Option<LogCtx>,
1097 /// PR.6: what the guest `project` seam resolves through. `None` in a
1098 /// harness that wired no resolver; the seam then answers `none`.
1099 project: Option<ProjectCtx>,
1100 /// OA.23: resolves a composed line to the file it came from. `None` when
1101 /// no multibuffer owner wired one, which answers `none` rather than
1102 /// pretending.
1103 excerpt_source: Option<lattice_core::ExcerptSourceResolverHandle>,
1104 /// OA.27: what a provider view is currently showing. `None` when no view
1105 /// owner wired one, which answers an empty list rather than pretending.
1106 view_args: Option<lattice_core::ViewArgsResolverHandle>,
1107 /// OA.30: the counter `refresh-decorations` bumps. `None` in a harness that
1108 /// wired no editor, where the call is a no-op.
1109 decoration_epoch: Option<lattice_mode::DecorationEpochHandle>,
1110 /// CD.6b: the buffers `clamp-position` measures. `None` in a harness that
1111 /// wired no editor, where every buffer reads as absent.
1112 buffers: Option<lattice_mode::BufferStoreHandle>,
1113 /// PH7.8c: events emitted while `register-events` is still running, held
1114 /// until this plugin's subscriptions are on the bus.
1115 ///
1116 /// `Some(_)` only inside that window; `None` — publish straight through —
1117 /// everywhere else, including every later `on-event`. See
1118 /// [`crate::event_task`]'s flush for why the window exists.
1119 deferred_events: Option<Vec<(String, Vec<u8>)>>,
1120 /// CG.4: the foreground-cancel registry, stamped once per store.
1121 /// `arm_store` takes the lock once per guest call to refresh
1122 /// [`Self::cancel_token`]; the epoch callback never touches it.
1123 cancel: Option<lattice_mode::ForegroundCancelHandle>,
1124 /// CG.4: the token the epoch callback polls — a plain atomic, read
1125 /// once per millisecond inside a running guest call. Refreshed by
1126 /// `arm_store`, so each call is cancelled by the operation that was
1127 /// armed when it STARTED, not by one armed after it finished.
1128 cancel_token: Option<lattice_protocol::CancellationToken>,
1129 /// CG.4: how many times the deadline callback has fired this call.
1130 ///
1131 /// Diagnostic only since OA.0b. It used to BE the time budget — the
1132 /// callback counted its own firings and trapped at
1133 /// `budget.epoch_deadline` — which silently assumed the guest crosses
1134 /// an epoch checkpoint every tick. A guest that spends its wall clock
1135 /// inside HOST imports does not: wasmtime notices the deadline once on
1136 /// re-entry however many ticks elapsed, so the counter undercounts by
1137 /// exactly the ratio of host time to guest time. See `epoch_started`.
1138 epoch_spent: u64,
1139 /// OA.0b: when the current call was armed. The time budget is enforced
1140 /// against this, so `PluginBudget::epoch_deadline`'s documented unit —
1141 /// milliseconds — is what it actually means, for every guest rather
1142 /// than only for a compute-bound one. Reset per call by `arm_store`.
1143 epoch_started: Option<std::time::Instant>,
1144 /// OC.3 / ML.6: what the `ui` seam's modeline calls act on — the element
1145 /// registry plus the bus content updates publish onto. `Some` on the async
1146 /// spawn paths that are handed a modeline; `None` on the sync grammar store.
1147 ///
1148 /// That `None` is the guarantee, not an oversight. The plan for OC.3 said
1149 /// the seam would be "wired on the async linker only", which does not
1150 /// survive the Component Model — a plugin's import set is fixed for the
1151 /// whole component and the same artefact instantiates against the grammar
1152 /// linker too, so an import missing there fails the WHOLE plugin (TC.6 /
1153 /// CR.3 / LG.3c / OM.11, and org has been broken exactly this way once).
1154 /// So `ui` is linked on both and the modeline is kept off the keystroke path
1155 /// here instead, where it is testable.
1156 ui: Option<ui_host::UiCtx>,
1157 /// OC.2: the plugin's armed periodic wakes. `Some` only on a store whose
1158 /// actor can fire them — [`PluginHost::spawn_event_plugin`], and only when a
1159 /// [`SleeperHandle`] was installed. `None` everywhere else, in which case
1160 /// `wake-every` answers `0` and logs; a guest that treats a `0` as armed
1161 /// simply never hears back, which is a visible nothing rather than a
1162 /// plausible wrong answer.
1163 ///
1164 /// Notably `None` on the sync grammar store: `events` is not on the grammar
1165 /// linker, so the seam is unreachable from the keystroke path structurally,
1166 /// and this field is the second, redundant answer to the same question.
1167 wake: Option<wake::WakeCtx>,
1168 /// OR.1: the plugin's durable byte store, shared with every other seam
1169 /// instance of the SAME manifest id. `None` for a store built without a
1170 /// manifest name (the degenerate `instantiate` path and internal harnesses)
1171 /// — there is no id to scope a store to, so `store-get` answers `none` and
1172 /// `store-put` says why.
1173 ///
1174 /// Stamped for every store here rather than per-spawn-path, on the
1175 /// `project` / `ui` reasoning: the handle needs no plugin id allocation and
1176 /// nothing to wait for, so no path can forget it. Forgetting it is exactly
1177 /// the failure this seam exists to prevent — a picker seam holding a store
1178 /// the grammar seam cannot see is the drift, not a degradation of it.
1179 store: Option<plugin_store::PluginStoreHandle>,
1180 /// OR.5b: the picker sources this guest declared during
1181 /// `register-picker-sources`. Drained by `spawn_picker_source` after the
1182 /// export returns — the `grammar_contributions` shape.
1183 picker_contributions: picker_host::PickerContributions,
1184 /// MV.1: the views a guest declared through `register-multibuffer-view`.
1185 multibuffer_view_contributions: multibuffer_view_host::MultibufferViewContributions,
1186 /// OR.2: the directory watches this guest armed, keyed by the path it named.
1187 ///
1188 /// **On the `Store`, not in a host-side registry**, and that is the whole
1189 /// teardown story: a watch's lifetime is exactly this plugin instance's, so
1190 /// dropping the `Store` — on unload, on quarantine, on the event actor's
1191 /// channel closing — stops every watch with no bookkeeping anyone can
1192 /// forget to write. `unwatch` is then a `remove` on this map.
1193 watches: std::collections::HashMap<PathBuf, watch_host::Watch>,
1194 /// PM.7: plugins this guest declared via `plugin-manager.require` during
1195 /// `register-plugins`. Recorded here, drained by
1196 /// [`PluginHost::spawn_plugin_manager_plugin`] after the export returns —
1197 /// the host resolves/builds/loads them off-thread, never inside the guest
1198 /// call. Empty for every world that does not import `plugin-manager`.
1199 require_contributions: plugin_manager_host::RequireContributions,
1200}
1201
1202/// The bus-publish handle a plugin needs to emit custom events (PH7.8b.2). Set
1203/// on [`PluginState`] by [`PluginHost::spawn_event_plugin`] once the plugin's
1204/// identity is allocated. The subscribe side already threads the concrete
1205/// [`EventBus`] into the host ([`event_task`](crate::event_task)), so the emit
1206/// side stores it directly rather than behind a closure indirection.
1207struct EventEmitCtx {
1208 /// The host-issued identity — the `plugin:<id>` provenance stamped on every
1209 /// event this plugin declares via `register-event`.
1210 plugin_id: PluginId,
1211 /// The bus `emit-event` publishes `Event::Plugin` onto.
1212 bus: Arc<EventBus>,
1213}
1214
1215/// PO.5: what the guest `logging` seam needs to route a `log` call — the plugin's
1216/// host-issued id (keys the trace record) + the shared tracer. Set on
1217/// [`PluginState`] by each async instantiate/spawn path
1218/// ([`PluginHost::log_ctx_for`]); the `EventEmitCtx` analog for Layer 2.
1219struct LogCtx {
1220 /// The host-issued id stamped on every `PluginTraceRecord` from this plugin.
1221 plugin: u32,
1222 /// The sink the guest's log lines land in (the same tracer as the boundary
1223 /// trace, so the two interleave in `*plugin-trace*`).
1224 tracer: crate::trace::PluginTracerHandle,
1225}
1226
1227impl WasiView for PluginState {
1228 fn ctx(&mut self) -> WasiCtxView<'_> {
1229 WasiCtxView {
1230 ctx: &mut self.wasi,
1231 table: &mut self.table,
1232 }
1233 }
1234}
1235
1236/// Host impl of the `host-services` guest→host seam (PH7.4b, §5). The generated
1237/// `Host::walk` returns the WIT `result<list<string>, string>` directly as
1238/// `Result<Vec<String>, String>` — a sync host func that cannot trap, so bindgen
1239/// omits the outer `wasmtime::Result`. Walk logic + the capability gate live in
1240/// [`host_services::walk_within_grant`]; the impl just forwards with the Store's
1241/// grant.
1242impl crate::lattice::plugin_host::host_services::Host for PluginState {
1243 /// OA.23: where a line of a multibuffer came from.
1244 ///
1245 /// Forwards to the wired resolver and projects its answer. `none` on every
1246 /// way of not knowing — no resolver, not a composed buffer, a line that is
1247 /// not source text, a source with no path — because a guest asks about the
1248 /// cursor's line and a cursor can be anywhere. A non-UTF-8 path is `none`
1249 /// too: it cannot cross as a `string`, and that is the boundary's rule
1250 /// everywhere else.
1251 fn excerpt_source(
1252 &mut self,
1253 buffer: u64,
1254 line: u32,
1255 ) -> Option<crate::lattice::plugin_host::host_services::SourceLocation> {
1256 let resolver = self.excerpt_source.as_ref()?;
1257 let found = resolver.excerpt_source(lattice_core::BufferId(buffer as u32), line)?;
1258 Some(crate::lattice::plugin_host::host_services::SourceLocation {
1259 path: found.path.to_str()?.to_string(),
1260 line: found.line,
1261 buffer: found.source.0,
1262 })
1263 }
1264
1265 /// OA.23b: forwards to the wired resolver. `none` with no resolver, and
1266 /// `none` for a `buffer` no view owns — a guest can only have got the id
1267 /// from `excerpt-source`, and a view can close between the two calls.
1268 fn source_line(&mut self, buffer: u32, line: u32) -> Option<String> {
1269 self.excerpt_source
1270 .as_ref()?
1271 .source_line(lattice_core::BufferId(buffer), line)
1272 }
1273
1274 /// OA.27 `view-args`: forwards to the wired resolver.
1275 ///
1276 /// An EMPTY LIST rather than an `option` on the wire, and the WIT says why:
1277 /// every way of not knowing — no resolver, not a provider view, a view the
1278 /// host holds no state for — means "no arguments", which is exactly what a
1279 /// fresh view has and what a guest parses a default from. An `option` would
1280 /// offer a distinction with no different action behind it.
1281 fn view_args(&mut self, buffer: u64) -> Vec<String> {
1282 self.view_args
1283 .as_ref()
1284 .and_then(|r| r.view_args(lattice_core::BufferId(buffer as u32)))
1285 .unwrap_or_default()
1286 }
1287
1288 /// OA.30 `refresh-decorations`: bump the counter the refresh pump compares.
1289 ///
1290 /// No result and no error. There is nothing a guest could do with either:
1291 /// the call is advisory, the work happens on the host's next tick, and an
1292 /// unwired counter means this editor runs no decoration producers at all —
1293 /// so the guest's marks were never going to paint regardless.
1294 fn refresh_decorations(&mut self) {
1295 if let Some(epoch) = self.decoration_epoch.as_ref() {
1296 epoch.bump();
1297 }
1298 }
1299
1300 fn walk(&mut self, root: String) -> Result<Vec<String>, String> {
1301 host_services::walk_within_grant(&self.grant, &root)
1302 }
1303
1304 /// OC.5a `read-file`: the read that works on every seam, including the
1305 /// synchronous grammar dispatch path where the guest's own WASI view
1306 /// panics rather than reads. Gate + logic in
1307 /// [`host_services::read_within_grant`].
1308 fn read_file(&mut self, path: String) -> Result<String, String> {
1309 host_services::read_within_grant(&self.grant, &path)
1310 }
1311
1312 /// CD.3 `delete-file`: `read-file`'s peer, gated on `fs:write`. Gate +
1313 /// logic in [`host_services::delete_within_grant`].
1314 fn delete_file(&mut self, path: String) -> Result<(), String> {
1315 host_services::delete_within_grant(&self.grant, &path)
1316 }
1317
1318 /// CD.3b `can-write-file`: the boundary's own grant test plus the
1319 /// applier's checks, as a query. See
1320 /// [`host_services::can_write_within_grant`].
1321 fn can_write_file(&mut self, path: String) -> Result<(), String> {
1322 host_services::can_write_within_grant(&self.grant, &path)
1323 }
1324
1325 /// CD.6b `clamp-position`: `at`, moved inside `buffer` as it is now.
1326 /// `none` when no buffer has that id, or no store is wired. Logic in
1327 /// [`host_services::clamp_position`].
1328 fn clamp_position(
1329 &mut self,
1330 buffer: u32,
1331 at: crate::lattice::plugin_host::types::Position,
1332 ) -> Option<crate::lattice::plugin_host::types::Position> {
1333 let handle = self
1334 .buffers
1335 .as_ref()?
1336 .handle_for(lattice_core::BufferId(buffer))?;
1337 let (line, byte) =
1338 host_services::clamp_position(&handle.snapshot().buffer, at.line, at.byte);
1339 Some(crate::lattice::plugin_host::types::Position { line, byte })
1340 }
1341
1342 /// `register-event` (PH7.8b.2): declare a plugin-defined event into the
1343 /// runtime registry under this plugin's `plugin:<id>` provenance. Returns
1344 /// `false` on a built-in-shadow (the registry refuses it) OR when no emit
1345 /// context is wired (a plugin not spawned onto a bus — degrade gracefully to
1346 /// "not registered" rather than panic; the host isn't boot-wired yet).
1347 fn register_event(&mut self, name: String, doc: String) -> bool {
1348 match &self.event_emit {
1349 Some(ctx) => host_services::register_plugin_event(ctx.plugin_id, &name, &doc),
1350 None => {
1351 tracing::warn!(
1352 event = %name,
1353 "register-event ignored: plugin has no event bus wired"
1354 );
1355 false
1356 }
1357 }
1358 }
1359
1360 /// `emit-event` (PH7.8b.2): publish a plugin-defined event on the bus. A
1361 /// plugin with no emit context wired (not spawned onto a bus) degrades to a
1362 /// warn + drop — never a panic (the four-artefact graceful-failure clause).
1363 fn emit_event(&mut self, name: String, payload: Vec<u8>) {
1364 // PH7.8c: inside the `register-events` window this plugin has no bus
1365 // subscription yet, so publishing now would deliver to everyone EXCEPT
1366 // the guest that asked. Hold it; the spawn flushes once the
1367 // subscriptions are wired.
1368 if let Some(pending) = self.deferred_events.as_mut() {
1369 pending.push((name, payload));
1370 return;
1371 }
1372 match &self.event_emit {
1373 Some(ctx) => host_services::emit_plugin_event(&ctx.bus, name, payload),
1374 None => {
1375 tracing::warn!(
1376 event = %name,
1377 "emit-event dropped: plugin has no event bus wired"
1378 );
1379 }
1380 }
1381 }
1382
1383 /// OC.4 `local-utc-offset-seconds`: the host's current offset from UTC.
1384 /// Ungated, and resolved per call so DST is correct — see
1385 /// [`host_services::local_utc_offset_seconds`].
1386 fn local_utc_offset_seconds(&mut self) -> i32 {
1387 host_services::local_utc_offset_seconds()
1388 }
1389
1390 /// OR.3 `new-uuid`: a fresh uppercase v4 id, mintable from every seam —
1391 /// including the synchronous grammar linker, which is the only reason it is
1392 /// host-side. See [`host_services::new_uuid`].
1393 fn new_uuid(&mut self) -> Result<String, String> {
1394 host_services::new_uuid()
1395 }
1396
1397 /// OR.2 `watch`. Gated on the same `fs:read` grant `walk` and `read-file`
1398 /// check — a watch reveals filesystem activity, so it is the same
1399 /// authorization question, answered by the same function.
1400 ///
1401 /// Requires an event bus on this seam, because a watch with nowhere to
1402 /// deliver is not a degraded watch, it is a thread that runs forever and
1403 /// tells nobody. The refusal names that, so a plugin author arming one from
1404 /// the grammar seam learns why it is silent rather than assuming the
1405 /// watcher is broken.
1406 fn watch(&mut self, path: String) -> Result<(), String> {
1407 let Some(ctx) = &self.event_emit else {
1408 tracing::warn!(
1409 path = %path,
1410 "watch refused: plugin has no event bus wired on this seam"
1411 );
1412 return Err(format!(
1413 "fs watch denied: '{path}' — this seam has no event bus, so a watch could \
1414 never be delivered (arm it from the plugin's events seam)"
1415 ));
1416 };
1417 let key = PathBuf::from(&path);
1418 if self.watches.contains_key(&key) {
1419 // Idempotent: re-arming would leave the first watch orphaned and
1420 // double every batch.
1421 return Ok(());
1422 }
1423 let watch =
1424 watch_host::watch_within_grant(&self.grant, ctx.bus.clone(), ctx.plugin_id.0, &path)?;
1425 self.watches.insert(key, watch);
1426 Ok(())
1427 }
1428
1429 /// OR.2 `unwatch`. Dropping the entry stops the OS watch and ends its
1430 /// coalescing thread. Unwatching what is not watched is `ok` — a disarm is
1431 /// idempotent, because the alternative is a guest that must track host
1432 /// state to avoid an error.
1433 fn unwatch(&mut self, path: String) -> Result<(), String> {
1434 self.watches.remove(&PathBuf::from(&path));
1435 Ok(())
1436 }
1437
1438 /// OR.1 `store-put`. Gated on `state:write`; `err` names which of the three
1439 /// refusals happened, because "the put failed" sends a plugin author
1440 /// nowhere.
1441 fn store_put(&mut self, key: String, value: Vec<u8>) -> Result<(), String> {
1442 match self.writable_store()? {
1443 Some(mut store) => store.put(&key, value),
1444 None => Err(
1445 "store put failed: this plugin has no store (loaded without a manifest id)".into(),
1446 ),
1447 }
1448 }
1449
1450 /// OR.1 `store-get`. `none` for every absent case — nothing stored, no
1451 /// grant, no store — because a reader for whom absence is ordinary cannot
1452 /// act differently on any of them.
1453 fn store_get(&mut self, key: String) -> Option<Vec<u8>> {
1454 self.readable_store()?.get(&key)
1455 }
1456
1457 /// OR.1 `store-delete`. Deleting what is not there is `ok`.
1458 fn store_delete(&mut self, key: String) -> Result<(), String> {
1459 match self.writable_store()? {
1460 Some(mut store) => store.delete(&key),
1461 None => Err(
1462 "store delete failed: this plugin has no store (loaded without a manifest id)"
1463 .into(),
1464 ),
1465 }
1466 }
1467
1468 /// OR.1 `store-keys`. Empty for an ungranted or storeless plugin.
1469 fn store_keys(&mut self, prefix: String) -> Vec<String> {
1470 self.readable_store()
1471 .map(|s| s.keys(&prefix))
1472 .unwrap_or_default()
1473 }
1474
1475 /// OR.1 `store-generation`. `0` for an ungranted or storeless plugin — a
1476 /// number that never moves, so a reader polling it simply never rebuilds
1477 /// rather than rebuilding forever.
1478 fn store_generation(&mut self) -> u64 {
1479 self.readable_store().map(|s| s.generation()).unwrap_or(0)
1480 }
1481}
1482
1483impl PluginState {
1484 /// The store for a READ (`store-get` / `store-keys` / `store-generation`),
1485 /// or `None` when this plugin has none or was not granted `state:write`.
1486 ///
1487 /// Reads are gated on the same grant as writes. A plugin that may not
1488 /// persist has nothing of its own to read back, and letting it read would
1489 /// only expose what an earlier, granted version of itself had left there.
1490 fn readable_store(&self) -> Option<std::sync::MutexGuard<'_, plugin_store::PluginStore>> {
1491 if !self.grant.state_write {
1492 return None;
1493 }
1494 // Poisoning is recovered rather than propagated: a panic in another
1495 // thread's guest call must not turn every later store read into a
1496 // panic on the keystroke path. The bytes are unaffected — the map is
1497 // only ever mutated through methods that cannot leave it half-updated.
1498 Some(
1499 self.store
1500 .as_ref()?
1501 .lock()
1502 .unwrap_or_else(|poisoned| poisoned.into_inner()),
1503 )
1504 }
1505
1506 /// The store for a WRITE. `Err` when the grant is missing (named, so the
1507 /// author fixes their manifest); `Ok(None)` when there is no store at all.
1508 #[allow(clippy::type_complexity)]
1509 fn writable_store(
1510 &self,
1511 ) -> Result<Option<std::sync::MutexGuard<'_, plugin_store::PluginStore>>, String> {
1512 if !self.grant.state_write {
1513 // info!: user-actionable (a plugin was denied persistence), the
1514 // level `walk` / `read-file` denials use.
1515 tracing::info!("host-services store denied: the plugin has no `state:write` grant");
1516 return Err(
1517 "store denied: this plugin did not request the `state:write` capability".into(),
1518 );
1519 }
1520 Ok(self
1521 .store
1522 .as_ref()
1523 .map(|s| s.lock().unwrap_or_else(|poisoned| poisoned.into_inner())))
1524 }
1525}
1526
1527/// Host impl of the `ui` guest→host contribution seam (OC.3 / ML.6, §5). Three
1528/// sync, non-trapping functions — the `host-services` shape — over a plugin's
1529/// own modeline element. Every id is namespaced with the plugin's name before it
1530/// reaches the registry, so one plugin cannot address another's element or a
1531/// built-in. Logic + the namespacing live in [`ui_host`] so they are testable
1532/// without a `Store`; this impl is the wiring and the absent-context arm.
1533/// OR.5b: the `picker-registry` guest→host seam — where a plugin declares its
1534/// picker sources.
1535///
1536/// The `grammar::register_*` shape: a sync host func that only records into
1537/// `PluginState`, drained by the spawn path after the registration export
1538/// returns. A spec that will not cross the boundary is dropped with a `warn`
1539/// rather than failing the whole registration — one malformed source must not
1540/// cost a plugin its other ones.
1541impl crate::picker_host::bindings::lattice::plugin_host::picker_registry::Host for PluginState {
1542 fn register_picker_source(
1543 &mut self,
1544 spec: crate::lattice::plugin_host::types::PickerSourceSpec,
1545 ) {
1546 match <lattice_picker::source::PickerSourceSpec as WitBoundary>::from_wit(spec) {
1547 Ok(spec) => self.picker_contributions.push(spec),
1548 Err(error) => {
1549 tracing::warn!(%error, "register-picker-source refused: spec did not cross");
1550 }
1551 }
1552 }
1553}
1554
1555/// MV.1 — the registry a view plugin declares its views through.
1556///
1557/// The `register_picker_source` shape exactly: a sync host func that only
1558/// records into `PluginState`, drained by the spawn after the registration
1559/// export returns. Unlike the picker's, the spec does NOT convert here — a
1560/// `MultibufferViewSpec` is host-side data the provider consumes directly, so
1561/// there is nothing to fail at this point. Refusals that need the other
1562/// claimant's name (an id a native provider already owns) happen where both are
1563/// visible, in `providers::plugin_view`.
1564impl crate::multibuffer_view_host::bindings::lattice::plugin_host::multibuffer_view_registry::Host
1565 for PluginState
1566{
1567 fn register_multibuffer_view(
1568 &mut self,
1569 spec: crate::lattice::plugin_host::types::MultibufferViewSpec,
1570 ) {
1571 if spec.id.trim().is_empty() || spec.buffer_name.trim().is_empty() {
1572 // Both are names the host will look a view up by; an empty one is
1573 // unreachable rather than merely odd. Dropped with a warning so one
1574 // malformed view does not cost the plugin its others.
1575 tracing::warn!(
1576 id = %spec.id,
1577 buffer = %spec.buffer_name,
1578 "register-multibuffer-view refused: id and buffer-name must be non-empty"
1579 );
1580 return;
1581 }
1582 self.multibuffer_view_contributions.declare(spec);
1583 }
1584
1585 /// OA.15a: `refresh-view` — re-open one of this guest's views.
1586 ///
1587 /// A REQUEST, exactly like `enable-mode`: the opener needs the `&mut`
1588 /// `ModeActivator` and a plugin store cannot reach it, so this publishes
1589 /// and the Editor applies on its next tick. The typed event carries its own
1590 /// wake, so the re-scan reaches the screen without a keystroke — a plain
1591 /// `Event` variant would have landed only on the next keypress, which is
1592 /// the "works, but only after I hit something" class this codebase has paid
1593 /// for repeatedly.
1594 ///
1595 /// Validated here only for emptiness, which is the one thing that is wrong
1596 /// no matter what is registered. Whether `view` names a live provider is
1597 /// checked by the drain, where the registry is visible — the same division
1598 /// `register-multibuffer-view` already makes with `providers::plugin_view`.
1599 fn refresh_view(&mut self, view: String, args: Vec<String>) {
1600 if view.trim().is_empty() {
1601 tracing::warn!("refresh-view refused: view name must be non-empty");
1602 return;
1603 }
1604 match &self.event_emit {
1605 Some(ctx) => {
1606 ctx.bus
1607 .publish_typed(lattice_mode::provider_view::ProviderViewRefreshRequested {
1608 provider: view,
1609 args,
1610 })
1611 }
1612 // The "not boot-wired yet" case, like `emit-event` and
1613 // `enable-mode`: a warn and a drop, never a trap. A guest calling
1614 // this from a test harness with no bus is not misbehaving.
1615 None => tracing::warn!(
1616 %view,
1617 "refresh-view dropped: no bus wired (plugin not spawned onto a bus)"
1618 ),
1619 }
1620 }
1621}
1622
1623impl crate::lattice::plugin_host::ui::Host for PluginState {
1624 fn register_segment(
1625 &mut self,
1626 id: String,
1627 zone: crate::lattice::plugin_host::types::UiZone,
1628 priority: i32,
1629 ) -> bool {
1630 let id = ui_host::namespaced_id(self.plugin_name.as_deref(), &id);
1631 let Some(ctx) = self.ui.as_ref() else {
1632 tracing::warn!(
1633 element = %id,
1634 "register-segment ignored: no modeline wired on this seam"
1635 );
1636 return false;
1637 };
1638 match ui_host::register_segment(ctx, id.clone(), zone, priority) {
1639 // No teardown token is recorded here: unload reverses this plugin's
1640 // elements by NAMESPACE (`PluginTeardown::modeline_namespace`), so a
1641 // segment registered later in the plugin's life is covered too.
1642 Ok(()) => true,
1643 Err(error) => {
1644 tracing::warn!(element = %id, %error, "register-segment refused");
1645 false
1646 }
1647 }
1648 }
1649
1650 fn emit_segment(&mut self, id: String, text: String) {
1651 let id = ui_host::namespaced_id(self.plugin_name.as_deref(), &id);
1652 match self.ui.as_ref() {
1653 Some(ctx) => ui_host::emit_segment(ctx, id, text),
1654 None => {
1655 tracing::warn!(
1656 element = %id,
1657 "emit-segment dropped: no modeline wired on this seam"
1658 );
1659 }
1660 }
1661 }
1662
1663 fn clear_segment(&mut self, id: String) {
1664 let id = ui_host::namespaced_id(self.plugin_name.as_deref(), &id);
1665 if let Some(ctx) = self.ui.as_ref() {
1666 ui_host::clear_segment(ctx, id);
1667 }
1668 }
1669}
1670
1671/// Host impl of the `plugin-manager` guest→host seam (PM.7).
1672///
1673/// `require` **records and returns**. It performs no resolution, no clone, no
1674/// build and no load — see the module docs for why that split is load-bearing
1675/// rather than merely tidy.
1676///
1677/// A rejected spec returns `false` instead of trapping: one bad entry in a
1678/// user's `init.rs` must not take the whole config down, which is the same
1679/// graceful-degradation clause every other seam here follows.
1680impl crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::Host
1681 for PluginState
1682{
1683 fn require(
1684 &mut self,
1685 spec: crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::PluginSpec,
1686 ) -> bool {
1687 use crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::PluginSource as WitSource;
1688 if !plugin_manager_host::is_safe_plugin_name(&spec.name) {
1689 tracing::warn!(
1690 name = %spec.name,
1691 "require ignored: plugin name is not a single safe path component"
1692 );
1693 return false;
1694 }
1695 let source = match spec.source {
1696 WitSource::Local(path) => plugin_manager_host::RequiredSource::Local(path),
1697 WitSource::Git(g) => plugin_manager_host::RequiredSource::Git {
1698 url: g.url,
1699 rev: g.rev,
1700 },
1701 WitSource::Prebuilt(url) => plugin_manager_host::RequiredSource::Prebuilt { url },
1702 };
1703 tracing::debug!(name = %spec.name, "require recorded");
1704 self.require_contributions
1705 .record(plugin_manager_host::RequiredPlugin {
1706 name: spec.name,
1707 source,
1708 enable_mode: spec.enable_mode,
1709 pinned: spec.pinned,
1710 });
1711 true
1712 }
1713}
1714
1715/// PR.6: what the guest `project` seam needs to answer.
1716///
1717/// Both halves are required: the resolver turns a path into a project, and
1718/// the buffer store turns a `buffer` id into that path. Bundled so
1719/// `PluginState` carries one `Option` rather than two that could disagree
1720/// about whether the seam is wired.
1721#[derive(Clone)]
1722pub(crate) struct ProjectCtx {
1723 pub(crate) resolver: lattice_core::ProjectResolverHandle,
1724 pub(crate) buffers: lattice_mode::BufferStoreHandle,
1725}
1726
1727/// PR.6: host impl of the `project` guest→host seam.
1728///
1729/// Design: `docs/dev/architecture/project-resolution.md` §6. The host answers,
1730/// the guest asks — core never depends on a plugin being alive.
1731///
1732/// Both funcs return `option`, unlike the total native
1733/// `ProjectResolver::for_path`, and the two `none`s mean different things:
1734/// `root-for-buffer` returns it for an id the host does not know (untrusted
1735/// input from the guest), `root-for-path` only when no resolver is wired at all
1736/// (a stripped harness). A buffer that *exists* always resolves — one with no
1737/// path on disk reports the working directory with `kind = pwd`.
1738///
1739/// Sync host funcs. Resolution can walk the filesystem on a cache miss, but it
1740/// runs on the plugin's own store and task — never the UI or actor thread.
1741impl crate::lattice::plugin_host::project::Host for PluginState {
1742 fn root_for_buffer(
1743 &mut self,
1744 buffer: u64,
1745 ) -> Option<crate::lattice::plugin_host::project::ProjectInfo> {
1746 let ctx = self.project.as_ref()?;
1747 // A guest-supplied id is untrusted: `path_for` returning `None` is
1748 // ambiguous between "no such buffer" and "buffer has no path", so ask
1749 // the store whether it knows the buffer at all first.
1750 //
1751 // `contains_buffer`, NOT `name_for`. This used to ask `name_for(id)?`
1752 // on the belief that it "answers for every registered buffer, pathless
1753 // ones included" — the opposite of what it does. `name` is the
1754 // synthetic-name slot, so it is `None` for every buffer opened from a
1755 // file, and this seam therefore refused to resolve exactly the buffers
1756 // a user edits. The `project` plugin's `document-opened` handler got
1757 // `none` for every real file, remembered nothing, and
1758 // `:project-switch` reported "no projects remembered yet" no matter how
1759 // long the editor had been running.
1760 let id = lattice_core::BufferId(buffer as u32);
1761 if !ctx.buffers.contains_buffer(id) {
1762 return None;
1763 }
1764 let path = ctx.buffers.path_for(id);
1765 Some(to_wire(match path {
1766 Some(path) => ctx.resolver.for_path(&path),
1767 // Pathless (scratch, terminal): an empty relative path resolves
1768 // against the resolver's own pwd, so the answer stays consistent
1769 // with `:cd` rather than reading the process cwd.
1770 None => ctx.resolver.for_path(std::path::Path::new("")),
1771 }))
1772 }
1773
1774 fn root_for_path(
1775 &mut self,
1776 path: String,
1777 ) -> Option<crate::lattice::plugin_host::project::ProjectInfo> {
1778 let ctx = self.project.as_ref()?;
1779 Some(to_wire(ctx.resolver.for_path(std::path::Path::new(&path))))
1780 }
1781}
1782
1783/// Native [`lattice_core::Project`] → the wire record.
1784fn to_wire(p: lattice_core::Project) -> crate::lattice::plugin_host::project::ProjectInfo {
1785 use crate::lattice::plugin_host::project as wit;
1786 let (kind, marker) = match &p.kind {
1787 lattice_core::ProjectKind::Marker(m) => (wit::ProjectKind::Marker, m.clone()),
1788 // Empty rather than a sentinel word: a guest checking `kind` has the
1789 // answer already, and a guest rendering `marker` should show nothing
1790 // rather than the string "pwd".
1791 lattice_core::ProjectKind::Pwd => (wit::ProjectKind::Pwd, String::new()),
1792 };
1793 wit::ProjectInfo {
1794 root: p.root.display().to_string(),
1795 kind,
1796 marker,
1797 }
1798}
1799
1800/// Host impl of the `logging` guest→host seam (PO.5, Layer 2). Routes each guest
1801/// `log(level, context, message)` into the plugin's tracer as a
1802/// [`Direction::HostImport`](crate::trace::Direction::HostImport) record with
1803/// `seam = logging`, so the guest's own narrative interleaves with the boundary
1804/// trace in `*plugin-trace*`. The `context` becomes the record's `call` (the
1805/// guest's category) and the `message` its `detail`; `tracer.trace` gates it by
1806/// the plugin's level, exactly like a boundary record. A plugin with no `log_ctx`
1807/// wired (the sync grammar guest, or a pre-tracer path) degrades to a debug-drop —
1808/// never a panic (the graceful-failure clause). Sync host func (only a ring push),
1809/// so bindgen omits the outer `wasmtime::Result`.
1810impl crate::lattice::plugin_host::logging::Host for PluginState {
1811 fn log(
1812 &mut self,
1813 level: crate::lattice::plugin_host::logging::Level,
1814 context: String,
1815 message: String,
1816 ) {
1817 use crate::trace::{Direction, PluginTraceRecord, TraceOutcome};
1818 let Some(ctx) = &self.log_ctx else {
1819 tracing::debug!(%context, %message, "plugin log dropped: no tracer wired");
1820 return;
1821 };
1822 ctx.tracer.trace(PluginTraceRecord {
1823 plugin: ctx.plugin,
1824 seam: PluginSeam::Logging,
1825 direction: Direction::HostImport,
1826 // The guest's chosen category rides in `call`; the formatter renders
1827 // `logging <context>: <message>` for Layer-2 records.
1828 call: std::borrow::Cow::Owned(context),
1829 level: map_log_level(level),
1830 // Logging carries no timing/fuel — the outcome is a nominal Ok; the
1831 // formatter shows the message, not the outcome, for logging records.
1832 outcome: TraceOutcome::Ok {
1833 micros: 0,
1834 fuel_delta: 0,
1835 },
1836 detail: Some(message),
1837 });
1838 }
1839}
1840
1841/// Map a `wasi:logging`-shaped [`Level`](crate::lattice::plugin_host::logging::Level)
1842/// to a host [`TraceLevel`](crate::trace::TraceLevel). `critical` folds into
1843/// `Error` (the tracer has no separate critical tier).
1844fn map_log_level(level: crate::lattice::plugin_host::logging::Level) -> crate::trace::TraceLevel {
1845 use crate::lattice::plugin_host::logging::Level;
1846 use crate::trace::TraceLevel;
1847 match level {
1848 Level::Trace => TraceLevel::Trace,
1849 Level::Debug => TraceLevel::Debug,
1850 Level::Info => TraceLevel::Info,
1851 Level::Warn => TraceLevel::Warn,
1852 Level::Error | Level::Critical => TraceLevel::Error,
1853 }
1854}
1855
1856/// Host impl of the `grammar` guest→host register API (PH7.7b, §4.1). The guest
1857/// calls these (from its `register-grammar` export) to contribute vim grammar;
1858/// each records the declaration into the Store's [`grammar_host::GrammarContributions`]
1859/// so the host can drain it after registration and build native `*Spec`s with
1860/// trampoline `apply`s (PH7.7c). Sync + infallible (they only push — recording
1861/// cannot trap; name collisions / registry errors surface at drain time, not
1862/// here), so bindgen omits the outer `wasmtime::Result`. The bodies forward to
1863/// the accumulator (the `host_services` `walk` shape).
1864impl crate::grammar_host::bindings::lattice::plugin_host::grammar::Host for PluginState {
1865 fn register_motion(
1866 &mut self,
1867 name: String,
1868 doc: String,
1869 spec: crate::lattice::plugin_host::types::MotionSpec,
1870 callback: u32,
1871 ) {
1872 self.grammar_contributions
1873 .record_motion(name, doc, spec, callback);
1874 }
1875
1876 fn register_operator(
1877 &mut self,
1878 name: String,
1879 doc: String,
1880 spec: crate::lattice::plugin_host::types::OperatorSpec,
1881 callback: u32,
1882 ) {
1883 self.grammar_contributions
1884 .record_operator(name, doc, spec, callback);
1885 }
1886
1887 fn register_text_object(
1888 &mut self,
1889 name: String,
1890 doc: String,
1891 spec: crate::lattice::plugin_host::types::TextObjectSpec,
1892 callback: u32,
1893 ) {
1894 self.grammar_contributions
1895 .record_text_object(name, doc, spec, callback);
1896 }
1897
1898 fn register_action(
1899 &mut self,
1900 name: String,
1901 doc: String,
1902 spec: crate::lattice::plugin_host::types::ActionSpec,
1903 callback: u32,
1904 ) {
1905 self.grammar_contributions
1906 .record_action(name, doc, spec, callback);
1907 }
1908
1909 fn register_ex_command(
1910 &mut self,
1911 name: String,
1912 doc: String,
1913 spec: crate::lattice::plugin_host::types::ExCommandSpec,
1914 parse_callback: u32,
1915 apply_callback: u32,
1916 ) {
1917 self.grammar_contributions.record_ex_command(
1918 name,
1919 doc,
1920 spec,
1921 parse_callback,
1922 apply_callback,
1923 );
1924 }
1925}
1926
1927/// Host impl of the `buffer` `document` resource (AP.0.1). A grammar action
1928/// receives a `borrow<document>`; each method here resolves the borrowed handle
1929/// out of the Store's `ResourceTable` to the [`DocumentResource`](crate::buffer::DocumentResource)
1930/// the trampoline minted and forwards to its (already unit-tested) reader. Bulk
1931/// rope text never crosses — `get-text-range` slices only the requested span.
1932/// The interface-level `buffer` host trait — no free functions (the interface is
1933/// only the `document` resource + the `buffer-snapshot` record), so it is a
1934/// marker `add_to_linker` requires alongside [`HostDocument`].
1935impl crate::grammar_host::bindings::lattice::plugin_host::buffer::Host for PluginState {}
1936
1937impl crate::grammar_host::bindings::lattice::plugin_host::buffer::HostDocument for PluginState {
1938 fn get_text_range(
1939 &mut self,
1940 self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1941 r: crate::lattice::plugin_host::types::Range,
1942 ) -> Result<String, String> {
1943 let native = lattice_protocol::position::Range::from_wit(r)?;
1944 let doc = self
1945 .table
1946 .get(&self_)
1947 .map_err(|e| format!("document handle: {e}"))?;
1948 doc.get_text_range(native)
1949 }
1950
1951 fn line_count(
1952 &mut self,
1953 self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1954 ) -> u32 {
1955 self.table.get(&self_).map(|d| d.line_count()).unwrap_or(0)
1956 }
1957
1958 fn byte_len(
1959 &mut self,
1960 self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1961 ) -> u64 {
1962 self.table.get(&self_).map(|d| d.byte_len()).unwrap_or(0)
1963 }
1964
1965 fn line(
1966 &mut self,
1967 self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1968 n: u32,
1969 ) -> Option<String> {
1970 self.table.get(&self_).ok().and_then(|d| d.line_at(n))
1971 }
1972
1973 fn path(
1974 &mut self,
1975 self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1976 ) -> Option<String> {
1977 self.table.get(&self_).ok().and_then(|d| d.path())
1978 }
1979
1980 fn drop(
1981 &mut self,
1982 rep: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1983 ) -> wasmtime::Result<()> {
1984 // The host lends `borrow<document>` handles and reclaims the owned table
1985 // entry itself in the trampoline, so a guest never owns one to drop; this
1986 // fires only if that invariant changes. Delete defensively, ignore a
1987 // missing entry (already reclaimed) — never a trap on teardown.
1988 let _ = self.table.delete(rep);
1989 Ok(())
1990 }
1991}
1992
1993// TS.1: the `tree-sitter` interface `Host` — the marker bindgen requires
1994// alongside `HostTreeSnapshot` / `HostNode` (the `buffer::Host` shape), plus
1995// OT.2's one free function.
1996impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::Host for PluginState {
1997 /// OT.2: parse an off-buffer file and mint a `tree-snapshot` for it.
1998 ///
1999 /// Every failure is `None`, never a trap — see the WIT for why the caller
2000 /// cannot usefully tell them apart. Each arm logs at the level its cause
2001 /// deserves: a denied grant is user-actionable (`info!`, matching
2002 /// `read-file`'s and `walk`'s level), everything else is diagnostic
2003 /// (`debug!`) because a project walk hitting unparseable files is the
2004 /// ordinary case, not a problem, and `info!` here would flood
2005 /// `*messages*` on a refile over a large tree.
2006 fn parse_file(
2007 &mut self,
2008 path: String,
2009 ) -> Option<wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>> {
2010 // The capability gate first: cheapest, and the one that is a policy
2011 // answer rather than a fact about the file.
2012 if !self
2013 .grant
2014 .editor
2015 .contains(lattice_mode::CapabilitySet::TREE_SITTER)
2016 {
2017 tracing::info!(
2018 %path,
2019 "parse-file denied: plugin lacks the tree-sitter capability"
2020 );
2021 return None;
2022 }
2023 // Then the fs grant — the SAME check `read-file` makes, so a plugin
2024 // cannot reach further with a parse than it can with a read.
2025 let source = match host_services::read_within_grant(&self.grant, &path) {
2026 Ok(text) => text,
2027 Err(error) => {
2028 tracing::debug!(%path, %error, "parse-file: read failed");
2029 return None;
2030 }
2031 };
2032 // Resolve the language exactly as a buffer's would — native arms first,
2033 // then the plugin registry — so a plugin gets a tree for its own
2034 // registered extension for the same reason the editor would.
2035 let lang = lattice_syntax::Lang::detect_from_path(Some(std::path::Path::new(&path)));
2036 let mut syntax = match lattice_syntax::Syntax::for_language(lang) {
2037 Ok(Some(syntax)) => syntax,
2038 // `Plain`, or a language whose grammar is not loadable here.
2039 Ok(None) => {
2040 tracing::debug!(%path, ?lang, "parse-file: no grammar for this extension");
2041 return None;
2042 }
2043 Err(error) => {
2044 tracing::debug!(%path, ?lang, %error, "parse-file: grammar load failed");
2045 return None;
2046 }
2047 };
2048 syntax.parse(&source);
2049 let snapshot = std::sync::Arc::new(syntax.snapshot_owned());
2050 // A snapshot can exist with no tree behind it. Minting a handle whose
2051 // every method answers nothing would be worse than `none`, which says
2052 // so — the same filter the buffer path applies.
2053 if snapshot.tree().is_none() {
2054 tracing::debug!(%path, ?lang, "parse-file: parsed to no tree");
2055 return None;
2056 }
2057 match self
2058 .table
2059 .push(crate::tree_resource::TreeSnapshotResource::new(snapshot))
2060 {
2061 Ok(resource) => Some(resource),
2062 Err(error) => {
2063 tracing::debug!(%path, %error, "parse-file: resource table push failed");
2064 None
2065 }
2066 }
2067 }
2068}
2069
2070impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostTreeSnapshot
2071 for PluginState
2072{
2073 fn root(
2074 &mut self,
2075 self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2076 ) -> wasmtime::component::Resource<crate::tree_resource::NodeResource> {
2077 let node = self
2078 .table
2079 .get(&self_)
2080 .expect("tree-snapshot handle live for the call")
2081 .root();
2082 self.table.push(node).expect("node resource table push")
2083 }
2084
2085 fn node_at(
2086 &mut self,
2087 self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2088 pos: crate::lattice::plugin_host::types::Position,
2089 ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2090 let pos = lattice_protocol::position::Position::from_wit(pos).ok()?;
2091 let node = self.table.get(&self_).ok()?.node_at(pos)?;
2092 self.table.push(node).ok()
2093 }
2094
2095 fn enclosing(
2096 &mut self,
2097 self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2098 pos: crate::lattice::plugin_host::types::Position,
2099 kinds: Vec<String>,
2100 ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2101 let pos = lattice_protocol::position::Position::from_wit(pos).ok()?;
2102 let node = self.table.get(&self_).ok()?.enclosing(pos, &kinds)?;
2103 self.table.push(node).ok()
2104 }
2105
2106 fn language(
2107 &mut self,
2108 self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2109 ) -> String {
2110 self.table
2111 .get(&self_)
2112 .map(|ts| ts.language())
2113 .unwrap_or_default()
2114 }
2115
2116 fn compile_query(
2117 &mut self,
2118 self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2119 source: String,
2120 ) -> Result<wasmtime::component::Resource<crate::tree_resource::QueryResource>, String> {
2121 let query = self
2122 .table
2123 .get(&self_)
2124 .map_err(|e| format!("tree-snapshot handle: {e}"))?
2125 .compile_query(&source)?;
2126 self.table
2127 .push(query)
2128 .map_err(|e| format!("query resource push: {e}"))
2129 }
2130
2131 fn run_query(
2132 &mut self,
2133 self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2134 q: wasmtime::component::Resource<crate::tree_resource::QueryResource>,
2135 within: Option<crate::lattice::plugin_host::types::Range>,
2136 ) -> Vec<crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::Capture> {
2137 let within = within.and_then(|r| lattice_protocol::position::Range::from_wit(r).ok());
2138 // Collect the (owned) NodeResource captures while borrowing the table,
2139 // then release the borrows and push each into the table (a mutable
2140 // borrow) as an owned `node` handle the guest receives + drops.
2141 let results = {
2142 let (Ok(ts), Ok(qr)) = (self.table.get(&self_), self.table.get(&q)) else {
2143 return Vec::new();
2144 };
2145 ts.run_query(qr, within)
2146 };
2147 results
2148 .into_iter()
2149 .filter_map(|(name, node)| {
2150 let node = self.table.push(node).ok()?;
2151 Some(
2152 crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::Capture {
2153 name,
2154 node,
2155 },
2156 )
2157 })
2158 .collect()
2159 }
2160
2161 fn run_query_ranges(
2162 &mut self,
2163 self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2164 q: wasmtime::component::Resource<crate::tree_resource::QueryResource>,
2165 within: Option<crate::lattice::plugin_host::types::Range>,
2166 ) -> Vec<crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::CaptureRange> {
2167 let within = within.and_then(|r| lattice_protocol::position::Range::from_wit(r).ok());
2168 // No second pass over the table: a range is a value, so unlike
2169 // `run_query` this never needs a mutable table borrow to mint handles.
2170 let (Ok(ts), Ok(qr)) = (self.table.get(&self_), self.table.get(&q)) else {
2171 return Vec::new();
2172 };
2173 ts.run_query_ranges(qr, within)
2174 .into_iter()
2175 // A range that will not convert is dropped rather than trapping:
2176 // one unrepresentable capture must not fail the whole query.
2177 .filter_map(|(name, match_index, range)| {
2178 Some(
2179 crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::CaptureRange {
2180 name,
2181 match_index,
2182 range: range.to_wit().ok()?,
2183 },
2184 )
2185 })
2186 .collect()
2187 }
2188
2189 fn drop(
2190 &mut self,
2191 rep: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2192 ) -> wasmtime::Result<()> {
2193 // The trampoline lends `borrow<tree-snapshot>` and reclaims the owned
2194 // entry itself (the `document` discipline); a guest never owns one. Delete
2195 // defensively, ignore a missing entry — never a trap on teardown.
2196 let _ = self.table.delete(rep);
2197 Ok(())
2198 }
2199}
2200
2201impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostNode for PluginState {
2202 fn kind(
2203 &mut self,
2204 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2205 ) -> String {
2206 self.table.get(&self_).map(|n| n.kind()).unwrap_or_default()
2207 }
2208
2209 fn is_named(
2210 &mut self,
2211 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2212 ) -> bool {
2213 self.table
2214 .get(&self_)
2215 .map(|n| n.is_named())
2216 .unwrap_or(false)
2217 }
2218
2219 fn is_error(
2220 &mut self,
2221 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2222 ) -> bool {
2223 self.table
2224 .get(&self_)
2225 .map(|n| n.is_error())
2226 .unwrap_or(false)
2227 }
2228
2229 fn byte_range(
2230 &mut self,
2231 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2232 ) -> crate::lattice::plugin_host::types::Range {
2233 let r = self.table.get(&self_).map(|n| n.byte_range()).unwrap_or(
2234 lattice_protocol::position::Range {
2235 start: lattice_protocol::position::Position { line: 0, byte: 0 },
2236 end: lattice_protocol::position::Position { line: 0, byte: 0 },
2237 },
2238 );
2239 crate::lattice::plugin_host::types::Range {
2240 start: crate::lattice::plugin_host::types::Position {
2241 line: r.start.line,
2242 byte: r.start.byte,
2243 },
2244 end: crate::lattice::plugin_host::types::Position {
2245 line: r.end.line,
2246 byte: r.end.byte,
2247 },
2248 }
2249 }
2250
2251 fn parent(
2252 &mut self,
2253 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2254 ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2255 let node = self.table.get(&self_).ok()?.parent()?;
2256 self.table.push(node).ok()
2257 }
2258
2259 fn named_child_count(
2260 &mut self,
2261 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2262 ) -> u32 {
2263 self.table
2264 .get(&self_)
2265 .map(|n| n.named_child_count())
2266 .unwrap_or(0)
2267 }
2268
2269 fn named_child(
2270 &mut self,
2271 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2272 index: u32,
2273 ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2274 let node = self.table.get(&self_).ok()?.named_child(index)?;
2275 self.table.push(node).ok()
2276 }
2277
2278 fn child_by_field(
2279 &mut self,
2280 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2281 name: String,
2282 ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2283 let node = self.table.get(&self_).ok()?.child_by_field(&name)?;
2284 self.table.push(node).ok()
2285 }
2286
2287 fn next_named_sibling(
2288 &mut self,
2289 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2290 ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2291 let node = self.table.get(&self_).ok()?.next_named_sibling()?;
2292 self.table.push(node).ok()
2293 }
2294
2295 fn prev_named_sibling(
2296 &mut self,
2297 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2298 ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2299 let node = self.table.get(&self_).ok()?.prev_named_sibling()?;
2300 self.table.push(node).ok()
2301 }
2302
2303 fn walk(
2304 &mut self,
2305 self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2306 ) -> wasmtime::component::Resource<crate::tree_resource::CursorResource> {
2307 let cursor = self
2308 .table
2309 .get(&self_)
2310 .expect("node handle live for the call")
2311 .walk();
2312 self.table.push(cursor).expect("cursor resource table push")
2313 }
2314
2315 fn drop(
2316 &mut self,
2317 rep: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2318 ) -> wasmtime::Result<()> {
2319 // Nodes ARE owned by the guest (unlike the lent snapshot handle); the
2320 // guest's generated RAII wrapper drops them when they leave scope. Delete
2321 // the table entry, ignoring a missing one — never a trap.
2322 let _ = self.table.delete(rep);
2323 Ok(())
2324 }
2325}
2326
2327// TS.2: the `query` resource is opaque — no methods beyond the implicit drop.
2328impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostQuery for PluginState {
2329 fn drop(
2330 &mut self,
2331 rep: wasmtime::component::Resource<crate::tree_resource::QueryResource>,
2332 ) -> wasmtime::Result<()> {
2333 // Guest-owned (returned by `compile-query`); dropped by the guest's RAII
2334 // wrapper. Delete defensively, ignore a missing entry — never a trap.
2335 let _ = self.table.delete(rep);
2336 Ok(())
2337 }
2338}
2339
2340impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostTreeCursor
2341 for PluginState
2342{
2343 fn current_node(
2344 &mut self,
2345 self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2346 ) -> wasmtime::component::Resource<crate::tree_resource::NodeResource> {
2347 let node = self
2348 .table
2349 .get(&self_)
2350 .expect("cursor handle live for the call")
2351 .current_node();
2352 self.table.push(node).expect("node resource table push")
2353 }
2354
2355 fn current_field(
2356 &mut self,
2357 self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2358 ) -> Option<String> {
2359 self.table.get(&self_).ok().and_then(|c| c.current_field())
2360 }
2361
2362 fn goto_first_named_child(
2363 &mut self,
2364 self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2365 ) -> bool {
2366 self.table
2367 .get_mut(&self_)
2368 .map(|c| c.goto_first_named_child())
2369 .unwrap_or(false)
2370 }
2371
2372 fn goto_next_named_sibling(
2373 &mut self,
2374 self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2375 ) -> bool {
2376 self.table
2377 .get_mut(&self_)
2378 .map(|c| c.goto_next_named_sibling())
2379 .unwrap_or(false)
2380 }
2381
2382 fn goto_parent(
2383 &mut self,
2384 self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2385 ) -> bool {
2386 self.table
2387 .get_mut(&self_)
2388 .map(|c| c.goto_parent())
2389 .unwrap_or(false)
2390 }
2391
2392 fn reset(
2393 &mut self,
2394 self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2395 n: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2396 ) {
2397 // Read the target node's path (releasing that borrow) before mutating the
2398 // cursor — the two live in the same table.
2399 let Some(path) = self.table.get(&n).ok().map(|nr| nr.path().to_vec()) else {
2400 return;
2401 };
2402 if let Ok(cursor) = self.table.get_mut(&self_) {
2403 cursor.reset_to_path(path);
2404 }
2405 }
2406
2407 fn drop(
2408 &mut self,
2409 rep: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2410 ) -> wasmtime::Result<()> {
2411 let _ = self.table.delete(rep);
2412 Ok(())
2413 }
2414}
2415
2416/// Host impl of the `events` guest→host subscription API (PH7.8b, §5). The guest
2417/// calls `subscribe` (from its `register-events` export) to observe editor
2418/// events; each records the `(filter, handler)` pair into the Store's
2419/// [`events_host::EventContributions`] so the host can drain it after
2420/// registration and wire each subscription to the native `EventBus` (PH7.8c).
2421/// Sync + infallible (it only pushes — recording cannot trap), so bindgen omits
2422/// the outer `wasmtime::Result` (the `grammar` / `host_services` shape). The
2423/// filter stays WIT-typed here and projects to native at wire time.
2424impl crate::events_host::bindings::lattice::plugin_host::events::Host for PluginState {
2425 fn subscribe(&mut self, filter: crate::lattice::plugin_host::types::EventFilter, handler: u32) {
2426 self.event_subscriptions.record(filter, handler);
2427 }
2428
2429 /// OC.2 `wake-every`: arm a periodic wake, answered by `on-wake(id)` on this
2430 /// plugin's own actor task. Returns `0` when no wake context is wired — a
2431 /// store whose actor cannot fire one, or a host with no [`SleeperHandle`]
2432 /// installed. Degrading rather than trapping is the four-artefact
2433 /// graceful-failure clause; the `warn!` is what makes the nothing visible.
2434 fn wake_every(&mut self, ms: u32) -> u32 {
2435 let plugin = self.event_emit.as_ref().map_or(0, |c| c.plugin_id.0);
2436 match self.wake.as_mut() {
2437 Some(ctx) => ctx.arm(plugin, ms),
2438 None => {
2439 tracing::warn!(ms, "wake-every refused: no wake mechanism on this seam");
2440 0
2441 }
2442 }
2443 }
2444
2445 /// OC.2 `cancel-wake`: disarm. Idempotent by contract, including on a store
2446 /// with no wake context at all.
2447 fn cancel_wake(&mut self, id: u32) {
2448 let plugin = self.event_emit.as_ref().map_or(0, |c| c.plugin_id.0);
2449 if let Some(ctx) = self.wake.as_mut() {
2450 ctx.cancel(plugin, id);
2451 }
2452 }
2453}
2454
2455/// Host impl of the `config` guest→host option seam (PH7.10, §5). The guest calls
2456/// `register-option` (from its `register-options` export) to declare options and
2457/// `get-option` to read any option's current string value. Both are sync +
2458/// non-trapping, so bindgen omits the outer `wasmtime::Result` (the
2459/// `host-services` `walk` shape). `register-option` maps the WIT `option-type` to
2460/// a native `OptionType` and registers into the SAME `ConfigRegistry` core
2461/// options use ([`config_host::register_plugin_option`]); a plugin with no
2462/// registry wired degrades to `false` / `none` (never a panic — the
2463/// four-artefact graceful-failure clause).
2464impl crate::theme_host::bindings::lattice::plugin_host::theme::Host for PluginState {
2465 fn register_element(
2466 &mut self,
2467 name: String,
2468 doc: String,
2469 default: crate::theme_host::bindings::lattice::plugin_host::theme::StyleSpec,
2470 ) -> Result<(), String> {
2471 let Some(registry) = self.theme_registry.clone() else {
2472 // Graceful, not a trap: a harness with no theme service wired is a
2473 // test shape, not a plugin error. The element simply does not
2474 // exist and rows fall back to their default style.
2475 tracing::warn!(
2476 element = %name,
2477 "register-element ignored: plugin has no theme registry wired"
2478 );
2479 return Ok(());
2480 };
2481 // Auto-namespaced by manifest id, exactly like `register-option`, so a
2482 // plugin cannot squat a bare name or shadow a builtin. An internal
2483 // caller with no manifest id gets no prefix.
2484 let Some(plugin_id) = self.plugin_name.clone() else {
2485 return Err("register-element requires a plugin identity".to_string());
2486 };
2487 let spec = crate::theme_host::style_spec_from_wit(default);
2488 let full =
2489 crate::theme_host::register_plugin_element(&*registry, &plugin_id, &name, &doc, spec);
2490 self.theme_contributions.push(full);
2491 Ok(())
2492 }
2493
2494 /// TK.5: an override for an element this plugin owns, above the theme.
2495 ///
2496 /// `register-element` supplies a default, which sits BELOW the active
2497 /// theme — so a plugin could not express "the user configured this and it
2498 /// must win" without this.
2499 fn set_element_override(
2500 &mut self,
2501 name: String,
2502 style: crate::theme_host::bindings::lattice::plugin_host::theme::StyleSpec,
2503 ) -> Result<(), String> {
2504 let Some(registry) = self.theme_registry.clone() else {
2505 // Same graceful shape as `register_element`: a harness with no
2506 // theme service is a test shape, not a plugin error.
2507 tracing::warn!(
2508 element = %name,
2509 "set-element-override ignored: plugin has no theme registry wired"
2510 );
2511 return Ok(());
2512 };
2513 let Some(plugin_id) = self.plugin_name.clone() else {
2514 return Err("set-element-override requires a plugin identity".to_string());
2515 };
2516 let spec = crate::theme_host::style_spec_from_wit(style);
2517 crate::theme_host::set_plugin_element_override(&*registry, &plugin_id, &name, spec)
2518 }
2519}
2520
2521/// SG.3a: the `signs` seam's single host func.
2522///
2523/// Namespacing and the copy-on-write registry write live in [`sign_host`] as
2524/// free functions so they are unit-testable without a `Store` (the
2525/// `theme_host` precedent). This body is the thin part: derive the namespace
2526/// from the manifest id the HOST holds — never from anything the guest passed
2527/// — and record the teardown token.
2528impl crate::sign_host::bindings::lattice::plugin_host::signs::Host for PluginState {
2529 fn define_sign(
2530 &mut self,
2531 name: String,
2532 spec: crate::sign_host::bindings::lattice::plugin_host::signs::SignSpec,
2533 ) -> Result<(), String> {
2534 let Some(registry) = self.sign_registry.clone() else {
2535 // Graceful, not a trap: a harness with no sign registry wired is a
2536 // test shape, not a plugin error. The sign simply does not exist,
2537 // and a placement naming it paints nothing rather than something
2538 // wrong.
2539 tracing::warn!(
2540 sign = %name,
2541 "define-sign ignored: plugin has no sign registry wired"
2542 );
2543 return Ok(());
2544 };
2545 // Auto-namespaced by manifest id, exactly like `register-element`, so a
2546 // plugin cannot squat a bare name or shadow a native producer's sign.
2547 let Some(plugin_id) = self.plugin_name.clone() else {
2548 return Err("define-sign requires a plugin identity".to_string());
2549 };
2550 let full = crate::sign_host::define_plugin_sign(®istry, &plugin_id, &name, spec);
2551 self.sign_contributions.push(full);
2552 Ok(())
2553 }
2554}
2555
2556/// CR.3: the `help` seam's single host func.
2557///
2558/// Validation and namespacing live in [`help_host`] as free functions so they
2559/// are unit-testable without a `Store` (the `config_host` / `theme_host`
2560/// precedent). This body is the thin part: derive the namespace from the
2561/// manifest id the HOST holds — never from anything the guest passed — and
2562/// record.
2563///
2564/// A rejection is an `Err` back to the guest, not a trap. One malformed page
2565/// costs itself; the plugin's other pages still register, and the load still
2566/// succeeds. That matches how every other declaration seam here treats bad
2567/// guest data.
2568impl crate::help_host::bindings::lattice::plugin_host::help::Host for PluginState {
2569 fn register_topic(
2570 &mut self,
2571 name: String,
2572 summary: String,
2573 body: String,
2574 related_commands: Vec<String>,
2575 ) -> Result<(), String> {
2576 let plugin_id = self.plugin_name.clone().unwrap_or_default();
2577 match crate::help_host::validate_topic(&plugin_id, &name, &summary, &body, related_commands)
2578 {
2579 Ok(spec) => {
2580 self.help_contributions.push(spec);
2581 Ok(())
2582 }
2583 Err(err) => {
2584 tracing::warn!(topic = %name, %err, "register-topic rejected");
2585 Err(err)
2586 }
2587 }
2588 }
2589}
2590
2591/// LG.3c: the `language` seam's declaration host func.
2592///
2593/// Records the guest's bytes and query sources; compiling the grammar is the
2594/// loader's job, after this store is gone (see `language_host`). A rejection
2595/// is an `Err` back to the guest, not a trap — one malformed language costs
2596/// itself, the plugin's others still register, and the load still succeeds.
2597impl crate::language_host::bindings::lattice::plugin_host::language::Host for PluginState {
2598 fn register_language(
2599 &mut self,
2600 spec: crate::language_host::bindings::lattice::plugin_host::language::LanguageSpec,
2601 ) -> Result<(), String> {
2602 let declared = spec.name.clone();
2603 match crate::language_host::validate_language(spec) {
2604 Ok(spec) => {
2605 self.language_contributions.push(spec);
2606 Ok(())
2607 }
2608 Err(err) => {
2609 tracing::warn!(language = %declared, %err, "register-language rejected");
2610 Err(err)
2611 }
2612 }
2613 }
2614}
2615
2616/// CR.4: the `dashboard` seam's declaration host func.
2617///
2618/// Records only — the host instantiates the live per-section guests after the
2619/// export returns. Section ids are NOT namespaced (see `dashboard.wit`):
2620/// replacing a built-in section by id is a supported capability, so the
2621/// namespace that defuses `help`'s collisions would defeat this seam's.
2622impl crate::dashboard_host::bindings::lattice::plugin_host::dashboard::Host for PluginState {
2623 fn register_section(
2624 &mut self,
2625 id: String,
2626 order: i32,
2627 default_enabled: bool,
2628 ) -> Result<(), String> {
2629 match crate::dashboard_host::validate_section(&id, order, default_enabled) {
2630 Ok(spec) => {
2631 self.dashboard_contributions.push(spec);
2632 Ok(())
2633 }
2634 Err(err) => {
2635 tracing::warn!(section = %id, %err, "register-section rejected");
2636 Err(err)
2637 }
2638 }
2639 }
2640}
2641
2642impl crate::config_host::bindings::lattice::plugin_host::config::Host for PluginState {
2643 fn register_option(
2644 &mut self,
2645 name: String,
2646 ty: crate::config_host::bindings::lattice::plugin_host::config::OptionType,
2647 default: String,
2648 doc: String,
2649 ) -> bool {
2650 use crate::config_host::PluginOptionKind;
2651 use crate::config_host::bindings::lattice::plugin_host::config::OptionType as WitOptionType;
2652 let kind = match ty {
2653 WitOptionType::Boolean => PluginOptionKind::Boolean,
2654 WitOptionType::Integer => PluginOptionKind::Integer,
2655 WitOptionType::String => PluginOptionKind::String,
2656 };
2657 // Auto-namespace the plugin's OWN option by its manifest id: a plugin
2658 // registering `style` contributes `auto-pair.style`, so plugins can't
2659 // collide in the global option namespace. No prefix for an internal
2660 // caller with no manifest id.
2661 let full = match &self.plugin_name {
2662 Some(id) => format!("{id}.{name}"),
2663 None => name,
2664 };
2665 // The `&self.config_registry` borrow ends with the match (the result is a
2666 // plain `bool`), so the `config_contributions` push below doesn't overlap.
2667 let registered = match &self.config_registry {
2668 Some(registry) => {
2669 config_host::register_plugin_option(registry, &full, kind, &default, &doc)
2670 }
2671 None => {
2672 tracing::warn!(
2673 option = %full,
2674 "register-option ignored: plugin has no config registry wired"
2675 );
2676 false
2677 }
2678 };
2679 if registered {
2680 self.config_contributions.push(full);
2681 }
2682 registered
2683 }
2684
2685 fn get_option(&mut self, name: String) -> Option<String> {
2686 let registry = self.config_registry.as_ref()?;
2687 // The plugin's OWN namespace first (`style` → `auto-pair.style`), then the
2688 // raw name — so a plugin reads its options with short names AND can still
2689 // read a core option (`tabstop`) that isn't in its namespace.
2690 if let Some(id) = &self.plugin_name
2691 && let Some(opt) = registry.lookup(&format!("{id}.{name}"))
2692 {
2693 return Some(opt.get_formatted());
2694 }
2695 registry.lookup(&name).map(|opt| opt.get_formatted())
2696 }
2697
2698 /// OC.11c: `option-diagnostic` — did the last assignment to this option
2699 /// fail, and what did it say?
2700 ///
2701 /// The seam exists because a failed assignment is a NO-OP: the option
2702 /// keeps its previous value, so a plugin reading it back cannot tell "the
2703 /// user configured this and it did not parse" from "the user never
2704 /// configured this". org-capture filed notes through a legacy fallback for
2705 /// exactly that reason.
2706 ///
2707 /// Own namespace first, then the raw name — `get-option`'s resolution, so
2708 /// a plugin asks about its own option with the short name it declared.
2709 fn option_diagnostic(
2710 &mut self,
2711 name: String,
2712 ) -> Option<crate::config_host::bindings::lattice::plugin_host::config::ConfigDiagnostic> {
2713 let registry = self.config_registry.as_ref()?;
2714 let found = self
2715 .plugin_name
2716 .as_ref()
2717 .and_then(|id| registry.failed_assignment(&format!("{id}.{name}")))
2718 .or_else(|| registry.failed_assignment(&name))?;
2719 Some(
2720 crate::config_host::bindings::lattice::plugin_host::config::ConfigDiagnostic {
2721 message: found.message,
2722 // Empty rather than optional across the boundary: WIT has
2723 // `option<string>` but an absent source and an empty one mean
2724 // the same thing to every caller — "not from a file" — and one
2725 // representation is one fewer thing for a guest to get wrong.
2726 source: found
2727 .source
2728 .map(|p| p.display().to_string())
2729 .unwrap_or_default(),
2730 },
2731 )
2732 }
2733
2734 /// TC.3: `register-structured-option` — declare an option whose value has
2735 /// structure. The schema-taking peer of `register-option`, with the same
2736 /// namespacing and the same collision rule.
2737 ///
2738 /// `false` on: a malformed arena (a bad index or a cycle — see
2739 /// `boundary_config`), a `default` that does not fit the declared `schema`,
2740 /// or a name collision. In every case NOTHING is registered: an option that
2741 /// exists and cannot hold a legal value is worse than one that does not
2742 /// exist, because the plugin's own reads then fall back to a default it
2743 /// never chose.
2744 fn register_structured_option(
2745 &mut self,
2746 name: String,
2747 schema: crate::config_host::bindings::lattice::plugin_host::config::ConfigSchema,
2748 default: crate::config_host::bindings::lattice::plugin_host::config::ConfigValue,
2749 doc: String,
2750 ) -> bool {
2751 let Some(registry) = self.config_registry.as_ref() else {
2752 tracing::warn!(
2753 option = %name,
2754 "register-structured-option ignored: plugin has no config registry wired"
2755 );
2756 return false;
2757 };
2758 // Auto-namespaced exactly like `register-option`: the host owns the
2759 // namespace so plugins cannot collide, and a user sets it as
2760 // `:set org.capture-templates=…`.
2761 let full = match &self.plugin_name {
2762 Some(id) => format!("{id}.{name}"),
2763 None => name.clone(),
2764 };
2765 let schema = match crate::boundary_config::schema_from_wit(&schema) {
2766 Ok(s) => s,
2767 Err(error) => {
2768 tracing::warn!(option = %full, %error, "register-structured-option: bad schema");
2769 return false;
2770 }
2771 };
2772 let default = match crate::boundary_config::value_from_wit(&default) {
2773 Ok(v) => v,
2774 Err(error) => {
2775 tracing::warn!(option = %full, %error, "register-structured-option: bad default");
2776 return false;
2777 }
2778 };
2779 match crate::config_host::register_structured_option(registry, &full, schema, default, &doc)
2780 {
2781 Ok(()) => {
2782 self.config_contributions.push(full);
2783 true
2784 }
2785 Err(error) => {
2786 tracing::warn!(option = %full, %error, "register-structured-option rejected");
2787 false
2788 }
2789 }
2790 }
2791
2792 /// TC.3: `get-option-value` — an option's current value as a tree.
2793 ///
2794 /// Works for scalar options too, a scalar being a degenerate schema, so a
2795 /// guest that wants typed reads everywhere uses this one call rather than
2796 /// choosing per option.
2797 fn get_option_value(
2798 &mut self,
2799 name: String,
2800 ) -> Option<crate::config_host::bindings::lattice::plugin_host::config::ConfigValue> {
2801 let registry = self.config_registry.as_ref()?;
2802 // Own namespace first, then the raw name — `get-option`'s resolution,
2803 // because a guest that switches to typed reads must not also have to
2804 // change every name it passes.
2805 if let Some(id) = &self.plugin_name
2806 && let Some(opt) = registry.lookup(&format!("{id}.{name}"))
2807 {
2808 return Some(crate::boundary_config::value_to_wit(&opt.get_value()));
2809 }
2810 registry
2811 .lookup(&name)
2812 .map(|opt| crate::boundary_config::value_to_wit(&opt.get_value()))
2813 }
2814
2815 /// TC.3: `set-option-value` — set an option from a tree.
2816 ///
2817 /// Validated against the option's declared schema, so a bad field is
2818 /// refused with a PATH rather than by whatever message the plugin would
2819 /// have written. `false` on an unknown option / a value that does not fit /
2820 /// no registry — never a trap, `set-option`'s contract.
2821 fn set_option_value(
2822 &mut self,
2823 name: String,
2824 value: crate::config_host::bindings::lattice::plugin_host::config::ConfigValue,
2825 ) -> bool {
2826 let Some(registry) = self.config_registry.as_ref() else {
2827 tracing::warn!(
2828 option = %name,
2829 "set-option-value ignored: plugin has no config registry wired"
2830 );
2831 return false;
2832 };
2833 let target = self
2834 .plugin_name
2835 .as_ref()
2836 .map(|id| format!("{id}.{name}"))
2837 .filter(|full| registry.lookup(full).is_some())
2838 .unwrap_or(name);
2839 let Some(opt) = registry.lookup(&target) else {
2840 tracing::warn!(option = %target, "set-option-value failed: unknown option");
2841 return false;
2842 };
2843 let value = match crate::boundary_config::value_from_wit(&value) {
2844 Ok(v) => v,
2845 Err(error) => {
2846 tracing::warn!(option = %target, %error, "set-option-value: malformed value");
2847 return false;
2848 }
2849 };
2850 match opt.set_value(&value) {
2851 Ok(()) => true,
2852 Err(error) => {
2853 tracing::warn!(option = %target, %error, "set-option-value rejected");
2854 false
2855 }
2856 }
2857 }
2858
2859 /// `set-option` (CI.7): override an existing option's value via the SAME
2860 /// `parse_and_set_command` path `:set name=value` uses (coerce + validate +
2861 /// publish `OptionChanged`). `false` on an unknown option / invalid value /
2862 /// no registry — a logged no-op, never a trap.
2863 /// `set-option-in-buffer`: the `:setlocal` front-end for a guest.
2864 ///
2865 /// Publishes a host-internal request rather than writing, for
2866 /// `enable-mode`'s reason: the buffer-local override layer lives on the
2867 /// Editor (`buffer_local_overrides`), and the plugin host holds a
2868 /// `ConfigRegistry` handle — which is the GLOBAL layer and the wrong scope
2869 /// entirely. Writing there is what `set-option` already does.
2870 ///
2871 /// Returns `false` only for what can be judged HERE: no bus, or an option
2872 /// name the registry does not know. Validity of the value is the Editor's
2873 /// to judge when it parses, so a `true` means "the request was sent and
2874 /// names a real option", not "the value stuck".
2875 fn set_option_in_buffer(&mut self, buffer: u64, name: String, value: String) -> bool {
2876 // Namespace resolution matches `set-option`: the plugin's OWN option
2877 // wins on a short name, so a config can still reach a core option by
2878 // its full name.
2879 let target = match self.config_registry.as_ref() {
2880 Some(registry) => {
2881 let namespaced = self
2882 .plugin_name
2883 .as_ref()
2884 .map(|id| format!("{id}.{name}"))
2885 .filter(|full| registry.lookup(full).is_some());
2886 match namespaced {
2887 Some(full) => full,
2888 None if registry.lookup(&name).is_some() => name,
2889 None => {
2890 tracing::warn!(
2891 option = %name,
2892 "set-option-in-buffer ignored: no option by that name"
2893 );
2894 return false;
2895 }
2896 }
2897 }
2898 None => {
2899 tracing::warn!(
2900 option = %name,
2901 "set-option-in-buffer ignored: plugin has no config registry wired"
2902 );
2903 return false;
2904 }
2905 };
2906 match &self.event_emit {
2907 Some(ctx) => {
2908 ctx.bus
2909 .publish(lattice_protocol::Event::BufferOptionOverrideRequested {
2910 buffer: lattice_protocol::ids::BufferId::new(buffer),
2911 option: format!("{target}={value}"),
2912 });
2913 true
2914 }
2915 None => {
2916 tracing::warn!(
2917 option = %target,
2918 "set-option-in-buffer dropped: no bus wired (plugin not spawned onto a bus)"
2919 );
2920 false
2921 }
2922 }
2923 }
2924
2925 fn set_option(&mut self, name: String, value: String) -> bool {
2926 let Some(registry) = self.config_registry.as_ref() else {
2927 tracing::warn!(
2928 option = %name,
2929 "set-option ignored: plugin has no config registry wired"
2930 );
2931 return false;
2932 };
2933 // The plugin's OWN option if it exists in its namespace (`style` →
2934 // `auto-pair.style`), else the raw name — so a plugin sets its own option
2935 // with a short name AND can set a core option by its full name.
2936 let target = self
2937 .plugin_name
2938 .as_ref()
2939 .map(|id| format!("{id}.{name}"))
2940 .filter(|full| registry.lookup(full).is_some())
2941 .unwrap_or(name);
2942 match registry.parse_and_set_command(&format!("{target}={value}")) {
2943 Ok(_) => true,
2944 Err(err) => {
2945 tracing::warn!(
2946 option = %target,
2947 %value,
2948 %err,
2949 "set-option failed (unknown option or invalid value)"
2950 );
2951 false
2952 }
2953 }
2954 }
2955}
2956
2957/// Host impl of the `modes` guest→host mode-declaration seam (PH7.11a, §5). The
2958/// guest calls `register-mode` (from its `register-modes` export) to declare a
2959/// minor mode; each records the declaration into the Store's
2960/// [`mode_host::ModeContributions`] so the host can drain it after registration
2961/// and register each into a `&mut ModeRegistry` (`spawn_mode_plugin`).
2962/// Sync + infallible (it only pushes — recording cannot trap; the register
2963/// outcome surfaces at drain time), so bindgen omits the outer `wasmtime::Result`
2964/// (the `grammar` / `config` shape). The WIT declaration projects to the native
2965/// [`mode_host::PluginModeDecl`] here (kind / policy / capability flags → native).
2966impl crate::keymap_host::bindings::lattice::plugin_host::keymap::Host for PluginState {
2967 fn register_binding(
2968 &mut self,
2969 binding_mode: crate::keymap_host::bindings::lattice::plugin_host::keymap::BindingMode,
2970 chord: String,
2971 command: String,
2972 ) -> bool {
2973 // Clone the wired context out first so the immutable borrow ends before
2974 // the `keymap_contributions` push (the `config_host` register-option
2975 // borrow-split precedent).
2976 let Some(ctx) = self.keymap_ctx.as_ref() else {
2977 tracing::warn!(
2978 chord,
2979 command,
2980 "register-binding skipped: no keymap wired (degraded — host not spawned onto a keymap)"
2981 );
2982 return false;
2983 };
2984 let keymap = ctx.keymap.clone();
2985 let commands = std::sync::Arc::clone(&ctx.commands);
2986 let plugin_id = ctx.plugin_id.0;
2987 let mode = crate::keymap_host::project_binding_mode(binding_mode);
2988
2989 let bound = crate::keymap_host::bind_user_keybinding(
2990 &keymap, &commands, plugin_id, mode, &chord, &command,
2991 );
2992 if bound {
2993 self.keymap_contributions
2994 .push(crate::keymap_host::KeymapBindingToken { mode, chord });
2995 }
2996 bound
2997 }
2998}
2999
3000impl crate::mode_host::bindings::lattice::plugin_host::modes::Host for PluginState {
3001 fn register_mode(
3002 &mut self,
3003 decl: crate::mode_host::bindings::lattice::plugin_host::modes::ModeDeclaration,
3004 ) {
3005 use crate::mode_host::bindings::lattice::plugin_host::modes::{
3006 ActivationPolicy as WitPolicy, BindingMode as WitBindingMode,
3007 ModeCapabilities as WitCaps, ModeKind as WitKind,
3008 OverridePriority as WitOverridePriority,
3009 };
3010 use crate::mode_host::{
3011 PluginKeymapBinding, PluginModeDecl, PluginModeKind, PluginModeOverride,
3012 };
3013 use lattice_keymap::BindingMode;
3014 use lattice_mode::{ActivationPolicy, CapabilitySet, ModeId, OverridePriority};
3015
3016 let kind = match decl.kind {
3017 WitKind::Major => PluginModeKind::Major,
3018 WitKind::Minor => PluginModeKind::Minor,
3019 };
3020 let policy = match decl.activation_policy {
3021 WitPolicy::Manual => ActivationPolicy::Manual,
3022 WitPolicy::Global => ActivationPolicy::Global,
3023 WitPolicy::Universal => ActivationPolicy::Universal,
3024 WitPolicy::Majors(ids) => {
3025 ActivationPolicy::Majors(ids.iter().map(|m| ModeId::new(m)).collect())
3026 }
3027 };
3028 // WIT `flags` project to the native bitflags one bit at a time (the
3029 // generated flags type is distinct from `CapabilitySet`).
3030 let mut caps = CapabilitySet::empty();
3031 caps.set(
3032 CapabilitySet::BUFFER_URI,
3033 decl.capabilities.contains(WitCaps::BUFFER_URI),
3034 );
3035 caps.set(CapabilitySet::LSP, decl.capabilities.contains(WitCaps::LSP));
3036 caps.set(
3037 CapabilitySet::TREE_SITTER,
3038 decl.capabilities.contains(WitCaps::TREE_SITTER),
3039 );
3040 caps.set(
3041 CapabilitySet::FOLDS,
3042 decl.capabilities.contains(WitCaps::FOLDS),
3043 );
3044 caps.set(
3045 CapabilitySet::WRITABLE,
3046 decl.capabilities.contains(WitCaps::WRITABLE),
3047 );
3048 caps.set(
3049 CapabilitySet::DIAGNOSTICS,
3050 decl.capabilities.contains(WitCaps::DIAGNOSTICS),
3051 );
3052
3053 // Project the keymap bindings (PH7.11b): WIT `binding-mode` → native
3054 // `BindingMode`; chord + command names cross as strings (resolved against
3055 // the `CommandRegistry` at bind time in `spawn_mode_plugin`).
3056 let keymap = decl
3057 .keymap
3058 .into_iter()
3059 .map(|b| PluginKeymapBinding {
3060 mode: match b.binding_mode {
3061 WitBindingMode::Normal => BindingMode::Normal,
3062 WitBindingMode::Insert => BindingMode::Insert,
3063 WitBindingMode::Visual => BindingMode::Visual,
3064 WitBindingMode::Select => BindingMode::Select,
3065 WitBindingMode::Replace => BindingMode::Replace,
3066 WitBindingMode::Command => BindingMode::Command,
3067 WitBindingMode::Search => BindingMode::Search,
3068 },
3069 chord: b.chord,
3070 command: b.command,
3071 })
3072 .collect();
3073
3074 // MO.1: option overrides. Name and value cross as strings and are
3075 // resolved against the `ConfigRegistry` at drain — the same shape the
3076 // keymap's command names take, and for the same reason: the registry
3077 // they must agree with is native, and a declaration is only data.
3078 let options = decl
3079 .options
3080 .into_iter()
3081 .map(|o| PluginModeOverride {
3082 name: o.name,
3083 value: o.value,
3084 priority: match o.priority {
3085 WitOverridePriority::Low => OverridePriority::Low,
3086 WitOverridePriority::Normal => OverridePriority::Normal,
3087 WitOverridePriority::High => OverridePriority::High,
3088 },
3089 })
3090 .collect();
3091
3092 self.mode_contributions.record(PluginModeDecl {
3093 id: decl.id,
3094 kind,
3095 policy,
3096 caps,
3097 keymap,
3098 // OM.2: crosses as a plain string; `register_plugin_mode` decides
3099 // whether it means anything (majors only) and the registry
3100 // indexes it.
3101 target_language: decl.target_language,
3102 options,
3103 });
3104 }
3105
3106 /// `enable-mode` (CI.4): request the Editor enable a minor mode globally. The
3107 /// guest can't reach the activator, so this publishes
3108 /// `Event::ModeEnablementRequested` onto the bus; the Editor flips the
3109 /// registry + re-activates open buffers. Degrades to a `warn` when no bus is
3110 /// wired (the "not boot-wired yet" case, like `emit-event`), never a trap.
3111 fn enable_mode(&mut self, id: String) {
3112 self.request_mode_enablement(id, true);
3113 }
3114
3115 /// `disable-mode` (CI.4): the inverse of [`enable_mode`](Self::enable_mode).
3116 fn disable_mode(&mut self, id: String) {
3117 self.request_mode_enablement(id, false);
3118 }
3119}
3120
3121impl PluginState {
3122 /// Shared body of `enable-mode` / `disable-mode` (CI.4): publish the
3123 /// host-internal enablement request onto the plugin's bus (the same
3124 /// `event_emit` handle `emit-event` uses).
3125 fn request_mode_enablement(&mut self, mode: String, enabled: bool) {
3126 match &self.event_emit {
3127 Some(ctx) => ctx
3128 .bus
3129 .publish(lattice_protocol::Event::ModeEnablementRequested { mode, enabled }),
3130 None => tracing::warn!(
3131 %mode,
3132 enabled,
3133 "enable-mode/disable-mode dropped: no bus wired (plugin not spawned onto a bus)"
3134 ),
3135 }
3136 }
3137}
3138
3139/// A host-issued plugin identity. Monotonic, allocated by the [`PluginHost`] at
3140/// instantiation — never supplied by the guest. It is the `u32` inside
3141/// [`SourceLayer::Plugin`], so every contribution a plugin registers (PH7.3+)
3142/// traces back to a provenance the guest cannot forge.
3143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3144pub struct PluginId(pub u32);
3145
3146/// The default on-disk module-cache directory,
3147/// `<config-home>/lattice/cache/plugin-modules/` — wasmtime's AOT-compiled
3148/// components, keyed by wasmtime on the component bytes and its own version.
3149/// Under the config home like every other lattice path, in the `cache`
3150/// subdirectory that says it is safe to delete. Falls back to the temp dir
3151/// when no config home resolves.
3152///
3153/// Was `dirs::cache_dir()/lattice/plugin-cache/`; [`migrate_cache_dirs`]
3154/// carries an existing one over so the first launch after an upgrade does not
3155/// recompile every plugin.
3156fn default_cache_dir() -> PathBuf {
3157 lattice_config::cache_home()
3158 .unwrap_or_else(std::env::temp_dir)
3159 .join("plugin-modules")
3160}
3161
3162/// The pre-0.9.2 cache directory, read once by [`migrate_cache_dirs`].
3163fn legacy_cache_dir() -> Option<PathBuf> {
3164 dirs::cache_dir().map(|d| d.join("lattice").join("plugin-cache"))
3165}
3166
3167/// Carry the module cache under the config home, once. Returns whether
3168/// anything moved. A cache that cannot be moved is simply rebuilt.
3169pub fn migrate_cache_dirs() -> bool {
3170 let Some(legacy) = legacy_cache_dir() else {
3171 return false;
3172 };
3173 lattice_config::migrate_path(&legacy, &default_cache_dir())
3174}
3175
3176/// The default per-plugin data-dir base, `<config-home>/lattice/plugins/` —
3177/// `~/.config/lattice/plugins/` on Linux AND macOS (honouring
3178/// `$XDG_CONFIG_HOME`), `%APPDATA%\lattice\plugins` on Windows. Each plugin's
3179/// private dir is `<base>/<plugin-name>/data/` (fragment §6), beside the
3180/// plugin itself: that is the tree `require`d plugins are built into, so one
3181/// directory is a plugin's whole home. Falls back to the temp dir if no config
3182/// home resolves.
3183///
3184/// **It used to be `dirs::data_dir()/lattice/plugins/`, and that lost data.**
3185/// On Linux `dirs::data_dir()` is `~/.local/share`, which is also where
3186/// `install.sh` puts the bundled plugins under its default `--prefix ~/.local`
3187/// — and the installer upgrades by replacing that directory and `rm -rf`-ing
3188/// the old one. Every reinstall therefore deleted every plugin's store.
3189/// Reproduced against the published v0.9.0 archive, 2026-09-22.
3190/// [`migrate_plugin_data`] carries surviving directories to the new base.
3191fn default_data_dir_base() -> PathBuf {
3192 data_dir_base_from(lattice_config::config_home().as_deref())
3193}
3194
3195/// The pure resolver behind [`default_data_dir_base`], split out so the
3196/// layout is testable without touching the process environment.
3197pub fn data_dir_base_from(config_home: Option<&Path>) -> PathBuf {
3198 config_home
3199 .map(Path::to_path_buf)
3200 .unwrap_or_else(std::env::temp_dir)
3201 .join("lattice")
3202 .join("plugins")
3203}
3204
3205/// The pre-2026-09-22 data-dir base, `dirs::data_dir()/lattice/plugins/`.
3206/// Read once at boot by [`migrate_plugin_data`]; nothing else should use it.
3207pub fn legacy_data_dir_base() -> PathBuf {
3208 dirs::data_dir()
3209 .unwrap_or_else(std::env::temp_dir)
3210 .join("lattice")
3211 .join("plugins")
3212}
3213
3214/// Move each `<old>/<name>/data` directory to `<new>/<name>/data`, returning
3215/// how many moved. Idempotent, and safe to call when `old` does not exist.
3216///
3217/// **Only the `data` subdirectory moves.** On Linux the old base doubles as
3218/// the install tree, so its `<name>/` directories also hold the bundled
3219/// plugins' components and manifests; moving a whole `<name>` directory would
3220/// uninstall the plugin. A `data` directory already present at the
3221/// destination is left alone and the old one stays put — whatever a newer
3222/// lattice wrote wins over the copy this is carrying.
3223///
3224/// Failures are logged and skipped: a plugin whose data cannot be moved (a
3225/// cross-device rename, a permission error) still starts, with its old state
3226/// where it was, rather than taking the editor down at boot.
3227pub fn migrate_plugin_data(old: &Path, new: &Path) -> usize {
3228 if old == new {
3229 return 0;
3230 }
3231 let Ok(entries) = std::fs::read_dir(old) else {
3232 return 0;
3233 };
3234 let mut moved = 0;
3235 for entry in entries.flatten() {
3236 let from = entry.path().join("data");
3237 if !from.is_dir() {
3238 continue;
3239 }
3240 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
3241 continue;
3242 };
3243 let to = new.join(&name).join("data");
3244 if to.exists() {
3245 tracing::debug!(plugin = %name, "plugin data already migrated; leaving the old copy");
3246 continue;
3247 }
3248 if let Some(parent) = to.parent()
3249 && let Err(e) = std::fs::create_dir_all(parent)
3250 {
3251 tracing::warn!(plugin = %name, error = %e, "could not create the plugin data directory");
3252 continue;
3253 }
3254 match std::fs::rename(&from, &to) {
3255 Ok(()) => {
3256 tracing::info!(plugin = %name, to = %to.display(), "moved plugin data beside the plugin");
3257 moved += 1;
3258 }
3259 Err(e) => {
3260 tracing::warn!(plugin = %name, error = %e, "could not move plugin data; leaving it in place");
3261 }
3262 }
3263 }
3264 moved
3265}
3266
3267/// The wasmtime engine, the (import-free) component linker, the on-disk module
3268/// cache, and the epoch ticker. One host per editor process; construct it once
3269/// (the engine owns Cranelift).
3270pub struct PluginHost {
3271 engine: Engine,
3272 linker: Linker<PluginState>,
3273 // A second linker for the SYNCHRONOUS grammar seam (PH7.7c). The shared
3274 // `linker` wires WASI *async* (picker/completion suspend at host calls); the
3275 // grammar trampoline calls the guest *synchronously* on the dispatch thread,
3276 // so its guest must never reach an async host import. This linker wires WASI
3277 // *sync* (`add_to_linker_sync`) + the sync `grammar` register import, so a
3278 // grammar guest's sync `instantiate` + `apply` calls have no async import to
3279 // invoke — the sync path is correct by construction, not by luck. Same
3280 // engine (shared AOT cache), just a different import table.
3281 grammar_linker: Linker<PluginState>,
3282 // A clone of the cache handed to the engine config; kept so callers can
3283 // read hit/miss stats. `Cache` is a cheap Arc-backed handle.
3284 cache: Cache,
3285 // Base dir under which each plugin's private data dir is created:
3286 // `<data_dir_base>/<plugin-id>/data/` (PH7.2, fragment §6).
3287 data_dir_base: PathBuf,
3288 // OR.1: one durable byte store per MANIFEST ID, handed to every
3289 // `PluginState` built for that id.
3290 //
3291 // Keyed by id rather than by instance, and that is the slice's whole point
3292 // rather than a caching detail. The picker seam, the grammar seam and the
3293 // event seam are separate `wasmtime::Store`s with separate memory; a store
3294 // per instance would mean N copies drifting, with find-node offering a node
3295 // `<CR>` cannot open and nothing reporting an error. Sharing the handle
3296 // makes a write on one seam visible to a read on another immediately —
3297 // before any flush, and without a reload.
3298 stores: Mutex<std::collections::HashMap<String, plugin_store::PluginStoreHandle>>,
3299 // Monotonic source of host-issued `PluginId`s. `&self` methods allocate,
3300 // so this is atomic.
3301 next_id: AtomicU32,
3302 // PO.5: the boundary tracer, so each instantiate/spawn path can stamp a
3303 // plugin's `PluginState.log_ctx` (the guest `logging` seam routes into it).
3304 // Set once by the loader (`set_tracer`) after it builds the tracer — the host
3305 // is constructed first (`install.rs`), so `OnceLock` gives set-once storage
3306 // through the shared `Arc<PluginHost>` without a constructor reorder. `None`
3307 // (unset) → a guest `log` degrades to a debug-drop, like `event_emit`.
3308 tracer: std::sync::OnceLock<crate::trace::PluginTracerHandle>,
3309 // PR.6: what the guest `project` seam answers from. Set once by boot
3310 // (`set_project_context`) after the resolver is registered; the host is
3311 // constructed first, so `OnceLock` gives set-once storage through the
3312 // shared `Arc<PluginHost>` without a constructor reorder — the `tracer`
3313 // precedent above. Unset → `root-for-*` returns `none`, which a real
3314 // editor never does and a stripped harness always does.
3315 project: std::sync::OnceLock<ProjectCtx>,
3316 // CG.4: the foreground-cancel registry `<C-g>` fires. Set once by
3317 // boot, like `tracer` / `project`; unset → guest calls run to their
3318 // budget and cannot be interrupted early, which is the pre-CG.4
3319 // behaviour.
3320 cancel: std::sync::OnceLock<lattice_mode::ForegroundCancelHandle>,
3321 // OC.2: the timer the wake seam sleeps on. Injected rather than owned
3322 // because this crate deliberately has no runtime — `tokio` is a
3323 // dev-dependency and `futures` was chosen over `tokio::sync` to keep it
3324 // that way. Set once by boot, like `tracer` / `project` / `cancel`; unset
3325 // → `wake-every` answers `0`, which is what every harness that never wires
3326 // one gets.
3327 sleeper: std::sync::OnceLock<wake::SleeperHandle>,
3328 // OA.14d: the option registry EVERY store reads through, stamped in
3329 // `new_store` like `project` rather than per spawn path.
3330 //
3331 // It used to be wired only by the seams that were noticed to need it, and
3332 // the seams that were not noticed simply answered `none` — `get-option`
3333 // inside `register-theme-elements` returned nothing at all, so org's
3334 // per-keyword colours were derived from the compiled default no matter what
3335 // anyone configured. That is the reported `todo-keyword-styles` bug, and it
3336 // is a wiring gap rather than an ordering one, so no event could have fixed
3337 // it. Reading configuration needs no plugin id and has nothing to wait for,
3338 // so like `project` there is no path that can forget it.
3339 config: std::sync::OnceLock<Arc<lattice_config::ConfigRegistry>>,
3340 // OA.23: answers "which file did this composed line come from". Abstract
3341 // for `project`'s reason — the plugin host cannot depend on
3342 // `lattice-multibuffer`, which sits above it. Unset answers `none`, which
3343 // is the honest degradation: a guest asking about an excerpt in a host
3344 // with no multibuffers is asking about something that is not there.
3345 excerpt_source: std::sync::OnceLock<lattice_core::ExcerptSourceResolverHandle>,
3346 /// OA.27: what a provider view is showing, for the `view-args` seam.
3347 view_args: std::sync::OnceLock<lattice_core::ViewArgsResolverHandle>,
3348 /// OA.30: the decoration-refresh counter, for `refresh-decorations`.
3349 decoration_epoch: std::sync::OnceLock<lattice_mode::DecorationEpochHandle>,
3350 /// CD.6b: the buffer store, for `clamp-position`.
3351 buffers: std::sync::OnceLock<lattice_mode::BufferStoreHandle>,
3352 // OC.3 / ML.6: what the `ui` seam acts on — the modeline element registry
3353 // and the bus content updates publish onto. Both halves are required (a
3354 // registry with no bus registers descriptors nothing ever repaints), so
3355 // they are set together like `project`'s resolver + buffer store. Unset →
3356 // `register-segment` returns false and `emit-segment` warns and drops.
3357 ui: std::sync::OnceLock<ui_host::UiCtx>,
3358 // Dropped last; keeps the ticker alive for the host's lifetime and stops
3359 // it on drop.
3360 _epoch_ticker: EpochTicker,
3361}
3362
3363impl PluginHost {
3364 /// Build a host with the default module-cache directory
3365 /// ([`default_cache_dir`]) and per-plugin data-dir base
3366 /// ([`default_data_dir_base`]). This is the production constructor.
3367 pub fn new() -> Result<Self, PluginHostError> {
3368 let base = default_data_dir_base();
3369 // Once, at boot: the module cache moved under the config home too, and
3370 // carrying it saves recompiling every plugin on the first launch.
3371 migrate_cache_dirs();
3372 // Once, at boot: carry plugin state written under the old base
3373 // (`dirs::data_dir()/lattice/plugins`) beside the plugins. A no-op
3374 // after the first run, and on a machine that never used it.
3375 migrate_plugin_data(&legacy_data_dir_base(), &base);
3376 Self::with_dirs(default_cache_dir(), base)
3377 }
3378
3379 /// Build a host caching compiled components under `cache_dir`, with the
3380 /// default per-plugin data-dir base. Kept as the narrow constructor the
3381 /// cache tests already use.
3382 pub fn with_cache_dir(cache_dir: impl Into<PathBuf>) -> Result<Self, PluginHostError> {
3383 Self::with_dirs(cache_dir, default_data_dir_base())
3384 }
3385
3386 /// Build a host with an explicit module-cache directory *and* per-plugin
3387 /// data-dir base. Capability tests point both at per-test tempdirs so the
3388 /// data-dir mounts and provenance are hermetic.
3389 ///
3390 /// The AOT (Cranelift) compile of a component is cached on disk by
3391 /// wasmtime, keyed on the component bytes, the compiler configuration, the
3392 /// target, and the wasmtime version — so a **second launch reuses the
3393 /// cached module** instead of recompiling (design.md §15 Q17). wasmtime
3394 /// owns the keying and invalidation; the host owns only the location.
3395 ///
3396 /// The linker is populated with the WASI (preview2) host functions once
3397 /// here; each plugin's *view* onto them is scoped per-`Store` from its
3398 /// grant (PH7.2). Components that import no WASI (the hand-written
3399 /// lifecycle fixtures) instantiate fine against the populated linker.
3400 pub fn with_dirs(
3401 cache_dir: impl Into<PathBuf>,
3402 data_dir_base: impl Into<PathBuf>,
3403 ) -> Result<Self, PluginHostError> {
3404 let cache_dir = cache_dir.into();
3405 std::fs::create_dir_all(&cache_dir).map_err(|e| PluginHostError::Cache(e.into()))?;
3406 let mut cache_config = CacheConfig::new();
3407 cache_config.with_directory(cache_dir);
3408 let cache = Cache::new(cache_config).map_err(|e| PluginHostError::Cache(e.into()))?;
3409
3410 let mut config = Config::new();
3411 // Async is always available on the engine in wasmtime 46; the generated
3412 // exports are async (see the `bindgen!` above). Fuel + epoch give the
3413 // two hard per-call budgets.
3414 config.consume_fuel(true);
3415 config.epoch_interruption(true);
3416 // Transparent AOT artifact cache: `Component::new` (in `compile`) skips
3417 // recompilation on a cache hit.
3418 config.cache(Some(cache.clone()));
3419
3420 let engine = Engine::new(&config).map_err(|e| PluginHostError::Engine(e.into()))?;
3421 let mut linker = Linker::new(&engine);
3422 // Wire the WASI host functions to `PluginState`'s `WasiView`. Async to
3423 // match the canonical ABI — a WASI host call suspends the guest stack,
3424 // never pins the caller's thread.
3425 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
3426 .map_err(|e| PluginHostError::Linker(e.into()))?;
3427 // The `host-services` guest→host seam (PH7.4b). Sync host funcs are fine
3428 // in the async linker; `walk` is bounded, so it does not need to suspend.
3429 crate::lattice::plugin_host::host_services::add_to_linker::<_, HasSelf<_>>(
3430 &mut linker,
3431 |state: &mut PluginState| state,
3432 )
3433 .map_err(|e| PluginHostError::Linker(e.into()))?;
3434 // OR.5b: the `picker-registry` seam a picker plugin declares its sources
3435 // through. A sync host func that only records into `PluginState`; inert
3436 // for every world that does not import it.
3437 crate::picker_host::bindings::lattice::plugin_host::picker_registry::add_to_linker::<
3438 _,
3439 HasSelf<_>,
3440 >(&mut linker, |state: &mut PluginState| state)
3441 .map_err(|e| PluginHostError::Linker(e.into()))?;
3442 // MV.1: the `multibuffer-view-registry` seam a view plugin declares
3443 // its views through. Same shape and same inertness.
3444 crate::multibuffer_view_host::bindings::lattice::plugin_host::multibuffer_view_registry::add_to_linker::<
3445 _,
3446 HasSelf<_>,
3447 >(&mut linker, |state: &mut PluginState| state)
3448 .map_err(|e| PluginHostError::Linker(e.into()))?;
3449 // PM.7: the `plugin-manager` (`require`) seam. A sync host func — it
3450 // only records into `PluginState` — and inert for every world that
3451 // does not import `plugin-manager`, which is all of them but the
3452 // config/init world.
3453 crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::add_to_linker::<_, HasSelf<_>>(
3454 &mut linker,
3455 |state: &mut PluginState| state,
3456 )
3457 .map_err(|e| PluginHostError::Linker(e.into()))?;
3458 // The `events` guest→host subscription seam (PH7.8b). Wired into the
3459 // ASYNC linker — the events-plugin world's `on-event` delivery is async
3460 // (off the keystroke path), unlike the sync grammar seam. `subscribe` is
3461 // a sync host func (it only records into `PluginState`) and is inert for
3462 // worlds that don't import `events` (picker/completion/the scaffold).
3463 crate::events_host::bindings::lattice::plugin_host::events::add_to_linker::<_, HasSelf<_>>(
3464 &mut linker,
3465 |state: &mut PluginState| state,
3466 )
3467 .map_err(|e| PluginHostError::Linker(e.into()))?;
3468 // The `config` guest→host option seam (PH7.10). Sync host funcs
3469 // (`register-option` / `get-option` only touch the `ConfigRegistry`),
3470 // inert for worlds that don't import `config`.
3471 crate::config_host::bindings::lattice::plugin_host::config::add_to_linker::<_, HasSelf<_>>(
3472 &mut linker,
3473 |state: &mut PluginState| state,
3474 )
3475 .map_err(|e| PluginHostError::Linker(e.into()))?;
3476 // TC.4: the `theme` guest->host element-declaration seam. Sync host
3477 // func (`register-element` only touches the theme registry), inert for
3478 // worlds that don't import `theme`.
3479 crate::theme_host::bindings::lattice::plugin_host::theme::add_to_linker::<_, HasSelf<_>>(
3480 &mut linker,
3481 |state: &mut PluginState| state,
3482 )
3483 .map_err(|e| PluginHostError::Linker(e.into()))?;
3484 // SG.3a: the `signs` guest→host sign-declaration seam. Sync host func
3485 // (`define-sign` only writes the sign registry), inert for worlds that
3486 // don't import `signs` — but wired UNCONDITIONALLY, because an
3487 // unwired import fails instantiation of the WHOLE component, not just
3488 // the call.
3489 crate::sign_host::bindings::lattice::plugin_host::signs::add_to_linker::<_, HasSelf<_>>(
3490 &mut linker,
3491 |state: &mut PluginState| state,
3492 )
3493 .map_err(|e| PluginHostError::Linker(e.into()))?;
3494 // CR.3: the `help` guest→host topic-registration seam. Sync host func
3495 // (`register-topic` only records into `PluginState`), inert for worlds
3496 // that don't import `help`.
3497 crate::help_host::bindings::lattice::plugin_host::help::add_to_linker::<_, HasSelf<_>>(
3498 &mut linker,
3499 |state: &mut PluginState| state,
3500 )
3501 .map_err(|e| PluginHostError::Linker(e.into()))?;
3502 // LG.3c: the `language` seam. Sync host func (`register-language`
3503 // only records into `PluginState`), inert for worlds that don't
3504 // import `language`.
3505 crate::language_host::bindings::lattice::plugin_host::language::add_to_linker::<
3506 _,
3507 HasSelf<_>,
3508 >(&mut linker, |state: &mut PluginState| state)
3509 .map_err(|e| PluginHostError::Linker(e.into()))?;
3510 // CR.4: the `dashboard` declaration seam. `register-section` only
3511 // records into `PluginState`, so it is sync and safe on either
3512 // linker; inert for worlds that don't import `dashboard`.
3513 crate::dashboard_host::bindings::lattice::plugin_host::dashboard::add_to_linker::<
3514 _,
3515 HasSelf<_>,
3516 >(&mut linker, |state: &mut PluginState| state)
3517 .map_err(|e| PluginHostError::Linker(e.into()))?;
3518 // The `modes` guest→host mode-declaration seam (PH7.11a). Sync host func
3519 // (`register-mode` only records into `PluginState`), inert for worlds that
3520 // don't import `modes`.
3521 crate::mode_host::bindings::lattice::plugin_host::modes::add_to_linker::<_, HasSelf<_>>(
3522 &mut linker,
3523 |state: &mut PluginState| state,
3524 )
3525 .map_err(|e| PluginHostError::Linker(e.into()))?;
3526 // The `keymap` guest→host binding-registration seam (PL8.D.1). Sync host
3527 // func (`register-binding` resolves + binds into `KeymapLayer::User`,
3528 // recording a teardown token — no await), inert for worlds that don't
3529 // import `keymap`. Async linker: registration is off the keystroke path
3530 // (binding *resolution* stays native).
3531 crate::keymap_host::bindings::lattice::plugin_host::keymap::add_to_linker::<_, HasSelf<_>>(
3532 &mut linker,
3533 |state: &mut PluginState| state,
3534 )
3535 .map_err(|e| PluginHostError::Linker(e.into()))?;
3536 // The `logging` guest→host seam (PO.5, Layer 2). Sync host func (`log`
3537 // only pushes into the `PluginTracer` ring), wired into the ASYNC linker —
3538 // logging is off the keystroke path (NOT in the sync grammar linker, so a
3539 // grammar guest can never reach it). Inert for worlds that don't import
3540 // `logging`. Shared across the async-seam worlds via the bindgen
3541 // `with`-reuse of this one generated module.
3542 crate::lattice::plugin_host::logging::add_to_linker::<_, HasSelf<_>>(
3543 &mut linker,
3544 |state: &mut PluginState| state,
3545 )
3546 .map_err(|e| PluginHostError::Linker(e.into()))?;
3547 // PR.6: the `project` guest→host seam. Sync host funcs (a cached
3548 // directory walk), wired here beside `logging` and inert for worlds
3549 // that don't import it. Shared across the async-seam worlds via the
3550 // bindgen `with`-reuse of this one generated module.
3551 crate::lattice::plugin_host::project::add_to_linker::<_, HasSelf<_>>(
3552 &mut linker,
3553 |state: &mut PluginState| state,
3554 )
3555 .map_err(|e| PluginHostError::Linker(e.into()))?;
3556 // OC.3 / ML.6: the `ui` modeline-contribution seam. Sync host funcs (a
3557 // registry write and a bus publish), wired here beside `logging` /
3558 // `project` and inert for worlds that don't import it.
3559 crate::lattice::plugin_host::ui::add_to_linker::<_, HasSelf<_>>(
3560 &mut linker,
3561 |state: &mut PluginState| state,
3562 )
3563 .map_err(|e| PluginHostError::Linker(e.into()))?;
3564 // Multi-seam support (AP.1 spike): a single plugin `.wasm` may `provide`
3565 // grammar AND async seams (auto-pair: grammar + modes + config). Its
3566 // import set is fixed, so the async linker (used for the mode/config
3567 // drains) must ALSO satisfy the grammar + buffer imports. Both are sync
3568 // host funcs (register-* only record; `document` reads are table lookups),
3569 // so they are inert here for the async-only worlds and correct for a
3570 // combined component. Symmetric to grammar-linker below.
3571 crate::grammar_host::bindings::lattice::plugin_host::grammar::add_to_linker::<_, HasSelf<_>>(
3572 &mut linker,
3573 |state: &mut PluginState| state,
3574 )
3575 .map_err(|e| PluginHostError::Linker(e.into()))?;
3576 crate::grammar_host::bindings::lattice::plugin_host::buffer::add_to_linker::<_, HasSelf<_>>(
3577 &mut linker,
3578 |state: &mut PluginState| state,
3579 )
3580 .map_err(|e| PluginHostError::Linker(e.into()))?;
3581 // TS.1: the `tree-sitter` `tree-snapshot` / `node` resources, on the
3582 // async linker too (superset symmetry with `buffer`) — a combined plugin
3583 // instantiated here for its async drains still imports `tree-sitter` via
3584 // `apply-action`. The reads are sync host-table lookups; inert for worlds
3585 // that don't import it.
3586 crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::add_to_linker::<
3587 _,
3588 HasSelf<_>,
3589 >(&mut linker, |state: &mut PluginState| state)
3590 .map_err(|e| PluginHostError::Linker(e.into()))?;
3591 // The grammar seam's SYNC linker (PH7.7b/c). The `grammar` register API
3592 // (`register-*`) is guest→host sync host funcs (they only record into
3593 // `PluginState`). It is wired into a SECOND linker whose WASI is `sync`
3594 // — so a grammar guest instantiated here has no async host import, and
3595 // the trampoline's synchronous `apply` calls on the dispatch thread are
3596 // correct by construction (the PH7.7 fork). Same `engine`, so the AOT
3597 // cache is shared; only the import table differs from the async `linker`.
3598 let mut grammar_linker = Linker::new(&engine);
3599 wasmtime_wasi::p2::add_to_linker_sync(&mut grammar_linker)
3600 .map_err(|e| PluginHostError::Linker(e.into()))?;
3601 crate::grammar_host::bindings::lattice::plugin_host::grammar::add_to_linker::<_, HasSelf<_>>(
3602 &mut grammar_linker,
3603 |state: &mut PluginState| state,
3604 )
3605 .map_err(|e| PluginHostError::Linker(e.into()))?;
3606 // AP.0.1: the `buffer` `document` resource, so a grammar action's
3607 // `borrow<document>` param resolves. On the SYNC grammar linker only —
3608 // the reads are synchronous host-table lookups (no I/O), correct for the
3609 // dispatch-thread trampoline.
3610 crate::grammar_host::bindings::lattice::plugin_host::buffer::add_to_linker::<_, HasSelf<_>>(
3611 &mut grammar_linker,
3612 |state: &mut PluginState| state,
3613 )
3614 .map_err(|e| PluginHostError::Linker(e.into()))?;
3615 // TS.1: the `tree-sitter` resources on the SYNC grammar linker, so a
3616 // grammar action's `option<borrow<tree-snapshot>>` param resolves. Reads
3617 // are synchronous host-table lookups + parse-free tree walks (no I/O, no
3618 // parse — the tree is already there), correct for the dispatch-thread
3619 // trampoline.
3620 crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::add_to_linker::<
3621 _,
3622 HasSelf<_>,
3623 >(&mut grammar_linker, |state: &mut PluginState| state)
3624 .map_err(|e| PluginHostError::Linker(e.into()))?;
3625 // OR.5b: `picker-registry` on the SYNC grammar linker too. Org provides
3626 // both `grammar` and `picker-source`, and a component's import set must
3627 // resolve on EVERY linker it is instantiated against — an import absent
3628 // here fails the WHOLE component, not one seam. That is the OC.2 scar,
3629 // and this is the fifth seam to be wired on both for it.
3630 crate::picker_host::bindings::lattice::plugin_host::picker_registry::add_to_linker::<
3631 _,
3632 HasSelf<_>,
3633 >(&mut grammar_linker, |state: &mut PluginState| state)
3634 .map_err(|e| PluginHostError::Linker(e.into()))?;
3635 // MV.1: `multibuffer-view-registry` on the SYNC grammar linker too, for
3636 // the reason directly above and with the same consequence for getting
3637 // it wrong. Org will provide `grammar` AND `multibuffer-view-source`,
3638 // and a component's import set must resolve on EVERY linker it is
3639 // instantiated against — an import absent here fails the WHOLE
3640 // component, silently, not just the one seam. That is the OC.2 scar:
3641 // one `logging::log` call once took org down entirely.
3642 crate::multibuffer_view_host::bindings::lattice::plugin_host::multibuffer_view_registry::add_to_linker::<
3643 _,
3644 HasSelf<_>,
3645 >(&mut grammar_linker, |state: &mut PluginState| state)
3646 .map_err(|e| PluginHostError::Linker(e.into()))?;
3647 // Multi-seam support (AP.1 spike): a combined plugin instantiated here for
3648 // its GRAMMAR drain still imports its `modes` / `config` seams, so the sync
3649 // grammar linker must satisfy them too. Both `register-mode` /
3650 // `register-option` / `get-option` are sync host funcs that only record
3651 // into `PluginState` (never called on the apply-action hot path — only
3652 // during the registration exports, which run on the async drains), so they
3653 // are safe here and inert for grammar-only guests. `logging` is
3654 // deliberately NOT added — the combined `auto-pair` world omits it, so the
3655 // "no logging reachable from the grammar hot path" invariant holds.
3656 crate::config_host::bindings::lattice::plugin_host::config::add_to_linker::<_, HasSelf<_>>(
3657 &mut grammar_linker,
3658 |state: &mut PluginState| state,
3659 )
3660 .map_err(|e| PluginHostError::Linker(e.into()))?;
3661 // TC.6: a multi-seam component that provides BOTH `grammar` and `theme`
3662 // (treesitter-context) is instantiated against this sync linker for its
3663 // grammar seam, and instantiation must satisfy EVERY import the world
3664 // declares — not only the ones that seam uses. Registering an element
3665 // touches the theme registry and nothing else, so it is safe on the
3666 // sync path; leaving it out simply made the whole component fail to
3667 // load, which is how this was found.
3668 crate::theme_host::bindings::lattice::plugin_host::theme::add_to_linker::<_, HasSelf<_>>(
3669 &mut grammar_linker,
3670 |state: &mut PluginState| state,
3671 )
3672 .map_err(|e| PluginHostError::Linker(e.into()))?;
3673 // SG.3a, for the TC.6 reason above, and found the same way: org
3674 // provides BOTH `grammar` and `signs` (OA.30's agenda marks declare
3675 // the `>` they paint), so its component is instantiated against this
3676 // sync linker for its grammar seam — and instantiation must satisfy
3677 // EVERY import the world declares, not only the ones that seam uses.
3678 // `define-sign` writes the sign registry and nothing else, so it is
3679 // safe here; leaving it out made the WHOLE org plugin fail to load
3680 // with "instance export `define-sign` has the wrong type", which
3681 // reads like a WIT mismatch rather than a missing linker entry.
3682 crate::sign_host::bindings::lattice::plugin_host::signs::add_to_linker::<_, HasSelf<_>>(
3683 &mut grammar_linker,
3684 |state: &mut PluginState| state,
3685 )
3686 .map_err(|e| PluginHostError::Linker(e.into()))?;
3687 // CR.3, for the TC.6 reason above: a multi-seam component providing
3688 // BOTH `grammar` and `help` is instantiated against this sync linker
3689 // for its grammar seam, and instantiation must satisfy EVERY import
3690 // the world declares, not only the ones that seam uses. Recording a
3691 // topic touches `PluginState` and nothing else, so it is safe here;
3692 // leaving it out would make the whole component fail to load.
3693 crate::help_host::bindings::lattice::plugin_host::help::add_to_linker::<_, HasSelf<_>>(
3694 &mut grammar_linker,
3695 |state: &mut PluginState| state,
3696 )
3697 .map_err(|e| PluginHostError::Linker(e.into()))?;
3698 // LG.3c, for the same TC.6 reason: a multi-seam component providing
3699 // BOTH `grammar` and `language` instantiates against this sync linker
3700 // for its grammar seam, and instantiation must satisfy EVERY import
3701 // the world declares. Recording a language touches `PluginState` and
3702 // nothing else, so it is safe here.
3703 crate::language_host::bindings::lattice::plugin_host::language::add_to_linker::<
3704 _,
3705 HasSelf<_>,
3706 >(&mut grammar_linker, |state: &mut PluginState| state)
3707 .map_err(|e| PluginHostError::Linker(e.into()))?;
3708 // CR.4: and on the sync linker, which is the one `dashboard-plugin`
3709 // actually instantiates against — `render-section` runs inside the
3710 // compositor and must not suspend.
3711 crate::dashboard_host::bindings::lattice::plugin_host::dashboard::add_to_linker::<
3712 _,
3713 HasSelf<_>,
3714 >(&mut grammar_linker, |state: &mut PluginState| state)
3715 .map_err(|e| PluginHostError::Linker(e.into()))?;
3716 crate::mode_host::bindings::lattice::plugin_host::modes::add_to_linker::<_, HasSelf<_>>(
3717 &mut grammar_linker,
3718 |state: &mut PluginState| state,
3719 )
3720 .map_err(|e| PluginHostError::Linker(e.into()))?;
3721 // PC.4, for the same TC.6 reason: the `project` plugin provides BOTH
3722 // `grammar` (its `:project-*` ex-commands) and imports `project` (it
3723 // READS roots and never supplies one), so its component is instantiated
3724 // against this sync linker for the grammar seam and instantiation must
3725 // satisfy EVERY import the world declares — not only the ones that seam
3726 // uses. Without this the WHOLE component fails to load with
3727 // "`root-for-buffer` has the wrong type / function implementation is
3728 // missing", which is how it was found.
3729 //
3730 // Safe here, and `project.wit` already promises it: "Sync, and available
3731 // in every world. It may walk the filesystem on a cache miss, but it
3732 // runs on the plugin's own store and task — never the UI or actor
3733 // thread." The host impl is a sync `fn` over a cached resolver, so there
3734 // is nothing to suspend on.
3735 crate::lattice::plugin_host::project::add_to_linker::<_, HasSelf<_>>(
3736 &mut grammar_linker,
3737 |state: &mut PluginState| state,
3738 )
3739 .map_err(|e| PluginHostError::Linker(e.into()))?;
3740 // OM.11, for the same TC.6 reason: org provides BOTH `grammar` and
3741 // `picker-source` (refile's target list), and a picker source needs
3742 // `walk`. Instantiation must satisfy every import the world declares,
3743 // so without this the whole component fails to load on the grammar
3744 // seam — which is how this was found, and what the `multiseam` fixture
3745 // now pins (it imports `host-services` for exactly this reason).
3746 //
3747 // `walk` and `read-file` are sync, bounded host funcs. What this does
3748 // NOT wire is `logging`, still deliberately absent, so "no logging
3749 // reachable from the grammar hot path" stays structural.
3750 //
3751 // **This comment used to claim the guest could already read files here
3752 // through WASI, and that was wrong.** `add_to_linker_sync` does give the
3753 // guest a filesystem view, but `wasmtime-wasi`'s sync filesystem shim
3754 // blocks on a runtime internally, and this thread is already inside one
3755 // — so a grammar action calling `std::fs::read_to_string` panics with
3756 // "Cannot start a runtime from within a runtime" rather than reading
3757 // anything. Found by org's capture trying exactly that (OC.5a).
3758 //
3759 // That is why `read-file` exists on `host-services`: a host-side read,
3760 // capability-gated like `walk`, is the only read reachable from this
3761 // seam. Async seams (picker / completion, on the async `linker` above)
3762 // are unaffected and use WASI directly — org's picker source does.
3763 crate::lattice::plugin_host::host_services::add_to_linker::<_, HasSelf<_>>(
3764 &mut grammar_linker,
3765 |state: &mut PluginState| state,
3766 )
3767 .map_err(|e| PluginHostError::Linker(e.into()))?;
3768 // OC.3 / ML.6, for the same TC.6 reason, and the plan for that slice
3769 // predicted otherwise — it said `ui` would be "wired on the async linker
3770 // only, so the modeline is structurally unreachable from the keystroke
3771 // path". That mechanism does not survive the Component Model. A
3772 // component's import set is fixed for the whole artefact, and org — the
3773 // plugin this seam exists for — provides `grammar` too, so an import
3774 // absent here fails the WHOLE plugin rather than one seam. Org has
3775 // already been broken in exactly this way once, by a single
3776 // `logging::log` call (see its world comment).
3777 //
3778 // The guarantee the plan wanted is kept one layer in instead:
3779 // `instantiate_grammar_plugin` clears `PluginState::ui`, so a grammar
3780 // action's `emit-segment` finds no context and warns + drops. That is
3781 // the `config` / `theme` / `keymap` shape, and unlike a linker omission
3782 // it is testable — `a_grammar_action_cannot_reach_the_modeline` is the
3783 // test.
3784 crate::lattice::plugin_host::ui::add_to_linker::<_, HasSelf<_>>(
3785 &mut grammar_linker,
3786 |state: &mut PluginState| state,
3787 )
3788 .map_err(|e| PluginHostError::Linker(e.into()))?;
3789 // OC.6, and the fifth instance of the same TC.6 reason. Org bridges a
3790 // chord to its own async side — the clock's session, wake and modeline
3791 // all live on its event actor (design D6) — so its component imports
3792 // `events`, and a component's import set is fixed for the whole
3793 // artefact. Without this the grammar seam of that same `.wasm` fails to
3794 // instantiate and org loses its entire keymap, not just its clock.
3795 //
3796 // All three functions are safe here. `subscribe` only records into
3797 // `PluginState`. `wake-every` finds `wake: None` on a grammar store —
3798 // that field is filled in by `spawn_event_plugin` alone — so it answers
3799 // `0` and warns, which is what keeps the periodic wake off the keystroke
3800 // path now that the import has to be reachable from it. `cancel-wake` on
3801 // a store with no wake context is a no-op by contract.
3802 crate::events_host::bindings::lattice::plugin_host::events::add_to_linker::<_, HasSelf<_>>(
3803 &mut grammar_linker,
3804 |state: &mut PluginState| state,
3805 )
3806 .map_err(|e| PluginHostError::Linker(e.into()))?;
3807 let epoch_ticker = EpochTicker::spawn(&engine, EPOCH_TICK_INTERVAL)?;
3808
3809 Ok(Self {
3810 engine,
3811 linker,
3812 grammar_linker,
3813 cache,
3814 data_dir_base: data_dir_base.into(),
3815 next_id: AtomicU32::new(0),
3816 tracer: std::sync::OnceLock::new(),
3817 project: std::sync::OnceLock::new(),
3818 cancel: std::sync::OnceLock::new(),
3819 sleeper: std::sync::OnceLock::new(),
3820 config: std::sync::OnceLock::new(),
3821 excerpt_source: std::sync::OnceLock::new(),
3822 view_args: std::sync::OnceLock::new(),
3823 decoration_epoch: std::sync::OnceLock::new(),
3824 buffers: std::sync::OnceLock::new(),
3825 ui: std::sync::OnceLock::new(),
3826 stores: Mutex::new(std::collections::HashMap::new()),
3827 _epoch_ticker: epoch_ticker,
3828 })
3829 }
3830
3831 /// OT.3b: where a plugin's private data lives — `<base>/<id>/data/`, the
3832 /// same directory `build_plugin_wasi` grants it.
3833 ///
3834 /// Exposed so a seam adapter can persist across restarts beside the
3835 /// plugin's own data (the agenda result cache), and so uninstalling a
3836 /// plugin removes what it cached. `None` for an id that is not a safe
3837 /// directory name — the same refusal `build_plugin_wasi` makes, rather
3838 /// than a second opinion about it.
3839 pub fn plugin_data_dir(&self, plugin_id: &str) -> Option<PathBuf> {
3840 crate::manifest::is_safe_plugin_id(plugin_id)
3841 .then(|| self.data_dir_base.join(plugin_id).join("data"))
3842 }
3843
3844 /// OR.1: the shared byte store for `plugin_id`, opened on first use and
3845 /// reused for every later seam instance of the same id.
3846 ///
3847 /// `None` for an id that is not a safe directory name — the same refusal
3848 /// [`plugin_data_dir`](Self::plugin_data_dir) makes, rather than a second
3849 /// opinion about it. Such a plugin gets no data mount either, so a store
3850 /// would have nowhere to live.
3851 fn store_for(&self, plugin_id: &str) -> Option<plugin_store::PluginStoreHandle> {
3852 let dir = self.plugin_data_dir(plugin_id)?;
3853 let mut stores = self
3854 .stores
3855 .lock()
3856 .unwrap_or_else(|poisoned| poisoned.into_inner());
3857 if let Some(existing) = stores.get(plugin_id) {
3858 return Some(existing.clone());
3859 }
3860 // The dir is created by `build_plugin_wasi` on the manifest paths, but
3861 // not on every path that names a plugin, so create it here too rather
3862 // than depending on an ordering the compiler cannot check. A failure
3863 // degrades at flush time (logged, dropped), never here.
3864 let _ = std::fs::create_dir_all(&dir);
3865 let handle: plugin_store::PluginStoreHandle =
3866 Arc::new(Mutex::new(plugin_store::PluginStore::open(&dir)));
3867 plugin_store::register(&handle);
3868 stores.insert(plugin_id.to_string(), handle.clone());
3869 Some(handle)
3870 }
3871
3872 /// OR.1: read one key out of a plugin's store, host-side.
3873 ///
3874 /// The store is the guest's schema and the host never interprets it — this
3875 /// hands back the same opaque bytes `store-get` would, without
3876 /// instantiating anything. It exists because a *reader* of a plugin's index
3877 /// may be host-side (a multibuffer provider) and because a test that asked
3878 /// the writing guest what it wrote would pass against a per-instance store,
3879 /// which is the exact drift the store exists to prevent.
3880 ///
3881 /// `None` for an unknown plugin id, an unsafe one, or a key with nothing
3882 /// under it — the three are indistinguishable to a caller, and all three
3883 /// mean "build it".
3884 pub fn plugin_store_get(&self, plugin_id: &str, key: &str) -> Option<Vec<u8>> {
3885 let store = self.store_for(plugin_id)?;
3886 let guard = store.lock().unwrap_or_else(|p| p.into_inner());
3887 guard.get(key)
3888 }
3889
3890 /// Install the boundary tracer (PO.5) — the loader calls this once, after it
3891 /// builds the tracer, so every subsequent instantiate/spawn stamps the
3892 /// plugin's `log_ctx` and the guest `logging` seam routes into the ring.
3893 /// Idempotent: a second call is ignored (the tracer is set once per host).
3894 pub fn set_tracer(&self, tracer: crate::trace::PluginTracerHandle) {
3895 let _ = self.tracer.set(tracer);
3896 }
3897
3898 /// Build the [`LogCtx`] for a freshly-allocated `id` — `Some` once a tracer is
3899 /// installed ([`set_tracer`](Self::set_tracer)), else `None` (a guest `log`
3900 /// then degrades to a debug-drop). Stamped onto the store in each async
3901 /// instantiate/spawn path.
3902 fn log_ctx_for(&self, id: PluginId) -> Option<LogCtx> {
3903 self.tracer.get().map(|tracer| LogCtx {
3904 plugin: id.0,
3905 tracer: tracer.clone(),
3906 })
3907 }
3908
3909 /// PR.6: hand the host what the guest `project` seam answers from.
3910 ///
3911 /// Called once at boot after the resolver is registered. Idempotent —
3912 /// a second call is ignored, like [`set_tracer`](Self::set_tracer).
3913 pub fn set_project_context(
3914 &self,
3915 resolver: lattice_core::ProjectResolverHandle,
3916 buffers: lattice_mode::BufferStoreHandle,
3917 ) {
3918 let _ = self.project.set(ProjectCtx { resolver, buffers });
3919 }
3920
3921 /// CG.4: hand the host the foreground-cancel registry, so a running
3922 /// guest call can be interrupted by `<C-g>`.
3923 ///
3924 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3925 pub fn set_foreground_cancel(&self, cancel: lattice_mode::ForegroundCancelHandle) {
3926 let _ = self.cancel.set(cancel);
3927 }
3928
3929 /// OC.2: hand the host the timer the `wake-every` seam sleeps on.
3930 ///
3931 /// This crate owns no runtime, so it cannot construct one — the caller that
3932 /// spawns the actors is the one that has an executor to sleep on, and it
3933 /// supplies the [`Sleeper`] here. Unset leaves `wake-every` answering `0`.
3934 ///
3935 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3936 pub fn set_sleeper(&self, sleeper: wake::SleeperHandle) {
3937 let _ = self.sleeper.set(sleeper);
3938 }
3939
3940 /// OA.14d: hand the host the option registry every store reads through.
3941 ///
3942 /// The floor, not a replacement for the per-seam wiring: a seam that cannot
3943 /// function without a registry (`config` itself) still takes one as an
3944 /// argument, because "this seam requires it" and "any guest may read an
3945 /// option" are different claims. What this closes is the second one, which
3946 /// had been answered seam by seam and was therefore wrong for whichever
3947 /// seams nobody had thought about — `theme` and `language` among them, the
3948 /// two org reads its keyword set from at load.
3949 ///
3950 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3951 pub fn set_config_registry(&self, registry: Arc<lattice_config::ConfigRegistry>) {
3952 let _ = self.config.set(registry);
3953 }
3954
3955 /// OA.23: hand the host what resolves a composed line to its source file.
3956 ///
3957 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3958 pub fn set_excerpt_source_resolver(&self, resolver: lattice_core::ExcerptSourceResolverHandle) {
3959 let _ = self.excerpt_source.set(resolver);
3960 }
3961
3962 /// HB.2b: whether a resolver was ever wired.
3963 ///
3964 /// Every store is stamped from this slot at creation, so a host that
3965 /// reaches its first `instantiate_*` unwired hands every guest a seam that
3966 /// answers `none` forever — and answering `none` is a legitimate reply the
3967 /// guest cannot tell apart from "this line is not composed". Exposed so the
3968 /// boot pin can assert the wiring rather than a reading of `install`.
3969 pub fn excerpt_source_wired(&self) -> bool {
3970 self.excerpt_source.get().is_some()
3971 }
3972
3973 /// OA.27: hand the host what answers "what is this provider view showing".
3974 ///
3975 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3976 pub fn set_view_args_resolver(&self, resolver: lattice_core::ViewArgsResolverHandle) {
3977 let _ = self.view_args.set(resolver);
3978 }
3979
3980 /// OA.27: whether a resolver was ever wired.
3981 ///
3982 /// Exposed for [`excerpt_source_wired`](Self::excerpt_source_wired)'s
3983 /// reason, and it bites harder here: an unwired `view-args` answers an
3984 /// empty list, a guest parses that as a fresh view, and every chord that
3985 /// walks the view then silently starts over from the default. There is no
3986 /// error anywhere on that path — which is precisely how the bug this seam
3987 /// replaces survived.
3988 pub fn view_args_wired(&self) -> bool {
3989 self.view_args.get().is_some()
3990 }
3991
3992 /// CD.6b: hand the host the buffer store `clamp-position` measures.
3993 ///
3994 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3995 pub fn set_buffer_store(&self, buffers: lattice_mode::BufferStoreHandle) {
3996 let _ = self.buffers.set(buffers);
3997 }
3998
3999 /// CD.6b: whether a buffer store was ever wired.
4000 ///
4001 /// Pinned at boot for `view_args_wired`'s reason: unwired, `clamp-position`
4002 /// answers `none` for every buffer, which a guest reads as "that buffer is
4003 /// closed". A roam link would then never be written back, with a message
4004 /// blaming a buffer that is still open.
4005 pub fn buffer_store_wired(&self) -> bool {
4006 self.buffers.get().is_some()
4007 }
4008
4009 /// OA.30: hand the host the counter `refresh-decorations` bumps.
4010 ///
4011 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
4012 pub fn set_decoration_epoch(&self, epoch: lattice_mode::DecorationEpochHandle) {
4013 let _ = self.decoration_epoch.set(epoch);
4014 }
4015
4016 /// OA.30: whether a counter was ever wired.
4017 ///
4018 /// Pinned at boot for `view_args_wired`'s reason: unwired, the seam is a
4019 /// silent no-op and a guest's marks simply never repaint — no error on any
4020 /// path, and the symptom reads as a broken feature rather than a missing
4021 /// wire.
4022 pub fn decoration_epoch_wired(&self) -> bool {
4023 self.decoration_epoch.get().is_some()
4024 }
4025
4026 /// OC.3 / ML.6: hand the host what a plugin's `ui` modeline calls act on.
4027 ///
4028 /// Both halves at once, on the `set_project_context` reasoning: a registry
4029 /// with no bus would let a plugin register a descriptor and push content
4030 /// that nothing ever repaints — half-wired, and half-wired is the failure
4031 /// mode this seam is most likely to have (`plugin-gates-hand-guests-
4032 /// throwaway-contexts`). Unset leaves `register-segment` returning `false`.
4033 ///
4034 /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
4035 pub fn set_modeline(&self, modeline: lattice_mode::ModelineServiceHandle, bus: Arc<EventBus>) {
4036 let _ = self.ui.set(ui_host::UiCtx { modeline, bus });
4037 }
4038
4039 /// Allocate the next host-issued [`PluginId`]. Monotonic and unique for the
4040 /// host's lifetime; the guest never influences it.
4041 fn alloc_id(&self) -> PluginId {
4042 PluginId(self.next_id.fetch_add(1, Ordering::Relaxed))
4043 }
4044
4045 /// Number of module-cache hits so far (a compiled artifact was reused from
4046 /// disk instead of recompiled). Exposed for tests and future observability.
4047 pub fn cache_hits(&self) -> usize {
4048 self.cache.cache_hits()
4049 }
4050
4051 /// Number of module-cache misses so far (a component was compiled and its
4052 /// artifact written to the cache).
4053 pub fn cache_misses(&self) -> usize {
4054 self.cache.cache_misses()
4055 }
4056
4057 /// Compile component bytes (AOT via Cranelift) into a reusable
4058 /// [`Component`]. Malformed / non-component input returns
4059 /// [`PluginHostError::Compile`] — no panic. Compilation is synchronous
4060 /// regardless of the async engine.
4061 pub fn compile(&self, bytes: &[u8]) -> Result<Component, PluginHostError> {
4062 Component::new(&self.engine, bytes).map_err(|e| PluginHostError::Compile(e.into()))
4063 }
4064
4065 /// Instantiate a compiled component into a live [`LoadedPlugin`] with the
4066 /// default per-call budget and **no capability grant** (an empty WASI view
4067 /// — zero filesystem access, no data dir). This is the degenerate load; a
4068 /// real plugin uses [`instantiate_plugin`](Self::instantiate_plugin) with a
4069 /// manifest.
4070 pub async fn instantiate(
4071 &self,
4072 component: &Component,
4073 ) -> Result<LoadedPlugin, PluginHostError> {
4074 self.instantiate_with_budget(component, PluginBudget::default())
4075 .await
4076 }
4077
4078 /// Instantiate a compiled component with an explicit per-call budget and
4079 /// **no capability grant** (empty WASI view). See [`instantiate`](Self::instantiate).
4080 pub async fn instantiate_with_budget(
4081 &self,
4082 component: &Component,
4083 budget: PluginBudget,
4084 ) -> Result<LoadedPlugin, PluginHostError> {
4085 // No grant, no data dir: the guest reaches no filesystem at all — and a
4086 // host-services `walk` from such a plugin is denied (empty grant).
4087 let wasi = WasiCtxBuilder::new().build();
4088 let (mut store, bindings) = self
4089 .instantiate_inner(component, wasi, CapabilityGrant::default(), budget, None)
4090 .await?;
4091 // PO.5: stamp the logging route once the id is allocated, so a `plugin`-
4092 // world guest's `log` reaches the tracer.
4093 let id = self.alloc_id();
4094 store.data_mut().log_ctx = self.log_ctx_for(id);
4095 Ok(LoadedPlugin {
4096 store,
4097 bindings,
4098 budget,
4099 id,
4100 grant: CapabilityGrant::default(),
4101 denied: Vec::new(),
4102 data_dir: None,
4103 })
4104 }
4105
4106 /// Instantiate a plugin **under its capability grant** (PH7.2, fragment §6).
4107 ///
4108 /// The grant is computed from `manifest` + `tier`; a private data dir
4109 /// (`<data-base>/<manifest.id>/data/`) is created and mounted writable, and
4110 /// each granted `fs:*` prefix is preopened at its own path. The resulting
4111 /// [`Store`]'s WASI view reaches **exactly** the granted filesystem and
4112 /// nothing else — a plugin without an `fs:write` grant cannot write outside
4113 /// its data dir at the WASI layer. Requested capabilities the tier withheld
4114 /// (e.g. `proc:spawn` for a user-installed plugin) are surfaced on
4115 /// [`LoadedPlugin::denied_capabilities`] so the host can notify the user;
4116 /// the load still succeeds (graceful degradation).
4117 ///
4118 /// Each instantiation gets its own `Store` (the isolation boundary) and a
4119 /// fresh host-issued [`PluginId`]. Instantiation runs with generous
4120 /// [`INSTANTIATION_FUEL`]; the tighter [`PluginBudget`] applies per call.
4121 pub async fn instantiate_plugin(
4122 &self,
4123 component: &Component,
4124 manifest: &PluginManifest,
4125 tier: TrustTier,
4126 budget: PluginBudget,
4127 ) -> Result<LoadedPlugin, PluginHostError> {
4128 let (wasi, outcome, data_dir) = self.build_plugin_wasi(manifest, tier);
4129 let (mut store, bindings) = self
4130 .instantiate_inner(
4131 component,
4132 wasi,
4133 outcome.grant.clone(),
4134 budget,
4135 Some(&manifest.id),
4136 )
4137 .await?;
4138 // PO.5: stamp the logging route once the id is allocated.
4139 let id = self.alloc_id();
4140 store.data_mut().log_ctx = self.log_ctx_for(id);
4141 Ok(LoadedPlugin {
4142 store,
4143 bindings,
4144 budget,
4145 id,
4146 grant: outcome.grant,
4147 denied: outcome.denied,
4148 data_dir: Some(data_dir),
4149 })
4150 }
4151
4152 /// Shared instantiation core: build the `Store` around `wasi`, arm the
4153 /// instantiation fuel/epoch, and instantiate the component against the
4154 /// WASI-populated linker.
4155 async fn instantiate_inner(
4156 &self,
4157 component: &Component,
4158 wasi: WasiCtx,
4159 grant: CapabilityGrant,
4160 budget: PluginBudget,
4161 name: Option<&str>,
4162 ) -> Result<(Store<PluginState>, Plugin), PluginHostError> {
4163 let mut store = self.new_store(wasi, grant, budget, name)?;
4164 let bindings = Plugin::instantiate_async(&mut store, component, &self.linker)
4165 .await
4166 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
4167 Ok((store, bindings))
4168 }
4169
4170 /// Build a per-plugin `Store` around `wasi` + `grant` and arm it with the
4171 /// generous [`INSTANTIATION_FUEL`] + `budget`'s epoch (the start function's
4172 /// allowance; per-call arming via [`arm_store`] happens before each export).
4173 /// Shared by the lifecycle-world instantiation and the picker actor spawn
4174 /// (`picker_task`) so both build the same scoped `Store`.
4175 fn new_store(
4176 &self,
4177 wasi: WasiCtx,
4178 grant: CapabilityGrant,
4179 budget: PluginBudget,
4180 // The plugin's manifest id — drives config-option auto-namespacing. `None`
4181 // only for internal callers with no manifest (they register no options).
4182 name: Option<&str>,
4183 ) -> Result<Store<PluginState>, PluginHostError> {
4184 let state = PluginState {
4185 wasi,
4186 table: ResourceTable::new(),
4187 grant,
4188 grammar_contributions: grammar_host::GrammarContributions::default(),
4189 event_subscriptions: events_host::EventContributions::default(),
4190 // Set by `spawn_event_plugin` once the plugin's id is allocated; a
4191 // plugin not spawned onto a bus cannot emit (warn + drop).
4192 event_emit: None,
4193 // OA.14d: stamped for every store from the host-level registry (the
4194 // `project` shape), so ANY seam's guest can read an option. A spawn
4195 // path that takes a registry of its own still overwrites this with
4196 // the one it was handed — same `Arc` in every real wiring, and an
4197 // explicit argument where the seam genuinely cannot work without
4198 // one. `None` only when boot wired no registry at all, which is the
4199 // honest degradation (warn + false / none).
4200 config_registry: self.config.get().cloned(),
4201 // From the manifest id; drives config-option auto-namespacing.
4202 plugin_name: name.map(str::to_string),
4203 config_contributions: Vec::new(),
4204 theme_registry: None,
4205 theme_contributions: Vec::new(),
4206 sign_registry: None,
4207 sign_contributions: Vec::new(),
4208 help_contributions: Vec::new(),
4209 language_contributions: Vec::new(),
4210 dashboard_contributions: Vec::new(),
4211 // Drained by `spawn_mode_plugin` into the `ModeRegistry` after
4212 // `register-modes` returns (PH7.11a).
4213 mode_contributions: mode_host::ModeContributions::default(),
4214 // Set by `spawn_keymap_plugin`; a plugin not spawned onto a keymap
4215 // cannot bind (register-binding → false).
4216 keymap_ctx: None,
4217 keymap_contributions: Vec::new(),
4218 // Stamped by each async instantiate/spawn path (`log_ctx_for`) once
4219 // the id is allocated; `None` here + for the sync grammar guest (which
4220 // never imports `logging`) → a guest `log` is a debug-drop.
4221 log_ctx: None,
4222 // PR.6: stamped for every store below, not per-spawn-path like
4223 // `log_ctx` — resolution needs no plugin id, so there is nothing
4224 // to wait for and no path that can forget it.
4225 project: self.project.get().cloned(),
4226 // OA.23: stamped for every store like `project`, and for the same
4227 // reason — resolving a composed line needs no plugin id, so there
4228 // is nothing to wait for and no spawn path that can forget it.
4229 excerpt_source: self.excerpt_source.get().cloned(),
4230 // OA.27: stamped for every store, on the `excerpt_source` reasoning
4231 // — resolving a view's arguments needs no plugin id, so there is
4232 // nothing to wait for and no spawn path that can forget it. It has
4233 // to reach the GRAMMAR store above all, which is where the chords
4234 // that read a view's arguments actually run.
4235 view_args: self.view_args.get().cloned(),
4236 // OA.30: stamped for every store on `view_args`' reasoning. It has
4237 // to reach the GRAMMAR store above all — a mark is toggled by a
4238 // chord, and the chord is what has to say the gutter changed.
4239 decoration_epoch: self.decoration_epoch.get().cloned(),
4240 // CD.6b: stamped for every store on `view_args`' reasoning. The
4241 // GRAMMAR store is the one that needs it: a capture commits from a
4242 // chord, and that is where the caller's extent is asked.
4243 buffers: self.buffers.get().cloned(),
4244 // PH7.8c: opened by `spawn_event_plugin` around `register-events`
4245 // and closed by its flush. Every other seam publishes straight
4246 // through, which is what `None` means.
4247 deferred_events: None,
4248 cancel: self.cancel.get().cloned(),
4249 cancel_token: None,
4250 epoch_spent: 0,
4251 epoch_started: None,
4252 // OC.3: stamped for every store here rather than per-spawn-path, on
4253 // the `project` reasoning — there is nothing to wait for, so no path
4254 // can forget it. The one store that must NOT have it, the sync
4255 // grammar store, clears it explicitly in
4256 // `instantiate_grammar_plugin`, which is a line a reader can find.
4257 ui: self.ui.get().cloned(),
4258 // OC.2: only `spawn_event_plugin` fills this in — it is the one path
4259 // whose actor has somewhere to deliver an `on-wake`.
4260 wake: None,
4261 // OR.1: stamped for every store here, on the `project` / `ui`
4262 // reasoning — it needs no allocated plugin id, so there is nothing
4263 // to wait for and no spawn path that can forget it. A seam holding
4264 // a store its sibling seams cannot see IS the drift this exists to
4265 // prevent, so the wiring must not be per-path.
4266 store: name.and_then(|id| self.store_for(id)),
4267 picker_contributions: picker_host::PickerContributions::default(),
4268 // MV.1: stamped for every store like `picker_contributions`, so the
4269 // one seam that drains it cannot be the only one that has it.
4270 multibuffer_view_contributions:
4271 multibuffer_view_host::MultibufferViewContributions::default(),
4272 // OR.2: empty until the guest arms one; dropped with this `Store`.
4273 watches: std::collections::HashMap::new(),
4274 require_contributions: Default::default(),
4275 };
4276 let mut store = Store::new(&self.engine, state);
4277 store
4278 .set_fuel(INSTANTIATION_FUEL)
4279 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
4280 // Default epoch behaviour is to trap on deadline; arm it generously
4281 // for instantiation (per-call arming happens before each export).
4282 store.set_epoch_deadline(budget.epoch_deadline);
4283 Ok(store)
4284 }
4285
4286 /// Compute a plugin's grant from `manifest` + `tier`, create its private
4287 /// data dir, and build the scoped WASI view — the front half of
4288 /// [`instantiate_plugin`](Self::instantiate_plugin), factored out so the
4289 /// picker actor spawn (`picker_task`) instantiates under the identical
4290 /// capability model. A data dir that cannot be created degrades to "no data
4291 /// mount" (a `warn!`, never a failed load — fragment §6).
4292 fn build_plugin_wasi(
4293 &self,
4294 manifest: &PluginManifest,
4295 tier: TrustTier,
4296 ) -> (WasiCtx, GrantOutcome, PathBuf) {
4297 let outcome = grant(manifest, tier);
4298 // SECURITY (isolation, defense-in-depth): the id is validated at parse
4299 // (`from_toml_str` rejects a path-escaping id), but a programmatic
4300 // `PluginManifest::new` bypasses that. Re-check HERE — the true security
4301 // boundary — before joining the id into a WRITABLE mount path: an unsafe
4302 // id degrades to NO data mount (an empty scoped WASI view), never an
4303 // out-of-sandbox writable dir. A crafted `/etc/cron.d` / `../../.ssh` id
4304 // can therefore never relocate the mount, grant or no grant.
4305 let data_dir = if crate::manifest::is_safe_plugin_id(&manifest.id) {
4306 let dir = self.data_dir_base.join(&manifest.id).join("data");
4307 if let Err(err) = std::fs::create_dir_all(&dir) {
4308 tracing::warn!(
4309 path = %dir.display(),
4310 error = %err,
4311 "plugin data dir create failed; the data mount is degraded"
4312 );
4313 }
4314 dir
4315 } else {
4316 tracing::error!(
4317 id = %manifest.id,
4318 "plugin id is not a safe path component; refusing the data mount (no /data)"
4319 );
4320 // A path inside the base that is NOT preopened (no create, no mount):
4321 // build_wasi_ctx only preopens dirs it is told to; an absent data dir
4322 // means the guest simply has no /data. Return the (unmounted) intended
4323 // path for the caller's bookkeeping without ever creating/mounting it.
4324 self.data_dir_base.join("__invalid__").join("data")
4325 };
4326 let wasi = build_wasi_ctx(&outcome.grant, &data_dir);
4327 (wasi, outcome, data_dir)
4328 }
4329}
4330
4331/// A live plugin instance: its `Store` (holding the scoped WASI view), the
4332/// lifecycle bindings, the per-call budget, its host-issued identity and
4333/// effective grant. Dropping it tears the `Store` down (the reload/teardown
4334/// seam PH7.12 formalises).
4335pub struct LoadedPlugin {
4336 store: Store<PluginState>,
4337 bindings: Plugin,
4338 budget: PluginBudget,
4339 id: PluginId,
4340 grant: CapabilityGrant,
4341 denied: Vec<Capability>,
4342 data_dir: Option<PathBuf>,
4343}
4344
4345impl LoadedPlugin {
4346 /// The host-issued identity for this instance. The guest never influences
4347 /// it; it is the numeric id inside this plugin's provenance.
4348 pub fn id(&self) -> PluginId {
4349 self.id
4350 }
4351
4352 /// The provenance layer the host stamps for every contribution this plugin
4353 /// registers (PH7.3+). Host-issued from [`PluginId`], so a plugin cannot
4354 /// forge a builtin/user provenance — the acid test of §6.
4355 pub fn source_layer(&self) -> SourceLayer {
4356 SourceLayer::Plugin(self.id.0)
4357 }
4358
4359 /// The effective capability grant this plugin runs under.
4360 pub fn grant(&self) -> &CapabilityGrant {
4361 &self.grant
4362 }
4363
4364 /// Requested capabilities the trust tier withheld (e.g. `proc:spawn` for a
4365 /// user-installed plugin). The host turns these into a "loaded with reduced
4366 /// function" notification; the plugin still loaded.
4367 pub fn denied_capabilities(&self) -> &[Capability] {
4368 &self.denied
4369 }
4370
4371 /// The plugin's private data dir, if it was instantiated with a grant
4372 /// ([`PluginHost::instantiate_plugin`]). `None` for the degenerate
4373 /// no-grant load.
4374 pub fn data_dir(&self) -> Option<&Path> {
4375 self.data_dir.as_deref()
4376 }
4377
4378 /// Re-arm the fuel + epoch budget before a lifecycle call, so each call
4379 /// gets a fresh allowance rather than sharing a running total.
4380 fn arm_budget(&mut self) -> Result<(), PluginHostError> {
4381 arm_store(&mut self.store, self.budget)
4382 }
4383
4384 /// Call the component's `activate` export. For `init.rs` this runs the
4385 /// user's configuration; for the no-op component it returns immediately.
4386 /// Fuel/epoch exhaustion or any wasm trap surfaces as
4387 /// [`PluginHostError::Trap`] and leaves the host live.
4388 pub async fn activate(&mut self) -> Result<(), PluginHostError> {
4389 self.arm_budget()?;
4390 self.bindings
4391 .call_activate(&mut self.store)
4392 .await
4393 .map_err(|source| PluginHostError::Trap {
4394 func: "activate",
4395 kind: classify_trap(&source),
4396 source: source.into(),
4397 })
4398 }
4399
4400 /// Call the component's `deactivate` export (teardown / reload).
4401 pub async fn deactivate(&mut self) -> Result<(), PluginHostError> {
4402 self.arm_budget()?;
4403 self.bindings
4404 .call_deactivate(&mut self.store)
4405 .await
4406 .map_err(|source| PluginHostError::Trap {
4407 func: "deactivate",
4408 kind: classify_trap(&source),
4409 source: source.into(),
4410 })
4411 }
4412}