lattice_host/editor_actor.rs
1//! `EditorActor` — the editor runs on its own thread.
2//!
3//! Phase 5.8.AF.5 / Slice 3c.0.
4//!
5//! ## Why this exists
6//!
7//! Paramount goal #4 (CLAUDE.md): "Three-layer architecture
8//! (UI / Core / Plugins) communicating via typed message passing.
9//! Multi-threaded by construction. Nothing blocks the UI -- enforced
10//! architecturally, not by discipline."
11//!
12//! After Slices 3a + 3b.* moved every per-buffer LSP cache off
13//! the renderer thread via wait-free read primitives, the last
14//! piece of architectural debt is `Editor` itself living on the
15//! renderer thread. While the renderer holds `&mut Editor`, the
16//! UI thread *can* (in principle) do editor work synchronously,
17//! and any future feature that does so silently regresses the
18//! architecture. The fix: relocate `Editor` to its own thread so
19//! the renderer is *physically incapable* of touching it
20//! directly.
21//!
22//! ## Shape
23//!
24//! - `EditorActorHandle` — what the renderer holds. Carries the
25//! command-send half (`cmd_tx`), the signal-receive half
26//! (`signal_rx`), and a clone of the editor's
27//! `Arc<ArcSwap<RenderState>>`. Not `Clone` because it owns
28//! the unique receiver; `send_action` / `send_command` go
29//! through `&self`.
30//! - `EditorCommand` — the typed mailbox payload. Renderer-to-
31//! editor messages. Includes `Apply(Action)`, `HandleEffect`,
32//! `DispatchBlocking { invocation, reply }`, `Tick`, `Ping`,
33//! `Shutdown`. Extensible: subsequent slices add variants.
34//! - `spawn_editor_actor(editor) -> EditorActorHandle` — takes
35//! ownership of an `Editor`, spawns a dedicated thread with a
36//! `current_thread` tokio runtime, returns the handle. The
37//! thread name is `"lattice-editor"` for observability.
38//!
39//! ## Slice 3c.0 status
40//!
41//! Everything in this module is **dormant** -- no production call
42//! site wires `spawn_editor_actor` yet. The follow-on sub-slices:
43//!
44//! - **3c.1**: populate `ActiveDocumentRenderState` (the read
45//! contract for cursor/scroll/modal/etc.) so renderers can
46//! migrate off direct `editor.X` reads.
47//! - **3c.2 / 3c.3**: TUI / GPUI renderers switch their reads
48//! to `RenderState`.
49//! - **3c.4**: renderers wire `EditorActorHandle`; `App::apply`
50//! becomes `handle.send_action(action)`.
51//! - **3c.5**: sever `Arc<Editor>` from renderer entirely.
52//! - **3c.6 / 3c.7**: polish + docs.
53//!
54//! Tests in this file verify the substrate works end-to-end:
55//! spawn the actor, send a `Ping`, await the reply.
56
57use std::sync::Arc;
58
59use arc_swap::ArcSwap;
60use lattice_grammar::CommandInvocation;
61use lattice_grammar::effect::Effect;
62use lattice_runtime::RuntimeError;
63use tokio::sync::{mpsc, oneshot};
64
65use crate::action::Action;
66use crate::dispatch::{DispatchOutcome, RendererSignal};
67use crate::editor::Editor;
68use crate::render_state::RenderState;
69
70/// Error returned by the synchronous handle methods when the
71/// actor thread has shut down. Production code maps this to a
72/// fatal condition: the editor thread dying is unrecoverable.
73/// In tests, surfaces as a `panic!` via `unwrap` (acceptable —
74/// test fixtures keep the handle alive for their duration).
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct ActorGone;
77
78impl std::fmt::Display for ActorGone {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 write!(f, "editor actor thread has terminated")
81 }
82}
83
84impl std::error::Error for ActorGone {}
85
86/// Renderer-to-editor mailbox payload. Each variant corresponds
87/// to a way the renderer (or another task) drives the editor's
88/// state.
89pub enum EditorCommand {
90 /// Apply a renderer-translated `Action`. The action's
91 /// `DispatchOutcome` is processed inside the actor: signals
92 /// fan to `signal_tx`; queued `next_actions` and `effects`
93 /// are processed iteratively (same shape as
94 /// [`crate::editor::Editor::dispatch`]'s caller-side drain).
95 Apply(Action),
96 /// Synchronous variant of [`Self::Apply`]: dispatch the
97 /// action, then signal completion on `reply`. Phase 5.8.AF.5
98 /// / Slice 3c.final.E: used by the renderer's synchronous
99 /// `App::apply` wrapper so existing call sites that expect
100 /// to read editor-published state on the next line keep
101 /// working unchanged. The actor processes commands serially,
102 /// so awaiting this reply is a barrier — any previously-
103 /// queued commands (including signals) drain first.
104 ApplyAndReply {
105 action: Action,
106 reply: oneshot::Sender<()>,
107 },
108 /// Closure escape hatch (3c.final.E): run an arbitrary
109 /// mutation against `Editor` on the actor thread. Fire-and-
110 /// forget — no reply. Used by the renderer's `mutate_async`
111 /// helper for callsites that don't need a synchronous
112 /// barrier (e.g., LSP response handlers that simply stash a
113 /// cache and let the next publish flow downstream).
114 Mutate(Box<dyn FnOnce(&mut Editor) + Send>),
115 /// Synchronous variant of [`Self::Mutate`]: run the closure
116 /// against `Editor`, then signal completion. Used by the
117 /// majority of App-side helpers during the slice-E sweep;
118 /// the existing helper bodies move into the closure verbatim
119 /// and the caller blocks on `reply.recv()`. As helpers gain
120 /// typed `Action::*` variants over follow-up slices, their
121 /// callers retire `MutateAndReply` in favour of
122 /// `ApplyAndReply`.
123 MutateAndReply {
124 closure: Box<dyn FnOnce(&mut Editor) + Send>,
125 reply: oneshot::Sender<()>,
126 },
127 /// Read-side RPC (3c.final.E.swap): run a closure against
128 /// `&Editor` (immutable borrow) and reply with the boxed
129 /// result. The closure's return type is erased via
130 /// `Box<dyn Any + Send>` so the actor's command enum stays
131 /// non-generic; the caller's `with_editor<R>` helper
132 /// downcasts back to `R`. Used by every `&self`-receiver
133 /// helper on `App` / `GpuiApp` that previously did
134 /// `self.editor.X(...)` for a read.
135 Read {
136 closure: Box<dyn FnOnce(&Editor) -> Box<dyn std::any::Any + Send> + Send>,
137 reply: oneshot::Sender<Box<dyn std::any::Any + Send>>,
138 },
139 /// Read-and-mutate RPC (3c.final.E.swap): like `MutateAndReply`
140 /// but the closure returns a value. Used by `mutate_editor_with`
141 /// post-swap so closures returning `Vec<RendererSignal>` or
142 /// `Result<_, _>` still work. Same `Box<dyn Any>` erasure
143 /// pattern as [`Self::Read`].
144 MutateWithReply {
145 closure: Box<dyn FnOnce(&mut Editor) -> Box<dyn std::any::Any + Send> + Send>,
146 reply: oneshot::Sender<Box<dyn std::any::Any + Send>>,
147 },
148 /// Apply a renderer-emitted `Effect` directly (bypasses
149 /// dispatch). Used by Effect routers that already mapped
150 /// the effect upstream.
151 HandleEffect(Effect),
152 /// Synchronous dispatch with a single-reply oneshot. The
153 /// caller awaits the response. Used today by the `:g` /
154 /// `:v` body-replay loop and any other path that needs the
155 /// effect *immediately* (not via signal fan-out).
156 DispatchBlocking {
157 invocation: CommandInvocation,
158 reply: oneshot::Sender<Result<Effect, RuntimeError>>,
159 },
160 /// Fire the periodic `run_tick_pending` aggregator (LSP
161 /// drains, fs events, request fires). Sent by the renderer
162 /// at frame cadence today; once the editor task owns a
163 /// periodic timer of its own, this command retires.
164 Tick,
165 /// Empty roundtrip — replies as soon as the actor receives
166 /// the message. Used by tests to await processing without
167 /// observing side effects. Also useful for synchronous
168 /// barriers in subsequent slices.
169 Ping { reply: oneshot::Sender<()> },
170 /// Tear down the actor. The thread joins cleanly after the
171 /// in-flight command completes.
172 Shutdown,
173
174 // ------------------------------------------------------------
175 // 3c.atomic.F: typed setter commands. Mirror the in-process
176 // setters added in 3c.atomic.C (`Editor::set_cursor` /
177 // `set_cursor_line` / `set_cursor_byte` / `set_scroll` /
178 // `set_modal`) and the publish wrapper around
179 // `set_viewport_height` introduced in 3c.atomic.D.
180 //
181 // These commands cover the OUT-OF-DISPATCH write surface --
182 // viewport resize signalled by the renderer, test fixtures
183 // that wanted to seed cursor/scroll/modal directly, and
184 // future paths where a non-editor task needs to nudge active-
185 // document state. Each variant routes to the corresponding
186 // setter, which writes the field and publishes a fresh
187 // `RenderState`. The renderer observes the change via its
188 // shared `Arc<ArcSwap<RenderState>>` clone -- identical
189 // contract to the in-process method, executed on the editor
190 // thread.
191 // ------------------------------------------------------------
192 /// Replace `Editor::cursor` and publish render-state.
193 SetCursor(lattice_protocol::position::Position),
194 /// Replace `Editor::cursor.line` and publish render-state.
195 SetCursorLine(u32),
196 /// Replace `Editor::cursor.byte` and publish render-state.
197 SetCursorByte(u32),
198 /// Replace `Editor::scroll` and publish render-state.
199 SetScroll(u32),
200 /// Replace `Editor::modal` and publish render-state.
201 SetModal(lattice_grammar::ModalState),
202 /// Resize the active pane's viewport. Mirrors the App-side
203 /// `set_viewport_height` wrapper (clamp to >= 1, run
204 /// `ensure_cursor_visible`, publish). The actor wraps the
205 /// no-publish editor field write + the visibility fan-out
206 /// into one atomic command so the renderer's per-frame "tell
207 /// editor the pane size" hand-off is a single `cmd_tx.send`.
208 SetViewportHeight(u32),
209 /// Per-pane geometry update. Issue #25 (2026-05-22): the
210 /// renderer's per-frame layout pass walks the pane tree and
211 /// fires one of these per leaf with the leaf's computed
212 /// height + width in screen rows / columns. The host stores
213 /// them on `PaneState`; the active leaf's viewport_height
214 /// is mirrored into `Editor::viewport_height` for the
215 /// cursor-clamp + highlights-worker code paths that don't
216 /// carry a pane index. Publishes RS at the tail.
217 SetPaneViewport { idx: usize, height: u32, width: u32 },
218}
219
220/// Renderer-side handle to the editor actor.
221///
222/// Holds:
223/// - `cmd_tx` — unbounded; the renderer sends commands without
224/// blocking. Backpressure is the actor's per-command processing
225/// cost; today every command processes in microseconds so the
226/// queue stays effectively empty.
227/// - `signal_rx` — unbounded; the renderer drains signals once
228/// per frame via [`Self::poll_signal`]. The actor pushes
229/// signals as it processes commands.
230/// - `render_state` — a clone of the editor's
231/// `Arc<ArcSwap<RenderState>>`. The renderer loads via
232/// `handle.render_state.load()` (wait-free); the editor's
233/// `publish_render_state` calls are observable through this
234/// shared Arc.
235///
236/// Not `Clone`: the `signal_rx` is uniquely owned. Renderers
237/// that need to send commands from multiple places can clone
238/// the underlying `mpsc::UnboundedSender` via
239/// [`Self::cmd_sender`], which is freely `Clone`.
240pub struct EditorActorHandle {
241 cmd_tx: mpsc::UnboundedSender<EditorCommand>,
242 signal_rx: mpsc::UnboundedReceiver<RendererSignal>,
243 render_state: Arc<ArcSwap<RenderState>>,
244 /// I.3: the editor's `paint_request` `Notify`, fired by the async
245 /// workers after a republish. Cloned to the TUI event loop so a
246 /// background publish wakes a repaint without polling.
247 paint_request: Arc<tokio::sync::Notify>,
248 /// Join handle to the dedicated editor thread. Held so
249 /// shutdown can join cleanly when the handle drops. `Some`
250 /// in normal construction; `None` after explicit
251 /// [`Self::shutdown_and_join`].
252 join: Option<std::thread::JoinHandle<()>>,
253}
254
255/// Runtime-flavor-aware `oneshot::Receiver::blocking_recv`.
256///
257/// Slice `3c.fixup.actor-sync-rpc` (companion to
258/// `3c.fixup.actor-block-on` in `lattice-runtime/src/runtime.rs`).
259/// The sync-RPC methods below (`apply_blocking`, `mutate_blocking`,
260/// `mutate_blocking_with`, `with_editor`) all park the caller's
261/// thread on a `oneshot::Receiver` until the actor replies. Tokio's
262/// `blocking_recv` panics with
263/// "Cannot block the current thread from within a runtime"
264/// when called from inside an async context.
265///
266/// The GPUI peer's main thread hosts a tokio runtime (current-thread,
267/// for `tokio_main`-style entry); any App-side helper that reaches
268/// the actor seam from that thread previously panicked at startup.
269/// Caught by `cargo run --features window` on 2026-05-21; the fix
270/// follows the same three-arm pattern as
271/// `lattice_runtime::block_on`:
272///
273/// 1. No current handle — direct `blocking_recv()` (the previous
274/// contract).
275/// 2. MultiThread runtime — relinquish the worker via
276/// `task::block_in_place(|| rx.blocking_recv())`.
277/// 3. Non-MultiThread runtime (the GPUI main thread case) —
278/// escape to a fresh OS thread via `std::thread::scope` and
279/// do the blocking recv there, outside any tokio context.
280fn safe_blocking_recv<T: Send>(rx: oneshot::Receiver<T>) -> Result<T, oneshot::error::RecvError> {
281 use tokio::runtime::{Handle, RuntimeFlavor};
282 match Handle::try_current() {
283 Ok(handle) if matches!(handle.runtime_flavor(), RuntimeFlavor::MultiThread) => {
284 tokio::task::block_in_place(|| rx.blocking_recv())
285 }
286 Ok(_) => std::thread::scope(|s| {
287 s.spawn(|| rx.blocking_recv())
288 .join()
289 .expect("nested-blocking_recv bridge thread completed")
290 }),
291 Err(_) => rx.blocking_recv(),
292 }
293}
294
295impl EditorActorHandle {
296 /// Send a command to the editor actor. Returns `Err` only
297 /// when the actor has already shut down (channel closed).
298 /// In production this should never happen during normal
299 /// operation; in tests the failure mode is "test forgot to
300 /// keep the handle alive."
301 pub fn send(&self, cmd: EditorCommand) -> Result<(), mpsc::error::SendError<EditorCommand>> {
302 self.cmd_tx.send(cmd)
303 }
304
305 /// Convenience: send an `Apply(action)` command. Fire-and-
306 /// forget — does not wait for completion. The renderer
307 /// keystroke handler uses this so the input loop returns
308 /// immediately; the dispatched action runs on the actor
309 /// thread and publishes RenderState when done (the existing
310 /// `paint_request` Notify wakes the GPUI peer's next paint).
311 pub fn send_action(&self, action: Action) -> Result<(), mpsc::error::SendError<EditorCommand>> {
312 self.send(EditorCommand::Apply(action))
313 }
314
315 /// Synchronous dispatch — blocks until the actor finishes
316 /// processing this action (and any cascade). Used by
317 /// `App::apply` so existing call sites that read editor-
318 /// derived state on the next line keep working. Returns
319 /// `Err` only if the actor died mid-await.
320 ///
321 /// Safe to call from inside or outside a tokio runtime:
322 /// `safe_blocking_recv` selects the right wait path based on
323 /// the current runtime flavor (see its docstring).
324 pub fn apply_blocking(&self, action: Action) -> Result<(), ActorGone> {
325 let (reply_tx, reply_rx) = oneshot::channel();
326 self.send(EditorCommand::ApplyAndReply {
327 action,
328 reply: reply_tx,
329 })
330 .map_err(|_| ActorGone)?;
331 safe_blocking_recv(reply_rx).map_err(|_| ActorGone)
332 }
333
334 /// Closure escape hatch — synchronous: send the closure +
335 /// block until the actor runs it and publishes RS. Used by
336 /// App-side helpers during the slice-E sweep when the
337 /// helper's body mutates editor state directly. Each helper
338 /// wraps its body in one closure; the caller's read-back of
339 /// editor-derived state via `app.ad()` etc. sees the post-
340 /// mutation snapshot.
341 pub fn mutate_blocking(
342 &self,
343 closure: Box<dyn FnOnce(&mut Editor) + Send>,
344 ) -> Result<(), ActorGone> {
345 let (reply_tx, reply_rx) = oneshot::channel();
346 self.send(EditorCommand::MutateAndReply {
347 closure,
348 reply: reply_tx,
349 })
350 .map_err(|_| ActorGone)?;
351 // Slice 3c.fixup.actor-sync-rpc: runtime-flavor-aware wait.
352 safe_blocking_recv(reply_rx).map_err(|_| ActorGone)
353 }
354
355 /// Closure escape hatch — fire-and-forget. For tokio task
356 /// callers that can't block (e.g., LSP response handlers).
357 pub fn mutate_async(
358 &self,
359 closure: Box<dyn FnOnce(&mut Editor) + Send>,
360 ) -> Result<(), ActorGone> {
361 self.send(EditorCommand::Mutate(closure))
362 .map_err(|_| ActorGone)
363 }
364
365 /// Read-side RPC (3c.final.E.swap): synchronously run a
366 /// closure against `&Editor` on the actor thread and return
367 /// the result. Used by every `&self`-receiver helper on
368 /// `App` / `GpuiApp` that needs to read editor state.
369 /// Internally uses `Box<dyn Any>` erasure to keep the
370 /// actor's command enum non-generic.
371 pub fn with_editor<R, F>(&self, f: F) -> R
372 where
373 R: Send + 'static,
374 F: FnOnce(&Editor) -> R + Send + 'static,
375 {
376 let (tx, rx) = oneshot::channel::<Box<dyn std::any::Any + Send>>();
377 let closure: Box<dyn FnOnce(&Editor) -> Box<dyn std::any::Any + Send> + Send> =
378 Box::new(move |e| Box::new(f(e)));
379 self.send(EditorCommand::Read { closure, reply: tx })
380 .expect("editor actor alive");
381 // Slice 3c.fixup.actor-sync-rpc: runtime-flavor-aware wait.
382 let any = safe_blocking_recv(rx).expect("editor actor alive");
383 *any.downcast::<R>().expect("read RPC result type matches")
384 }
385
386 /// Sync mutate RPC with return value (3c.final.E.swap).
387 /// Used by `mutate_editor_with` post-swap: the closure
388 /// returns `R`; the actor publishes RS at its tail.
389 pub fn mutate_blocking_with<R, F>(&self, f: F) -> R
390 where
391 R: Send + 'static,
392 F: FnOnce(&mut Editor) -> R + Send + 'static,
393 {
394 let (tx, rx) = oneshot::channel::<Box<dyn std::any::Any + Send>>();
395 let closure: Box<dyn FnOnce(&mut Editor) -> Box<dyn std::any::Any + Send> + Send> =
396 Box::new(move |e| Box::new(f(e)));
397 self.send(EditorCommand::MutateWithReply { closure, reply: tx })
398 .expect("editor actor alive");
399 // Slice 3c.fixup.actor-sync-rpc: runtime-flavor-aware wait.
400 let any = safe_blocking_recv(rx).expect("editor actor alive");
401 *any.downcast::<R>().expect("mutate RPC result type matches")
402 }
403
404 // ------------------------------------------------------------
405 // 3c.atomic.F: typed setter helpers. Each wraps the
406 // corresponding command variant so callers write
407 // `handle.set_cursor(p)` instead of
408 // `handle.send(EditorCommand::SetCursor(p))`. Symmetric with
409 // the in-process `Editor::set_*` setters added in
410 // 3c.atomic.C: same call shape, same semantics, different
411 // dispatch (in-actor vs. in-process).
412 // ------------------------------------------------------------
413
414 /// Replace the editor's cursor and republish.
415 pub fn set_cursor(
416 &self,
417 cursor: lattice_protocol::position::Position,
418 ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
419 self.send(EditorCommand::SetCursor(cursor))
420 }
421
422 /// Replace `cursor.line` and republish.
423 pub fn set_cursor_line(&self, line: u32) -> Result<(), mpsc::error::SendError<EditorCommand>> {
424 self.send(EditorCommand::SetCursorLine(line))
425 }
426
427 /// Replace `cursor.byte` and republish.
428 pub fn set_cursor_byte(&self, byte: u32) -> Result<(), mpsc::error::SendError<EditorCommand>> {
429 self.send(EditorCommand::SetCursorByte(byte))
430 }
431
432 /// Replace `scroll` and republish.
433 pub fn set_scroll(&self, scroll: u32) -> Result<(), mpsc::error::SendError<EditorCommand>> {
434 self.send(EditorCommand::SetScroll(scroll))
435 }
436
437 /// Replace `modal` and republish.
438 pub fn set_modal(
439 &self,
440 modal: lattice_grammar::ModalState,
441 ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
442 self.send(EditorCommand::SetModal(modal))
443 }
444
445 /// Resize the active pane's viewport. Runs the same body as
446 /// the App-side wrapper: clamp to >= 1, run
447 /// `ensure_cursor_visible`, publish.
448 pub fn set_viewport_height(
449 &self,
450 height: u32,
451 ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
452 self.send(EditorCommand::SetViewportHeight(height))
453 }
454
455 /// Issue #25 (2026-05-22): per-pane geometry setter. The
456 /// renderer's per-frame layout pass walks the pane tree and
457 /// fires one of these per leaf. The active leaf's height is
458 /// auto-mirrored into `Editor::viewport_height` for cursor
459 /// clamp + highlights worker.
460 pub fn set_pane_viewport(
461 &self,
462 idx: usize,
463 height: u32,
464 width: u32,
465 ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
466 self.send(EditorCommand::SetPaneViewport { idx, height, width })
467 }
468
469 /// Drain a single pending signal from the editor's
470 /// signal stream. Returns `None` when the queue is empty
471 /// (steady-state on most frames). Called per-frame by the
472 /// renderer; the typical drain pattern is
473 /// `while let Some(sig) = handle.poll_signal() { ... }`.
474 pub fn poll_signal(&mut self) -> Option<RendererSignal> {
475 self.signal_rx.try_recv().ok()
476 }
477
478 /// Cheap clone of the command-send side. Use this when
479 /// multiple call sites in the renderer need to send
480 /// commands without sharing a `&self` to the full handle.
481 pub fn cmd_sender(&self) -> mpsc::UnboundedSender<EditorCommand> {
482 self.cmd_tx.clone()
483 }
484
485 /// Wait-free read of the current `RenderState` snapshot.
486 /// Returns an `Arc<RenderState>` the renderer holds across
487 /// the frame.
488 pub fn render_state(&self) -> Arc<RenderState> {
489 self.render_state.load_full()
490 }
491
492 /// Shared `ArcSwap` reference for callers that need to
493 /// store it alongside other Arc-shared state (e.g., a
494 /// renderer that wants to clone the cell into a paint
495 /// closure).
496 pub fn render_state_arc(&self) -> Arc<ArcSwap<RenderState>> {
497 self.render_state.clone()
498 }
499
500 /// Shared `paint_request` `Notify` the actor's workers fire after an
501 /// async republish (syntax recolour, LSP decoration, cells/virtual-rows).
502 /// The TUI event loop (I.3) awaits this to repaint promptly; the GPUI peer
503 /// uses the same notify natively.
504 pub fn paint_request(&self) -> Arc<tokio::sync::Notify> {
505 self.paint_request.clone()
506 }
507
508 /// Send `Shutdown` and join the editor thread. Idempotent
509 /// in the sense that subsequent calls return `Ok(())`
510 /// without re-joining. Returns `Err` only if the thread
511 /// panicked.
512 pub fn shutdown_and_join(&mut self) -> std::thread::Result<()> {
513 let _ = self.cmd_tx.send(EditorCommand::Shutdown);
514 if let Some(handle) = self.join.take() {
515 handle.join()
516 } else {
517 Ok(())
518 }
519 }
520}
521
522impl Drop for EditorActorHandle {
523 fn drop(&mut self) {
524 // Best-effort clean shutdown. If the renderer holds the
525 // handle as a long-lived field, this only fires at
526 // editor exit; the join inside is short because the
527 // actor's command loop is fast.
528 if self.join.is_some() {
529 let _ = self.cmd_tx.send(EditorCommand::Shutdown);
530 if let Some(h) = self.join.take() {
531 let _ = h.join();
532 }
533 }
534 }
535}
536
537/// Spawn the editor actor on a dedicated thread with a
538/// `current_thread` tokio runtime. Takes ownership of `editor`.
539///
540/// The dedicated thread is named `"lattice-editor"` for
541/// observability. The actor's tokio runtime is `current_thread`
542/// (single-threaded executor); editor work that needs the
543/// multi-thread LSP runtime spawns onto it via
544/// `lattice_runtime::runtime::spawn_on_lsp_runtime` as before
545/// (unchanged).
546///
547/// Returns an [`EditorActorHandle`]. Drop the handle to shut
548/// the actor down.
549pub fn spawn_editor_actor(editor: Editor) -> EditorActorHandle {
550 let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<EditorCommand>();
551 let (signal_tx, signal_rx) = mpsc::unbounded_channel::<RendererSignal>();
552 let render_state = editor.render_state.clone();
553 let paint_request = editor.paint_request.clone();
554
555 let join = std::thread::Builder::new()
556 .name("lattice-editor".to_string())
557 .spawn(move || {
558 // current_thread runtime: single-task executor on
559 // this thread. The editor task can spawn onto the
560 // multi-thread `lsp_runtime` for LSP work as
561 // before; only the editor's *own* work runs here.
562 let rt = tokio::runtime::Builder::new_current_thread()
563 .enable_all()
564 .thread_name("lattice-editor")
565 .build()
566 .expect("editor runtime should build");
567 rt.block_on(run_actor(editor, cmd_rx, signal_tx));
568 })
569 .expect("spawn editor thread");
570
571 EditorActorHandle {
572 cmd_tx,
573 signal_rx,
574 render_state,
575 paint_request,
576 join: Some(join),
577 }
578}
579
580/// Actor event loop. Processes commands one at a time; the
581/// `Editor` is owned exclusively by this task so all mutations
582/// are single-writer. Signals flow to `signal_tx`;
583/// `DispatchOutcome` cascades (`effects`, `next_actions`) are
584/// processed iteratively to avoid stack growth on long chains.
585async fn run_actor(
586 mut editor: Editor,
587 mut cmd_rx: mpsc::UnboundedReceiver<EditorCommand>,
588 signal_tx: mpsc::UnboundedSender<RendererSignal>,
589) {
590 // Slice B.1 (2026-06-03): the loop wakes on EITHER an incoming
591 // command OR `async_landed` — fired by async completions (today
592 // the syntax reparse worker on publish) that produce
593 // render-relevant state with no keystroke in flight. On that wake
594 // we run the tick aggregator + re-publish, so e.g. an idle markdown
595 // reparse repaints without waiting for the next key. Runs on the
596 // single-writer actor thread, not the UI thread (paramount #1).
597 let async_landed = editor.async_landed.clone();
598 // L4a.2 (lsp-architecture.md §15): the inline cursor-line
599 // diagnostic-summary idle gate. A pinned sleep, seeded far in the
600 // future and retargeted each iteration to `editor`'s armed
601 // deadline (set in `update_inline_diag_gate` during publish). The
602 // guarded select! arm fires only for a live arm; on fire it makes
603 // the summary visible + republishes, all on the actor thread
604 // (paramount #1 — never the UI thread).
605 let inline_diag_sleep = tokio::time::sleep(std::time::Duration::from_secs(60 * 60));
606 tokio::pin!(inline_diag_sleep);
607 // WK.3: the generic idle-gate registry's sleep — the same shape as the
608 // one above, but targeting the earliest deadline armed by ANY subsystem
609 // (which-key's pending-chord delay is the first). A second pinned sleep
610 // rather than a shared one, because the inline-diagnostic gate is not
611 // migrated into the registry yet (which-key.md §9: its arm decision lives
612 // inside `publish_render_state` and needs a `CursorSettled` event that
613 // does not exist). When that migration lands this arm absorbs the one
614 // above.
615 let idle_gate_sleep = tokio::time::sleep(std::time::Duration::from_secs(60 * 60));
616 tokio::pin!(idle_gate_sleep);
617 loop {
618 // Retarget the idle-gate sleep to the current deadline. Cheap;
619 // when disarmed we point it an hour out and the `is_some()`
620 // guard keeps the arm dormant.
621 inline_diag_sleep
622 .as_mut()
623 .reset(editor.inline_diag_deadline.unwrap_or_else(|| {
624 tokio::time::Instant::now() + std::time::Duration::from_secs(60 * 60)
625 }));
626 let idle_gate_deadline = editor.idle_gate_deadline();
627 idle_gate_sleep
628 .as_mut()
629 .reset(idle_gate_deadline.unwrap_or_else(|| {
630 tokio::time::Instant::now() + std::time::Duration::from_secs(60 * 60)
631 }));
632 let cmd = tokio::select! {
633 maybe_cmd = cmd_rx.recv() => match maybe_cmd {
634 Some(cmd) => cmd,
635 None => break,
636 },
637 _ = async_landed.notified() => {
638 let signals = editor.run_tick_pending();
639 // AW.4 (hover-popup fix): an async drain — hover (`K`),
640 // signature-help, an async picker — can emit a `DisplayBuffer`
641 // signal, a request to open a popup. The popup is HOST state
642 // (`Editor::popup_buffer`); both peers' own `DisplayBuffer`
643 // handlers just call `editor.display_buffer` then render
644 // `popup_buffer` from RenderState. But the TUI peer has NO
645 // `signal_rx` consumer (it drives entirely off `paint_request` +
646 // RenderState), so a `DisplayBuffer` forwarded on `signal_tx` from
647 // *this* arm is silently dropped and the popup never shows — the
648 // server-responds-but-no-popup bug. Apply it host-side HERE,
649 // before `publish_render_state`, so opening `popup_buffer` moves
650 // `paint_revision` → `paint_request` fires → both peers repaint and
651 // render the popup off the wake. Consuming (not forwarding) it is
652 // parity with the GPUI peer's handler, not a divergence.
653 let forward = editor.absorb_async_display_signals(signals);
654 // §12 paint gate: an async arrival that moved a non-cell
655 // render-visible surface (LSP readiness badge, diagnostics
656 // overlay, popup, …) must reach a frame WITHOUT a keystroke
657 // — the cells / virtual-rows workers only paint on their
658 // own content change. `publish_render_state` reports
659 // whether `paint_revision` moved; fire `paint_request`
660 // when it did. Gated so a no-op publish doesn't spin the
661 // GPUI paint bridge.
662 let painted = editor.publish_render_state();
663 if painted {
664 editor.paint_request.notify_one();
665 }
666 // Notify cells via the event bus so the wake is
667 // sequenced after the ArcSwap store in
668 // publish_render_state. Cells wakes via the
669 // AsyncRenderStatePublished bridge in editor_boot.rs.
670 editor.event_bus.publish_typed(
671 crate::events::AsyncRenderStatePublished,
672 );
673 for sig in forward {
674 let _ = signal_tx.send(sig);
675 }
676 continue;
677 }
678 // L4a.2: the inline-diagnostic idle deadline elapsed.
679 // Flip the gate visible, republish (so `build_render_state`
680 // emits the cursor-line summary), and wake cells via the
681 // same `AsyncRenderStatePublished` bridge the async_landed
682 // arm uses. No `run_tick_pending` — the gate only changes
683 // presentation, not pending async work.
684 // WK.3: a subsystem's armed deadline elapsed. Run every due
685 // gate, apply its effects, republish and repaint — all on the
686 // actor thread, and all WITHOUT a keystroke, which is the whole
687 // point of the primitive (a popup that only appears once the
688 // user presses something is not a hint, it is a bug).
689 _ = &mut idle_gate_sleep, if idle_gate_deadline.is_some() => {
690 let signals = editor.fire_idle_gates();
691 let forward = editor.absorb_async_display_signals(signals);
692 let painted = editor.publish_render_state();
693 if painted {
694 editor.paint_request.notify_one();
695 }
696 editor.event_bus.publish_typed(
697 crate::events::AsyncRenderStatePublished,
698 );
699 for sig in forward {
700 let _ = signal_tx.send(sig);
701 }
702 continue;
703 }
704 _ = &mut inline_diag_sleep, if editor.inline_diag_deadline.is_some() => {
705 editor.fire_inline_diag_gate();
706 // §12 paint gate: the idle gate flips the inline summary
707 // visible — a non-cell surface — so fire `paint_request`
708 // when the publish reports the change.
709 let painted = editor.publish_render_state();
710 if painted {
711 editor.paint_request.notify_one();
712 }
713 editor.event_bus.publish_typed(
714 crate::events::AsyncRenderStatePublished,
715 );
716 continue;
717 }
718 };
719 match cmd {
720 EditorCommand::Apply(action) => {
721 let outcome = editor.dispatch(action);
722 drain_outcome(outcome, &mut editor, &signal_tx);
723 }
724 EditorCommand::ApplyAndReply { action, reply } => {
725 let outcome = editor.dispatch(action);
726 drain_outcome(outcome, &mut editor, &signal_tx);
727 // Signal completion after dispatch + cascade.
728 // Render-state already published by dispatch tail
729 // so the caller's next `render_state.load()` sees
730 // the new state.
731 let _ = reply.send(());
732 }
733 EditorCommand::Mutate(closure) => {
734 closure(&mut editor);
735 // The closure may or may not have published RS;
736 // for the renderer's read contract to hold, fire
737 // a publish here so the caller's next read sees
738 // any field mutation the closure made.
739 editor.publish_render_state();
740 }
741 EditorCommand::MutateAndReply { closure, reply } => {
742 closure(&mut editor);
743 editor.publish_render_state();
744 let _ = reply.send(());
745 }
746 EditorCommand::Read { closure, reply } => {
747 let any = closure(&editor);
748 let _ = reply.send(any);
749 }
750 EditorCommand::MutateWithReply { closure, reply } => {
751 let any = closure(&mut editor);
752 editor.publish_render_state();
753 let _ = reply.send(any);
754 }
755 EditorCommand::HandleEffect(effect) => {
756 let outcome = editor.handle_effect(effect);
757 drain_outcome(outcome, &mut editor, &signal_tx);
758 }
759 EditorCommand::DispatchBlocking { invocation, reply } => {
760 let result = editor.dispatch_blocking(invocation);
761 let _ = reply.send(result);
762 }
763 EditorCommand::Tick => {
764 let signals = editor.run_tick_pending();
765 for sig in signals {
766 let _ = signal_tx.send(sig);
767 }
768 }
769 EditorCommand::Ping { reply } => {
770 let _ = reply.send(());
771 }
772 EditorCommand::Shutdown => break,
773 // 3c.atomic.F: typed setter commands. Each delegates
774 // to the in-process setter, which writes the field
775 // and publishes. The renderer's shared
776 // `Arc<ArcSwap<RenderState>>` clone observes the new
777 // pointer wait-free.
778 EditorCommand::SetCursor(p) => editor.set_cursor(p),
779 EditorCommand::SetCursorLine(line) => editor.set_cursor_line(line),
780 EditorCommand::SetCursorByte(byte) => editor.set_cursor_byte(byte),
781 EditorCommand::SetScroll(s) => editor.set_scroll(s),
782 EditorCommand::SetModal(m) => editor.set_modal(m),
783 EditorCommand::SetViewportHeight(h) => {
784 // Mirrors the App-side wrapper:
785 // clamp to >= 1, run ensure_cursor_visible (which
786 // may adjust `scroll`), publish once at the tail.
787 editor.viewport_height = h.max(1);
788 editor.ensure_cursor_visible();
789 editor.publish_render_state();
790 }
791 EditorCommand::SetPaneViewport { idx, height, width } => {
792 // Issue #25 (2026-05-22): write per-pane geometry
793 // onto the leaf and, when this is the active pane,
794 // mirror its height into `Editor::viewport_height`
795 // so cursor-clamp + highlights-worker keep reading
796 // a single value but it now always reflects the
797 // active pane's actual painted area.
798 let active_idx = editor.pane_tree.active_index();
799 let leaves = editor.pane_tree.leaves_mut();
800 let pane_kind = leaves.get(idx).map(|l| (l.buffer, l.buffer_id));
801 if idx < leaves.len() {
802 leaves[idx].viewport_height = height.max(1);
803 leaves[idx].viewport_width = width.max(1);
804 }
805 if idx == active_idx {
806 editor.viewport_height = height.max(1);
807 editor.ensure_cursor_visible();
808 // DB.4: content-centring pad depends on the pane width, so
809 // recompute it (and the rest of the option cache) when the
810 // active pane resizes — keeps the dashboard centred.
811 editor.rebuild_option_cache();
812 }
813 // T4.1 (2026-05-25): when the pane hosts a
814 // terminal, propagate the new geometry to both
815 // the alacritty grid (via SharedTerm::resize)
816 // and the PTY (via PtyHandle::resize) so the
817 // child sees a SIGWINCH and re-lays-out its UI.
818 if let Some((lattice_core::BufferKind::Terminal, buf_id)) = pane_kind {
819 let rows = height.max(1).min(u16::MAX as u32) as u16;
820 let cols = width.max(1).min(u16::MAX as u32) as u16;
821 let _ = editor.buffers.with_terminal(buf_id, |t| {
822 t.term.resize(rows, cols);
823 let _ = t.pty.resize(rows, cols);
824 });
825 }
826 editor.publish_render_state();
827 }
828 }
829 }
830}
831
832/// Drain a `DispatchOutcome`'s signals + cascade follow-ups.
833/// Iterative (not recursive) to bound stack usage on long
834/// `next_actions` chains.
835fn drain_outcome(
836 outcome: DispatchOutcome,
837 editor: &mut Editor,
838 signal_tx: &mpsc::UnboundedSender<RendererSignal>,
839) {
840 let mut work: Vec<DispatchOutcome> = vec![outcome];
841 while let Some(mut o) = work.pop() {
842 for sig in o.renderer_signals.drain(..) {
843 let _ = signal_tx.send(sig);
844 }
845 for effect in o.effects.drain(..) {
846 work.push(editor.handle_effect(effect));
847 }
848 for action in o.next_actions.drain(..) {
849 work.push(editor.dispatch(action));
850 }
851 // `consumed` is a renderer-side flag (used by
852 // `sync_keymap_overlays`); irrelevant inside the actor.
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 /// Spawn the actor, send a Ping, await the reply. Verifies
861 /// the substrate: thread spawns, runtime initializes,
862 /// command channel works, oneshot reply works.
863 #[test]
864 fn actor_ping_pong_roundtrip() {
865 let editor = Editor::default();
866 let handle = spawn_editor_actor(editor);
867 let (reply_tx, reply_rx) = oneshot::channel();
868 handle
869 .send(EditorCommand::Ping { reply: reply_tx })
870 .expect("send ping");
871 reply_rx.blocking_recv().expect("ping reply");
872 }
873
874 /// `Apply(Action::None)` causes the editor's render_state
875 /// publication to fire — observable via Arc identity change
876 /// on `handle.render_state()`.
877 #[test]
878 fn actor_apply_action_publishes_render_state() {
879 let editor = Editor::default();
880 let handle = spawn_editor_actor(editor);
881 let before = handle.render_state();
882 handle.send_action(Action::None).expect("send action");
883 // Synchronize: Ping reply after the Apply guarantees the
884 // actor processed the Apply (commands are serialized).
885 let (reply_tx, reply_rx) = oneshot::channel();
886 handle
887 .send(EditorCommand::Ping { reply: reply_tx })
888 .expect("send ping");
889 reply_rx.blocking_recv().expect("ping reply");
890 let after = handle.render_state();
891 assert!(
892 !Arc::ptr_eq(&before, &after),
893 "dispatch() tail must publish a fresh RenderState Arc"
894 );
895 }
896
897 /// Dropping the handle cleanly shuts the thread down. No
898 /// hang; no panic; thread joins.
899 #[test]
900 fn actor_drop_handle_joins_thread() {
901 let editor = Editor::default();
902 let handle = spawn_editor_actor(editor);
903 let (reply_tx, reply_rx) = oneshot::channel();
904 handle
905 .send(EditorCommand::Ping { reply: reply_tx })
906 .expect("send ping");
907 reply_rx.blocking_recv().expect("ping reply");
908 drop(handle);
909 // If we reach here without hanging, the thread joined.
910 }
911
912 /// Explicit shutdown_and_join works without panic and is
913 /// idempotent on subsequent calls.
914 #[test]
915 fn actor_explicit_shutdown_join_idempotent() {
916 let editor = Editor::default();
917 let mut handle = spawn_editor_actor(editor);
918 handle.shutdown_and_join().expect("first join");
919 // Second call returns Ok without re-joining (no
920 // handle to join).
921 handle.shutdown_and_join().expect("second join");
922 }
923
924 /// `Tick` runs `run_tick_pending` and forwards any signals.
925 /// On a fresh editor with no pending drains, the signal
926 /// stream stays empty.
927 #[test]
928 fn actor_tick_with_no_pending_work_emits_no_signals() {
929 let editor = Editor::default();
930 let mut handle = spawn_editor_actor(editor);
931 handle.send(EditorCommand::Tick).expect("send tick");
932 // Synchronize.
933 let (reply_tx, reply_rx) = oneshot::channel();
934 handle
935 .send(EditorCommand::Ping { reply: reply_tx })
936 .expect("send ping");
937 reply_rx.blocking_recv().expect("ping reply");
938 // Drain any signals.
939 let mut sig_count = 0usize;
940 while handle.poll_signal().is_some() {
941 sig_count += 1;
942 }
943 // A fresh-default editor with no LSP attach has no
944 // signal-producing drains; tick should be silent.
945 assert_eq!(sig_count, 0, "fresh editor Tick should be silent");
946 }
947
948 // ------------------------------------------------------------
949 // 3c.atomic.F: typed setter command tests.
950 //
951 // Each test spawns a fresh actor, sends a setter command,
952 // synchronizes with a Ping, and asserts that the published
953 // `RenderState` reflects the mutation. The published-Arc
954 // identity changes on each setter (different `Arc::ptr_eq`),
955 // which is the canonical signal that `publish_render_state`
956 // fired inside the actor body.
957 // ------------------------------------------------------------
958
959 /// Synchronize on the actor's command queue. Ping reply
960 /// is sent after the preceding command's handler returns,
961 /// because commands are processed serially.
962 fn await_actor(handle: &EditorActorHandle) {
963 let (reply_tx, reply_rx) = oneshot::channel();
964 handle
965 .send(EditorCommand::Ping { reply: reply_tx })
966 .expect("send ping");
967 reply_rx.blocking_recv().expect("ping reply");
968 }
969
970 #[test]
971 fn actor_set_cursor_publishes_new_position() {
972 let editor = Editor::default();
973 let handle = spawn_editor_actor(editor);
974 let before = handle.render_state();
975 let target = lattice_protocol::position::Position::new(3, 7);
976 handle.set_cursor(target).expect("send set_cursor");
977 await_actor(&handle);
978 let after = handle.render_state();
979 assert!(
980 !Arc::ptr_eq(&before, &after),
981 "SetCursor must publish a fresh RenderState Arc"
982 );
983 assert_eq!(after.active_document.load().cursor, target);
984 }
985
986 #[test]
987 fn actor_set_cursor_line_byte_publish_independently() {
988 let editor = Editor::default();
989 let handle = spawn_editor_actor(editor);
990 handle.set_cursor_line(5).expect("send set_cursor_line");
991 await_actor(&handle);
992 let after_line = handle.render_state();
993 assert_eq!(after_line.active_document.load().cursor.line, 5);
994 // byte stays at default.
995 assert_eq!(after_line.active_document.load().cursor.byte, 0);
996
997 handle.set_cursor_byte(11).expect("send set_cursor_byte");
998 await_actor(&handle);
999 let after_byte = handle.render_state();
1000 assert_eq!(after_byte.active_document.load().cursor.line, 5);
1001 assert_eq!(after_byte.active_document.load().cursor.byte, 11);
1002 }
1003
1004 #[test]
1005 fn actor_set_scroll_publishes_new_scroll() {
1006 let editor = Editor::default();
1007 let handle = spawn_editor_actor(editor);
1008 handle.set_scroll(42).expect("send set_scroll");
1009 await_actor(&handle);
1010 let after = handle.render_state();
1011 assert_eq!(after.active_document.load().scroll, 42);
1012 }
1013
1014 #[test]
1015 fn actor_set_modal_publishes_new_modal() {
1016 let editor = Editor::default();
1017 let handle = spawn_editor_actor(editor);
1018 handle
1019 .set_modal(lattice_grammar::ModalState::Insert)
1020 .expect("send set_modal");
1021 await_actor(&handle);
1022 let after = handle.render_state();
1023 assert!(matches!(
1024 after.active_document.load().modal,
1025 lattice_grammar::ModalState::Insert
1026 ));
1027 }
1028
1029 /// Slice 3c.final.E: `apply_blocking` dispatches the action,
1030 /// blocks until the actor processes it, then returns. The
1031 /// caller's next render_state read sees the post-dispatch
1032 /// state.
1033 #[test]
1034 fn actor_apply_blocking_completes_synchronously() {
1035 let editor = Editor::default();
1036 let handle = spawn_editor_actor(editor);
1037 let before = handle.render_state();
1038 handle
1039 .apply_blocking(Action::None)
1040 .expect("apply blocking succeeds");
1041 let after = handle.render_state();
1042 assert!(
1043 !Arc::ptr_eq(&before, &after),
1044 "apply_blocking must publish a fresh RS before returning"
1045 );
1046 }
1047
1048 /// Slice 3c.final.E: `mutate_blocking` runs the closure and
1049 /// fires `publish_render_state`. The mutation is visible
1050 /// through the published RS after the call returns.
1051 #[test]
1052 fn actor_mutate_blocking_applies_closure_and_publishes() {
1053 use lattice_protocol::position::Position;
1054 let editor = Editor::default();
1055 let handle = spawn_editor_actor(editor);
1056 handle
1057 .mutate_blocking(Box::new(|e| {
1058 e.cursor = Position::new(9, 4);
1059 e.scroll = 7;
1060 }))
1061 .expect("mutate blocking succeeds");
1062 let rs = handle.render_state();
1063 assert_eq!(rs.active_document.load().cursor, Position::new(9, 4));
1064 assert_eq!(rs.active_document.load().scroll, 7);
1065 }
1066
1067 /// Slice 3c.final.E: `mutate_async` is fire-and-forget; a
1068 /// subsequent `apply_blocking` barrier ensures the mutation
1069 /// has landed before the assertion.
1070 #[test]
1071 fn actor_mutate_async_applies_when_drained() {
1072 use lattice_protocol::position::Position;
1073 let editor = Editor::default();
1074 let handle = spawn_editor_actor(editor);
1075 handle
1076 .mutate_async(Box::new(|e| e.cursor = Position::new(2, 1)))
1077 .expect("mutate async succeeds");
1078 // Barrier: serial command processing means this returns
1079 // only after the prior Mutate runs.
1080 handle
1081 .apply_blocking(Action::None)
1082 .expect("apply blocking barrier");
1083 assert_eq!(
1084 handle.render_state().active_document.load().cursor,
1085 Position::new(2, 1)
1086 );
1087 }
1088
1089 #[test]
1090 fn actor_set_viewport_height_clamps_and_publishes() {
1091 let editor = Editor::default();
1092 let handle = spawn_editor_actor(editor);
1093 // 0 must clamp to 1 -- mirror App-side wrapper.
1094 handle.set_viewport_height(0).expect("send vh=0");
1095 await_actor(&handle);
1096 assert_eq!(
1097 handle.render_state().active_document.load().viewport_height,
1098 1
1099 );
1100
1101 handle.set_viewport_height(24).expect("send vh=24");
1102 await_actor(&handle);
1103 assert_eq!(
1104 handle.render_state().active_document.load().viewport_height,
1105 24
1106 );
1107 }
1108}