1use 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
38const MAX_IDLE_POLL: Duration = Duration::from_secs(30);
40const MIN_IDLE_POLL: Duration = Duration::from_millis(100);
42
43const MAX_REQUEST_BYTES: u64 = 8 * 1024 * 1024;
47const MAX_LIMIT: usize = 1000;
50const MAX_DEPTH: u32 = 8;
53const STORE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10);
56const STORE_RELEASE_POLL: Duration = Duration::from_millis(10);
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "op", content = "args", rename_all = "snake_case")]
64pub enum Request {
65 Hello,
67 Status,
69 Search(SearchArgs),
71 SearchEpisodes(SearchEpisodesArgs),
73 Query(QueryArgs),
75 Traverse(TraverseArgs),
77 AddEntity(AddEntityArgs),
79 Relate(RelateArgs),
81 IngestArchive(IngestArchiveArgs),
83 SyncPipeline(SyncPipelineArgs),
85 Shutdown,
87}
88
89impl Request {
90 #[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 #[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 | 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct SyncPipelineArgs {
201 pub docs: PipelineDocuments,
202}
203
204#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct ResponseError {
219 pub code: String,
220 pub message: String,
221}
222
223impl Response {
224 #[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 #[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 #[must_use]
249 pub fn from_graph_error(err: &GraphError) -> Self {
250 Self::failure(error_code(err), err.to_string())
251 }
252
253 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
270fn 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#[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
297pub 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
317fn clamp_limit(limit: usize) -> usize {
319 limit.clamp(1, MAX_LIMIT)
320}
321
322fn clamp_depth(depth: u32) -> u32 {
324 depth.clamp(1, MAX_DEPTH)
325}
326
327async 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 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#[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 #[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 pub fn begin(&self) {
453 self.active.fetch_add(1, Ordering::SeqCst);
454 self.touch_at(Instant::now());
455 }
456
457 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 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 #[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 #[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
499struct 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
515pub 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#[derive(Debug, Clone)]
563pub struct ServeOptions {
564 pub memory_dir: PathBuf,
566 pub socket_path: PathBuf,
568 pub idle_timeout: Option<Duration>,
570 pub log_to_stderr: bool,
572}
573
574impl ServeOptions {
575 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
594struct DaemonContext {
596 started: Instant,
597 memory_dir: PathBuf,
598 socket_path: PathBuf,
599 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#[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
625pub 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 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 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
691async 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
716async 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
738fn 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 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 context.shutdown.notify_one();
879 break;
880 }
881 }
882}
883
884type RequestLines = tokio::io::Lines<BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>;
886
887fn request_cap_reached(lines: &mut RequestLines) -> bool {
889 lines.get_mut().get_mut().limit() == 0
890}
891
892fn recharge_request_cap(lines: &mut RequestLines) {
894 lines.get_mut().get_mut().set_limit(MAX_REQUEST_BYTES);
895}
896
897fn 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
917fn 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
924fn 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 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 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 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 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 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}