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.
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}