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