1use std::io::ErrorKind;
26use std::os::unix::ffi::OsStrExt;
27use std::path::{Path, PathBuf};
28use std::time::{Duration, Instant};
29
30use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
31use tokio::net::UnixStream;
32
33use crate::error::RecallError;
34use crate::graph::GraphMemory;
35use crate::serve::{DaemonInfo, Request, Response};
36use crate::serve_security::{
37 append_private_file, check_peer_uid, create_new_private_file, create_private_dir, current_uid,
38 require_owned_dir, unlink_socket,
39};
40
41pub const DAEMON_BIN_ENV: &str = "RECALL_ECHO_BIN";
44
45const DAEMON_ENV_ALLOWLIST: &[&str] = &[
49 "PATH",
50 "HOME",
51 "XDG_RUNTIME_DIR",
52 "RECALL_ECHO_HOME",
53 DAEMON_BIN_ENV,
54 "SSL_CERT_FILE",
56 "SSL_CERT_DIR",
57 "HTTPS_PROXY",
58 "HTTP_PROXY",
59 "NO_PROXY",
60 "https_proxy",
61 "http_proxy",
62 "no_proxy",
63];
64
65const MAX_SOCKET_PATH_LEN: usize = 100;
67const START_TIMEOUT: Duration = Duration::from_secs(30);
69const POLL_INTERVAL: Duration = Duration::from_millis(25);
71const STALE_LOCK_AGE: Duration = Duration::from_secs(30);
73const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
75const CALL_TIMEOUT: Duration = Duration::from_secs(300);
79const MAX_CONNECT_ROUNDS: u32 = 3;
81pub(crate) const ADMIN_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
84const ADMIN_LOCK_STALE_AGE: Duration = Duration::from_secs(900);
87const MAX_STOP_ROUNDS: u32 = 3;
89
90pub fn socket_path(memory_dir: &Path) -> Result<PathBuf, RecallError> {
99 let config = crate::config::load_from_dir(memory_dir);
100 let path = match config.serve.socket_path.as_deref() {
101 Some(configured) if !configured.trim().is_empty() => {
102 PathBuf::from(crate::paths::expand_tilde(configured.trim()))
103 }
104 _ => runtime_dir()?.join(format!("{}.sock", path_hash(&canonical(memory_dir)))),
105 };
106
107 if path.as_os_str().as_bytes().len() > MAX_SOCKET_PATH_LEN {
108 return Err(RecallError::Daemon(format!(
109 "daemon socket path is too long ({} bytes, max {MAX_SOCKET_PATH_LEN}): {}. \
110 Set `[serve] socket_path` in .recall-echo.toml to a shorter path.",
111 path.as_os_str().as_bytes().len(),
112 path.display()
113 )));
114 }
115 Ok(path)
116}
117
118fn runtime_dir() -> Result<PathBuf, RecallError> {
120 match std::env::var_os("XDG_RUNTIME_DIR") {
121 Some(dir) if !dir.is_empty() => Ok(PathBuf::from(dir).join("recall-echo")),
122 _ => Ok(PathBuf::from(format!(
123 "/tmp/recall-echo-{}",
124 current_uid()?
125 ))),
126 }
127}
128
129fn canonical(path: &Path) -> PathBuf {
130 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
131}
132
133fn path_hash(path: &Path) -> String {
136 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
137 for byte in path.as_os_str().as_bytes() {
138 hash ^= u64::from(*byte);
139 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
140 }
141 format!("{hash:016x}")
142}
143
144pub(crate) fn ensure_socket_dir(dir: &Path) -> Result<(), RecallError> {
152 if runtime_dir().is_ok_and(|derived| derived == dir) {
153 create_private_dir(dir)
154 } else {
155 require_owned_dir(dir)
156 }
157}
158
159#[must_use]
163pub fn graph_mode(memory_dir: &Path) -> String {
164 crate::config::load_from_dir(memory_dir)
165 .graph
166 .map_or_else(|| "embedded".to_string(), |graph| graph.mode)
167}
168
169fn uses_daemon(memory_dir: &Path) -> bool {
170 graph_mode(memory_dir) != "server"
171}
172
173pub async fn execute(
178 memory_dir: &Path,
179 request: &Request,
180) -> Result<serde_json::Value, RecallError> {
181 if !uses_daemon(memory_dir) {
182 return execute_direct(memory_dir, request).await;
183 }
184
185 let socket = socket_path(memory_dir)?;
186 let mut connection = connect_or_spawn(memory_dir, &socket).await?;
187 match connection.call(request).await {
188 Ok(response) => response.into_result(),
189 Err(CallError::Disconnected(_)) if request.is_retryable() => {
193 let mut connection = connect_or_spawn(memory_dir, &socket).await?;
194 connection.call(request).await?.into_result()
195 }
196 Err(err) => Err(err.into()),
197 }
198}
199
200async fn execute_direct(
201 memory_dir: &Path,
202 request: &Request,
203) -> Result<serde_json::Value, RecallError> {
204 let graph = GraphMemory::open(&memory_dir.join("graph")).await?;
205 crate::serve::dispatch_graph(&graph, request)
206 .await
207 .into_result()
208}
209
210pub async fn exclusive<T, F, Fut>(memory_dir: &Path, operation: F) -> Result<T, RecallError>
221where
222 F: FnOnce(GraphMemory) -> Fut,
223 Fut: std::future::Future<Output = Result<T, RecallError>>,
224{
225 let graph_dir = memory_dir.join("graph");
226 if !uses_daemon(memory_dir) {
227 return operation(GraphMemory::open(&graph_dir).await?).await;
228 }
229
230 let socket = socket_path(memory_dir)?;
231 let admin = acquire_admin_lock(&socket, Instant::now() + ADMIN_WAIT_TIMEOUT).await?;
232 stop_daemon_for_admin(memory_dir).await?;
233
234 let graph = GraphMemory::open_embedded(&graph_dir).await?;
235 let result = operation(graph).await;
236 drop(admin);
237 result
238}
239
240async fn stop_daemon_for_admin(memory_dir: &Path) -> Result<(), RecallError> {
242 for _ in 0..MAX_STOP_ROUNDS {
243 if !stop_daemon(memory_dir).await? {
244 return Ok(());
245 }
246 }
247 Err(RecallError::Daemon(format!(
248 "a graph daemon for {} keeps restarting; cannot take the store exclusively",
249 memory_dir.display()
250 )))
251}
252
253pub async fn daemon_info(memory_dir: &Path) -> Result<Option<DaemonInfo>, RecallError> {
256 if !uses_daemon(memory_dir) {
257 return Ok(None);
258 }
259 let socket = socket_path(memory_dir)?;
260 let Some(mut connection) = Connection::try_connect(&socket).await else {
261 return Ok(None);
262 };
263 match connection.hello().await {
264 Ok(info) => Ok(Some(info)),
265 Err(_) => Ok(None),
266 }
267}
268
269pub async fn stop_daemon(memory_dir: &Path) -> Result<bool, RecallError> {
271 if !uses_daemon(memory_dir) {
272 return Ok(false);
273 }
274 let socket = socket_path(memory_dir)?;
275 let Some(mut connection) = Connection::try_connect(&socket).await else {
276 unlink_socket(&socket)?;
278 return Ok(false);
279 };
280
281 match connection.call(&Request::Shutdown).await {
282 Ok(response) => {
283 response.into_result()?;
284 }
285 Err(CallError::Disconnected(_)) => {}
288 Err(err) => return Err(err.into()),
289 }
290 drop(connection);
291 wait_for_socket_gone(&socket, Instant::now() + START_TIMEOUT).await?;
292 Ok(true)
293}
294
295enum CallError {
303 Disconnected(RecallError),
305 Fatal(RecallError),
307}
308
309impl From<CallError> for RecallError {
310 fn from(err: CallError) -> Self {
311 match err {
312 CallError::Disconnected(err) | CallError::Fatal(err) => err,
313 }
314 }
315}
316
317fn classify_io(err: std::io::Error) -> CallError {
319 match err.kind() {
320 ErrorKind::BrokenPipe
321 | ErrorKind::ConnectionReset
322 | ErrorKind::ConnectionAborted
323 | ErrorKind::UnexpectedEof
324 | ErrorKind::NotConnected => CallError::Disconnected(err.into()),
325 _ => CallError::Fatal(err.into()),
326 }
327}
328
329struct Connection {
331 reader: BufReader<tokio::net::unix::OwnedReadHalf>,
332 writer: tokio::net::unix::OwnedWriteHalf,
333}
334
335impl Connection {
336 async fn connect(socket: &Path) -> std::io::Result<Self> {
337 let stream = UnixStream::connect(socket).await?;
338 verify_daemon_peer(&stream)?;
339 let (reader, writer) = stream.into_split();
340 Ok(Self {
341 reader: BufReader::new(reader),
342 writer,
343 })
344 }
345
346 async fn try_connect(socket: &Path) -> Option<Self> {
348 Self::connect(socket).await.ok()
349 }
350
351 async fn call(&mut self, request: &Request) -> Result<Response, CallError> {
353 match tokio::time::timeout(CALL_TIMEOUT, self.exchange(request)).await {
354 Ok(result) => result,
355 Err(_) => Err(CallError::Fatal(RecallError::Daemon(format!(
356 "the graph daemon did not answer `{}` within {}s — see the daemon log",
357 request.op_name(),
358 CALL_TIMEOUT.as_secs()
359 )))),
360 }
361 }
362
363 async fn exchange(&mut self, request: &Request) -> Result<Response, CallError> {
364 let mut line = serde_json::to_vec(request).map_err(|err| CallError::Fatal(err.into()))?;
365 line.push(b'\n');
366 self.writer.write_all(&line).await.map_err(classify_io)?;
367 self.writer.flush().await.map_err(classify_io)?;
368
369 let mut response_line = String::new();
370 let read = self
371 .reader
372 .read_line(&mut response_line)
373 .await
374 .map_err(classify_io)?;
375 if read == 0 {
376 return Err(CallError::Disconnected(RecallError::Daemon(format!(
377 "daemon closed the connection while handling `{}` — see the daemon log",
378 request.op_name()
379 ))));
380 }
381 serde_json::from_str(&response_line).map_err(|err| CallError::Fatal(err.into()))
382 }
383
384 async fn hello(&mut self) -> Result<DaemonInfo, CallError> {
385 let response =
386 match tokio::time::timeout(HANDSHAKE_TIMEOUT, self.exchange(&Request::Hello)).await {
387 Ok(result) => result?,
388 Err(_) => {
389 return Err(CallError::Fatal(RecallError::Daemon(format!(
390 "daemon did not answer the version handshake within {}s",
391 HANDSHAKE_TIMEOUT.as_secs()
392 ))))
393 }
394 };
395 let data = response.into_result().map_err(CallError::Fatal)?;
396 serde_json::from_value(data).map_err(|err| CallError::Fatal(err.into()))
397 }
398}
399
400fn verify_daemon_peer(stream: &UnixStream) -> std::io::Result<()> {
403 let peer = stream.peer_cred()?;
404 let owner = current_uid().map_err(permission_denied)?;
405 check_peer_uid(peer.uid(), owner).map_err(permission_denied)
406}
407
408fn permission_denied(err: RecallError) -> std::io::Error {
409 std::io::Error::new(ErrorKind::PermissionDenied, err.to_string())
410}
411
412async fn connect_or_spawn(memory_dir: &Path, socket: &Path) -> Result<Connection, RecallError> {
414 if let Some(parent) = socket.parent() {
418 ensure_socket_dir(parent)?;
419 }
420 let deadline = Instant::now() + START_TIMEOUT;
421
422 for _ in 0..MAX_CONNECT_ROUNDS {
423 match Connection::connect(socket).await {
424 Ok(mut connection) => match connection.hello().await {
425 Ok(info) if info.version == env!("CARGO_PKG_VERSION") => return Ok(connection),
426 Ok(_) => {
427 let _ = connection.call(&Request::Shutdown).await;
429 drop(connection);
430 wait_for_socket_gone(socket, deadline).await?;
431 }
432 Err(CallError::Disconnected(_)) => {
435 drop(connection);
436 unlink_socket(socket)?;
437 }
438 Err(CallError::Fatal(err)) => return Err(err),
439 },
440 Err(err) if err.kind() == ErrorKind::PermissionDenied => {
441 return Err(RecallError::Daemon(format!(
442 "permission denied opening the daemon socket {}: {err}. \
443 Check ownership of the socket directory, or set \
444 `[serve] socket_path` in .recall-echo.toml.",
445 socket.display()
446 )));
447 }
448 Err(err) if err.kind() == ErrorKind::NotFound => {}
449 Err(_) => {
450 unlink_socket(socket)?;
452 }
453 }
454
455 start_daemon(memory_dir, socket, Instant::now() + START_TIMEOUT).await?;
456 }
457
458 Err(RecallError::Daemon(format!(
459 "gave up connecting to the graph daemon on {} after {MAX_CONNECT_ROUNDS} attempts",
460 socket.display()
461 )))
462}
463
464async fn start_daemon(
469 memory_dir: &Path,
470 socket: &Path,
471 deadline: Instant,
472) -> Result<(), RecallError> {
473 wait_for_admin_lock(socket, Instant::now() + ADMIN_WAIT_TIMEOUT).await?;
474
475 match acquire_spawn_lock(socket)? {
476 Some(lock) => {
477 let result = match spawn_daemon(memory_dir, socket) {
478 Ok(mut child) => {
479 wait_for_socket(socket, memory_dir, deadline, Some(&mut child)).await
480 }
481 Err(err) => Err(err),
482 };
483 drop(lock);
484 result
485 }
486 None => wait_for_socket(socket, memory_dir, deadline, None).await,
487 }
488}
489
490struct SpawnLock(PathBuf);
492
493impl Drop for SpawnLock {
494 fn drop(&mut self) {
495 let _ = std::fs::remove_file(&self.0);
496 }
497}
498
499fn lock_path(socket: &Path) -> PathBuf {
500 let mut path = socket.as_os_str().to_os_string();
501 path.push(".lock");
502 PathBuf::from(path)
503}
504
505fn acquire_spawn_lock(socket: &Path) -> Result<Option<SpawnLock>, RecallError> {
508 if let Some(parent) = socket.parent() {
509 ensure_socket_dir(parent)?;
510 }
511 let path = lock_path(socket);
512
513 match create_lock_file(&path) {
514 Ok(()) => Ok(Some(SpawnLock(path))),
515 Err(err) if err.kind() == ErrorKind::AlreadyExists => {
516 if lock_is_stale(&path) {
517 let _ = std::fs::remove_file(&path);
518 return match create_lock_file(&path) {
519 Ok(()) => Ok(Some(SpawnLock(path))),
520 Err(_) => Ok(None),
521 };
522 }
523 Ok(None)
524 }
525 Err(err) => Err(RecallError::Daemon(format!(
526 "cannot create the daemon spawn lock {}: {err}",
527 path.display()
528 ))),
529 }
530}
531
532fn create_lock_file(path: &Path) -> std::io::Result<()> {
533 use std::io::Write as _;
534 let mut file = create_new_private_file().open(path)?;
535 writeln!(file, "{}", std::process::id())
536}
537
538fn lock_is_stale(path: &Path) -> bool {
539 std::fs::metadata(path)
540 .and_then(|meta| meta.modified())
541 .map(|modified| modified.elapsed().unwrap_or_default() > STALE_LOCK_AGE)
542 .unwrap_or(true)
543}
544
545#[derive(Debug)]
559struct AdminLock {
560 path: PathBuf,
561}
562
563impl Drop for AdminLock {
564 fn drop(&mut self) {
565 let _ = std::fs::remove_file(&self.path);
566 }
567}
568
569fn admin_lock_path(socket: &Path) -> PathBuf {
571 let mut path = socket.as_os_str().to_os_string();
572 path.push(".admin");
573 PathBuf::from(path)
574}
575
576async fn acquire_admin_lock(socket: &Path, deadline: Instant) -> Result<AdminLock, RecallError> {
578 if let Some(parent) = socket.parent() {
579 ensure_socket_dir(parent)?;
580 }
581 let path = admin_lock_path(socket);
582
583 loop {
584 match create_lock_file(&path) {
585 Ok(()) => return Ok(AdminLock { path }),
586 Err(err) if err.kind() == ErrorKind::AlreadyExists => {
587 if !admin_lock_is_live(&path) {
588 let _ = std::fs::remove_file(&path);
589 } else if Instant::now() >= deadline {
590 return Err(admin_wait_timeout(&path));
591 }
592 tokio::time::sleep(POLL_INTERVAL).await;
593 if Instant::now() >= deadline {
594 return Err(admin_wait_timeout(&path));
595 }
596 }
597 Err(err) => {
598 return Err(RecallError::Daemon(format!(
599 "cannot create the admin lock {}: {err}",
600 path.display()
601 )))
602 }
603 }
604 }
605}
606
607pub(crate) async fn wait_for_admin_lock(
609 socket: &Path,
610 deadline: Instant,
611) -> Result<(), RecallError> {
612 let path = admin_lock_path(socket);
613 while admin_lock_is_live(&path) {
614 if Instant::now() >= deadline {
615 return Err(admin_wait_timeout(&path));
616 }
617 tokio::time::sleep(POLL_INTERVAL).await;
618 }
619 Ok(())
620}
621
622fn admin_wait_timeout(path: &Path) -> RecallError {
623 RecallError::Daemon(format!(
624 "a recall-echo admin operation has owned the graph store for more than {}s \
625 (lock {}). Wait for it to finish, or remove the lock if its process is gone.",
626 ADMIN_WAIT_TIMEOUT.as_secs(),
627 path.display()
628 ))
629}
630
631pub(crate) fn admin_lock_is_held(socket: &Path) -> bool {
633 admin_lock_is_live(&admin_lock_path(socket))
634}
635
636fn admin_lock_is_live(path: &Path) -> bool {
637 let Ok(meta) = std::fs::metadata(path) else {
638 return false;
639 };
640 match lock_owner_pid(path).map(process_is_alive) {
641 Some(Some(alive)) => alive,
642 _ => meta
644 .modified()
645 .map(|modified| modified.elapsed().unwrap_or_default() < ADMIN_LOCK_STALE_AGE)
646 .unwrap_or(false),
647 }
648}
649
650fn lock_owner_pid(path: &Path) -> Option<u32> {
651 std::fs::read_to_string(path)
652 .ok()?
653 .lines()
654 .next()?
655 .trim()
656 .parse()
657 .ok()
658}
659
660fn process_is_alive(pid: u32) -> Option<bool> {
663 if !Path::new("/proc/self").exists() {
664 return None;
665 }
666 Some(Path::new(&format!("/proc/{pid}")).exists())
667}
668
669fn daemon_binary() -> Result<PathBuf, RecallError> {
670 if let Some(binary) = std::env::var_os(DAEMON_BIN_ENV) {
671 return Ok(PathBuf::from(binary));
672 }
673 std::env::current_exe().map_err(|err| {
674 RecallError::Daemon(format!(
675 "cannot locate the recall-echo binary to start the graph daemon: {err}. \
676 Set {DAEMON_BIN_ENV} to its path."
677 ))
678 })
679}
680
681fn spawn_daemon(memory_dir: &Path, socket: &Path) -> Result<std::process::Child, RecallError> {
684 use std::os::unix::process::CommandExt as _;
685
686 let binary = daemon_binary()?;
687 let log_path = daemon_log_path(memory_dir);
688 if let Some(parent) = log_path.parent() {
689 std::fs::create_dir_all(parent)?;
690 }
691 let log = append_private_file().open(&log_path).map_err(|err| {
692 RecallError::Daemon(format!(
693 "cannot open the daemon log {}: {err}",
694 log_path.display()
695 ))
696 })?;
697
698 let mut command = std::process::Command::new(&binary);
699 command
700 .arg("serve")
701 .arg("--dir")
702 .arg(memory_dir)
703 .stdin(std::process::Stdio::null())
704 .stdout(std::process::Stdio::from(log.try_clone()?))
705 .stderr(std::process::Stdio::from(log))
706 .process_group(0);
709 apply_daemon_env(&mut command);
710
711 command.spawn().map_err(|err| {
712 RecallError::Daemon(format!(
713 "cannot start the graph daemon ({} serve --dir {}): {err}. \
714 Socket: {}",
715 binary.display(),
716 memory_dir.display(),
717 socket.display()
718 ))
719 })
720}
721
722fn apply_daemon_env(command: &mut std::process::Command) {
728 command.env_clear();
729 for key in DAEMON_ENV_ALLOWLIST {
730 if let Some(value) = std::env::var_os(key) {
731 command.env(key, value);
732 }
733 }
734}
735
736#[must_use]
738pub fn daemon_log_path(memory_dir: &Path) -> PathBuf {
739 memory_dir.join("graph").join("daemon.log")
740}
741
742async fn wait_for_socket(
748 socket: &Path,
749 memory_dir: &Path,
750 deadline: Instant,
751 mut child: Option<&mut std::process::Child>,
752) -> Result<(), RecallError> {
753 while Instant::now() < deadline {
754 if Connection::try_connect(socket).await.is_some() {
755 return Ok(());
756 }
757 if let Some(child) = child.as_mut() {
758 if let Ok(Some(status)) = child.try_wait() {
759 return Err(RecallError::Daemon(format!(
760 "the graph daemon exited immediately ({status}). Last daemon log lines:\n{}",
761 log_tail(&daemon_log_path(memory_dir), 5)
762 )));
763 }
764 }
765 tokio::time::sleep(POLL_INTERVAL).await;
766 }
767 Err(RecallError::Daemon(format!(
768 "the graph daemon did not start within {}s (socket {} never accepted a connection). \
769 Last daemon log lines:\n{}",
770 START_TIMEOUT.as_secs(),
771 socket.display(),
772 log_tail(&daemon_log_path(memory_dir), 5)
773 )))
774}
775
776async fn wait_for_socket_gone(socket: &Path, deadline: Instant) -> Result<(), RecallError> {
777 while Instant::now() < deadline {
778 if Connection::try_connect(socket).await.is_none() {
779 unlink_socket(socket)?;
780 return Ok(());
781 }
782 tokio::time::sleep(POLL_INTERVAL).await;
783 }
784 Err(RecallError::Daemon(format!(
785 "the graph daemon on {} did not stop when asked",
786 socket.display()
787 )))
788}
789
790fn log_tail(path: &Path, lines: usize) -> String {
792 match std::fs::read_to_string(path) {
793 Ok(contents) => {
794 let tail: Vec<&str> = contents
795 .lines()
796 .rev()
797 .take(lines)
798 .collect::<Vec<_>>()
799 .into_iter()
800 .rev()
801 .collect();
802 if tail.is_empty() {
803 " (daemon log is empty)".to_string()
804 } else {
805 tail.iter()
806 .map(|line| format!(" {line}"))
807 .collect::<Vec<_>>()
808 .join("\n")
809 }
810 }
811 Err(err) => format!(" (no daemon log at {}: {err})", path.display()),
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818
819 #[test]
820 fn path_hash_is_stable_and_distinct() {
821 let first = path_hash(Path::new("/home/echo/memory"));
822 assert_eq!(first, path_hash(Path::new("/home/echo/memory")));
823 assert_ne!(first, path_hash(Path::new("/home/echo/memory2")));
824 assert_eq!(first.len(), 16);
825 }
826
827 #[test]
828 fn socket_path_is_per_memory_dir() {
829 let first = tempfile::tempdir().unwrap();
830 let second = tempfile::tempdir().unwrap();
831 assert_ne!(
832 socket_path(first.path()).unwrap(),
833 socket_path(second.path()).unwrap()
834 );
835 }
836
837 #[test]
838 fn socket_path_honors_config_override() {
839 let dir = tempfile::tempdir().unwrap();
840 std::fs::write(
841 dir.path().join(".recall-echo.toml"),
842 "[serve]\nsocket_path = \"/tmp/re-test.sock\"\n",
843 )
844 .unwrap();
845 assert_eq!(
846 socket_path(dir.path()).unwrap(),
847 PathBuf::from("/tmp/re-test.sock")
848 );
849 }
850
851 #[test]
852 fn socket_path_rejects_paths_over_the_unix_limit() {
853 let dir = tempfile::tempdir().unwrap();
854 let long = format!("/tmp/{}.sock", "x".repeat(MAX_SOCKET_PATH_LEN));
855 std::fs::write(
856 dir.path().join(".recall-echo.toml"),
857 format!("[serve]\nsocket_path = \"{long}\"\n"),
858 )
859 .unwrap();
860
861 let err = socket_path(dir.path()).unwrap_err();
862 assert!(err.to_string().contains("too long"), "{err}");
863 }
864
865 #[test]
866 fn spawn_lock_admits_one_winner_and_frees_on_drop() {
867 let dir = tempfile::tempdir().unwrap();
868 let socket = dir.path().join("graph.sock");
869
870 let winner = acquire_spawn_lock(&socket).unwrap();
871 assert!(winner.is_some());
872 assert!(acquire_spawn_lock(&socket).unwrap().is_none());
873
874 drop(winner);
875 assert!(acquire_spawn_lock(&socket).unwrap().is_some());
876 }
877
878 #[test]
879 fn stale_spawn_lock_is_reclaimed() {
880 let dir = tempfile::tempdir().unwrap();
881 let socket = dir.path().join("graph.sock");
882 let lock = lock_path(&socket);
883
884 std::fs::write(&lock, "1\n").unwrap();
885 assert!(!lock_is_stale(&lock));
886
887 let old = std::time::SystemTime::now() - STALE_LOCK_AGE - Duration::from_secs(60);
888 set_mtime(&lock, old);
889 assert!(lock_is_stale(&lock));
890 assert!(acquire_spawn_lock(&socket).unwrap().is_some());
891 }
892
893 #[test]
894 fn graph_mode_defaults_to_embedded_and_reads_server() {
895 let dir = tempfile::tempdir().unwrap();
896 assert_eq!(graph_mode(dir.path()), "embedded");
897 assert!(uses_daemon(dir.path()));
898
899 std::fs::write(
900 dir.path().join(".recall-echo.toml"),
901 "[graph]\nmode = \"server\"\n",
902 )
903 .unwrap();
904 assert_eq!(graph_mode(dir.path()), "server");
905 assert!(!uses_daemon(dir.path()));
906 }
907
908 #[test]
909 fn log_tail_reports_missing_and_present_logs() {
910 let dir = tempfile::tempdir().unwrap();
911 let path = dir.path().join("daemon.log");
912 assert!(log_tail(&path, 5).contains("no daemon log"));
913
914 std::fs::write(&path, "one\ntwo\nthree\n").unwrap();
915 let tail = log_tail(&path, 2);
916 assert!(tail.contains("two") && tail.contains("three"));
917 assert!(!tail.contains("one"));
918 }
919
920 #[test]
921 fn the_derived_runtime_dir_is_created_owner_only() {
922 use std::os::unix::fs::PermissionsExt;
923
924 let derived = runtime_dir().unwrap();
925 ensure_socket_dir(&derived).unwrap();
926
927 let mode = std::fs::metadata(&derived).unwrap().permissions().mode();
928 assert_eq!(mode & 0o777, 0o700);
929 }
930
931 #[test]
934 fn a_configured_socket_dir_is_validated_not_created() {
935 use std::os::unix::fs::PermissionsExt;
936
937 let dir = tempfile::tempdir().unwrap();
938 let missing = dir.path().join("run");
939
940 let err = ensure_socket_dir(&missing).unwrap_err();
941 assert!(matches!(err, RecallError::Daemon(_)), "{err}");
942 assert!(err.to_string().contains("socket directory"), "{err}");
943 assert!(!missing.exists(), "the directory must not be created");
944
945 std::fs::create_dir(&missing).unwrap();
947 std::fs::set_permissions(&missing, std::fs::Permissions::from_mode(0o755)).unwrap();
948 ensure_socket_dir(&missing).unwrap();
949 let mode = std::fs::metadata(&missing).unwrap().permissions().mode();
950 assert_eq!(mode & 0o777, 0o755, "the directory must not be chmod-ed");
951 }
952
953 #[test]
954 fn socket_dir_failure_is_a_named_daemon_error() {
955 let dir = tempfile::tempdir().unwrap();
956 let blocker = dir.path().join("blocker");
957 std::fs::write(&blocker, b"not a directory").unwrap();
958
959 let err = ensure_socket_dir(&blocker.join("run")).unwrap_err();
960 assert!(matches!(err, RecallError::Daemon(_)), "{err}");
961 assert!(err.to_string().contains("socket directory"), "{err}");
962 }
963
964 #[tokio::test]
965 async fn admin_lock_admits_one_holder_and_frees_on_drop() {
966 let dir = tempfile::tempdir().unwrap();
967 let socket = dir.path().join("graph.sock");
968
969 assert!(!admin_lock_is_held(&socket));
970 let held = acquire_admin_lock(&socket, Instant::now() + Duration::from_millis(50))
971 .await
972 .unwrap();
973 assert!(admin_lock_is_held(&socket));
974
975 let err = acquire_admin_lock(&socket, Instant::now() + Duration::from_millis(50))
976 .await
977 .unwrap_err();
978 assert!(err.to_string().contains("admin operation"), "{err}");
979
980 drop(held);
981 assert!(!admin_lock_is_held(&socket));
982 acquire_admin_lock(&socket, Instant::now() + Duration::from_millis(50))
983 .await
984 .unwrap();
985 }
986
987 #[tokio::test]
989 async fn an_admin_lock_owned_by_a_dead_process_is_reclaimed() {
990 let dir = tempfile::tempdir().unwrap();
991 let socket = dir.path().join("graph.sock");
992 let path = admin_lock_path(&socket);
993
994 std::fs::write(&path, "0\n").unwrap();
996 assert!(!admin_lock_is_held(&socket));
997 acquire_admin_lock(&socket, Instant::now() + Duration::from_millis(50))
998 .await
999 .unwrap();
1000 }
1001
1002 #[tokio::test]
1003 async fn a_live_admin_lock_is_honored_by_waiters() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let socket = dir.path().join("graph.sock");
1006 let path = admin_lock_path(&socket);
1007
1008 std::fs::write(&path, format!("{}\n", std::process::id())).unwrap();
1009 assert!(admin_lock_is_held(&socket));
1010
1011 let err = wait_for_admin_lock(&socket, Instant::now() + Duration::from_millis(50))
1012 .await
1013 .unwrap_err();
1014 assert!(matches!(err, RecallError::Daemon(_)), "{err}");
1015
1016 std::fs::remove_file(&path).unwrap();
1017 wait_for_admin_lock(&socket, Instant::now() + Duration::from_millis(50))
1018 .await
1019 .unwrap();
1020 }
1021
1022 #[test]
1023 fn the_daemon_environment_is_an_allowlist() {
1024 assert!(DAEMON_ENV_ALLOWLIST.contains(&"PATH"));
1025 assert!(DAEMON_ENV_ALLOWLIST.contains(&DAEMON_BIN_ENV));
1026 for secret in [
1027 "ANTHROPIC_API_KEY",
1028 "CLAUDE_CODE_OAUTH_TOKEN",
1029 "AWS_SECRET_ACCESS_KEY",
1030 ] {
1031 assert!(
1032 !DAEMON_ENV_ALLOWLIST.contains(&secret),
1033 "{secret} must not reach the detached daemon"
1034 );
1035 }
1036 }
1037
1038 fn set_mtime(path: &Path, when: std::time::SystemTime) {
1041 let file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
1042 file.set_times(std::fs::FileTimes::new().set_modified(when))
1043 .unwrap();
1044 }
1045}