//! 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::collections::VecDeque; use std::fs; use std::io; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::FileTypeExt; use std::os::unix::net::UnixStream; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant}; use pmacs_protocol::{ AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message, requested_protocol_version, write_message, }; use winit::event_loop::EventLoopProxy; use crate::AppEvent; /// Private root-broker target operands, kept as exact Unix paths until the /// protocol-v20 bootstrap frame is serialized. #[derive(Clone, Debug, Eq, PartialEq)] pub struct InitialTargetPaths { /// Absolute launcher working directory. pub cwd: PathBuf, /// Launcher-expanded target path. pub path: PathBuf, } /// 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 }, /// The daemon's `Hello.instance_capabilities` doesn't advertise a /// capability a semantic frontend needs (audit F-003). Most commonly /// the daemon was built without `--features crdt`, so `crdt_replica` /// / `semantic_render` are `false`: negotiation would "succeed" but no /// `BufferSnapshot` ever arrives and the window sits on /// `(connecting...)` forever. We reject up front instead. CapabilityMismatch { missing: Vec<&'static str> }, /// The requested target requires the protocol-v20 bootstrap envelope. InitialTargetUnsupported { server: u32 }, /// The daemon rejected the target before a window was created. InitialTargetFailed { path: PathBuf, message: String }, /// The daemon violated the target bootstrap ordering contract. InitialTargetProtocol(String), } impl AttachClientError { /// A short, actionable line to render *in the window* (the user may /// never see stderr). The `Display` impl carries the full detail for /// logs; this is the one-liner for the failed-attach placeholder. pub fn window_status(&self) -> String { match self { Self::CapabilityMismatch { .. } => { "daemon lacks CRDT support — restart it built with `--features crdt`".to_owned() } _ => "(attach failed; see stderr)".to_owned(), } } } 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:?})" ), Self::CapabilityMismatch { missing } => write!( f, "daemon does not advertise required capabilities {missing:?} — start the daemon \ built with the `crdt` feature (it advertises `crdt_replica` / `semantic_render` \ only on CRDT builds; without them no BufferSnapshot is ever sent)" ), Self::InitialTargetUnsupported { server } => write!( f, "initial target requires daemon protocol v20, but the live daemon speaks v{server}" ), Self::InitialTargetFailed { path, message } => { write!( f, "could not open initial target {}: {message}", path.display() ) } Self::InitialTargetProtocol(message) => { write!(f, "invalid initial-target bootstrap: {message}") } } } } impl std::error::Error for AttachClientError {} /// Failure while connecting the managed GPU path. #[derive(Debug)] pub enum ManagedAttachError { /// The daemon connection reached the normal attach client and failed. Attach(AttachClientError), /// A refused socket path exists but is not a Unix socket. NonSocketPath(PathBuf), /// Inspecting a refused socket path failed. InspectSocket { /// Path whose entry type could not be inspected. path: PathBuf, /// Filesystem error from `metadata`. source: io::Error, }, /// The requested daemon executable could not be started. SpawnDaemon { /// Executable supplied by the root broker. executable: PathBuf, /// Process-spawn failure. source: io::Error, }, /// No attachable daemon appeared before the bounded deadline. StartupTimeout { /// Socket path that remained unreachable. socket: PathBuf, /// Most recent connect error. connect: io::Error, /// Observed daemon process outcome, when it exited early. daemon_status: Option, /// Startup deadline used for this attempt. timeout: Duration, }, } impl std::fmt::Display for ManagedAttachError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Attach(error) => error.fmt(f), Self::NonSocketPath(path) => write!( f, "refusing to start a daemon: socket path {} exists and is not a Unix socket", path.display() ), Self::InspectSocket { path, source } => write!( f, "cannot inspect refused socket path {}: {source}", path.display() ), Self::SpawnDaemon { executable, source } => write!( f, "could not start daemon executable {}: {source}", executable.display() ), Self::StartupTimeout { socket, connect, daemon_status, timeout, } => { write!( f, "daemon did not become attachable on {} within {timeout:?}: {connect}", socket.display() )?; if let Some(status) = daemon_status { write!(f, " (spawned daemon {status})")?; } Ok(()) } } } } impl std::error::Error for ManagedAttachError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Attach(error) => Some(error), Self::InspectSocket { source, .. } | Self::SpawnDaemon { source, .. } | Self::StartupTimeout { connect: source, .. } => Some(source), Self::NonSocketPath(_) => None, } } } impl From for ManagedAttachError { fn from(error: AttachClientError) -> Self { Self::Attach(error) } } #[derive(Debug, Default)] struct DaemonProcessState { reaped: bool, wait_result: Option, } /// Observable process facts for a daemon started by managed attach. #[derive(Clone, Debug)] pub struct ManagedDaemonFacts { spawned: bool, pid: Option, state: Arc>, } impl ManagedDaemonFacts { fn existing() -> Self { Self { spawned: false, pid: None, state: Arc::new(Mutex::new(DaemonProcessState::default())), } } fn spawned(pid: u32) -> Self { Self { spawned: true, pid: Some(pid), state: Arc::new(Mutex::new(DaemonProcessState::default())), } } fn record_wait(&self, result: String) { let mut state = self.state.lock().expect("managed daemon state lock"); state.reaped = true; state.wait_result = Some(result); } /// Whether this invocation started a daemon process. pub fn spawned_daemon(&self) -> bool { self.spawned } /// Process ID of the daemon this invocation started. pub fn daemon_pid(&self) -> Option { self.pid } /// Whether the started child has been observed with `wait`. pub fn daemon_reaped(&self) -> bool { self.state.lock().expect("managed daemon state lock").reaped } /// Recorded `wait` result for a completed child. pub fn daemon_wait_result(&self) -> Option { self.state .lock() .expect("managed daemon state lock") .wait_result .clone() } } /// A successful attach plus lifecycle facts for any daemon it started. pub struct ManagedAttach { /// Connected semantic attach client. pub client: AttachClient, /// Shared facts updated by the daemon child reaper. pub daemon: ManagedDaemonFacts, } /// The capabilities a semantic `pmacs-gpu` frontend requires the daemon to /// advertise in `Hello.instance_capabilities`, and which of them this /// daemon is missing (audit F-003). Empty ⇒ the attach can proceed. fn missing_capabilities(caps: &pmacs_protocol::InstanceCapabilities) -> Vec<&'static str> { let mut missing = Vec::new(); if !caps.multi_frontend { missing.push("multi_frontend"); } if !caps.crdt_replica { missing.push("crdt_replica"); } if !caps.semantic_render { missing.push("semantic_render"); } missing } /// 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), } /// Hard cap on queued **lossless** outbound events (audit F-008). A /// daemon so stalled that this many keys / CRDT ops / pastes pile up is /// effectively dead; past it we fail fast (see [`Outbox::enqueue`]) rather /// than grow memory without bound. Coalesceable events (viewport / drag) /// never count against it — they collapse into the queue tail. 8192 is far /// above any human-paced burst, so it only trips on a genuine stall. const OUTBOX_MAX: usize = 8192; /// The kind tag of the two high-frequency events coalesced by /// tail-replacement (F-008): scroll `Viewport`s and `Pointer` **drags**. /// `None` marks a *lossless* event — `Key`, `CrdtOp`, `Paste`, /// `MenuPointer`, and the discrete pointer clicks (`Down` / `Up` / /// `DoubleDown` / `TripleDown` / `Context`) — which are ordered and never /// dropped. Coalescing a drag is safe only against a *same-kind tail*, so /// a `Down, Drag, Drag, Up` gesture keeps its `Down`/`Up` ordering. fn coalesce_kind(event: &FrontendEvent) -> Option { match event { FrontendEvent::Viewport { .. } => Some(0), FrontendEvent::Pointer { kind: PointerKind::Drag, .. } => Some(1), // Vterm Stage 3 — terminal pointer MOVE and DRAG are the // high-frequency terminal gestures and coalesce like their // document twin. Press, release, and wheel stay lossless: // collapsing a wheel run would silently lose scroll distance, // and collapsing a press/release would break selection. FrontendEvent::TerminalPointer { kind: MouseKind::Move, .. } => Some(2), FrontendEvent::TerminalPointer { kind: MouseKind::Drag(_), .. } => Some(3), _ => None, } } /// Bounded, coalescing outbound queue drained by the writer thread /// (audit F-008). Replaces the previous unbounded `mpsc::channel`, which /// let a stalled daemon grow memory without limit and replay a backlog of /// stale viewport/pointer traffic on recovery. struct Outbox { queue: VecDeque, /// Set once the writer gives up (socket error) or a lossless overflow /// trips fail-fast. Further [`Outbox::enqueue`] calls return `false`. closed: bool, } impl Outbox { fn new() -> Self { Self { queue: VecDeque::new(), closed: false, } } /// Apply the F-008 enqueue policy. Returns `false` when the event /// can't be accepted — the outbox is `closed`, or a lossless append /// would exceed [`OUTBOX_MAX`] (which also *sets* `closed`: a clean /// disconnect → reconnect → fresh snapshot is more correct than /// silently dropping a `CrdtOp` and desyncing the optimistic replica). /// /// A coalesceable event (viewport / drag) whose kind matches the queue /// **tail** *replaces* it — collapsing a scroll or drag flood to O(1) /// without reordering across an intervening click or key. fn enqueue(&mut self, event: FrontendEvent) -> bool { if self.closed { return false; } if let Some(kind) = coalesce_kind(&event) && self.queue.back().and_then(coalesce_kind) == Some(kind) { *self .queue .back_mut() .expect("tail present when back() matched") = event; return true; } if self.queue.len() >= OUTBOX_MAX { self.closed = true; return false; } self.queue.push_back(event); true } } /// 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. fn read_initial_target_bootstrap( stream: &mut UnixStream, display_path: PathBuf, ) -> Result { let mut snapshot = None; loop { let message: InstanceMessage = read_message(stream).map_err(AttachClientError::Handshake)?; match message { candidate @ InstanceMessage::BufferSnapshot { .. } if snapshot.is_none() => { snapshot = Some(candidate); } InstanceMessage::BufferSnapshot { .. } => { return Err(AttachClientError::InitialTargetProtocol( "received more than one target snapshot".to_owned(), )); } InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }) => { let Some(snapshot) = snapshot else { return Err(AttachClientError::InitialTargetProtocol( "Opened arrived before BufferSnapshot".to_owned(), )); }; let InstanceMessage::BufferSnapshot { buffer_id: snapshot_buffer, .. } = snapshot else { unreachable!("bootstrap snapshot variant checked above"); }; if snapshot_buffer != buffer_id { return Err(AttachClientError::InitialTargetProtocol(format!( "Opened named {buffer_id:?}, snapshot named {snapshot_buffer:?}" ))); } return Ok(snapshot); } InstanceMessage::InitialTargetResult(InitialTargetResult::Failed { message }) => { return Err(AttachClientError::InitialTargetFailed { path: display_path, message, }); } InstanceMessage::Goodbye(reason) => { return Err(AttachClientError::InitialTargetProtocol(format!( "daemon closed bootstrap: {reason:?}" ))); } other => { return Err(AttachClientError::InitialTargetProtocol(format!( "unexpected {} before target readiness", crate::instance_message_label(&other) ))); } } } } pub fn connect( socket_path: &Path, proxy: EventLoopProxy, ) -> Result { connect_with_sink(socket_path, move |event| { proxy.send_event(AppEvent::Attach(event)).is_ok() }) } /// [`connect`], with the decoded-message destination left to the caller. /// /// The winit path forwards to the event loop; the headless probe used by /// the Stage 3 acceptance forwards to a channel. Both drive the SAME /// handshake, capability gate, reader, writer, and outbox — a probe that /// reimplemented any of that would prove nothing about the real client. /// /// `sink` returns `false` when its destination is gone, which stops the /// reader thread. pub fn connect_with_sink( socket_path: &Path, sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?; connect_stream_with_sink(stream, None, sink) } #[allow( clippy::too_many_lines, reason = "the synchronous handshake and thread startup remain one ordered transport transaction" )] fn connect_stream_with_sink( stream: UnixStream, initial_target: Option, sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { // 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 ); // Capability gate (audit F-003). The daemon advertises what it can do // in `Hello.instance_capabilities`; a semantic frontend needs // `multi_frontend` + `crdt_replica` + `semantic_render`. A daemon // built without `--features crdt` advertises those as `false`: the // handshake would otherwise "succeed", but no `BufferSnapshot` ever // arrives and the window sits on `(connecting...)` forever. Reject up // front with an actionable error rather than hanging silently. No // `AttachResponse` round-trip is needed — the daemon already told us // in `Hello`; we just have to check it against what we require. let missing = missing_capabilities(&hello.instance_capabilities); if !missing.is_empty() { return Err(AttachClientError::CapabilityMismatch { missing }); } if initial_target.is_some() && hello.protocol_version < 20 { return Err(AttachClientError::InitialTargetUnsupported { server: hello.protocol_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). // Bottom-panel Stage 2B-3: the `Hello` version is a compatibility // BASELINE, and this counter-offer is what activates anything above // it. The handshake is server-first, so the daemon cannot advertise a // version a shipped frontend might reject; the frontend is the only // party that can safely name a higher one, because by this point it // has already accepted the baseline. `requested_protocol_version` // echoes anything older than the current baseline verbatim. let session_protocol_version = requested_protocol_version(hello.protocol_version); let req = AttachRequest { protocol_version: session_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)?; let target_display_path = initial_target.as_ref().map(|target| target.path.clone()); if hello.protocol_version >= 20 { let bootstrap = SessionBootstrapRequest { initial_target: initial_target.map(|target| InitialTarget { cwd: target.cwd.as_os_str().as_bytes().to_vec(), path: target.path.as_os_str().as_bytes().to_vec(), }), }; write_message(&mut handshake_stream, &bootstrap).map_err(AttachClientError::Handshake)?; } let initial_message = match target_display_path { Some(path) => Some(read_initial_target_bootstrap(&mut handshake_stream, path)?), None => None, }; // 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)?; // A third clone kept solely to *shut the socket down* (F-008). When the // outbox closes — a lossless overflow, or a writer write error — we // `shutdown(Both)` so the reader (blocked in `read_message` on its own // clone) wakes with EOF and fires `Disconnected`. That routes the // stall into the existing visible teardown instead of leaving the GPU // showing locally-applied edits the daemon never received. Clones share // the socket's file description, so a shutdown on any of them affects // all — and it half-closes toward the daemon so it sees the departure. let shutdown_handle = stream.try_clone().map_err(AttachClientError::Connect)?; let write_stream = stream; // Bounded, coalescing outbound queue (F-008) shared with the writer // thread; the `Condvar` wakes the writer when the UI thread enqueues. let outbox = Arc::new((Mutex::new(Outbox::new()), Condvar::new())); // 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 !sink(AttachEvent::Message(Box::new(msg))) { // Destination torn down — quietly exit. return; } } Err(e) => { sink(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. It waits on the // outbox condvar, takes the whole pending batch, and *releases the // lock before* the blocking writes so the UI thread can keep // enqueueing (and coalescing) meanwhile. let writer_outbox = Arc::clone(&outbox); thread::Builder::new() .name("pmacs-gpu attach writer".into()) .spawn(move || { let mut write_stream = write_stream; let (lock, cvar) = &*writer_outbox; loop { let batch = { let mut ob = lock.lock().expect("outbox lock"); while ob.queue.is_empty() && !ob.closed { ob = cvar.wait(ob).expect("outbox condvar wait"); } if ob.queue.is_empty() && ob.closed { return; } std::mem::take(&mut ob.queue) }; for event in batch { if let Err(e) = write_message(&mut write_stream, &event) { eprintln!("pmacs-gpu: attach writer stopped: {e}"); lock.lock().expect("outbox lock").closed = true; // Wake the reader (and signal the daemon) so the // session tears down visibly (F-008) rather than // leaving the reader blocked on a dead socket. let _ = write_stream.shutdown(std::net::Shutdown::Both); return; } } } }) .expect("spawn attach writer thread"); Ok(AttachClient { outbox, shutdown_handle, frontend_id: hello.assigned_frontend_id, session_protocol_version, baseline_protocol_version: hello.protocol_version, initial_message, }) } const MANAGED_STARTUP_TIMEOUT: Duration = Duration::from_secs(5); const MANAGED_RETRY_INTERVAL: Duration = Duration::from_millis(50); /// Managed attach carrying an optional pre-window initial target. pub fn connect_managed_with_target( socket_path: &Path, daemon_executable: &Path, initial_target: Option, proxy: EventLoopProxy, ) -> Result { connect_managed_with_target_and_sink( socket_path, daemon_executable, initial_target, move |event| proxy.send_event(AppEvent::Attach(event)).is_ok(), ) } /// Managed attach with both a target and caller-provided event sink. pub fn connect_managed_with_target_and_sink( socket_path: &Path, daemon_executable: &Path, initial_target: Option, sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { connect_managed_inner( socket_path, daemon_executable, |path| UnixStream::connect(path), spawn_daemon, MANAGED_STARTUP_TIMEOUT, MANAGED_RETRY_INTERVAL, initial_target, sink, ) } fn spawn_daemon(daemon_executable: &Path, socket_path: &Path) -> io::Result { let mut command = Command::new(daemon_executable); command .arg("--daemon") .arg("--socket") .arg(socket_path) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); command.process_group(0); command.spawn() } fn initial_startup_authorized( socket_path: &Path, error: &io::Error, ) -> Result { match error.kind() { io::ErrorKind::NotFound => Ok(true), io::ErrorKind::ConnectionRefused => match fs::metadata(socket_path) { Ok(metadata) if metadata.file_type().is_socket() => Ok(true), Ok(_) => Err(ManagedAttachError::NonSocketPath(socket_path.to_owned())), Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(true), Err(source) => Err(ManagedAttachError::InspectSocket { path: socket_path.to_owned(), source, }), }, _ => Ok(false), } } fn post_spawn_retryable(socket_path: &Path, error: &io::Error) -> Result { match error.kind() { io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock => Ok(true), _ => initial_startup_authorized(socket_path, error), } } fn start_daemon_reaper(mut child: Child, facts: ManagedDaemonFacts) { thread::Builder::new() .name("pmacs-gpu daemon reaper".into()) .spawn(move || { let result = match child.wait() { Ok(status) => status.to_string(), Err(error) => format!("wait failed: {error}"), }; facts.record_wait(result); }) .expect("spawn managed daemon reaper thread"); } #[allow( clippy::too_many_arguments, reason = "connector, spawner, timing, and sink stay injectable for deterministic lifecycle tests" )] fn connect_managed_inner( socket_path: &Path, daemon_executable: &Path, mut connector: C, spawner: S, timeout: Duration, retry_interval: Duration, initial_target: Option, sink: F, ) -> Result where C: FnMut(&Path) -> io::Result, S: FnOnce(&Path, &Path) -> io::Result, F: Fn(AttachEvent) -> bool + Send + 'static, { match connector(socket_path) { Ok(stream) => { let client = connect_stream_with_sink(stream, initial_target, sink)?; return Ok(ManagedAttach { client, daemon: ManagedDaemonFacts::existing(), }); } Err(error) => { if !initial_startup_authorized(socket_path, &error)? { return Err(AttachClientError::Connect(error).into()); } } } let child = spawner(daemon_executable, socket_path).map_err(|source| { ManagedAttachError::SpawnDaemon { executable: daemon_executable.to_owned(), source, } })?; let daemon = ManagedDaemonFacts::spawned(child.id()); start_daemon_reaper(child, daemon.clone()); let deadline = Instant::now() + timeout; loop { match connector(socket_path) { Ok(stream) => { let client = connect_stream_with_sink(stream, initial_target, sink)?; return Ok(ManagedAttach { client, daemon }); } Err(error) => { let retryable = post_spawn_retryable(socket_path, &error)?; if !retryable { return Err(AttachClientError::Connect(error).into()); } if Instant::now() >= deadline { let daemon_status = daemon.daemon_wait_result(); return Err(ManagedAttachError::StartupTimeout { socket: socket_path.to_owned(), connect: error, daemon_status, timeout, }); } thread::sleep( retry_interval.min(deadline.saturating_duration_since(Instant::now())), ); } } } } /// Handle the main loop keeps after `connect` returns. It queues /// `FrontendEvent`s for the attach writer thread. pub struct AttachClient { /// Bounded, coalescing outbound queue (F-008) drained by the writer /// thread. `send_event` locks it, applies the enqueue policy, and /// wakes the writer via the paired `Condvar`. outbox: Arc<(Mutex, Condvar)>, /// Socket clone used only to `shutdown(Both)` when the outbox closes /// (F-008), so the reader wakes and the session tears down visibly /// instead of diverging silently. See [`connect`]. shutdown_handle: 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, /// The version this session actually speaks: the frontend's /// `AttachRequest` counter-offer, which the daemon adopts. Wire /// variants newer than the session (e.g. `Pointer`, v5) must be gated /// on this — a daemon below the variant's floor hard-errors decoding /// an unknown discriminant. /// /// Bottom-panel Stage 2B-3: this is deliberately NOT /// `Hello.protocol_version` any more. The server-first `Hello` carries /// a compatibility *baseline* that no shipped frontend may be forced /// to reject, so it under-reports what the pair can speak; the offer /// this frontend made is the session's real ceiling. session_protocol_version: u32, /// The baseline the daemon advertised in its server-first `Hello`. /// /// Kept beside the negotiated version because they answer different /// questions, and because "the daemon still advertises 20 while this /// session runs 21" is precisely the compatibility property Stage /// 2B-3 has to be able to demonstrate. baseline_protocol_version: u32, /// Target snapshot retained across the pre-window readiness barrier. initial_message: Option, } impl AttachClient { /// Frontend id assigned by the daemon in the initial `Hello`. pub fn frontend_id(&self) -> FrontendId { self.frontend_id } /// Take the target snapshot that must be applied before first redraw. pub fn take_initial_message(&mut self) -> Option { self.initial_message.take() } /// 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::session_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) -> Result<(), TransportError> { self.send_event(FrontendEvent::Paste { frontend_id: self.frontend_id, data, }) } /// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the /// terminal-cell geometry this frontend has on screen. Callers gate /// on [`Self::session_protocol_version`] `>= 19`. /// /// Cells, never pixels — the frontend divides its own drawable /// rectangle by its own metrics, keeping the no-pixels contract the /// document `Viewport` established. pub fn send_terminal_resize( &self, buffer_id: BufferId, size: CellSize, ) -> Result<(), TransportError> { self.send_event(FrontendEvent::TerminalResize { frontend_id: self.frontend_id, buffer_id, size, }) } /// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a /// gesture hit-tested locally to a terminal cell. Callers gate on /// [`Self::session_protocol_version`] `>= 19`. pub fn send_terminal_pointer( &self, buffer_id: BufferId, coord: CellCoord, kind: MouseKind, mods: Modifiers, ) -> Result<(), TransportError> { self.send_event(FrontendEvent::TerminalPointer { frontend_id: self.frontend_id, buffer_id, coord, kind, mods, }) } /// 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, invoke: bool, ) -> Result<(), TransportError> { self.send_event(FrontendEvent::MenuPointer { frontend_id: self.frontend_id, index, invoke, }) } /// The version this session negotiated — the frontend's /// `AttachRequest` offer, which the daemon adopts. /// /// Every "is this variant on the wire?" gate keys on this, never on /// [`Self::baseline_protocol_version`]. pub fn session_protocol_version(&self) -> u32 { self.session_protocol_version } /// The compatibility baseline the daemon advertised in `Hello`. /// /// Only the handshake itself needs this. It is *lower* than /// [`Self::session_protocol_version`] whenever an additive family has /// been activated by counter-offer, which is the normal case. pub fn baseline_protocol_version(&self) -> u32 { self.baseline_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> { let (lock, cvar) = &*self.outbox; let mut ob = lock.lock().expect("outbox lock"); if ob.enqueue(event) { // Drop the guard before notifying so the woken writer doesn't // immediately re-block on a still-held lock. drop(ob); cvar.notify_one(); Ok(()) } else { // Refusing a lossless event prevents replica divergence against // a stalled daemon (or an earlier writer failure). // Tear the session down actively (F-008): shut the socket so // the reader wakes with EOF and fires `Disconnected`, giving a // visible "(daemon disconnected)" instead of a GPU that keeps // showing optimistic edits the daemon never received. Idempotent // — a second shutdown just returns `NotConnected`, ignored. drop(ob); let _ = self.shutdown_handle.shutdown(std::net::Shutdown::Both); Err(TransportError::Io(std::io::Error::new( std::io::ErrorKind::BrokenPipe, "attach writer stopped or outbound queue overflowed", ))) } } } #[cfg(test)] mod tests { use super::*; use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; use pmacs_protocol::{InstanceCapabilities, InstanceIdentity, MouseButton}; fn caps( multi_frontend: bool, crdt_replica: bool, semantic_render: bool, ) -> InstanceCapabilities { InstanceCapabilities { multi_frontend, crdt_replica, semantic_render, } } fn hello(protocol_version: u32) -> Hello { Hello { protocol_version, assigned_frontend_id: FrontendId(7), instance_identity: InstanceIdentity { pmacs_version: "test".to_owned(), build_hash: None, instance_name: None, uptime_secs: 0, working_directory: "/tmp".to_owned(), }, instance_capabilities: caps(true, true, true), } } #[test] fn initial_target_bootstrap_is_synchronous_byte_exact_and_snapshot_first() { let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair"); let raw_path = OsString::from_vec(vec![b'n', b'o', b't', b'e', 0xff]); let paths = InitialTargetPaths { cwd: PathBuf::from("/launcher"), path: PathBuf::from(&raw_path), }; let expected = paths.clone(); let server = thread::spawn(move || { write_message(&mut server_stream, &hello(PROTOCOL_VERSION)).expect("write Hello"); let _: AttachRequest = read_message(&mut server_stream).expect("read AttachRequest"); let bootstrap: SessionBootstrapRequest = read_message(&mut server_stream).expect("read bootstrap"); let target = bootstrap.initial_target.expect("initial target"); assert_eq!(target.cwd, expected.cwd.as_os_str().as_bytes()); assert_eq!(target.path, expected.path.as_os_str().as_bytes()); let buffer_id = BufferId::from_raw(41); write_message( &mut server_stream, &InstanceMessage::BufferSnapshot { buffer_id, crdt_snapshot: vec![1, 2, 3], }, ) .expect("write target snapshot"); write_message( &mut server_stream, &InstanceMessage::InitialTargetResult(InitialTargetResult::Opened { buffer_id }), ) .expect("write target result"); }); let mut client = connect_stream_with_sink(client_stream, Some(paths), |_| true) .expect("target bootstrap"); assert!(matches!( client.take_initial_message(), Some(InstanceMessage::BufferSnapshot { buffer_id, crdt_snapshot, }) if buffer_id == BufferId::from_raw(41) && crdt_snapshot == [1, 2, 3] )); assert!(client.take_initial_message().is_none()); server.join().expect("bootstrap server"); } #[test] fn initial_target_fails_before_attach_on_legacy_protocol() { let (client_stream, mut server_stream) = UnixStream::pair().expect("socketpair"); let server = thread::spawn(move || { write_message(&mut server_stream, &hello(19)).expect("write legacy Hello"); }); let Err(error) = connect_stream_with_sink( client_stream, Some(InitialTargetPaths { cwd: PathBuf::from("/launcher"), path: PathBuf::from("note"), }), |_| true, ) else { panic!("legacy target must fail"); }; assert!(matches!( error, AttachClientError::InitialTargetUnsupported { server: 19 } )); server.join().expect("legacy server"); } #[test] fn full_crdt_daemon_advertises_everything_required() { // A daemon built with `--features crdt` advertises all three — the // attach proceeds (F-003). assert!(missing_capabilities(&caps(true, true, true)).is_empty()); } #[test] fn non_crdt_daemon_is_rejected_with_the_missing_caps() { // The exact silent-hang case: no crdt build ⇒ crdt_replica / // semantic_render false. We name them so the error is actionable. let missing = missing_capabilities(&caps(true, false, false)); assert_eq!(missing, vec!["crdt_replica", "semantic_render"]); // A single-frontend daemon is also unusable for a GPU attach. assert_eq!( missing_capabilities(&caps(false, true, true)), vec!["multi_frontend"] ); } #[test] fn capability_mismatch_status_line_is_actionable() { // The in-window line (not stderr) must point at the fix. let err = AttachClientError::CapabilityMismatch { missing: vec!["crdt_replica"], }; let status = err.window_status(); assert!( status.contains("crdt"), "status should name the fix: {status}" ); // Other errors keep the generic placeholder. let other = AttachClientError::VersionMismatch { server: 99, client: 6, }; assert_eq!(other.window_status(), "(attach failed; see stderr)"); } // --- F-008: bounded, coalescing outbox -------------------------------- fn fe_viewport(generation: u64) -> FrontendEvent { FrontendEvent::Viewport { frontend_id: FrontendId::LOCAL, buffer_id: BufferId::from_raw(1), visible: ByteRange { start: 0, end: 10 }, generation, } } fn fe_pointer(kind: PointerKind, byte: u64) -> FrontendEvent { FrontendEvent::Pointer { frontend_id: FrontendId::LOCAL, buffer_id: BufferId::from_raw(1), byte, kind, mods: Modifiers::NONE, } } fn fe_key(c: char) -> FrontendEvent { FrontendEvent::Key(KeyEvent { frontend_id: FrontendId::LOCAL, key: Key::Char(c), mods: Modifiers::NONE, timestamp_ns: 0, }) } #[test] fn consecutive_viewports_coalesce_to_the_latest() { let mut ob = Outbox::new(); assert!(ob.enqueue(fe_viewport(1))); assert!(ob.enqueue(fe_viewport(2))); assert!(ob.enqueue(fe_viewport(3))); // A scroll flood collapses to one — the newest generation. assert_eq!(ob.queue.len(), 1); match &ob.queue[0] { FrontendEvent::Viewport { generation, .. } => assert_eq!(*generation, 3), other => panic!("expected a viewport, got {other:?}"), } } #[test] fn drags_coalesce_but_clicks_keep_their_order() { let mut ob = Outbox::new(); // A press, a run of motion, and a release. ob.enqueue(fe_pointer(PointerKind::Down, 0)); ob.enqueue(fe_pointer(PointerKind::Drag, 1)); ob.enqueue(fe_pointer(PointerKind::Drag, 2)); ob.enqueue(fe_pointer(PointerKind::Drag, 3)); ob.enqueue(fe_pointer(PointerKind::Up, 4)); // Down, one coalesced Drag (latest byte), Up — order preserved, the // gesture is intact; the drag run collapsed to O(1). assert_eq!(ob.queue.len(), 3); assert!(matches!( &ob.queue[0], FrontendEvent::Pointer { kind: PointerKind::Down, .. } )); assert!(matches!( &ob.queue[1], FrontendEvent::Pointer { kind: PointerKind::Drag, byte: 3, .. } )); assert!(matches!( &ob.queue[2], FrontendEvent::Pointer { kind: PointerKind::Up, .. } )); } fn fe_terminal_pointer(kind: MouseKind, row: u32, col: u32) -> FrontendEvent { FrontendEvent::TerminalPointer { frontend_id: FrontendId(1), buffer_id: BufferId::from_raw(1), coord: CellCoord::new(row, col), kind, mods: Modifiers::NONE, } } /// Acceptance 34: terminal move/drag runs coalesce to the latest /// cell, while press, release, and wheel stay lossless and ordered. #[test] fn terminal_motion_coalesces_but_presses_and_wheels_stay_lossless() { let mut ob = Outbox::new(); ob.enqueue(fe_terminal_pointer( MouseKind::Down(MouseButton::Left), 0, 0, )); ob.enqueue(fe_terminal_pointer( MouseKind::Drag(MouseButton::Left), 0, 1, )); ob.enqueue(fe_terminal_pointer( MouseKind::Drag(MouseButton::Left), 0, 2, )); ob.enqueue(fe_terminal_pointer( MouseKind::Drag(MouseButton::Left), 0, 3, )); ob.enqueue(fe_terminal_pointer(MouseKind::Up(MouseButton::Left), 0, 3)); assert_eq!(ob.queue.len(), 3, "the drag run collapsed to one"); assert!(matches!( &ob.queue[1], FrontendEvent::TerminalPointer { kind: MouseKind::Drag(MouseButton::Left), coord: CellCoord { row: 0, col: 3 }, .. } )); // Wheel ticks carry scroll DISTANCE; collapsing a run would // silently lose scrollback rows. let mut wheel = Outbox::new(); wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1)); wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1)); wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1)); assert_eq!(wheel.queue.len(), 3, "wheel ticks are lossless"); // Hover motion coalesces, but not across a different kind. let mut moves = Outbox::new(); moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 1)); moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 2)); assert_eq!(moves.queue.len(), 1); moves.enqueue(fe_terminal_pointer(MouseKind::ScrollDown, 2, 2)); moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 3)); assert_eq!(moves.queue.len(), 3); } #[test] fn coalescing_only_collapses_a_same_kind_tail() { let mut ob = Outbox::new(); // Keys between viewports break the run: nothing coalesces, order // and every lossless event are preserved. ob.enqueue(fe_key('a')); ob.enqueue(fe_viewport(1)); ob.enqueue(fe_key('b')); ob.enqueue(fe_viewport(2)); assert_eq!(ob.queue.len(), 4); // A drag does not fold into a Down tail (different kind). let mut ob2 = Outbox::new(); ob2.enqueue(fe_pointer(PointerKind::Down, 0)); ob2.enqueue(fe_pointer(PointerKind::Drag, 1)); assert_eq!(ob2.queue.len(), 2); } #[test] fn lossless_overflow_fails_fast_and_closes() { let mut ob = Outbox::new(); for i in 0..OUTBOX_MAX { assert!(ob.enqueue(fe_key('x')), "fill up to the cap (i={i})"); } // The cap-crossing lossless event is rejected and the outbox is // now closed — a clean disconnect beats dropping a CrdtOp silently. assert!(!ob.enqueue(fe_key('y'))); assert!(ob.closed); // Once closed, everything is refused (including coalesceable kinds). assert!(!ob.enqueue(fe_viewport(1))); } #[test] fn coalescing_does_not_count_against_the_cap() { let mut ob = Outbox::new(); // Even a huge scroll flood stays at one queued event, so it can // never trip the overflow fail-fast. for g in 0..(OUTBOX_MAX as u64 * 4) { assert!(ob.enqueue(fe_viewport(g))); } assert_eq!(ob.queue.len(), 1); assert!(!ob.closed); } #[test] fn a_closed_outbox_shuts_the_socket_down_to_wake_the_reader() { use std::io::Read; // A socketpair stands in for the daemon connection; `a` is the peer // a blocked reader would be reading from. let (mut a, b) = UnixStream::pair().expect("socketpair"); // The post-overflow state: the outbox is already closed. let mut outbox = Outbox::new(); outbox.closed = true; let client = AttachClient { outbox: Arc::new((Mutex::new(outbox), Condvar::new())), shutdown_handle: b, frontend_id: FrontendId::LOCAL, session_protocol_version: PROTOCOL_VERSION, baseline_protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, initial_message: None, }; // A send against the closed outbox fails *and* shuts the socket // down (F-008 fail-fast is now a real teardown, not just a flag). assert!(client.send_event(fe_key('x')).is_err()); // The peer reads EOF: a real blocked reader would wake here and // fire Disconnected, instead of the session diverging silently. let mut buf = [0u8; 8]; assert_eq!( a.read(&mut buf).expect("read peer"), 0, "peer should see EOF after the shutdown" ); } #[test] fn managed_attach_starts_only_for_absent_or_refused_sockets() { let temp = tempfile::tempdir().expect("tempdir"); let socket_path = temp.path().join("managed.sock"); let socket = socket_path.as_path(); assert!( initial_startup_authorized(socket, &io::Error::new(io::ErrorKind::NotFound, "absent")) .expect("classify absent socket") ); assert!( initial_startup_authorized( socket, &io::Error::new(io::ErrorKind::ConnectionRefused, "refused") ) .expect("classify vanished socket") ); for kind in [ io::ErrorKind::PermissionDenied, io::ErrorKind::Interrupted, io::ErrorKind::WouldBlock, io::ErrorKind::InvalidInput, ] { assert!( !initial_startup_authorized(socket, &io::Error::new(kind, "final")) .expect("classify final connect error"), "{kind:?} must not authorize daemon startup" ); } } #[test] fn managed_retry_adds_only_interrupted_and_would_block() { let temp = tempfile::tempdir().expect("tempdir"); let socket_path = temp.path().join("managed.sock"); let socket = socket_path.as_path(); for kind in [io::ErrorKind::Interrupted, io::ErrorKind::WouldBlock] { assert!( post_spawn_retryable(socket, &io::Error::new(kind, "transient")) .expect("classify transient retry") ); } assert!( !post_spawn_retryable( socket, &io::Error::new(io::ErrorKind::PermissionDenied, "final") ) .expect("classify final retry error") ); } #[test] fn managed_attach_refuses_a_non_socket_path_without_spawning() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); let result = connect_managed_inner( &path, Path::new("/unused/pmacs"), |_| { Err(io::Error::new( io::ErrorKind::ConnectionRefused, "synthetic refused connect", )) }, |_, _| panic!("non-socket path must not spawn"), Duration::from_millis(1), Duration::from_millis(1), None, |_| false, ); assert!(matches!( result, Err(ManagedAttachError::NonSocketPath(rejected)) if rejected == path )); } #[test] fn managed_attach_fails_closed_on_non_retryable_connect_errors() { let temp = tempfile::tempdir().expect("tempdir"); let socket = temp.path().join("managed.sock"); let result = connect_managed_inner( &socket, Path::new("/unused/pmacs"), |_| { Err(io::Error::new( io::ErrorKind::PermissionDenied, "synthetic permission failure", )) }, |_, _| panic!("permission failure must not spawn"), Duration::from_millis(1), Duration::from_millis(1), None, |_| false, ); assert!(matches!( result, Err(ManagedAttachError::Attach(AttachClientError::Connect(error))) if error.kind() == io::ErrorKind::PermissionDenied )); } #[test] fn managed_retry_survives_transients_and_uses_the_successful_stream() { let temp = tempfile::tempdir().expect("tempdir"); let socket = temp.path().join("managed.sock"); let (client_stream, mut server_stream) = UnixStream::pair().expect("socket pair"); let server = thread::spawn(move || { let hello = Hello { protocol_version: PROTOCOL_VERSION, assigned_frontend_id: FrontendId::LOCAL, instance_identity: InstanceIdentity { pmacs_version: "managed-retry-test".to_owned(), build_hash: None, instance_name: None, uptime_secs: 0, working_directory: "/tmp".to_owned(), }, instance_capabilities: caps(true, true, true), }; write_message(&mut server_stream, &hello).expect("write Hello"); let _: AttachRequest = read_message(&mut server_stream).expect("read real AttachRequest"); }); let mut attempts = 0; let mut client_stream = Some(client_stream); let managed = connect_managed_inner( &socket, Path::new("/bin/sh"), |_| { attempts += 1; match attempts { 1 => Err(io::Error::new(io::ErrorKind::NotFound, "initial miss")), 2 => Err(io::Error::new(io::ErrorKind::Interrupted, "signal")), 3 => Err(io::Error::new(io::ErrorKind::WouldBlock, "backlog")), 4 => Ok(client_stream.take().expect("single successful stream")), _ => panic!("unexpected connection attempt"), } }, |_, _| Command::new("/bin/sh").args(["-c", "exit 0"]).spawn(), Duration::from_secs(1), Duration::ZERO, None, |_| true, ) .expect("transient sequence must attach"); assert_eq!(attempts, 4); assert!(managed.daemon.spawned_daemon()); assert_eq!( managed.client.session_protocol_version(), PROTOCOL_VERSION, "a managed attach negotiates this binary's wire, not the Hello baseline" ); assert_eq!( managed.client.baseline_protocol_version(), pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, "while the daemon's server-first Hello still advertises the baseline" ); server.join().expect("handshake server"); } #[test] fn startup_timeout_reports_the_configured_duration() { let error = ManagedAttachError::StartupTimeout { socket: PathBuf::from("/tmp/unused.sock"), connect: io::Error::new(io::ErrorKind::NotFound, "still absent"), daemon_status: Some("exit status: 17".to_owned()), timeout: Duration::from_millis(1), }; let message = error.to_string(); assert!( message.contains("1ms"), "unexpected timeout message: {message}" ); assert!(!message.contains("5 seconds")); } }