Skip to main content

agave_scheduling_utils/handshake/
client.rs

1use {
2    crate::handshake::{
3        ClientHandshakeError, ClientLogon, ClientSession, ClientWorkerSession,
4        shared::{LOGON_FAILURE, MAX_WORKERS, VERSION},
5    },
6    libc::CMSG_LEN,
7    nix::sys::socket::{self, ControlMessageOwned, MsgFlags, UnixAddr},
8    rts_alloc::Allocator,
9    std::{
10        fs::File,
11        io::{IoSliceMut, Write},
12        os::{
13            fd::{AsRawFd, FromRawFd},
14            unix::net::UnixStream,
15        },
16        path::Path,
17        time::Duration,
18    },
19};
20
21/// Number of global shared memory objects (in addition to per worker objects).
22const GLOBAL_SHMEM: usize = 3;
23
24/// The maximum size in bytes of the control message containing the queues assuming [`MAX_WORKERS`]
25/// is respected.
26///
27/// Each FD is 4 bytes so we simply multiply the number of shmem objects by 4 to get the control
28/// message buffer size.
29const CMSG_MAX_SIZE: usize = (GLOBAL_SHMEM + MAX_WORKERS * 2) * 4;
30
31/// Connects to the scheduler server on the given IPC path.
32///
33/// # Timeout
34///
35/// Timeout is enforced at the syscall level. In the typical case, this function will do two
36/// syscalls, one to send the logon message and one to receive the response. However, if for
37/// whatever reason the OS does not accept 1024 bytes in a single syscall, then multiple writes
38/// could be needed. As such this timeout is meant to guard against a broken server but not
39/// necessarily ensure this function always returns before the timeout (this is somewhat in line
40/// with typical timeouts because you have no guarantee of being rescheduled).
41pub fn connect(
42    path: impl AsRef<Path>,
43    logon: ClientLogon,
44    timeout: Duration,
45) -> Result<ClientSession, ClientHandshakeError> {
46    connect_path(path.as_ref(), logon, timeout)
47}
48
49fn connect_path(
50    path: &Path,
51    logon: ClientLogon,
52    timeout: Duration,
53) -> Result<ClientSession, ClientHandshakeError> {
54    // NB: Technically this connect call can block indefinitely if the receiver's connection queue
55    // is full. In practice this should almost never happen. If it does work arounds are:
56    //
57    // - Users can spawn off a thread to handle the connect call and then just poll that thread
58    //   exiting.
59    // - This library could drop to raw unix sockets and use select/poll to enforce a timeout on the
60    //   IO operation.
61    let mut stream = UnixStream::connect(path)?;
62    stream.set_read_timeout(Some(timeout))?;
63    stream.set_write_timeout(Some(timeout))?;
64
65    // Send the logon message to the server.
66    send_logon(&mut stream, logon)?;
67
68    // Receive the server's response & on success the files for the newly allocated shared memory.
69    let files = recv_response(&mut stream)?;
70
71    // Join the shared memory regions.
72    let session = setup_session(&logon, files)?;
73
74    Ok(session)
75}
76
77fn send_logon(stream: &mut UnixStream, logon: ClientLogon) -> Result<(), ClientHandshakeError> {
78    // Send the logon message.
79    let mut buf = [0; 1024];
80    buf[..8].copy_from_slice(&VERSION.to_le_bytes());
81    const LOGON_END: usize = 8 + core::mem::size_of::<ClientLogon>();
82    let ptr = buf[8..LOGON_END].as_mut_ptr().cast::<ClientLogon>();
83    // SAFETY:
84    // - `buf` is valid for writes.
85    // - `buf.len()` has enough space for logon's size in memory.
86    unsafe {
87        core::ptr::write_unaligned(ptr, logon);
88    }
89    stream.write_all(&buf)?;
90
91    Ok(())
92}
93
94fn recv_response(stream: &mut UnixStream) -> Result<Vec<File>, ClientHandshakeError> {
95    // Receive the requested FDs.
96    let mut buf = [0; 1024];
97    let mut iov = [IoSliceMut::new(&mut buf)];
98    // SAFETY: CMSG_LEN is always safe (const expression).
99    let mut cmsgs = [0u8; unsafe { CMSG_LEN(CMSG_MAX_SIZE as u32) as usize }];
100    let msg = socket::recvmsg::<UnixAddr>(
101        stream.as_raw_fd(),
102        &mut iov,
103        Some(&mut cmsgs),
104        MsgFlags::empty(),
105    )?;
106
107    // Check for failure.
108    let buf = msg.iovs().next().unwrap();
109    if buf[0] == LOGON_FAILURE {
110        let reason_len = usize::from(buf[1]);
111        #[allow(clippy::arithmetic_side_effects)]
112        let reason = std::str::from_utf8(&buf[2..2 + reason_len]).unwrap();
113
114        return Err(ClientHandshakeError::Rejected(reason.to_string()));
115    }
116
117    // Extract FDs and immediately wrap in `File` for RAII ownership.
118    let mut cmsgs = msg.cmsgs().unwrap();
119    let fds = match cmsgs.next() {
120        Some(ControlMessageOwned::ScmRights(fds)) => fds,
121        Some(msg) => panic!("Unexpected; msg={msg:?}"),
122        None => panic!(),
123    };
124    // SAFETY: FDs were just received via `ScmRights` and are valid.
125    let files = fds
126        .into_iter()
127        .map(|fd| unsafe { File::from_raw_fd(fd) })
128        .collect();
129
130    Ok(files)
131}
132
133pub fn setup_session(
134    logon: &ClientLogon,
135    files: Vec<File>,
136) -> Result<ClientSession, ClientHandshakeError> {
137    if files.len() < GLOBAL_SHMEM {
138        return Err(ClientHandshakeError::ProtocolViolation);
139    }
140    let (global_files, worker_files) = files.split_at(GLOBAL_SHMEM);
141    let [allocator_file, tpu_to_pack_file, progress_tracker_file] = global_files else {
142        unreachable!();
143    };
144
145    // Setup requested allocators.
146    let allocators = (0..logon.allocator_handles)
147        .map(|_| Allocator::join(allocator_file))
148        .collect::<Result<Vec<_>, _>>()?;
149
150    // Ensure worker file count matches expectations.
151    if worker_files.is_empty()
152        || !worker_files.len().is_multiple_of(2)
153        || worker_files.len() / 2 != logon.worker_count
154    {
155        return Err(ClientHandshakeError::ProtocolViolation);
156    }
157
158    // NB: After creating & mapping the queues we are fine to drop the files as mmap will keep the
159    // underlying object alive until process exit or munmap.
160    let session = ClientSession {
161        allocators,
162        tpu_to_pack: unsafe { shaq::spsc::Consumer::join(tpu_to_pack_file)? },
163        progress_tracker: unsafe { shaq::spsc::Consumer::join(progress_tracker_file)? },
164        workers: worker_files
165            .chunks(2)
166            .map(|window| {
167                let [pack_to_worker, worker_to_pack] = window else {
168                    panic!();
169                };
170
171                Ok(ClientWorkerSession {
172                    pack_to_worker: unsafe { shaq::spsc::Producer::join(pack_to_worker)? },
173                    worker_to_pack: unsafe { shaq::spsc::Consumer::join(worker_to_pack)? },
174                })
175            })
176            .collect::<Result<_, ClientHandshakeError>>()?,
177    };
178
179    // Drop the file handles now that mmaps are completed.
180    drop(files);
181
182    Ok(session)
183}
184
185impl From<nix::Error> for ClientHandshakeError {
186    fn from(value: nix::Error) -> Self {
187        Self::Io(value.into())
188    }
189}