1use crate::client::paths;
8use crate::proto::daemon::{
9 BulkTerminateSessionsRequest, BulkTerminateSessionsResponse, DaemonRequest, DaemonResponse,
10 GetProcessTreeRequest, GetSessionBacklogRequest, GetSessionBacklogResponse, KeyValue,
11 KillTreeRequest, KillZombiesRequest, ListActiveRequest, ListByOriginatorRequest, PingRequest,
12 PipeStreamKind, PurgeExitedSessionsRequest, PurgeExitedSessionsResponse, RequestType,
13 ResizePtySessionRequest, ServiceConfig, ServiceDeleteRequest, ServiceDescribeRequest,
14 ServiceFlushRequest, ServiceListRequest, ServiceLogsRequest, ServiceRestartRequest,
15 ServiceResurrectRequest, ServiceSaveRequest, ServiceStartRequest, ServiceStopRequest,
16 ShutdownRequest, SpawnDaemonRequest as ProtoSpawnDaemonRequest, StatusCode, StatusRequest,
17};
18use interprocess::local_socket::Stream;
19use interprocess::TryClone;
20use prost::Message;
21use std::path::PathBuf;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24#[derive(Debug)]
30pub enum ClientError {
31 Connect(std::io::Error),
33 Io(std::io::Error),
35 Decode(prost::DecodeError),
37 Server {
39 code: StatusCode,
41 message: String,
43 },
44 DaemonNotRunning,
46}
47
48impl std::fmt::Display for ClientError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 ClientError::Connect(e) => write!(f, "failed to connect to daemon: {e}"),
52 ClientError::Io(e) => write!(f, "daemon I/O error: {e}"),
53 ClientError::Decode(e) => write!(f, "failed to decode daemon response: {e}"),
54 ClientError::Server { code, message } => {
55 write!(f, "daemon returned {:?}: {}", code, message)
56 }
57 ClientError::DaemonNotRunning => write!(f, "daemon is not running"),
58 }
59 }
60}
61
62impl std::error::Error for ClientError {
63 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64 match self {
65 ClientError::Connect(e) | ClientError::Io(e) => Some(e),
66 ClientError::Decode(e) => Some(e),
67 ClientError::Server { .. } | ClientError::DaemonNotRunning => None,
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
78pub struct SpawnCommandRequest {
79 pub command: String,
81 pub cwd: Option<PathBuf>,
83 pub env: Vec<(String, String)>,
85 pub originator: Option<String>,
87 pub clear_inherited_env: bool,
93}
94
95impl SpawnCommandRequest {
96 fn default_originator() -> String {
97 let caller = std::env::current_exe()
98 .ok()
99 .and_then(|path| {
100 path.file_stem()
101 .map(|stem| stem.to_string_lossy().into_owned())
102 })
103 .filter(|value| !value.is_empty())
104 .unwrap_or_else(|| "running-process-client".to_string());
105 format!("{caller}:{}", std::process::id())
106 }
107
108 pub fn shell(command: impl Into<String>) -> Self {
111 Self {
112 command: command.into(),
113 cwd: std::env::current_dir().ok(),
114 env: std::env::vars().collect(),
115 originator: Some(Self::default_originator()),
116 clear_inherited_env: false,
117 }
118 }
119
120 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
122 self.cwd = Some(cwd.into());
123 self
124 }
125
126 pub fn with_envs<I, K, V>(mut self, env: I) -> Self
130 where
131 I: IntoIterator<Item = (K, V)>,
132 K: Into<String>,
133 V: Into<String>,
134 {
135 self.env = env
136 .into_iter()
137 .map(|(key, value)| (key.into(), value.into()))
138 .collect();
139 self
140 }
141
142 pub fn with_env_replace<I, K, V>(mut self, env: I) -> Self
155 where
156 I: IntoIterator<Item = (K, V)>,
157 K: Into<String>,
158 V: Into<String>,
159 {
160 self.env = env
161 .into_iter()
162 .map(|(key, value)| (key.into(), value.into()))
163 .collect();
164 self.clear_inherited_env = true;
165 self
166 }
167
168 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
171 let key = key.into();
172 let value = value.into();
173 if let Some((_, existing)) = self
174 .env
175 .iter_mut()
176 .find(|(existing_key, _)| *existing_key == key)
177 {
178 *existing = value;
179 } else {
180 self.env.push((key, value));
181 }
182 self
183 }
184
185 pub fn with_originator(mut self, originator: impl Into<String>) -> Self {
188 self.originator = Some(originator.into());
189 self
190 }
191}
192
193#[derive(Debug, Clone, PartialEq)]
195pub struct SpawnedDaemon {
196 pub pid: u32,
198 pub created_at: f64,
200 pub command: String,
202 pub cwd: Option<String>,
204 pub originator: Option<String>,
206 pub containment: String,
208}
209
210pub struct DaemonClient {
219 reader: Stream,
223 writer: Stream,
224 next_id: AtomicU64,
225}
226
227impl DaemonClient {
228 pub fn connect(scope_hash: Option<&str>) -> Result<Self, ClientError> {
233 let path = paths::socket_path(scope_hash);
234 Self::connect_to(&path)
235 }
236
237 pub fn connect_to(socket_path: &str) -> Result<Self, ClientError> {
242 paths::make_socket_name(socket_path).map_err(ClientError::Connect)?;
246 let stream = crate::client::deadline_io::connect_with_timeout(socket_path)
247 .map_err(ClientError::Connect)?;
248 let stream_clone = stream.try_clone().map_err(ClientError::Connect)?;
249 use interprocess::local_socket::traits::Stream as _;
254 stream.set_nonblocking(true).map_err(ClientError::Connect)?;
255 stream_clone
256 .set_nonblocking(true)
257 .map_err(ClientError::Connect)?;
258
259 Ok(Self {
260 reader: stream,
261 writer: stream_clone,
262 next_id: AtomicU64::new(1),
263 })
264 }
265
266 pub fn send_request(&mut self, request: DaemonRequest) -> Result<DaemonResponse, ClientError> {
271 use crate::client::deadline_io::{
272 read_frame_with_deadline, rpc_read_deadline, write_all_with_deadline,
273 };
274
275 let payload = request.encode_to_vec();
277 let mut framed = Vec::with_capacity(4 + payload.len());
278 framed.extend_from_slice(&(payload.len() as u32).to_be_bytes());
279 framed.extend_from_slice(&payload);
280
281 let deadline = rpc_read_deadline();
286 write_all_with_deadline(&mut self.writer, &framed, deadline).map_err(ClientError::Io)?;
287 let resp_buf =
288 read_frame_with_deadline(&mut self.reader, deadline).map_err(ClientError::Io)?;
289
290 DaemonResponse::decode(&resp_buf[..]).map_err(ClientError::Decode)
291 }
292
293 pub(crate) fn next_request_id(&self) -> u64 {
299 self.next_id.fetch_add(1, Ordering::Relaxed)
300 }
301
302 fn ensure_ok(&self, response: &DaemonResponse) -> Result<(), ClientError> {
303 if response.code == StatusCode::Ok as i32 {
304 return Ok(());
305 }
306
307 let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
308 Err(ClientError::Server {
309 code,
310 message: response.message.clone(),
311 })
312 }
313
314 pub fn ping(&mut self) -> Result<DaemonResponse, ClientError> {
316 let request = DaemonRequest {
317 id: self.next_request_id(),
318 r#type: RequestType::Ping.into(),
319 protocol_version: 1,
320 client_name: String::from("running-process-client"),
321 ping: Some(PingRequest {}),
322 ..Default::default()
323 };
324 self.send_request(request)
325 }
326
327 pub fn shutdown(
329 &mut self,
330 graceful: bool,
331 timeout_seconds: f64,
332 ) -> Result<DaemonResponse, ClientError> {
333 let request = DaemonRequest {
334 id: self.next_request_id(),
335 r#type: RequestType::Shutdown.into(),
336 protocol_version: 1,
337 client_name: String::from("running-process-client"),
338 shutdown: Some(ShutdownRequest {
339 graceful,
340 timeout_seconds,
341 }),
342 ..Default::default()
343 };
344 self.send_request(request)
345 }
346
347 pub fn status(&mut self) -> Result<DaemonResponse, ClientError> {
349 let request = DaemonRequest {
350 id: self.next_request_id(),
351 r#type: RequestType::Status.into(),
352 protocol_version: 1,
353 client_name: String::from("running-process-client"),
354 status: Some(StatusRequest {}),
355 ..Default::default()
356 };
357 self.send_request(request)
358 }
359
360 pub fn list_active(&mut self) -> Result<DaemonResponse, ClientError> {
362 let request = DaemonRequest {
363 id: self.next_request_id(),
364 r#type: RequestType::ListActive.into(),
365 protocol_version: 1,
366 client_name: String::from("running-process-client"),
367 list_active: Some(ListActiveRequest {}),
368 ..Default::default()
369 };
370 self.send_request(request)
371 }
372
373 pub fn list_by_originator(&mut self, tool: &str) -> Result<DaemonResponse, ClientError> {
375 let request = DaemonRequest {
376 id: self.next_request_id(),
377 r#type: RequestType::ListByOriginator.into(),
378 protocol_version: 1,
379 client_name: String::from("running-process-client"),
380 list_by_originator: Some(ListByOriginatorRequest {
381 tool: tool.to_string(),
382 }),
383 ..Default::default()
384 };
385 self.send_request(request)
386 }
387
388 pub fn kill_zombies(&mut self, dry_run: bool) -> Result<DaemonResponse, ClientError> {
390 let request = DaemonRequest {
391 id: self.next_request_id(),
392 r#type: RequestType::KillZombies.into(),
393 protocol_version: 1,
394 client_name: String::from("running-process-client"),
395 kill_zombies: Some(KillZombiesRequest { dry_run }),
396 ..Default::default()
397 };
398 self.send_request(request)
399 }
400
401 pub fn kill_tree(
403 &mut self,
404 pid: u32,
405 timeout_seconds: f64,
406 ) -> Result<DaemonResponse, ClientError> {
407 let request = DaemonRequest {
408 id: self.next_request_id(),
409 r#type: RequestType::KillTree.into(),
410 protocol_version: 1,
411 client_name: String::from("running-process-client"),
412 kill_tree: Some(KillTreeRequest {
413 pid,
414 timeout_seconds,
415 }),
416 ..Default::default()
417 };
418 self.send_request(request)
419 }
420
421 pub fn get_process_tree(&mut self, pid: u32) -> Result<DaemonResponse, ClientError> {
423 let request = DaemonRequest {
424 id: self.next_request_id(),
425 r#type: RequestType::GetProcessTree.into(),
426 protocol_version: 1,
427 client_name: String::from("running-process-client"),
428 get_process_tree: Some(GetProcessTreeRequest { pid }),
429 ..Default::default()
430 };
431 self.send_request(request)
432 }
433
434 pub fn spawn_command(
436 &mut self,
437 request: &SpawnCommandRequest,
438 ) -> Result<SpawnedDaemon, ClientError> {
439 let daemon_request = DaemonRequest {
440 id: self.next_request_id(),
441 r#type: RequestType::SpawnDaemon.into(),
442 protocol_version: 1,
443 client_name: String::from("running-process-client"),
444 spawn_daemon: Some(ProtoSpawnDaemonRequest {
445 command: request.command.clone(),
446 cwd: request
447 .cwd
448 .as_ref()
449 .map(|cwd| cwd.to_string_lossy().into_owned())
450 .unwrap_or_default(),
451 env: request
452 .env
453 .iter()
454 .map(|(k, v)| KeyValue {
455 key: k.clone(),
456 value: v.clone(),
457 })
458 .collect(),
459 originator: request.originator.clone().unwrap_or_default(),
460 clear_inherited_env: request.clear_inherited_env,
461 }),
462 ..Default::default()
463 };
464
465 let response = self.send_request(daemon_request)?;
466 self.ensure_ok(&response)?;
467
468 let payload = response.spawn_daemon.ok_or_else(|| ClientError::Server {
469 code: StatusCode::Internal,
470 message: "spawn response missing payload".to_string(),
471 })?;
472
473 Ok(SpawnedDaemon {
474 pid: payload.pid,
475 created_at: payload.created_at,
476 command: payload.command,
477 cwd: if payload.cwd.is_empty() {
478 None
479 } else {
480 Some(payload.cwd)
481 },
482 originator: if payload.originator.is_empty() {
483 None
484 } else {
485 Some(payload.originator)
486 },
487 containment: payload.containment,
488 })
489 }
490
491 pub fn service_start(&mut self, config: ServiceConfig) -> Result<DaemonResponse, ClientError> {
495 let request = DaemonRequest {
496 id: self.next_request_id(),
497 r#type: RequestType::ServiceStart.into(),
498 protocol_version: 1,
499 client_name: String::from("running-process-client"),
500 service_start: Some(ServiceStartRequest {
501 config: Some(config),
502 }),
503 ..Default::default()
504 };
505 self.send_request(request)
506 }
507
508 pub fn service_stop(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
510 let request = DaemonRequest {
511 id: self.next_request_id(),
512 r#type: RequestType::ServiceStop.into(),
513 protocol_version: 1,
514 client_name: String::from("running-process-client"),
515 service_stop: Some(ServiceStopRequest {
516 target: target.to_string(),
517 }),
518 ..Default::default()
519 };
520 self.send_request(request)
521 }
522
523 pub fn service_restart(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
525 let request = DaemonRequest {
526 id: self.next_request_id(),
527 r#type: RequestType::ServiceRestart.into(),
528 protocol_version: 1,
529 client_name: String::from("running-process-client"),
530 service_restart: Some(ServiceRestartRequest {
531 target: target.to_string(),
532 }),
533 ..Default::default()
534 };
535 self.send_request(request)
536 }
537
538 pub fn service_delete(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
540 let request = DaemonRequest {
541 id: self.next_request_id(),
542 r#type: RequestType::ServiceDelete.into(),
543 protocol_version: 1,
544 client_name: String::from("running-process-client"),
545 service_delete: Some(ServiceDeleteRequest {
546 target: target.to_string(),
547 }),
548 ..Default::default()
549 };
550 self.send_request(request)
551 }
552
553 pub fn service_list(&mut self) -> Result<DaemonResponse, ClientError> {
555 let request = DaemonRequest {
556 id: self.next_request_id(),
557 r#type: RequestType::ServiceList.into(),
558 protocol_version: 1,
559 client_name: String::from("running-process-client"),
560 service_list: Some(ServiceListRequest {}),
561 ..Default::default()
562 };
563 self.send_request(request)
564 }
565
566 pub fn service_describe(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
568 let request = DaemonRequest {
569 id: self.next_request_id(),
570 r#type: RequestType::ServiceDescribe.into(),
571 protocol_version: 1,
572 client_name: String::from("running-process-client"),
573 service_describe: Some(ServiceDescribeRequest {
574 target: target.to_string(),
575 }),
576 ..Default::default()
577 };
578 self.send_request(request)
579 }
580
581 pub fn service_logs(
583 &mut self,
584 target: &str,
585 lines: u32,
586 follow: bool,
587 ) -> Result<DaemonResponse, ClientError> {
588 let request = DaemonRequest {
589 id: self.next_request_id(),
590 r#type: RequestType::ServiceLogs.into(),
591 protocol_version: 1,
592 client_name: String::from("running-process-client"),
593 service_logs: Some(ServiceLogsRequest {
594 target: target.to_string(),
595 lines,
596 follow,
597 }),
598 ..Default::default()
599 };
600 self.send_request(request)
601 }
602
603 pub fn service_flush(&mut self, target: &str) -> Result<DaemonResponse, ClientError> {
605 let request = DaemonRequest {
606 id: self.next_request_id(),
607 r#type: RequestType::ServiceFlush.into(),
608 protocol_version: 1,
609 client_name: String::from("running-process-client"),
610 service_flush: Some(ServiceFlushRequest {
611 target: target.to_string(),
612 }),
613 ..Default::default()
614 };
615 self.send_request(request)
616 }
617
618 pub fn service_save(&mut self) -> Result<DaemonResponse, ClientError> {
620 let request = DaemonRequest {
621 id: self.next_request_id(),
622 r#type: RequestType::ServiceSave.into(),
623 protocol_version: 1,
624 client_name: String::from("running-process-client"),
625 service_save: Some(ServiceSaveRequest {}),
626 ..Default::default()
627 };
628 self.send_request(request)
629 }
630
631 pub fn service_resurrect(&mut self) -> Result<DaemonResponse, ClientError> {
633 let request = DaemonRequest {
634 id: self.next_request_id(),
635 r#type: RequestType::ServiceResurrect.into(),
636 protocol_version: 1,
637 client_name: String::from("running-process-client"),
638 service_resurrect: Some(ServiceResurrectRequest {}),
639 ..Default::default()
640 };
641 self.send_request(request)
642 }
643
644 pub fn resize_pty_session(
649 &mut self,
650 session_id: &str,
651 rows: u16,
652 cols: u16,
653 ) -> Result<(), ClientError> {
654 let request = DaemonRequest {
655 id: self.next_request_id(),
656 r#type: RequestType::ResizePtySession.into(),
657 protocol_version: 1,
658 client_name: String::from("running-process-client"),
659 resize_pty_session: Some(ResizePtySessionRequest {
660 session_id: session_id.into(),
661 rows: rows as u32,
662 cols: cols as u32,
663 }),
664 ..Default::default()
665 };
666 let response = self.send_request(request)?;
667 if response.code != StatusCode::Ok as i32 {
668 let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
669 return Err(ClientError::Server {
670 code,
671 message: response.message,
672 });
673 }
674 Ok(())
675 }
676
677 pub fn purge_exited_sessions(
680 &mut self,
681 originator: &str,
682 ) -> Result<PurgeExitedSessionsResponse, ClientError> {
683 let request = DaemonRequest {
684 id: self.next_request_id(),
685 r#type: RequestType::PurgeExitedSessions.into(),
686 protocol_version: 1,
687 client_name: String::from("running-process-client"),
688 purge_exited_sessions: Some(PurgeExitedSessionsRequest {
689 originator: originator.into(),
690 }),
691 ..Default::default()
692 };
693 let response = self.send_request(request)?;
694 if response.code != StatusCode::Ok as i32 {
695 let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
696 return Err(ClientError::Server {
697 code,
698 message: response.message,
699 });
700 }
701 response
702 .purge_exited_sessions
703 .ok_or_else(|| ClientError::Server {
704 code: StatusCode::Internal,
705 message: "purge_exited_sessions response missing payload".into(),
706 })
707 }
708
709 pub fn bulk_terminate_sessions(
712 &mut self,
713 older_than_secs: u64,
714 originator: &str,
715 grace_ms: u32,
716 ) -> Result<BulkTerminateSessionsResponse, ClientError> {
717 let request = DaemonRequest {
718 id: self.next_request_id(),
719 r#type: RequestType::BulkTerminateSessions.into(),
720 protocol_version: 1,
721 client_name: String::from("running-process-client"),
722 bulk_terminate_sessions: Some(BulkTerminateSessionsRequest {
723 older_than_secs,
724 originator: originator.into(),
725 grace_ms,
726 }),
727 ..Default::default()
728 };
729 let response = self.send_request(request)?;
730 if response.code != StatusCode::Ok as i32 {
731 let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
732 return Err(ClientError::Server {
733 code,
734 message: response.message,
735 });
736 }
737 response
738 .bulk_terminate_sessions
739 .ok_or_else(|| ClientError::Server {
740 code: StatusCode::Internal,
741 message: "bulk_terminate_sessions response missing payload".into(),
742 })
743 }
744
745 pub fn get_session_backlog(
750 &mut self,
751 session_id: &str,
752 pipe_stream: PipeStreamKind,
753 ) -> Result<Option<GetSessionBacklogResponse>, ClientError> {
754 let request = DaemonRequest {
755 id: self.next_request_id(),
756 r#type: RequestType::GetSessionBacklog.into(),
757 protocol_version: 1,
758 client_name: String::from("running-process-client"),
759 get_session_backlog: Some(GetSessionBacklogRequest {
760 session_id: session_id.into(),
761 pipe_stream: pipe_stream as i32,
762 }),
763 ..Default::default()
764 };
765 let response = self.send_request(request)?;
766 if response.code == StatusCode::NotFound as i32 {
767 return Ok(None);
768 }
769 if response.code != StatusCode::Ok as i32 {
770 let code = StatusCode::try_from(response.code).unwrap_or(StatusCode::UnknownRequest);
771 return Err(ClientError::Server {
772 code,
773 message: response.message,
774 });
775 }
776 Ok(response.get_session_backlog)
777 }
778}
779
780pub fn connect_or_start(scope_hash: Option<&str>) -> Result<DaemonClient, ClientError> {
791 if let Ok(client) = DaemonClient::connect(scope_hash) {
793 return Ok(client);
794 }
795
796 spawn_daemon()?;
798
799 let delays_ms: [u64; 4] = [50, 100, 200, 400];
807 for delay in delays_ms {
808 std::thread::sleep(std::time::Duration::from_millis(delay));
809 if let Ok(client) = DaemonClient::connect(scope_hash) {
810 return Ok(client);
811 }
812 }
813
814 Err(ClientError::DaemonNotRunning)
815}
816
817pub fn launch_detached(command: &str) -> Result<SpawnedDaemon, ClientError> {
822 let mut client = connect_or_start(None)?;
823 client.spawn_command(&SpawnCommandRequest::shell(command))
824}
825
826pub fn daemonize_command(command: &str) -> Result<SpawnedDaemon, ClientError> {
832 launch_detached(command)
833}
834
835fn spawn_daemon() -> Result<(), ClientError> {
837 let exe = daemon_exe_path();
838 let mut command = std::process::Command::new(&exe);
839 command.arg("start");
840 crate::spawn_daemon(&mut command).map_err(ClientError::Io)?;
841 Ok(())
842}
843
844fn daemon_exe_path() -> String {
849 if let Ok(mut path) = std::env::current_exe() {
850 path.pop(); let candidate = path.join(if cfg!(windows) {
852 "running-process-daemon.exe"
853 } else {
854 "running-process-daemon"
855 });
856 if candidate.exists() {
857 return candidate.to_string_lossy().into_owned();
858 }
859 }
860 String::from("running-process-daemon")
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 #[test]
869 fn launch_detached_has_public_sync_signature() {
870 let _api: fn(&str) -> Result<SpawnedDaemon, ClientError> = launch_detached;
871 }
872
873 #[test]
874 fn spawn_command_request_builder_sets_detached_launch_context() {
875 let request = SpawnCommandRequest::shell("echo hello")
876 .with_cwd("work")
877 .with_envs([("A", "1")])
878 .with_env("B", "2")
879 .with_originator("tool:123");
880
881 assert_eq!(request.command, "echo hello");
882 assert_eq!(request.cwd.as_deref(), Some(std::path::Path::new("work")));
883 assert_eq!(
884 request.env,
885 vec![
886 ("A".to_string(), "1".to_string()),
887 ("B".to_string(), "2".to_string())
888 ]
889 );
890 assert_eq!(request.originator.as_deref(), Some("tool:123"));
891 }
892}