1712 lines
65 KiB
Rust
1712 lines
65 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::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<String>,
|
||
/// 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<AttachClientError> for ManagedAttachError {
|
||
fn from(error: AttachClientError) -> Self {
|
||
Self::Attach(error)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Default)]
|
||
struct DaemonProcessState {
|
||
reaped: bool,
|
||
wait_result: Option<String>,
|
||
}
|
||
|
||
/// Observable process facts for a daemon started by managed attach.
|
||
#[derive(Clone, Debug)]
|
||
pub struct ManagedDaemonFacts {
|
||
spawned: bool,
|
||
pid: Option<u32>,
|
||
state: Arc<Mutex<DaemonProcessState>>,
|
||
}
|
||
|
||
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<u32> {
|
||
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<String> {
|
||
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<InstanceMessage>),
|
||
/// 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<u8> {
|
||
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),
|
||
// Bottom panel Stage 2B-3 (framing §5.2) — four more tail-only
|
||
// tags, each with its own reason.
|
||
//
|
||
// Geometry is latest-wins because epochs need only INCREASE, not
|
||
// be consecutive: the daemon accepts a jump from 3 to 7 and the
|
||
// dropped declarations described geometry that no longer exists.
|
||
FrontendEvent::FrontendCellGeometry { .. } => Some(4),
|
||
// A resize drag coalesces over the complete event including its
|
||
// epochs, so a collapsed run cannot mix a new row count with an
|
||
// old presentation identity.
|
||
FrontendEvent::PanelResizeRows { .. } => Some(5),
|
||
// Panel move and drag mirror their terminal twins. Down / Up /
|
||
// wheel / context stay LOSSLESS and ordered: repeated left Downs
|
||
// are what the daemon's click state reads as a multi-click, and
|
||
// Down(Right) is the context-menu gesture, so collapsing either
|
||
// silently changes the gesture's meaning.
|
||
FrontendEvent::PanelPointer {
|
||
kind: MouseKind::Move,
|
||
..
|
||
} => Some(6),
|
||
FrontendEvent::PanelPointer {
|
||
kind: MouseKind::Drag(_),
|
||
..
|
||
} => Some(7),
|
||
_ => 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<FrontendEvent>,
|
||
/// 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<InstanceMessage, AttachClientError> {
|
||
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<AppEvent>,
|
||
) -> Result<AttachClient, AttachClientError> {
|
||
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<AttachClient, AttachClientError> {
|
||
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<InitialTargetPaths>,
|
||
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
|
||
) -> Result<AttachClient, AttachClientError> {
|
||
// 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::<InstanceMessage>(&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<InitialTargetPaths>,
|
||
proxy: EventLoopProxy<AppEvent>,
|
||
) -> Result<ManagedAttach, ManagedAttachError> {
|
||
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<InitialTargetPaths>,
|
||
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
|
||
) -> Result<ManagedAttach, ManagedAttachError> {
|
||
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<Child> {
|
||
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<bool, ManagedAttachError> {
|
||
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<bool, ManagedAttachError> {
|
||
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<C, S, F>(
|
||
socket_path: &Path,
|
||
daemon_executable: &Path,
|
||
mut connector: C,
|
||
spawner: S,
|
||
timeout: Duration,
|
||
retry_interval: Duration,
|
||
initial_target: Option<InitialTargetPaths>,
|
||
sink: F,
|
||
) -> Result<ManagedAttach, ManagedAttachError>
|
||
where
|
||
C: FnMut(&Path) -> io::Result<UnixStream>,
|
||
S: FnOnce(&Path, &Path) -> io::Result<Child>,
|
||
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<Outbox>, 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<InstanceMessage>,
|
||
}
|
||
|
||
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<InstanceMessage> {
|
||
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<u8>) -> 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::FrontendCellGeometry` (Q#BP15a): this
|
||
/// frontend's authoritative whole-cell layout capacity. Callers gate on
|
||
/// [`Self::session_protocol_version`] `>= 21`.
|
||
///
|
||
/// Valid **without** a side window on purpose — the daemon needs
|
||
/// columns before it can paint a first panel frame, so gating this on
|
||
/// panel presence would deadlock the first open.
|
||
pub fn send_frontend_cell_geometry(
|
||
&self,
|
||
geometry_epoch: u64,
|
||
total: CellSize,
|
||
) -> Result<(), TransportError> {
|
||
self.send_event(FrontendEvent::FrontendCellGeometry {
|
||
frontend_id: self.frontend_id,
|
||
geometry_epoch,
|
||
total,
|
||
})
|
||
}
|
||
|
||
/// Send a `FrontendEvent::PanelResizeRows` (Q#BP15a): the fixed panel
|
||
/// rows a divider drag is requesting. Callers gate on
|
||
/// [`Self::session_protocol_version`] `>= 21`.
|
||
///
|
||
/// Both epochs ride along as identities, not geometry: the daemon
|
||
/// accepts the request only for the panel it most recently declared,
|
||
/// under the geometry it most recently accepted.
|
||
pub fn send_panel_resize_rows(
|
||
&self,
|
||
geometry_epoch: u64,
|
||
panel_epoch: u64,
|
||
rows: u32,
|
||
) -> Result<(), TransportError> {
|
||
self.send_event(FrontendEvent::PanelResizeRows {
|
||
frontend_id: self.frontend_id,
|
||
geometry_epoch,
|
||
panel_epoch,
|
||
rows,
|
||
})
|
||
}
|
||
|
||
/// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture
|
||
/// hit-tested locally to a panel CELL. Callers gate on
|
||
/// [`Self::session_protocol_version`] `>= 21`.
|
||
///
|
||
/// `buffer_id` and `panel_epoch` close different holes and neither
|
||
/// subsumes the other — the first catches an A→B buffer replacement,
|
||
/// the second a close/hide/reopen of the *same* buffer — so both are
|
||
/// carried rather than one being derived from the other.
|
||
pub fn send_panel_pointer(
|
||
&self,
|
||
geometry_epoch: u64,
|
||
panel_epoch: u64,
|
||
buffer_id: BufferId,
|
||
coord: CellCoord,
|
||
kind: MouseKind,
|
||
mods: Modifiers,
|
||
) -> Result<(), TransportError> {
|
||
self.send_event(FrontendEvent::PanelPointer {
|
||
frontend_id: self.frontend_id,
|
||
geometry_epoch,
|
||
panel_epoch,
|
||
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<u32>,
|
||
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 || {
|
||
// Advertises the BASELINE, like a real daemon: the session
|
||
// version is settled by the client's counter-offer below, and a
|
||
// fixture that advertised the current wire could not tell the
|
||
// two apart.
|
||
let hello = Hello {
|
||
protocol_version: pmacs_protocol::ADVERTISED_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"));
|
||
}
|
||
}
|