Skip to main content

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}