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(®istered.path, ®istered.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(®istry_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
462fn 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 ®istry_entry(&["/workspace"])
721 ));
722 assert!(!registry_entry_matches_config(
723 &config,
724 ®istry_entry(&["/workspace", "/other"])
725 ));
726 assert!(!registry_entry_matches_config(
727 &config,
728 ®istry_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 ®istry_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}