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