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