code_moniker_daemon_client/
lib.rs1#![cfg(unix)]
2
3use std::future::Future;
4use std::ops::Deref;
5use std::os::fd::AsRawFd;
6use std::os::unix::net::UnixStream;
7use std::os::unix::process::CommandExt;
8use std::path::{Path, PathBuf};
9use std::process::{Command as ProcessCommand, Stdio};
10use std::sync::Arc;
11use std::thread;
12use std::time::Duration;
13
14use code_moniker_query::{
15 CommandRequest, CommandResponse, DaemonRpcClient, DaemonWorkspaceConfig, HandshakeResponse,
16 PROTOCOL_VERSION, QueryRequest, QueryResponse,
17};
18use jsonrpsee::ws_client::{WsClient, WsClientBuilder};
19use tokio::runtime::Runtime;
20
21use code_moniker_query::{
22 DaemonRegistryState, daemon_registry_heartbeat_expired, list_registry_files, pid_is_alive,
23};
24
25const DAEMON_READY_ATTEMPTS: usize = 300;
26const DAEMON_READY_CONNECT_ATTEMPTS: usize = 10;
27const DAEMON_READY_POLL: Duration = Duration::from_millis(100);
28
29pub use code_moniker_query::{
30 DaemonRegistryEntry, canonical_workspace_config, canonical_workspace_root,
31 canonical_workspace_roots, config_from_roots, config_roots, daemon_workspace_config,
32 list_registry_entries, read_registry_entry, registry_dir, registry_path_for_config,
33 registry_path_for_root, registry_path_for_roots, remove_registry_entry_if_own,
34 validate_daemon_start_config, workspace_label,
35};
36
37#[derive(Clone)]
38pub struct DaemonClient {
39 connection: DaemonConnection,
40 endpoint: DaemonEndpoint,
41}
42
43#[derive(Clone)]
44struct DaemonEndpoint {
45 config: DaemonWorkspaceConfig,
46 roots: Vec<PathBuf>,
47 address: String,
48 _supervisor_guard: Option<Arc<UnixStream>>,
49}
50
51#[derive(Clone)]
52struct RegisteredDaemon {
53 path: PathBuf,
54 entry: DaemonRegistryEntry,
55}
56
57#[derive(Clone)]
58pub struct DaemonConnection {
59 runtime: Arc<Runtime>,
60 ws: Arc<WsClient>,
61 handshake: HandshakeResponse,
62}
63
64impl DaemonClient {
65 pub fn connect(roots: Vec<PathBuf>) -> anyhow::Result<Self> {
66 Self::connect_config(config_from_roots(roots)?)
67 }
68
69 pub fn connect_config(config: DaemonWorkspaceConfig) -> anyhow::Result<Self> {
70 let config = canonical_workspace_config(config)?;
71 let Some(entry) = read_registry_entry(&config)? else {
72 anyhow::bail!(
73 "no daemon registered for {}",
74 workspace_label(&config_roots(&config))
75 );
76 };
77 connect_entry(config, entry)
78 }
79
80 pub fn connect_or_start(roots: Vec<PathBuf>) -> anyhow::Result<Self> {
81 Self::connect_or_start_config(config_from_roots(roots)?)
82 }
83
84 pub fn connect_or_start_config(config: DaemonWorkspaceConfig) -> anyhow::Result<Self> {
85 let config = canonical_workspace_config(config)?;
86 validate_daemon_start_config(&config)?;
87 if let Some(client) = connect_registered_daemon(&config)? {
88 return Ok(client);
89 }
90 start_compatible_daemon(config)
91 }
92
93 pub fn connect_or_start_supporting(
94 config: DaemonWorkspaceConfig,
95 capability: &str,
96 ) -> anyhow::Result<Self> {
97 let client = Self::connect_or_start_config(config.clone())?;
98 if client.supports_query(capability)? {
99 return Ok(client);
100 }
101 restart_for_capability(client, config, capability)
102 }
103
104 pub fn root(&self) -> &Path {
105 &self.endpoint.roots[0]
106 }
107
108 pub fn roots(&self) -> &[PathBuf] {
109 &self.endpoint.roots
110 }
111
112 pub fn config(&self) -> &DaemonWorkspaceConfig {
113 &self.endpoint.config
114 }
115
116 pub fn endpoint(&self) -> &str {
117 &self.endpoint.address
118 }
119}
120
121impl Deref for DaemonClient {
122 type Target = DaemonConnection;
123
124 fn deref(&self) -> &Self::Target {
125 &self.connection
126 }
127}
128
129impl DaemonConnection {
130 pub fn handshake(&self, _client: &str) -> anyhow::Result<HandshakeResponse> {
131 Ok(self.handshake.clone())
132 }
133
134 pub fn supports_query(&self, capability: &str) -> anyhow::Result<bool> {
135 let handshake = self.handshake("daemon-client")?;
136 Ok(handshake
137 .capabilities
138 .queries
139 .iter()
140 .any(|verb| verb == capability))
141 }
142
143 pub fn query(&self, request: QueryRequest) -> anyhow::Result<QueryResponse> {
144 validate_protocol(&self.handshake)?;
145 self.block(self.ws.query(request))
146 .map_err(|err| anyhow::anyhow!("{err}"))
147 }
148
149 pub fn command(&self, request: CommandRequest) -> anyhow::Result<String> {
150 Ok(self.command_response(request)?.message)
151 }
152
153 pub fn command_response(&self, request: CommandRequest) -> anyhow::Result<CommandResponse> {
154 validate_protocol(&self.handshake)?;
155 self.block(self.ws.command(request))
156 .map_err(|err| anyhow::anyhow!("{err}"))
157 }
158
159 pub fn shutdown(&self) -> anyhow::Result<()> {
160 self.block(self.ws.shutdown())
161 .map_err(|err| anyhow::anyhow!("{err}"))
162 }
163
164 fn block<F: Future>(&self, fut: F) -> F::Output {
165 self.runtime.block_on(fut)
166 }
167}
168
169fn connect_entry(
170 config: DaemonWorkspaceConfig,
171 entry: DaemonRegistryEntry,
172) -> anyhow::Result<DaemonClient> {
173 let runtime = Arc::new(build_runtime()?);
174 let url = format!("ws://{}", entry.endpoint);
175 let ws = runtime.block_on(async { WsClientBuilder::default().build(&url).await })?;
176 let handshake = runtime
177 .block_on(ws.handshake("daemon-client".to_string()))
178 .map_err(|err| anyhow::anyhow!("{err}"))?;
179 validate_workspace(config_roots(&config), &handshake)?;
180 let client = DaemonClient {
181 connection: DaemonConnection {
182 runtime,
183 ws: Arc::new(ws),
184 handshake,
185 },
186 endpoint: DaemonEndpoint {
187 roots: config_roots(&config),
188 config,
189 address: entry.endpoint,
190 _supervisor_guard: None,
191 },
192 };
193 Ok(client)
194}
195
196fn validate_client_protocol(client: &DaemonClient) -> anyhow::Result<()> {
197 let handshake = client.handshake("daemon-client")?;
198 validate_protocol(&handshake)
199}
200
201fn validate_protocol(handshake: &HandshakeResponse) -> anyhow::Result<()> {
202 if handshake.protocol_version == PROTOCOL_VERSION {
203 return Ok(());
204 }
205 anyhow::bail!(
206 "daemon protocol {} does not match client protocol {} (daemon version {}); reinstall code-moniker so the client and daemon versions match",
207 handshake.protocol_version,
208 PROTOCOL_VERSION,
209 handshake.daemon_version
210 )
211}
212
213fn validate_workspace(
214 expected_roots: Vec<PathBuf>,
215 handshake: &HandshakeResponse,
216) -> anyhow::Result<()> {
217 let mut expected = expected_roots
218 .into_iter()
219 .map(|root| root.display().to_string())
220 .collect::<Vec<_>>();
221 let mut actual = handshake.workspace_roots.clone();
222 expected.sort();
223 actual.sort();
224 if expected == actual {
225 return Ok(());
226 }
227 anyhow::bail!(
228 "daemon workspace mismatch: expected [{}], daemon serves [{}]",
229 expected.join(", "),
230 actual.join(", ")
231 )
232}
233
234fn connect_registered_daemon(
235 config: &DaemonWorkspaceConfig,
236) -> anyhow::Result<Option<DaemonClient>> {
237 let Some(registered) = registry_entry_for(config)? else {
238 return Ok(None);
239 };
240 let client = match wait_for_daemon(config.clone()) {
241 Ok(client) => client,
242 Err(error) => {
243 let current = read_registry_entry(config)?;
244 let same_fresh_claim = current.as_ref().is_some_and(|current| {
245 current.pid == registered.entry.pid
246 && current.token == registered.entry.token
247 && pid_is_alive(current.pid)
248 && !daemon_registry_heartbeat_expired(current)
249 });
250 if same_fresh_claim {
251 anyhow::bail!(
252 "registered daemon pid {} for {} is alive but its endpoint is unavailable; stop that process before retrying: {error:#}",
253 registered.entry.pid,
254 registered.entry.workspace_root
255 )
256 }
257 remove_registry_entry_if_own(®istered.path, ®istered.entry);
258 return Ok(None);
259 }
260 };
261 let handshake = client.handshake("daemon-client")?;
262 if handshake.protocol_version == PROTOCOL_VERSION {
263 return Ok(Some(client));
264 }
265 let _ = client.shutdown();
266 drop(client);
267 wait_for_deregistration(config);
268 let _ = cleanup_stale_config(config);
269 Ok(None)
270}
271
272fn start_compatible_daemon(config: DaemonWorkspaceConfig) -> anyhow::Result<DaemonClient> {
273 let supervisor_guard = Arc::new(start_daemon_process(&config)?);
274 let mut client = wait_for_daemon(config)?;
275 client.endpoint._supervisor_guard = Some(supervisor_guard);
276 validate_client_protocol(&client)?;
277 Ok(client)
278}
279
280fn restart_for_capability(
281 client: DaemonClient,
282 config: DaemonWorkspaceConfig,
283 capability: &str,
284) -> anyhow::Result<DaemonClient> {
285 let _ = client.shutdown();
286 drop(client);
287 let config = canonical_workspace_config(config)?;
288 wait_for_deregistration(&config);
289 let _ = cleanup_stale_config(&config);
290 let client = start_compatible_daemon(config)?;
291 if !client.supports_query(capability)? {
292 anyhow::bail!(
293 "the code-moniker daemon binary predates `{capability}`; update code-moniker and retry"
294 );
295 }
296 Ok(client)
297}
298
299fn registry_entry_for(config: &DaemonWorkspaceConfig) -> anyhow::Result<Option<RegisteredDaemon>> {
300 let registry_path = registry_path_for_config(config)?;
301 if let Some(entry) = read_registry_entry(config)? {
302 if pid_is_alive(entry.pid) {
303 return Ok(Some(RegisteredDaemon {
304 path: registry_path,
305 entry,
306 }));
307 }
308 remove_registry_entry_if_own(®istry_path, &entry);
309 }
310 for (path, entry) in list_registry_files()? {
311 if !pid_is_alive(entry.pid) {
312 remove_registry_entry_if_own(&path, &entry);
313 continue;
314 }
315 if registry_entry_matches_config(config, &entry) && pid_is_alive(entry.pid) {
316 return Ok(Some(RegisteredDaemon { path, entry }));
317 }
318 }
319 Ok(None)
320}
321
322fn registry_entry_matches_config(
323 config: &DaemonWorkspaceConfig,
324 entry: &DaemonRegistryEntry,
325) -> bool {
326 let mut expected_roots = config.roots.clone();
327 let mut actual_roots = entry.workspace_roots.clone();
328 expected_roots.sort();
329 actual_roots.sort();
330 expected_roots == actual_roots
331 && config.project == entry.project
332 && config.cache_dir == entry.cache_dir
333}
334
335fn build_runtime() -> anyhow::Result<Runtime> {
336 Ok(tokio::runtime::Builder::new_multi_thread()
337 .worker_threads(2)
338 .enable_all()
339 .thread_name("code-moniker-daemon-client")
340 .build()?)
341}
342
343fn wait_for_deregistration(config: &DaemonWorkspaceConfig) {
346 for _ in 0..30 {
347 match read_registry_entry(config) {
348 Ok(Some(entry)) if pid_is_alive(entry.pid) => {
349 thread::sleep(Duration::from_millis(100));
350 }
351 _ => return,
352 }
353 }
354}
355
356fn wait_for_daemon(config: DaemonWorkspaceConfig) -> anyhow::Result<DaemonClient> {
357 wait_for_daemon_with_limits(
358 config,
359 DAEMON_READY_ATTEMPTS,
360 DAEMON_READY_CONNECT_ATTEMPTS,
361 DAEMON_READY_POLL,
362 )
363}
364
365fn wait_for_daemon_with_limits(
366 config: DaemonWorkspaceConfig,
367 ready_attempts: usize,
368 ready_connect_attempts: usize,
369 poll: Duration,
370) -> anyhow::Result<DaemonClient> {
371 let mut last_error = None;
372 let mut ready_connect_failures = 0;
373 for _ in 0..ready_attempts {
374 if let Some(registered) = registry_entry_for(&config)?
375 && registered.entry.state == DaemonRegistryState::Ready
376 {
377 match connect_entry(config.clone(), registered.entry) {
378 Ok(client) => return Ok(client),
379 Err(error) if error.to_string().contains("daemon workspace mismatch") => {
380 return Err(error);
381 }
382 Err(error) => {
383 ready_connect_failures += 1;
384 last_error = Some(error);
385 if ready_connect_failures >= ready_connect_attempts {
386 break;
387 }
388 }
389 }
390 }
391 thread::sleep(poll);
392 }
393 let workspace = workspace_label(&config_roots(&config));
394 match last_error {
395 Some(error) => anyhow::bail!(
396 "daemon ready endpoint remained unusable for {workspace} after {ready_connect_failures} connection attempts: {error:#}"
397 ),
398 None => {
399 let timeout_seconds = (ready_attempts as u128 * poll.as_millis()) / 1_000;
400 anyhow::bail!("daemon did not become ready for {workspace} after {timeout_seconds}s")
401 }
402 }
403}
404
405pub fn cleanup_stale_entry(roots: Vec<PathBuf>) -> anyhow::Result<()> {
406 cleanup_stale_config(&config_from_roots(roots)?)
407}
408
409pub fn cleanup_stale_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<()> {
410 let path = registry_path_for_config(config)?;
411 if let Some(entry) = read_registry_entry(config)?
412 && !pid_is_alive(entry.pid)
413 {
414 remove_registry_entry_if_own(&path, &entry);
415 }
416 Ok(())
417}
418
419fn start_daemon_process(config: &DaemonWorkspaceConfig) -> anyhow::Result<UnixStream> {
420 let exe = std::env::current_exe()?;
421 let (supervisor_guard, child_supervisor) = UnixStream::pair()?;
422 let supervisor_fd = child_supervisor.as_raw_fd();
423 let mut command = ProcessCommand::new(exe);
424 command
425 .arg("daemon")
426 .arg("start")
427 .arg("--supervisor-pid")
428 .arg(std::process::id().to_string())
429 .arg("--supervisor-fd")
430 .arg(supervisor_fd.to_string())
431 .stdin(Stdio::null())
432 .stdout(Stdio::null())
433 .stderr(Stdio::null());
434 unsafe {
437 command.pre_exec(move || {
438 if libc::fcntl(supervisor_fd, libc::F_SETFD, 0) == -1 {
439 return Err(std::io::Error::last_os_error());
440 }
441 Ok(())
442 });
443 }
444 if let Some(project) = &config.project {
445 command.arg("--project").arg(project);
446 }
447 if let Some(cache_dir) = &config.cache_dir {
448 command.arg("--cache").arg(cache_dir);
449 }
450 if let Some(live_refresh) = &config.live_refresh {
451 command.arg("--live-refresh").arg(live_refresh);
452 }
453 for root in config_roots(config) {
454 command.arg(root);
455 }
456 command.spawn().map(|_| supervisor_guard).map_err(|err| {
457 anyhow::anyhow!(
458 "cannot start daemon for {}: {err}",
459 workspace_label(&config_roots(config))
460 )
461 })
462}
463
464#[cfg(test)]
465mod tests {
466 use std::net::TcpListener;
467
468 use code_moniker_query::CapabilitySet;
469 use code_moniker_query::write_registry_entry;
470
471 use super::*;
472
473 fn handshake(protocol_version: u32) -> HandshakeResponse {
474 HandshakeResponse {
475 protocol_version,
476 daemon_version: "test".to_string(),
477 workspace_root: "/workspace".to_string(),
478 workspace_roots: vec!["/workspace".to_string()],
479 capabilities: CapabilitySet::default(),
480 }
481 }
482
483 fn registry_entry(roots: &[&str]) -> DaemonRegistryEntry {
484 DaemonRegistryEntry {
485 workspace_root: roots.join(","),
486 workspace_roots: roots.iter().map(|root| (*root).to_string()).collect(),
487 project: None,
488 cache_dir: None,
489 live_refresh: Some("on-demand".to_string()),
490 endpoint: "127.0.0.1:1234".to_string(),
491 token: "test".to_string(),
492 pid: std::process::id(),
493 heartbeat_unix_ms: code_moniker_query::registry_heartbeat_unix_ms(),
494 state: DaemonRegistryState::Ready,
495 }
496 }
497
498 #[test]
499 fn accepts_current_protocol() {
500 validate_protocol(&handshake(PROTOCOL_VERSION)).expect("current protocol");
501 }
502
503 #[test]
504 fn rejects_any_protocol_mismatch_with_reinstall_guidance() {
505 for mismatched in [PROTOCOL_VERSION - 1, PROTOCOL_VERSION + 1] {
506 let error = validate_protocol(&handshake(mismatched)).expect_err("mismatched protocol");
507 let message = error.to_string();
508 assert!(message.contains(&format!("daemon protocol {mismatched}")));
509 assert!(message.contains(&format!("client protocol {PROTOCOL_VERSION}")));
510 assert!(message.contains("reinstall code-moniker"));
511 }
512 }
513
514 #[test]
515 fn accepts_only_the_exact_daemon_workspace() {
516 let config = DaemonWorkspaceConfig {
517 roots: vec!["/workspace".to_string()],
518 project: None,
519 cache_dir: None,
520 live_refresh: Some("auto".to_string()),
521 };
522 assert!(registry_entry_matches_config(
523 &config,
524 ®istry_entry(&["/workspace"])
525 ));
526 assert!(!registry_entry_matches_config(
527 &config,
528 ®istry_entry(&["/workspace", "/other"])
529 ));
530 assert!(!registry_entry_matches_config(
531 &config,
532 ®istry_entry(&["/other"])
533 ));
534 }
535
536 #[test]
537 fn rejects_a_daemon_handshake_for_another_workspace() {
538 validate_workspace(
539 vec![PathBuf::from("/workspace")],
540 &handshake(PROTOCOL_VERSION),
541 )
542 .expect("matching workspace");
543 let error = validate_workspace(vec![PathBuf::from("/other")], &handshake(PROTOCOL_VERSION))
544 .expect_err("mismatched workspace");
545 assert!(error.to_string().contains("daemon workspace mismatch"));
546 assert!(error.to_string().contains("/other"));
547 assert!(error.to_string().contains("/workspace"));
548 }
549
550 #[test]
551 fn preserves_a_live_registry_entry_with_an_unreachable_endpoint() {
552 let workspace = tempfile::tempdir().expect("workspace");
553 let config = config_from_roots([workspace.path()]).expect("config");
554 let listener = TcpListener::bind("127.0.0.1:0").expect("reserve endpoint");
555 let endpoint = listener.local_addr().expect("endpoint").to_string();
556 drop(listener);
557 let entry = DaemonRegistryEntry {
558 workspace_root: config.roots[0].clone(),
559 workspace_roots: config.roots.clone(),
560 project: config.project.clone(),
561 cache_dir: config.cache_dir.clone(),
562 live_refresh: config.live_refresh.clone(),
563 endpoint,
564 token: "unreachable-live-entry".to_string(),
565 pid: std::process::id(),
566 heartbeat_unix_ms: code_moniker_query::registry_heartbeat_unix_ms(),
567 state: DaemonRegistryState::Ready,
568 };
569 write_registry_entry(&config, &entry).expect("registry fixture");
570
571 let error = match connect_registered_daemon(&config) {
572 Err(error) => error,
573 Ok(_) => panic!("a live but unreachable daemon must not be replaced"),
574 };
575 assert!(
576 error
577 .to_string()
578 .contains("is alive but its endpoint is unavailable"),
579 "{error:#}"
580 );
581 assert!(
582 read_registry_entry(&config)
583 .expect("read registry")
584 .is_some(),
585 "a live daemon claim must remain registered"
586 );
587 remove_registry_entry_if_own(
588 ®istry_path_for_config(&config).expect("registry path"),
589 &entry,
590 );
591 }
592
593 #[test]
594 fn expires_an_unreachable_legacy_claim_even_if_its_pid_was_reused() {
595 let workspace = tempfile::tempdir().expect("workspace");
596 let config = config_from_roots([workspace.path()]).expect("config");
597 let listener = TcpListener::bind("127.0.0.1:0").expect("reserve endpoint");
598 let endpoint = listener.local_addr().expect("endpoint").to_string();
599 drop(listener);
600 let mut entry = registry_entry(&[config.roots[0].as_str()]);
601 entry.workspace_root = config.roots[0].clone();
602 entry.workspace_roots = config.roots.clone();
603 entry.endpoint = endpoint;
604 entry.heartbeat_unix_ms = 0;
605 write_registry_entry(&config, &entry).expect("legacy registry fixture");
606
607 let client = connect_registered_daemon(&config).expect("expire legacy claim");
608 assert!(
609 client.is_none(),
610 "expired unreachable claim must be recyclable"
611 );
612 assert!(
613 read_registry_entry(&config)
614 .expect("read registry")
615 .is_none(),
616 "expired claim must be removed"
617 );
618 }
619}