Skip to main content

rmux_client/
connection.rs

1//! Blocking Unix-socket transport for detached RPC traffic.
2
3use std::ffi::OsStr;
4#[cfg(all(test, unix))]
5use std::ffi::OsString;
6#[cfg(all(test, unix))]
7use std::fs;
8use std::io::{self, Read, Write};
9#[cfg(all(test, unix))]
10use std::os::unix::ffi::{OsStrExt, OsStringExt};
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use crate::ClientError;
15use rmux_ipc::{connect_blocking, BlockingLocalStream, LocalEndpoint};
16use rmux_proto::{
17    encode_frame, AttachSessionResponse, ControlMode, ControlModeResponse, FrameDecoder,
18    HandshakeRequest, Request, Response, RmuxError, RMUX_FRAME_MAGIC, RMUX_WIRE_VERSION,
19};
20
21/// Read buffer size for blocking socket reads.
22const READ_BUFFER_SIZE: usize = 8192;
23/// Default timeout for establishing detached RPC connections.
24const SOCKET_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
25/// Default timeout for writing detached RPC requests.
26const SOCKET_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
27/// Default timeout for ordinary detached RPC response reads.
28const SOCKET_RESPONSE_TIMEOUT: Duration = Duration::from_secs(15);
29/// Legacy wire version kept only for targeted shutdown of pre-0.6 daemons.
30const LEGACY_SHUTDOWN_WIRE_VERSION: u8 = 1;
31
32#[cfg(all(test, unix))]
33const FALLBACK_SOCKET_ROOT: &str = "/tmp";
34#[cfg(all(test, unix))]
35const SOCKET_DIR_PREFIX: &str = "rmux";
36
37/// Computes the default RMUX client socket path.
38///
39/// The path uses an rmux-specific per-user directory so an rmux client never
40/// speaks the rmux wire protocol to a real tmux server.
41pub fn default_socket_path() -> Result<PathBuf, ClientError> {
42    rmux_ipc::default_endpoint()
43        .map(LocalEndpoint::into_path)
44        .map_err(ClientError::Io)
45}
46
47/// Computes an rmux socket path for a top-level `-L` socket name.
48pub fn socket_path_for_label(label: impl AsRef<OsStr>) -> Result<PathBuf, ClientError> {
49    rmux_ipc::endpoint_for_label(label)
50        .map(LocalEndpoint::into_path)
51        .map_err(ClientError::Io)
52}
53
54/// Resolves the top-level socket path from `-L`, `-S`, inherited multiplexer
55/// environment, or defaults.
56///
57/// `-S` wins over `-L`; both command-line forms win over inherited
58/// multiplexer environment.
59pub fn resolve_socket_path(
60    socket_name: Option<&OsStr>,
61    socket_path: Option<&Path>,
62) -> Result<PathBuf, ClientError> {
63    rmux_ipc::resolve_endpoint(socket_name, socket_path)
64        .map(LocalEndpoint::into_path)
65        .map_err(ClientError::Io)
66}
67
68/// Result of attempting to connect to the RMUX server.
69// Keep the successful path as an owned public `Connection`; boxing would add an
70// unnecessary API wrinkle around the small absent-server control-flow case.
71#[allow(clippy::large_enum_variant)]
72#[derive(Debug)]
73pub enum ConnectResult {
74    /// Successfully connected to the server.
75    Connected(Connection),
76    /// The server is absent (socket does not exist or connection refused).
77    Absent,
78}
79
80/// Attempts to connect to the RMUX server, distinguishing absent servers from
81/// real connection errors.
82///
83/// Returns [`ConnectResult::Absent`] when the socket does not exist or the
84/// connection is refused, which lets callers like `kill-session` succeed with
85/// exit code `0` for an absent server. Returns an error only for unexpected
86/// transport failures.
87pub fn connect_or_absent(socket_path: &Path) -> Result<ConnectResult, ClientError> {
88    connect_or_absent_with_timeout_using(
89        socket_path,
90        SOCKET_CONNECT_TIMEOUT,
91        connect_stream_with_timeout,
92    )
93}
94
95/// Connects to the RMUX server, returning an error if the server is absent.
96pub fn connect(socket_path: &Path) -> Result<Connection, ClientError> {
97    connect_with_timeout_using(
98        socket_path,
99        SOCKET_CONNECT_TIMEOUT,
100        connect_stream_with_timeout,
101    )
102}
103
104/// A blocking connection to the RMUX server that exchanges typed frames.
105#[derive(Debug)]
106pub struct Connection {
107    stream: BlockingLocalStream,
108    decoder: FrameDecoder,
109    handshake_capabilities: Option<Vec<String>>,
110}
111
112/// The explicit result of requesting an attach-stream upgrade.
113// Keep the public enum shape stable: callers match `Rejected(Response)` directly.
114#[allow(clippy::large_enum_variant)]
115#[derive(Debug)]
116pub enum AttachTransition {
117    /// The server accepted the attach request and switched protocols.
118    Upgraded(AttachSessionUpgrade),
119    /// The server responded without switching protocols.
120    Rejected(Response),
121}
122
123/// The explicit result of requesting a control-mode upgrade.
124// Keep the public enum shape stable: callers match `Rejected(Response)` directly.
125#[allow(clippy::large_enum_variant)]
126#[derive(Debug)]
127pub enum ControlTransition {
128    /// The server accepted the control-mode request and switched protocols.
129    Upgraded(ControlModeUpgrade),
130    /// The server responded without switching protocols.
131    Rejected(Response),
132}
133
134/// A detached connection that has transitioned into attach-stream mode.
135#[derive(Debug)]
136pub struct AttachSessionUpgrade {
137    response: AttachSessionResponse,
138    stream: BlockingLocalStream,
139    initial_bytes: Vec<u8>,
140}
141
142/// A detached connection that has transitioned into control-mode streaming.
143#[derive(Debug)]
144pub struct ControlModeUpgrade {
145    pub(crate) response: ControlModeResponse,
146    pub(crate) stream: BlockingLocalStream,
147}
148
149impl AttachSessionUpgrade {
150    /// Returns the upgrade response sent by the server.
151    #[must_use]
152    pub const fn response(&self) -> &AttachSessionResponse {
153        &self.response
154    }
155
156    /// Consumes the upgrade and returns the raw attach-stream socket.
157    #[must_use]
158    pub fn into_stream(self) -> BlockingLocalStream {
159        self.stream
160    }
161
162    /// Consumes the upgrade and returns the raw attach-stream socket plus any
163    /// bytes already read beyond the detached response frame.
164    #[must_use]
165    pub fn into_parts(self) -> (BlockingLocalStream, Vec<u8>) {
166        (self.stream, self.initial_bytes)
167    }
168}
169
170impl ControlModeUpgrade {
171    /// Returns the upgrade response sent by the server.
172    #[must_use]
173    pub const fn response(&self) -> &ControlModeResponse {
174        &self.response
175    }
176
177    /// Returns the negotiated control-mode flavor.
178    #[must_use]
179    pub const fn mode(&self) -> ControlMode {
180        self.response.mode
181    }
182
183    /// Consumes the upgrade and returns the raw control-mode socket.
184    #[must_use]
185    pub fn into_stream(self) -> BlockingLocalStream {
186        self.stream
187    }
188}
189
190impl Connection {
191    pub(crate) fn new(stream: BlockingLocalStream) -> Result<Self, ClientError> {
192        set_read_timeout(&stream, Some(SOCKET_RESPONSE_TIMEOUT)).map_err(ClientError::Io)?;
193        set_write_timeout(&stream, Some(SOCKET_WRITE_TIMEOUT)).map_err(ClientError::Io)?;
194
195        Ok(Self {
196            stream,
197            decoder: FrameDecoder::new(),
198            handshake_capabilities: None,
199        })
200    }
201
202    /// Sends a request and reads the server's response.
203    ///
204    /// Server-side `Response::Error` payloads are returned as-is in the `Ok`
205    /// variant so callers can pattern-match on them. Only transport and framing
206    /// failures produce `Err`.
207    pub fn roundtrip(&mut self, request: &Request) -> Result<Response, ClientError> {
208        self.write_request(request)?;
209        self.read_response()
210    }
211
212    /// Returns whether the connected daemon advertises a protocol capability.
213    ///
214    /// Optional client behavior uses this as a soft gate: older daemons that do
215    /// not answer the handshake shape or report an error are treated as not
216    /// supporting the capability, leaving the connection usable for legacy
217    /// requests.
218    pub fn supports_capability(&mut self, capability: &str) -> Result<bool, ClientError> {
219        if let Some(capabilities) = &self.handshake_capabilities {
220            return Ok(capabilities.iter().any(|supported| supported == capability));
221        }
222
223        match self.roundtrip(&Request::Handshake(HandshakeRequest::current()))? {
224            Response::Handshake(response) => {
225                self.handshake_capabilities = Some(response.capabilities);
226                Ok(self
227                    .handshake_capabilities
228                    .as_ref()
229                    .expect("handshake capabilities were just cached")
230                    .iter()
231                    .any(|supported| supported == capability))
232            }
233            Response::Error(error) => {
234                if matches!(&error.error, RmuxError::UnsupportedWireVersion { .. }) {
235                    return Err(ClientError::Protocol(error.error));
236                }
237                self.handshake_capabilities = Some(Vec::new());
238                Ok(false)
239            }
240            _ => {
241                self.handshake_capabilities = Some(Vec::new());
242                Ok(false)
243            }
244        }
245    }
246
247    /// Sends a request without a detached response read timeout.
248    ///
249    /// This is reserved for scripting requests whose server-side completion can
250    /// legitimately block beyond the normal five-second detached RPC bound.
251    pub(crate) fn roundtrip_without_read_timeout(
252        &mut self,
253        request: &Request,
254    ) -> Result<Response, ClientError> {
255        let previous_timeout = read_timeout(&self.stream).map_err(ClientError::Io)?;
256        set_read_timeout(&self.stream, None).map_err(ClientError::Io)?;
257        let result = self.roundtrip(request);
258        set_read_timeout(&self.stream, previous_timeout).map_err(ClientError::Io)?;
259        result
260    }
261
262    pub(crate) fn write_request(&mut self, request: &Request) -> Result<(), ClientError> {
263        let frame = encode_frame(request).map_err(ClientError::Protocol)?;
264        self.stream.write_all(&frame).map_err(ClientError::Io)
265    }
266
267    pub(crate) fn write_legacy_wire_v1_request(
268        &mut self,
269        request: &Request,
270    ) -> Result<(), ClientError> {
271        let frame = encode_legacy_wire_v1_frame(request)?;
272        self.stream.write_all(&frame).map_err(ClientError::Io)
273    }
274
275    pub(crate) fn read_response(&mut self) -> Result<Response, ClientError> {
276        let mut buffer = [0u8; READ_BUFFER_SIZE];
277
278        loop {
279            match self.decoder.next_frame::<Response>() {
280                Ok(Some(response)) => return Ok(response),
281                Ok(None) => {}
282                Err(error) => return Err(ClientError::Protocol(error)),
283            }
284
285            let bytes_read = match self.stream.read(&mut buffer) {
286                Ok(bytes_read) => bytes_read,
287                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
288                Err(error) => return Err(ClientError::Io(error)),
289            };
290
291            if bytes_read == 0 {
292                return Err(ClientError::UnexpectedEof);
293            }
294
295            self.decoder.push_bytes(&buffer[..bytes_read]);
296        }
297    }
298
299    pub(crate) fn stream_mut(&mut self) -> &mut BlockingLocalStream {
300        &mut self.stream
301    }
302
303    pub(crate) fn into_attach_upgrade(
304        self,
305        response: AttachSessionResponse,
306    ) -> Result<AttachSessionUpgrade, ClientError> {
307        set_read_timeout(&self.stream, None).map_err(ClientError::Io)?;
308        set_write_timeout(&self.stream, None).map_err(ClientError::Io)?;
309        let initial_bytes = self.decoder.remaining_bytes().to_vec();
310
311        Ok(AttachSessionUpgrade {
312            response,
313            stream: self.stream,
314            initial_bytes,
315        })
316    }
317
318    pub(crate) fn into_control_upgrade(
319        self,
320        response: ControlModeResponse,
321    ) -> Result<ControlModeUpgrade, ClientError> {
322        set_read_timeout(&self.stream, None).map_err(ClientError::Io)?;
323        set_write_timeout(&self.stream, None).map_err(ClientError::Io)?;
324
325        Ok(ControlModeUpgrade {
326            response,
327            stream: self.stream,
328        })
329    }
330}
331
332fn encode_legacy_wire_v1_frame(request: &Request) -> Result<Vec<u8>, ClientError> {
333    let mut frame = encode_frame(request).map_err(ClientError::Protocol)?;
334    if frame.first().copied() != Some(RMUX_FRAME_MAGIC) {
335        return Err(ClientError::Protocol(RmuxError::Encode(
336            "current frame encoder produced an invalid RMUX envelope".to_owned(),
337        )));
338    }
339
340    if RMUX_WIRE_VERSION > 0x7f {
341        return Err(ClientError::Protocol(RmuxError::Encode(
342            "legacy shutdown recovery expects a single-byte current wire version".to_owned(),
343        )));
344    }
345
346    match frame.get_mut(1) {
347        Some(version) if *version == RMUX_WIRE_VERSION as u8 => {
348            *version = LEGACY_SHUTDOWN_WIRE_VERSION;
349            Ok(frame)
350        }
351        _ => Err(ClientError::Protocol(RmuxError::Encode(
352            "current frame encoder used an unexpected wire-version envelope".to_owned(),
353        ))),
354    }
355}
356
357pub(crate) fn read_response_frame_exact(
358    stream: &mut BlockingLocalStream,
359) -> Result<Response, ClientError> {
360    let mut decoder = FrameDecoder::new();
361    let mut byte = [0_u8; 1];
362
363    loop {
364        match decoder.next_frame::<Response>() {
365            Ok(Some(response)) => return Ok(response),
366            Ok(None) => {}
367            Err(error) => return Err(ClientError::Protocol(error)),
368        }
369
370        read_exact_or_eof(stream, &mut byte)?;
371        decoder.push_bytes(&byte);
372    }
373}
374
375fn read_exact_or_eof(
376    stream: &mut BlockingLocalStream,
377    buffer: &mut [u8],
378) -> Result<(), ClientError> {
379    match stream.read_exact(buffer) {
380        Ok(()) => Ok(()),
381        Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => {
382            Err(ClientError::UnexpectedEof)
383        }
384        Err(error) => Err(ClientError::Io(error)),
385    }
386}
387
388#[cfg(all(test, unix))]
389fn socket_path_from_parts(
390    rmux_tmpdir: Option<&OsStr>,
391    user_id: u32,
392    label: &OsStr,
393) -> io::Result<PathBuf> {
394    let root = socket_root_from_parts(rmux_tmpdir)?;
395    let base = root.join(format!("{SOCKET_DIR_PREFIX}-{user_id}"));
396    let mut path = base.into_os_string().into_vec();
397    path.push(b'/');
398    path.extend_from_slice(label.as_bytes());
399
400    Ok(PathBuf::from(OsString::from_vec(path)))
401}
402
403#[cfg(all(test, unix))]
404fn socket_root_from_parts(rmux_tmpdir: Option<&OsStr>) -> io::Result<PathBuf> {
405    let rmux_tmpdir = rmux_tmpdir
406        .filter(|value| !value.is_empty())
407        .map(PathBuf::from);
408    let candidates = rmux_tmpdir
409        .into_iter()
410        .chain(std::iter::once(PathBuf::from(FALLBACK_SOCKET_ROOT)));
411
412    for candidate in candidates {
413        if let Ok(resolved) = fs::canonicalize(&candidate) {
414            return Ok(resolved);
415        }
416    }
417
418    Err(io::Error::new(
419        io::ErrorKind::NotFound,
420        "no suitable rmux socket directory",
421    ))
422}
423
424fn connect_or_absent_with_timeout_using<F>(
425    socket_path: &Path,
426    timeout: Duration,
427    connect_stream: F,
428) -> Result<ConnectResult, ClientError>
429where
430    F: FnOnce(&Path, Duration) -> io::Result<BlockingLocalStream>,
431{
432    match connect_stream(socket_path, timeout) {
433        Ok(stream) => Ok(ConnectResult::Connected(Connection::new(stream)?)),
434        Err(error) if is_absent_error(&error) => Ok(ConnectResult::Absent),
435        Err(error) => Err(ClientError::Io(error)),
436    }
437}
438
439fn connect_with_timeout_using<F>(
440    socket_path: &Path,
441    timeout: Duration,
442    connect_stream: F,
443) -> Result<Connection, ClientError>
444where
445    F: FnOnce(&Path, Duration) -> io::Result<BlockingLocalStream>,
446{
447    let stream = connect_stream(socket_path, timeout).map_err(ClientError::Io)?;
448    Connection::new(stream)
449}
450
451fn connect_stream_with_timeout(
452    socket_path: &Path,
453    timeout: Duration,
454) -> io::Result<BlockingLocalStream> {
455    connect_blocking(
456        &LocalEndpoint::from_path(socket_path.to_path_buf()),
457        timeout,
458    )
459}
460
461fn read_timeout(stream: &BlockingLocalStream) -> io::Result<Option<Duration>> {
462    stream.read_timeout()
463}
464
465fn set_read_timeout(stream: &BlockingLocalStream, timeout: Option<Duration>) -> io::Result<()> {
466    stream.set_read_timeout(timeout)
467}
468
469fn set_write_timeout(stream: &BlockingLocalStream, timeout: Option<Duration>) -> io::Result<()> {
470    stream.set_write_timeout(timeout)
471}
472
473/// Returns `true` for I/O errors that indicate the server is not running.
474fn is_absent_error(error: &io::Error) -> bool {
475    matches!(
476        error.kind(),
477        io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
478    )
479}
480
481#[cfg(all(test, unix))]
482mod tests {
483    include!("connection/tests.rs");
484}