335 lines
13 KiB
Rust
335 lines
13 KiB
Rust
//! 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",
|
||
))
|
||
})
|
||
}
|
||
}
|