Skip to main content

kunobi_daemon/local/
unix_socket.rs

1//! Unix listener acquisition and guarded endpoint cleanup.
2use crate::ProcessLock;
3use std::{
4    io,
5    os::unix::{
6        fs::{FileTypeExt, MetadataExt, PermissionsExt},
7        net::UnixListener,
8    },
9    path::{Path, PathBuf},
10    time::Duration,
11};
12
13pub use super::Bound;
14/// Failed acquisition, preserving filesystem errors separately from contention.
15#[derive(Debug)]
16#[non_exhaustive]
17pub enum BindError {
18    /// Failed to prepare the private parent directory.
19    Directory(io::Error),
20    /// Failed to bind, acquire the serialization lock, or probe the existing socket.
21    Bind(io::Error),
22    /// Failed to remove a proven stale socket.
23    Unlink(io::Error),
24}
25/// Longest socket path, in bytes, that fits in a Unix socket address on this
26/// platform: `sockaddr_un.sun_path` less the NUL that terminates it.
27pub const MAX_PATH_BYTES: usize = match crate::socket_path::MAX_SOCKET_PATH_BYTES {
28    Some(limit) => limit,
29    None => unreachable!(),
30};
31
32/// Fails when `socket` is too long to bind or connect to as a Unix socket.
33///
34/// The OS reports this only as an invalid argument, naming neither the length
35/// nor the limit, and a deep application directory hits it without warning.
36/// Prefer [`crate::socket_path::SocketDir`], which checks a directory against
37/// every socket it will hold before any of them is created.
38pub fn check_path(socket: &Path) -> io::Result<()> {
39    crate::socket_path::check_socket_path(socket)
40}
41fn lock_path(path: &Path) -> PathBuf {
42    let mut path = path.as_os_str().to_owned();
43    path.push(".bind.lock");
44    path.into()
45}
46/// Prepare an application-owned directory before creating its socket.
47pub use crate::ensure_private_dir;
48fn bind_private(socket: &Path) -> io::Result<UnixListener> {
49    // The 0700 parent excludes other users during bind; never alter global umask.
50    let listener = crate::spawn_lock::without_spawns(|| UnixListener::bind(socket))?;
51    std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
52    Ok(listener)
53}
54/// Serialize stale-socket recovery. Only ConnectionRefused permits reclaiming
55/// an existing socket; permission failures and unrelated files are preserved.
56///
57/// # Spawning while binding
58///
59/// Linux creates the socket close-on-exec in one step. macOS takes two, and a
60/// child spawned by another thread between them inherits the listener. It
61/// keeps accepting for the child's whole life, after the returned listener is
62/// dropped or this process exits, so a successor gets `AlreadyRunning`.
63///
64/// Every spawn this crate makes waits while the socket is created:
65/// [`super::unix::spawn_with_child_waiting`], the `launch` spawns built on it,
66/// and [`crate::warmup`]. Other spawns can still capture it. A process that
67/// spawns another way while it may be binding should have each child close,
68/// or mark close-on-exec, every descriptor above stderr before it execs.
69pub fn acquire(socket: &Path) -> Result<Bound<UnixListener>, BindError> {
70    // Before creating the directory or taking the lock: a path that cannot be
71    // bound should leave nothing behind.
72    check_path(socket).map_err(BindError::Bind)?;
73    ensure_private_dir(
74        socket
75            .parent()
76            .ok_or_else(|| BindError::Directory(io::ErrorKind::InvalidInput.into()))?,
77    )
78    .map_err(BindError::Directory)?;
79    let Some(_lock) = ProcessLock::try_acquire(lock_path(socket)).map_err(BindError::Bind)? else {
80        return Ok(Bound::AlreadyRunning);
81    };
82    match bind_private(socket) {
83        Ok(listener) => return Ok(Bound::Won(listener)),
84        Err(error) if error.kind() == io::ErrorKind::AddrInUse => {}
85        Err(error) => return Err(BindError::Bind(error)),
86    }
87    let metadata = std::fs::symlink_metadata(socket).map_err(BindError::Bind)?;
88    if !metadata.file_type().is_socket() {
89        return Err(BindError::Bind(io::ErrorKind::AlreadyExists.into()));
90    }
91    let probe = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)
92        .map_err(BindError::Bind)?;
93    let address = socket2::SockAddr::unix(socket).map_err(BindError::Bind)?;
94    match probe.connect_timeout(&address, Duration::from_millis(250)) {
95        Ok(()) => return Ok(Bound::AlreadyRunning),
96        Err(error)
97            if matches!(
98                error.kind(),
99                io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
100            ) =>
101        {
102            return Ok(Bound::AlreadyRunning);
103        }
104        Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {}
105        Err(error) => return Err(BindError::Bind(error)),
106    }
107    std::fs::remove_file(socket).map_err(BindError::Unlink)?;
108    bind_private(socket)
109        .map(Bound::Won)
110        .map_err(BindError::Bind)
111}
112
113/// Remove only the socket inode this handle observed, under the bind lock.
114/// An exiting generation cannot unlink an endpoint created by its successor.
115pub struct SocketGuard {
116    path: PathBuf,
117    device: u64,
118    inode: u64,
119}
120impl SocketGuard {
121    /// Capture immediately after binding, before publishing discovery.
122    pub fn capture(path: &Path) -> io::Result<Self> {
123        let metadata = std::fs::symlink_metadata(path)?;
124        if !metadata.file_type().is_socket() {
125            return Err(io::ErrorKind::InvalidInput.into());
126        }
127        Ok(Self {
128            path: path.to_owned(),
129            device: metadata.dev(),
130            inode: metadata.ino(),
131        })
132    }
133}
134impl Drop for SocketGuard {
135    fn drop(&mut self) {
136        let Ok(Some(_lock)) = ProcessLock::try_acquire(lock_path(&self.path)) else {
137            return;
138        };
139        if std::fs::symlink_metadata(&self.path).is_ok_and(|metadata| {
140            metadata.file_type().is_socket()
141                && metadata.dev() == self.device
142                && metadata.ino() == self.inode
143        }) {
144            let _ = std::fs::remove_file(&self.path);
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::io::Write;
153
154    fn temp_dir(name: &str) -> std::path::PathBuf {
155        let mut p = std::env::temp_dir();
156        p.push(format!("daemon-single-{}-{name}", std::process::id()));
157        let _ = std::fs::remove_dir_all(&p);
158        p
159    }
160
161    #[test]
162    fn a_path_fits_up_to_the_limit_and_not_one_byte_more() {
163        let fits = format!("/{}", "a".repeat(MAX_PATH_BYTES - 1));
164        assert_eq!(fits.len(), MAX_PATH_BYTES);
165        check_path(Path::new(&fits)).unwrap();
166
167        let error = check_path(Path::new(&format!("{fits}b"))).unwrap_err();
168        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
169        let message = error.to_string();
170        assert!(
171            message.contains(&format!(
172                "{} bytes, but a Unix socket path can be at most {MAX_PATH_BYTES}",
173                MAX_PATH_BYTES + 1
174            )),
175            "{message}"
176        );
177    }
178
179    #[test]
180    fn the_limit_matches_this_platform_socket_address() {
181        let expected = if cfg!(any(target_os = "linux", target_os = "android")) {
182            107
183        } else {
184            103
185        };
186        assert_eq!(MAX_PATH_BYTES, expected);
187    }
188
189    #[test]
190    fn the_limit_is_the_one_the_os_enforces() {
191        let dir = temp_dir("limit");
192        std::fs::create_dir_all(&dir).unwrap();
193        let prefix = dir.join("s").as_os_str().len();
194        // Pad the name so the whole path lands exactly on the limit, then one past it.
195        let at_limit = dir.join(format!("s{}", "a".repeat(MAX_PATH_BYTES - prefix)));
196        assert_eq!(at_limit.as_os_str().len(), MAX_PATH_BYTES);
197        UnixListener::bind(&at_limit).expect("the limit itself binds");
198        let past = dir.join(format!("s{}", "a".repeat(MAX_PATH_BYTES - prefix + 1)));
199        assert!(
200            UnixListener::bind(&past).is_err(),
201            "one byte past the limit must not bind"
202        );
203        let _ = std::fs::remove_dir_all(&dir);
204    }
205
206    #[test]
207    fn an_overlong_path_is_refused_before_anything_is_created() {
208        let dir = temp_dir("overlong");
209        let sock = dir.join("a".repeat(MAX_PATH_BYTES));
210        match acquire(&sock) {
211            Err(BindError::Bind(error)) => {
212                assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
213                assert!(error.to_string().contains("at most"), "{error}");
214            }
215            other => panic!("expected a bind error, got {other:?}"),
216        }
217        assert!(
218            !dir.exists(),
219            "no directory may be created for a path that cannot bind"
220        );
221    }
222
223    #[test]
224    fn the_first_binder_wins() {
225        let dir = temp_dir("first");
226        let sock = dir.join("b.sock");
227        match acquire(&sock).unwrap() {
228            Bound::Won(_l) => {}
229            other => panic!("expected to win, got {other:?}"),
230        }
231        let _ = std::fs::remove_dir_all(&dir);
232    }
233
234    #[test]
235    fn a_second_binder_stands_down_while_the_first_is_listening() {
236        let dir = temp_dir("second");
237        let sock = dir.join("b.sock");
238        let _first = match acquire(&sock).unwrap() {
239            Bound::Won(l) => l,
240            other => panic!("first should win, got {other:?}"),
241        };
242
243        match acquire(&sock).unwrap() {
244            Bound::AlreadyRunning => {}
245            other => panic!("second should stand down, got {other:?}"),
246        }
247
248        let _ = std::fs::remove_dir_all(&dir);
249    }
250
251    #[test]
252    fn a_stale_socket_left_by_a_dead_broker_is_reclaimed() {
253        // Without this the system is permanently brokerless after any crash:
254        // the path exists so `bind` fails, but nothing is listening so no client
255        // can ever connect.
256        let dir = temp_dir("stale");
257        std::fs::create_dir_all(&dir).unwrap();
258        let sock = dir.join("b.sock");
259
260        // Leave a socket inode that nothing listens on, as a killed process
261        // does. Bind without listen: a child spawned concurrently by another
262        // test inherits every descriptor this process holds until it execs or
263        // exits, and a listening socket it held would accept the probe below,
264        // so the test failed intermittently with AlreadyRunning. A socket that
265        // never listened refuses a connection whoever holds it.
266        {
267            let never_listened =
268                socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None).unwrap();
269            never_listened
270                .bind(&socket2::SockAddr::unix(&sock).unwrap())
271                .unwrap();
272        }
273        assert!(sock.exists(), "fixture should leave the inode behind");
274
275        match acquire(&sock).unwrap() {
276            Bound::Won(_l) => {}
277            other => panic!("a stale socket should be reclaimed, got {other:?}"),
278        }
279
280        let _ = std::fs::remove_dir_all(&dir);
281    }
282
283    #[test]
284    fn a_live_broker_is_never_unlinked() {
285        // The dangerous mistake: unlinking on EADDRINUSE alone. That races a
286        // healthy broker and silently detaches every client attached to it.
287        let dir = temp_dir("nounlink");
288        let sock = dir.join("b.sock");
289        let _first = match acquire(&sock).unwrap() {
290            Bound::Won(l) => l,
291            other => panic!("{other:?}"),
292        };
293
294        let before = std::fs::metadata(&sock).unwrap();
295        let _ = acquire(&sock).unwrap();
296        let after = std::fs::metadata(&sock).unwrap();
297
298        use std::os::unix::fs::MetadataExt;
299        assert_eq!(
300            before.ino(),
301            after.ino(),
302            "the live broker's socket was replaced"
303        );
304
305        let _ = std::fs::remove_dir_all(&dir);
306    }
307
308    #[test]
309    fn a_regular_file_at_the_socket_path_is_preserved() {
310        // An unrelated regular file must survive a failed acquisition.
311        let dir = temp_dir("regular");
312        std::fs::create_dir_all(&dir).unwrap();
313        let sock = dir.join("b.sock");
314        let mut f = std::fs::File::create(&sock).unwrap();
315        f.write_all(b"not a socket").unwrap();
316        drop(f);
317
318        assert!(acquire(&sock).is_err());
319        assert_eq!(std::fs::read(&sock).unwrap(), b"not a socket");
320
321        let _ = std::fs::remove_dir_all(&dir);
322    }
323
324    #[test]
325    fn the_socket_is_owner_only() {
326        let dir = temp_dir("perms");
327        let sock = dir.join("b.sock");
328        let _l = match acquire(&sock).unwrap() {
329            Bound::Won(l) => l,
330            other => panic!("{other:?}"),
331        };
332
333        let mode = std::fs::metadata(&sock).unwrap().permissions().mode() & 0o777;
334        assert_eq!(mode, 0o600, "socket is 0o{mode:o}, expected 0o600");
335
336        let _ = std::fs::remove_dir_all(&dir);
337    }
338
339    #[test]
340    fn the_parent_directory_is_owner_only_even_if_it_already_existed() {
341        // `create_dir_all` is a no-op on an existing directory, so a directory
342        // created by an older build - or by a user - would keep its permissions
343        // and never be checked.
344        let dir = temp_dir("dirperms");
345        std::fs::create_dir_all(&dir).unwrap();
346        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
347
348        let sock = dir.join("b.sock");
349        let _l = acquire(&sock).unwrap();
350
351        let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
352        assert_eq!(mode, 0o700, "directory left at 0o{mode:o}");
353
354        let _ = std::fs::remove_dir_all(&dir);
355    }
356
357    #[test]
358    fn many_concurrent_acquirers_produce_exactly_one_winner() {
359        // The thundering herd: N relays start at once against no broker. Exactly
360        // one must win; every other must stand down cleanly rather than error.
361        use std::sync::Arc;
362        use std::sync::atomic::{AtomicUsize, Ordering};
363
364        let dir = temp_dir("herd");
365        std::fs::create_dir_all(&dir).unwrap();
366        let sock = Arc::new(dir.join("b.sock"));
367
368        let won = Arc::new(AtomicUsize::new(0));
369        let stood_down = Arc::new(AtomicUsize::new(0));
370        let failed = Arc::new(AtomicUsize::new(0));
371
372        let acquired = Arc::new(std::sync::Barrier::new(15));
373        let handles: Vec<_> = (0..15)
374            .map(|_| {
375                let sock = Arc::clone(&sock);
376                let won = Arc::clone(&won);
377                let stood_down = Arc::clone(&stood_down);
378                let failed = Arc::clone(&failed);
379                let acquired = Arc::clone(&acquired);
380                std::thread::spawn(move || {
381                    let outcome = acquire(&sock);
382                    match &outcome {
383                        Ok(Bound::Won(_)) => {
384                            won.fetch_add(1, Ordering::SeqCst);
385                        }
386                        Ok(Bound::AlreadyRunning) => {
387                            stood_down.fetch_add(1, Ordering::SeqCst);
388                        }
389                        Err(_) => {
390                            failed.fetch_add(1, Ordering::SeqCst);
391                        }
392                    }
393                    // Keep the winning listener alive until every contender
394                    // has inspected it, regardless of how it is scheduled.
395                    acquired.wait();
396                    drop(outcome);
397                })
398            })
399            .collect();
400        for h in handles {
401            h.join().unwrap();
402        }
403
404        assert_eq!(failed.load(Ordering::SeqCst), 0, "some acquirers errored");
405        assert_eq!(
406            won.load(Ordering::SeqCst),
407            1,
408            "expected exactly one winner, got {} (stood down: {})",
409            won.load(Ordering::SeqCst),
410            stood_down.load(Ordering::SeqCst)
411        );
412        assert_eq!(stood_down.load(Ordering::SeqCst), 14);
413
414        let _ = std::fs::remove_dir_all(&dir);
415    }
416}