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(®istry_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
326pub 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 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 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
525fn 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(®istry));
695 registry.heartbeat_unix_ms =
696 registry_heartbeat_unix_ms() - DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS - 1;
697 assert!(daemon_registry_heartbeat_expired(®istry));
698 registry.heartbeat_unix_ms = registry_heartbeat_unix_ms();
699 assert!(!daemon_registry_heartbeat_expired(®istry));
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}