Skip to main content

winx_code_agent/daemon/
transport.rs

1//! Control-plane transport: Unix-domain sockets on Unix, named pipes on Windows.
2//!
3//! The JSON-RPC layer only sees [`DaemonStream`] (an `AsyncRead + AsyncWrite`
4//! value) and [`DaemonListener`]. On Windows the "socket path" keeps its role
5//! as the session identity: the listener creates a marker file at that path
6//! and derives the pipe name from it, so every lifecycle routine that scans,
7//! probes, or removes `*.sock` files works unchanged on both platforms.
8
9use std::io;
10use std::path::{Path, PathBuf};
11#[cfg(unix)]
12use std::time::{Duration, Instant};
13
14#[cfg(unix)]
15pub use unix_impl::{connect, peer_is_same_user, DaemonListener, DaemonStream};
16#[cfg(windows)]
17pub use windows_impl::{connect, peer_is_same_user, pipe_name, DaemonListener, DaemonStream};
18
19/// Restrict a filesystem artifact to its owner. Unix applies `mode`; Windows
20/// relies on the per-user ACL that `%LOCALAPPDATA%` already carries.
21#[cfg_attr(not(unix), allow(clippy::unused_async))] // only the Unix branch awaits
22pub async fn restrict_to_owner(path: &Path, mode: u32) -> io::Result<()> {
23    #[cfg(unix)]
24    {
25        use std::os::unix::fs::PermissionsExt as _;
26        tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).await
27    }
28    #[cfg(not(unix))]
29    {
30        let _ = (path, mode);
31        Ok(())
32    }
33}
34
35/// Lock file that serializes daemons claiming `socket`.
36pub fn lock_path(socket: &Path) -> PathBuf {
37    let mut name = socket.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
38    name.push(".lock");
39    socket.with_file_name(name)
40}
41
42/// How long a claim keeps retrying a busy lock before giving up. A lock can
43/// stay held for a few milliseconds after its owner released it: `flock`
44/// follows the open file description, and a child forked while the lock was
45/// open keeps that description until it execs. A genuine second daemon is
46/// still refused, just a quarter second later.
47#[cfg(unix)]
48const CLAIM_RETRY_WINDOW: Duration = Duration::from_millis(250);
49
50/// Open `path` owner-only and take the exclusive advisory lock, failing with
51/// `AddrInUse` when another process still holds it after the retry window.
52#[cfg(unix)]
53async fn claim_lock(path: &Path) -> io::Result<std::fs::File> {
54    use std::os::unix::fs::OpenOptionsExt as _;
55    let deadline = Instant::now() + CLAIM_RETRY_WINDOW;
56    loop {
57        let file = std::fs::OpenOptions::new()
58            .read(true)
59            .write(true)
60            .create(true)
61            .truncate(false)
62            .mode(0o600)
63            .open(path)?;
64        if crate::os::unix::try_lock_exclusive(&file)? {
65            return Ok(file);
66        }
67        if Instant::now() >= deadline {
68            return Err(io::Error::new(
69                io::ErrorKind::AddrInUse,
70                format!("another daemon is starting or listening (lock {})", path.display()),
71            ));
72        }
73        drop(file);
74        tokio::time::sleep(Duration::from_millis(10)).await;
75    }
76}
77
78#[cfg(unix)]
79mod unix_impl {
80    use std::io;
81    use std::path::Path;
82
83    use tokio::net::{UnixListener, UnixStream};
84
85    pub type DaemonStream = UnixStream;
86
87    pub struct DaemonListener {
88        inner: UnixListener,
89        /// Held for the listener's lifetime; see [`DaemonListener::bind`].
90        _lock: std::fs::File,
91    }
92
93    impl DaemonListener {
94        /// Claim `path` for this process, replacing a stale socket left by a
95        /// dead daemon but never stealing a live one.
96        ///
97        /// Two daemons started at the same moment both see the same stale
98        /// socket; without coordination the second one unlinks the first
99        /// one's freshly bound socket and both end up listening, one of them
100        /// unreachable. An exclusive advisory lock on `<path>.lock` makes the
101        /// existence check and the bind one atomic step: the loser fails with
102        /// `AddrInUse` and its launcher simply connects to the winner.
103        pub async fn bind(path: &Path) -> io::Result<Self> {
104            let lock = super::claim_lock(&super::lock_path(path)).await?;
105            if tokio::fs::try_exists(path).await? {
106                if connect(path).await.is_ok() {
107                    return Err(io::Error::new(
108                        io::ErrorKind::AddrInUse,
109                        format!("a daemon is already listening at {}", path.display()),
110                    ));
111                }
112                tokio::fs::remove_file(path).await?;
113            }
114            Ok(Self { inner: UnixListener::bind(path)?, _lock: lock })
115        }
116
117        pub async fn accept(&mut self) -> io::Result<DaemonStream> {
118            self.inner.accept().await.map(|(stream, _)| stream)
119        }
120    }
121
122    pub async fn connect(path: &Path) -> io::Result<DaemonStream> {
123        UnixStream::connect(path).await
124    }
125
126    /// Only the daemon's own uid may drive it; `SO_PEERCRED` is authoritative.
127    pub fn peer_is_same_user(stream: &DaemonStream) -> io::Result<bool> {
128        Ok(stream.peer_cred()?.uid() == crate::os::unix::effective_uid())
129    }
130}
131
132#[cfg(windows)]
133mod windows_impl {
134    use std::io;
135    use std::path::{Path, PathBuf};
136    use std::pin::Pin;
137    use std::task::{Context, Poll};
138    use std::time::{Duration, Instant};
139
140    use sha2::{Digest as _, Sha256};
141    use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
142    use tokio::net::windows::named_pipe::{
143        ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions,
144    };
145    use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY;
146
147    use crate::os::windows::PipeSecurity;
148
149    /// How long a client keeps retrying while every pipe instance is busy.
150    /// The listener always keeps a spare instance, so this only covers the
151    /// instant between one client's `connect` and the next instance's creation.
152    const PIPE_BUSY_RETRY_WINDOW: Duration = Duration::from_secs(2);
153
154    /// Deterministic pipe name for a socket path: the same path always maps to
155    /// the same pipe, so clients need nothing but the path, and two different
156    /// paths (two runtime directories) never collide.
157    pub fn pipe_name(path: &Path) -> String {
158        let normalized = path.to_string_lossy().to_lowercase();
159        let digest = Sha256::digest(normalized.as_bytes());
160        let mut tag = String::with_capacity(24);
161        for byte in &digest[..12] {
162            use std::fmt::Write as _;
163            let _ = write!(tag, "{byte:02x}");
164        }
165        format!(r"\\.\pipe\winx-{tag}")
166    }
167
168    /// One accepted or dialed pipe connection.
169    #[derive(Debug)]
170    pub enum DaemonStream {
171        Server(NamedPipeServer),
172        Client(NamedPipeClient),
173    }
174
175    impl AsyncRead for DaemonStream {
176        fn poll_read(
177            self: Pin<&mut Self>,
178            cx: &mut Context<'_>,
179            buf: &mut ReadBuf<'_>,
180        ) -> Poll<io::Result<()>> {
181            match self.get_mut() {
182                Self::Server(pipe) => Pin::new(pipe).poll_read(cx, buf),
183                Self::Client(pipe) => Pin::new(pipe).poll_read(cx, buf),
184            }
185        }
186    }
187
188    impl AsyncWrite for DaemonStream {
189        fn poll_write(
190            self: Pin<&mut Self>,
191            cx: &mut Context<'_>,
192            data: &[u8],
193        ) -> Poll<io::Result<usize>> {
194            match self.get_mut() {
195                Self::Server(pipe) => Pin::new(pipe).poll_write(cx, data),
196                Self::Client(pipe) => Pin::new(pipe).poll_write(cx, data),
197            }
198        }
199
200        fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
201            match self.get_mut() {
202                Self::Server(pipe) => Pin::new(pipe).poll_flush(cx),
203                Self::Client(pipe) => Pin::new(pipe).poll_flush(cx),
204            }
205        }
206
207        fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
208            match self.get_mut() {
209                Self::Server(pipe) => Pin::new(pipe).poll_shutdown(cx),
210                Self::Client(pipe) => Pin::new(pipe).poll_shutdown(cx),
211            }
212        }
213    }
214
215    impl DaemonStream {
216        /// Non-blocking read used by idle health connections to notice a peer
217        /// that went away.
218        pub fn try_read(&self, buf: &mut [u8]) -> io::Result<usize> {
219            match self {
220                Self::Server(pipe) => pipe.try_read(buf),
221                Self::Client(pipe) => pipe.try_read(buf),
222            }
223        }
224    }
225
226    /// Named-pipe listener that owns the marker file at the socket path.
227    pub struct DaemonListener {
228        name: String,
229        marker: PathBuf,
230        security: PipeSecurity,
231        spare: Option<NamedPipeServer>,
232    }
233
234    impl DaemonListener {
235        /// Claim the pipe named after `path`. `first_pipe_instance` already
236        /// makes the kernel refuse a second daemon, so a stale marker file
237        /// needs no check: it is simply rewritten by the winner.
238        // Signature parity with the Unix listener, which awaits the stale-socket check.
239        #[allow(clippy::unused_async, clippy::unused_async_trait_impl)]
240        pub async fn bind(path: &Path) -> io::Result<Self> {
241            let name = pipe_name(path);
242            // Every instance carries an owner-only DACL plus an integrity
243            // label (see `PipeSecurity`): other local accounts and
244            // lower-integrity processes cannot open the pipe at all.
245            // `first_pipe_instance` makes a second daemon fail to bind instead
246            // of silently sharing the name; `reject_remote_clients` keeps the
247            // pipe off the network.
248            let security = PipeSecurity::owner_only()?;
249            let spare = security.create_pipe_instance(
250                ServerOptions::new().first_pipe_instance(true).reject_remote_clients(true),
251                &name,
252            )?;
253            std::fs::write(path, format!("{name}\n{}\n", std::process::id()))?;
254            Ok(Self { name, marker: path.to_path_buf(), security, spare: Some(spare) })
255        }
256
257        fn new_instance(&self) -> io::Result<NamedPipeServer> {
258            self.security
259                .create_pipe_instance(ServerOptions::new().reject_remote_clients(true), &self.name)
260        }
261
262        pub async fn accept(&mut self) -> io::Result<DaemonStream> {
263            let server = match self.spare.take() {
264                Some(server) => server,
265                None => self.new_instance()?,
266            };
267            server.connect().await?;
268            // Create the next instance before handing this one out so a client
269            // racing in finds a free instance instead of `ERROR_PIPE_BUSY`.
270            self.spare = self.new_instance().ok();
271            Ok(DaemonStream::Server(server))
272        }
273
274        pub fn marker_path(&self) -> &Path {
275            &self.marker
276        }
277    }
278
279    /// Dial the daemon behind `path`. A missing server surfaces as
280    /// `NotFound`, which callers already treat like a refused Unix socket.
281    pub async fn connect(path: &Path) -> io::Result<DaemonStream> {
282        let name = pipe_name(path);
283        let deadline = Instant::now() + PIPE_BUSY_RETRY_WINDOW;
284        loop {
285            match ClientOptions::new().open(&name) {
286                Ok(client) => return Ok(DaemonStream::Client(client)),
287                Err(error)
288                    if error.raw_os_error() == Some(ERROR_PIPE_BUSY.cast_signed())
289                        && Instant::now() < deadline =>
290                {
291                    tokio::time::sleep(Duration::from_millis(20)).await;
292                }
293                Err(error) => return Err(error),
294            }
295        }
296    }
297
298    /// See [`DaemonListener::bind`]: the owner-only DACL and integrity label
299    /// on every pipe instance stop other accounts and lower-integrity
300    /// processes from opening it, so an accepted connection is same-user.
301    pub fn peer_is_same_user(_stream: &DaemonStream) -> io::Result<bool> {
302        Ok(true)
303    }
304
305    #[cfg(test)]
306    mod tests {
307        use super::pipe_name;
308        use std::path::Path;
309
310        #[test]
311        fn pipe_names_are_stable_and_case_insensitive() {
312            let lower = pipe_name(Path::new(r"c:\users\x\winx\winxd.sock"));
313            let upper = pipe_name(Path::new(r"C:\Users\X\winx\winxd.sock"));
314            assert_eq!(lower, upper);
315            assert!(lower.starts_with(r"\\.\pipe\winx-"));
316            assert_ne!(lower, pipe_name(Path::new(r"C:\Users\X\winx\other.sock")));
317        }
318    }
319}
320
321#[cfg(all(test, unix))]
322mod unix_claim_tests {
323    #![allow(clippy::expect_used)]
324    use super::{connect, lock_path, DaemonListener};
325
326    #[tokio::test]
327    async fn second_bind_fails_while_the_first_listens_and_the_first_stays_reachable() {
328        let dir = tempfile::tempdir().expect("tempdir");
329        let socket = dir.path().join("winxd.sock");
330        let first = DaemonListener::bind(&socket).await.expect("first bind");
331        let error = DaemonListener::bind(&socket)
332            .await
333            .err()
334            .expect("second bind must not steal the socket");
335        assert_eq!(error.kind(), std::io::ErrorKind::AddrInUse, "{error}");
336        assert!(lock_path(&socket).is_file(), "lock file beside the socket");
337        // The loser did not unlink the winner's socket.
338        assert!(connect(&socket).await.is_ok(), "first listener still reachable");
339        drop(first);
340        DaemonListener::bind(&socket).await.expect("bind after the first listener is gone");
341    }
342
343    #[tokio::test]
344    async fn stale_socket_from_a_dead_daemon_is_replaced() {
345        let dir = tempfile::tempdir().expect("tempdir");
346        let socket = dir.path().join("winxd.sock");
347        let stale = tokio::net::UnixListener::bind(&socket).expect("stale listener");
348        drop(stale);
349        assert!(socket.exists(), "unix sockets outlive their listener");
350        let listener = DaemonListener::bind(&socket).await.expect("replace stale socket");
351        assert!(connect(&socket).await.is_ok());
352        drop(listener);
353    }
354
355    #[test]
356    fn lock_path_sits_beside_the_socket() {
357        assert_eq!(
358            lock_path(std::path::Path::new("/run/u/winx/winxd.sock")),
359            std::path::PathBuf::from("/run/u/winx/winxd.sock.lock")
360        );
361    }
362}