1#![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
35pub 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 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 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}