//! Attach-mode client: connect to a running pmacs daemon over a Unix //! socket, negotiate `semantic_render + crdt_replica`, then pump //! `InstanceMessage` frames onto the winit event loop. //! //! Session 3+ of the pmacs-gpu arc — see `docs/pmacs-gpu-design.md`. //! Scope: handshake + decode the message stream + send a few //! `FrontendEvent`s back (currently just `Viewport`; session 5+ adds //! cursor/edit/focus). Importing the CRDT snapshot, applying live //! ops, and reconstructing the rope happen on the main thread, where //! the `LoroDoc` lives (it isn't trivially `Send`; cross-thread //! shipping is the *decoded* `InstanceMessage`, not the doc state). //! //! The reader thread blocks on a single `read_message` per iteration; //! every received message becomes an [`AttachEvent`] forwarded //! through [`winit::event_loop::EventLoopProxy::send_event`], which //! wakes the main loop so the frame logic can apply the message and //! redraw. use std::os::unix::net::UnixStream; use std::path::Path; use std::sync::{Arc, Mutex}; use std::thread; use pmacs_protocol::{ AttachRequest, BufferId, ByteRange, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message, write_message, }; use winit::event_loop::EventLoopProxy; use crate::AppEvent; /// Errors the attach client surfaces. Kept narrow on purpose: the /// hello-world fallback is the right recovery for any of these in /// session 3, so the caller's only job is to log + drop back to the /// inert renderer. #[derive(Debug)] pub enum AttachClientError { /// Couldn't open the Unix socket. Connect(std::io::Error), /// Transport framing failed during the handshake. Handshake(TransportError), /// Server's `protocol_version` is outside `SUPPORTED_PROTOCOL_VERSIONS`. VersionMismatch { server: u32, client: u32 }, } impl std::fmt::Display for AttachClientError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Connect(e) => write!(f, "connect to daemon socket failed: {e}"), Self::Handshake(e) => write!(f, "attach handshake failed: {e}"), Self::VersionMismatch { server, client } => write!( f, "daemon protocol version {server} not in client's supported set (this client = \ {client}, supports {SUPPORTED_PROTOCOL_VERSIONS:?})" ), } } } impl std::error::Error for AttachClientError {} /// One decoded event forwarded from the reader thread to the main /// loop. `Message` carries the entire `InstanceMessage`; `Disconnected` /// fires once when the reader thread exits (clean EOF or transport /// error — both look identical from the main thread's perspective). #[derive(Debug)] pub enum AttachEvent { /// A decoded message frame from the daemon. Message(Box), /// The reader thread exited. Includes the disconnect reason for /// logging on the main thread. Disconnected(String), } /// Connect, handshake, and spawn the reader thread. /// /// Returns once the handshake has completed and the reader thread is /// running. The reader thread owns the read half of the stream; the /// returned [`AttachClient`] retains the write half so the main loop /// can eventually emit `FrontendEvent`s back to the daemon (session 4 /// will need this — selection / viewport / edits travel that way). /// /// **Initial window size note** — `AttachRequest::initial_size` is /// nominally a `CellSize` (rows × cols) anchored to the TUI. The /// `pmacs-gpu` window isn't a cell grid; we send a placeholder of the /// approximate cell count for the initial 800×200 window so the /// daemon's initial render makes plausible space for content. This /// is a small finding for session 3's audit (the wire-shape detail /// "`AttachRequest`'s `CellSize` assumes a grid frontend"); resolution /// classified under rule (iii) as deferred — a structural answer /// belongs with Q#2's minimap variant or its own protocol thread, /// not session 3's attach loop. pub fn connect( socket_path: &Path, proxy: EventLoopProxy, ) -> Result { let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?; // Hello round-trip. let mut handshake_stream = stream.try_clone().map_err(AttachClientError::Connect)?; let hello: Hello = read_message(&mut handshake_stream).map_err(AttachClientError::Handshake)?; if !is_supported_protocol_version(hello.protocol_version) { return Err(AttachClientError::VersionMismatch { server: hello.protocol_version, client: PROTOCOL_VERSION, }); } eprintln!( "pmacs-gpu: attached to daemon (protocol v{}, instance pmacs {})", hello.protocol_version, hello.instance_identity.pmacs_version ); // AttachRequest — declare the capabilities a semantic frontend // needs. `multi_frontend` is included because the existing daemon // gates `crdt_replica` behind it (M10.x dependency). // // **Daemon requirement**: the daemon must be built with the // `crdt` feature (`cargo run --features crdt --bin pmacs -- // --daemon ...`). Without it the daemon's // `InstanceCapabilities::default` returns `crdt_replica: false`, // negotiation succeeds but no `BufferSnapshot` ever arrives, and // the `pmacs-gpu` window sits on `(connecting...)` forever. This // surfaced as a session-3 finding when manually validating the // attach loop; classified as small under rule (iii) — recorded // here so the next person attaching against a non-crdt daemon // recognizes the symptom immediately. let req = AttachRequest { protocol_version: hello.protocol_version, frontend_capabilities: FrontendCapabilities { synchronized_output: false, unicode_smp: true, true_color: true, mouse: false, bracketed_paste: false, terminal_kind: Some("pmacs-gpu".to_owned()), multi_frontend: true, crdt_replica: true, semantic_render: true, }, // Placeholder — see the doc comment above. Cell-shaped initial // size is awkward for a pixel frontend; for session 3 we send // approximate dimensions so the daemon's initial-render // ranging is plausible. initial_size: pmacs_protocol::CellSize::new(24, 80), }; write_message(&mut handshake_stream, &req).map_err(AttachClientError::Handshake)?; // Split read/write halves for the reader thread + main-thread // write path. UnixStream clones share the underlying FD with // independent buffer state — safe to read on one clone while the // other writes (the FD is full-duplex). let mut read_stream = stream.try_clone().map_err(AttachClientError::Connect)?; let write_stream = stream; // Reader thread. Each iteration: block on read_message, decode, // forward via the event-loop proxy. Exits cleanly on EOF / any // transport error; the main thread receives a single Disconnected // event and drops back to the inert renderer. thread::Builder::new() .name("pmacs-gpu attach reader".into()) .spawn(move || { loop { match read_message::(&mut read_stream) { Ok(msg) => { if proxy .send_event(AppEvent::Attach(AttachEvent::Message(Box::new(msg)))) .is_err() { // Main loop torn down — quietly exit. return; } } Err(e) => { let _ = proxy .send_event(AppEvent::Attach(AttachEvent::Disconnected(e.to_string()))); return; } } } }) .expect("spawn attach reader thread"); Ok(AttachClient { write_stream: Arc::new(Mutex::new(write_stream)), frontend_id: hello.assigned_frontend_id, }) } /// Handle the main loop keeps after `connect` returns. Session 4 /// wires the write side for `FrontendEvent::Viewport` emission; /// future sessions will add cursor / edit / focus / detach. /// /// The write half is wrapped in `Arc>` because, while /// pmacs-gpu's event loop is single-threaded, a future multi-window /// shape might emit events from several places concurrently. The /// lock cost is one mutex per emitted frame — negligible. pub struct AttachClient { write_stream: Arc>, /// Assigned by the daemon in the `Hello` response. Every /// `FrontendEvent` carries this so the daemon can route input back /// to the per-session `SemanticRenderState`. frontend_id: FrontendId, } impl AttachClient { /// Send a `FrontendEvent::Viewport` to the daemon. The daemon's /// `SemanticRenderState::set_viewport` feeds the spans producer; /// without this call the daemon ships no `StyleSpans` for the /// buffer (no declared viewport ⇒ no scoped styling). pub fn send_viewport( &self, buffer_id: BufferId, visible: ByteRange, generation: u64, ) -> Result<(), TransportError> { let mut stream = self .write_stream .lock() .expect("attach write-stream mutex poisoned"); write_message( &mut *stream, &FrontendEvent::Viewport { frontend_id: self.frontend_id, buffer_id, visible, generation, }, ) } /// Send a `FrontendEvent::Key` to the daemon (session B1). The /// daemon routes it through `dispatch_key` — the same keymap + /// command + Lua stack the TUI drives — so cursor motion and (in /// later sessions) edits are produced entirely instance-side; the /// resulting `CursorByte` / `CrdtOp` come back over the attach /// stream. `timestamp_ns` is 0 (no capture clock plumbed yet; the /// daemon does not depend on it). pub fn send_key(&self, key: Key, mods: Modifiers) -> Result<(), TransportError> { let mut stream = self .write_stream .lock() .expect("attach write-stream mutex poisoned"); write_message( &mut *stream, &FrontendEvent::Key(KeyEvent { frontend_id: self.frontend_id, key, mods, timestamp_ns: 0, }), ) } }