1use std::path::Path;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::time::Duration;
12
13static 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#[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
39fn 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#[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
105fn 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
125pub fn release_memory() {
127 crate::drop_listing_cache();
128 #[cfg(all(target_os = "linux", target_env = "gnu"))]
129 unsafe {
131 libc::malloc_trim(0);
132 }
133}
134
135pub 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#[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#[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
162pub 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 #[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}