Skip to main content

nomoreide_daemon/
lib.rs

1//! Machine-global loopback daemon ownership and state boundary.
2
3// A route helper that fails hands back the response it wants sent — that is
4// axum's own idiom, and `axum::response::Response` is 128 bytes, exactly the
5// threshold `result_large_err` fires at. Boxing it would put a `Box<Response>`
6// and a `.map_err` in the signature of every helper on a path that is already
7// allocating an HTTP response, to save moving 128 bytes. The lint is right in
8// general and wrong here.
9#![allow(clippy::result_large_err)]
10
11mod remote;
12mod runtime;
13mod server;
14mod service_discovery;
15
16pub use server::{
17    run, run_embedded, run_embedded_with_shutdown_requests, run_with_listener, serve_until,
18    serve_with_shutdown_requests, DaemonOptions, ShutdownRequest,
19};
20
21use nomoreide_core::filesystem::{atomic_write, AtomicWriteOptions};
22use nomoreide_daemon_client::{DaemonState, RuntimePaths};
23use serde::{Deserialize, Serialize};
24use std::fs::{self, File, OpenOptions};
25use std::io::{self, Seek, SeekFrom, Write};
26use uuid::Uuid;
27
28#[derive(Debug, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub(crate) struct LockRecord {
31    pid: u32,
32    pub(crate) owner_id: String,
33}
34
35/// Exclusive ownership of the machine-global runtime. The OS lock is released
36/// automatically on crash; state and credentials are removed on orderly drop.
37pub struct DaemonOwnership {
38    paths: RuntimePaths,
39    lock_file: File,
40    owner_id: String,
41    credential: String,
42}
43
44impl DaemonOwnership {
45    pub fn acquire(paths: RuntimePaths) -> io::Result<Self> {
46        fs::create_dir_all(&paths.state_dir)?;
47        #[cfg(unix)]
48        {
49            use std::os::unix::fs::PermissionsExt;
50            fs::set_permissions(&paths.state_dir, fs::Permissions::from_mode(0o700))?;
51        }
52        let mut options = OpenOptions::new();
53        options.read(true).write(true).create(true);
54        #[cfg(unix)]
55        {
56            use std::os::unix::fs::OpenOptionsExt;
57            options
58                .mode(0o600)
59                .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
60        }
61        #[cfg(windows)]
62        {
63            use std::os::windows::fs::OpenOptionsExt;
64            use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
65            options
66                .share_mode(0)
67                .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
68        }
69        let mut lock_file = options.open(&paths.lock).map_err(map_lock_open_error)?;
70        if !lock_file.metadata()?.is_file() {
71            return Err(io::Error::new(
72                io::ErrorKind::InvalidData,
73                "daemon lock path is not a regular file",
74            ));
75        }
76        lock_exclusive(&lock_file)?;
77        #[cfg(unix)]
78        {
79            use std::os::unix::fs::PermissionsExt;
80            lock_file.set_permissions(fs::Permissions::from_mode(0o600))?;
81        }
82
83        let owner_id = Uuid::new_v4().to_string();
84        let record = LockRecord {
85            pid: std::process::id(),
86            owner_id: owner_id.clone(),
87        };
88        lock_file.set_len(0)?;
89        lock_file.seek(SeekFrom::Start(0))?;
90        lock_file.write_all(&serde_json::to_vec(&record).map_err(invalid_data)?)?;
91        lock_file.sync_all()?;
92
93        // Any files visible before this lock was acquired belong to a crashed
94        // owner. Clear them before publishing this owner's identity.
95        remove_if_present(&paths.state)?;
96        remove_if_present(&paths.credential)?;
97
98        Ok(Self {
99            paths,
100            lock_file,
101            owner_id,
102            credential: format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()),
103        })
104    }
105
106    pub fn publish(&self, state: &DaemonState) -> io::Result<()> {
107        state.validate()?;
108        if state.pid != std::process::id() {
109            return Err(io::Error::new(
110                io::ErrorKind::InvalidInput,
111                "daemon state pid does not match the lock owner",
112            ));
113        }
114        if state.owner_id != self.owner_id {
115            return Err(io::Error::new(
116                io::ErrorKind::InvalidInput,
117                "daemon state identity does not match the lock owner",
118            ));
119        }
120        let mut serialized = serde_json::to_vec_pretty(state).map_err(invalid_data)?;
121        serialized.push(b'\n');
122        atomic_write(
123            &self.paths.credential,
124            format!("{}\n", self.credential),
125            AtomicWriteOptions::private(),
126        )?;
127        atomic_write(&self.paths.state, serialized, AtomicWriteOptions::private())
128    }
129
130    pub fn credential(&self) -> &str {
131        &self.credential
132    }
133
134    pub fn owner_id(&self) -> &str {
135        &self.owner_id
136    }
137}
138
139impl Drop for DaemonOwnership {
140    fn drop(&mut self) {
141        let _ = remove_if_present(&self.paths.state);
142        let _ = remove_if_present(&self.paths.credential);
143        unlock(&self.lock_file);
144    }
145}
146
147#[cfg(unix)]
148fn lock_exclusive(file: &File) -> io::Result<()> {
149    use std::os::fd::AsRawFd;
150    let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
151    if result == 0 {
152        Ok(())
153    } else {
154        let error = io::Error::last_os_error();
155        if error.raw_os_error() == Some(libc::EWOULDBLOCK) {
156            Err(io::Error::new(
157                io::ErrorKind::WouldBlock,
158                "another NoMoreIDE daemon owns the runtime lock",
159            ))
160        } else {
161            Err(error)
162        }
163    }
164}
165
166#[cfg(unix)]
167fn unlock(file: &File) {
168    use std::os::fd::AsRawFd;
169    let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
170}
171
172#[cfg(windows)]
173fn lock_exclusive(_file: &File) -> io::Result<()> {
174    // share_mode(0) on the open file is the exclusive, crash-safe lock.
175    Ok(())
176}
177
178#[cfg(windows)]
179fn unlock(_file: &File) {}
180
181fn remove_if_present(path: &std::path::Path) -> io::Result<()> {
182    match fs::remove_file(path) {
183        Ok(()) => Ok(()),
184        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
185        Err(error) => Err(error),
186    }
187}
188
189#[cfg(not(windows))]
190fn map_lock_open_error(error: io::Error) -> io::Error {
191    error
192}
193
194#[cfg(windows)]
195fn map_lock_open_error(error: io::Error) -> io::Error {
196    const ERROR_SHARING_VIOLATION: i32 = 32;
197    if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) {
198        io::Error::new(
199            io::ErrorKind::WouldBlock,
200            "another NoMoreIDE daemon owns the runtime lock",
201        )
202    } else {
203        error
204    }
205}
206
207fn invalid_data(error: impl std::fmt::Display) -> io::Error {
208    io::Error::new(io::ErrorKind::InvalidData, error.to_string())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use nomoreide_daemon_client::DaemonEndpoint;
215
216    fn paths(label: &str) -> RuntimePaths {
217        RuntimePaths::new(
218            std::env::temp_dir().join(format!("nomoreide-daemon-{label}-{}", Uuid::new_v4())),
219        )
220    }
221
222    fn state(owner_id: &str) -> DaemonState {
223        let endpoint = DaemonEndpoint::localhost(4317);
224        DaemonState {
225            pid: std::process::id(),
226            owner_id: owner_id.into(),
227            url: endpoint.as_str().trim_end_matches('/').to_string(),
228            port: 4317,
229            version: Some("0.1.103".into()),
230            started_at: "2026-08-20T00:00:00Z".into(),
231        }
232    }
233
234    #[test]
235    fn only_one_owner_can_hold_the_runtime_lock() {
236        let paths = paths("exclusive");
237        let owner = DaemonOwnership::acquire(paths.clone()).unwrap();
238        let error = DaemonOwnership::acquire(paths.clone()).err().unwrap();
239        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
240        drop(owner);
241        assert!(DaemonOwnership::acquire(paths.clone()).is_ok());
242        let _ = fs::remove_dir_all(paths.state_dir);
243    }
244
245    #[test]
246    fn acquisition_clears_stale_state_and_publishes_private_runtime_files() {
247        let paths = paths("stale");
248        fs::create_dir_all(&paths.state_dir).unwrap();
249        fs::write(&paths.state, "stale").unwrap();
250        fs::write(&paths.credential, "stale-secret").unwrap();
251
252        let owner = DaemonOwnership::acquire(paths.clone()).unwrap();
253        assert!(!paths.state.exists());
254        assert!(!paths.credential.exists());
255        owner.publish(&state(owner.owner_id())).unwrap();
256        assert_eq!(
257            fs::read_to_string(&paths.credential).unwrap().trim().len(),
258            64
259        );
260        assert!(!fs::read_to_string(&paths.state)
261            .unwrap()
262            .contains(owner.credential()));
263
264        #[cfg(unix)]
265        {
266            use std::os::unix::fs::PermissionsExt;
267            for path in [&paths.lock, &paths.state, &paths.credential] {
268                assert_eq!(
269                    fs::metadata(path).unwrap().permissions().mode() & 0o777,
270                    0o600
271                );
272            }
273        }
274
275        drop(owner);
276        assert!(!paths.state.exists());
277        assert!(!paths.credential.exists());
278        let _ = fs::remove_dir_all(paths.state_dir);
279    }
280
281    #[test]
282    fn publication_rejects_state_for_a_different_owner() {
283        let paths = paths("identity-mismatch");
284        let owner = DaemonOwnership::acquire(paths.clone()).unwrap();
285
286        let error = owner.publish(&state("different-owner")).unwrap_err();
287
288        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
289        assert!(!paths.state.exists());
290        assert!(!paths.credential.exists());
291        drop(owner);
292        let _ = fs::remove_dir_all(paths.state_dir);
293    }
294
295    #[cfg(unix)]
296    #[test]
297    fn ownership_refuses_a_symlinked_lock_file() {
298        use std::os::unix::fs::symlink;
299
300        let paths = paths("symlink");
301        fs::create_dir_all(&paths.state_dir).unwrap();
302        let target = paths.state_dir.join("target");
303        fs::write(&target, "must remain unchanged").unwrap();
304        symlink(&target, &paths.lock).unwrap();
305
306        assert!(DaemonOwnership::acquire(paths.clone()).is_err());
307        assert_eq!(
308            fs::read_to_string(&target).unwrap(),
309            "must remain unchanged"
310        );
311        let _ = fs::remove_dir_all(paths.state_dir);
312    }
313}