Skip to main content

code_moniker_query/
discovery.rs

1use std::fs;
2use std::fs::OpenOptions;
3use std::hash::{Hash, Hasher};
4use std::io::{Read, Write};
5#[cfg(unix)]
6use std::os::fd::AsRawFd;
7#[cfg(windows)]
8use std::os::windows::io::AsRawHandle;
9#[cfg(windows)]
10use std::os::windows::prelude::OsStrExt;
11use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde::{Deserialize, Serialize};
16
17use crate::DaemonWorkspaceConfig;
18
19#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21pub struct BuildIdentity {
22	pub version: String,
23	pub fingerprint: String,
24}
25
26pub fn current_build_identity(version: impl Into<String>) -> anyhow::Result<BuildIdentity> {
27	static FINGERPRINT: OnceLock<Result<String, String>> = OnceLock::new();
28	let fingerprint = FINGERPRINT.get_or_init(|| {
29		std::env::current_exe()
30			.map_err(|error| error.to_string())
31			.and_then(|path| binary_fingerprint(path).map_err(|error| error.to_string()))
32	});
33	Ok(BuildIdentity {
34		version: version.into(),
35		fingerprint: fingerprint
36			.clone()
37			.map_err(|error| anyhow::anyhow!("cannot fingerprint current binary: {error}"))?,
38	})
39}
40
41pub fn binary_fingerprint(path: impl AsRef<Path>) -> anyhow::Result<String> {
42	let path = path.as_ref();
43	let mut file = fs::File::open(path)
44		.map_err(|error| anyhow::anyhow!("cannot read binary {}: {error}", path.display()))?;
45	let mut buffer = [0_u8; 64 * 1024];
46	let mut hash = 0xcbf29ce484222325_u64;
47	loop {
48		let read = file.read(&mut buffer)?;
49		if read == 0 {
50			break;
51		}
52		for byte in &buffer[..read] {
53			hash ^= u64::from(*byte);
54			hash = hash.wrapping_mul(0x100000001b3);
55		}
56	}
57	Ok(format!("fnv1a64:{hash:016x}"))
58}
59
60#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62pub struct DaemonRegistryEntry {
63	pub workspace_root: String,
64	pub workspace_roots: Vec<String>,
65	pub project: Option<String>,
66	pub cache_dir: Option<String>,
67	pub live_refresh: Option<String>,
68	pub endpoint: String,
69	pub token: String,
70	pub pid: u32,
71	#[serde(default)]
72	pub build: BuildIdentity,
73	#[serde(default)]
74	pub heartbeat_unix_ms: u64,
75}
76
77pub const DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS: u64 = 15_000;
78
79pub fn registry_heartbeat_unix_ms() -> u64 {
80	SystemTime::now()
81		.duration_since(UNIX_EPOCH)
82		.unwrap_or_default()
83		.as_millis()
84		.try_into()
85		.unwrap_or(u64::MAX)
86}
87
88pub fn daemon_registry_heartbeat_expired(entry: &DaemonRegistryEntry) -> bool {
89	entry.heartbeat_unix_ms == 0
90		|| registry_heartbeat_unix_ms().saturating_sub(entry.heartbeat_unix_ms)
91			> DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS
92}
93
94pub fn registry_dir() -> PathBuf {
95	std::env::var_os("CODE_MONIKER_REGISTRY_DIR")
96		.map(PathBuf::from)
97		.unwrap_or_else(|| std::env::temp_dir().join("code-moniker-daemons"))
98}
99
100pub fn canonical_workspace_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
101	let root = root.as_ref();
102	root.canonicalize()
103		.map_err(|err| anyhow::anyhow!("cannot canonicalize {}: {err}", root.display()))
104}
105
106pub fn canonical_workspace_roots<I, P>(roots: I) -> anyhow::Result<Vec<PathBuf>>
107where
108	I: IntoIterator<Item = P>,
109	P: AsRef<Path>,
110{
111	let mut canonical = Vec::new();
112	for root in roots {
113		let root = canonical_workspace_root(root)?;
114		if !canonical.contains(&root) {
115			canonical.push(root);
116		}
117	}
118	if canonical.is_empty() {
119		canonical.push(canonical_workspace_root(".")?);
120	}
121	Ok(canonical)
122}
123
124pub fn daemon_workspace_config<I, P>(
125	roots: I,
126	project: Option<String>,
127	cache_dir: Option<PathBuf>,
128	live_refresh: Option<String>,
129) -> anyhow::Result<DaemonWorkspaceConfig>
130where
131	I: IntoIterator<Item = P>,
132	P: AsRef<Path>,
133{
134	let roots = canonical_workspace_roots(roots)?;
135	Ok(DaemonWorkspaceConfig {
136		roots: roots
137			.into_iter()
138			.map(|root| root.display().to_string())
139			.collect(),
140		project,
141		cache_dir: cache_dir
142			.map(normalize_path)
143			.transpose()?
144			.map(|path| path.display().to_string()),
145		live_refresh,
146	})
147}
148
149pub fn validate_daemon_start_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<()> {
150	let roots = config_roots(config);
151	if let Some(root) = roots.iter().find(|root| root.parent().is_none()) {
152		anyhow::bail!(
153			"refusing to start a code-moniker daemon at filesystem root `{}`; pass an explicit project directory (MCP configurations should use an absolute project path)",
154			root.display()
155		);
156	}
157	Ok(())
158}
159
160pub fn canonical_workspace_config(
161	config: DaemonWorkspaceConfig,
162) -> anyhow::Result<DaemonWorkspaceConfig> {
163	daemon_workspace_config(
164		config.roots.iter().map(PathBuf::from),
165		config.project,
166		config.cache_dir.map(PathBuf::from),
167		config.live_refresh,
168	)
169}
170
171pub fn config_from_roots<I, P>(roots: I) -> anyhow::Result<DaemonWorkspaceConfig>
172where
173	I: IntoIterator<Item = P>,
174	P: AsRef<Path>,
175{
176	daemon_workspace_config(roots, None, None, Some("on-demand".to_string()))
177}
178
179pub fn registry_path_for_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
180	registry_path_for_roots([root])
181}
182
183pub fn registry_path_for_roots<I, P>(roots: I) -> anyhow::Result<PathBuf>
184where
185	I: IntoIterator<Item = P>,
186	P: AsRef<Path>,
187{
188	registry_path_for_config(&config_from_roots(roots)?)
189}
190
191pub fn registry_path_for_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<PathBuf> {
192	let config = canonical_workspace_config(config.clone())?;
193	Ok(registry_dir().join(format!("{}.json", stable_config_hash(&config))))
194}
195
196pub fn daemon_log_path_for_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<PathBuf> {
197	Ok(registry_path_for_config(config)?.with_extension("log"))
198}
199
200pub fn read_registry_entry(
201	config: &DaemonWorkspaceConfig,
202) -> anyhow::Result<Option<DaemonRegistryEntry>> {
203	let path = registry_path_for_config(config)?;
204	match fs::read_to_string(&path) {
205		Ok(text) => parse_registry_entry(&path, &text).map(Some),
206		Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
207		Err(err) => Err(anyhow::anyhow!(
208			"cannot read daemon registry entry {}: {err}",
209			path.display()
210		)),
211	}
212}
213
214pub fn write_registry_entry(
215	config: &DaemonWorkspaceConfig,
216	entry: &DaemonRegistryEntry,
217) -> anyhow::Result<()> {
218	fs::create_dir_all(registry_dir())?;
219	atomic_write_registry_entry(&registry_path_for_config(config)?, entry)?;
220	Ok(())
221}
222
223pub fn claim_registry_entry(
224	config: &DaemonWorkspaceConfig,
225	entry: &DaemonRegistryEntry,
226) -> anyhow::Result<bool> {
227	fs::create_dir_all(registry_dir())?;
228	claim_registry_file(&registry_path_for_config(config)?, entry)
229}
230
231fn claim_registry_file(path: &Path, entry: &DaemonRegistryEntry) -> anyhow::Result<bool> {
232	claim_registry_file_before_publish(path, entry, || {})
233}
234
235fn claim_registry_file_before_publish(
236	path: &Path,
237	entry: &DaemonRegistryEntry,
238	before_publish: impl FnOnce(),
239) -> anyhow::Result<bool> {
240	let temp = path.with_extension(format!("{}.claim.tmp", entry.token));
241	let text = serde_json::to_vec_pretty(entry)?;
242	let mut file = OpenOptions::new()
243		.write(true)
244		.create_new(true)
245		.open(&temp)?;
246	let prepared = (|| {
247		file.write_all(&text)?;
248		file.sync_all()?;
249		Ok::<_, anyhow::Error>(())
250	})();
251	drop(file);
252	if let Err(error) = prepared {
253		let _ = fs::remove_file(temp);
254		return Err(error);
255	}
256	before_publish();
257	let result = publish_registry_claim(&temp, path);
258	let _ = fs::remove_file(temp);
259	result
260}
261
262#[cfg(not(windows))]
263fn publish_registry_claim(source: &Path, destination: &Path) -> anyhow::Result<bool> {
264	match fs::hard_link(source, destination) {
265		Ok(()) => Ok(true),
266		Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
267		Err(error) => Err(error.into()),
268	}
269}
270
271#[cfg(windows)]
272fn publish_registry_claim(source: &Path, destination: &Path) -> anyhow::Result<bool> {
273	use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW};
274
275	let source = source
276		.as_os_str()
277		.encode_wide()
278		.chain(std::iter::once(0))
279		.collect::<Vec<_>>();
280	let destination = destination
281		.as_os_str()
282		.encode_wide()
283		.chain(std::iter::once(0))
284		.collect::<Vec<_>>();
285	if unsafe {
286		MoveFileExW(
287			source.as_ptr(),
288			destination.as_ptr(),
289			MOVEFILE_WRITE_THROUGH,
290		)
291	} != 0
292	{
293		return Ok(true);
294	}
295	let error = std::io::Error::last_os_error();
296	if error.kind() == std::io::ErrorKind::AlreadyExists {
297		Ok(false)
298	} else {
299		Err(error.into())
300	}
301}
302
303pub fn update_registry_entry_if_own(
304	config: &DaemonWorkspaceConfig,
305	entry: &DaemonRegistryEntry,
306) -> anyhow::Result<bool> {
307	let path = registry_path_for_config(config)?;
308	with_registry_lock(&path, || {
309		let current = match fs::read_to_string(&path) {
310			Ok(text) => Some(parse_registry_entry(&path, &text)?),
311			Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
312			Err(error) => {
313				return Err(anyhow::anyhow!(
314					"cannot read daemon registry entry {}: {error}",
315					path.display()
316				));
317			}
318		};
319		let owned = current
320			.map(|current| current.token == entry.token && current.pid == entry.pid)
321			.unwrap_or(false);
322		if owned {
323			atomic_write_registry_entry(&path, entry)?;
324		}
325		Ok(owned)
326	})
327}
328
329fn parse_registry_entry(path: &Path, text: &str) -> anyhow::Result<DaemonRegistryEntry> {
330	serde_json::from_str(text).map_err(|error| {
331		anyhow::anyhow!(
332			"cannot decode daemon registry entry {}: {error}",
333			path.display()
334		)
335	})
336}
337
338fn atomic_write_registry_entry(path: &Path, entry: &DaemonRegistryEntry) -> anyhow::Result<()> {
339	let temp = path.with_extension(format!("{}.tmp", entry.token));
340	let text = serde_json::to_vec_pretty(entry)?;
341	{
342		let mut file = OpenOptions::new()
343			.write(true)
344			.create_new(true)
345			.open(&temp)?;
346		file.write_all(&text)?;
347		file.sync_all()?;
348	}
349	replace_registry_file(&temp, path)?;
350	Ok(())
351}
352
353#[cfg(windows)]
354fn replace_registry_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
355	use windows_sys::Win32::Storage::FileSystem::{
356		MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
357	};
358
359	let source = source
360		.as_os_str()
361		.encode_wide()
362		.chain(std::iter::once(0))
363		.collect::<Vec<_>>();
364	let destination = destination
365		.as_os_str()
366		.encode_wide()
367		.chain(std::iter::once(0))
368		.collect::<Vec<_>>();
369	if unsafe {
370		MoveFileExW(
371			source.as_ptr(),
372			destination.as_ptr(),
373			MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
374		)
375	} == 0
376	{
377		return Err(std::io::Error::last_os_error().into());
378	}
379	Ok(())
380}
381
382#[cfg(not(windows))]
383fn replace_registry_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
384	fs::rename(source, destination)?;
385	Ok(())
386}
387
388// Shutdown-time removal: a successor daemon may have overwritten this path
389// with its own entry while we were stopping. Only unlink what is still ours,
390// or the new daemon stays alive but invisible to the registry.
391pub fn remove_registry_entry_if_own(path: &Path, own: &DaemonRegistryEntry) {
392	let _ = with_registry_lock(path, || {
393		let current = fs::read_to_string(path)
394			.ok()
395			.and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
396		let owned = current
397			.map(|entry| entry.token == own.token && entry.pid == own.pid)
398			.unwrap_or(false);
399		if owned {
400			let _ = fs::remove_file(path);
401		}
402		Ok(())
403	});
404}
405
406#[cfg(unix)]
407fn with_registry_lock<T>(
408	path: &Path,
409	action: impl FnOnce() -> anyhow::Result<T>,
410) -> anyhow::Result<T> {
411	let lock_path = path.with_extension("lock");
412	let lock = OpenOptions::new()
413		.read(true)
414		.write(true)
415		.create(true)
416		.truncate(false)
417		.open(lock_path)?;
418	// SAFETY: flock operates on this process-owned open descriptor. The lock is
419	// released automatically when `lock` is dropped on every return path.
420	if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == -1 {
421		return Err(std::io::Error::last_os_error().into());
422	}
423	action()
424}
425
426#[cfg(not(unix))]
427fn with_registry_lock<T>(
428	path: &Path,
429	action: impl FnOnce() -> anyhow::Result<T>,
430) -> anyhow::Result<T> {
431	#[cfg(windows)]
432	{
433		use windows_sys::Win32::Storage::FileSystem::{
434			LOCKFILE_EXCLUSIVE_LOCK, LockFileEx, UnlockFileEx,
435		};
436		use windows_sys::Win32::System::IO::OVERLAPPED;
437
438		let lock_path = path.with_extension("lock");
439		let lock = OpenOptions::new()
440			.read(true)
441			.write(true)
442			.create(true)
443			.truncate(false)
444			.open(lock_path)?;
445		let mut overlapped = unsafe { std::mem::zeroed::<OVERLAPPED>() };
446		let handle = lock.as_raw_handle();
447		if unsafe {
448			LockFileEx(
449				handle,
450				LOCKFILE_EXCLUSIVE_LOCK,
451				0,
452				u32::MAX,
453				u32::MAX,
454				&mut overlapped,
455			)
456		} == 0
457		{
458			return Err(std::io::Error::last_os_error().into());
459		}
460		let result = action();
461		let unlock = unsafe { UnlockFileEx(handle, 0, u32::MAX, u32::MAX, &mut overlapped) };
462		if unlock == 0 && result.is_ok() {
463			return Err(std::io::Error::last_os_error().into());
464		}
465		result
466	}
467	#[cfg(not(windows))]
468	{
469		let _ = path;
470		action()
471	}
472}
473
474pub fn list_registry_files() -> anyhow::Result<Vec<(PathBuf, DaemonRegistryEntry)>> {
475	let dir = registry_dir();
476	if !dir.exists() {
477		return Ok(Vec::new());
478	}
479	let mut entries = Vec::new();
480	for entry in fs::read_dir(&dir)? {
481		let entry = entry?;
482		if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
483			continue;
484		}
485		let text = fs::read_to_string(entry.path())?;
486		if let Ok(registry) = serde_json::from_str::<DaemonRegistryEntry>(&text) {
487			entries.push((entry.path(), registry));
488		}
489	}
490	entries.sort_by(|(_, a), (_, b)| a.workspace_root.cmp(&b.workspace_root));
491	Ok(entries)
492}
493
494pub fn pid_is_alive(pid: u32) -> bool {
495	#[cfg(unix)]
496	{
497		// SAFETY: signal 0 never delivers a signal; it only asks the kernel
498		// whether the PID exists and whether this process may signal it.
499		let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
500		let errno = (result != 0)
501			.then(|| std::io::Error::last_os_error().raw_os_error())
502			.flatten();
503		kill_result_means_alive(result, errno)
504	}
505	#[cfg(not(unix))]
506	{
507		#[cfg(windows)]
508		{
509			windows_pid_is_alive(pid)
510		}
511		#[cfg(not(windows))]
512		{
513			let _ = pid;
514			false
515		}
516	}
517}
518
519#[cfg(unix)]
520fn kill_result_means_alive(result: i32, errno: Option<i32>) -> bool {
521	result == 0 || errno != Some(libc::ESRCH)
522}
523
524#[cfg(windows)]
525fn windows_pid_is_alive(pid: u32) -> bool {
526	use windows_sys::Win32::Foundation::{
527		CloseHandle, ERROR_ACCESS_DENIED, GetLastError, WAIT_TIMEOUT,
528	};
529	use windows_sys::Win32::System::Threading::{
530		OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
531	};
532
533	let handle = unsafe {
534		OpenProcess(
535			PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
536			0,
537			pid,
538		)
539	};
540	if handle.is_null() {
541		return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
542	}
543	let state = unsafe { WaitForSingleObject(handle, 0) };
544	unsafe {
545		CloseHandle(handle);
546	}
547	state == WAIT_TIMEOUT
548}
549
550pub fn list_registry_entries() -> anyhow::Result<Vec<DaemonRegistryEntry>> {
551	let mut entries = Vec::new();
552	for (path, entry) in list_registry_files()? {
553		if pid_is_alive(entry.pid) {
554			entries.push(entry);
555		} else {
556			remove_registry_entry_if_own(&path, &entry);
557		}
558	}
559	entries.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
560	Ok(entries)
561}
562
563pub fn config_roots(config: &DaemonWorkspaceConfig) -> Vec<PathBuf> {
564	config.roots.iter().map(PathBuf::from).collect()
565}
566
567pub fn workspace_label(roots: &[PathBuf]) -> String {
568	if roots.len() == 1 {
569		roots[0].display().to_string()
570	} else {
571		roots
572			.iter()
573			.map(|root| root.display().to_string())
574			.collect::<Vec<_>>()
575			.join(";")
576	}
577}
578
579fn normalize_path(path: PathBuf) -> anyhow::Result<PathBuf> {
580	if path.is_absolute() {
581		Ok(path)
582	} else {
583		Ok(std::env::current_dir()?.join(path))
584	}
585}
586
587// The registry key is the workspace identity: what gets indexed (roots,
588// project, cache), never how it refreshes. Hashing live_refresh here once
589// split one workspace across two registry slots — a daemon started with
590// `--live-refresh auto` was invisible to a default-mode `daemon status`.
591fn stable_config_hash(config: &DaemonWorkspaceConfig) -> String {
592	let mut hasher = StableHasher::default();
593	for root in &config.roots {
594		root.hash(&mut hasher);
595		0xff_u8.hash(&mut hasher);
596	}
597	config.project.hash(&mut hasher);
598	0xfe_u8.hash(&mut hasher);
599	config.cache_dir.hash(&mut hasher);
600	format!("{:016x}", hasher.finish())
601}
602
603#[derive(Default)]
604struct StableHasher(u64);
605
606impl Hasher for StableHasher {
607	fn finish(&self) -> u64 {
608		self.0
609	}
610
611	fn write(&mut self, bytes: &[u8]) {
612		let mut hash = if self.0 == 0 {
613			0xcbf29ce484222325
614		} else {
615			self.0
616		};
617		for byte in bytes {
618			hash ^= u64::from(*byte);
619			hash = hash.wrapping_mul(0x100000001b3);
620		}
621		self.0 = hash;
622	}
623}
624
625#[cfg(test)]
626mod tests {
627	use super::*;
628
629	fn entry(token: &str, pid: u32) -> DaemonRegistryEntry {
630		DaemonRegistryEntry {
631			workspace_root: "/tmp/ws".to_string(),
632			workspace_roots: vec!["/tmp/ws".to_string()],
633			project: None,
634			cache_dir: None,
635			live_refresh: None,
636			endpoint: "127.0.0.1:1".to_string(),
637			token: token.to_string(),
638			pid,
639			build: BuildIdentity::default(),
640			heartbeat_unix_ms: registry_heartbeat_unix_ms(),
641		}
642	}
643
644	#[test]
645	fn registry_identity_ignores_the_refresh_mode() {
646		let base = DaemonWorkspaceConfig {
647			roots: vec!["/tmp/ws".to_string()],
648			project: None,
649			cache_dir: None,
650			live_refresh: Some("auto".to_string()),
651		};
652		let mut on_demand = base.clone();
653		on_demand.live_refresh = Some("on-demand".to_string());
654		assert_eq!(
655			stable_config_hash(&base),
656			stable_config_hash(&on_demand),
657			"one workspace must map to one registry slot, whatever the refresh mode"
658		);
659
660		let mut other_project = base.clone();
661		other_project.project = Some("api".to_string());
662		assert_ne!(
663			stable_config_hash(&base),
664			stable_config_hash(&other_project),
665			"what gets indexed still separates registry slots"
666		);
667	}
668
669	#[test]
670	fn malformed_registry_entry_is_not_treated_as_missing_or_unowned() {
671		let workspace = tempfile::tempdir().expect("workspace");
672		let config = config_from_roots([workspace.path()]).expect("workspace config");
673		let path = registry_path_for_config(&config).expect("registry path");
674		fs::create_dir_all(path.parent().expect("registry directory"))
675			.expect("create registry directory");
676		fs::write(&path, "{not-json").expect("write malformed registry entry");
677
678		let read_error = read_registry_entry(&config)
679			.expect_err("malformed registry entry must be a typed read failure");
680		assert!(
681			read_error
682				.to_string()
683				.contains("cannot decode daemon registry entry"),
684			"{read_error:#}"
685		);
686
687		let update_error = update_registry_entry_if_own(&config, &entry("owner", 111))
688			.expect_err("malformed registry entry must not look like an unowned claim");
689		assert!(
690			update_error
691				.to_string()
692				.contains("cannot decode daemon registry entry"),
693			"{update_error:#}"
694		);
695
696		let _ = fs::remove_file(&path);
697		let _ = fs::remove_file(path.with_extension("lock"));
698	}
699
700	#[cfg(unix)]
701	#[test]
702	fn daemon_workspace_rejects_the_filesystem_root() {
703		let config =
704			daemon_workspace_config([Path::new("/")], None, None, Some("auto".to_string()))
705				.expect("filesystem root identity remains available for status and cleanup");
706		let error = validate_daemon_start_config(&config)
707			.expect_err("filesystem root must fail before daemon startup");
708		let message = error.to_string();
709		assert!(message.contains("refusing to start"), "{message}");
710		assert!(message.contains("absolute project path"), "{message}");
711	}
712
713	#[test]
714	fn shutdown_removal_spares_a_successor_entry() {
715		let dir = tempfile::tempdir().expect("tempdir");
716		let path = dir.path().join("ws.json");
717		let old = entry("old-token", 111);
718		let new = entry("new-token", 222);
719
720		fs::write(&path, serde_json::to_string(&new).expect("json")).expect("write");
721		remove_registry_entry_if_own(&path, &old);
722		assert!(path.exists(), "the successor's entry must survive");
723
724		remove_registry_entry_if_own(&path, &new);
725		assert!(!path.exists(), "the owner removes its own entry");
726
727		remove_registry_entry_if_own(&path, &new);
728	}
729
730	#[test]
731	fn legacy_registry_entries_default_missing_build_identity() {
732		let mut value = serde_json::to_value(entry("legacy", 111)).expect("json");
733		value.as_object_mut().expect("object").remove("build");
734		let decoded: DaemonRegistryEntry = serde_json::from_value(value).expect("legacy entry");
735		assert_eq!(decoded.build, BuildIdentity::default());
736	}
737
738	#[test]
739	fn binary_fingerprint_changes_with_binary_content() {
740		let temp = tempfile::tempdir().expect("tempdir");
741		let binary = temp.path().join("code-moniker");
742		fs::write(&binary, b"old build").expect("old build");
743		let old = binary_fingerprint(&binary).expect("old fingerprint");
744		fs::write(&binary, b"new build").expect("new build");
745		let new = binary_fingerprint(&binary).expect("new fingerprint");
746
747		assert_ne!(old, new);
748		assert!(old.starts_with("fnv1a64:"));
749		assert!(new.starts_with("fnv1a64:"));
750	}
751
752	#[test]
753	fn missing_or_old_heartbeat_expires_but_a_fresh_claim_does_not() {
754		let mut registry = entry("heartbeat", 111);
755		registry.heartbeat_unix_ms = 0;
756		assert!(daemon_registry_heartbeat_expired(&registry));
757		registry.heartbeat_unix_ms =
758			registry_heartbeat_unix_ms() - DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS - 1;
759		assert!(daemon_registry_heartbeat_expired(&registry));
760		registry.heartbeat_unix_ms = registry_heartbeat_unix_ms();
761		assert!(!daemon_registry_heartbeat_expired(&registry));
762	}
763
764	#[test]
765	fn atomic_registry_update_replaces_a_complete_entry() {
766		let dir = tempfile::tempdir().expect("tempdir");
767		let path = dir.path().join("workspace.json");
768		let initial = entry("same-daemon", 111);
769		atomic_write_registry_entry(&path, &initial).expect("write initial entry");
770		let updated = DaemonRegistryEntry {
771			heartbeat_unix_ms: initial.heartbeat_unix_ms + 1,
772			..initial.clone()
773		};
774		atomic_write_registry_entry(&path, &updated).expect("write updated entry");
775		let read: DaemonRegistryEntry =
776			serde_json::from_str(&fs::read_to_string(path).expect("read entry")).expect("json");
777		assert_eq!(read, updated);
778	}
779
780	#[test]
781	fn registry_claim_keeps_the_final_path_hidden_until_publication() {
782		let dir = tempfile::tempdir().expect("tempdir");
783		let path = dir.path().join("workspace.json");
784		let owner = entry("owner-token", 111);
785		let timeout = std::time::Duration::from_secs(5);
786		let (prepared_tx, prepared_rx) = std::sync::mpsc::sync_channel(1);
787		let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
788		let claim_path = path.clone();
789		let claim_owner = owner.clone();
790		let claimant = std::thread::spawn(move || {
791			claim_registry_file_before_publish(&claim_path, &claim_owner, || {
792				prepared_tx.send(()).expect("signal prepared claim");
793				publish_rx
794					.recv_timeout(timeout)
795					.expect("receive publication release");
796			})
797		});
798
799		prepared_rx
800			.recv_timeout(timeout)
801			.expect("claimant prepares the complete temporary entry");
802		assert!(
803			!path.exists(),
804			"the final registry path must stay hidden while its complete temporary is pending"
805		);
806		publish_tx.send(()).expect("release registry publication");
807		assert!(
808			claimant
809				.join()
810				.expect("join claimant")
811				.expect("claim registry"),
812			"the first complete entry must win the registry claim"
813		);
814
815		let published: DaemonRegistryEntry = serde_json::from_str(
816			&fs::read_to_string(&path).expect("read published registry entry"),
817		)
818		.expect("published registry entry is complete JSON");
819		assert_eq!(published, owner);
820	}
821
822	#[test]
823	fn concurrent_registry_claims_publish_exactly_one_complete_owner() {
824		let dir = tempfile::tempdir().expect("tempdir");
825		let path = dir.path().join("workspace.json");
826		let first = entry("first-token", 111);
827		let second = entry("second-token", 222);
828		let timeout = std::time::Duration::from_secs(5);
829		let (prepared_tx, prepared_rx) = std::sync::mpsc::sync_channel(2);
830		let spawn_claimant = |claim: DaemonRegistryEntry| {
831			let claim_path = path.clone();
832			let claimant_prepared_tx = prepared_tx.clone();
833			let (publish_tx, publish_rx) = std::sync::mpsc::sync_channel(1);
834			let claimant = std::thread::spawn(move || {
835				claim_registry_file_before_publish(&claim_path, &claim, || {
836					claimant_prepared_tx
837						.send(())
838						.expect("signal prepared claim");
839					publish_rx
840						.recv_timeout(timeout)
841						.expect("receive publication release");
842				})
843			});
844			(claimant, publish_tx)
845		};
846		let (first_claimant, first_publish_tx) = spawn_claimant(first.clone());
847		let (second_claimant, second_publish_tx) = spawn_claimant(second.clone());
848
849		for _ in 0..2 {
850			prepared_rx
851				.recv_timeout(timeout)
852				.expect("claimant prepares a complete temporary entry");
853		}
854		assert!(
855			!path.exists(),
856			"neither prepared claimant may expose the final path before publication"
857		);
858		first_publish_tx
859			.send(())
860			.expect("release first registry publication");
861		second_publish_tx
862			.send(())
863			.expect("release second registry publication");
864		let first_won = first_claimant
865			.join()
866			.expect("join first claimant")
867			.expect("first claim");
868		let second_won = second_claimant
869			.join()
870			.expect("join second claimant")
871			.expect("second claim");
872		assert_ne!(first_won, second_won, "exactly one claimant must win");
873
874		let published: DaemonRegistryEntry = serde_json::from_str(
875			&fs::read_to_string(&path).expect("read published registry entry"),
876		)
877		.expect("published registry entry is complete JSON");
878		assert_eq!(published, if first_won { first } else { second });
879		assert_eq!(
880			fs::read_dir(dir.path()).expect("list registry dir").count(),
881			1,
882			"claim publication must not leave temporary files"
883		);
884	}
885
886	#[cfg(unix)]
887	#[test]
888	fn permission_denied_pid_is_alive_but_missing_pid_is_dead() {
889		assert!(kill_result_means_alive(-1, Some(libc::EPERM)));
890		assert!(!kill_result_means_alive(-1, Some(libc::ESRCH)));
891		assert!(kill_result_means_alive(0, None));
892	}
893
894	#[cfg(windows)]
895	#[test]
896	fn current_windows_process_is_alive() {
897		assert!(pid_is_alive(std::process::id()));
898	}
899
900	#[test]
901	fn registry_directory_honors_the_environment_override() {
902		let expected = tempfile::tempdir()
903			.expect("registry tempdir")
904			.path()
905			.join("custom-registry");
906		let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
907			.args([
908				"--exact",
909				"discovery::tests::registry_directory_environment_child",
910				"--ignored",
911			])
912			.env("CODE_MONIKER_REGISTRY_DIR", &expected)
913			.env("CODE_MONIKER_REGISTRY_TEST_EXPECTED", &expected)
914			.status()
915			.expect("run registry environment child");
916		assert!(status.success());
917	}
918
919	#[test]
920	#[ignore = "subprocess fixture"]
921	fn registry_directory_environment_child() {
922		let Some(expected) = std::env::var_os("CODE_MONIKER_REGISTRY_TEST_EXPECTED") else {
923			return;
924		};
925		assert_eq!(registry_dir(), PathBuf::from(expected));
926	}
927
928	#[test]
929	fn registry_lock_serializes_processes() {
930		let dir = tempfile::tempdir().expect("tempdir");
931		let target = dir.path().join("workspace.json");
932		let started = dir.path().join("child-started");
933		let acquired = dir.path().join("child-acquired");
934		let mut child = None;
935
936		with_registry_lock(&target, || {
937			child = Some(
938				std::process::Command::new(std::env::current_exe()?)
939					.args([
940						"--exact",
941						"discovery::tests::registry_lock_child",
942						"--nocapture",
943					])
944					.env("CODE_MONIKER_LOCK_TEST_TARGET", &target)
945					.env("CODE_MONIKER_LOCK_TEST_STARTED", &started)
946					.env("CODE_MONIKER_LOCK_TEST_ACQUIRED", &acquired)
947					.spawn()?,
948			);
949			let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
950			while !started.exists() && std::time::Instant::now() < deadline {
951				std::thread::sleep(std::time::Duration::from_millis(10));
952			}
953			assert!(started.exists(), "child process did not reach the lock");
954			std::thread::sleep(std::time::Duration::from_millis(200));
955			assert!(
956				!acquired.exists(),
957				"child acquired the registry lock before its owner released it"
958			);
959			Ok(())
960		})
961		.expect("hold parent lock");
962
963		let status = child
964			.expect("child process")
965			.wait()
966			.expect("wait for child");
967		assert!(status.success(), "child lock process failed: {status}");
968		assert!(acquired.exists(), "child never acquired the released lock");
969	}
970
971	#[test]
972	fn registry_lock_child() {
973		let (Some(target), Some(started), Some(acquired)) = (
974			std::env::var_os("CODE_MONIKER_LOCK_TEST_TARGET"),
975			std::env::var_os("CODE_MONIKER_LOCK_TEST_STARTED"),
976			std::env::var_os("CODE_MONIKER_LOCK_TEST_ACQUIRED"),
977		) else {
978			return;
979		};
980		fs::write(&started, b"started").expect("announce child");
981		with_registry_lock(Path::new(&target), || {
982			fs::write(&acquired, b"acquired")?;
983			Ok(())
984		})
985		.expect("acquire child lock");
986	}
987}