use crate::AppState;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
type Observed = (Option<SystemTime>, u64);
#[allow(unsafe_code)] pub fn trim_heap() -> bool {
if std::env::var("OCSERVE_TRIM").is_ok_and(|v| v == "0") {
return false;
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
unsafe {
libc::malloc_trim(0);
}
true
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
{
false
}
}
#[derive(Default)]
pub struct WatchState {
pub paths: Vec<PathBuf>,
observed: HashMap<PathBuf, (Observed, u64)>,
}
fn stat_one(p: &std::path::Path) -> Observed {
match std::fs::metadata(p) {
Ok(md) => (md.modified().ok(), md.len()),
Err(_) => (None, 0),
}
}
fn content_hash(p: &std::path::Path) -> u64 {
use std::hash::{Hash, Hasher};
let Ok(bytes) = std::fs::read(p) else {
return 0;
};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
pub fn observe(st: &AppState) {
let paths = st.watch.read().paths.clone();
let mut w = st.watch.write();
w.observed = paths
.iter()
.map(|p| (p.clone(), (stat_one(p), content_hash(p))))
.collect();
}
pub fn changed(st: &AppState) -> bool {
let mut w = st.watch.write();
if w.paths.is_empty() {
return false;
}
let paths = w.paths.clone();
let mut any = false;
for p in &paths {
let tuple = stat_one(p);
match w.observed.get(p) {
Some((last, _)) if *last == tuple => {}
Some((_, hash)) => {
let h = content_hash(p);
if h == *hash {
w.observed.insert(p.clone(), (tuple, h));
tracing::info!(
"watch: {} mtime changed, content identical — reload skipped",
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
);
ocserve_metrics::labeled_counter(
"ocserve_config_reload_total",
"result=\"skipped\"",
1,
);
} else {
any = true;
}
}
None => any = true,
}
}
any
}
pub async fn reconcile(st: &Arc<AppState>) -> anyhow::Result<()> {
let _guard = st.reconcile_lock.lock().await;
let Some(reload) = st.reloader.read().clone() else {
tracing::warn!("config reload: reloader unset (tests/boot) — restart to apply");
return Ok(());
};
let reload_t0 = std::time::Instant::now();
let rss0 = ocserve_metrics::rss_bytes().unwrap_or(0);
let old_mcp = st
.payloads
.read()
.config
.get("mcp")
.cloned()
.unwrap_or_else(|| json!({}));
let loaded = reload();
let (payloads, registry) = match loaded {
Ok(pair) => pair,
Err(e) => {
ocserve_metrics::labeled_counter("ocserve_config_reload_total", "result=\"error\"", 1);
return Err(e.context("config reload"));
}
};
let changed_files: Vec<String> = {
let w = st.watch.read();
w.paths
.iter()
.filter(|p| w.observed.get(*p).map(|(t, _)| *t) != Some(stat_one(p)))
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect()
};
let location = json!({
"directory": st.paths["directory"].as_str().unwrap_or("/"),
"project": {"id": "global", "directory": "/"},
});
let wire_map = crate::rebuild_wire(
&payloads,
st.wire_off.load(std::sync::atomic::Ordering::Relaxed),
&location,
);
*st.payloads.write() = payloads;
*st.wire.write() = wire_map;
*st.llm.write() = registry;
observe(st); trim_heap();
let rss1 = ocserve_metrics::rss_bytes().unwrap_or(0);
tracing::info!(
"config hot-reload ok in {}ms: [{}] rss {:.1} → {:.1} MB",
reload_t0.elapsed().as_millis(),
changed_files.join(", "),
rss0 as f64 / 1_048_576.0,
rss1 as f64 / 1_048_576.0,
);
let new_mcp = st
.payloads
.read()
.config
.get("mcp")
.cloned()
.unwrap_or_else(|| json!({}));
let mcp_changed = if old_mcp != new_mcp {
reconcile_mcp(st, &old_mcp, &new_mcp).await
} else {
false
};
ocserve_metrics::labeled_counter(
"ocserve_config_reload_total",
if mcp_changed {
"result=\"mcp\""
} else {
"result=\"ok\""
},
1,
);
Ok(())
}
async fn reconcile_mcp(st: &Arc<AppState>, old: &Value, new: &Value) -> bool {
let to_map = |v: &Value| -> HashMap<String, ocserve_mcp::ServerCfg> {
ocserve_mcp::parse_config(v)
.into_iter()
.map(|c| (c.name.clone(), c))
.collect()
};
let old_cfgs = to_map(old);
let new_cfgs = to_map(new);
let Some(hub) = st.mcp.get().cloned() else {
tracing::info!("mcp config changed but no hub is set — skipped");
return false;
};
let mut any = false;
for (name, old_cfg) in &old_cfgs {
let keep = new_cfgs.get(name).is_some_and(|c| c.enabled);
if old_cfg.enabled && !keep {
if let Err(e) = hub.disconnect(name).await {
tracing::debug!("mcp reconcile disconnect {name}: {e:#}"); }
hub.drop_cfg(name);
any = true;
}
}
for (name, new_cfg) in &new_cfgs {
if !new_cfg.enabled {
continue;
}
match old_cfgs.get(name) {
Some(old_cfg) if old_cfg == new_cfg && old_cfg.enabled => {}
Some(old_cfg) => {
if !old_cfg.enabled {
}
hub.upsert_cfg(new_cfg.clone());
if let Err(e) = hub.connect(name).await {
tracing::warn!("mcp reconcile connect {name}: {e:#}");
}
any = true;
}
None => {
hub.upsert_cfg(new_cfg.clone());
if let Err(e) = hub.connect(name).await {
tracing::warn!("mcp reconcile connect {name}: {e:#}");
}
any = true;
}
}
}
if any {
let listed = hub.tool_schemas().await;
tracing::info!("mcp reconcile: {} tool schemas rescanned", listed.len());
}
any
}
#[derive(Debug, Clone, Copy)]
pub struct WatchOpts {
pub enabled: bool,
pub interval_ms: u64,
}
impl Default for WatchOpts {
fn default() -> Self {
Self {
enabled: true,
interval_ms: 2000,
}
}
}
impl WatchOpts {
pub fn from_env() -> Self {
Self {
enabled: !std::env::var("OCSERVE_CONFIG_WATCH").is_ok_and(|v| v == "0"),
interval_ms: std::env::var("OCSERVE_CONFIG_POLL_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2000)
.max(100),
}
}
}
pub async fn watch_loop(st: Arc<AppState>, opts: WatchOpts) {
if !opts.enabled {
tracing::info!("config hot-reload disabled (OCSERVE_CONFIG_WATCH=0)");
return;
}
let interval_ms = opts.interval_ms.max(100); {
let w = st.watch.read();
tracing::info!(
"config hot-reload: polling {} file(s) every {interval_ms}ms",
w.paths.len()
);
}
let mut tick = tokio::time::interval(Duration::from_millis(interval_ms));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
if changed(&st)
&& let Err(e) = reconcile(&st).await
{
tracing::warn!("config hot-reload failed (will retry next tick): {e:#}");
}
}
}