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