Skip to main content

recall_echo/
serve.rs

1//! `recall-echo serve` — the graph daemon.
2//!
3//! One daemon per memory directory owns the embedded graph store and answers
4//! command-level requests over a unix socket, one JSON object per line:
5//!
6//! ```text
7//! → {"op":"search","args":{"query":"rust","limit":5}}
8//! ← {"ok":true,"data":[ ... ]}
9//! ```
10//!
11//! The daemon is *crash-only*: it keeps no state outside the database, so it
12//! can be killed at any instant. Clients ([`crate::serve_client`]) detect the
13//! dead socket, clean it up and start a fresh daemon.
14//!
15//! Unix only — the socket is a plain `UnixListener`, with no transport
16//! abstraction (see RE-29 decisions log).
17
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicUsize, Ordering};
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23use serde::{Deserialize, Serialize};
24use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
25use tokio::net::{UnixListener, UnixStream};
26use tokio::sync::Notify;
27
28use crate::error::RecallError;
29use crate::graph::error::GraphError;
30use crate::graph::types::{
31    EntityType, NewEntity, NewRelationship, PipelineDocuments, QueryOptions, SearchOptions,
32};
33use crate::graph::GraphMemory;
34use crate::serve_security::{
35    append_private_file, check_peer_uid, current_uid, unlink_socket, PRIVATE_FILE_MODE,
36};
37
38/// Longest idle-poll interval; keeps a long-lived daemon from spinning.
39const MAX_IDLE_POLL: Duration = Duration::from_secs(30);
40/// Shortest idle-poll interval; keeps short test timeouts responsive.
41const MIN_IDLE_POLL: Duration = Duration::from_millis(100);
42
43/// Largest request line the daemon will read. An archive ingest is the biggest
44/// legitimate request by far and stays far below this; anything larger is a
45/// buggy or hostile client trying to make the daemon buffer without bound.
46const MAX_REQUEST_BYTES: u64 = 8 * 1024 * 1024;
47/// Largest result set a request may ask for. Wire-supplied limits reach the
48/// HNSW KNN operator, where an unbounded value is an unbounded scan.
49const MAX_LIMIT: usize = 1000;
50/// Deepest graph expansion a request may ask for. Expansion is exponential in
51/// the branching factor.
52const MAX_DEPTH: u32 = 8;
53/// How long the daemon waits for its own store to close before giving up and
54/// unlinking the socket anyway.
55const STORE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
56/// Polling interval while waiting for connection tasks to release the store.
57const STORE_RELEASE_POLL: Duration = Duration::from_millis(10);
58
59// ── Protocol ─────────────────────────────────────────────────────────────
60
61/// A client request. Wire form is `{"op": "...", "args": {...}}`.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "op", content = "args", rename_all = "snake_case")]
64pub enum Request {
65    /// Version handshake — returns [`DaemonInfo`].
66    Hello,
67    /// Graph counts.
68    Status,
69    /// Semantic entity search.
70    Search(SearchArgs),
71    /// Semantic episode search.
72    SearchEpisodes(SearchEpisodesArgs),
73    /// Hybrid query: semantic + graph expansion + optional episodes.
74    Query(QueryArgs),
75    /// Traverse relationships from a named entity.
76    Traverse(TraverseArgs),
77    /// Create an entity.
78    AddEntity(AddEntityArgs),
79    /// Create a relationship between two named entities.
80    Relate(RelateArgs),
81    /// Ingest a conversation archive (episodes only, no LLM extraction).
82    IngestArchive(IngestArchiveArgs),
83    /// Sync the pipeline documents into the graph (no LLM extraction).
84    SyncPipeline(SyncPipelineArgs),
85    /// Ask the daemon to exit.
86    Shutdown,
87}
88
89impl Request {
90    /// Short name of the operation, for logs.
91    #[must_use]
92    pub fn op_name(&self) -> &'static str {
93        match self {
94            Request::Hello => "hello",
95            Request::Status => "status",
96            Request::Search(_) => "search",
97            Request::SearchEpisodes(_) => "search_episodes",
98            Request::Query(_) => "query",
99            Request::Traverse(_) => "traverse",
100            Request::AddEntity(_) => "add_entity",
101            Request::Relate(_) => "relate",
102            Request::IngestArchive(_) => "ingest_archive",
103            Request::SyncPipeline(_) => "sync_pipeline",
104            Request::Shutdown => "shutdown",
105        }
106    }
107
108    /// Whether repeating this request against a fresh daemon is safe.
109    ///
110    /// A connection that drops mid-request cannot tell us whether the daemon
111    /// applied it before dying, so only read-only or idempotent operations may
112    /// be retried. Repeating an archive ingest would duplicate its episodes —
113    /// a silently corrupted memory is worse than a reported failure.
114    #[must_use]
115    pub fn is_retryable(&self) -> bool {
116        match self {
117            Request::Hello
118            | Request::Status
119            | Request::Search(_)
120            | Request::SearchEpisodes(_)
121            | Request::Query(_)
122            | Request::Traverse(_)
123            // Pipeline sync diffs documents against the graph.
124            | Request::SyncPipeline(_)
125            | Request::Shutdown => true,
126            Request::AddEntity(_) | Request::Relate(_) | Request::IngestArchive(_) => false,
127        }
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct SearchArgs {
133    pub query: String,
134    pub limit: usize,
135    #[serde(default)]
136    pub entity_type: Option<String>,
137    #[serde(default)]
138    pub keyword: Option<String>,
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct SearchEpisodesArgs {
143    pub query: String,
144    pub limit: usize,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct QueryArgs {
149    pub query: String,
150    pub limit: usize,
151    #[serde(default)]
152    pub entity_type: Option<String>,
153    #[serde(default)]
154    pub keyword: Option<String>,
155    pub depth: u32,
156    pub episodes: bool,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160pub struct TraverseArgs {
161    pub entity: String,
162    pub depth: u32,
163    #[serde(default)]
164    pub type_filter: Option<String>,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct AddEntityArgs {
169    pub name: String,
170    pub entity_type: String,
171    pub abstract_text: String,
172    #[serde(default)]
173    pub overview: Option<String>,
174    #[serde(default)]
175    pub source: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct RelateArgs {
180    pub from: String,
181    pub rel_type: String,
182    pub to: String,
183    #[serde(default)]
184    pub description: Option<String>,
185    #[serde(default)]
186    pub source: Option<String>,
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct IngestArchiveArgs {
191    pub content: String,
192    pub session_id: String,
193    #[serde(default)]
194    pub log_number: Option<u32>,
195}
196
197/// Pipeline sync needs no LLM provider, so it runs against the daemon like any
198/// other graph operation instead of taking the store exclusively.
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct SyncPipelineArgs {
201    pub docs: PipelineDocuments,
202}
203
204/// A daemon response. Wire form is `{"ok": true, "data": ...}` or
205/// `{"ok": false, "error": {"code": "...", "message": "..."}}`.
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct Response {
208    pub ok: bool,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub data: Option<serde_json::Value>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub error: Option<ResponseError>,
213}
214
215/// A named failure. `code` is stable and machine-readable; `message` is the
216/// human-readable text (already prefixed by the error kind, e.g. `store locked`).
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct ResponseError {
219    pub code: String,
220    pub message: String,
221}
222
223impl Response {
224    /// A successful response carrying `data`.
225    #[must_use]
226    pub fn success(data: serde_json::Value) -> Self {
227        Self {
228            ok: true,
229            data: Some(data),
230            error: None,
231        }
232    }
233
234    /// A failed response with a stable `code` and human-readable `message`.
235    #[must_use]
236    pub fn failure(code: impl Into<String>, message: impl Into<String>) -> Self {
237        Self {
238            ok: false,
239            data: None,
240            error: Some(ResponseError {
241                code: code.into(),
242                message: message.into(),
243            }),
244        }
245    }
246
247    /// Convert a graph error into a coded failure response.
248    #[must_use]
249    pub fn from_graph_error(err: &GraphError) -> Self {
250        Self::failure(error_code(err), err.to_string())
251    }
252
253    /// Unwrap into the client-side result: data on success, a named
254    /// [`RecallError::Remote`] on failure.
255    pub fn into_result(self) -> Result<serde_json::Value, RecallError> {
256        if self.ok {
257            return Ok(self.data.unwrap_or(serde_json::Value::Null));
258        }
259        let error = self.error.unwrap_or(ResponseError {
260            code: "unknown".into(),
261            message: "daemon reported failure without a message".into(),
262        });
263        Err(RecallError::Remote {
264            code: error.code,
265            message: error.message,
266        })
267    }
268}
269
270/// Stable machine-readable code for a graph error.
271fn error_code(err: &GraphError) -> &'static str {
272    match err {
273        GraphError::Db(_) => "db",
274        GraphError::Locked(_) => "locked",
275        GraphError::Embed(_) => "embedding",
276        GraphError::NotFound(_) => "not_found",
277        GraphError::Extraction(_) => "extraction",
278        GraphError::Dedup(_) => "dedup",
279        GraphError::Llm(_) => "llm",
280        GraphError::Parse(_) => "parse",
281        GraphError::Io(_) => "io",
282        GraphError::Json(_) => "json",
283        GraphError::ImmutableMerge(_) => "immutable_merge",
284    }
285}
286
287/// Identity of a running daemon, returned by [`Request::Hello`].
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289pub struct DaemonInfo {
290    pub version: String,
291    pub pid: u32,
292    pub memory_dir: String,
293    pub socket_path: String,
294    pub uptime_secs: u64,
295}
296
297// ── Dispatch ─────────────────────────────────────────────────────────────
298
299/// Execute a graph operation against an open store.
300///
301/// Control operations ([`Request::Hello`], [`Request::Shutdown`]) are owned by
302/// the connection loop and reported as `unsupported` here.
303pub async fn dispatch_graph(graph: &GraphMemory, request: &Request) -> Response {
304    match execute_graph(graph, request).await {
305        Ok(Some(data)) => Response::success(data),
306        Ok(None) => Response::failure(
307            "unsupported",
308            format!(
309                "`{}` is a control operation, not a graph operation",
310                request.op_name()
311            ),
312        ),
313        Err(err) => Response::from_graph_error(&err),
314    }
315}
316
317/// Clamp a wire-supplied result limit into a range the store can serve.
318fn clamp_limit(limit: usize) -> usize {
319    limit.clamp(1, MAX_LIMIT)
320}
321
322/// Clamp a wire-supplied expansion depth.
323fn clamp_depth(depth: u32) -> u32 {
324    depth.clamp(1, MAX_DEPTH)
325}
326
327/// `Ok(None)` means "not a graph operation".
328async fn execute_graph(
329    graph: &GraphMemory,
330    request: &Request,
331) -> Result<Option<serde_json::Value>, GraphError> {
332    let data = match request {
333        Request::Hello | Request::Shutdown => return Ok(None),
334        Request::Status => serde_json::to_value(graph.stats().await?)?,
335        Request::Search(args) => {
336            let options = SearchOptions {
337                limit: clamp_limit(args.limit),
338                entity_type: args.entity_type.clone(),
339                keyword: args.keyword.clone(),
340            };
341            serde_json::to_value(graph.search_with_options(&args.query, &options).await?)?
342        }
343        Request::SearchEpisodes(args) => {
344            let mut episodes = graph
345                .search_episodes(&args.query, clamp_limit(args.limit))
346                .await?;
347            for result in &mut episodes {
348                result.episode.embedding = None;
349            }
350            serde_json::to_value(episodes)?
351        }
352        Request::Query(args) => {
353            let options = QueryOptions {
354                limit: clamp_limit(args.limit),
355                entity_type: args.entity_type.clone(),
356                keyword: args.keyword.clone(),
357                graph_depth: clamp_depth(args.depth),
358                include_episodes: args.episodes,
359            };
360            let mut result = graph.query(&args.query, &options).await?;
361            for episode in &mut result.episodes {
362                episode.episode.embedding = None;
363            }
364            serde_json::to_value(result)?
365        }
366        Request::Traverse(args) => serde_json::to_value(
367            graph
368                .traverse_filtered(
369                    &args.entity,
370                    clamp_depth(args.depth),
371                    args.type_filter.as_deref(),
372                )
373                .await?,
374        )?,
375        Request::AddEntity(args) => {
376            let entity_type: EntityType = args
377                .entity_type
378                .parse()
379                .map_err(|e: String| GraphError::Parse(e))?;
380            let mut entity = graph
381                .add_entity(NewEntity {
382                    name: args.name.clone(),
383                    entity_type,
384                    abstract_text: args.abstract_text.clone(),
385                    overview: args.overview.clone(),
386                    content: None,
387                    attributes: None,
388                    source: args.source.clone(),
389                })
390                .await?;
391            // 384 floats of JSON text no client has ever read.
392            entity.embedding = None;
393            serde_json::to_value(entity)?
394        }
395        Request::Relate(args) => {
396            let relationship = graph
397                .add_relationship(NewRelationship {
398                    from_entity: args.from.clone(),
399                    to_entity: args.to.clone(),
400                    rel_type: args.rel_type.clone(),
401                    description: args.description.clone(),
402                    confidence: None,
403                    source: args.source.clone(),
404                })
405                .await?;
406            serde_json::to_value(relationship)?
407        }
408        Request::IngestArchive(args) => {
409            let report = graph
410                .ingest_archive(&args.content, &args.session_id, args.log_number, None)
411                .await?;
412            serde_json::to_value(report)?
413        }
414        Request::SyncPipeline(args) => {
415            serde_json::to_value(graph.sync_pipeline(&args.docs).await?)?
416        }
417    };
418    Ok(Some(data))
419}
420
421// ── Idle tracking ────────────────────────────────────────────────────────
422
423/// Tracks daemon activity so an unused daemon shuts itself down.
424///
425/// A daemon is idle when no connection is open *and* the last completed
426/// request is older than the configured timeout. `None` disables idle
427/// shutdown entirely (`--foreground`, or `idle_timeout_secs = 0`).
428#[derive(Debug)]
429pub struct IdleTracker {
430    timeout: Option<Duration>,
431    active: AtomicUsize,
432    last_activity: Mutex<Instant>,
433}
434
435impl IdleTracker {
436    #[must_use]
437    pub fn new(timeout: Option<Duration>) -> Self {
438        Self::new_at(timeout, Instant::now())
439    }
440
441    /// Construct with an explicit start instant (used by tests).
442    #[must_use]
443    pub fn new_at(timeout: Option<Duration>, start: Instant) -> Self {
444        Self {
445            timeout,
446            active: AtomicUsize::new(0),
447            last_activity: Mutex::new(start),
448        }
449    }
450
451    /// Register a connection as open.
452    pub fn begin(&self) {
453        self.active.fetch_add(1, Ordering::SeqCst);
454        self.touch_at(Instant::now());
455    }
456
457    /// Register a connection as closed.
458    pub fn end(&self) {
459        let previous = self.active.fetch_sub(1, Ordering::SeqCst);
460        debug_assert!(previous > 0, "IdleTracker::end without begin");
461        self.touch_at(Instant::now());
462    }
463
464    /// Record activity at `now`.
465    pub fn touch_at(&self, now: Instant) {
466        let mut last = self
467            .last_activity
468            .lock()
469            .unwrap_or_else(|poisoned| poisoned.into_inner());
470        *last = now;
471    }
472
473    /// True when the daemon has been unused for longer than the timeout.
474    #[must_use]
475    pub fn is_idle_at(&self, now: Instant) -> bool {
476        let Some(timeout) = self.timeout else {
477            return false;
478        };
479        if self.active.load(Ordering::SeqCst) > 0 {
480            return false;
481        }
482        let last = *self
483            .last_activity
484            .lock()
485            .unwrap_or_else(|poisoned| poisoned.into_inner());
486        now.saturating_duration_since(last) >= timeout
487    }
488
489    /// How often the accept loop should re-check idleness.
490    #[must_use]
491    pub fn poll_interval(&self) -> Duration {
492        match self.timeout {
493            None => MAX_IDLE_POLL,
494            Some(timeout) => (timeout / 10).clamp(MIN_IDLE_POLL, MAX_IDLE_POLL),
495        }
496    }
497}
498
499/// RAII connection counter for [`IdleTracker`].
500struct ActivityGuard(Arc<DaemonContext>);
501
502impl ActivityGuard {
503    fn new(context: Arc<DaemonContext>) -> Self {
504        context.idle.begin();
505        Self(context)
506    }
507}
508
509impl Drop for ActivityGuard {
510    fn drop(&mut self) {
511        self.0.idle.end();
512    }
513}
514
515// ── Logging ──────────────────────────────────────────────────────────────
516
517/// Append-only daemon log at `<memory_dir>/graph/daemon.log`.
518///
519/// Never fails a request: if the file cannot be opened, lines go to stderr.
520pub struct DaemonLog {
521    file: Mutex<Option<std::fs::File>>,
522    echo_stderr: bool,
523}
524
525impl DaemonLog {
526    #[must_use]
527    pub fn open(path: &Path, echo_stderr: bool) -> Self {
528        let file = append_private_file().open(path).ok();
529        Self {
530            file: Mutex::new(file),
531            echo_stderr,
532        }
533    }
534
535    pub fn log(&self, message: &str) {
536        use std::io::Write as _;
537        let line = format!(
538            "[{}] pid={} {message}\n",
539            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
540            std::process::id(),
541        );
542        let mut guard = self
543            .file
544            .lock()
545            .unwrap_or_else(|poisoned| poisoned.into_inner());
546        match guard.as_mut() {
547            Some(file) => {
548                let _ = file.write_all(line.as_bytes());
549                let _ = file.flush();
550                if self.echo_stderr {
551                    eprint!("{line}");
552                }
553            }
554            None => eprint!("{line}"),
555        }
556    }
557}
558
559// ── Daemon ───────────────────────────────────────────────────────────────
560
561/// Everything `serve` needs to run.
562#[derive(Debug, Clone)]
563pub struct ServeOptions {
564    /// The memory directory whose `graph/` store this daemon owns.
565    pub memory_dir: PathBuf,
566    /// Unix socket to listen on.
567    pub socket_path: PathBuf,
568    /// Idle shutdown timeout; `None` never shuts down.
569    pub idle_timeout: Option<Duration>,
570    /// Mirror the daemon log to stderr (foreground / systemd mode).
571    pub log_to_stderr: bool,
572}
573
574impl ServeOptions {
575    /// Build options from `[serve]` in the memory directory's config.
576    ///
577    /// `foreground` (systemd) disables idle shutdown and mirrors the log to
578    /// stderr, leaving daemon lifetime to the supervisor.
579    pub fn from_config(memory_dir: &Path, foreground: bool) -> Result<Self, RecallError> {
580        let config = crate::config::load_from_dir(memory_dir);
581        let idle_timeout = match (foreground, config.serve.idle_timeout_secs) {
582            (true, _) | (_, 0) => None,
583            (false, secs) => Some(Duration::from_secs(secs)),
584        };
585        Ok(Self {
586            memory_dir: memory_dir.to_path_buf(),
587            socket_path: crate::serve_client::socket_path(memory_dir)?,
588            idle_timeout,
589            log_to_stderr: foreground,
590        })
591    }
592}
593
594/// Shared, immutable-ish daemon state.
595struct DaemonContext {
596    started: Instant,
597    memory_dir: PathBuf,
598    socket_path: PathBuf,
599    /// Only this uid may use the socket.
600    owner_uid: u32,
601    idle: IdleTracker,
602    shutdown: Notify,
603}
604
605impl DaemonContext {
606    fn info(&self) -> DaemonInfo {
607        DaemonInfo {
608            version: env!("CARGO_PKG_VERSION").to_string(),
609            pid: std::process::id(),
610            memory_dir: self.memory_dir.display().to_string(),
611            socket_path: self.socket_path.display().to_string(),
612            uptime_secs: self.started.elapsed().as_secs(),
613        }
614    }
615}
616
617/// Path of the pidfile that accompanies a socket.
618#[must_use]
619pub fn pidfile_path(socket_path: &Path) -> PathBuf {
620    let mut path = socket_path.as_os_str().to_os_string();
621    path.push(".pid");
622    PathBuf::from(path)
623}
624
625/// Run the daemon until it is asked to stop or goes idle.
626pub async fn run(options: ServeOptions) -> Result<(), RecallError> {
627    let graph_dir = options.memory_dir.join("graph");
628    std::fs::create_dir_all(&graph_dir)?;
629
630    let log = Arc::new(DaemonLog::open(
631        &graph_dir.join("daemon.log"),
632        options.log_to_stderr,
633    ));
634    log.log(&format!(
635        "starting v{} for {} on {}",
636        env!("CARGO_PKG_VERSION"),
637        options.memory_dir.display(),
638        options.socket_path.display()
639    ));
640
641    if crate::serve_client::graph_mode(&options.memory_dir) == "server" {
642        log.log("warning: [graph] mode = \"server\" — clients bypass the daemon");
643    }
644
645    let owner_uid = current_uid()?;
646
647    // Own the store before advertising the socket: clients that connect only
648    // after a successful bind never see a half-initialized daemon.
649    let graph = open_store(&graph_dir, &options.socket_path, &log).await?;
650
651    let listener = match bind_socket(&options.socket_path) {
652        Ok(listener) => listener,
653        Err(err) => {
654            log.log(&format!("failed to bind socket: {err}"));
655            return Err(err);
656        }
657    };
658    write_pidfile(&options.socket_path)?;
659
660    let context = Arc::new(DaemonContext {
661        started: Instant::now(),
662        memory_dir: options.memory_dir.clone(),
663        socket_path: options.socket_path.clone(),
664        owner_uid,
665        idle: IdleTracker::new(options.idle_timeout),
666        shutdown: Notify::new(),
667    });
668    warm_embedder(Arc::clone(&graph), Arc::clone(&log));
669    log.log("ready");
670
671    accept_loop(
672        listener,
673        Arc::clone(&graph),
674        Arc::clone(&context),
675        Arc::clone(&log),
676    )
677    .await;
678
679    // Close the store *before* the socket disappears: a client waiting for the
680    // socket to go treats that as "the store is free", and would otherwise
681    // race the SurrealKV file lock we have not released yet.
682    release_store(graph, &log).await;
683    if let Err(err) = unlink_socket(&options.socket_path) {
684        log.log(&format!("socket cleanup: {err}"));
685    }
686    let _ = std::fs::remove_file(pidfile_path(&options.socket_path));
687    log.log("stopped");
688    Ok(())
689}
690
691/// Open the store, waiting out an admin operation that currently owns it.
692async fn open_store(
693    graph_dir: &Path,
694    socket_path: &Path,
695    log: &DaemonLog,
696) -> Result<Arc<GraphMemory>, RecallError> {
697    let deadline = Instant::now() + crate::serve_client::ADMIN_WAIT_TIMEOUT;
698    loop {
699        crate::serve_client::wait_for_admin_lock(socket_path, deadline).await?;
700        match GraphMemory::open_embedded(graph_dir).await {
701            Ok(graph) => return Ok(Arc::new(graph)),
702            Err(GraphError::Locked(message))
703                if Instant::now() < deadline
704                    && crate::serve_client::admin_lock_is_held(socket_path) =>
705            {
706                log.log(&format!("waiting for an admin operation: {message}"));
707            }
708            Err(err) => {
709                log.log(&format!("failed to open graph store: {err}"));
710                return Err(err.into());
711            }
712        }
713    }
714}
715
716/// Drop the store once every in-flight connection has let go of it.
717async fn release_store(graph: Arc<GraphMemory>, log: &DaemonLog) {
718    let deadline = Instant::now() + STORE_RELEASE_TIMEOUT;
719    let mut graph = graph;
720    loop {
721        match Arc::try_unwrap(graph) {
722            Ok(store) => {
723                drop(store);
724                return;
725            }
726            Err(shared) => {
727                if Instant::now() >= deadline {
728                    log.log("gave up waiting for in-flight requests to release the store");
729                    return;
730                }
731                graph = shared;
732                tokio::time::sleep(STORE_RELEASE_POLL).await;
733            }
734        }
735    }
736}
737
738/// Load the ONNX embedding model in the background, so the first request that
739/// needs an embedding does not pay for it inline.
740///
741/// Skipped while the model cache is empty: warming a cold cache downloads the
742/// model, which must stay tied to a request that actually needs it rather than
743/// happening on every daemon start.
744fn warm_embedder(graph: Arc<GraphMemory>, log: Arc<DaemonLog>) {
745    let models_dir = graph.path().join("models");
746    if !has_cached_model(&models_dir) {
747        return;
748    }
749    tokio::task::spawn_blocking(move || {
750        let started = Instant::now();
751        match graph.embedder() {
752            Ok(_) => log.log(&format!(
753                "embedder warm in {}ms",
754                started.elapsed().as_millis()
755            )),
756            Err(err) => log.log(&format!("embedder warm-up failed: {err}")),
757        }
758    });
759}
760
761fn has_cached_model(models_dir: &Path) -> bool {
762    std::fs::read_dir(models_dir).is_ok_and(|mut entries| entries.next().is_some())
763}
764
765async fn accept_loop(
766    listener: UnixListener,
767    graph: Arc<GraphMemory>,
768    context: Arc<DaemonContext>,
769    log: Arc<DaemonLog>,
770) {
771    loop {
772        let poll = context.idle.poll_interval();
773        tokio::select! {
774            accepted = listener.accept() => match accepted {
775                Ok((stream, _)) => {
776                    let graph = Arc::clone(&graph);
777                    let context = Arc::clone(&context);
778                    let log = Arc::clone(&log);
779                    tokio::spawn(async move {
780                        handle_connection(stream, graph, context, log).await;
781                    });
782                }
783                Err(err) => log.log(&format!("accept error: {err}")),
784            },
785            () = context.shutdown.notified() => {
786                log.log("shutdown requested");
787                break;
788            }
789            () = tokio::time::sleep(poll) => {
790                if context.idle.is_idle_at(Instant::now()) {
791                    log.log("idle timeout — exiting");
792                    break;
793                }
794            }
795        }
796    }
797}
798
799async fn handle_connection(
800    stream: UnixStream,
801    graph: Arc<GraphMemory>,
802    context: Arc<DaemonContext>,
803    log: Arc<DaemonLog>,
804) {
805    let _activity = ActivityGuard::new(Arc::clone(&context));
806    if let Err(err) = authorize_peer(&stream, context.owner_uid) {
807        log.log(&format!("rejected connection: {err}"));
808        return;
809    }
810
811    let (reader, mut writer) = stream.into_split();
812    let mut lines = BufReader::new(reader.take(MAX_REQUEST_BYTES)).lines();
813
814    loop {
815        let line = match lines.next_line().await {
816            Ok(Some(line)) => line,
817            Ok(None) => {
818                // The reader stopped at the byte cap instead of at a newline:
819                // the client is sending a request larger than we will read.
820                if request_cap_reached(&mut lines) {
821                    let response = Response::failure(
822                        "bad_request",
823                        format!("request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
824                    );
825                    log.log("rejected an oversized request");
826                    let _ = write_response(&mut writer, &response).await;
827                }
828                break;
829            }
830            Err(err) => {
831                log.log(&format!("read error: {err}"));
832                break;
833            }
834        };
835        recharge_request_cap(&mut lines);
836        if line.trim().is_empty() {
837            continue;
838        }
839
840        let (response, stop) = match serde_json::from_str::<Request>(&line) {
841            Ok(Request::Hello) => (
842                Response::success(
843                    serde_json::to_value(context.info()).unwrap_or(serde_json::Value::Null),
844                ),
845                false,
846            ),
847            Ok(Request::Shutdown) => (
848                Response::success(serde_json::json!({ "stopping": true })),
849                true,
850            ),
851            Ok(request) => {
852                let started = Instant::now();
853                let response = dispatch_graph(&graph, &request).await;
854                log.log(&format!(
855                    "{} {} in {}ms",
856                    request.op_name(),
857                    if response.ok { "ok" } else { "failed" },
858                    started.elapsed().as_millis()
859                ));
860                (response, false)
861            }
862            Err(err) => (
863                Response::failure("bad_request", format!("malformed request: {err}")),
864                false,
865            ),
866        };
867
868        if let Err(err) = write_response(&mut writer, &response).await {
869            log.log(&format!("write error: {err}"));
870            break;
871        }
872        context.idle.touch_at(Instant::now());
873
874        if stop {
875            // `notify_one` stores a permit when the accept loop happens to be
876            // between `select!` iterations; `notify_waiters` would be lost
877            // there and the daemon would outlive the shutdown request.
878            context.shutdown.notify_one();
879            break;
880        }
881    }
882}
883
884/// A connection's request reader, capped at [`MAX_REQUEST_BYTES`] per request.
885type RequestLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>;
886
887/// True when the reader stopped because the request hit the byte cap.
888fn request_cap_reached(lines: &mut RequestLines) -> bool {
889    lines.get_mut().get_mut().limit() == 0
890}
891
892/// Give the next request on this connection its own full byte budget.
893fn recharge_request_cap(lines: &mut RequestLines) {
894    lines.get_mut().get_mut().set_limit(MAX_REQUEST_BYTES);
895}
896
897/// The socket has no authentication of its own: anyone who can open it can
898/// read every ingest payload and forge every answer. Only our own uid may.
899fn authorize_peer(stream: &UnixStream, owner_uid: u32) -> Result<(), RecallError> {
900    let peer = stream.peer_cred().map_err(|err| {
901        RecallError::Daemon(format!("cannot read socket peer credentials: {err}"))
902    })?;
903    check_peer_uid(peer.uid(), owner_uid)
904}
905
906async fn write_response(
907    writer: &mut tokio::net::unix::OwnedWriteHalf,
908    response: &Response,
909) -> Result<(), RecallError> {
910    let mut line = serde_json::to_vec(response)?;
911    line.push(b'\n');
912    writer.write_all(&line).await?;
913    writer.flush().await?;
914    Ok(())
915}
916
917/// Path the socket is bound to before it is published under its real name.
918fn staging_path(socket_path: &Path) -> PathBuf {
919    let mut path = socket_path.as_os_str().to_os_string();
920    path.push(".new");
921    PathBuf::from(path)
922}
923
924/// Bind the listening socket, clearing a stale socket left by a dead daemon.
925///
926/// The socket is bound under a temporary name in the same directory, made
927/// owner-only, and only then renamed into place: `bind` applies the process
928/// umask, so publishing first would leave a world-reachable socket for as long
929/// as it takes to chmod it.
930fn bind_socket(socket_path: &Path) -> Result<UnixListener, RecallError> {
931    use std::os::unix::fs::PermissionsExt;
932
933    if let Some(parent) = socket_path.parent() {
934        crate::serve_client::ensure_socket_dir(parent)?;
935    }
936    if std::os::unix::net::UnixStream::connect(socket_path).is_ok() {
937        return Err(RecallError::Daemon(format!(
938            "another daemon is already listening on {}",
939            socket_path.display()
940        )));
941    }
942
943    let staged = staging_path(socket_path);
944    unlink_socket(&staged)?;
945    let listener = UnixListener::bind(&staged).map_err(|err| {
946        RecallError::Daemon(format!("cannot listen on {}: {err}", staged.display()))
947    })?;
948    std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(PRIVATE_FILE_MODE)).map_err(
949        |err| {
950            RecallError::Daemon(format!(
951                "cannot restrict the daemon socket {}: {err}",
952                staged.display()
953            ))
954        },
955    )?;
956
957    // Whatever sits at the published path is a socket a dead daemon left.
958    unlink_socket(socket_path)?;
959    std::fs::rename(&staged, socket_path).map_err(|err| {
960        let _ = std::fs::remove_file(&staged);
961        RecallError::Daemon(format!(
962            "cannot publish the daemon socket at {}: {err}",
963            socket_path.display()
964        ))
965    })?;
966    Ok(listener)
967}
968
969fn write_pidfile(socket_path: &Path) -> Result<(), RecallError> {
970    use std::io::Write as _;
971
972    let contents = serde_json::json!({
973        "pid": std::process::id(),
974        "version": env!("CARGO_PKG_VERSION"),
975        "socket_path": socket_path.display().to_string(),
976    });
977    let path = pidfile_path(socket_path);
978    let _ = std::fs::remove_file(&path);
979    let mut file = crate::serve_security::create_new_private_file().open(&path)?;
980    writeln!(file, "{contents}")?;
981    Ok(())
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987
988    const fn assert_shareable<T: Send + Sync>() {}
989
990    #[test]
991    fn graph_memory_is_shareable_across_tasks() {
992        // The daemon hands one Arc<GraphMemory> to every connection task.
993        assert_shareable::<GraphMemory>();
994    }
995
996    #[test]
997    fn request_wire_format_is_op_and_args() {
998        let request = Request::Search(SearchArgs {
999            query: "rust".into(),
1000            limit: 5,
1001            entity_type: None,
1002            keyword: None,
1003        });
1004        let json = serde_json::to_value(&request).unwrap();
1005        assert_eq!(json["op"], "search");
1006        assert_eq!(json["args"]["query"], "rust");
1007        assert_eq!(json["args"]["limit"], 5);
1008    }
1009
1010    #[test]
1011    fn control_requests_serialize_without_args() {
1012        assert_eq!(
1013            serde_json::to_string(&Request::Hello).unwrap(),
1014            r#"{"op":"hello"}"#
1015        );
1016        assert_eq!(
1017            serde_json::to_string(&Request::Shutdown).unwrap(),
1018            r#"{"op":"shutdown"}"#
1019        );
1020    }
1021
1022    #[test]
1023    fn request_round_trips_every_variant() {
1024        let requests = vec![
1025            Request::Hello,
1026            Request::Status,
1027            Request::Search(SearchArgs {
1028                query: "q".into(),
1029                limit: 3,
1030                entity_type: Some("tool".into()),
1031                keyword: Some("k".into()),
1032            }),
1033            Request::SearchEpisodes(SearchEpisodesArgs {
1034                query: "q".into(),
1035                limit: 2,
1036            }),
1037            Request::Query(QueryArgs {
1038                query: "q".into(),
1039                limit: 10,
1040                entity_type: None,
1041                keyword: None,
1042                depth: 2,
1043                episodes: true,
1044            }),
1045            Request::Traverse(TraverseArgs {
1046                entity: "Rust".into(),
1047                depth: 1,
1048                type_filter: None,
1049            }),
1050            Request::AddEntity(AddEntityArgs {
1051                name: "Rust".into(),
1052                entity_type: "tool".into(),
1053                abstract_text: "language".into(),
1054                overview: None,
1055                source: Some("test".into()),
1056            }),
1057            Request::Relate(RelateArgs {
1058                from: "D".into(),
1059                rel_type: "USES".into(),
1060                to: "Rust".into(),
1061                description: None,
1062                source: None,
1063            }),
1064            Request::IngestArchive(IngestArchiveArgs {
1065                content: "# log".into(),
1066                session_id: "s1".into(),
1067                log_number: Some(7),
1068            }),
1069            Request::SyncPipeline(SyncPipelineArgs {
1070                docs: PipelineDocuments {
1071                    learning: "# learning".into(),
1072                    ..PipelineDocuments::default()
1073                },
1074            }),
1075            Request::Shutdown,
1076        ];
1077
1078        for request in requests {
1079            let line = serde_json::to_string(&request).unwrap();
1080            let parsed: Request = serde_json::from_str(&line).unwrap();
1081            assert_eq!(parsed, request, "round trip failed for {line}");
1082        }
1083    }
1084
1085    #[test]
1086    fn optional_args_may_be_omitted_on_the_wire() {
1087        let parsed: Request =
1088            serde_json::from_str(r#"{"op":"search","args":{"query":"q","limit":1}}"#).unwrap();
1089        assert_eq!(
1090            parsed,
1091            Request::Search(SearchArgs {
1092                query: "q".into(),
1093                limit: 1,
1094                entity_type: None,
1095                keyword: None,
1096            })
1097        );
1098    }
1099
1100    #[test]
1101    fn success_response_carries_data_only() {
1102        let response = Response::success(serde_json::json!({"n": 1}));
1103        let json = serde_json::to_value(&response).unwrap();
1104        assert_eq!(json["ok"], true);
1105        assert_eq!(json["data"]["n"], 1);
1106        assert!(json.get("error").is_none());
1107        assert_eq!(response.into_result().unwrap(), serde_json::json!({"n": 1}));
1108    }
1109
1110    #[test]
1111    fn failure_response_maps_to_named_remote_error() {
1112        let response = Response::from_graph_error(&GraphError::Locked("store busy".into()));
1113        assert_eq!(response.error.as_ref().unwrap().code, "locked");
1114        let err = response.into_result().unwrap_err();
1115        assert!(err.to_string().contains("store locked"));
1116        assert!(matches!(err, RecallError::Remote { .. }));
1117    }
1118
1119    #[test]
1120    fn failure_response_round_trips() {
1121        let response = Response::failure("bad_request", "malformed");
1122        let line = serde_json::to_string(&response).unwrap();
1123        let parsed: Response = serde_json::from_str(&line).unwrap();
1124        assert_eq!(parsed, response);
1125        assert!(!parsed.ok);
1126    }
1127
1128    #[test]
1129    fn error_codes_are_distinct_per_kind() {
1130        assert_eq!(error_code(&GraphError::Locked(String::new())), "locked");
1131        assert_eq!(
1132            error_code(&GraphError::NotFound(String::new())),
1133            "not_found"
1134        );
1135        assert_eq!(error_code(&GraphError::Embed(String::new())), "embedding");
1136    }
1137
1138    #[test]
1139    fn idle_timer_fires_after_timeout() {
1140        let start = Instant::now();
1141        let tracker = IdleTracker::new_at(Some(Duration::from_secs(60)), start);
1142
1143        assert!(!tracker.is_idle_at(start + Duration::from_secs(59)));
1144        assert!(tracker.is_idle_at(start + Duration::from_secs(60)));
1145    }
1146
1147    #[test]
1148    fn idle_timer_never_fires_while_a_connection_is_open() {
1149        let start = Instant::now();
1150        let tracker = IdleTracker::new_at(Some(Duration::from_secs(1)), start);
1151
1152        tracker.begin();
1153        assert!(!tracker.is_idle_at(start + Duration::from_secs(600)));
1154        tracker.end();
1155
1156        tracker.touch_at(start);
1157        assert!(tracker.is_idle_at(start + Duration::from_secs(600)));
1158    }
1159
1160    #[test]
1161    fn activity_resets_the_idle_timer() {
1162        let start = Instant::now();
1163        let tracker = IdleTracker::new_at(Some(Duration::from_secs(10)), start);
1164
1165        tracker.touch_at(start + Duration::from_secs(9));
1166        assert!(!tracker.is_idle_at(start + Duration::from_secs(18)));
1167        assert!(tracker.is_idle_at(start + Duration::from_secs(19)));
1168    }
1169
1170    #[test]
1171    fn idle_shutdown_disabled_without_timeout() {
1172        let start = Instant::now();
1173        let tracker = IdleTracker::new_at(None, start);
1174        assert!(!tracker.is_idle_at(start + Duration::from_secs(86_400)));
1175        assert_eq!(tracker.poll_interval(), MAX_IDLE_POLL);
1176    }
1177
1178    #[test]
1179    fn poll_interval_is_bounded() {
1180        let now = Instant::now();
1181        assert_eq!(
1182            IdleTracker::new_at(Some(Duration::from_millis(10)), now).poll_interval(),
1183            MIN_IDLE_POLL
1184        );
1185        assert_eq!(
1186            IdleTracker::new_at(Some(Duration::from_secs(3600)), now).poll_interval(),
1187            MAX_IDLE_POLL
1188        );
1189        assert_eq!(
1190            IdleTracker::new_at(Some(Duration::from_secs(100)), now).poll_interval(),
1191            Duration::from_secs(10)
1192        );
1193    }
1194
1195    #[test]
1196    fn only_repeatable_operations_are_retryable() {
1197        assert!(Request::Status.is_retryable());
1198        assert!(Request::Traverse(TraverseArgs {
1199            entity: "Rust".into(),
1200            depth: 1,
1201            type_filter: None,
1202        })
1203        .is_retryable());
1204
1205        // Replaying this one would duplicate every episode of a conversation.
1206        assert!(!Request::IngestArchive(IngestArchiveArgs {
1207            content: "# log".into(),
1208            session_id: "s1".into(),
1209            log_number: Some(1),
1210        })
1211        .is_retryable());
1212        assert!(!Request::AddEntity(AddEntityArgs {
1213            name: "Rust".into(),
1214            entity_type: "tool".into(),
1215            abstract_text: "language".into(),
1216            overview: None,
1217            source: None,
1218        })
1219        .is_retryable());
1220    }
1221
1222    #[test]
1223    fn wire_limits_are_clamped_into_a_servable_range() {
1224        assert_eq!(clamp_limit(10), 10);
1225        assert_eq!(clamp_limit(MAX_LIMIT), MAX_LIMIT);
1226        // A limit this large overflows the `limit * 4` KNN `ef` computation
1227        // and asks SurrealDB for an unbounded scan.
1228        assert_eq!(clamp_limit(usize::MAX), MAX_LIMIT);
1229        assert_eq!(clamp_limit(0), 1);
1230    }
1231
1232    #[test]
1233    fn wire_depths_are_clamped_into_a_servable_range() {
1234        assert_eq!(clamp_depth(2), 2);
1235        assert_eq!(clamp_depth(MAX_DEPTH), MAX_DEPTH);
1236        assert_eq!(clamp_depth(u32::MAX), MAX_DEPTH);
1237        assert_eq!(clamp_depth(0), 1);
1238    }
1239
1240    #[test]
1241    fn the_staged_socket_sits_beside_the_published_one() {
1242        let socket = Path::new("/run/recall-echo/abc.sock");
1243        let staged = staging_path(socket);
1244        assert_eq!(staged.parent(), socket.parent());
1245        assert_ne!(staged, socket);
1246        // `sockaddr_un.sun_path` is 108 bytes and the published path is capped
1247        // at 100, so the staging name must stay within the remainder.
1248        assert!(staged.as_os_str().len() - socket.as_os_str().len() <= 7);
1249    }
1250
1251    #[test]
1252    fn pidfile_sits_beside_the_socket() {
1253        assert_eq!(
1254            pidfile_path(Path::new("/run/recall-echo/abc.sock")),
1255            PathBuf::from("/run/recall-echo/abc.sock.pid")
1256        );
1257    }
1258
1259    #[test]
1260    fn serve_options_from_config_uses_defaults() {
1261        let dir = tempfile::tempdir().unwrap();
1262        std::fs::write(
1263            dir.path().join(".recall-echo.toml"),
1264            format!(
1265                "[serve]\nsocket_path = \"{}/graph.sock\"\n",
1266                dir.path().display()
1267            ),
1268        )
1269        .unwrap();
1270
1271        let options = ServeOptions::from_config(dir.path(), false).unwrap();
1272        assert_eq!(options.idle_timeout, Some(Duration::from_secs(3600)));
1273        assert!(!options.log_to_stderr);
1274        assert_eq!(options.socket_path, dir.path().join("graph.sock"));
1275    }
1276
1277    #[test]
1278    fn foreground_disables_idle_shutdown() {
1279        let dir = tempfile::tempdir().unwrap();
1280        std::fs::write(
1281            dir.path().join(".recall-echo.toml"),
1282            format!(
1283                "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 30\n",
1284                dir.path().display()
1285            ),
1286        )
1287        .unwrap();
1288
1289        let options = ServeOptions::from_config(dir.path(), true).unwrap();
1290        assert_eq!(options.idle_timeout, None);
1291        assert!(options.log_to_stderr);
1292    }
1293
1294    #[test]
1295    fn zero_idle_timeout_disables_idle_shutdown() {
1296        let dir = tempfile::tempdir().unwrap();
1297        std::fs::write(
1298            dir.path().join(".recall-echo.toml"),
1299            format!(
1300                "[serve]\nsocket_path = \"{}/graph.sock\"\nidle_timeout_secs = 0\n",
1301                dir.path().display()
1302            ),
1303        )
1304        .unwrap();
1305
1306        assert_eq!(
1307            ServeOptions::from_config(dir.path(), false)
1308                .unwrap()
1309                .idle_timeout,
1310            None
1311        );
1312    }
1313}