1use 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#[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
35fn 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#[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
101fn 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
121pub fn release_memory() {
123 crate::drop_listing_cache();
124 #[cfg(all(target_os = "linux", target_env = "gnu"))]
125 unsafe {
127 libc::malloc_trim(0);
128 }
129}
130
131pub struct CallActivity(());
134
135impl Drop for CallActivity {
136 fn drop(&mut self) {
137 crate::bookkeep::touch_activity();
138 }
139}
140
141#[must_use]
143pub fn call_started() -> CallActivity {
144 crate::bookkeep::touch_activity();
145 CallActivity(())
146}
147
148pub 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}