lattice_plugin_loader/install.rs
1//! PL8.A/B — the crate-owned `install(boot)` entry point.
2//!
3//! Wiring the loader into the editor is **one line** in the host's Phase-B
4//! install list (`lattice_plugin_loader::install(&mut boot)`) and zero host
5//! internals — the mode-ownership acid test: no `Editor::` method, no host
6//! `Action` variant. `install` stands the runtime up, captures the editor
7//! environment (runtime handle, event bus, the runtime-mutable picker registry,
8//! the provenance sink) from the generic `SubsystemBoot` seams, registers the
9//! [`PluginLoaderHandle`] service, and spawns on-disk discovery **off the boot
10//! thread** so no plugin cold-start delays boot.
11
12use std::sync::Arc;
13
14use lattice_config::ConfigRegistry;
15use lattice_mode::{PluginMetaSinkHandle, SubsystemBoot};
16use lattice_picker::PickerRegistryHandle;
17use lattice_plugin_host::{
18 PluginHost, PluginTracePushed, PluginTracer, PluginTracerHandle, TraceLevel, TrustTier,
19};
20use lattice_protocol::{Event, EventKind};
21use lattice_runtime::{EventFilter, SubscriptionTarget};
22
23use std::sync::atomic::{AtomicBool, Ordering};
24
25use crate::{LoaderServices, PluginLoader, PluginLoaderHandle};
26
27/// OC.2: the concrete timer behind the `wake-every` seam.
28///
29/// It lives here rather than in `lattice-plugin-host` because that crate keeps
30/// `tokio` a dev-dependency on purpose — `futures` was picked over `tokio::sync`
31/// so the lib owns no runtime and every actor is spawned by its caller. This
32/// crate IS that caller, so the executor dependency lands where the executor
33/// already is.
34struct TokioSleeper;
35
36impl lattice_plugin_host::Sleeper for TokioSleeper {
37 fn sleep(&self, dur: std::time::Duration) -> futures::future::BoxFuture<'static, ()> {
38 Box::pin(tokio::time::sleep(dur))
39 }
40}
41
42/// Process-global opt-**in** to boot-time plugin auto-discovery, set by
43/// [`enable_autoload`]. Checked (together with the
44/// `LATTICE_DISABLE_PLUGIN_AUTOLOAD` env var, which still overrides) in
45/// [`install`].
46///
47/// ## Why opt-in, and what it cost to learn
48///
49/// This was an opt-**out** (`disable_autoload`), and 42 of the 45 test files
50/// that boot a real `Editor` never called it. Every one of them silently
51/// loaded the developer's real `~/.config/lattice/plugins/` — so a test's
52/// behaviour depended on which plugins the person running it happened to have
53/// installed, and CI (with an empty home) and a laptop disagreed.
54///
55/// It is not a hypothetical: `lsp_async_wake.rs` asserts that no
56/// `async_landed` wake fires within a second of settling, and a real plugin
57/// load fires one. The test passed for everyone until an org plugin was
58/// installed, then failed on that machine only — the shape that costs an
59/// afternoon, because nothing in the failure points at the cause.
60///
61/// The fix has to be structural rather than 42 edits: a per-file opt-out is a
62/// thing the 43rd file forgets, and its absence does not announce itself. So
63/// the default is now sealed and production opts in.
64///
65/// **The trade this accepts:** if the [`enable_autoload`] call is ever lost
66/// from the binary's startup, a shipped editor loads no plugins. That is a
67/// loud failure — the first run shows it — where the one it replaces was
68/// silent and machine-dependent.
69static AUTOLOAD_ENABLED: AtomicBool = AtomicBool::new(false);
70
71/// Turn on boot-time plugin auto-discovery for this process.
72///
73/// **Called by the binary's startup, once, before any `Editor::boot`.** Every
74/// other embedder — tests, benches, tools — gets a sealed editor by default
75/// and has to say otherwise.
76pub fn enable_autoload() {
77 AUTOLOAD_ENABLED.store(true, Ordering::Relaxed);
78}
79
80/// Write out every plugin's store before the process exits.
81///
82/// **Called by the binary, after its UI returns.** A store is otherwise
83/// written only when it changes, at most once a second, so the last second of
84/// changes would be lost on exit: `Drop` never runs for a store whose handle a
85/// task on a `static` runtime still holds.
86pub fn flush_plugin_stores() {
87 lattice_plugin_host::plugin_store::flush_all();
88}
89
90/// Suppress boot-time plugin auto-discovery for this process.
91///
92/// Now that the default is sealed this is only meaningful *after*
93/// [`enable_autoload`] — a binary turning discovery back off, or a test in a
94/// process that enabled it. Kept because it is the honest inverse and because
95/// existing callers state their intent, which is worth reading even where it
96/// is a no-op.
97pub fn disable_autoload() {
98 AUTOLOAD_ENABLED.store(false, Ordering::Relaxed);
99}
100
101/// Whether boot-time auto-discovery is currently on. The env override is
102/// deliberately NOT consulted: this reports the latch, which is what a caller
103/// setting it wants to confirm, and what `autoload_is_opt_in.rs` pins.
104pub fn autoload_enabled() -> bool {
105 AUTOLOAD_ENABLED.load(Ordering::Relaxed)
106}
107
108fn autoload_disabled() -> bool {
109 !AUTOLOAD_ENABLED.load(Ordering::Relaxed)
110 || std::env::var_os("LATTICE_DISABLE_PLUGIN_AUTOLOAD").is_some()
111}
112
113pub fn install(boot: &mut impl SubsystemBoot) {
114 let host = match PluginHost::new() {
115 Ok(host) => Arc::new(host),
116 Err(err) => {
117 tracing::warn!(
118 error = %err,
119 "plugin host unavailable; the editor runs without plugin support"
120 );
121 return;
122 }
123 };
124
125 // PO.1 (plugin observability): stand the boundary tracer up — its publisher
126 // bound to the runtime bus so every appended `PluginTraceRecord` streams as a
127 // typed `PluginTracePushed` event (the `LspLogger` boot-wiring precedent). The
128 // seams emit into it (PO.2/PO.3, via `LoaderServices.tracer`); the trace-buffer
129 // views subscribe (PO.4). Off the hot path by contract — this only wires it.
130 let tracer: PluginTracerHandle = Arc::new(PluginTracer::with_defaults());
131 let trace_bus = boot.event_bus().clone();
132 tracer.set_event_publisher(Box::new(move |record| {
133 trace_bus.publish_typed(PluginTracePushed { record });
134 }));
135 // PO.5: hand the tracer to the host so each instantiate/spawn stamps the
136 // plugin's `log_ctx` and the guest `logging` seam (Layer 2) routes into the
137 // same ring as the boundary trace.
138 host.set_tracer(tracer.clone());
139 // PR.6: hand the host what the guest `project` seam answers from. Both
140 // halves are required — the resolver turns a path into a project, the
141 // buffer store turns a `buffer` id into that path — so an absent either
142 // leaves the seam answering `none` rather than half-answering.
143 // CG.4: hand the host the foreground-cancel registry so `<C-g>`
144 // interrupts a running guest call, not just the next one.
145 // OC.3 / ML.6: hand the host what a plugin's modeline calls act on. Both
146 // halves together — a registry with no bus lets a plugin register a
147 // descriptor and push content that nothing ever repaints, which is
148 // half-wired in the exact way this seam is most likely to be.
149 if let Some(modeline) = boot.service::<lattice_mode::ModelineServiceHandle>() {
150 host.set_modeline((*modeline).clone(), boot.event_bus().clone());
151 } else {
152 tracing::debug!("modeline seam unwired: plugin segments will not register");
153 }
154 // OC.2: hand the host a timer for the `wake-every` seam. `lattice-plugin-host`
155 // owns no runtime by design, so it cannot make one — this crate, which spawns
156 // every actor, is where an executor is actually in scope.
157 host.set_sleeper(Arc::new(TokioSleeper));
158 if let Some(cancel) = boot.service::<lattice_mode::ForegroundCancelHandle>() {
159 host.set_foreground_cancel((*cancel).clone());
160 } else {
161 tracing::debug!("foreground-cancel unwired: plugin calls run to their budget");
162 }
163 if let (Some(resolver), Some(buffers)) = (
164 boot.service::<lattice_core::ProjectResolverHandle>(),
165 boot.service::<lattice_mode::BufferStoreHandle>(),
166 ) {
167 host.set_project_context((*resolver).clone(), (*buffers).clone());
168 } else {
169 tracing::debug!("project seam unwired: no resolver or buffer store at plugin install");
170 }
171 // CD.6b: the store `clamp-position` measures. Its own slot rather than
172 // `project`'s, so whether a buffer exists does not depend on whether
173 // project resolution was wired.
174 if let Some(buffers) = boot.service::<lattice_mode::BufferStoreHandle>() {
175 host.set_buffer_store((*buffers).clone());
176 } else {
177 tracing::debug!("clamp-position unwired: no buffer store at plugin install");
178 }
179 // OA.23: what answers "which file did this composed line come from".
180 //
181 // The registry alone: it maps a view's composed line to a source document,
182 // and a source carries its own path. Unwired leaves `excerpt-source`
183 // answering `none`, which is what a host with no multibuffers should say.
184 if let Some(views) = boot.service::<lattice_multibuffer::MultibufferRegistryHandle>() {
185 host.set_excerpt_source_resolver(std::sync::Arc::new(
186 lattice_multibuffer::registry::MultibufferExcerptSource::new((*views).clone()),
187 ));
188 } else {
189 tracing::debug!("excerpt-source seam unwired: no multibuffer registry");
190 }
191 // OA.27: what answers "what is this provider view showing".
192 //
193 // The per-view scan state, which already holds the arguments the view was
194 // opened with and carries them across every re-open. Unwired leaves
195 // `view-args` answering an empty list — and a guest reads that as a fresh
196 // view, so every chord that walks a view starts over from the default with
197 // no error anywhere. That silence is why this wiring has a boot pin.
198 if let Some(scan_views) =
199 boot.service::<lattice_multibuffer::providers::scan_view::ScanViewServiceHandle>()
200 {
201 host.set_view_args_resolver(std::sync::Arc::new(
202 lattice_multibuffer::providers::scan_view::ScanViewArgs::new((*scan_views).clone()),
203 ));
204 } else {
205 tracing::debug!("view-args seam unwired: no scan-view service");
206 }
207 // OA.30: the counter `refresh-decorations` bumps. Without it a guest
208 // decoration producer is asked once and cached forever — its answer never
209 // repaints, with no error anywhere, which reads as a broken feature rather
210 // than a missing wire.
211 if let Some(epoch) = boot.service::<lattice_mode::DecorationEpochHandle>() {
212 host.set_decoration_epoch((*epoch).clone());
213 } else {
214 tracing::debug!("refresh-decorations unwired: no decoration epoch service");
215 }
216
217 // Capture the editor environment from the generic boot seams. `service`
218 // returns `Arc<Handle-alias>` (double-Arc); unwrap one layer to the handle.
219 let services = LoaderServices {
220 media_registry: boot
221 .service::<lattice_mode::MediaSourceRegistryHandle>()
222 .map(|h| (*h).clone()),
223 agenda_registry: boot
224 .service::<lattice_mode::ScannedExcerptSourceRegistryHandle>()
225 .map(|h| (*h).clone()),
226 // CM.2: absent only in harnesses that wire no keymap. A plugin that
227 // declares an operator chord then fails its load loudly rather than
228 // registering an operator nobody can press.
229 operator_chords: boot
230 .service::<lattice_mode::OperatorChordWirerHandle>()
231 .map(|h| (*h).clone()),
232 runtime: Some(boot.runtime_handle().clone()),
233 bus: Some(boot.event_bus().clone()),
234 // MV.1: where a plugin's declared views register their openers, and
235 // where their excerpts land once `build` answers.
236 provider_view_registry: boot
237 .service::<lattice_mode::ProviderViewRegistryHandle>()
238 .map(|h| (*h).clone()),
239 multibuffer_registry: boot
240 .service::<lattice_multibuffer::registry::MultibufferRegistryHandle>()
241 .map(|h| (*h).clone()),
242 picker_registry: boot.service::<PickerRegistryHandle>().map(|h| (*h).clone()),
243 config_registry: boot.service::<Arc<ConfigRegistry>>().map(|h| (*h).clone()),
244 command_registry: boot
245 .service::<lattice_grammar::CommandRegistryHandle>()
246 .map(|h| (*h).clone()),
247 mode_registry: boot
248 .service::<lattice_mode::ModeRegistryHandle>()
249 .map(|h| (*h).clone()),
250 keymap: boot
251 .service::<lattice_keymap::KeymapHandle>()
252 .map(|h| (*h).clone()),
253 meta_sink: boot.service::<PluginMetaSinkHandle>().map(|h| (*h).clone()),
254 decoration_registry: boot
255 .service::<lattice_mode::GutterDecorationSourceRegistryHandle>()
256 .map(|h| (*h).clone()),
257 context_registry: boot
258 .service::<lattice_mode::ContextSourceRegistryHandle>()
259 .map(|h| (*h).clone()),
260 theme_registry: boot
261 .service::<lattice_theme::ThemeRegistryHandle>()
262 .map(|h| (*h).clone()),
263 // SG.3a: registered by `editor_boot` beside the theme registry, and
264 // for the same reason it must be captured HERE — the loader captures
265 // its drain services at install time, so a `signs` plugin's
266 // definitions have nowhere to land if the service is registered after
267 // this line. `plugin_loader_captures_every_drain_service` is the boot
268 // pin that catches it.
269 sign_registry: boot
270 .service::<lattice_mode::SignRegistryHandle>()
271 .map(|h| (*h).clone()),
272 // OC.3 / ML.6: registered by `editor_boot` in Phase A, alongside the
273 // built-in element registration — well before this line.
274 modeline: boot
275 .service::<lattice_mode::ModelineServiceHandle>()
276 .map(|h| (*h).clone()),
277 // CM.6b: registered by `lattice_compilation::install`, which runs
278 // early in Phase B — well before this line.
279 parser_factories: boot
280 .service::<lattice_compilation::CompilationParserFactoriesHandle>()
281 .map(|h| (*h).clone()),
282 // CR.3: registered by the host in Phase A (the `builtin_topics()`
283 // hoist), so it is present well before this line.
284 help_topics: boot
285 .service::<lattice_help::topics::HelpTopicRegistryHandle>()
286 .map(|h| (*h).clone()),
287 // CR.4: registered by `lattice_dashboard::install`, which runs early
288 // in Phase B — well before this line.
289 dashboard_sections: boot
290 .service::<lattice_dashboard::DashboardRegistryHandle>()
291 .map(|h| (*h).clone()),
292 // TR.2b: registered by `editor_boot` in Phase A (TR.1 moved it there
293 // from magit precisely so it is present regardless of which feature
294 // crates loaded), so it is available well before this line.
295 transient_registry: boot
296 .service::<lattice_picker::TransientSourceRegistryHandle>()
297 .map(|h| (*h).clone()),
298 tracer: Some(tracer.clone()),
299 };
300 if services.picker_registry.is_none() {
301 // The host always registers the picker registry; its absence means a
302 // boot-order regression. Degrade to no plugin support, logged.
303 tracing::warn!("picker registry service missing; the editor runs without plugin support");
304 return;
305 }
306
307 let loader: PluginLoaderHandle = Arc::new(PluginLoader::with_services(host, services));
308 // Option A (PL8.C.2): the loader self-registers its `:plugin-load` /
309 // `:plugin-unload` / `:plugin-reload` ex-commands into the runtime-mutable
310 // command registry — zero host code, the full command surface owned by the
311 // loader crate.
312 loader.register_ex_commands();
313 // PL8.H.1: track plugin health for the manager view — a `PluginCrashed`
314 // subscription drained on the runtime flips a trapped plugin to quarantined.
315 loader.subscribe_health();
316 // PM.3: react to `<id>.enabled` toggles — activate/deactivate a core plugin's
317 // default mode live (`:set auto-pair.enabled=false`).
318 loader.subscribe_mode_gates();
319 boot.register_service::<PluginLoaderHandle>(loader.clone());
320
321 // PO.4.3: observe `:set plugin.trace-level=…` and push the new default into
322 // the tracer live — PO.3's republish then reaches every un-overridden hot gate
323 // on the next keystroke. Mechanism B (the dashboard `install_recompose_triggers`
324 // precedent): the subsystem that OWNS the tracer subscribes its own
325 // `OptionChanged` channel and name-filters, rather than adding a plugin arm to
326 // the host's option cascade (mode-ownership — the App stays a thin host). The
327 // event carries the new value as a string, so no `lattice-config` value-type
328 // coupling — `TraceLevel::parse` bridges (the labels match `PluginTraceLevel`).
329 let observer_tracer = tracer.clone();
330 let (option_tx, mut option_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
331 boot.event_bus().subscribe(
332 EventFilter::kind(EventKind::OptionChanged),
333 SubscriptionTarget::Channel(option_tx),
334 );
335 boot.runtime_handle().spawn(async move {
336 while let Some(event) = option_rx.recv().await {
337 let Event::OptionChanged { name, new, .. } = event else {
338 continue;
339 };
340 if name != "plugin.trace-level" {
341 continue;
342 }
343 match TraceLevel::parse(&new) {
344 Some(level) => observer_tracer.set_default_level(level),
345 None => tracing::warn!(value = %new, "ignoring unparseable plugin.trace-level"),
346 }
347 }
348 });
349
350 // PO.1: register the tracer (built above) as a boot service so the
351 // trace-buffer views (PO.4) resolve it; the seams already hold it via
352 // `LoaderServices.tracer`.
353 boot.register_service::<PluginTracerHandle>(tracer);
354
355 // Headless / CI / test opt-out: `LATTICE_DISABLE_PLUGIN_AUTOLOAD` skips the
356 // boot-time filesystem discovery below (core plugins + init.rs + on-disk
357 // plugins), so a developer's real `~/.config/lattice` never leaks into a
358 // test editor and CI can boot deterministically. The loader service and its
359 // `:plugin-load` / `:plugin-unload` / `:plugin-reload` ex-commands are
360 // already wired above, so manual loading still works — only auto-discovery
361 // is suppressed.
362 if autoload_disabled() {
363 tracing::debug!("plugin auto-load disabled; skipping boot-time plugin discovery");
364 return;
365 }
366
367 // CI.2: `init.rs` loads FIRST, then on-disk plugins — in ONE task, off the
368 // boot thread. init.rs is config authority (`<config>/lattice/init/`, loaded
369 // with a boot-capability `Bundled` tier); it must register its
370 // `plugin-loaded` subscriptions BEFORE any plugin fires that event, so a
371 // deferred `on-plugin-loaded` handler can't miss its plugin
372 // (config-and-init.md §3). Both stay OFF the boot thread — a plugin
373 // cold-start must not delay boot; contributions appear a frame or two after
374 // boot (the eventual-consistency the UX contract permits). An absent init /
375 // plugins dir (the common case) is a benign skip.
376 let init_dir = crate::default_init_dir();
377 let plugins_dir = crate::default_plugins_dir();
378 // PM.1: the core-plugins root — prebuilt plugins that ship WITH lattice,
379 // discovered from a runtime search path (plugin-manager.md §7). `None` when no
380 // runtime root is present (a source checkout with no staged `runtime/`, etc.).
381 let core_plugins_dir = crate::default_core_plugins_dir();
382 // PL8.D.4: watch the init dir so a rebuilt `init.wasm` auto-reloads without a
383 // manual `:reload-config`. A no-op if the dir doesn't exist.
384 if let Some(ref dir) = init_dir {
385 crate::watch::spawn_init_watcher(loader.clone(), dir.clone(), boot.runtime_handle());
386 }
387 if core_plugins_dir.is_some() || init_dir.is_some() || plugins_dir.is_some() {
388 let loader = loader.clone();
389 boot.runtime_handle().spawn(async move {
390 // 0. CORE plugins first (prebuilt runtime root, `Bundled` tier) — PM.1.
391 // They enable via a config gate (PM.3), NOT init.rs, so loading them
392 // before init.rs is correct: a user init.rs `on-plugin-loaded`
393 // handler targets USER plugins (step 2), which still load after it.
394 if let Some(core_dir) = core_plugins_dir {
395 let n = loader
396 .discover_and_load(&core_dir, TrustTier::Bundled)
397 .await;
398 if n > 0 {
399 tracing::info!(
400 count = n,
401 dir = %core_dir.display(),
402 "core plugins loaded from the runtime root"
403 );
404 }
405 }
406 // 1. init.rs next — AWAITED, so its subscriptions are live before
407 // step 2 loads plugins that fire `plugin-loaded`.
408 if let Some(init_dir) = init_dir {
409 // PM.7b: build init.rs before loading it. It is a
410 // `wasm32-wasip2` component like any other, so the PM.5
411 // service builds it — one build primitive, two callers
412 // (design §6). This is what removes the "run cargo by hand
413 // first" step: an edited init.rs rebuilds on the next boot,
414 // and an unchanged one is a pure load that never invokes a
415 // toolchain.
416 //
417 // Failure is a skip. A user whose init.rs stopped compiling
418 // must still get an editor — with their previous init.wasm if
419 // one exists (`StaleKept`), and without config if not.
420 build_init_if_needed(&init_dir).await;
421 match loader.load_path(&init_dir, TrustTier::Bundled).await {
422 Ok(id) => tracing::info!(
423 id = id.0,
424 dir = %init_dir.display(),
425 "user init.rs config loaded"
426 ),
427 // WT.4: **this arm was the silent failure.** One `debug!`
428 // covered two entirely different situations — "the user has
429 // no init.rs", which is normal and uninteresting, and "the
430 // user has an init.rs and it would not load", which is the
431 // most consequential thing that can happen at boot: init.rs
432 // holds the `require` that installs and rebuilds every other
433 // plugin, so when it dies nothing else loads either. The
434 // editor opened, everything was absent, and this line said
435 // it at a level nobody sees.
436 //
437 // A `plugin.toml` in the init dir is what tells them apart:
438 // if one is there the user meant to have config, so its
439 // absence is a failure they need to be told about.
440 Err(err) if init_dir.join("plugin.toml").is_file() => tracing::warn!(
441 dir = %init_dir.display(),
442 error = %err,
443 "user init.rs failed to load — plugins it requires will not install; \
444 `lattice --wit-sync` then restart if the plugin API has changed"
445 ),
446 Err(err) => tracing::debug!(
447 dir = %init_dir.display(),
448 error = %err,
449 "no user init.rs loaded"
450 ),
451 }
452 // PM.7b: whatever init.rs `require`d is now queued. Resolve,
453 // build and load it BEFORE step 2's on-disk scan, so a plugin
454 // that was just installed into the user root is discovered by
455 // that scan rather than waiting for the next boot.
456 install_required_plugins(&loader).await;
457 }
458 // 2. Then the plugins the init.rs handlers react to.
459 if let Some(dir) = plugins_dir {
460 let n = loader
461 .discover_and_load(&dir, TrustTier::UserInstalled)
462 .await;
463 if n > 0 {
464 tracing::info!(count = n, dir = %dir.display(), "plugins loaded from disk");
465 }
466 }
467 });
468 }
469}
470
471/// PM.7b: build the user's `init.rs` in place, returning the [`BuildOutcome`] —
472/// or `None` when the directory holds no cargo project (the common case is a
473/// hand-built `init.wasm` dropped in place, which must keep working, so this
474/// returns `None` rather than an error and the caller loads the artifact as-is).
475///
476/// This is the reusable core the boot build ([`build_init_if_needed`]),
477/// `:reload-config`, and the plugins view's rebuild-of-`init` all share, so all
478/// three compile source → artifact identically. Returning the outcome (rather
479/// than logging and discarding it, as the boot-only helper used to) is what lets
480/// `:reload-config` surface the compiler error to the user instead of leaving it
481/// in a log they were not watching.
482///
483/// The build stages into the same directory the loader then discovers, so
484/// nothing downstream needs to know a build happened.
485///
486/// Runs on `spawn_blocking`: a cold component build is seconds to minutes, and
487/// every caller is either the boot task or an off-keystroke reload task, both of
488/// which share the async runtime with the editor (paramount goal #1 / #4).
489pub(crate) async fn build_init(init_dir: &std::path::Path) -> Option<crate::build::BuildOutcome> {
490 if !init_dir.join("Cargo.toml").is_file() {
491 tracing::debug!(
492 dir = %init_dir.display(),
493 "init dir is not a cargo project; loading any prebuilt init.wasm as-is"
494 );
495 return None;
496 }
497 let dir = init_dir.to_path_buf();
498 match tokio::task::spawn_blocking(move || {
499 let parent = dir.parent().map(|p| p.to_path_buf()).unwrap_or_default();
500 crate::build::build_plugin(
501 &crate::build::CargoComponentBuilder,
502 &dir,
503 "init",
504 &parent,
505 false,
506 )
507 })
508 .await
509 {
510 Ok(outcome) => Some(outcome),
511 Err(e) => {
512 tracing::warn!(error = %e, "init.rs build task failed to run");
513 Some(crate::build::BuildOutcome::Failed {
514 error: format!("init.rs build task failed to run: {e}"),
515 })
516 }
517 }
518}
519
520/// PM.7b: build the user's `init.rs` if its source changed (the boot path).
521///
522/// A thin wrapper over [`build_init`] that logs and discards the outcome — at
523/// boot there is no user watching a `*messages*` echo, and a build failure
524/// falls back to the previous artifact regardless.
525async fn build_init_if_needed(init_dir: &std::path::Path) {
526 let Some(outcome) = build_init(init_dir).await else {
527 return;
528 };
529 match outcome.error() {
530 Some(error) => tracing::warn!(
531 dir = %init_dir.display(),
532 %error,
533 "init.rs build failed; using the previous build if there is one"
534 ),
535 None => tracing::debug!(dir = %init_dir.display(), "init.rs is current"),
536 }
537}
538
539/// PM.7b: resolve, build and load everything `init.rs` declared via `require`.
540///
541/// Each spec is independent: one broken source costs that plugin and nothing
542/// else. The whole pipeline runs on `spawn_blocking` — it clones, downloads
543/// and compiles — and only the final load returns to the async context.
544async fn install_required_plugins(loader: &std::sync::Arc<crate::PluginLoader>) {
545 let specs = loader.take_required();
546 if specs.is_empty() {
547 return;
548 }
549 let Some(user_root) = crate::default_plugins_dir() else {
550 tracing::warn!("require: no config dir; cannot install declared plugins");
551 return;
552 };
553 let cache_root = crate::default_source_cache_dir();
554 tracing::info!(
555 count = specs.len(),
556 "installing plugins declared by init.rs"
557 );
558
559 let installs = match tokio::task::spawn_blocking({
560 let user_root = user_root.clone();
561 move || {
562 crate::pipeline::install_all(
563 &crate::resolve::SystemGit,
564 &crate::resolve::HttpFetcher,
565 &crate::build::CargoComponentBuilder,
566 &specs,
567 &cache_root,
568 &user_root,
569 // Boot uses what the user already has: deterministic, works
570 // offline, no network round trip per start. `:plugin-update`
571 // is the verb that goes looking for something newer.
572 crate::resolve::RefreshPolicy::UseCache,
573 )
574 }
575 })
576 .await
577 {
578 Ok(v) => v,
579 Err(e) => {
580 tracing::warn!(error = %e, "require: install task failed to run");
581 return;
582 }
583 };
584
585 for install in installs {
586 match install {
587 crate::pipeline::Install::Ready {
588 name,
589 stale,
590 enable_mode,
591 ..
592 } => {
593 if let Some(error) = stale {
594 tracing::warn!(
595 plugin = %name,
596 %error,
597 "plugin is running a previous build (rebuild failed)"
598 );
599 }
600 // The artifact is staged in the user root; load it through the
601 // ordinary discovery path so a `require`d plugin and a
602 // hand-installed one take exactly the same route in.
603 let dir = user_root.join(&name);
604 match loader.load_path(&dir, TrustTier::UserInstalled).await {
605 Ok(id) => {
606 tracing::info!(plugin = %name, id = id.0, "required plugin loaded");
607 // use-package's `enable-mode` sugar. Requested only
608 // AFTER a successful load: asking to enable a mode
609 // belonging to a plugin that failed to load would be
610 // a request nothing can satisfy.
611 if let Some(mode) = enable_mode {
612 loader.request_mode_enablement(&mode);
613 }
614 }
615 Err(err) => tracing::warn!(
616 plugin = %name,
617 error = %err,
618 "required plugin built but failed to load"
619 ),
620 }
621 }
622 crate::pipeline::Install::Skipped { name, error } => tracing::warn!(
623 plugin = %name,
624 %error,
625 "required plugin skipped"
626 ),
627 }
628 }
629}