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