use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
static IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
const DEFAULT: Duration = Duration::from_secs(120);
const ORPHAN_DEFAULT: Duration = Duration::from_secs(6 * 60 * 60);
#[must_use]
pub fn drop_after() -> Option<Duration> {
if let Ok(raw) = std::env::var("LJOS_MCP_IDLE_DROP") {
if !raw.trim().is_empty() {
return parse(&raw);
}
}
let mut window = Some(DEFAULT);
for path in crate::converge::config_paths(None) {
if let Some(set) = configured(&path) {
window = set;
}
}
window
}
fn configured(path: &Path) -> Option<Option<Duration>> {
let text = std::fs::read_to_string(path).ok()?;
let value = toml::from_str::<toml::Value>(&text).ok()?;
match value.get("mcp")?.get("idle_drop")? {
toml::Value::Integer(secs) => Some(u64::try_from(*secs).ok().and_then(window)),
toml::Value::String(raw) => Some(parse(raw)),
toml::Value::Boolean(false) => Some(None),
_ => None,
}
}
fn window(secs: u64) -> Option<Duration> {
(secs > 0).then(|| Duration::from_secs(secs))
}
fn parse(raw: &str) -> Option<Duration> {
let raw = raw.trim();
if matches!(
raw.to_ascii_lowercase().as_str(),
"off" | "false" | "no" | "never"
) {
return None;
}
if let Ok(secs) = raw.parse::<u64>() {
return window(secs);
}
match crate::reap::parse_idle(raw) {
Ok(found) if found.is_zero() => None,
Ok(found) => Some(found),
Err(_) => Some(DEFAULT),
}
}
#[must_use]
pub fn orphan_after() -> Option<Duration> {
if let Ok(raw) = std::env::var("LJOS_MCP_ORPHAN_IDLE") {
if !raw.trim().is_empty() {
return parse_orphan(&raw);
}
}
let mut window = Some(ORPHAN_DEFAULT);
for path in crate::converge::config_paths(None) {
if let Some(set) = configured_orphan(&path) {
window = set;
}
}
window
}
fn configured_orphan(path: &Path) -> Option<Option<Duration>> {
let text = std::fs::read_to_string(path).ok()?;
let value = toml::from_str::<toml::Value>(&text).ok()?;
match value.get("mcp")?.get("orphan_idle")? {
toml::Value::Integer(secs) => Some(u64::try_from(*secs).ok().and_then(window)),
toml::Value::String(raw) => Some(parse_orphan(raw)),
toml::Value::Boolean(false) => Some(None),
_ => None,
}
}
fn parse_orphan(raw: &str) -> Option<Duration> {
let raw = raw.trim();
if matches!(
raw.to_ascii_lowercase().as_str(),
"0" | "off" | "false" | "no" | "never"
) {
return None;
}
if let Ok(secs) = raw.parse::<u64>() {
return window(secs);
}
match crate::reap::parse_idle(raw) {
Ok(found) if found.is_zero() => None,
Ok(found) => Some(found),
Err(_) => Some(ORPHAN_DEFAULT),
}
}
pub fn release_memory() {
crate::drop_listing_cache();
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
libc::malloc_trim(0);
}
}
pub struct CallActivity(());
impl Drop for CallActivity {
fn drop(&mut self) {
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
crate::bookkeep::touch_activity();
}
}
#[must_use]
pub fn call_started() -> CallActivity {
IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
crate::bookkeep::touch_activity();
CallActivity(())
}
#[must_use]
pub fn idle_now(now: u64, last: u64, limit: Duration) -> bool {
IN_FLIGHT.load(Ordering::SeqCst) == 0 && crate::bookkeep::idle_due(now, last, limit)
}
pub fn spawn_idle_drop() {
let Some(limit) = drop_after() else {
return;
};
crate::bookkeep::touch_activity();
let tick = (limit / 2).clamp(Duration::from_secs(1), Duration::from_secs(10));
let _ = std::thread::Builder::new()
.name("ljos-mcp-drop".into())
.stack_size(64 * 1024)
.spawn(move || {
let mut dropped_at = 0;
loop {
std::thread::sleep(tick);
let last = crate::bookkeep::last_activity();
if last != dropped_at && idle_now(crate::bookkeep::now_secs(), last, limit) {
release_memory();
dropped_at = last;
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_call_in_flight_is_not_idle() {
let limit = Duration::from_secs(1);
let old = crate::bookkeep::now_secs() - 100;
assert!(idle_now(crate::bookkeep::now_secs(), old, limit));
let held = call_started();
assert!(!idle_now(crate::bookkeep::now_secs() + 100, old, limit));
let also = call_started();
drop(held);
assert!(!idle_now(crate::bookkeep::now_secs() + 100, old, limit));
drop(also);
let last = crate::bookkeep::last_activity();
assert!(!idle_now(crate::bookkeep::now_secs(), last, limit));
assert!(idle_now(last + 100, last, limit));
}
#[test]
fn a_bare_number_is_seconds_and_off_keeps_everything() {
assert_eq!(parse("120"), Some(Duration::from_secs(120)));
assert_eq!(parse("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse("0"), None);
assert_eq!(parse("off"), None);
assert_eq!(parse("soon"), Some(DEFAULT));
}
#[test]
fn orphan_idle_is_seconds_and_off_leaves_the_server() {
assert_eq!(parse_orphan("30"), Some(Duration::from_secs(30)));
assert_eq!(parse_orphan("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse_orphan("0"), None);
assert_eq!(parse_orphan("off"), None);
assert_eq!(parse_orphan("soon"), Some(ORPHAN_DEFAULT));
}
#[test]
fn ljos_toml_sets_orphan_idle_under_mcp() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("ljos.toml");
std::fs::write(&file, "[mcp]\norphan_idle = 30\n").unwrap();
assert_eq!(
configured_orphan(&file),
Some(Some(Duration::from_secs(30)))
);
std::fs::write(&file, "[mcp]\norphan_idle = \"off\"\n").unwrap();
assert_eq!(configured_orphan(&file), Some(None));
std::fs::write(&file, "[mcp]\nidle_drop = 30\n").unwrap();
assert_eq!(configured_orphan(&file), None);
}
#[test]
fn ljos_toml_sets_the_window_under_mcp() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("ljos.toml");
std::fs::write(&file, "[mcp]\nidle_drop = 30\n").unwrap();
assert_eq!(configured(&file), Some(Some(Duration::from_secs(30))));
std::fs::write(&file, "[mcp]\nidle_drop = \"off\"\n").unwrap();
assert_eq!(configured(&file), Some(None));
std::fs::write(&file, "[git_hooks]\nskip = []\n").unwrap();
assert_eq!(configured(&file), None);
}
}