Skip to main content

running_process/broker/server/handoff/
unix.rs

1//! Unix `SCM_RIGHTS` handoff transport model.
2//!
3//! This module owns the broker-side `sendmsg(SCM_RIGHTS)` call used to pass an
4//! already-accepted client connection into a backend process. The backend still
5//! has to verify the one-time token before adopting the connection; failures
6//! map into the existing silent reconnect fallback policy.
7
8#[cfg(unix)]
9use std::path::Path;
10use std::path::PathBuf;
11
12use super::{
13    HandoffAttemptDecision, HandoffAttemptFailure, HandoffFallbackDecision, HandoffFallbackReason,
14    HandoffToken,
15};
16
17/// Whether this build target can eventually use Unix-domain `SCM_RIGHTS`.
18pub const SCM_RIGHTS_TRANSPORT_SUPPORTED: bool = cfg!(unix);
19
20/// Opaque raw Unix file descriptor value owned by the broker or backend.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub struct UnixFileDescriptor(i32);
23
24impl UnixFileDescriptor {
25    /// Build an opaque file descriptor value for transport bookkeeping.
26    pub fn new(raw_fd: i32) -> Self {
27        Self(raw_fd)
28    }
29
30    /// Return the raw opaque file descriptor value.
31    pub fn raw(self) -> i32 {
32        self.0
33    }
34}
35
36/// Backend Unix-domain socket that will receive `SCM_RIGHTS` messages.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct UnixHandoffSocket {
39    /// Filesystem path or platform socket path for the backend handoff socket.
40    pub path: PathBuf,
41}
42
43impl UnixHandoffSocket {
44    /// Build a backend handoff socket descriptor.
45    pub fn new(path: impl Into<PathBuf>) -> Self {
46        Self { path: path.into() }
47    }
48}
49
50/// Inputs for one future `sendmsg(SCM_RIGHTS)` attempt.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct ScmRightsAttempt {
53    /// Broker-owned connection file descriptor to pass.
54    pub fd: UnixFileDescriptor,
55    /// Backend handoff socket that should receive the file descriptor.
56    pub backend_socket: UnixHandoffSocket,
57    /// One-time token associated with this handoff attempt.
58    pub handoff_token: HandoffToken,
59}
60
61impl ScmRightsAttempt {
62    /// Build typed inputs for one `SCM_RIGHTS` attempt.
63    pub fn new(
64        fd: UnixFileDescriptor,
65        backend_socket: UnixHandoffSocket,
66        handoff_token: HandoffToken,
67    ) -> Self {
68        Self {
69            fd,
70            backend_socket,
71            handoff_token,
72        }
73    }
74}
75
76/// Successful `SCM_RIGHTS` outcome once real fd passing is wired.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct ScmRightsSuccess {
79    /// File descriptor value sent to the backend.
80    pub sent_fd: UnixFileDescriptor,
81    /// Backend handoff socket that received the file descriptor.
82    pub backend_socket: UnixHandoffSocket,
83    /// One-time token paired with the sent file descriptor.
84    pub handoff_token: HandoffToken,
85}
86
87impl ScmRightsSuccess {
88    /// Build a typed successful handoff result.
89    pub fn new(
90        sent_fd: UnixFileDescriptor,
91        backend_socket: UnixHandoffSocket,
92        handoff_token: HandoffToken,
93    ) -> Self {
94        Self {
95            sent_fd,
96            backend_socket,
97            handoff_token,
98        }
99    }
100}
101
102/// Result returned by the future Unix transport.
103pub type ScmRightsResult = Result<ScmRightsSuccess, ScmRightsError>;
104
105/// Try to send the broker-held file descriptor to the backend handoff socket.
106///
107/// The sent file descriptor remains owned by the broker. The backend receives
108/// a duplicate descriptor through `SCM_RIGHTS` and must verify the paired
109/// [`HandoffToken`] before treating the connection as adopted.
110pub fn try_send_scm_rights(attempt: &ScmRightsAttempt) -> ScmRightsResult {
111    platform_try_send_scm_rights(attempt)
112}
113
114/// Send the broker-held file descriptor and token over an already-connected
115/// Unix-domain handoff socket.
116///
117/// [`try_send_scm_rights`] dials a fresh connection per attempt; the
118/// production serve path instead reuses the framed broker↔backend handoff
119/// connection so the `SCM_RIGHTS` message and the [`HandoffOffer`
120/// frame](crate::broker::protocol::HandoffOffer) travel over the same
121/// stream. The caller keeps ownership of both descriptors.
122#[cfg(unix)]
123pub fn try_send_scm_rights_over(
124    socket_fd: std::os::fd::RawFd,
125    attempt: &ScmRightsAttempt,
126) -> ScmRightsResult {
127    send_fd_with_token(
128        socket_fd,
129        attempt.fd.raw(),
130        attempt.handoff_token.as_bytes(),
131        &attempt.backend_socket.path,
132    )?;
133    Ok(ScmRightsSuccess::new(
134        attempt.fd,
135        attempt.backend_socket.clone(),
136        attempt.handoff_token,
137    ))
138}
139
140/// Failure from a future `sendmsg(SCM_RIGHTS)` handoff attempt.
141#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
142pub enum ScmRightsError {
143    /// The current target cannot use the Unix handoff transport.
144    #[error("SCM_RIGHTS handoff transport is unsupported on this platform")]
145    UnsupportedPlatform,
146    /// The platform denied file descriptor passing.
147    #[error("permission denied passing fd {fd} to backend handoff socket {socket}")]
148    PermissionDenied {
149        /// File descriptor targeted by the handoff.
150        fd: i32,
151        /// Backend handoff socket path.
152        socket: PathBuf,
153    },
154    /// The backend handoff socket could not be reached.
155    #[error("backend handoff socket is unavailable: {socket}")]
156    BackendSocketUnavailable {
157        /// Backend handoff socket path.
158        socket: PathBuf,
159    },
160    /// The nonblocking `SCM_RIGHTS` send could not complete immediately.
161    #[error("SCM_RIGHTS send would block for backend handoff socket {socket}")]
162    WouldBlock {
163        /// Backend handoff socket path.
164        socket: PathBuf,
165    },
166    /// The `sendmsg(SCM_RIGHTS)` call failed after connecting to the backend socket.
167    #[error("SCM_RIGHTS send failed for fd {fd} to backend handoff socket {socket}")]
168    SendFailed {
169        /// File descriptor targeted by the handoff.
170        fd: i32,
171        /// Backend handoff socket path.
172        socket: PathBuf,
173        /// Raw OS error code returned by the platform, when available.
174        raw_os_error: Option<i32>,
175    },
176    /// Some token bytes were sent, so the descriptor may have reached the backend.
177    #[error(
178        "SCM_RIGHTS send was partial ({sent_bytes}/{expected_bytes} bytes) for fd {fd} to backend handoff socket {socket}"
179    )]
180    PartialSend {
181        /// File descriptor targeted by the handoff.
182        fd: i32,
183        /// Backend handoff socket path.
184        socket: PathBuf,
185        /// Token bytes accepted by the socket.
186        sent_bytes: usize,
187        /// Complete token length required by the protocol.
188        expected_bytes: usize,
189    },
190    /// The backend did not acknowledge the passed file descriptor before the deadline.
191    #[error("backend handoff socket {socket} did not acknowledge passed fd")]
192    BackendAckTimeout {
193        /// Backend handoff socket path.
194        socket: PathBuf,
195    },
196}
197
198#[cfg(unix)]
199fn platform_try_send_scm_rights(attempt: &ScmRightsAttempt) -> ScmRightsResult {
200    use std::os::fd::AsRawFd;
201    use std::os::unix::net::UnixStream;
202
203    let stream = UnixStream::connect(&attempt.backend_socket.path)
204        .map_err(|err| socket_connect_error(&attempt.backend_socket.path, err))?;
205    stream
206        .set_nonblocking(true)
207        .map_err(|err| socket_connect_error(&attempt.backend_socket.path, err))?;
208
209    send_fd_with_token(
210        stream.as_raw_fd(),
211        attempt.fd.raw(),
212        attempt.handoff_token.as_bytes(),
213        &attempt.backend_socket.path,
214    )?;
215
216    Ok(ScmRightsSuccess::new(
217        attempt.fd,
218        attempt.backend_socket.clone(),
219        attempt.handoff_token,
220    ))
221}
222
223#[cfg(not(unix))]
224fn platform_try_send_scm_rights(_attempt: &ScmRightsAttempt) -> ScmRightsResult {
225    Err(ScmRightsError::UnsupportedPlatform)
226}
227
228#[cfg(unix)]
229fn send_fd_with_token(
230    socket_fd: std::os::fd::RawFd,
231    sent_fd: std::os::fd::RawFd,
232    token: &[u8; 16],
233    socket_path: &Path,
234) -> Result<(), ScmRightsError> {
235    let mut token_payload = *token;
236    let mut iov = libc::iovec {
237        iov_base: token_payload.as_mut_ptr().cast(),
238        iov_len: token_payload.len(),
239    };
240    let mut control = vec![0_u8; cmsg_space::<libc::c_int>()];
241    let mut message = unsafe { std::mem::zeroed::<libc::msghdr>() };
242    message.msg_iov = &mut iov;
243    message.msg_iovlen = 1;
244    message.msg_control = control.as_mut_ptr().cast();
245    message.msg_controllen = control.len() as _;
246
247    unsafe {
248        let header = libc::CMSG_FIRSTHDR(&message);
249        if header.is_null() {
250            return Err(ScmRightsError::SendFailed {
251                fd: sent_fd,
252                socket: socket_path.to_path_buf(),
253                raw_os_error: None,
254            });
255        }
256
257        (*header).cmsg_level = libc::SOL_SOCKET;
258        (*header).cmsg_type = libc::SCM_RIGHTS;
259        (*header).cmsg_len = cmsg_len::<libc::c_int>() as _;
260        *libc::CMSG_DATA(header).cast::<libc::c_int>() = sent_fd;
261    }
262
263    let sent = unsafe { libc::sendmsg(socket_fd, &message, sendmsg_flags()) };
264    if sent < 0 {
265        return Err(sendmsg_error(
266            sent_fd,
267            socket_path,
268            std::io::Error::last_os_error(),
269        ));
270    }
271    if sent as usize != token_payload.len() {
272        return Err(ScmRightsError::PartialSend {
273            fd: sent_fd,
274            socket: socket_path.to_path_buf(),
275            sent_bytes: sent as usize,
276            expected_bytes: token_payload.len(),
277        });
278    }
279
280    Ok(())
281}
282
283#[cfg(unix)]
284fn cmsg_space<T>() -> usize {
285    unsafe { libc::CMSG_SPACE(std::mem::size_of::<T>() as _) as usize }
286}
287
288#[cfg(unix)]
289fn cmsg_len<T>() -> usize {
290    unsafe { libc::CMSG_LEN(std::mem::size_of::<T>() as _) as usize }
291}
292
293#[cfg(all(unix, any(target_os = "android", target_os = "linux")))]
294fn sendmsg_flags() -> libc::c_int {
295    combine_sendmsg_flags(libc::MSG_DONTWAIT, Some(libc::MSG_NOSIGNAL))
296}
297
298#[cfg(all(unix, not(any(target_os = "android", target_os = "linux"))))]
299fn sendmsg_flags() -> libc::c_int {
300    combine_sendmsg_flags(libc::MSG_DONTWAIT, None)
301}
302
303#[cfg(any(test, unix))]
304fn combine_sendmsg_flags(dontwait: libc::c_int, nosignal: Option<libc::c_int>) -> libc::c_int {
305    dontwait | nosignal.unwrap_or(0)
306}
307
308#[cfg(unix)]
309fn socket_connect_error(socket: &Path, error: std::io::Error) -> ScmRightsError {
310    match error.kind() {
311        std::io::ErrorKind::PermissionDenied => ScmRightsError::PermissionDenied {
312            fd: -1,
313            socket: socket.to_path_buf(),
314        },
315        std::io::ErrorKind::WouldBlock => ScmRightsError::WouldBlock {
316            socket: socket.to_path_buf(),
317        },
318        _ => ScmRightsError::BackendSocketUnavailable {
319            socket: socket.to_path_buf(),
320        },
321    }
322}
323
324#[cfg(unix)]
325fn sendmsg_error(fd: std::os::fd::RawFd, socket: &Path, error: std::io::Error) -> ScmRightsError {
326    // Unix may report ENOBUFS rather than EAGAIN when a send queue is
327    // saturated. macOS also reports EMSGSIZE for this fixed-size ancillary
328    // message after the Unix stream is pre-saturated. Both are transient
329    // backpressure here: sendmsg returned -1 and accepted no descriptor.
330    let raw_os_error = error.raw_os_error();
331    let transient_backpressure = raw_os_error == Some(libc::ENOBUFS)
332        || cfg!(target_os = "macos") && raw_os_error == Some(libc::EMSGSIZE);
333    if transient_backpressure {
334        return ScmRightsError::WouldBlock {
335            socket: socket.to_path_buf(),
336        };
337    }
338    match error.kind() {
339        std::io::ErrorKind::PermissionDenied => ScmRightsError::PermissionDenied {
340            fd,
341            socket: socket.to_path_buf(),
342        },
343        std::io::ErrorKind::WouldBlock => ScmRightsError::WouldBlock {
344            socket: socket.to_path_buf(),
345        },
346        std::io::ErrorKind::ConnectionRefused
347        | std::io::ErrorKind::ConnectionReset
348        | std::io::ErrorKind::BrokenPipe
349        | std::io::ErrorKind::NotConnected => ScmRightsError::BackendSocketUnavailable {
350            socket: socket.to_path_buf(),
351        },
352        _ => ScmRightsError::SendFailed {
353            fd,
354            socket: socket.to_path_buf(),
355            raw_os_error: error.raw_os_error(),
356        },
357    }
358}
359
360impl ScmRightsError {
361    /// Return the existing attempt-failure classification, when this was a real attempt.
362    pub fn attempt_failure(&self) -> Option<HandoffAttemptFailure> {
363        match self {
364            Self::UnsupportedPlatform => None,
365            Self::PermissionDenied { .. } => Some(HandoffAttemptFailure::PermissionDenied),
366            Self::BackendSocketUnavailable { .. }
367            | Self::WouldBlock { .. }
368            | Self::SendFailed { .. }
369            | Self::PartialSend { .. }
370            | Self::BackendAckTimeout { .. } => Some(HandoffAttemptFailure::BackendAckTimeout),
371        }
372    }
373
374    /// Map this transport failure into the existing fallback reason vocabulary.
375    pub fn fallback_reason(&self) -> HandoffFallbackReason {
376        match self.attempt_failure() {
377            Some(failure) => failure.into(),
378            None => HandoffFallbackReason::ServicePolicyDisabled,
379        }
380    }
381
382    /// Return the silent reconnect fallback for this transport failure.
383    pub fn fallback_decision(&self) -> HandoffFallbackDecision {
384        HandoffFallbackDecision::new(self.fallback_reason())
385    }
386
387    /// Return the full attempt decision for callers that operate on broker decisions.
388    pub fn fallback_attempt_decision(&self) -> HandoffAttemptDecision {
389        HandoffAttemptDecision::FallbackToReconnect(self.fallback_decision())
390    }
391
392    /// Return true when this error is safe to hide behind reconnect fallback.
393    pub fn is_fallback_safe(&self) -> bool {
394        let fallback = self.fallback_decision();
395        fallback.uses_backend_reconnect() && !fallback.sends_client_error()
396    }
397
398    /// Return true when the backend may already own the duplicated descriptor.
399    ///
400    /// Stream sockets attach `SCM_RIGHTS` to the first delivered byte, so a
401    /// positive short send is indeterminate even though the complete token was
402    /// not delivered. The orchestrator revokes that token before fallback.
403    pub fn fd_may_have_reached_backend(&self) -> bool {
404        matches!(self, Self::PartialSend { sent_bytes, .. } if *sent_bytes > 0)
405    }
406}
407
408#[cfg(test)]
409mod platform_neutral_tests {
410    use super::{combine_sendmsg_flags, ScmRightsError};
411
412    #[test]
413    fn sendmsg_flags_always_include_per_call_nonblocking() {
414        const MOCK_DONTWAIT: i32 = 0b0010;
415        const MOCK_NOSIGNAL: i32 = 0b1000;
416
417        assert_ne!(
418            combine_sendmsg_flags(MOCK_DONTWAIT, Some(MOCK_NOSIGNAL)) & MOCK_DONTWAIT,
419            0
420        );
421        assert_ne!(
422            combine_sendmsg_flags(MOCK_DONTWAIT, None) & MOCK_DONTWAIT,
423            0
424        );
425    }
426
427    #[test]
428    fn positive_partial_send_tracks_indeterminate_fd_delivery() {
429        let error = ScmRightsError::PartialSend {
430            fd: 7,
431            socket: "handoff".into(),
432            sent_bytes: 1,
433            expected_bytes: 16,
434        };
435
436        assert!(error.fd_may_have_reached_backend());
437        assert!(error.is_fallback_safe());
438    }
439}
440
441#[cfg(all(test, unix))]
442mod tests {
443    use std::fs::File;
444    use std::io::Write;
445    use std::os::fd::{AsRawFd, RawFd};
446    use std::os::unix::net::{UnixListener, UnixStream};
447    use std::sync::mpsc;
448    use std::thread;
449    use std::time::Duration;
450
451    use super::*;
452
453    #[test]
454    fn send_scm_rights_to_backend_socket_transfers_fd_and_token() {
455        let dir = tempfile::tempdir().unwrap();
456        let socket_path = dir.path().join("handoff.sock");
457        let listener = UnixListener::bind(&socket_path).unwrap();
458        let expected_token = HandoffToken::from_bytes([0x41; 16]);
459        let receiver = thread::spawn(move || {
460            let (stream, _) = listener.accept().unwrap();
461            recv_fd_and_token(stream)
462        });
463        let file = File::open("/dev/null").unwrap();
464        let attempt = ScmRightsAttempt::new(
465            UnixFileDescriptor::new(file.as_raw_fd()),
466            UnixHandoffSocket::new(socket_path),
467            expected_token,
468        );
469
470        let success = try_send_scm_rights(&attempt).unwrap();
471        let (received_fd, received_token) = receiver.join().unwrap();
472
473        assert_eq!(success.sent_fd, attempt.fd);
474        assert_eq!(success.handoff_token, expected_token);
475        assert_eq!(received_token, expected_token);
476        assert_ne!(received_fd, file.as_raw_fd());
477
478        unsafe {
479            libc::close(received_fd);
480        }
481    }
482
483    #[test]
484    fn missing_backend_socket_maps_to_fallback_safe_error() {
485        let dir = tempfile::tempdir().unwrap();
486        let socket = UnixHandoffSocket::new(dir.path().join("missing.sock"));
487        let file = File::open("/dev/null").unwrap();
488        let attempt = ScmRightsAttempt::new(
489            UnixFileDescriptor::new(file.as_raw_fd()),
490            socket.clone(),
491            HandoffToken::from_bytes([0x42; 16]),
492        );
493
494        let err = try_send_scm_rights(&attempt).unwrap_err();
495
496        assert!(matches!(
497            err,
498            ScmRightsError::BackendSocketUnavailable { socket: ref path }
499                if path == &socket.path
500        ));
501        assert!(err.is_fallback_safe());
502    }
503
504    #[test]
505    fn ancillary_queue_enobufs_maps_to_silent_would_block() {
506        let socket = Path::new("saturated-handoff");
507        let error = sendmsg_error(
508            7,
509            socket,
510            std::io::Error::from_raw_os_error(libc::ENOBUFS),
511        );
512
513        assert!(matches!(
514            error,
515            ScmRightsError::WouldBlock { socket: ref path } if path == socket
516        ));
517        assert!(error.is_fallback_safe());
518        assert!(!error.fallback_decision().sends_client_error());
519    }
520
521    #[cfg(target_os = "macos")]
522    #[test]
523    fn saturated_ancillary_queue_emsgsize_maps_to_silent_would_block() {
524        let socket = Path::new("saturated-handoff");
525        let error = sendmsg_error(
526            7,
527            socket,
528            std::io::Error::from_raw_os_error(libc::EMSGSIZE),
529        );
530
531        assert!(matches!(
532            error,
533            ScmRightsError::WouldBlock { socket: ref path } if path == socket
534        ));
535        assert!(error.is_fallback_safe());
536        assert!(!error.fallback_decision().sends_client_error());
537    }
538
539    #[test]
540    fn send_scm_rights_over_connected_socket_transfers_fd_and_token() {
541        let (sender, receiver) = UnixStream::pair().unwrap();
542        let file = File::open("/dev/null").unwrap();
543        let token = HandoffToken::from_bytes([0x43; 16]);
544        let socket = UnixHandoffSocket::new("connected-handoff");
545        let attempt = ScmRightsAttempt::new(
546            UnixFileDescriptor::new(file.as_raw_fd()),
547            socket,
548            token,
549        );
550
551        let success = try_send_scm_rights_over(sender.as_raw_fd(), &attempt).unwrap();
552        let (received_fd, received_token) = recv_fd_and_token(receiver);
553
554        assert_eq!(success.sent_fd, attempt.fd);
555        assert_eq!(received_token, token);
556        unsafe {
557            libc::close(received_fd);
558        }
559    }
560
561    #[test]
562    fn saturated_blocking_handoff_socket_returns_silent_would_block_promptly() {
563        let (mut sender, receiver) = UnixStream::pair().unwrap();
564        sender.set_nonblocking(true).unwrap();
565        let fill = [0_u8; 64 * 1024];
566        loop {
567            match sender.write(&fill) {
568                Ok(_) => {}
569                Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break,
570                Err(error) => panic!("fill handoff socket: {error}"),
571            }
572        }
573        sender.set_nonblocking(false).unwrap();
574
575        let file = File::open("/dev/null").unwrap();
576        let socket = UnixHandoffSocket::new("saturated-handoff");
577        let attempt = ScmRightsAttempt::new(
578            UnixFileDescriptor::new(file.as_raw_fd()),
579            socket.clone(),
580            HandoffToken::from_bytes([0x44; 16]),
581        );
582        let (done_tx, done_rx) = mpsc::channel();
583        let send_thread = thread::spawn(move || {
584            let result = try_send_scm_rights_over(sender.as_raw_fd(), &attempt);
585            done_tx.send(result).unwrap();
586        });
587
588        let result = match done_rx.recv_timeout(Duration::from_millis(500)) {
589            Ok(result) => result,
590            Err(error) => {
591                drop(receiver);
592                send_thread.join().unwrap();
593                panic!("SCM_RIGHTS send exceeded nonblocking deadline: {error}");
594            }
595        };
596        drop(receiver);
597        send_thread.join().unwrap();
598
599        let error = result.unwrap_err();
600        assert!(matches!(
601            error,
602            ScmRightsError::WouldBlock { socket: ref path } if path == &socket.path
603        ), "unexpected saturated send result: {error:?}");
604        assert!(error.is_fallback_safe());
605        assert!(!error.fallback_decision().sends_client_error());
606    }
607
608    fn recv_fd_and_token(stream: UnixStream) -> (RawFd, HandoffToken) {
609        let mut token_payload = [0_u8; 16];
610        let mut iov = libc::iovec {
611            iov_base: token_payload.as_mut_ptr().cast(),
612            iov_len: token_payload.len(),
613        };
614        let mut control = vec![0_u8; cmsg_space::<libc::c_int>()];
615        let mut message = unsafe { std::mem::zeroed::<libc::msghdr>() };
616        message.msg_iov = &mut iov;
617        message.msg_iovlen = 1;
618        message.msg_control = control.as_mut_ptr().cast();
619        message.msg_controllen = control.len() as _;
620
621        let received = unsafe { libc::recvmsg(stream.as_raw_fd(), &mut message, 0) };
622        assert_eq!(received as usize, token_payload.len());
623
624        let header = unsafe { libc::CMSG_FIRSTHDR(&message) };
625        assert!(!header.is_null());
626        unsafe {
627            assert_eq!((*header).cmsg_level, libc::SOL_SOCKET);
628            assert_eq!((*header).cmsg_type, libc::SCM_RIGHTS);
629            let received_fd = *libc::CMSG_DATA(header).cast::<libc::c_int>();
630            (received_fd, HandoffToken::from_bytes(token_payload))
631        }
632    }
633}