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	let path = registry_path_for_config(config)?;
229	let text = serde_json::to_vec_pretty(entry)?;
230	match OpenOptions::new().write(true).create_new(true).open(path) {
231		Ok(mut file) => {
232			file.write_all(&text)?;
233			file.sync_all()?;
234			Ok(true)
235		}
236		Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
237		Err(error) => Err(error.into()),
238	}
239}
240
241pub fn update_registry_entry_if_own(
242	config: &DaemonWorkspaceConfig,
243	entry: &DaemonRegistryEntry,
244) -> anyhow::Result<bool> {
245	let path = registry_path_for_config(config)?;
246	with_registry_lock(&path, || {
247		let current = match fs::read_to_string(&path) {
248			Ok(text) => Some(parse_registry_entry(&path, &text)?),
249			Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
250			Err(error) => {
251				return Err(anyhow::anyhow!(
252					"cannot read daemon registry entry {}: {error}",
253					path.display()
254				));
255			}
256		};
257		let owned = current
258			.map(|current| current.token == entry.token && current.pid == entry.pid)
259			.unwrap_or(false);
260		if owned {
261			atomic_write_registry_entry(&path, entry)?;
262		}
263		Ok(owned)
264	})
265}
266
267fn parse_registry_entry(path: &Path, text: &str) -> anyhow::Result<DaemonRegistryEntry> {
268	serde_json::from_str(text).map_err(|error| {
269		anyhow::anyhow!(
270			"cannot decode daemon registry entry {}: {error}",
271			path.display()
272		)
273	})
274}
275
276fn atomic_write_registry_entry(path: &Path, entry: &DaemonRegistryEntry) -> anyhow::Result<()> {
277	let temp = path.with_extension(format!("{}.tmp", entry.token));
278	let text = serde_json::to_vec_pretty(entry)?;
279	{
280		let mut file = OpenOptions::new()
281			.write(true)
282			.create_new(true)
283			.open(&temp)?;
284		file.write_all(&text)?;
285		file.sync_all()?;
286	}
287	replace_registry_file(&temp, path)?;
288	Ok(())
289}
290
291#[cfg(windows)]
292fn replace_registry_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
293	use windows_sys::Win32::Storage::FileSystem::{
294		MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
295	};
296
297	let source = source
298		.as_os_str()
299		.encode_wide()
300		.chain(std::iter::once(0))
301		.collect::<Vec<_>>();
302	let destination = destination
303		.as_os_str()
304		.encode_wide()
305		.chain(std::iter::once(0))
306		.collect::<Vec<_>>();
307	if unsafe {
308		MoveFileExW(
309			source.as_ptr(),
310			destination.as_ptr(),
311			MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
312		)
313	} == 0
314	{
315		return Err(std::io::Error::last_os_error().into());
316	}
317	Ok(())
318}
319
320#[cfg(not(windows))]
321fn replace_registry_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
322	fs::rename(source, destination)?;
323	Ok(())
324}
325
326// Shutdown-time removal: a successor daemon may have overwritten this path
327// with its own entry while we were stopping. Only unlink what is still ours,
328// or the new daemon stays alive but invisible to the registry.
329pub fn remove_registry_entry_if_own(path: &Path, own: &DaemonRegistryEntry) {
330	let _ = with_registry_lock(path, || {
331		let current = fs::read_to_string(path)
332			.ok()
333			.and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
334		let owned = current
335			.map(|entry| entry.token == own.token && entry.pid == own.pid)
336			.unwrap_or(false);
337		if owned {
338			let _ = fs::remove_file(path);
339		}
340		Ok(())
341	});
342}
343
344#[cfg(unix)]
345fn with_registry_lock<T>(
346	path: &Path,
347	action: impl FnOnce() -> anyhow::Result<T>,
348) -> anyhow::Result<T> {
349	let lock_path = path.with_extension("lock");
350	let lock = OpenOptions::new()
351		.read(true)
352		.write(true)
353		.create(true)
354		.truncate(false)
355		.open(lock_path)?;
356	// SAFETY: flock operates on this process-owned open descriptor. The lock is
357	// released automatically when `lock` is dropped on every return path.
358	if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == -1 {
359		return Err(std::io::Error::last_os_error().into());
360	}
361	action()
362}
363
364#[cfg(not(unix))]
365fn with_registry_lock<T>(
366	path: &Path,
367	action: impl FnOnce() -> anyhow::Result<T>,
368) -> anyhow::Result<T> {
369	#[cfg(windows)]
370	{
371		use windows_sys::Win32::Storage::FileSystem::{
372			LOCKFILE_EXCLUSIVE_LOCK, LockFileEx, UnlockFileEx,
373		};
374		use windows_sys::Win32::System::IO::OVERLAPPED;
375
376		let lock_path = path.with_extension("lock");
377		let lock = OpenOptions::new()
378			.read(true)
379			.write(true)
380			.create(true)
381			.truncate(false)
382			.open(lock_path)?;
383		let mut overlapped = unsafe { std::mem::zeroed::<OVERLAPPED>() };
384		let handle = lock.as_raw_handle();
385		if unsafe {
386			LockFileEx(
387				handle,
388				LOCKFILE_EXCLUSIVE_LOCK,
389				0,
390				u32::MAX,
391				u32::MAX,
392				&mut overlapped,
393			)
394		} == 0
395		{
396			return Err(std::io::Error::last_os_error().into());
397		}
398		let result = action();
399		let unlock = unsafe { UnlockFileEx(handle, 0, u32::MAX, u32::MAX, &mut overlapped) };
400		if unlock == 0 && result.is_ok() {
401			return Err(std::io::Error::last_os_error().into());
402		}
403		result
404	}
405	#[cfg(not(windows))]
406	{
407		let _ = path;
408		action()
409	}
410}
411
412pub fn list_registry_files() -> anyhow::Result<Vec<(PathBuf, DaemonRegistryEntry)>> {
413	let dir = registry_dir();
414	if !dir.exists() {
415		return Ok(Vec::new());
416	}
417	let mut entries = Vec::new();
418	for entry in fs::read_dir(&dir)? {
419		let entry = entry?;
420		if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
421			continue;
422		}
423		let text = fs::read_to_string(entry.path())?;
424		if let Ok(registry) = serde_json::from_str::<DaemonRegistryEntry>(&text) {
425			entries.push((entry.path(), registry));
426		}
427	}
428	entries.sort_by(|(_, a), (_, b)| a.workspace_root.cmp(&b.workspace_root));
429	Ok(entries)
430}
431
432pub fn pid_is_alive(pid: u32) -> bool {
433	#[cfg(unix)]
434	{
435		// SAFETY: signal 0 never delivers a signal; it only asks the kernel
436		// whether the PID exists and whether this process may signal it.
437		let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
438		let errno = (result != 0)
439			.then(|| std::io::Error::last_os_error().raw_os_error())
440			.flatten();
441		kill_result_means_alive(result, errno)
442	}
443	#[cfg(not(unix))]
444	{
445		#[cfg(windows)]
446		{
447			windows_pid_is_alive(pid)
448		}
449		#[cfg(not(windows))]
450		{
451			let _ = pid;
452			false
453		}
454	}
455}
456
457#[cfg(unix)]
458fn kill_result_means_alive(result: i32, errno: Option<i32>) -> bool {
459	result == 0 || errno != Some(libc::ESRCH)
460}
461
462#[cfg(windows)]
463fn windows_pid_is_alive(pid: u32) -> bool {
464	use windows_sys::Win32::Foundation::{
465		CloseHandle, ERROR_ACCESS_DENIED, GetLastError, WAIT_TIMEOUT,
466	};
467	use windows_sys::Win32::System::Threading::{
468		OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
469	};
470
471	let handle = unsafe {
472		OpenProcess(
473			PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
474			0,
475			pid,
476		)
477	};
478	if handle.is_null() {
479		return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
480	}
481	let state = unsafe { WaitForSingleObject(handle, 0) };
482	unsafe {
483		CloseHandle(handle);
484	}
485	state == WAIT_TIMEOUT
486}
487
488pub fn list_registry_entries() -> anyhow::Result<Vec<DaemonRegistryEntry>> {
489	let mut entries = Vec::new();
490	for (path, entry) in list_registry_files()? {
491		if pid_is_alive(entry.pid) {
492			entries.push(entry);
493		} else {
494			remove_registry_entry_if_own(&path, &entry);
495		}
496	}
497	entries.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
498	Ok(entries)
499}
500
501pub fn config_roots(config: &DaemonWorkspaceConfig) -> Vec<PathBuf> {
502	config.roots.iter().map(PathBuf::from).collect()
503}
504
505pub fn workspace_label(roots: &[PathBuf]) -> String {
506	if roots.len() == 1 {
507		roots[0].display().to_string()
508	} else {
509		roots
510			.iter()
511			.map(|root| root.display().to_string())
512			.collect::<Vec<_>>()
513			.join(";")
514	}
515}
516
517fn normalize_path(path: PathBuf) -> anyhow::Result<PathBuf> {
518	if path.is_absolute() {
519		Ok(path)
520	} else {
521		Ok(std::env::current_dir()?.join(path))
522	}
523}
524
525// The registry key is the workspace identity: what gets indexed (roots,
526// project, cache), never how it refreshes. Hashing live_refresh here once
527// split one workspace across two registry slots — a daemon started with
528// `--live-refresh auto` was invisible to a default-mode `daemon status`.
529fn stable_config_hash(config: &DaemonWorkspaceConfig) -> String {
530	let mut hasher = StableHasher::default();
531	for root in &config.roots {
532		root.hash(&mut hasher);
533		0xff_u8.hash(&mut hasher);
534	}
535	config.project.hash(&mut hasher);
536	0xfe_u8.hash(&mut hasher);
537	config.cache_dir.hash(&mut hasher);
538	format!("{:016x}", hasher.finish())
539}
540
541#[derive(Default)]
542struct StableHasher(u64);
543
544impl Hasher for StableHasher {
545	fn finish(&self) -> u64 {
546		self.0
547	}
548
549	fn write(&mut self, bytes: &[u8]) {
550		let mut hash = if self.0 == 0 {
551			0xcbf29ce484222325
552		} else {
553			self.0
554		};
555		for byte in bytes {
556			hash ^= u64::from(*byte);
557			hash = hash.wrapping_mul(0x100000001b3);
558		}
559		self.0 = hash;
560	}
561}
562
563#[cfg(test)]
564mod tests {
565	use super::*;
566
567	fn entry(token: &str, pid: u32) -> DaemonRegistryEntry {
568		DaemonRegistryEntry {
569			workspace_root: "/tmp/ws".to_string(),
570			workspace_roots: vec!["/tmp/ws".to_string()],
571			project: None,
572			cache_dir: None,
573			live_refresh: None,
574			endpoint: "127.0.0.1:1".to_string(),
575			token: token.to_string(),
576			pid,
577			build: BuildIdentity::default(),
578			heartbeat_unix_ms: registry_heartbeat_unix_ms(),
579		}
580	}
581
582	#[test]
583	fn registry_identity_ignores_the_refresh_mode() {
584		let base = DaemonWorkspaceConfig {
585			roots: vec!["/tmp/ws".to_string()],
586			project: None,
587			cache_dir: None,
588			live_refresh: Some("auto".to_string()),
589		};
590		let mut on_demand = base.clone();
591		on_demand.live_refresh = Some("on-demand".to_string());
592		assert_eq!(
593			stable_config_hash(&base),
594			stable_config_hash(&on_demand),
595			"one workspace must map to one registry slot, whatever the refresh mode"
596		);
597
598		let mut other_project = base.clone();
599		other_project.project = Some("api".to_string());
600		assert_ne!(
601			stable_config_hash(&base),
602			stable_config_hash(&other_project),
603			"what gets indexed still separates registry slots"
604		);
605	}
606
607	#[test]
608	fn malformed_registry_entry_is_not_treated_as_missing_or_unowned() {
609		let workspace = tempfile::tempdir().expect("workspace");
610		let config = config_from_roots([workspace.path()]).expect("workspace config");
611		let path = registry_path_for_config(&config).expect("registry path");
612		fs::create_dir_all(path.parent().expect("registry directory"))
613			.expect("create registry directory");
614		fs::write(&path, "{not-json").expect("write malformed registry entry");
615
616		let read_error = read_registry_entry(&config)
617			.expect_err("malformed registry entry must be a typed read failure");
618		assert!(
619			read_error
620				.to_string()
621				.contains("cannot decode daemon registry entry"),
622			"{read_error:#}"
623		);
624
625		let update_error = update_registry_entry_if_own(&config, &entry("owner", 111))
626			.expect_err("malformed registry entry must not look like an unowned claim");
627		assert!(
628			update_error
629				.to_string()
630				.contains("cannot decode daemon registry entry"),
631			"{update_error:#}"
632		);
633
634		let _ = fs::remove_file(&path);
635		let _ = fs::remove_file(path.with_extension("lock"));
636	}
637
638	#[cfg(unix)]
639	#[test]
640	fn daemon_workspace_rejects_the_filesystem_root() {
641		let config =
642			daemon_workspace_config([Path::new("/")], None, None, Some("auto".to_string()))
643				.expect("filesystem root identity remains available for status and cleanup");
644		let error = validate_daemon_start_config(&config)
645			.expect_err("filesystem root must fail before daemon startup");
646		let message = error.to_string();
647		assert!(message.contains("refusing to start"), "{message}");
648		assert!(message.contains("absolute project path"), "{message}");
649	}
650
651	#[test]
652	fn shutdown_removal_spares_a_successor_entry() {
653		let dir = tempfile::tempdir().expect("tempdir");
654		let path = dir.path().join("ws.json");
655		let old = entry("old-token", 111);
656		let new = entry("new-token", 222);
657
658		fs::write(&path, serde_json::to_string(&new).expect("json")).expect("write");
659		remove_registry_entry_if_own(&path, &old);
660		assert!(path.exists(), "the successor's entry must survive");
661
662		remove_registry_entry_if_own(&path, &new);
663		assert!(!path.exists(), "the owner removes its own entry");
664
665		remove_registry_entry_if_own(&path, &new);
666	}
667
668	#[test]
669	fn legacy_registry_entries_default_missing_build_identity() {
670		let mut value = serde_json::to_value(entry("legacy", 111)).expect("json");
671		value.as_object_mut().expect("object").remove("build");
672		let decoded: DaemonRegistryEntry = serde_json::from_value(value).expect("legacy entry");
673		assert_eq!(decoded.build, BuildIdentity::default());
674	}
675
676	#[test]
677	fn binary_fingerprint_changes_with_binary_content() {
678		let temp = tempfile::tempdir().expect("tempdir");
679		let binary = temp.path().join("code-moniker");
680		fs::write(&binary, b"old build").expect("old build");
681		let old = binary_fingerprint(&binary).expect("old fingerprint");
682		fs::write(&binary, b"new build").expect("new build");
683		let new = binary_fingerprint(&binary).expect("new fingerprint");
684
685		assert_ne!(old, new);
686		assert!(old.starts_with("fnv1a64:"));
687		assert!(new.starts_with("fnv1a64:"));
688	}
689
690	#[test]
691	fn missing_or_old_heartbeat_expires_but_a_fresh_claim_does_not() {
692		let mut registry = entry("heartbeat", 111);
693		registry.heartbeat_unix_ms = 0;
694		assert!(daemon_registry_heartbeat_expired(&registry));
695		registry.heartbeat_unix_ms =
696			registry_heartbeat_unix_ms() - DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS - 1;
697		assert!(daemon_registry_heartbeat_expired(&registry));
698		registry.heartbeat_unix_ms = registry_heartbeat_unix_ms();
699		assert!(!daemon_registry_heartbeat_expired(&registry));
700	}
701
702	#[test]
703	fn atomic_registry_update_replaces_a_complete_entry() {
704		let dir = tempfile::tempdir().expect("tempdir");
705		let path = dir.path().join("workspace.json");
706		let initial = entry("same-daemon", 111);
707		atomic_write_registry_entry(&path, &initial).expect("write initial entry");
708		let updated = DaemonRegistryEntry {
709			heartbeat_unix_ms: initial.heartbeat_unix_ms + 1,
710			..initial.clone()
711		};
712		atomic_write_registry_entry(&path, &updated).expect("write updated entry");
713		let read: DaemonRegistryEntry =
714			serde_json::from_str(&fs::read_to_string(path).expect("read entry")).expect("json");
715		assert_eq!(read, updated);
716	}
717
718	#[cfg(unix)]
719	#[test]
720	fn permission_denied_pid_is_alive_but_missing_pid_is_dead() {
721		assert!(kill_result_means_alive(-1, Some(libc::EPERM)));
722		assert!(!kill_result_means_alive(-1, Some(libc::ESRCH)));
723		assert!(kill_result_means_alive(0, None));
724	}
725
726	#[cfg(windows)]
727	#[test]
728	fn current_windows_process_is_alive() {
729		assert!(pid_is_alive(std::process::id()));
730	}
731
732	#[test]
733	fn registry_directory_honors_the_environment_override() {
734		let expected = tempfile::tempdir()
735			.expect("registry tempdir")
736			.path()
737			.join("custom-registry");
738		let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
739			.args([
740				"--exact",
741				"discovery::tests::registry_directory_environment_child",
742				"--ignored",
743			])
744			.env("CODE_MONIKER_REGISTRY_DIR", &expected)
745			.env("CODE_MONIKER_REGISTRY_TEST_EXPECTED", &expected)
746			.status()
747			.expect("run registry environment child");
748		assert!(status.success());
749	}
750
751	#[test]
752	#[ignore = "subprocess fixture"]
753	fn registry_directory_environment_child() {
754		let Some(expected) = std::env::var_os("CODE_MONIKER_REGISTRY_TEST_EXPECTED") else {
755			return;
756		};
757		assert_eq!(registry_dir(), PathBuf::from(expected));
758	}
759
760	#[test]
761	fn registry_lock_serializes_processes() {
762		let dir = tempfile::tempdir().expect("tempdir");
763		let target = dir.path().join("workspace.json");
764		let started = dir.path().join("child-started");
765		let acquired = dir.path().join("child-acquired");
766		let mut child = None;
767
768		with_registry_lock(&target, || {
769			child = Some(
770				std::process::Command::new(std::env::current_exe()?)
771					.args([
772						"--exact",
773						"discovery::tests::registry_lock_child",
774						"--nocapture",
775					])
776					.env("CODE_MONIKER_LOCK_TEST_TARGET", &target)
777					.env("CODE_MONIKER_LOCK_TEST_STARTED", &started)
778					.env("CODE_MONIKER_LOCK_TEST_ACQUIRED", &acquired)
779					.spawn()?,
780			);
781			let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
782			while !started.exists() && std::time::Instant::now() < deadline {
783				std::thread::sleep(std::time::Duration::from_millis(10));
784			}
785			assert!(started.exists(), "child process did not reach the lock");
786			std::thread::sleep(std::time::Duration::from_millis(200));
787			assert!(
788				!acquired.exists(),
789				"child acquired the registry lock before its owner released it"
790			);
791			Ok(())
792		})
793		.expect("hold parent lock");
794
795		let status = child
796			.expect("child process")
797			.wait()
798			.expect("wait for child");
799		assert!(status.success(), "child lock process failed: {status}");
800		assert!(acquired.exists(), "child never acquired the released lock");
801	}
802
803	#[test]
804	fn registry_lock_child() {
805		let (Some(target), Some(started), Some(acquired)) = (
806			std::env::var_os("CODE_MONIKER_LOCK_TEST_TARGET"),
807			std::env::var_os("CODE_MONIKER_LOCK_TEST_STARTED"),
808			std::env::var_os("CODE_MONIKER_LOCK_TEST_ACQUIRED"),
809		) else {
810			return;
811		};
812		fs::write(&started, b"started").expect("announce child");
813		with_registry_lock(Path::new(&target), || {
814			fs::write(&acquired, b"acquired")?;
815			Ok(())
816		})
817		.expect("acquire child lock");
818	}
819}