Skip to main content

code_moniker_daemon_client/
lib.rs

1use std::future::Future;
2use std::io::{Read, Seek, SeekFrom};
3use std::ops::Deref;
4use std::path::{Path, PathBuf};
5use std::process::{Command as ProcessCommand, Stdio};
6use std::sync::Arc;
7use std::thread;
8use std::time::Duration;
9
10use code_moniker_query::{
11	Command, CommandRequest, CommandResponse, DaemonRpcClient, DaemonWorkspaceConfig,
12	HandshakeResponse, PROTOCOL_VERSION, QueryError, QueryRequest, QueryResponse,
13	current_build_identity,
14};
15use jsonrpsee::core::ClientError;
16use jsonrpsee::ws_client::{WsClient, WsClientBuilder};
17use tokio::runtime::Runtime;
18
19use code_moniker_query::{daemon_registry_heartbeat_expired, list_registry_files, pid_is_alive};
20
21const DAEMON_SERVING_ATTEMPTS: usize = 50;
22const DAEMON_SERVING_CONNECT_ATTEMPTS: usize = 10;
23const DAEMON_SERVING_POLL: Duration = Duration::from_millis(100);
24
25pub use code_moniker_query::{
26	DaemonRegistryEntry, WorkspaceSourceDocumentDto, WorkspaceSourceSetDto,
27	canonical_workspace_config, canonical_workspace_root, canonical_workspace_roots,
28	config_from_roots, config_roots, daemon_log_path_for_config, daemon_workspace_config,
29	list_registry_entries, read_registry_entry, registry_dir, registry_path_for_config,
30	registry_path_for_root, registry_path_for_roots, remove_registry_entry_if_own,
31	validate_daemon_start_config, workspace_label,
32};
33
34#[derive(Clone)]
35pub struct DaemonClient {
36	connection: DaemonConnection,
37	endpoint: DaemonEndpoint,
38}
39
40#[derive(Clone)]
41struct DaemonEndpoint {
42	config: DaemonWorkspaceConfig,
43	roots: Vec<PathBuf>,
44	address: String,
45}
46
47#[derive(Clone)]
48struct RegisteredDaemon {
49	path: PathBuf,
50	entry: DaemonRegistryEntry,
51}
52
53#[derive(Clone)]
54pub struct DaemonConnection {
55	runtime: Arc<Runtime>,
56	ws: Arc<WsClient>,
57	handshake: HandshakeResponse,
58}
59
60impl DaemonClient {
61	pub fn connect(roots: Vec<PathBuf>) -> anyhow::Result<Self> {
62		Self::connect_config(config_from_roots(roots)?)
63	}
64
65	pub fn connect_config(config: DaemonWorkspaceConfig) -> anyhow::Result<Self> {
66		let config = canonical_workspace_config(config)?;
67		let Some(entry) = read_registry_entry(&config)? else {
68			return Err(no_daemon_registered_error(&config));
69		};
70		connect_entry(config, entry)
71	}
72
73	pub fn connect_endpoint(endpoint: &str) -> anyhow::Result<Self> {
74		let entry = registry_entry_for_endpoint(endpoint)?;
75		let config = config_from_registry_entry(&entry);
76		connect_entry(config, entry)
77	}
78
79	pub fn connect_or_start(roots: Vec<PathBuf>) -> anyhow::Result<Self> {
80		Self::connect_or_start_config(config_from_roots(roots)?)
81	}
82
83	pub fn connect_or_start_config(config: DaemonWorkspaceConfig) -> anyhow::Result<Self> {
84		let config = canonical_workspace_config(config)?;
85		validate_daemon_start_config(&config)?;
86		if let Some(client) = connect_registered_daemon(&config)? {
87			return Ok(client);
88		}
89		start_compatible_daemon(config)
90	}
91
92	pub fn connect_or_start_supporting(
93		config: DaemonWorkspaceConfig,
94		capability: &str,
95	) -> anyhow::Result<Self> {
96		let client = Self::connect_or_start_config(config.clone())?;
97		if client.supports_query(capability)? {
98			return Ok(client);
99		}
100		restart_for_capability(client, config, capability)
101	}
102
103	pub fn root(&self) -> &Path {
104		&self.endpoint.roots[0]
105	}
106
107	pub fn roots(&self) -> &[PathBuf] {
108		&self.endpoint.roots
109	}
110
111	pub fn config(&self) -> &DaemonWorkspaceConfig {
112		&self.endpoint.config
113	}
114
115	pub fn endpoint(&self) -> &str {
116		&self.endpoint.address
117	}
118}
119
120pub fn no_daemon_registered_error(config: &DaemonWorkspaceConfig) -> anyhow::Error {
121	anyhow::anyhow!(
122		"no daemon registered for {}{}",
123		workspace_label(&config_roots(config)),
124		daemon_diagnostic_suffix(config)
125	)
126}
127
128pub fn registry_entry_for_endpoint(endpoint: &str) -> anyhow::Result<DaemonRegistryEntry> {
129	let matches = list_registry_entries()?
130		.into_iter()
131		.filter(|entry| entry.endpoint == endpoint)
132		.collect::<Vec<_>>();
133	match matches.as_slice() {
134		[] => anyhow::bail!(
135			"no daemon registered at endpoint {endpoint}; run `code-moniker daemon list`"
136		),
137		[entry] => Ok(entry.clone()),
138		_ => anyhow::bail!("multiple daemons registered at endpoint {endpoint}"),
139	}
140}
141
142impl Deref for DaemonClient {
143	type Target = DaemonConnection;
144
145	fn deref(&self) -> &Self::Target {
146		&self.connection
147	}
148}
149
150impl DaemonConnection {
151	pub fn handshake(&self, _client: &str) -> anyhow::Result<HandshakeResponse> {
152		Ok(self.handshake.clone())
153	}
154
155	pub fn supports_query(&self, capability: &str) -> anyhow::Result<bool> {
156		let handshake = self.handshake("daemon-client")?;
157		Ok(handshake
158			.capabilities
159			.queries
160			.iter()
161			.any(|verb| verb == capability))
162	}
163
164	pub fn query(&self, request: QueryRequest) -> anyhow::Result<QueryResponse> {
165		validate_compatibility(&self.handshake)?;
166		self.block(self.ws.query(request)).map_err(rpc_client_error)
167	}
168
169	pub fn command(&self, request: CommandRequest) -> anyhow::Result<String> {
170		Ok(self.command_response(request)?.message)
171	}
172
173	pub fn command_response(&self, request: CommandRequest) -> anyhow::Result<CommandResponse> {
174		validate_compatibility(&self.handshake)?;
175		self.block(self.ws.command(request))
176			.map_err(rpc_client_error)
177	}
178
179	pub fn replace_source_set(
180		&self,
181		source_set: WorkspaceSourceSetDto,
182	) -> anyhow::Result<CommandResponse> {
183		self.command_response(CommandRequest {
184			command: Command::WorkspaceSourceSetReplace { source_set },
185		})
186	}
187
188	pub fn remove_source_set(&self, srcset: impl Into<String>) -> anyhow::Result<CommandResponse> {
189		self.command_response(CommandRequest {
190			command: Command::WorkspaceSourceSetRemove {
191				srcset: srcset.into(),
192			},
193		})
194	}
195
196	pub fn shutdown(&self) -> anyhow::Result<()> {
197		self.block(self.ws.shutdown())
198			.map_err(|err| anyhow::anyhow!("{err}"))
199	}
200
201	fn block<F: Future>(&self, fut: F) -> F::Output {
202		self.runtime.block_on(fut)
203	}
204}
205
206fn connect_entry(
207	config: DaemonWorkspaceConfig,
208	entry: DaemonRegistryEntry,
209) -> anyhow::Result<DaemonClient> {
210	let runtime = Arc::new(build_runtime()?);
211	let url = format!("ws://{}", entry.endpoint);
212	let ws = runtime.block_on(async { WsClientBuilder::default().build(&url).await })?;
213	let handshake = runtime
214		.block_on(ws.handshake("daemon-client".to_string()))
215		.map_err(|err| anyhow::anyhow!("{err}"))?;
216	validate_workspace(config_roots(&config), &handshake)?;
217	let client = DaemonClient {
218		connection: DaemonConnection {
219			runtime,
220			ws: Arc::new(ws),
221			handshake,
222		},
223		endpoint: DaemonEndpoint {
224			roots: config_roots(&config),
225			config,
226			address: entry.endpoint,
227		},
228	};
229	Ok(client)
230}
231
232fn config_from_registry_entry(entry: &DaemonRegistryEntry) -> DaemonWorkspaceConfig {
233	let entry = entry.clone();
234	DaemonWorkspaceConfig {
235		roots: entry.workspace_roots,
236		project: entry.project,
237		cache_dir: entry.cache_dir,
238		live_refresh: entry.live_refresh,
239	}
240}
241
242fn validate_client_protocol(client: &DaemonClient) -> anyhow::Result<()> {
243	let handshake = client.handshake("daemon-client")?;
244	validate_compatibility(&handshake)
245}
246
247fn validate_compatibility(handshake: &HandshakeResponse) -> anyhow::Result<()> {
248	validate_protocol(handshake)?;
249	validate_build(handshake)
250}
251
252fn validate_protocol(handshake: &HandshakeResponse) -> anyhow::Result<()> {
253	if handshake.protocol_version == PROTOCOL_VERSION {
254		return Ok(());
255	}
256	if handshake.protocol_version < PROTOCOL_VERSION {
257		anyhow::bail!(
258			"daemon protocol {} is older than client protocol {} (daemon version {}); reconnect-or-start must recycle the daemon once so it can rebuild the index",
259			handshake.protocol_version,
260			PROTOCOL_VERSION,
261			handshake.daemon_version
262		)
263	}
264	anyhow::bail!(
265		"client protocol {} is older than daemon protocol {} (daemon version {}); update the client, the newer daemon was left running",
266		PROTOCOL_VERSION,
267		handshake.protocol_version,
268		handshake.daemon_version
269	)
270}
271
272fn validate_build(handshake: &HandshakeResponse) -> anyhow::Result<()> {
273	let client = current_build_identity(env!("CARGO_PKG_VERSION"))?;
274	if handshake.build == client {
275		return Ok(());
276	}
277	anyhow::bail!(
278		"daemon build {} ({}) does not match client build {} ({}); restart code-moniker so the snapshot producer matches the client",
279		handshake.build.version,
280		handshake.build.fingerprint,
281		client.version,
282		client.fingerprint
283	)
284}
285
286fn validate_workspace(
287	expected_roots: Vec<PathBuf>,
288	handshake: &HandshakeResponse,
289) -> anyhow::Result<()> {
290	let mut expected = expected_roots
291		.into_iter()
292		.map(|root| root.display().to_string())
293		.collect::<Vec<_>>();
294	let mut actual = handshake.workspace_roots.clone();
295	expected.sort();
296	actual.sort();
297	if expected == actual {
298		return Ok(());
299	}
300	anyhow::bail!(
301		"daemon workspace mismatch: expected [{}], daemon serves [{}]",
302		expected.join(", "),
303		actual.join(", ")
304	)
305}
306
307fn connect_registered_daemon(
308	config: &DaemonWorkspaceConfig,
309) -> anyhow::Result<Option<DaemonClient>> {
310	connect_registered_daemon_with_limits(
311		config,
312		DAEMON_SERVING_ATTEMPTS,
313		DAEMON_SERVING_CONNECT_ATTEMPTS,
314		DAEMON_SERVING_POLL,
315	)
316}
317
318fn connect_registered_daemon_with_limits(
319	config: &DaemonWorkspaceConfig,
320	serving_attempts: usize,
321	serving_connect_attempts: usize,
322	poll: Duration,
323) -> anyhow::Result<Option<DaemonClient>> {
324	let Some(registered) = registry_entry_for(config)? else {
325		return Ok(None);
326	};
327	let client = match wait_for_daemon_with_limits(
328		config.clone(),
329		serving_attempts,
330		serving_connect_attempts,
331		poll,
332	) {
333		Ok(client) => client,
334		Err(error) => {
335			let current = read_registry_entry(config)?;
336			let same_fresh_claim = current.as_ref().is_some_and(|current| {
337				current.pid == registered.entry.pid
338					&& current.token == registered.entry.token
339					&& pid_is_alive(current.pid)
340					&& !daemon_registry_heartbeat_expired(current)
341			});
342			if same_fresh_claim {
343				anyhow::bail!(
344					"registered daemon pid {} for {} is alive but its endpoint is unavailable; stop that process before retrying: {error:#}",
345					registered.entry.pid,
346					registered.entry.workspace_root
347				)
348			}
349			remove_registry_entry_if_own(&registered.path, &registered.entry);
350			return Ok(None);
351		}
352	};
353	let handshake = client.handshake("daemon-client")?;
354	let client_build = current_build_identity(env!("CARGO_PKG_VERSION"))?;
355	match compatibility_action(&handshake, &client_build) {
356		CompatibilityAction::Reuse => return Ok(Some(client)),
357		CompatibilityAction::RejectClient => {
358			return validate_client_protocol(&client).map(|()| Some(client));
359		}
360		CompatibilityAction::RestartDaemon => {}
361	}
362	let _ = client.shutdown();
363	drop(client);
364	wait_for_deregistration(config);
365	let _ = cleanup_stale_config(config);
366	Ok(None)
367}
368
369#[derive(Clone, Copy, Debug, Eq, PartialEq)]
370enum CompatibilityAction {
371	Reuse,
372	RestartDaemon,
373	RejectClient,
374}
375
376fn compatibility_action(
377	handshake: &HandshakeResponse,
378	client_build: &code_moniker_query::BuildIdentity,
379) -> CompatibilityAction {
380	if handshake.protocol_version < PROTOCOL_VERSION {
381		return CompatibilityAction::RestartDaemon;
382	}
383	if handshake.protocol_version > PROTOCOL_VERSION {
384		return CompatibilityAction::RejectClient;
385	}
386	if &handshake.build != client_build {
387		return CompatibilityAction::RestartDaemon;
388	}
389	CompatibilityAction::Reuse
390}
391
392fn start_compatible_daemon(config: DaemonWorkspaceConfig) -> anyhow::Result<DaemonClient> {
393	start_daemon_process(&config)?;
394	let client = wait_for_daemon(config)?;
395	validate_client_protocol(&client)?;
396	Ok(client)
397}
398
399fn restart_for_capability(
400	client: DaemonClient,
401	config: DaemonWorkspaceConfig,
402	capability: &str,
403) -> anyhow::Result<DaemonClient> {
404	let _ = client.shutdown();
405	drop(client);
406	let config = canonical_workspace_config(config)?;
407	wait_for_deregistration(&config);
408	let _ = cleanup_stale_config(&config);
409	let client = start_compatible_daemon(config)?;
410	if !client.supports_query(capability)? {
411		anyhow::bail!(
412			"the code-moniker daemon binary predates `{capability}`; update code-moniker and retry"
413		);
414	}
415	Ok(client)
416}
417
418fn registry_entry_for(config: &DaemonWorkspaceConfig) -> anyhow::Result<Option<RegisteredDaemon>> {
419	let registry_path = registry_path_for_config(config)?;
420	if let Some(entry) = read_registry_entry(config)? {
421		if pid_is_alive(entry.pid) {
422			return Ok(Some(RegisteredDaemon {
423				path: registry_path,
424				entry,
425			}));
426		}
427		remove_registry_entry_if_own(&registry_path, &entry);
428	}
429	for (path, entry) in list_registry_files()? {
430		if !pid_is_alive(entry.pid) {
431			remove_registry_entry_if_own(&path, &entry);
432			continue;
433		}
434		if registry_entry_matches_config(config, &entry) && pid_is_alive(entry.pid) {
435			return Ok(Some(RegisteredDaemon { path, entry }));
436		}
437	}
438	Ok(None)
439}
440
441fn registry_entry_matches_config(
442	config: &DaemonWorkspaceConfig,
443	entry: &DaemonRegistryEntry,
444) -> bool {
445	let mut expected_roots = config.roots.clone();
446	let mut actual_roots = entry.workspace_roots.clone();
447	expected_roots.sort();
448	actual_roots.sort();
449	expected_roots == actual_roots
450		&& config.project == entry.project
451		&& config.cache_dir == entry.cache_dir
452}
453
454fn build_runtime() -> anyhow::Result<Runtime> {
455	Ok(tokio::runtime::Builder::new_multi_thread()
456		.worker_threads(2)
457		.enable_all()
458		.thread_name("code-moniker-daemon-client")
459		.build()?)
460}
461
462// After asking an outdated daemon to shut down, give it a moment to leave
463// the registry so the fresh start does not race its guarded removal.
464fn wait_for_deregistration(config: &DaemonWorkspaceConfig) {
465	for _ in 0..30 {
466		match read_registry_entry(config) {
467			Ok(Some(entry)) if pid_is_alive(entry.pid) => {
468				thread::sleep(Duration::from_millis(100));
469			}
470			_ => return,
471		}
472	}
473}
474
475fn wait_for_daemon(config: DaemonWorkspaceConfig) -> anyhow::Result<DaemonClient> {
476	wait_for_daemon_with_limits(
477		config,
478		DAEMON_SERVING_ATTEMPTS,
479		DAEMON_SERVING_CONNECT_ATTEMPTS,
480		DAEMON_SERVING_POLL,
481	)
482}
483
484fn wait_for_daemon_with_limits(
485	config: DaemonWorkspaceConfig,
486	serving_attempts: usize,
487	serving_connect_attempts: usize,
488	poll: Duration,
489) -> anyhow::Result<DaemonClient> {
490	let mut last_error = None;
491	let mut serving_connect_failures = 0;
492	for _ in 0..serving_attempts {
493		if let Some(registered) = registry_entry_for(&config)? {
494			match connect_entry(config.clone(), registered.entry) {
495				Ok(client) => return Ok(client),
496				Err(error) if error.to_string().contains("daemon workspace mismatch") => {
497					return Err(error);
498				}
499				Err(error) => {
500					serving_connect_failures += 1;
501					last_error = Some(error);
502					if serving_connect_failures >= serving_connect_attempts {
503						break;
504					}
505				}
506			}
507		}
508		thread::sleep(poll);
509	}
510	let workspace = workspace_label(&config_roots(&config));
511	let diagnostic = daemon_diagnostic_suffix(&config);
512	match last_error {
513		Some(error) => anyhow::bail!(
514			"daemon endpoint remained unusable for {workspace} after {serving_connect_failures} connection attempts: {error:#}{diagnostic}"
515		),
516		None => {
517			let timeout_seconds = (serving_attempts as u128 * poll.as_millis()) / 1_000;
518			anyhow::bail!(
519				"daemon did not publish a serving endpoint for {workspace} after {timeout_seconds}s{diagnostic}"
520			)
521		}
522	}
523}
524
525pub fn cleanup_stale_entry(roots: Vec<PathBuf>) -> anyhow::Result<()> {
526	cleanup_stale_config(&config_from_roots(roots)?)
527}
528
529pub fn cleanup_stale_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<()> {
530	let path = registry_path_for_config(config)?;
531	if let Some(entry) = read_registry_entry(config)?
532		&& !pid_is_alive(entry.pid)
533	{
534		remove_registry_entry_if_own(&path, &entry);
535	}
536	Ok(())
537}
538
539fn start_daemon_process(config: &DaemonWorkspaceConfig) -> anyhow::Result<()> {
540	let exe = std::env::current_exe()?;
541	let diagnostic_path = daemon_log_path_for_config(config)?;
542	if let Some(parent) = diagnostic_path.parent() {
543		std::fs::create_dir_all(parent)?;
544	}
545	let diagnostic = std::fs::OpenOptions::new()
546		.create(true)
547		.append(true)
548		.open(&diagnostic_path)?;
549	let mut command = ProcessCommand::new(exe);
550	command
551		.arg("daemon")
552		.arg("start")
553		.stdin(Stdio::null())
554		.stdout(Stdio::null())
555		.stderr(Stdio::from(diagnostic));
556	if let Some(project) = &config.project {
557		command.arg("--project").arg(project);
558	}
559	if let Some(cache_dir) = &config.cache_dir {
560		command.arg("--cache").arg(cache_dir);
561	}
562	if let Some(live_refresh) = &config.live_refresh {
563		command.arg("--live-refresh").arg(live_refresh);
564	}
565	for root in config_roots(config) {
566		command.arg(root);
567	}
568	command.spawn().map(|_| ()).map_err(|err| {
569		anyhow::anyhow!(
570			"cannot start daemon for {}: {err}",
571			workspace_label(&config_roots(config))
572		)
573	})
574}
575
576fn daemon_diagnostic_suffix(config: &DaemonWorkspaceConfig) -> String {
577	const MAX_DIAGNOSTIC_BYTES: u64 = 8 * 1024;
578	let Ok(path) = daemon_log_path_for_config(config) else {
579		return String::new();
580	};
581	let Ok(mut file) = std::fs::File::open(&path) else {
582		return format!("\ndaemon diagnostics: {} (not written)", path.display());
583	};
584	let length = file.metadata().map(|metadata| metadata.len()).unwrap_or(0);
585	let start = length.saturating_sub(MAX_DIAGNOSTIC_BYTES);
586	if file.seek(SeekFrom::Start(start)).is_err() {
587		return format!("\ndaemon diagnostics: {} (unreadable)", path.display());
588	}
589	let mut bytes = Vec::new();
590	if file.read_to_end(&mut bytes).is_err() {
591		return format!("\ndaemon diagnostics: {} (unreadable)", path.display());
592	}
593	let text = String::from_utf8_lossy(&bytes);
594	let text = if start > 0 {
595		text.split_once('\n').map(|(_, tail)| tail).unwrap_or(&text)
596	} else {
597		&text
598	};
599	format!(
600		"\ndaemon diagnostics ({}):\n{}",
601		path.display(),
602		text.trim()
603	)
604}
605
606fn rpc_client_error(error: ClientError) -> anyhow::Error {
607	if let ClientError::Call(error) = &error
608		&& let Some(data) = error.data()
609		&& let Ok(query_error) = serde_json::from_str::<QueryError>(data.get())
610	{
611		return anyhow::anyhow!("{query_error}");
612	}
613	anyhow::anyhow!("{error}")
614}
615
616#[cfg(test)]
617mod tests {
618	use std::net::TcpListener;
619
620	use code_moniker_query::CapabilitySet;
621	use code_moniker_query::write_registry_entry;
622
623	use super::*;
624
625	fn handshake(protocol_version: u32) -> HandshakeResponse {
626		HandshakeResponse {
627			protocol_version,
628			daemon_version: "test".to_string(),
629			build: current_build_identity(env!("CARGO_PKG_VERSION")).expect("test build"),
630			workspace_root: "/workspace".to_string(),
631			workspace_roots: vec!["/workspace".to_string()],
632			capabilities: CapabilitySet::default(),
633		}
634	}
635
636	fn registry_entry(roots: &[&str]) -> DaemonRegistryEntry {
637		DaemonRegistryEntry {
638			workspace_root: roots.join(","),
639			workspace_roots: roots.iter().map(|root| (*root).to_string()).collect(),
640			project: None,
641			cache_dir: None,
642			live_refresh: Some("on-demand".to_string()),
643			endpoint: "127.0.0.1:1234".to_string(),
644			token: "test".to_string(),
645			pid: std::process::id(),
646			build: code_moniker_query::BuildIdentity::default(),
647			heartbeat_unix_ms: code_moniker_query::registry_heartbeat_unix_ms(),
648		}
649	}
650
651	#[test]
652	fn accepts_current_protocol() {
653		validate_compatibility(&handshake(PROTOCOL_VERSION)).expect("current compatibility");
654	}
655
656	#[test]
657	fn preserves_structured_query_errors_without_json_rpc_debug_noise() {
658		let uri = "rs:workspace.fn:missing()";
659		let query_error = QueryError::new("symbol_not_found", format!("symbol not found: {uri}"));
660		let rpc_error = jsonrpsee::types::ErrorObjectOwned::owned(
661			jsonrpsee::types::error::INTERNAL_ERROR_CODE,
662			query_error.message.clone(),
663			Some(query_error),
664		);
665
666		let error = rpc_client_error(ClientError::Call(rpc_error));
667
668		assert_eq!(
669			error.to_string(),
670			format!("symbol_not_found: symbol not found: {uri}")
671		);
672		assert!(!error.to_string().contains("ErrorObject"), "{error}");
673		assert!(!error.to_string().contains("RawValue"), "{error}");
674	}
675
676	#[test]
677	fn rejects_a_build_mismatch_even_when_protocol_matches() {
678		let mut daemon = handshake(PROTOCOL_VERSION);
679		daemon.build.fingerprint = "fnv1a64:0000000000000000".to_string();
680
681		let error = validate_compatibility(&daemon).expect_err("mismatched build");
682		assert!(error.to_string().contains("daemon build"));
683		assert!(error.to_string().contains("does not match client build"));
684		assert!(error.to_string().contains("snapshot producer"));
685	}
686
687	#[test]
688	fn protocol_direction_controls_recovery() {
689		let client_build = current_build_identity(env!("CARGO_PKG_VERSION")).expect("client build");
690		assert_eq!(
691			compatibility_action(&handshake(PROTOCOL_VERSION - 1), &client_build),
692			CompatibilityAction::RestartDaemon,
693			"an older daemon is recycled once so the current binary rebuilds its index"
694		);
695		assert_eq!(
696			compatibility_action(&handshake(PROTOCOL_VERSION + 1), &client_build),
697			CompatibilityAction::RejectClient,
698			"an older client must not destroy a newer daemon"
699		);
700
701		let newer_error =
702			validate_protocol(&handshake(PROTOCOL_VERSION + 1)).expect_err("newer daemon");
703		assert!(newer_error.to_string().contains("update the client"));
704		assert!(newer_error.to_string().contains("left running"));
705		let older_error =
706			validate_protocol(&handshake(PROTOCOL_VERSION - 1)).expect_err("older daemon");
707		assert!(older_error.to_string().contains("recycle the daemon once"));
708	}
709
710	#[test]
711	fn accepts_only_the_exact_daemon_workspace() {
712		let config = DaemonWorkspaceConfig {
713			roots: vec!["/workspace".to_string()],
714			project: None,
715			cache_dir: None,
716			live_refresh: Some("auto".to_string()),
717		};
718		assert!(registry_entry_matches_config(
719			&config,
720			&registry_entry(&["/workspace"])
721		));
722		assert!(!registry_entry_matches_config(
723			&config,
724			&registry_entry(&["/workspace", "/other"])
725		));
726		assert!(!registry_entry_matches_config(
727			&config,
728			&registry_entry(&["/other"])
729		));
730	}
731
732	#[test]
733	fn reconstructs_the_exact_daemon_identity_from_its_registry_entry() {
734		let mut entry = registry_entry(&["/workspace", "/other"]);
735		entry.project = Some("backend".to_string());
736		entry.cache_dir = Some("/cache".to_string());
737		entry.live_refresh = Some("auto".to_string());
738
739		let config = config_from_registry_entry(&entry);
740
741		assert_eq!(config.roots, vec!["/workspace", "/other"]);
742		assert_eq!(config.project.as_deref(), Some("backend"));
743		assert_eq!(config.cache_dir.as_deref(), Some("/cache"));
744		assert_eq!(config.live_refresh.as_deref(), Some("auto"));
745	}
746
747	#[test]
748	fn rejects_a_daemon_handshake_for_another_workspace() {
749		validate_workspace(
750			vec![PathBuf::from("/workspace")],
751			&handshake(PROTOCOL_VERSION),
752		)
753		.expect("matching workspace");
754		let error = validate_workspace(vec![PathBuf::from("/other")], &handshake(PROTOCOL_VERSION))
755			.expect_err("mismatched workspace");
756		assert!(error.to_string().contains("daemon workspace mismatch"));
757		assert!(error.to_string().contains("/other"));
758		assert!(error.to_string().contains("/workspace"));
759	}
760
761	#[test]
762	fn preserves_a_live_registry_entry_with_an_unreachable_endpoint() {
763		let workspace = tempfile::tempdir().expect("workspace");
764		let config = config_from_roots([workspace.path()]).expect("config");
765		let listener = TcpListener::bind("127.0.0.1:0").expect("reserve endpoint");
766		let endpoint = listener.local_addr().expect("endpoint").to_string();
767		drop(listener);
768		let entry = DaemonRegistryEntry {
769			workspace_root: config.roots[0].clone(),
770			workspace_roots: config.roots.clone(),
771			project: config.project.clone(),
772			cache_dir: config.cache_dir.clone(),
773			live_refresh: config.live_refresh.clone(),
774			endpoint,
775			token: "unreachable-live-entry".to_string(),
776			pid: std::process::id(),
777			build: code_moniker_query::BuildIdentity::default(),
778			heartbeat_unix_ms: code_moniker_query::registry_heartbeat_unix_ms(),
779		};
780		write_registry_entry(&config, &entry).expect("registry fixture");
781
782		let error = match connect_registered_daemon_with_limits(&config, 1, 1, Duration::ZERO) {
783			Err(error) => error,
784			Ok(_) => panic!("a live but unreachable daemon must not be replaced"),
785		};
786		assert!(
787			error
788				.to_string()
789				.contains("is alive but its endpoint is unavailable"),
790			"{error:#}"
791		);
792		assert!(
793			read_registry_entry(&config)
794				.expect("read registry")
795				.is_some(),
796			"a live daemon claim must remain registered"
797		);
798		remove_registry_entry_if_own(
799			&registry_path_for_config(&config).expect("registry path"),
800			&entry,
801		);
802	}
803
804	#[test]
805	fn expires_an_unreachable_legacy_claim_even_if_its_pid_was_reused() {
806		let workspace = tempfile::tempdir().expect("workspace");
807		let config = config_from_roots([workspace.path()]).expect("config");
808		let listener = TcpListener::bind("127.0.0.1:0").expect("reserve endpoint");
809		let endpoint = listener.local_addr().expect("endpoint").to_string();
810		drop(listener);
811		let mut entry = registry_entry(&[config.roots[0].as_str()]);
812		entry.workspace_root = config.roots[0].clone();
813		entry.workspace_roots = config.roots.clone();
814		entry.endpoint = endpoint;
815		entry.heartbeat_unix_ms = 0;
816		write_registry_entry(&config, &entry).expect("legacy registry fixture");
817
818		let client = connect_registered_daemon(&config).expect("expire legacy claim");
819		assert!(
820			client.is_none(),
821			"expired unreachable claim must be recyclable"
822		);
823		assert!(
824			read_registry_entry(&config)
825				.expect("read registry")
826				.is_none(),
827			"expired claim must be removed"
828		);
829	}
830
831	#[test]
832	fn startup_timeout_includes_the_captured_daemon_diagnostic() {
833		let workspace = tempfile::tempdir().expect("workspace");
834		let config = config_from_roots([workspace.path()]).expect("config");
835		let path = daemon_log_path_for_config(&config).expect("diagnostic path");
836		std::fs::create_dir_all(path.parent().expect("diagnostic parent"))
837			.expect("diagnostic directory");
838		std::fs::write(&path, "code-moniker daemon: fatal fixture\n").expect("diagnostic fixture");
839
840		let error =
841			match wait_for_daemon_with_limits(config.clone(), 0, 1, Duration::from_millis(0)) {
842				Err(error) => error,
843				Ok(_) => panic!("missing daemon must time out"),
844			};
845		let message = error.to_string();
846		assert!(message.contains("fatal fixture"), "{message}");
847		assert!(message.contains(&path.display().to_string()), "{message}");
848		let missing = no_daemon_registered_error(&config).to_string();
849		assert!(missing.contains("no daemon registered"), "{missing}");
850		assert!(missing.contains("fatal fixture"), "{missing}");
851		let _ = std::fs::remove_file(path);
852	}
853}