Skip to main content

ljos_cli/
idle_drop.rs

1//! What a long-lived ljos-mcp gives back once nobody is calling it.
2//!
3//! A tool call parses whole pack listings, tracker files and transcripts.
4//! The parse is freed when the call returns, but glibc keeps freed pages
5//! in its arenas, so a server that answered a burst stayed at the burst's
6//! size for days. After [`drop_after`] without a call the server drops
7//! the listing it caches and asks the allocator to return free pages.
8
9use std::path::Path;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::time::Duration;
12
13/// Tool calls in flight. A call that outlasts the window is not idle.
14static IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
15
16const DEFAULT: Duration = Duration::from_secs(120);
17const ORPHAN_DEFAULT: Duration = Duration::from_secs(6 * 60 * 60);
18
19/// How long the server sits without a call before it gives memory back.
20/// `LJOS_MCP_IDLE_DROP`, else `[mcp] idle_drop` in the `ljos.toml`
21/// layers, else 120 s. A bare number is seconds; `90s`, `5m` and `1h`
22/// carry their unit. `0` and `off` keep everything until exit.
23#[must_use]
24pub fn drop_after() -> Option<Duration> {
25    if let Ok(raw) = std::env::var("LJOS_MCP_IDLE_DROP") {
26        if !raw.trim().is_empty() {
27            return parse(&raw);
28        }
29    }
30    let mut window = Some(DEFAULT);
31    for path in crate::converge::config_paths(None) {
32        if let Some(set) = configured(&path) {
33            window = set;
34        }
35    }
36    window
37}
38
39/// The `[mcp] idle_drop` of one file, or `None` when the file sets none.
40fn configured(path: &Path) -> Option<Option<Duration>> {
41    let text = std::fs::read_to_string(path).ok()?;
42    let value = toml::from_str::<toml::Value>(&text).ok()?;
43    match value.get("mcp")?.get("idle_drop")? {
44        toml::Value::Integer(secs) => Some(u64::try_from(*secs).ok().and_then(window)),
45        toml::Value::String(raw) => Some(parse(raw)),
46        toml::Value::Boolean(false) => Some(None),
47        _ => None,
48    }
49}
50
51fn window(secs: u64) -> Option<Duration> {
52    (secs > 0).then(|| Duration::from_secs(secs))
53}
54
55fn parse(raw: &str) -> Option<Duration> {
56    let raw = raw.trim();
57    if matches!(
58        raw.to_ascii_lowercase().as_str(),
59        "off" | "false" | "no" | "never"
60    ) {
61        return None;
62    }
63    if let Ok(secs) = raw.parse::<u64>() {
64        return window(secs);
65    }
66    match crate::reap::parse_idle(raw) {
67        Ok(found) if found.is_zero() => None,
68        Ok(found) => Some(found),
69        Err(_) => Some(DEFAULT),
70    }
71}
72
73/// How long an `ljos-mcp` with no client request may live when no editor
74/// or agent is still above it. `LJOS_MCP_ORPHAN_IDLE`, else `[mcp]
75/// orphan_idle` in the `ljos.toml` layers, else six hours. A bare number
76/// is seconds. `0` and `off` leave the server until the client or stdin
77/// ends it.
78#[must_use]
79pub fn orphan_after() -> Option<Duration> {
80    if let Ok(raw) = std::env::var("LJOS_MCP_ORPHAN_IDLE") {
81        if !raw.trim().is_empty() {
82            return parse_orphan(&raw);
83        }
84    }
85    let mut window = Some(ORPHAN_DEFAULT);
86    for path in crate::converge::config_paths(None) {
87        if let Some(set) = configured_orphan(&path) {
88            window = set;
89        }
90    }
91    window
92}
93
94fn configured_orphan(path: &Path) -> Option<Option<Duration>> {
95    let text = std::fs::read_to_string(path).ok()?;
96    let value = toml::from_str::<toml::Value>(&text).ok()?;
97    match value.get("mcp")?.get("orphan_idle")? {
98        toml::Value::Integer(secs) => Some(u64::try_from(*secs).ok().and_then(window)),
99        toml::Value::String(raw) => Some(parse_orphan(raw)),
100        toml::Value::Boolean(false) => Some(None),
101        _ => None,
102    }
103}
104
105/// `raw` as a quiet window. A bare number is seconds, unlike
106/// [`crate::reap::parse_idle`], which reads a bare number as minutes.
107fn parse_orphan(raw: &str) -> Option<Duration> {
108    let raw = raw.trim();
109    if matches!(
110        raw.to_ascii_lowercase().as_str(),
111        "0" | "off" | "false" | "no" | "never"
112    ) {
113        return None;
114    }
115    if let Ok(secs) = raw.parse::<u64>() {
116        return window(secs);
117    }
118    match crate::reap::parse_idle(raw) {
119        Ok(found) if found.is_zero() => None,
120        Ok(found) => Some(found),
121        Err(_) => Some(ORPHAN_DEFAULT),
122    }
123}
124
125/// Drop what the process caches and return free pages to the system.
126pub fn release_memory() {
127    crate::drop_listing_cache();
128    #[cfg(all(target_os = "linux", target_env = "gnu"))]
129    // SAFETY: malloc_trim takes no pointers and is safe from any thread.
130    unsafe {
131        libc::malloc_trim(0);
132    }
133}
134
135/// Touches the activity clock now and again when dropped, and counts the
136/// call as in flight between the two, so a call that outlasts the window
137/// is not trimmed under its feet and is followed by a drop once it ends.
138pub struct CallActivity(());
139
140impl Drop for CallActivity {
141    fn drop(&mut self) {
142        IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
143        crate::bookkeep::touch_activity();
144    }
145}
146
147/// Hold for the length of one tool call.
148#[must_use]
149pub fn call_started() -> CallActivity {
150    IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
151    crate::bookkeep::touch_activity();
152    CallActivity(())
153}
154
155/// Whether the server has sat without a call for `limit` since `last`:
156/// nothing in flight, and the last activity old enough.
157#[must_use]
158pub fn idle_now(now: u64, last: u64, limit: Duration) -> bool {
159    IN_FLIGHT.load(Ordering::SeqCst) == 0 && crate::bookkeep::idle_due(now, last, limit)
160}
161
162/// Start the thread that calls [`release_memory`] once the last tool call
163/// is [`drop_after`] old. One drop per quiet spell: a server nobody calls
164/// does not trim again and again.
165pub fn spawn_idle_drop() {
166    let Some(limit) = drop_after() else {
167        return;
168    };
169    crate::bookkeep::touch_activity();
170    let tick = (limit / 2).clamp(Duration::from_secs(1), Duration::from_secs(10));
171    let _ = std::thread::Builder::new()
172        .name("ljos-mcp-drop".into())
173        .stack_size(64 * 1024)
174        .spawn(move || {
175            let mut dropped_at = 0;
176            loop {
177                std::thread::sleep(tick);
178                let last = crate::bookkeep::last_activity();
179                if last != dropped_at && idle_now(crate::bookkeep::now_secs(), last, limit) {
180                    release_memory();
181                    dropped_at = last;
182                }
183            }
184        });
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    /// A call in flight is not idle however old the clock, and once it
192    /// ends the clock is fresh.
193    #[test]
194    fn a_call_in_flight_is_not_idle() {
195        let limit = Duration::from_secs(1);
196        let old = crate::bookkeep::now_secs() - 100;
197        assert!(idle_now(crate::bookkeep::now_secs(), old, limit));
198        let held = call_started();
199        assert!(!idle_now(crate::bookkeep::now_secs() + 100, old, limit));
200        let also = call_started();
201        drop(held);
202        assert!(!idle_now(crate::bookkeep::now_secs() + 100, old, limit));
203        drop(also);
204        let last = crate::bookkeep::last_activity();
205        assert!(!idle_now(crate::bookkeep::now_secs(), last, limit));
206        assert!(idle_now(last + 100, last, limit));
207    }
208
209    #[test]
210    fn a_bare_number_is_seconds_and_off_keeps_everything() {
211        assert_eq!(parse("120"), Some(Duration::from_secs(120)));
212        assert_eq!(parse("5m"), Some(Duration::from_secs(300)));
213        assert_eq!(parse("0"), None);
214        assert_eq!(parse("off"), None);
215        assert_eq!(parse("soon"), Some(DEFAULT));
216    }
217
218    #[test]
219    fn orphan_idle_is_seconds_and_off_leaves_the_server() {
220        assert_eq!(parse_orphan("30"), Some(Duration::from_secs(30)));
221        assert_eq!(parse_orphan("5m"), Some(Duration::from_secs(300)));
222        assert_eq!(parse_orphan("0"), None);
223        assert_eq!(parse_orphan("off"), None);
224        assert_eq!(parse_orphan("soon"), Some(ORPHAN_DEFAULT));
225    }
226
227    #[test]
228    fn ljos_toml_sets_orphan_idle_under_mcp() {
229        let dir = tempfile::tempdir().unwrap();
230        let file = dir.path().join("ljos.toml");
231        std::fs::write(&file, "[mcp]\norphan_idle = 30\n").unwrap();
232        assert_eq!(
233            configured_orphan(&file),
234            Some(Some(Duration::from_secs(30)))
235        );
236        std::fs::write(&file, "[mcp]\norphan_idle = \"off\"\n").unwrap();
237        assert_eq!(configured_orphan(&file), Some(None));
238        std::fs::write(&file, "[mcp]\nidle_drop = 30\n").unwrap();
239        assert_eq!(configured_orphan(&file), None);
240    }
241
242    #[test]
243    fn ljos_toml_sets_the_window_under_mcp() {
244        let dir = tempfile::tempdir().unwrap();
245        let file = dir.path().join("ljos.toml");
246        std::fs::write(&file, "[mcp]\nidle_drop = 30\n").unwrap();
247        assert_eq!(configured(&file), Some(Some(Duration::from_secs(30))));
248        std::fs::write(&file, "[mcp]\nidle_drop = \"off\"\n").unwrap();
249        assert_eq!(configured(&file), Some(None));
250        std::fs::write(&file, "[git_hooks]\nskip = []\n").unwrap();
251        assert_eq!(configured(&file), None);
252    }
253}