Skip to main content

recall_echo/
serve_client.rs

1//! Client side of the graph daemon — connect, or start one and connect.
2//!
3//! Every hot graph operation (search, query, traverse, status, ingest, entity
4//! and relationship writes) goes through here. There is no embedded fallback:
5//! when the daemon cannot be reached *and* cannot be started, that is a named
6//! [`RecallError::Daemon`] error, never a silent degradation.
7//!
8//! Two escapes exist:
9//!
10//! - `[graph] mode = "server"` (advanced): the store is an external SurrealDB
11//!   server, so there is nothing to serialize — requests run in-process.
12//! - [`exclusive`]: admin operations (init, gc, extraction, bulk ingest, …)
13//!   take an admin lock, stop the daemon and keep the store for themselves;
14//!   hot operations that arrive meanwhile wait for the lock, and the next one
15//!   after it is released starts a fresh daemon.
16//!
17//! Both ends of the socket check the peer's uid, and the socket, its
18//! directory, the pidfile and the daemon log are all owner-only — see
19//! [`crate::serve_security`].
20
21use 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
37/// Environment override for the binary used to start the daemon.
38/// Defaults to the running executable; tests and wrappers set it explicitly.
39pub const DAEMON_BIN_ENV: &str = "RECALL_ECHO_BIN";
40
41/// Environment the detached daemon inherits. Everything else is dropped: the
42/// daemon outlives the command that started it by up to an hour, and hook
43/// contexts hand their process API credentials to every child they spawn.
44const DAEMON_ENV_ALLOWLIST: &[&str] = &[
45    "PATH",
46    "HOME",
47    "XDG_RUNTIME_DIR",
48    "RECALL_ECHO_HOME",
49    DAEMON_BIN_ENV,
50    // fastembed downloads the ONNX model over TLS on first use.
51    "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
61/// `sockaddr_un.sun_path` is 108 bytes on Linux; stay well inside it.
62const MAX_SOCKET_PATH_LEN: usize = 100;
63/// How long to wait for a freshly spawned daemon to accept connections.
64const START_TIMEOUT: Duration = Duration::from_secs(30);
65/// Polling interval while waiting for a socket to appear or disappear.
66const POLL_INTERVAL: Duration = Duration::from_millis(25);
67/// A spawn lockfile older than this belongs to a client that died mid-spawn.
68const STALE_LOCK_AGE: Duration = Duration::from_secs(30);
69/// How long to wait for a daemon to answer the version handshake.
70const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
71/// Upper bound on a single request/response exchange with the daemon. Long
72/// enough for an ingest of a large archive, short enough that a wedged daemon
73/// cannot hang a session hook forever.
74const CALL_TIMEOUT: Duration = Duration::from_secs(300);
75/// Upper bound on connect → spawn → reconnect rounds.
76const MAX_CONNECT_ROUNDS: u32 = 3;
77/// How long a hot operation waits for an admin operation to release the store
78/// before failing with a named error.
79pub(crate) const ADMIN_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
80/// An admin lock whose owner cannot be found in `/proc` is stale immediately;
81/// where process liveness is unknowable, it is stale after this long.
82const ADMIN_LOCK_STALE_AGE: Duration = Duration::from_secs(900);
83/// How many times `exclusive` re-stops a daemon that raced in behind it.
84const MAX_STOP_ROUNDS: u32 = 3;
85
86// ── Socket location ──────────────────────────────────────────────────────
87
88/// Socket path for a memory directory.
89///
90/// `$XDG_RUNTIME_DIR/recall-echo/<hash>.sock`, falling back to
91/// `/tmp/recall-echo-<uid>/<hash>.sock`, unless `[serve] socket_path`
92/// overrides it. The hash is taken over the canonical memory directory, so
93/// every graph gets its own daemon and symlinked paths share one.
94pub 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
114/// The directory recall-echo derives its own sockets in.
115fn 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
129/// FNV-1a 64 of a path — stable across processes and releases, unlike
130/// `DefaultHasher`, which is what a socket name needs.
131fn 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
140/// Make sure the socket directory exists and only this user can write into it.
141///
142/// recall-echo creates (and then validates as `0o700`) the runtime directory
143/// it derives itself. Any other directory comes from `[serve] socket_path`,
144/// i.e. from a config file that may not be trustworthy — it is validated but
145/// never created and never chmod-ed, so a config file cannot make the tool
146/// mutate an arbitrary directory.
147pub(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// ── Public entry points ──────────────────────────────────────────────────
156
157/// Backend mode from `[graph] mode` — `embedded` (daemon) or `server` (direct).
158#[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
169/// Run a hot graph operation, starting the daemon if necessary.
170///
171/// In `mode = "server"` the request runs in-process against the external
172/// SurrealDB server; the result is identical either way.
173pub 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        // The daemon went away between the handshake and its answer (a crash,
186        // or an `exclusive` operation racing us). One fresh daemon, one retry —
187        // but only for a request that is safe to apply twice.
188        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
206/// Take exclusive ownership of the store for an admin operation.
207///
208/// Takes the admin lock, stops a running daemon, opens the store in-process
209/// and runs `operation`. Hot clients that arrive while the lock is held wait
210/// for it instead of starting a daemon, so the store has exactly one owner at
211/// every instant. The daemon is not restarted here — the next hot operation
212/// starts a fresh one.
213///
214/// The lock is released only after the store has been closed, so the client
215/// that wakes up next never races the admin operation for the file lock.
216pub 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
236/// Stop the daemon, and any daemon that races in behind it.
237async 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
249/// Identity of the running daemon, or `None` when none is running (or when
250/// the store is an external server). Never starts a daemon.
251pub 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
265/// Stop the daemon for this memory directory. Returns whether one was running.
266pub 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        // Nothing listening — clear a stale socket so the next start is clean.
273        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        // The daemon died (or shut itself down) before it could answer: the
282        // end state we asked for is the one we got.
283        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
291// ── Connection ───────────────────────────────────────────────────────────
292
293/// Why a request over the daemon socket failed.
294///
295/// The distinction matters on the hook path: a dead daemon is recoverable by
296/// starting a fresh one, while a protocol or timeout failure is not and must
297/// surface as-is.
298enum CallError {
299    /// The daemon closed the connection: idle timeout, shutdown, or a crash.
300    Disconnected(RecallError),
301    /// Anything else — retrying will not help.
302    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
313/// A broken pipe or reset means the daemon went away mid-request.
314fn 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
325/// One JSON-line conversation with a daemon.
326struct 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    /// Connect, treating every failure as "no daemon there".
343    async fn try_connect(socket: &Path) -> Option<Self> {
344        Self::connect(socket).await.ok()
345    }
346
347    /// Send one request and read its response, bounded by [`CALL_TIMEOUT`].
348    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
396/// Refuse to talk to a daemon running as another user: it would see every
397/// ingest payload we send and could answer every query with anything it likes.
398fn 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
408/// Connect to the daemon for `socket`, starting one if needed.
409async fn connect_or_spawn(memory_dir: &Path, socket: &Path) -> Result<Connection, RecallError> {
410    // Establish that the socket lives somewhere only we can write *before*
411    // touching anything in it, so an unusable location is reported as such
412    // rather than as a puzzling connect failure.
413    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                    // Upgraded binary, stale daemon: ask it to go, then respawn.
424                    let _ = connection.call(&Request::Shutdown).await;
425                    drop(connection);
426                    wait_for_socket_gone(socket, deadline).await?;
427                }
428                // The daemon closed the socket mid-handshake — it went idle or
429                // an admin operation stopped it. Clean up and start a fresh one.
430                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                // Socket file exists but nothing is listening (crashed daemon).
447                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
460/// Start a daemon — exactly one client wins the race; the rest wait.
461///
462/// An admin operation ([`exclusive`]) owns the store while its lock is held,
463/// so a daemon started now would collide with it: wait for the lock first.
464async 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
486/// Holds the `O_EXCL` spawn lockfile; removes it on drop.
487struct 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
501/// `Some(lock)` — this client spawns the daemon. `None` — another client is
502/// already spawning one; wait for its socket.
503fn 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// ── Admin lock ───────────────────────────────────────────────────────────
542
543/// Exclusive ownership of a store by an admin operation.
544///
545/// Held for the whole operation — which can run for minutes — and released
546/// only after the store has been closed. Hot clients wait for it instead of
547/// starting a daemon that would collide with the operation.
548///
549/// Crash safety comes from the owning pid rather than from a heartbeat: an
550/// admin operation blocks its thread inside the ONNX embedder for long
551/// stretches, so a timer-based heartbeat would report a healthy operation as
552/// dead. Where process liveness cannot be read (`/proc` absent), the lock's
553/// mtime bounds how long a crashed holder can block others.
554#[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
565/// Path of the admin lockfile that accompanies a socket.
566fn 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
572/// Take the admin lock, waiting for another admin operation to finish.
573async 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
603/// Wait until no admin operation owns the store for `socket`.
604pub(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
627/// True when an admin lockfile belongs to a live admin operation.
628pub(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        // Unreadable pid, or a platform without `/proc`: fall back to age.
639        _ => 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
656/// `Some(alive)` where process liveness can be read from `/proc` (Linux),
657/// `None` where it cannot.
658fn 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
677/// Spawn `recall-echo serve --dir <memory_dir>` detached, with stdio going to
678/// the daemon log so panics and native-library output are captured.
679fn 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        // Own process group: terminal signals aimed at the client never reach
703        // the daemon, and the daemon outlives the shell that started it.
704        .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
718/// Hand the daemon a minimal environment.
719///
720/// The daemon is detached and long-lived; inheriting the client's environment
721/// would hand it whatever credentials the calling context happens to export
722/// (a Claude Code hook exports API keys) for the rest of its hour-long life.
723fn 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/// Daemon log path for a memory directory.
733#[must_use]
734pub fn daemon_log_path(memory_dir: &Path) -> PathBuf {
735    memory_dir.join("graph").join("daemon.log")
736}
737
738/// Wait until the daemon accepts connections.
739///
740/// When `child` is the daemon this client just spawned, its early exit (a
741/// locked store, a failed bind) is reported immediately instead of after the
742/// full start timeout.
743async 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
786/// Last `lines` lines of the daemon log, for error messages.
787fn 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    /// A `[serve] socket_path` can point anywhere, so its directory is only
928    /// ever validated — never created, never chmod-ed.
929    #[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        // An existing directory only we can write into is accepted as-is.
942        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    /// A client that died mid-operation must not block the store forever.
984    #[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        // pid 0 never names a live process, so `/proc/0` never exists.
991        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    /// Backdate a file's mtime without a libc dependency: rewrite it through a
1035    /// `File` whose times we set via `filetime`-free `set_times`.
1036    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}