Skip to main content

code_moniker_query/
discovery.rs

1use std::fs;
2use std::fs::OpenOptions;
3use std::hash::{Hash, Hasher};
4use std::io::Write;
5#[cfg(unix)]
6use std::os::fd::AsRawFd;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12use crate::DaemonWorkspaceConfig;
13
14#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub struct DaemonRegistryEntry {
17	pub workspace_root: String,
18	pub workspace_roots: Vec<String>,
19	pub project: Option<String>,
20	pub cache_dir: Option<String>,
21	pub live_refresh: Option<String>,
22	pub endpoint: String,
23	pub token: String,
24	pub pid: u32,
25	#[serde(default)]
26	pub heartbeat_unix_ms: u64,
27	#[serde(default)]
28	pub state: DaemonRegistryState,
29}
30
31pub const DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS: u64 = 15_000;
32
33pub fn registry_heartbeat_unix_ms() -> u64 {
34	SystemTime::now()
35		.duration_since(UNIX_EPOCH)
36		.unwrap_or_default()
37		.as_millis()
38		.try_into()
39		.unwrap_or(u64::MAX)
40}
41
42pub fn daemon_registry_heartbeat_expired(entry: &DaemonRegistryEntry) -> bool {
43	entry.heartbeat_unix_ms == 0
44		|| registry_heartbeat_unix_ms().saturating_sub(entry.heartbeat_unix_ms)
45			> DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS
46}
47
48#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[serde(rename_all = "snake_case")]
51pub enum DaemonRegistryState {
52	Indexing,
53	#[default]
54	Ready,
55}
56
57pub fn registry_dir() -> PathBuf {
58	std::env::temp_dir().join("code-moniker-daemons")
59}
60
61pub fn canonical_workspace_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
62	let root = root.as_ref();
63	root.canonicalize()
64		.map_err(|err| anyhow::anyhow!("cannot canonicalize {}: {err}", root.display()))
65}
66
67pub fn canonical_workspace_roots<I, P>(roots: I) -> anyhow::Result<Vec<PathBuf>>
68where
69	I: IntoIterator<Item = P>,
70	P: AsRef<Path>,
71{
72	let mut canonical = Vec::new();
73	for root in roots {
74		let root = canonical_workspace_root(root)?;
75		if !canonical.contains(&root) {
76			canonical.push(root);
77		}
78	}
79	if canonical.is_empty() {
80		canonical.push(canonical_workspace_root(".")?);
81	}
82	Ok(canonical)
83}
84
85pub fn daemon_workspace_config<I, P>(
86	roots: I,
87	project: Option<String>,
88	cache_dir: Option<PathBuf>,
89	live_refresh: Option<String>,
90) -> anyhow::Result<DaemonWorkspaceConfig>
91where
92	I: IntoIterator<Item = P>,
93	P: AsRef<Path>,
94{
95	let roots = canonical_workspace_roots(roots)?;
96	Ok(DaemonWorkspaceConfig {
97		roots: roots
98			.into_iter()
99			.map(|root| root.display().to_string())
100			.collect(),
101		project,
102		cache_dir: cache_dir
103			.map(normalize_path)
104			.transpose()?
105			.map(|path| path.display().to_string()),
106		live_refresh,
107	})
108}
109
110pub fn validate_daemon_start_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<()> {
111	let roots = config_roots(config);
112	if let Some(root) = roots.iter().find(|root| root.parent().is_none()) {
113		anyhow::bail!(
114			"refusing to start a code-moniker daemon at filesystem root `{}`; pass an explicit project directory (MCP configurations should use an absolute project path)",
115			root.display()
116		);
117	}
118	Ok(())
119}
120
121pub fn canonical_workspace_config(
122	config: DaemonWorkspaceConfig,
123) -> anyhow::Result<DaemonWorkspaceConfig> {
124	daemon_workspace_config(
125		config.roots.iter().map(PathBuf::from),
126		config.project,
127		config.cache_dir.map(PathBuf::from),
128		config.live_refresh,
129	)
130}
131
132pub fn config_from_roots<I, P>(roots: I) -> anyhow::Result<DaemonWorkspaceConfig>
133where
134	I: IntoIterator<Item = P>,
135	P: AsRef<Path>,
136{
137	daemon_workspace_config(roots, None, None, Some("on-demand".to_string()))
138}
139
140pub fn registry_path_for_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
141	registry_path_for_roots([root])
142}
143
144pub fn registry_path_for_roots<I, P>(roots: I) -> anyhow::Result<PathBuf>
145where
146	I: IntoIterator<Item = P>,
147	P: AsRef<Path>,
148{
149	registry_path_for_config(&config_from_roots(roots)?)
150}
151
152pub fn registry_path_for_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<PathBuf> {
153	let config = canonical_workspace_config(config.clone())?;
154	Ok(registry_dir().join(format!("{}.json", stable_config_hash(&config))))
155}
156
157pub fn read_registry_entry(
158	config: &DaemonWorkspaceConfig,
159) -> anyhow::Result<Option<DaemonRegistryEntry>> {
160	let path = registry_path_for_config(config)?;
161	match fs::read_to_string(&path) {
162		Ok(text) => Ok(serde_json::from_str(&text).ok()),
163		Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
164		Err(err) => Err(err.into()),
165	}
166}
167
168pub fn write_registry_entry(
169	config: &DaemonWorkspaceConfig,
170	entry: &DaemonRegistryEntry,
171) -> anyhow::Result<()> {
172	fs::create_dir_all(registry_dir())?;
173	atomic_write_registry_entry(&registry_path_for_config(config)?, entry)?;
174	Ok(())
175}
176
177pub fn claim_registry_entry(
178	config: &DaemonWorkspaceConfig,
179	entry: &DaemonRegistryEntry,
180) -> anyhow::Result<bool> {
181	fs::create_dir_all(registry_dir())?;
182	let path = registry_path_for_config(config)?;
183	let text = serde_json::to_vec_pretty(entry)?;
184	match OpenOptions::new().write(true).create_new(true).open(path) {
185		Ok(mut file) => {
186			file.write_all(&text)?;
187			file.sync_all()?;
188			Ok(true)
189		}
190		Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
191		Err(error) => Err(error.into()),
192	}
193}
194
195pub fn update_registry_entry_if_own(
196	config: &DaemonWorkspaceConfig,
197	entry: &DaemonRegistryEntry,
198) -> anyhow::Result<bool> {
199	let path = registry_path_for_config(config)?;
200	with_registry_lock(&path, || {
201		let current = fs::read_to_string(&path)
202			.ok()
203			.and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
204		let owned = current
205			.map(|current| current.token == entry.token && current.pid == entry.pid)
206			.unwrap_or(false);
207		if owned {
208			atomic_write_registry_entry(&path, entry)?;
209		}
210		Ok(owned)
211	})
212}
213
214fn atomic_write_registry_entry(path: &Path, entry: &DaemonRegistryEntry) -> anyhow::Result<()> {
215	let temp = path.with_extension(format!("{}.tmp", entry.token));
216	let text = serde_json::to_vec_pretty(entry)?;
217	{
218		let mut file = OpenOptions::new()
219			.write(true)
220			.create_new(true)
221			.open(&temp)?;
222		file.write_all(&text)?;
223		file.sync_all()?;
224	}
225	fs::rename(temp, path)?;
226	Ok(())
227}
228
229// Shutdown-time removal: a successor daemon may have overwritten this path
230// with its own entry while we were stopping. Only unlink what is still ours,
231// or the new daemon stays alive but invisible to the registry.
232pub fn remove_registry_entry_if_own(path: &Path, own: &DaemonRegistryEntry) {
233	let _ = with_registry_lock(path, || {
234		let current = fs::read_to_string(path)
235			.ok()
236			.and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
237		let owned = current
238			.map(|entry| entry.token == own.token && entry.pid == own.pid)
239			.unwrap_or(false);
240		if owned {
241			let _ = fs::remove_file(path);
242		}
243		Ok(())
244	});
245}
246
247#[cfg(unix)]
248fn with_registry_lock<T>(
249	path: &Path,
250	action: impl FnOnce() -> anyhow::Result<T>,
251) -> anyhow::Result<T> {
252	let lock_path = path.with_extension("lock");
253	let lock = OpenOptions::new()
254		.read(true)
255		.write(true)
256		.create(true)
257		.truncate(false)
258		.open(lock_path)?;
259	// SAFETY: flock operates on this process-owned open descriptor. The lock is
260	// released automatically when `lock` is dropped on every return path.
261	if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == -1 {
262		return Err(std::io::Error::last_os_error().into());
263	}
264	action()
265}
266
267#[cfg(not(unix))]
268fn with_registry_lock<T>(
269	_path: &Path,
270	action: impl FnOnce() -> anyhow::Result<T>,
271) -> anyhow::Result<T> {
272	action()
273}
274
275pub fn list_registry_files() -> anyhow::Result<Vec<(PathBuf, DaemonRegistryEntry)>> {
276	let dir = registry_dir();
277	if !dir.exists() {
278		return Ok(Vec::new());
279	}
280	let mut entries = Vec::new();
281	for entry in fs::read_dir(&dir)? {
282		let entry = entry?;
283		if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
284			continue;
285		}
286		let text = fs::read_to_string(entry.path())?;
287		if let Ok(registry) = serde_json::from_str::<DaemonRegistryEntry>(&text) {
288			entries.push((entry.path(), registry));
289		}
290	}
291	entries.sort_by(|(_, a), (_, b)| a.workspace_root.cmp(&b.workspace_root));
292	Ok(entries)
293}
294
295pub fn pid_is_alive(pid: u32) -> bool {
296	#[cfg(unix)]
297	{
298		// SAFETY: signal 0 never delivers a signal; it only asks the kernel
299		// whether the PID exists and whether this process may signal it.
300		let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
301		let errno = (result != 0)
302			.then(|| std::io::Error::last_os_error().raw_os_error())
303			.flatten();
304		kill_result_means_alive(result, errno)
305	}
306	#[cfg(not(unix))]
307	{
308		let _ = pid;
309		true
310	}
311}
312
313#[cfg(unix)]
314fn kill_result_means_alive(result: i32, errno: Option<i32>) -> bool {
315	result == 0 || errno != Some(libc::ESRCH)
316}
317
318pub fn list_registry_entries() -> anyhow::Result<Vec<DaemonRegistryEntry>> {
319	let mut entries = Vec::new();
320	for (path, entry) in list_registry_files()? {
321		if pid_is_alive(entry.pid) {
322			entries.push(entry);
323		} else {
324			remove_registry_entry_if_own(&path, &entry);
325		}
326	}
327	entries.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
328	Ok(entries)
329}
330
331pub fn config_roots(config: &DaemonWorkspaceConfig) -> Vec<PathBuf> {
332	config.roots.iter().map(PathBuf::from).collect()
333}
334
335pub fn workspace_label(roots: &[PathBuf]) -> String {
336	if roots.len() == 1 {
337		roots[0].display().to_string()
338	} else {
339		roots
340			.iter()
341			.map(|root| root.display().to_string())
342			.collect::<Vec<_>>()
343			.join(";")
344	}
345}
346
347fn normalize_path(path: PathBuf) -> anyhow::Result<PathBuf> {
348	if path.is_absolute() {
349		Ok(path)
350	} else {
351		Ok(std::env::current_dir()?.join(path))
352	}
353}
354
355// The registry key is the workspace identity: what gets indexed (roots,
356// project, cache), never how it refreshes. Hashing live_refresh here once
357// split one workspace across two registry slots — a daemon started with
358// `--live-refresh auto` was invisible to a default-mode `daemon status`.
359fn stable_config_hash(config: &DaemonWorkspaceConfig) -> String {
360	let mut hasher = StableHasher::default();
361	for root in &config.roots {
362		root.hash(&mut hasher);
363		0xff_u8.hash(&mut hasher);
364	}
365	config.project.hash(&mut hasher);
366	0xfe_u8.hash(&mut hasher);
367	config.cache_dir.hash(&mut hasher);
368	format!("{:016x}", hasher.finish())
369}
370
371#[derive(Default)]
372struct StableHasher(u64);
373
374impl Hasher for StableHasher {
375	fn finish(&self) -> u64 {
376		self.0
377	}
378
379	fn write(&mut self, bytes: &[u8]) {
380		let mut hash = if self.0 == 0 {
381			0xcbf29ce484222325
382		} else {
383			self.0
384		};
385		for byte in bytes {
386			hash ^= u64::from(*byte);
387			hash = hash.wrapping_mul(0x100000001b3);
388		}
389		self.0 = hash;
390	}
391}
392
393#[cfg(test)]
394mod tests {
395	use super::*;
396
397	fn entry(token: &str, pid: u32) -> DaemonRegistryEntry {
398		DaemonRegistryEntry {
399			workspace_root: "/tmp/ws".to_string(),
400			workspace_roots: vec!["/tmp/ws".to_string()],
401			project: None,
402			cache_dir: None,
403			live_refresh: None,
404			endpoint: "127.0.0.1:1".to_string(),
405			token: token.to_string(),
406			pid,
407			heartbeat_unix_ms: registry_heartbeat_unix_ms(),
408			state: DaemonRegistryState::Ready,
409		}
410	}
411
412	#[test]
413	fn registry_identity_ignores_the_refresh_mode() {
414		let base = DaemonWorkspaceConfig {
415			roots: vec!["/tmp/ws".to_string()],
416			project: None,
417			cache_dir: None,
418			live_refresh: Some("auto".to_string()),
419		};
420		let mut on_demand = base.clone();
421		on_demand.live_refresh = Some("on-demand".to_string());
422		assert_eq!(
423			stable_config_hash(&base),
424			stable_config_hash(&on_demand),
425			"one workspace must map to one registry slot, whatever the refresh mode"
426		);
427
428		let mut other_project = base.clone();
429		other_project.project = Some("api".to_string());
430		assert_ne!(
431			stable_config_hash(&base),
432			stable_config_hash(&other_project),
433			"what gets indexed still separates registry slots"
434		);
435	}
436
437	#[cfg(unix)]
438	#[test]
439	fn daemon_workspace_rejects_the_filesystem_root() {
440		let config =
441			daemon_workspace_config([Path::new("/")], None, None, Some("auto".to_string()))
442				.expect("filesystem root identity remains available for status and cleanup");
443		let error = validate_daemon_start_config(&config)
444			.expect_err("filesystem root must fail before daemon startup");
445		let message = error.to_string();
446		assert!(message.contains("refusing to start"), "{message}");
447		assert!(message.contains("absolute project path"), "{message}");
448	}
449
450	#[test]
451	fn shutdown_removal_spares_a_successor_entry() {
452		let dir = tempfile::tempdir().expect("tempdir");
453		let path = dir.path().join("ws.json");
454		let old = entry("old-token", 111);
455		let new = entry("new-token", 222);
456
457		fs::write(&path, serde_json::to_string(&new).expect("json")).expect("write");
458		remove_registry_entry_if_own(&path, &old);
459		assert!(path.exists(), "the successor's entry must survive");
460
461		remove_registry_entry_if_own(&path, &new);
462		assert!(!path.exists(), "the owner removes its own entry");
463
464		remove_registry_entry_if_own(&path, &new);
465	}
466
467	#[test]
468	fn legacy_registry_entries_default_to_ready() {
469		let mut value = serde_json::to_value(entry("legacy", 111)).expect("json");
470		value.as_object_mut().expect("object").remove("state");
471		let decoded: DaemonRegistryEntry = serde_json::from_value(value).expect("legacy entry");
472		assert_eq!(decoded.state, DaemonRegistryState::Ready);
473	}
474
475	#[test]
476	fn missing_or_old_heartbeat_expires_but_a_fresh_claim_does_not() {
477		let mut registry = entry("heartbeat", 111);
478		registry.heartbeat_unix_ms = 0;
479		assert!(daemon_registry_heartbeat_expired(&registry));
480		registry.heartbeat_unix_ms =
481			registry_heartbeat_unix_ms() - DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS - 1;
482		assert!(daemon_registry_heartbeat_expired(&registry));
483		registry.heartbeat_unix_ms = registry_heartbeat_unix_ms();
484		assert!(!daemon_registry_heartbeat_expired(&registry));
485	}
486
487	#[test]
488	fn atomic_registry_update_replaces_a_complete_entry() {
489		let dir = tempfile::tempdir().expect("tempdir");
490		let path = dir.path().join("workspace.json");
491		let indexing = DaemonRegistryEntry {
492			state: DaemonRegistryState::Indexing,
493			..entry("same-daemon", 111)
494		};
495		atomic_write_registry_entry(&path, &indexing).expect("write indexing entry");
496		let ready = DaemonRegistryEntry {
497			state: DaemonRegistryState::Ready,
498			..indexing.clone()
499		};
500		atomic_write_registry_entry(&path, &ready).expect("write ready entry");
501		let read: DaemonRegistryEntry =
502			serde_json::from_str(&fs::read_to_string(path).expect("read entry")).expect("json");
503		assert_eq!(read, ready);
504	}
505
506	#[cfg(unix)]
507	#[test]
508	fn permission_denied_pid_is_alive_but_missing_pid_is_dead() {
509		assert!(kill_result_means_alive(-1, Some(libc::EPERM)));
510		assert!(!kill_result_means_alive(-1, Some(libc::ESRCH)));
511		assert!(kill_result_means_alive(0, None));
512	}
513}