Skip to main content

running_process/broker/server/handoff/
unix.rs

1//! Unix `SCM_RIGHTS` handoff transport model.
2//!
3//! This module preserves the public 4.x transport model and maps the selected
4//! platform IPC primitive into the existing silent reconnect fallback policy.
5//! Native `sendmsg(SCM_RIGHTS)` mechanics live in the platform package.
6
7use std::path::PathBuf;
8
9use super::{
10    HandoffAttemptDecision, HandoffAttemptFailure, HandoffFallbackDecision, HandoffFallbackReason,
11    HandoffToken,
12};
13
14/// Whether this build target can eventually use Unix-domain `SCM_RIGHTS`.
15pub const SCM_RIGHTS_TRANSPORT_SUPPORTED: bool =
16    running_process_platform_internal::LEGACY_SCM_RIGHTS_TRANSPORT_SUPPORTED;
17
18/// Opaque raw Unix file descriptor value owned by the broker or backend.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub struct UnixFileDescriptor(i32);
21
22impl UnixFileDescriptor {
23    /// Build an opaque file descriptor value for transport bookkeeping.
24    pub fn new(raw_fd: i32) -> Self {
25        Self(raw_fd)
26    }
27
28    /// Return the raw opaque file descriptor value.
29    pub fn raw(self) -> i32 {
30        self.0
31    }
32}
33
34/// Backend Unix-domain socket that will receive `SCM_RIGHTS` messages.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct UnixHandoffSocket {
37    /// Filesystem path or platform socket path for the backend handoff socket.
38    pub path: PathBuf,
39}
40
41impl UnixHandoffSocket {
42    /// Build a backend handoff socket descriptor.
43    pub fn new(path: impl Into<PathBuf>) -> Self {
44        Self { path: path.into() }
45    }
46}
47
48/// Inputs for one future `sendmsg(SCM_RIGHTS)` attempt.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct ScmRightsAttempt {
51    /// Broker-owned connection file descriptor to pass.
52    pub fd: UnixFileDescriptor,
53    /// Backend handoff socket that should receive the file descriptor.
54    pub backend_socket: UnixHandoffSocket,
55    /// One-time token associated with this handoff attempt.
56    pub handoff_token: HandoffToken,
57}
58
59impl ScmRightsAttempt {
60    /// Build typed inputs for one `SCM_RIGHTS` attempt.
61    pub fn new(
62        fd: UnixFileDescriptor,
63        backend_socket: UnixHandoffSocket,
64        handoff_token: HandoffToken,
65    ) -> Self {
66        Self {
67            fd,
68            backend_socket,
69            handoff_token,
70        }
71    }
72}
73
74/// Successful `SCM_RIGHTS` outcome once real fd passing is wired.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct ScmRightsSuccess {
77    /// File descriptor value sent to the backend.
78    pub sent_fd: UnixFileDescriptor,
79    /// Backend handoff socket that received the file descriptor.
80    pub backend_socket: UnixHandoffSocket,
81    /// One-time token paired with the sent file descriptor.
82    pub handoff_token: HandoffToken,
83}
84
85impl ScmRightsSuccess {
86    /// Build a typed successful handoff result.
87    pub fn new(
88        sent_fd: UnixFileDescriptor,
89        backend_socket: UnixHandoffSocket,
90        handoff_token: HandoffToken,
91    ) -> Self {
92        Self {
93            sent_fd,
94            backend_socket,
95            handoff_token,
96        }
97    }
98}
99
100/// Result returned by the future Unix transport.
101pub type ScmRightsResult = Result<ScmRightsSuccess, ScmRightsError>;
102
103/// Try to send the broker-held file descriptor to the backend handoff socket.
104///
105/// The sent file descriptor remains owned by the broker. The backend receives
106/// a duplicate descriptor through `SCM_RIGHTS` and must verify the paired
107/// [`HandoffToken`] before treating the connection as adopted.
108pub fn try_send_scm_rights(attempt: &ScmRightsAttempt) -> ScmRightsResult {
109    running_process_platform_internal::legacy_send_fd_to(
110        &attempt.backend_socket.path,
111        attempt.fd.raw(),
112        attempt.handoff_token.as_bytes(),
113    )
114    .map_err(|error| legacy_error(attempt, error, true))?;
115    Ok(ScmRightsSuccess::new(
116        attempt.fd,
117        attempt.backend_socket.clone(),
118        attempt.handoff_token,
119    ))
120}
121
122/// Send the broker-held file descriptor and token over an already-connected
123/// Unix-domain handoff socket.
124///
125/// [`try_send_scm_rights`] dials a fresh connection per attempt; the
126/// production serve path instead reuses the framed broker↔backend handoff
127/// connection so the `SCM_RIGHTS` message and the [`HandoffOffer`
128/// frame](crate::broker::protocol::HandoffOffer) travel over the same
129/// stream. The caller keeps ownership of both descriptors.
130pub fn try_send_scm_rights_over(socket_fd: i32, attempt: &ScmRightsAttempt) -> ScmRightsResult {
131    running_process_platform_internal::legacy_send_fd_over(
132        socket_fd,
133        attempt.fd.raw(),
134        attempt.handoff_token.as_bytes(),
135    )
136    .map_err(|error| legacy_error(attempt, error, false))?;
137    Ok(ScmRightsSuccess::new(
138        attempt.fd,
139        attempt.backend_socket.clone(),
140        attempt.handoff_token,
141    ))
142}
143
144fn legacy_error(
145    attempt: &ScmRightsAttempt,
146    error: running_process_platform_internal::LegacyHandoffError,
147    connecting: bool,
148) -> ScmRightsError {
149    use running_process_platform_internal::platform::ipc::HandoffTransferErrorKind;
150
151    if let Some((sent_bytes, expected_bytes)) = error.partial_counts() {
152        return ScmRightsError::PartialSend {
153            fd: attempt.fd.raw(),
154            socket: attempt.backend_socket.path.clone(),
155            sent_bytes,
156            expected_bytes,
157        };
158    }
159    match error.kind() {
160        HandoffTransferErrorKind::Unsupported => ScmRightsError::UnsupportedPlatform,
161        HandoffTransferErrorKind::PermissionDenied => ScmRightsError::PermissionDenied {
162            fd: if connecting { -1 } else { attempt.fd.raw() },
163            socket: attempt.backend_socket.path.clone(),
164        },
165        HandoffTransferErrorKind::BackendUnavailable => ScmRightsError::BackendSocketUnavailable {
166            socket: attempt.backend_socket.path.clone(),
167        },
168        HandoffTransferErrorKind::WouldBlock => ScmRightsError::WouldBlock {
169            socket: attempt.backend_socket.path.clone(),
170        },
171        HandoffTransferErrorKind::Failed => ScmRightsError::SendFailed {
172            fd: attempt.fd.raw(),
173            socket: attempt.backend_socket.path.clone(),
174            raw_os_error: error.raw_os_error(),
175        },
176    }
177}
178
179/// Failure from a future `sendmsg(SCM_RIGHTS)` handoff attempt.
180#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
181pub enum ScmRightsError {
182    /// The current target cannot use the Unix handoff transport.
183    #[error("SCM_RIGHTS handoff transport is unsupported on this platform")]
184    UnsupportedPlatform,
185    /// The platform denied file descriptor passing.
186    #[error("permission denied passing fd {fd} to backend handoff socket {socket}")]
187    PermissionDenied {
188        /// File descriptor targeted by the handoff.
189        fd: i32,
190        /// Backend handoff socket path.
191        socket: PathBuf,
192    },
193    /// The backend handoff socket could not be reached.
194    #[error("backend handoff socket is unavailable: {socket}")]
195    BackendSocketUnavailable {
196        /// Backend handoff socket path.
197        socket: PathBuf,
198    },
199    /// The nonblocking `SCM_RIGHTS` send could not complete immediately.
200    #[error("SCM_RIGHTS send would block for backend handoff socket {socket}")]
201    WouldBlock {
202        /// Backend handoff socket path.
203        socket: PathBuf,
204    },
205    /// The `sendmsg(SCM_RIGHTS)` call failed after connecting to the backend socket.
206    #[error("SCM_RIGHTS send failed for fd {fd} to backend handoff socket {socket}")]
207    SendFailed {
208        /// File descriptor targeted by the handoff.
209        fd: i32,
210        /// Backend handoff socket path.
211        socket: PathBuf,
212        /// Raw OS error code returned by the platform, when available.
213        raw_os_error: Option<i32>,
214    },
215    /// Descriptor transfer succeeded but the follow-up protocol offer failed.
216    #[error("handoff offer delivery failed after passing fd {fd} to backend socket {socket}")]
217    PostTransferDeliveryFailed {
218        /// File descriptor targeted by the handoff.
219        fd: i32,
220        /// Backend handoff socket path.
221        socket: PathBuf,
222    },
223    /// Some token bytes were sent, so the descriptor may have reached the backend.
224    #[error(
225        "SCM_RIGHTS send was partial ({sent_bytes}/{expected_bytes} bytes) for fd {fd} to backend handoff socket {socket}"
226    )]
227    PartialSend {
228        /// File descriptor targeted by the handoff.
229        fd: i32,
230        /// Backend handoff socket path.
231        socket: PathBuf,
232        /// Token bytes accepted by the socket.
233        sent_bytes: usize,
234        /// Complete token length required by the protocol.
235        expected_bytes: usize,
236    },
237    /// The backend did not acknowledge the passed file descriptor before the deadline.
238    #[error("backend handoff socket {socket} did not acknowledge passed fd")]
239    BackendAckTimeout {
240        /// Backend handoff socket path.
241        socket: PathBuf,
242    },
243}
244
245impl ScmRightsError {
246    /// Return the existing attempt-failure classification, when this was a real attempt.
247    pub fn attempt_failure(&self) -> Option<HandoffAttemptFailure> {
248        match self {
249            Self::UnsupportedPlatform => None,
250            Self::PermissionDenied { .. } => Some(HandoffAttemptFailure::PermissionDenied),
251            Self::BackendSocketUnavailable { .. }
252            | Self::WouldBlock { .. }
253            | Self::SendFailed { .. }
254            | Self::PostTransferDeliveryFailed { .. }
255            | Self::PartialSend { .. }
256            | Self::BackendAckTimeout { .. } => Some(HandoffAttemptFailure::BackendAckTimeout),
257        }
258    }
259
260    /// Map this transport failure into the existing fallback reason vocabulary.
261    pub fn fallback_reason(&self) -> HandoffFallbackReason {
262        match self.attempt_failure() {
263            Some(failure) => failure.into(),
264            None => HandoffFallbackReason::ServicePolicyDisabled,
265        }
266    }
267
268    /// Return the silent reconnect fallback for this transport failure.
269    pub fn fallback_decision(&self) -> HandoffFallbackDecision {
270        HandoffFallbackDecision::new(self.fallback_reason())
271    }
272
273    /// Return the full attempt decision for callers that operate on broker decisions.
274    pub fn fallback_attempt_decision(&self) -> HandoffAttemptDecision {
275        HandoffAttemptDecision::FallbackToReconnect(self.fallback_decision())
276    }
277
278    /// Return true when this error is safe to hide behind reconnect fallback.
279    pub fn is_fallback_safe(&self) -> bool {
280        let fallback = self.fallback_decision();
281        fallback.uses_backend_reconnect() && !fallback.sends_client_error()
282    }
283
284    /// Return true when the backend may already own the duplicated descriptor.
285    ///
286    /// Stream sockets attach `SCM_RIGHTS` to the first delivered byte, so a
287    /// positive short send is indeterminate even though the complete token was
288    /// not delivered. The orchestrator revokes that token before fallback.
289    pub fn fd_may_have_reached_backend(&self) -> bool {
290        matches!(self, Self::PostTransferDeliveryFailed { .. })
291            || matches!(self, Self::PartialSend { sent_bytes, .. } if *sent_bytes > 0)
292    }
293}
294
295#[cfg(test)]
296mod platform_neutral_tests {
297    use super::ScmRightsError;
298
299    #[test]
300    fn positive_partial_send_tracks_indeterminate_fd_delivery() {
301        let error = ScmRightsError::PartialSend {
302            fd: 7,
303            socket: "handoff".into(),
304            sent_bytes: 1,
305            expected_bytes: 16,
306        };
307
308        assert!(error.fd_may_have_reached_backend());
309        assert!(error.is_fallback_safe());
310    }
311
312    #[test]
313    fn failed_offer_after_transfer_tracks_backend_ownership() {
314        let error = ScmRightsError::PostTransferDeliveryFailed {
315            fd: 7,
316            socket: "handoff".into(),
317        };
318
319        assert!(error.fd_may_have_reached_backend());
320        assert!(error.is_fallback_safe());
321    }
322}