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 claim_registry_file(®istry_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
388pub 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 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 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
587fn 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(®istry));
757 registry.heartbeat_unix_ms =
758 registry_heartbeat_unix_ms() - DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS - 1;
759 assert!(daemon_registry_heartbeat_expired(®istry));
760 registry.heartbeat_unix_ms = registry_heartbeat_unix_ms();
761 assert!(!daemon_registry_heartbeat_expired(®istry));
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}