Skip to main content

code_moniker_daemon_client/
lib.rs

1#![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(&registered.path, &registered.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(&registry_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
343// After asking an outdated daemon to shut down, give it a moment to leave
344// the registry so the fresh start does not race its guarded removal.
345fn 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	// SAFETY: the closure only clears FD_CLOEXEC on the already-open socket.
435	// No allocation or lock-taking operation runs in the forked child.
436	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			&registry_entry(&["/workspace"])
525		));
526		assert!(!registry_entry_matches_config(
527			&config,
528			&registry_entry(&["/workspace", "/other"])
529		));
530		assert!(!registry_entry_matches_config(
531			&config,
532			&registry_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			&registry_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}