Skip to main content

kindling_server/
state.rs

1//! Shared application state: the per-project service registry and the
2//! activity tracker that drives idle shutdown.
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10use kindling_service::KindlingService;
11use kindling_store::{project_db_path, project_id};
12
13use crate::error::ApiError;
14
15/// One [`KindlingService`] per project, keyed by the project-id hash.
16///
17/// `rusqlite::Connection` is `Send + !Sync`, so each service lives behind its
18/// own `Mutex`. The per-project mutex serialises writes — the intended WAL
19/// single-writer model. The outer mutex only guards the map, never DB work.
20type Registry = Arc<Mutex<HashMap<String, Arc<Mutex<KindlingService>>>>>;
21
22/// Shared, cloneable application state handed to every handler.
23#[derive(Clone)]
24pub struct AppState {
25    kindling_home: PathBuf,
26    services: Registry,
27    activity: Arc<Activity>,
28}
29
30impl AppState {
31    /// Build state rooted at `kindling_home`. Project DBs live under
32    /// `kindling_home/projects/<hash>/kindling.db`.
33    pub fn new(kindling_home: PathBuf) -> Self {
34        Self {
35            kindling_home,
36            services: Arc::new(Mutex::new(HashMap::new())),
37            activity: Arc::new(Activity::new()),
38        }
39    }
40
41    /// kindling home root.
42    pub fn kindling_home(&self) -> &PathBuf {
43        &self.kindling_home
44    }
45
46    /// Activity tracker (for the idle-shutdown layer/task).
47    pub fn activity(&self) -> &Arc<Activity> {
48        &self.activity
49    }
50
51    /// Project ids for every project that has been touched this session (used
52    /// by `/v1/health`). Sorted for deterministic output.
53    pub fn known_project_ids(&self) -> Vec<String> {
54        let map = self.services.lock().expect("registry mutex poisoned");
55        let mut ids: Vec<String> = map.keys().cloned().collect();
56        ids.sort();
57        ids
58    }
59
60    /// Resolve (and lazily open) the service for `project_root`. The DB path is
61    /// derived from the store's hashing — the single source of truth — so the
62    /// daemon and any other consumer of the same project share one database.
63    ///
64    /// Returns a clone of the per-project `Arc<Mutex<KindlingService>>`; the
65    /// caller locks it, does synchronous DB work, and drops the lock. Never
66    /// hold this lock across an `.await`.
67    pub fn service_for(&self, project_root: &str) -> Result<Arc<Mutex<KindlingService>>, ApiError> {
68        let key = project_id(project_root);
69
70        // Fast path: already open.
71        {
72            let map = self.services.lock().expect("registry mutex poisoned");
73            if let Some(svc) = map.get(&key) {
74                return Ok(Arc::clone(svc));
75            }
76        }
77
78        // Open outside the registry lock would risk a double-open race; instead
79        // open under the lock. DB open is fast and rare (once per project).
80        let mut map = self.services.lock().expect("registry mutex poisoned");
81        if let Some(svc) = map.get(&key) {
82            return Ok(Arc::clone(svc));
83        }
84        let db_path = project_db_path(&self.kindling_home, project_root);
85        let service = KindlingService::open(&db_path)
86            .map_err(|e| ApiError::Internal(format!("opening project db: {e}")))?;
87        let entry = Arc::new(Mutex::new(service));
88        map.insert(key, Arc::clone(&entry));
89        Ok(entry)
90    }
91}
92
93/// Tracks in-flight request count and last-activity time for idle shutdown.
94#[derive(Debug)]
95pub struct Activity {
96    in_flight: AtomicU64,
97    /// Millis since `start` of the last request boundary (start or finish).
98    last_active_ms: AtomicU64,
99    start: Instant,
100}
101
102impl Activity {
103    fn new() -> Self {
104        Self {
105            in_flight: AtomicU64::new(0),
106            last_active_ms: AtomicU64::new(0),
107            start: Instant::now(),
108        }
109    }
110
111    fn now_ms(&self) -> u64 {
112        self.start.elapsed().as_millis() as u64
113    }
114
115    /// Mark the start of a request: bump in-flight and touch last-active.
116    pub fn enter(&self) {
117        self.in_flight.fetch_add(1, Ordering::SeqCst);
118        self.last_active_ms.store(self.now_ms(), Ordering::SeqCst);
119    }
120
121    /// Mark the end of a request: drop in-flight and touch last-active.
122    pub fn leave(&self) {
123        self.in_flight.fetch_sub(1, Ordering::SeqCst);
124        self.last_active_ms.store(self.now_ms(), Ordering::SeqCst);
125    }
126
127    /// Whether the daemon has been idle (no in-flight, no recent activity) for
128    /// at least `timeout`.
129    pub fn is_idle_for(&self, timeout: Duration) -> bool {
130        if self.in_flight.load(Ordering::SeqCst) > 0 {
131            return false;
132        }
133        let idle_ms = self
134            .now_ms()
135            .saturating_sub(self.last_active_ms.load(Ordering::SeqCst));
136        idle_ms >= timeout.as_millis() as u64
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn idle_after_timeout_when_no_activity() {
146        let a = Activity::new();
147        // Brand new: last_active = 0, but elapsed grows. With a zero timeout we
148        // are immediately "idle".
149        assert!(a.is_idle_for(Duration::from_millis(0)));
150    }
151
152    #[test]
153    fn not_idle_while_in_flight() {
154        let a = Activity::new();
155        a.enter();
156        assert!(!a.is_idle_for(Duration::from_millis(0)));
157        a.leave();
158        assert!(a.is_idle_for(Duration::from_millis(0)));
159    }
160}