pmacs/pmacs-gpu/src/attach.rs

335 lines
13 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::mpsc;
use std::thread;
use pmacs_protocol::{
AttachRequest, BufferId, ByteRange, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId,
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, PointerKind,
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; a
/// writer thread owns the write half. The returned [`AttachClient`]
/// queues outbound `FrontendEvent`s so the winit UI thread never blocks
/// on daemon socket backpressure.
///
/// **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 + writer thread.
// 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;
let (writer_tx, writer_rx) = mpsc::channel::<FrontendEvent>();
// 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");
// Writer thread. Socket writes can block when the daemon falls
// behind; doing them here keeps keyboard input, redraws, and
// message application off that backpressure path.
thread::Builder::new()
.name("pmacs-gpu attach writer".into())
.spawn(move || {
let mut write_stream = write_stream;
while let Ok(event) = writer_rx.recv() {
if let Err(e) = write_message(&mut write_stream, &event) {
eprintln!("pmacs-gpu: attach writer stopped: {e}");
return;
}
}
})
.expect("spawn attach writer thread");
Ok(AttachClient {
writer_tx,
frontend_id: hello.assigned_frontend_id,
server_protocol_version: hello.protocol_version,
})
}
/// Handle the main loop keeps after `connect` returns. It queues
/// `FrontendEvent`s for the attach writer thread.
pub struct AttachClient {
writer_tx: mpsc::Sender<FrontendEvent>,
/// 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,
/// The daemon's `Hello.protocol_version`. Wire variants newer
/// than the daemon (e.g. `Pointer`, v5) must be gated on this —
/// an older daemon hard-errors decoding an unknown variant.
server_protocol_version: u32,
}
impl AttachClient {
/// Frontend id assigned by the daemon in the initial `Hello`.
pub fn frontend_id(&self) -> FrontendId {
self.frontend_id
}
/// 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> {
self.send_event(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> {
self.send_event(FrontendEvent::Key(KeyEvent {
frontend_id: self.frontend_id,
key,
mods,
timestamp_ns: 0,
}))
}
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
/// hit-tested gesture in source bytes. Callers gate on
/// [`Self::server_protocol_version`] `>= 5`.
pub fn send_pointer(
&self,
buffer_id: BufferId,
byte: u64,
kind: PointerKind,
mods: Modifiers,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::Pointer {
frontend_id: self.frontend_id,
buffer_id,
byte,
kind,
mods,
})
}
/// Send a `FrontendEvent::Paste` (Q#CM6) carrying OS-clipboard
/// bytes read locally via `arboard` on Ctrl-V. The daemon inserts it
/// at the cursor (replacing any region) and refreshes its clipboard
/// slot, exactly as it handles the TUI's bracketed paste.
pub fn send_paste(&self, data: Vec<u8>) -> Result<(), TransportError> {
self.send_event(FrontendEvent::Paste {
frontend_id: self.frontend_id,
data,
})
}
/// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu
/// navigation hit-tested locally against the popup we drew. `index`
/// is the row the pointer is over (`None` = off the menu); `invoke`
/// marks a click (invoke the row, or dismiss when `index` is `None`).
pub fn send_menu_pointer(
&self,
index: Option<u32>,
invoke: bool,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::MenuPointer {
frontend_id: self.frontend_id,
index,
invoke,
})
}
/// The daemon's negotiated wire version from `Hello`.
pub fn server_protocol_version(&self) -> u32 {
self.server_protocol_version
}
/// Send a locally-authored CRDT operation to the daemon. The GPU
/// uses this for idle plain-text insertion after applying the same
/// op to its local Loro replica, avoiding a Key round trip on the
/// hot typing path.
pub fn send_crdt_op(&self, buffer_id: BufferId, op: CrdtOp) -> Result<(), TransportError> {
self.send_event(FrontendEvent::CrdtOp {
frontend_id: self.frontend_id,
buffer_id,
op,
})
}
fn send_event(&self, event: FrontendEvent) -> Result<(), TransportError> {
self.writer_tx.send(event).map_err(|_| {
TransportError::Io(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"attach writer thread stopped",
))
})
}
}