Skip to main content

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}