pmacs/pmacs-gpu/src/attach.rs

255 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<InstanceMessage>),
/// 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<AppEvent>,
) -> Result<AttachClient, AttachClientError> {
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::<InstanceMessage>(&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<Mutex<...>>` 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<Mutex<UnixStream>>,
/// 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,
}),
)
}
}