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.
53#[allow(unsafe_code)] // sole production unsafe: glibc malloc_trim (below)
54pub fn trim_heap() -> bool {
55 if std::env::var("OCSERVE_TRIM").is_ok_and(|v| v == "0") {
56 return false;
57 }
58 #[cfg(all(target_os = "linux", target_env = "gnu"))]
59 {
60 // Safety: `malloc_trim` is MT-Safe (glibc); it releases free heap
61 // pages and takes no pointers from us. Arena lock is held briefly.
62 unsafe {
63 libc::malloc_trim(0);
64 }
65 true
66 }
67 #[cfg(not(all(target_os = "linux", target_env = "gnu")))]
68 {
69 false
70 }
71}
72
73/// Watched-file state. `paths` set once at boot (≤6 files — bounded by the
74/// set `Runtime::load_for` reads); `observed` tracks last-seen
75/// (mtime,len)+content-hash so the watcher fires only on real CONTENT
76/// changes. The hash matters: their opencode rewrites `models.json`
77/// ~hourly with IDENTICAL bytes (fresh-TTL fetch) — mtime churn without a
78/// content change used to trigger a full reload (the proven warm-kill
79/// spike: 7 OOM kills on 2026-10-05, journal 17:01:33) for zero benefit.
80#[derive(Default)]
81pub struct WatchState {
82 pub paths: Vec<PathBuf>,
83 observed: HashMap<PathBuf, (Observed, u64)>,
84}
85
86fn stat_one(p: &std::path::Path) -> Observed {
87 match std::fs::metadata(p) {
88 Ok(md) => (md.modified().ok(), md.len()),
89 Err(_) => (None, 0),
90 }
91}
92
93/// Content hash for change detection (only computed when the fast
94/// (mtime,len) tuple already differs — never on the steady poll path).
95fn content_hash(p: &std::path::Path) -> u64 {
96 use std::hash::{Hash, Hasher};
97 let Ok(bytes) = std::fs::read(p) else {
98 return 0;
99 };
100 let mut h = std::collections::hash_map::DefaultHasher::new();
101 bytes.hash(&mut h);
102 h.finish()
103}
104
105/// Record current tuples for every watched path (call after each successful
106/// load AND once at boot so the first poll tick doesn't re-load fresh state).
107pub fn observe(st: &AppState) {
108 let paths = st.watch.read().paths.clone();
109 let mut w = st.watch.write();
110 w.observed = paths
111 .iter()
112 .map(|p| (p.clone(), (stat_one(p), content_hash(p))))
113 .collect();
114}
115
116/// True when any watched path's CONTENT differs from the last observed
117/// state. (mtime,len) is the fast path; when only mtime churned (same
118/// bytes — their hourly identical catalog rewrite) the tuple is advanced
119/// in place, `result="skipped"` is counted, and NO reload is requested.
120/// Empty watch set → false (no-op).
121pub fn changed(st: &AppState) -> bool {
122 let mut w = st.watch.write();
123 if w.paths.is_empty() {
124 return false;
125 }
126 let paths = w.paths.clone();
127 let mut any = false;
128 for p in &paths {
129 let tuple = stat_one(p);
130 match w.observed.get(p) {
131 Some((last, _)) if *last == tuple => {}
132 Some((_, hash)) => {
133 let h = content_hash(p);
134 if h == *hash {
135 w.observed.insert(p.clone(), (tuple, h));
136 tracing::info!(
137 "watch: {} mtime changed, content identical — reload skipped",
138 p.file_name()
139 .map(|n| n.to_string_lossy().into_owned())
140 .unwrap_or_default()
141 );
142 ocserve_metrics::labeled_counter(
143 "ocserve_config_reload_total",
144 "result=\"skipped\"",
145 1,
146 );
147 } else {
148 any = true;
149 }
150 }
151 None => any = true,
152 }
153 }
154 any
155}
156
157/// Rebuild payloads + LLM registry from disk and (re)apply both, then diff
158/// the MCP section against what was previously served.
159///
160/// Failure contract: load error → NOTHING is swapped (old config keeps
161/// serving), `result="error"` counted, Err returned for the caller to log;
162/// observed tuples are NOT advanced so the watcher retries.
163pub async fn reconcile(st: &Arc<AppState>) -> anyhow::Result<()> {
164 let _guard = st.reconcile_lock.lock().await;
165 let Some(reload) = st.reloader.read().clone() else {
166 tracing::warn!("config reload: reloader unset (tests/boot) — restart to apply");
167 return Ok(());
168 };
169 // A2 (2026-10-05 OOM postmortem): every reload is now attributable —
170 // which files changed, how long it took, and the RSS delta. The old
171 // silent reloads hid the catalog-reconcile kill (14:12:21 write → :27).
172 let reload_t0 = std::time::Instant::now();
173 let rss0 = ocserve_metrics::rss_bytes().unwrap_or(0);
174 // capture the previously SERVED mcp section for the diff (before swap)
175 let old_mcp = st
176 .payloads
177 .read()
178 .config
179 .get("mcp")
180 .cloned()
181 .unwrap_or_else(|| json!({}));
182 let loaded = reload();
183 let (payloads, registry) = match loaded {
184 Ok(pair) => pair,
185 Err(e) => {
186 ocserve_metrics::labeled_counter("ocserve_config_reload_total", "result=\"error\"", 1);
187 return Err(e.context("config reload"));
188 }
189 };
190 // capture the changed-file set BEFORE observe() advances the tuples
191 let changed_files: Vec<String> = {
192 let w = st.watch.read();
193 w.paths
194 .iter()
195 .filter(|p| w.observed.get(*p).map(|(t, _)| *t) != Some(stat_one(p)))
196 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
197 .collect()
198 };
199 // F5: re-serialize the wire cache in the SAME swap window as the values
200 // (build first from the borrow, then publish both — a reader never sees
201 // values without their matching bytes). Location is static per process
202 // (paths never change after boot).
203 let location = json!({
204 "directory": st.paths["directory"].as_str().unwrap_or("/"),
205 "project": {"id": "global", "directory": "/"},
206 });
207 let wire_map = crate::rebuild_wire(
208 &payloads,
209 st.wire_off.load(std::sync::atomic::Ordering::Relaxed),
210 &location,
211 );
212 *st.payloads.write() = payloads;
213 *st.wire.write() = wire_map;
214 *st.llm.write() = registry;
215 observe(st); // only after success (retry semantics above)
216 // hand the build pipeline's freed pages back (measured ~218 MB high-water)
217 trim_heap();
218 let rss1 = ocserve_metrics::rss_bytes().unwrap_or(0);
219 tracing::info!(
220 "config hot-reload ok in {}ms: [{}] rss {:.1} → {:.1} MB",
221 reload_t0.elapsed().as_millis(),
222 changed_files.join(", "),
223 rss0 as f64 / 1_048_576.0,
224 rss1 as f64 / 1_048_576.0,
225 );
226
227 let new_mcp = st
228 .payloads
229 .read()
230 .config
231 .get("mcp")
232 .cloned()
233 .unwrap_or_else(|| json!({}));
234 let mcp_changed = if old_mcp != new_mcp {
235 reconcile_mcp(st, &old_mcp, &new_mcp).await
236 } else {
237 false
238 };
239 ocserve_metrics::labeled_counter(
240 "ocserve_config_reload_total",
241 if mcp_changed {
242 "result=\"mcp\""
243 } else {
244 "result=\"ok\""
245 },
246 1,
247 );
248 Ok(())
249}
250
251/// Diff old/new `mcp` config sections against the live hub. Returns true
252/// when any server was connected/disconnected/reconnected.
253async fn reconcile_mcp(st: &Arc<AppState>, old: &Value, new: &Value) -> bool {
254 let to_map = |v: &Value| -> HashMap<String, ocserve_mcp::ServerCfg> {
255 ocserve_mcp::parse_config(v)
256 .into_iter()
257 .map(|c| (c.name.clone(), c))
258 .collect()
259 };
260 let old_cfgs = to_map(old);
261 let new_cfgs = to_map(new);
262 let Some(hub) = st.mcp.get().cloned() else {
263 tracing::info!("mcp config changed but no hub is set — skipped");
264 return false;
265 };
266
267 let mut any = false;
268 // removed or newly-disabled servers
269 for (name, old_cfg) in &old_cfgs {
270 let keep = new_cfgs.get(name).is_some_and(|c| c.enabled);
271 if old_cfg.enabled && !keep {
272 if let Err(e) = hub.disconnect(name).await {
273 tracing::debug!("mcp reconcile disconnect {name}: {e:#}"); // absent = fine
274 }
275 hub.drop_cfg(name);
276 any = true;
277 }
278 }
279 // added / changed / newly-enabled servers
280 for (name, new_cfg) in &new_cfgs {
281 if !new_cfg.enabled {
282 continue;
283 }
284 match old_cfgs.get(name) {
285 Some(old_cfg) if old_cfg == new_cfg && old_cfg.enabled => {}
286 Some(old_cfg) => {
287 // changed definition: connect() replaces the live client,
288 // but only AFTER the new cfg is retained (connect reads cfgs)
289 if !old_cfg.enabled {
290 // was disabled (client absent) — connect alone is enough
291 }
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 None => {
299 hub.upsert_cfg(new_cfg.clone());
300 if let Err(e) = hub.connect(name).await {
301 tracing::warn!("mcp reconcile connect {name}: {e:#}");
302 }
303 any = true;
304 }
305 }
306 }
307 if any {
308 // boot parity: enumerate + connect-time trust scan the new surface
309 // (connect/disconnect invalidate tools_cache, so this rebuilds it)
310 let listed = hub.tool_schemas().await;
311 tracing::info!("mcp reconcile: {} tool schemas rescanned", listed.len());
312 }
313 any
314}
315
316/// Watcher knobs. Tests construct these directly (env reads are confined to
317/// `from_env` so parallel tests never race `set_var`).
318#[derive(Debug, Clone, Copy)]
319pub struct WatchOpts {
320 pub enabled: bool,
321 pub interval_ms: u64,
322}
323
324impl Default for WatchOpts {
325 fn default() -> Self {
326 Self {
327 enabled: true,
328 interval_ms: 2000,
329 }
330 }
331}
332
333impl WatchOpts {
334 /// OCSERVE_CONFIG_WATCH=0 disables; OCSERVE_CONFIG_POLL_MS tunes
335 /// (default 2000, clamped ≥100ms — 0 must never busy-loop).
336 pub fn from_env() -> Self {
337 Self {
338 enabled: !std::env::var("OCSERVE_CONFIG_WATCH").is_ok_and(|v| v == "0"),
339 interval_ms: std::env::var("OCSERVE_CONFIG_POLL_MS")
340 .ok()
341 .and_then(|v| v.parse().ok())
342 .unwrap_or(2000)
343 .max(100),
344 }
345 }
346}
347
348/// Watcher task (spawned by serve): poll → changed → reconcile.
349/// Owns no state beyond the Arc; exits only on abort/process end.
350pub async fn watch_loop(st: Arc<AppState>, opts: WatchOpts) {
351 if !opts.enabled {
352 tracing::info!("config hot-reload disabled (OCSERVE_CONFIG_WATCH=0)");
353 return;
354 }
355 let interval_ms = opts.interval_ms.max(100); // clamp (defensive for direct callers)
356 {
357 let w = st.watch.read();
358 tracing::info!(
359 "config hot-reload: polling {} file(s) every {interval_ms}ms",
360 w.paths.len()
361 );
362 }
363 let mut tick = tokio::time::interval(Duration::from_millis(interval_ms));
364 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
365 loop {
366 tick.tick().await;
367 if changed(&st)
368 && let Err(e) = reconcile(&st).await
369 {
370 tracing::warn!("config hot-reload failed (will retry next tick): {e:#}");
371 }
372 }
373}