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