Skip to main content

recall_echo/
serve_client.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Client side of the graph daemon — connect, or start one and connect.
6//!
7//! Every hot graph operation (search, query, traverse, status, ingest, entity
8//! and relationship writes) goes through here. There is no embedded fallback:
9//! when the daemon cannot be reached *and* cannot be started, that is a named
10//! [`RecallError::Daemon`] error, never a silent degradation.
11//!
12//! Two escapes exist:
13//!
14//! - `[graph] mode = "server"` (advanced): the store is an external SurrealDB
15//!   server, so there is nothing to serialize — requests run in-process.
16//! - [`exclusive`]: admin operations (init, gc, extraction, bulk ingest, …)
17//!   take an admin lock, stop the daemon and keep the store for themselves;
18//!   hot operations that arrive meanwhile wait for the lock, and the next one
19//!   after it is released starts a fresh daemon.
20//!
21//! Both ends of the socket check the peer's uid, and the socket, its
22//! directory, the pidfile and the daemon log are all owner-only — see
23//! [`crate::serve_security`].
24
25use 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
41/// Environment override for the binary used to start the daemon.
42/// Defaults to the running executable; tests and wrappers set it explicitly.
43pub const DAEMON_BIN_ENV: &str = "RECALL_ECHO_BIN";
44
45/// Environment the detached daemon inherits. Everything else is dropped: the
46/// daemon outlives the command that started it by up to an hour, and hook
47/// contexts hand their process API credentials to every child they spawn.
48const DAEMON_ENV_ALLOWLIST: &[&str] = &[
49    "PATH",
50    "HOME",
51    "XDG_RUNTIME_DIR",
52    "RECALL_ECHO_HOME",
53    DAEMON_BIN_ENV,
54    // fastembed downloads the ONNX model over TLS on first use.
55    "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
65/// `sockaddr_un.sun_path` is 108 bytes on Linux; stay well inside it.
66const MAX_SOCKET_PATH_LEN: usize = 100;
67/// How long to wait for a freshly spawned daemon to accept connections.
68const START_TIMEOUT: Duration = Duration::from_secs(30);
69/// Polling interval while waiting for a socket to appear or disappear.
70const POLL_INTERVAL: Duration = Duration::from_millis(25);
71/// A spawn lockfile older than this belongs to a client that died mid-spawn.
72const STALE_LOCK_AGE: Duration = Duration::from_secs(30);
73/// How long to wait for a daemon to answer the version handshake.
74const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
75/// Upper bound on a single request/response exchange with the daemon. Long
76/// enough for an ingest of a large archive, short enough that a wedged daemon
77/// cannot hang a session hook forever.
78const CALL_TIMEOUT: Duration = Duration::from_secs(300);
79/// Upper bound on connect → spawn → reconnect rounds.
80const MAX_CONNECT_ROUNDS: u32 = 3;
81/// How long a hot operation waits for an admin operation to release the store
82/// before failing with a named error.
83pub(crate) const ADMIN_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
84/// An admin lock whose owner cannot be found in `/proc` is stale immediately;
85/// where process liveness is unknowable, it is stale after this long.
86const ADMIN_LOCK_STALE_AGE: Duration = Duration::from_secs(900);
87/// How many times `exclusive` re-stops a daemon that raced in behind it.
88const MAX_STOP_ROUNDS: u32 = 3;
89
90// ── Socket location ──────────────────────────────────────────────────────
91
92/// Socket path for a memory directory.
93///
94/// `$XDG_RUNTIME_DIR/recall-echo/<hash>.sock`, falling back to
95/// `/tmp/recall-echo-<uid>/<hash>.sock`, unless `[serve] socket_path`
96/// overrides it. The hash is taken over the canonical memory directory, so
97/// every graph gets its own daemon and symlinked paths share one.
98pub 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
118/// The directory recall-echo derives its own sockets in.
119fn 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
133/// FNV-1a 64 of a path — stable across processes and releases, unlike
134/// `DefaultHasher`, which is what a socket name needs.
135fn 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
144/// Make sure the socket directory exists and only this user can write into it.
145///
146/// recall-echo creates (and then validates as `0o700`) the runtime directory
147/// it derives itself. Any other directory comes from `[serve] socket_path`,
148/// i.e. from a config file that may not be trustworthy — it is validated but
149/// never created and never chmod-ed, so a config file cannot make the tool
150/// mutate an arbitrary directory.
151pub(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// ── Public entry points ──────────────────────────────────────────────────
160
161/// Backend mode from `[graph] mode` — `embedded` (daemon) or `server` (direct).
162#[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
173/// Run a hot graph operation, starting the daemon if necessary.
174///
175/// In `mode = "server"` the request runs in-process against the external
176/// SurrealDB server; the result is identical either way.
177pub 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        // The daemon went away between the handshake and its answer (a crash,
190        // or an `exclusive` operation racing us). One fresh daemon, one retry —
191        // but only for a request that is safe to apply twice.
192        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
210/// Take exclusive ownership of the store for an admin operation.
211///
212/// Takes the admin lock, stops a running daemon, opens the store in-process
213/// and runs `operation`. Hot clients that arrive while the lock is held wait
214/// for it instead of starting a daemon, so the store has exactly one owner at
215/// every instant. The daemon is not restarted here — the next hot operation
216/// starts a fresh one.
217///
218/// The lock is released only after the store has been closed, so the client
219/// that wakes up next never races the admin operation for the file lock.
220pub 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
240/// Stop the daemon, and any daemon that races in behind it.
241async 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
253/// Identity of the running daemon, or `None` when none is running (or when
254/// the store is an external server). Never starts a daemon.
255pub 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
269/// Stop the daemon for this memory directory. Returns whether one was running.
270pub 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        // Nothing listening — clear a stale socket so the next start is clean.
277        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        // The daemon died (or shut itself down) before it could answer: the
286        // end state we asked for is the one we got.
287        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
295// ── Connection ───────────────────────────────────────────────────────────
296
297/// Why a request over the daemon socket failed.
298///
299/// The distinction matters on the hook path: a dead daemon is recoverable by
300/// starting a fresh one, while a protocol or timeout failure is not and must
301/// surface as-is.
302enum CallError {
303    /// The daemon closed the connection: idle timeout, shutdown, or a crash.
304    Disconnected(RecallError),
305    /// Anything else — retrying will not help.
306    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
317/// A broken pipe or reset means the daemon went away mid-request.
318fn 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
329/// One JSON-line conversation with a daemon.
330struct 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    /// Connect, treating every failure as "no daemon there".
347    async fn try_connect(socket: &Path) -> Option<Self> {
348        Self::connect(socket).await.ok()
349    }
350
351    /// Send one request and read its response, bounded by [`CALL_TIMEOUT`].
352    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
400/// Refuse to talk to a daemon running as another user: it would see every
401/// ingest payload we send and could answer every query with anything it likes.
402fn 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
412/// Connect to the daemon for `socket`, starting one if needed.
413async fn connect_or_spawn(memory_dir: &Path, socket: &Path) -> Result<Connection, RecallError> {
414    // Establish that the socket lives somewhere only we can write *before*
415    // touching anything in it, so an unusable location is reported as such
416    // rather than as a puzzling connect failure.
417    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                    // Upgraded binary, stale daemon: ask it to go, then respawn.
428                    let _ = connection.call(&Request::Shutdown).await;
429                    drop(connection);
430                    wait_for_socket_gone(socket, deadline).await?;
431                }
432                // The daemon closed the socket mid-handshake — it went idle or
433                // an admin operation stopped it. Clean up and start a fresh one.
434                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                // Socket file exists but nothing is listening (crashed daemon).
451                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
464/// Start a daemon — exactly one client wins the race; the rest wait.
465///
466/// An admin operation ([`exclusive`]) owns the store while its lock is held,
467/// so a daemon started now would collide with it: wait for the lock first.
468async 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
490/// Holds the `O_EXCL` spawn lockfile; removes it on drop.
491struct 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
505/// `Some(lock)` — this client spawns the daemon. `None` — another client is
506/// already spawning one; wait for its socket.
507fn 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// ── Admin lock ───────────────────────────────────────────────────────────
546
547/// Exclusive ownership of a store by an admin operation.
548///
549/// Held for the whole operation — which can run for minutes — and released
550/// only after the store has been closed. Hot clients wait for it instead of
551/// starting a daemon that would collide with the operation.
552///
553/// Crash safety comes from the owning pid rather than from a heartbeat: an
554/// admin operation blocks its thread inside the ONNX embedder for long
555/// stretches, so a timer-based heartbeat would report a healthy operation as
556/// dead. Where process liveness cannot be read (`/proc` absent), the lock's
557/// mtime bounds how long a crashed holder can block others.
558#[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
569/// Path of the admin lockfile that accompanies a socket.
570fn 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
576/// Take the admin lock, waiting for another admin operation to finish.
577async 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
607/// Wait until no admin operation owns the store for `socket`.
608pub(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
631/// True when an admin lockfile belongs to a live admin operation.
632pub(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        // Unreadable pid, or a platform without `/proc`: fall back to age.
643        _ => 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
660/// `Some(alive)` where process liveness can be read from `/proc` (Linux),
661/// `None` where it cannot.
662fn 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
681/// Spawn `recall-echo serve --dir <memory_dir>` detached, with stdio going to
682/// the daemon log so panics and native-library output are captured.
683fn 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        // Own process group: terminal signals aimed at the client never reach
707        // the daemon, and the daemon outlives the shell that started it.
708        .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
722/// Hand the daemon a minimal environment.
723///
724/// The daemon is detached and long-lived; inheriting the client's environment
725/// would hand it whatever credentials the calling context happens to export
726/// (a Claude Code hook exports API keys) for the rest of its hour-long life.
727fn 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/// Daemon log path for a memory directory.
737#[must_use]
738pub fn daemon_log_path(memory_dir: &Path) -> PathBuf {
739    memory_dir.join("graph").join("daemon.log")
740}
741
742/// Wait until the daemon accepts connections.
743///
744/// When `child` is the daemon this client just spawned, its early exit (a
745/// locked store, a failed bind) is reported immediately instead of after the
746/// full start timeout.
747async 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
790/// Last `lines` lines of the daemon log, for error messages.
791fn 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    /// A `[serve] socket_path` can point anywhere, so its directory is only
932    /// ever validated — never created, never chmod-ed.
933    #[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        // An existing directory only we can write into is accepted as-is.
946        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    /// A client that died mid-operation must not block the store forever.
988    #[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        // pid 0 never names a live process, so `/proc/0` never exists.
995        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    /// Backdate a file's mtime without a libc dependency: rewrite it through a
1039    /// `File` whose times we set via `filetime`-free `set_times`.
1040    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}