lattice_host/boot_context.rs
1//! Boot-composition: the `BootContext` — the host's generic-primitive surface.
2//!
3//! `editor_boot.rs` was a ~1700-line god-function where every async subsystem
4//! hand-wired the same six things: mode registration, command registration,
5//! service registration, an `async_landed` wake, a per-tick drain, and a
6//! deferred install for late handles. `BootContext` is that surface made
7//! explicit — the typed bundle a subsystem's `install(boot)` receives, exposing
8//! the easy-to-get-wrong operations as *primitives that cannot be wired without
9//! their safety property*.
10//!
11//! Design fragment: `docs/dev/architecture/boot-composition.md`.
12//! Slice plan: `docs/dev/operations/slice-plans/boot-composition.md`.
13//!
14//! ## Status
15//!
16//! - **BC.1** ✅ — the skeleton + the two wake-robustness primitives below.
17//! - **BC.3a** ✅ — `editor_boot::Editor::boot` now builds a `BootContext` in a
18//! Phase-A block and routes **all** command / mode / service registration
19//! through it (`commands_mut` / `modes_mut` / `register_service`), freezing
20//! each registry into the `Arc` the `Editor` literal seats. The `render_state`
21//! cell / `BufferStore` / `DiagnosticsQueryHandle` are fields here (built in
22//! Phase A); the §5 "forwardable cell" worry did not match the code (both are
23//! default-init / early-seeded Arc-shared cells — see the design fragment §5
24//! re-assessment), so the hoist preserved Arc identity by moving `let`
25//! bindings, never reconstructing.
26//! - **BC.3b+** 🚧 — per-subsystem `install(boot)` migrations (claude-code
27//! first), collapsing each subsystem's scattered wiring into one call.
28//!
29//! ## Wake-robustness primitives
30//!
31//! - [`BootContext::inbound`] — the bundled inbound primitive. A channel
32//! whose `send` wakes `async_landed` (the wake is inside the sender, so it
33//! is structurally impossible to forget) and whose items are drained
34//! per-tick via the tick-callback registry through a handler. Generalizes
35//! the I3 `ClaudeCodeInboundBus` and LSP's hand-rolled inbound buses. (Not
36//! yet consumed by `editor_boot` — BC.3b is its first caller.)
37//! - [`BootContext::wake_on_event`] — subscribe a typed event and wake
38//! `async_landed` whenever one is published. Generalizes the
39//! `MultibufferExcerptsReady` / L1c `wake_on` forwarder tasks.
40//! - [`BootContext::tick_callback`] — register a raw per-tick drain (the I1
41//! registry), retaining the RAII token for boot lifetime.
42
43use std::sync::Arc;
44
45use std::any::Any;
46
47use lattice_grammar::CommandRegistry;
48use lattice_grammar::effect::Effect;
49use lattice_mode::idle_gate::{IdleGateHandle, IdleGateHandler, IdleGateRegistryHandle};
50use lattice_mode::inbound::{InboundBus, make_inbound, make_inbound_raw};
51use lattice_mode::tick_callback::{
52 TickCallback, TickCallbackRegistration, TickCallbackRegistryHandle,
53};
54use lattice_mode::{BufferStoreHandle, ModeRegistry, ServiceRegistry, SubsystemBoot};
55use lattice_protocol::event_registry::Event as TypedEvent;
56use lattice_runtime::EventBus;
57use tokio::runtime::Handle;
58use tokio::sync::{Notify, mpsc};
59
60/// The host's generic-primitive surface, handed to per-subsystem wiring.
61///
62/// Holds shared handles by `Arc` (cheap to clone) plus the boot-lifetime
63/// tick-callback registration tokens, so drains registered via
64/// [`inbound`](Self::inbound) / [`tick_callback`](Self::tick_callback)
65/// outlive the construction phase. The tokens move into the `Editor` (program
66/// lifetime) via [`into_registrations`](Self::into_registrations); dropping the
67/// `BootContext` without taking them drops the drains.
68///
69/// ## BC.3a — registry ownership (decision 2-b)
70///
71/// `BootContext` **owns** the three registries during the build phase. A
72/// subsystem registers its modes / commands / services through `boot` — the
73/// [`SubsystemBoot`] surface ([`modes_mut`](SubsystemBoot::modes_mut) /
74/// [`commands_mut`](SubsystemBoot::commands_mut) /
75/// [`register_service`](SubsystemBoot::register_service)); host-native
76/// **builtins** (the native vim grammar, ex-commands, foundation / language /
77/// oil / file-tree / snippet / tutor / buffer-kind modes, host actions,
78/// mode-toggle commands, syntax text objects) register through the SAME seam.
79/// **BC.final finding (2026-06-25):** the `*_mut` accessors are NOT transitional
80/// — those builtins are host-native (not subsystems, never installed), so the
81/// accessors are the *permanent* builtin-registration seam, kept by design. The
82/// mode-ownership acid test ("a new SUBSYSTEM touches boot in one place")
83/// governs the Phase-B `install` list, not the host's own builtins. The
84/// registries are held
85/// behind `Option` and *taken* on [`freeze_command_registry`](Self::freeze_command_registry)
86/// / [`freeze_mode_registry`](Self::freeze_mode_registry) /
87/// [`freeze_service_registry`](Self::freeze_service_registry). The freeze order
88/// `editor_boot` uses: the `ModeRegistry` first (right after the mode-
89/// registration block — its `Arc` is needed by `register_mode_toggle_commands`,
90/// which borrows `&mut CommandRegistry` + `&ModeRegistry` at once and so cannot
91/// hold both through `boot`; freezing modes first hands back an
92/// `Arc<ModeRegistry>` that derefs to `&ModeRegistry`), then the
93/// `CommandRegistry` mid-boot (its `Arc` feeds the picker registry + document
94/// handles), then the `ServiceRegistry` last. Freezing only wraps + takes the
95/// registry; the populated data is unchanged, so the order is behaviour-neutral.
96/// Registering into an already-frozen registry is a boot-sequencing bug and
97/// panics with a clear message.
98pub struct BootContext {
99 event_bus: Arc<EventBus>,
100 tick_callbacks: TickCallbackRegistryHandle,
101 async_landed: Arc<Notify>,
102 runtime_handle: Handle,
103 /// Boot-lifetime tick-callback registration tokens (held so the drains
104 /// they represent are not unregistered the instant `inbound` /
105 /// `tick_callback` returns).
106 registrations: Vec<TickCallbackRegistration>,
107 /// BC.3a — the generic buffer-store handle (over the Phase-A
108 /// `BufferRegistry`); exposed via [`SubsystemBoot::buffer_store`] and
109 /// consumed by the claude-code (and future) read tools. (The LSP
110 /// diagnostics handle is NOT a field — subsystems reach it via the generic
111 /// [`SubsystemBoot::service`] lookup, keeping the trait free of lattice-lsp
112 /// types; the host registers it as a Phase-A service.)
113 buffer_store: BufferStoreHandle,
114 /// WK.3 — the idle-gate registry: subsystem-armed deadlines the actor
115 /// sleeps to. Shared with the `Editor` (which hands it to the actor loop),
116 /// so a gate armed by a subsystem's handler reaches the actor's `select!`.
117 idle_gates: IdleGateRegistryHandle,
118 /// BC.3a — owned registries, `None` once frozen (taken by `freeze_*`).
119 command_registry: Option<CommandRegistry>,
120 mode_registry: Option<ModeRegistry>,
121 service_registry: Option<ServiceRegistry>,
122}
123
124impl BootContext {
125 /// Bundle the host primitives + the registries `editor_boot` will populate
126 /// through this context. The three registries are passed in empty (fresh
127 /// `*::new()`); `editor_boot` and the per-subsystem installs register into
128 /// them via `boot` and `freeze_*` them at the right points.
129 #[allow(clippy::too_many_arguments)]
130 pub fn new(
131 event_bus: Arc<EventBus>,
132 tick_callbacks: TickCallbackRegistryHandle,
133 async_landed: Arc<Notify>,
134 runtime_handle: Handle,
135 buffer_store: BufferStoreHandle,
136 idle_gates: IdleGateRegistryHandle,
137 command_registry: CommandRegistry,
138 mode_registry: ModeRegistry,
139 service_registry: ServiceRegistry,
140 ) -> Self {
141 Self {
142 event_bus,
143 tick_callbacks,
144 async_landed,
145 runtime_handle,
146 registrations: Vec::new(),
147 buffer_store,
148 idle_gates,
149 command_registry: Some(command_registry),
150 mode_registry: Some(mode_registry),
151 service_registry: Some(service_registry),
152 }
153 }
154
155 /// The editor's off-keystroke wake handle (host-only lifecycle accessor;
156 /// the subsystem install surface is the [`SubsystemBoot`] impl below).
157 pub fn async_landed(&self) -> &Arc<Notify> {
158 &self.async_landed
159 }
160
161 /// BC.8d: a *host-drained* inbound bus — the wake-baked sender PLUS the raw
162 /// receiver, with no per-tick handler. For server-initiated work whose apply
163 /// is irreducibly `&mut Editor` (LSP `workspace/applyEdit`): the host seats
164 /// the receiver on the `Editor` and drains it from `run_tick_pending`, while
165 /// `send` still wakes the editor off-keystroke (the wake lives in the sender
166 /// — can't be forgotten). Keeps the irreducible apply host-side without an
167 /// internal-pump `Effect`. Inherent (not on [`SubsystemBoot`]): only the
168 /// host's own Phase-A wiring uses it; no subsystem `install` does.
169 pub fn inbound_raw<T>(&self) -> (InboundBus<T>, tokio::sync::mpsc::UnboundedReceiver<T>)
170 where
171 T: Send + 'static,
172 {
173 make_inbound_raw::<T>(Arc::clone(&self.async_landed))
174 }
175
176 /// The shared tick-callback registry (run once per tick by the host).
177 pub fn tick_callbacks(&self) -> &TickCallbackRegistryHandle {
178 &self.tick_callbacks
179 }
180
181 /// WK.3: the shared idle-gate registry. The host seats this on the
182 /// `Editor` so the actor loop can point its pinned sleep at
183 /// `earliest()` and fire the due gates when it elapses.
184 pub fn idle_gates(&self) -> &IdleGateRegistryHandle {
185 &self.idle_gates
186 }
187
188 /// Freeze the command registry into its shared runtime-mutable handle
189 /// (`ArcSwap`) and take it out of the context. Called mid-boot, after all
190 /// command registration, before the handle is consumed (picker registry,
191 /// document handles). Subsequent `commands_mut` panics.
192 ///
193 /// PL8.B / B3b: `ArcSwap` (not a bare `Arc`) so the plugin loader can
194 /// RCU-register a runtime grammar contribution; the dispatch path — the
195 /// per-buffer actor and every host-side ex-command / completion read —
196 /// snapshots it wait-free. Mirrors [`Self::freeze_mode_registry`].
197 pub fn freeze_command_registry(&mut self) -> lattice_grammar::CommandRegistryHandle {
198 Arc::new(arc_swap::ArcSwap::from_pointee(
199 self.command_registry
200 .take()
201 .expect("command registry already frozen"),
202 ))
203 }
204
205 /// Freeze the mode registry into its shared runtime-mutable handle
206 /// (`ArcSwap`) and take it out. Called after `register_mode_toggle_commands`.
207 /// Subsequent `modes_mut` panics. `ArcSwap` (not a bare `Arc`) so the plugin
208 /// loader can RCU-register a runtime mode; reads snapshot it wait-free.
209 pub fn freeze_mode_registry(&mut self) -> lattice_mode::ModeRegistryHandle {
210 Arc::new(arc_swap::ArcSwap::from_pointee(
211 self.mode_registry
212 .take()
213 .expect("mode registry already frozen"),
214 ))
215 }
216
217 /// Freeze the service registry into its shared `Arc` and take it out.
218 /// Called last, after the services block. Subsequent `services_mut` panics.
219 pub fn freeze_service_registry(&mut self) -> Arc<ServiceRegistry> {
220 Arc::new(
221 self.service_registry
222 .take()
223 .expect("service registry already frozen"),
224 )
225 }
226
227 /// Take the boot-lifetime tick-callback registration tokens. BC.3 calls
228 /// this to move them into the `Editor` so the drains live for the program
229 /// rather than being dropped when the `BootContext` is dropped.
230 pub fn into_registrations(self) -> Vec<TickCallbackRegistration> {
231 self.registrations
232 }
233}
234
235/// BC.3b: the subsystem install surface. Subsystems wire against this trait
236/// (defined in `lattice-mode`, below them) instead of the concrete
237/// `BootContext` (in `lattice-host`, above them — which would cycle). Host-only
238/// lifecycle (`new`, `freeze_*`, `into_registrations`, the
239/// `async_landed`/`tick_callbacks` accessors) stays inherent above; only the
240/// generic install operations live here. The LSP diagnostics handle is reached
241/// via [`service`](SubsystemBoot::service), so this surface names no
242/// `lattice-lsp` type.
243impl SubsystemBoot for BootContext {
244 fn commands_mut(&mut self) -> &mut CommandRegistry {
245 self.command_registry
246 .as_mut()
247 .expect("command registry already frozen (registered after freeze_command_registry)")
248 }
249
250 fn modes_mut(&mut self) -> &mut ModeRegistry {
251 self.mode_registry
252 .as_mut()
253 .expect("mode registry already frozen (registered after freeze_mode_registry)")
254 }
255
256 fn services_mut(&mut self) -> &mut ServiceRegistry {
257 self.service_registry
258 .as_mut()
259 .expect("service registry already frozen (registered after freeze_service_registry)")
260 }
261
262 fn register_service<T: Any + Send + Sync>(&mut self, service: T) {
263 self.services_mut().register(service);
264 }
265
266 fn service<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
267 self.service_registry.as_ref().and_then(|r| r.get::<T>())
268 }
269
270 fn inbound<T, H>(&mut self, handler: H) -> InboundBus<T>
271 where
272 T: Send + 'static,
273 H: FnMut(T) -> Vec<Effect> + Send + 'static,
274 {
275 let (bus, drain) = make_inbound::<T, H>(Arc::clone(&self.async_landed), handler);
276 let reg = self.tick_callbacks.register(drain);
277 self.registrations.push(reg);
278 bus
279 }
280
281 fn wake_on_event<E>(&self)
282 where
283 E: TypedEvent + Clone,
284 {
285 let (tx, mut rx) = mpsc::unbounded_channel::<E>();
286 self.event_bus.subscribe_typed(tx);
287 let wake = Arc::clone(&self.async_landed);
288 self.runtime_handle.spawn(async move {
289 while rx.recv().await.is_some() {
290 wake.notify_one();
291 }
292 });
293 }
294
295 fn tick_callback(&mut self, callback: TickCallback) {
296 let reg = self.tick_callbacks.register(callback);
297 self.registrations.push(reg);
298 }
299
300 fn idle_gate(&mut self, name: &'static str, handler: IdleGateHandler) -> IdleGateHandle {
301 self.idle_gates.register(name, handler)
302 }
303
304 fn event_bus(&self) -> &Arc<EventBus> {
305 &self.event_bus
306 }
307
308 fn runtime_handle(&self) -> &Handle {
309 &self.runtime_handle
310 }
311
312 fn buffer_store(&self) -> &BufferStoreHandle {
313 &self.buffer_store
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 #![allow(clippy::unwrap_used)]
320 use super::*;
321 use crate::buffer_registry::BufferRegistry;
322 use lattice_mode::tick_callback::TickCallbackRegistry;
323 use std::sync::Mutex;
324 use std::time::Duration;
325
326 fn ctx() -> BootContext {
327 let buffer_store: BufferStoreHandle =
328 BufferStoreHandle::new(Arc::new(BufferRegistry::new()));
329 BootContext::new(
330 Arc::new(EventBus::new()),
331 Arc::new(TickCallbackRegistry::new()),
332 Arc::new(Notify::new()),
333 Handle::current(),
334 buffer_store,
335 Arc::new(lattice_mode::idle_gate::IdleGateRegistry::new()),
336 CommandRegistry::new(),
337 ModeRegistry::new(),
338 ServiceRegistry::new(),
339 )
340 }
341
342 #[tokio::test]
343 async fn registries_register_then_freeze_into_arcs() {
344 let mut ctx = ctx();
345 // A service registered through the context survives into the frozen Arc.
346 ctx.register_service::<u64>(42);
347 // The command / mode registries are reachable + mutable pre-freeze.
348 let _ = ctx.commands_mut();
349 let _ = ctx.modes_mut();
350
351 let services = ctx.freeze_service_registry();
352 assert_eq!(
353 services.get::<u64>().as_deref(),
354 Some(&42),
355 "registered service is present in the frozen registry"
356 );
357 let _commands = ctx.freeze_command_registry();
358 let _modes = ctx.freeze_mode_registry();
359 }
360
361 #[tokio::test]
362 #[should_panic(expected = "service registry already frozen")]
363 async fn register_after_freeze_panics() {
364 let mut ctx = ctx();
365 let _ = ctx.freeze_service_registry();
366 // Registering after the freeze is a boot-sequencing bug.
367 ctx.register_service::<u64>(7);
368 }
369
370 #[tokio::test]
371 async fn inbound_drains_via_tick_registry_and_send_wakes() {
372 let mut ctx = ctx();
373 let seen = Arc::new(Mutex::new(Vec::<u32>::new()));
374 let seen_in = Arc::clone(&seen);
375 let bus = ctx.inbound::<u32, _>(move |n| {
376 seen_in.lock().unwrap().push(n);
377 vec![Effect::None]
378 });
379
380 // The drain is registered on the shared registry, token retained.
381 assert_eq!(ctx.tick_callbacks().registered_count(), 1);
382
383 bus.send(1).unwrap();
384 bus.send(2).unwrap();
385
386 // `send` woke the editor off-keystroke.
387 let woke =
388 tokio::time::timeout(Duration::from_millis(200), ctx.async_landed().notified()).await;
389 assert!(woke.is_ok(), "inbound send must wake the editor");
390
391 // The host's per-tick run drains both items through the handler.
392 let effects = ctx.tick_callbacks().run_all();
393 assert_eq!(effects.len(), 2, "both pending items drained as effects");
394 assert_eq!(
395 *seen.lock().unwrap(),
396 vec![1, 2],
397 "handler saw items in order"
398 );
399 }
400
401 #[tokio::test]
402 async fn tick_callback_registration_is_retained() {
403 let mut ctx = ctx();
404 ctx.tick_callback(Box::new(|| vec![Effect::None]));
405 // Token lives in the context, so the drain survives past the call.
406 assert_eq!(ctx.tick_callbacks().registered_count(), 1);
407 assert_eq!(ctx.tick_callbacks().run_all().len(), 1);
408 }
409
410 #[tokio::test]
411 async fn into_registrations_hands_off_the_tokens() {
412 let mut ctx = ctx();
413 ctx.tick_callback(Box::new(|| vec![Effect::None]));
414 let registry = Arc::clone(ctx.tick_callbacks());
415 let tokens = ctx.into_registrations();
416 assert_eq!(tokens.len(), 1, "one retained registration handed off");
417 // While the caller holds the tokens, the drain stays registered.
418 assert_eq!(registry.registered_count(), 1);
419 // Dropping them (BC.3 hands them to the Editor; here we just drop)
420 // unregisters the drain — proving the tokens are the lifetime anchor.
421 drop(tokens);
422 assert_eq!(registry.registered_count(), 0);
423 }
424
425 /// MG.18d — magit's post-refresh cursor must reach the screen with
426 /// NO keypress.
427 ///
428 /// The mechanism is easy to get subtly wrong: a bare
429 /// `tick_callback` would look identical in code review and pass any
430 /// test that dispatches an action first, because `run_tick_pending`
431 /// is also reached from `App::apply`'s tail. It would then sit
432 /// until the user pressed something — "staging works but the cursor
433 /// only catches up when I touch a key".
434 ///
435 /// So this asserts the wake specifically: send on magit's cursor
436 /// bus, and `async_landed` (the actor's off-keystroke arm) must
437 /// fire, with the drain producing the targeted cursor move.
438 #[tokio::test]
439 async fn magits_cursor_bus_wakes_the_editor_without_a_keypress() {
440 use lattice_magit::cursor_restore::{CursorBusHandle, CursorRequest};
441 use lattice_protocol::position::Position;
442
443 let mut ctx = ctx();
444 lattice_magit::install(&mut ctx);
445
446 let bus = ctx
447 .service::<CursorBusHandle>()
448 .expect("magit installs a cursor bus");
449 let target = lattice_core::BufferId(7);
450 (*bus)
451 .send(CursorRequest {
452 buffer: target,
453 position: Position::new(4, 0),
454 })
455 .map_err(|_| "send")
456 .expect("bus accepts the request");
457
458 let woke =
459 tokio::time::timeout(Duration::from_millis(200), ctx.async_landed().notified()).await;
460 assert!(
461 woke.is_ok(),
462 "the send must wake the editor — otherwise the cursor waits for a keypress"
463 );
464
465 let effects = ctx.tick_callbacks().run_all();
466 let moved = effects.iter().any(|e| {
467 matches!(
468 e,
469 Effect::CursorMoveIn { target: t, position }
470 if *t == target && *position == Position::new(4, 0)
471 )
472 });
473 assert!(
474 moved,
475 "the drain must yield a buffer-targeted cursor move, got {effects:?}"
476 );
477 }
478
479 #[tokio::test]
480 async fn wake_on_event_fires_the_wake() {
481 // `LspInlayHintRefresh` is just a convenient concrete `TypedEvent +
482 // Clone` fixture; the mechanism under test is generic.
483 use lattice_lsp::LspInlayHintRefresh;
484
485 let ctx = ctx();
486 ctx.wake_on_event::<LspInlayHintRefresh>();
487 ctx.event_bus().publish_typed(LspInlayHintRefresh {
488 server_id: Arc::from("test-server"),
489 });
490
491 let woke =
492 tokio::time::timeout(Duration::from_millis(200), ctx.async_landed().notified()).await;
493 assert!(
494 woke.is_ok(),
495 "publishing a subscribed event must wake the editor"
496 );
497 }
498}