ocserve_http/watch.rs
1//! H1: config hot-reload for EXTERNAL edits (vim/TUI writing the shared
2//! files) + the single reconcile pipeline that PATCH/auth paths also use.
3//!
4//! Divergence D-CONFIG-1 (TESTING §1.6): upstream has NO file watcher —
5//! config is `Effect.cachedInvalidateWithTTL(Duration.infinity)` invalidated
6//! only on its own write (v1 config/config.ts:295,302,678), so an external
7//! edit does not apply until restart. ocserve applies it within one poll
8//! interval. Shapes/routes unchanged; replay unaffected (file state is fixed
9//! during a recording).
10//!
11//! Design (antagonism decisions):
12//! - **Poll, not notify/inotify**: editors save via tmp+rename (inotify on
13//! the file loses the watch; dir-watch needs debounce + a dependency);
14//! stat-ing ≤6 known files every interval is bounded, rename-proof,
15//! zero-dependency. Interval clamped ≥100ms; `OCSERVE_CONFIG_WATCH=0`
16//! disables; `OCSERVE_CONFIG_POLL_MS` tunes (default 2000).
17//! - **Fail-safe at runtime, fail-fast at boot** (SRE §1): a broken config
18//! keeps the OLD payloads/registry serving + `result="error"` metric and
19//! self-heals on the next successful load; boot still refuses to start.
20//! - **Serialized**: PATCH/auth paths and the watcher task share one
21//! `reconcile_lock` (tokio Mutex — held across the reload + MCP awaits,
22//! never a parking_lot guard).
23//! - **Observed tuples recorded only after a successful load** → failed
24//! loads retry every tick until the file is valid (no lost updates, no
25//! write side effects → no feedback loop).
26//! - MCP section diffed against the previously SERVED config: added →
27//! upsert_cfg + connect; changed → connect (replaces the client) with the
28//! new cfg; removed/disabled → disconnect + drop_cfg; any change rescans
29//! tool schemas (boot parity: connect-time trust scan).
30
31use crate::AppState;
32use serde_json::{Value, json};
33use std::collections::HashMap;
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::time::{Duration, SystemTime};
37
38/// (mtime, len) of one watched file; (None, 0) = missing at stat time.
39type Observed = (Option<SystemTime>, u64);
40
41/// Return freed-but-retained glibc heap pages to the OS (`malloc_trim(0)`).
42///
43/// Measured 2026-10-05 (`bench/profiling/OOM-RELOAD-REPORT.md` §Tier-0): the
44/// full-catalog build pipeline leaves ~218 MB of FREED pages in the arena —
45/// a tiny-catalog control settles at 19 MB RSS while the full 5.3 MB catalog
46/// settles at 237 MB, and an LD_PRELOAD trim shim collapses that to 91 MB
47/// with reload deltas +3/0/−1 MB. `MALLOC_ARENA_MAX=1` (unit) removed the
48/// per-reload *step*; trim removes the high-water *retention*.
49///
50/// Called at boot, after every reconcile swap, and on a periodic task
51/// (`OCSERVE_TRIM_SECS`, default 300 s, 0 disables). `OCSERVE_TRIM=0` turns
52/// every call into a no-op (lab A/B knob). No-op on non-glibc targets.
53pub fn trim_heap() -> bool {
54 if std::env::var("OCSERVE_TRIM").is_ok_and(|v| v == "0") {
55 return false;
56 }
57 #[cfg(all(target_os = "linux", target_env = "gnu"))]
58 {
59 // Safety: `malloc_trim` is MT-Safe (glibc); it releases free heap
60 // pages and takes no pointers from us. Arena lock is held briefly.
61 unsafe {
62 libc::malloc_trim(0);
63 }
64 true
65 }
66 #[cfg(not(all(target_os = "linux", target_env = "gnu")))]
67 {
68 false
69 }
70}
71
72/// Watched-file state. `paths` set once at boot (≤6 files — bounded by the
73/// set `Runtime::load_for` reads); `observed` tracks last-seen
74/// (mtime,len)+content-hash so the watcher fires only on real CONTENT
75/// changes. The hash matters: their opencode rewrites `models.json`
76/// ~hourly with IDENTICAL bytes (fresh-TTL fetch) — mtime churn without a
77/// content change used to trigger a full reload (the proven warm-kill
78/// spike: 7 OOM kills on 2026-10-05, journal 17:01:33) for zero benefit.
79#[derive(Default)]
80pub struct WatchState {
81 pub paths: Vec<PathBuf>,
82 observed: HashMap<PathBuf, (Observed, u64)>,
83}
84
85fn stat_one(p: &std::path::Path) -> Observed {
86 match std::fs::metadata(p) {
87 Ok(md) => (md.modified().ok(), md.len()),
88 Err(_) => (None, 0),
89 }
90}
91
92/// Content hash for change detection (only computed when the fast
93/// (mtime,len) tuple already differs — never on the steady poll path).
94fn content_hash(p: &std::path::Path) -> u64 {
95 use std::hash::{Hash, Hasher};
96 let Ok(bytes) = std::fs::read(p) else {
97 return 0;
98 };
99 let mut h = std::collections::hash_map::DefaultHasher::new();
100 bytes.hash(&mut h);
101 h.finish()
102}
103
104/// Record current tuples for every watched path (call after each successful
105/// load AND once at boot so the first poll tick doesn't re-load fresh state).
106pub fn observe(st: &AppState) {
107 let paths = st.watch.read().paths.clone();
108 let mut w = st.watch.write();
109 w.observed = paths
110 .iter()
111 .map(|p| (p.clone(), (stat_one(p), content_hash(p))))
112 .collect();
113}
114
115/// True when any watched path's CONTENT differs from the last observed
116/// state. (mtime,len) is the fast path; when only mtime churned (same
117/// bytes — their hourly identical catalog rewrite) the tuple is advanced
118/// in place, `result="skipped"` is counted, and NO reload is requested.
119/// Empty watch set → false (no-op).
120pub fn changed(st: &AppState) -> bool {
121 let mut w = st.watch.write();
122 if w.paths.is_empty() {
123 return false;
124 }
125 let paths = w.paths.clone();
126 let mut any = false;
127 for p in &paths {
128 let tuple = stat_one(p);
129 match w.observed.get(p) {
130 Some((last, _)) if *last == tuple => {}
131 Some((_, hash)) => {
132 let h = content_hash(p);
133 if h == *hash {
134 w.observed.insert(p.clone(), (tuple, h));
135 tracing::info!(
136 "watch: {} mtime changed, content identical — reload skipped",
137 p.file_name()
138 .map(|n| n.to_string_lossy().into_owned())
139 .unwrap_or_default()
140 );
141 ocserve_metrics::labeled_counter(
142 "ocserve_config_reload_total",
143 "result=\"skipped\"",
144 1,
145 );
146 } else {
147 any = true;
148 }
149 }
150 None => any = true,
151 }
152 }
153 any
154}
155
156/// Rebuild payloads + LLM registry from disk and (re)apply both, then diff
157/// the MCP section against what was previously served.
158///
159/// Failure contract: load error → NOTHING is swapped (old config keeps
160/// serving), `result="error"` counted, Err returned for the caller to log;
161/// observed tuples are NOT advanced so the watcher retries.
162pub async fn reconcile(st: &Arc<AppState>) -> anyhow::Result<()> {
163 let _guard = st.reconcile_lock.lock().await;
164 let Some(reload) = st.reloader.read().clone() else {
165 tracing::warn!("config reload: reloader unset (tests/boot) — restart to apply");
166 return Ok(());
167 };
168 // A2 (2026-10-05 OOM postmortem): every reload is now attributable —
169 // which files changed, how long it took, and the RSS delta. The old
170 // silent reloads hid the catalog-reconcile kill (14:12:21 write → :27).
171 let reload_t0 = std::time::Instant::now();
172 let rss0 = ocserve_metrics::rss_bytes().unwrap_or(0);
173 // capture the previously SERVED mcp section for the diff (before swap)
174 let old_mcp = st
175 .payloads
176 .read()
177 .config
178 .get("mcp")
179 .cloned()
180 .unwrap_or_else(|| json!({}));
181 let loaded = reload();
182 let (payloads, registry) = match loaded {
183 Ok(pair) => pair,
184 Err(e) => {
185 ocserve_metrics::labeled_counter("ocserve_config_reload_total", "result=\"error\"", 1);
186 return Err(e.context("config reload"));
187 }
188 };
189 // capture the changed-file set BEFORE observe() advances the tuples
190 let changed_files: Vec<String> = {
191 let w = st.watch.read();
192 w.paths
193 .iter()
194 .filter(|p| w.observed.get(*p).map(|(t, _)| *t) != Some(stat_one(p)))
195 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
196 .collect()
197 };
198 // F5: re-serialize the wire cache in the SAME swap window as the values
199 // (build first from the borrow, then publish both — a reader never sees
200 // values without their matching bytes).
201 let wire_map = crate::rebuild_wire(
202 &payloads,
203 st.wire_off.load(std::sync::atomic::Ordering::Relaxed),
204 );
205 *st.payloads.write() = payloads;
206 *st.wire.write() = wire_map;
207 *st.llm.write() = registry;
208 observe(st); // only after success (retry semantics above)
209 // hand the build pipeline's freed pages back (measured ~218 MB high-water)
210 trim_heap();
211 let rss1 = ocserve_metrics::rss_bytes().unwrap_or(0);
212 tracing::info!(
213 "config hot-reload ok in {}ms: [{}] rss {:.1} → {:.1} MB",
214 reload_t0.elapsed().as_millis(),
215 changed_files.join(", "),
216 rss0 as f64 / 1_048_576.0,
217 rss1 as f64 / 1_048_576.0,
218 );
219
220 let new_mcp = st
221 .payloads
222 .read()
223 .config
224 .get("mcp")
225 .cloned()
226 .unwrap_or_else(|| json!({}));
227 let mcp_changed = if old_mcp != new_mcp {
228 reconcile_mcp(st, &old_mcp, &new_mcp).await
229 } else {
230 false
231 };
232 ocserve_metrics::labeled_counter(
233 "ocserve_config_reload_total",
234 if mcp_changed {
235 "result=\"mcp\""
236 } else {
237 "result=\"ok\""
238 },
239 1,
240 );
241 Ok(())
242}
243
244/// Diff old/new `mcp` config sections against the live hub. Returns true
245/// when any server was connected/disconnected/reconnected.
246async fn reconcile_mcp(st: &Arc<AppState>, old: &Value, new: &Value) -> bool {
247 let to_map = |v: &Value| -> HashMap<String, ocserve_mcp::ServerCfg> {
248 ocserve_mcp::parse_config(v)
249 .into_iter()
250 .map(|c| (c.name.clone(), c))
251 .collect()
252 };
253 let old_cfgs = to_map(old);
254 let new_cfgs = to_map(new);
255 let Some(hub) = st.mcp.get().cloned() else {
256 tracing::info!("mcp config changed but no hub is set — skipped");
257 return false;
258 };
259
260 let mut any = false;
261 // removed or newly-disabled servers
262 for (name, old_cfg) in &old_cfgs {
263 let keep = new_cfgs.get(name).is_some_and(|c| c.enabled);
264 if old_cfg.enabled && !keep {
265 if let Err(e) = hub.disconnect(name).await {
266 tracing::debug!("mcp reconcile disconnect {name}: {e:#}"); // absent = fine
267 }
268 hub.drop_cfg(name);
269 any = true;
270 }
271 }
272 // added / changed / newly-enabled servers
273 for (name, new_cfg) in &new_cfgs {
274 if !new_cfg.enabled {
275 continue;
276 }
277 match old_cfgs.get(name) {
278 Some(old_cfg) if old_cfg == new_cfg && old_cfg.enabled => {}
279 Some(old_cfg) => {
280 // changed definition: connect() replaces the live client,
281 // but only AFTER the new cfg is retained (connect reads cfgs)
282 if !old_cfg.enabled {
283 // was disabled (client absent) — connect alone is enough
284 }
285 hub.upsert_cfg(new_cfg.clone());
286 if let Err(e) = hub.connect(name).await {
287 tracing::warn!("mcp reconcile connect {name}: {e:#}");
288 }
289 any = true;
290 }
291 None => {
292 hub.upsert_cfg(new_cfg.clone());
293 if let Err(e) = hub.connect(name).await {
294 tracing::warn!("mcp reconcile connect {name}: {e:#}");
295 }
296 any = true;
297 }
298 }
299 }
300 if any {
301 // boot parity: enumerate + connect-time trust scan the new surface
302 // (connect/disconnect invalidate tools_cache, so this rebuilds it)
303 let listed = hub.tool_schemas().await;
304 tracing::info!("mcp reconcile: {} tool schemas rescanned", listed.len());
305 }
306 any
307}
308
309/// Watcher knobs. Tests construct these directly (env reads are confined to
310/// `from_env` so parallel tests never race `set_var`).
311#[derive(Debug, Clone, Copy)]
312pub struct WatchOpts {
313 pub enabled: bool,
314 pub interval_ms: u64,
315}
316
317impl Default for WatchOpts {
318 fn default() -> Self {
319 Self {
320 enabled: true,
321 interval_ms: 2000,
322 }
323 }
324}
325
326impl WatchOpts {
327 /// OCSERVE_CONFIG_WATCH=0 disables; OCSERVE_CONFIG_POLL_MS tunes
328 /// (default 2000, clamped ≥100ms — 0 must never busy-loop).
329 pub fn from_env() -> Self {
330 Self {
331 enabled: !std::env::var("OCSERVE_CONFIG_WATCH").is_ok_and(|v| v == "0"),
332 interval_ms: std::env::var("OCSERVE_CONFIG_POLL_MS")
333 .ok()
334 .and_then(|v| v.parse().ok())
335 .unwrap_or(2000)
336 .max(100),
337 }
338 }
339}
340
341/// Watcher task (spawned by serve): poll → changed → reconcile.
342/// Owns no state beyond the Arc; exits only on abort/process end.
343pub async fn watch_loop(st: Arc<AppState>, opts: WatchOpts) {
344 if !opts.enabled {
345 tracing::info!("config hot-reload disabled (OCSERVE_CONFIG_WATCH=0)");
346 return;
347 }
348 let interval_ms = opts.interval_ms.max(100); // clamp (defensive for direct callers)
349 {
350 let w = st.watch.read();
351 tracing::info!(
352 "config hot-reload: polling {} file(s) every {interval_ms}ms",
353 w.paths.len()
354 );
355 }
356 let mut tick = tokio::time::interval(Duration::from_millis(interval_ms));
357 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
358 loop {
359 tick.tick().await;
360 if changed(&st)
361 && let Err(e) = reconcile(&st).await
362 {
363 tracing::warn!("config hot-reload failed (will retry next tick): {e:#}");
364 }
365 }
366}