#![cfg(all(unix, feature = "config-watch"))]
use crate::obs::log::Logger;
use serde_json::json;
use std::path::Path;
#[cfg(any(target_os = "linux", target_os = "android"))]
const KUBE_DATA_LINK: &str = "..data";
const WATCH_MASK: u32 = libc::IN_CLOSE_WRITE
| libc::IN_MOVED_TO
| libc::IN_CREATE
| libc::IN_MOVED_FROM
| libc::IN_DELETE;
fn decide(events: &[(u32, Option<String>)], basename: &str) -> (bool, bool) {
let (mut fire, mut rearm) = (false, false);
for (mask, name) in events {
if mask & libc::IN_IGNORED != 0 {
rearm = true;
}
let named = name.as_deref();
if named == Some(basename) || named == Some(KUBE_DATA_LINK) {
fire = true;
}
}
(fire, rearm)
}
type Event = (u32, Option<String>);
pub fn parse_events(buf: &[u8]) -> Vec<Event> {
const HEADER: usize = 16; let mut out = Vec::new();
let mut off = 0usize;
while off + HEADER <= buf.len() {
let mask = u32::from_ne_bytes([buf[off + 4], buf[off + 5], buf[off + 6], buf[off + 7]]);
let len = u32::from_ne_bytes([buf[off + 12], buf[off + 13], buf[off + 14], buf[off + 15]])
as usize;
let name_start = off + HEADER;
let name_end = name_start + len;
if name_end > buf.len() {
break; }
let name = if len == 0 {
None
} else {
let raw = &buf[name_start..name_end];
let nul = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
Some(String::from_utf8_lossy(&raw[..nul]).into_owned())
};
out.push((mask, name));
off = name_end;
}
out
}
pub fn spawn_config_watcher(config_path: &Path, log: &Logger) {
let path = config_path.to_path_buf();
let log = log.clone();
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| Path::new(".").to_path_buf());
let basename = match path.file_name().map(|n| n.to_string_lossy().into_owned()) {
Some(b) => b,
None => {
log.warn(
"config.watch.error",
json!({"err": "config path has no file name", "path": path.display().to_string()}),
);
return;
}
};
log.info(
"config.watch.armed",
json!({"path": path.display().to_string(), "dir": parent.display().to_string()}),
);
let thread_log = log.clone();
if let Err(e) = std::thread::Builder::new()
.name("config-watch".into())
.spawn(move || watch_loop(&parent, &basename, &thread_log))
{
log.warn("config.watch.error", json!({"err": e.to_string()}));
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn watch_loop(dir: &Path, basename: &str, log: &Logger) {
use std::os::unix::ffi::OsStrExt;
let ifd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
if ifd < 0 {
log.warn(
"config.watch.error",
json!({"err": "inotify_init1 failed", "errno": errno()}),
);
return;
}
struct Fd(libc::c_int);
impl Drop for Fd {
fn drop(&mut self) {
unsafe { libc::close(self.0) };
}
}
let _guard = Fd(ifd);
let cdir = std::ffi::CString::new(dir.as_os_str().as_bytes()).ok();
let add_watch = || -> libc::c_int {
match &cdir {
Some(c) => unsafe { libc::inotify_add_watch(ifd, c.as_ptr(), WATCH_MASK) },
None => -1,
}
};
if cdir.is_none() {
log.warn(
"config.watch.error",
json!({"err": "watched dir path has an interior NUL"}),
);
return;
}
if add_watch() < 0 {
log.warn(
"config.watch.error",
json!({"err": "inotify_add_watch failed", "dir": dir.display().to_string(), "errno": errno()}),
);
return;
}
let mut buf = [0u8; 4096];
loop {
let n = unsafe { libc::read(ifd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n < 0 {
let e = errno();
if e == libc::EINTR {
continue;
}
log.warn(
"config.watch.error",
json!({"err": "inotify read failed", "errno": e}),
);
return; }
if n == 0 {
continue;
}
let (mut fire, rearm) = decide(&parse_events(&buf[..n as usize]), basename);
if rearm {
if add_watch() < 0 {
log.warn(
"config.watch.error",
json!({"err": "inotify re-arm failed", "errno": errno()}),
);
}
fire = true;
}
if fire {
if crate::signals::reload_requested() {
continue;
}
log.info("config.watch.fired", json!({"file": basename}));
crate::signals::request_reload_from_watch();
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
fn watch_loop(_dir: &Path, _basename: &str, log: &Logger) {
log.warn(
"config.watch.error",
json!({"err": "inotify file-watch is Linux-only; use SIGHUP on this platform"}),
);
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn errno() -> i32 {
std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn push_record(out: &mut Vec<u8>, wd: i32, mask: u32, cookie: u32, name: Option<&str>) {
out.extend_from_slice(&wd.to_ne_bytes());
out.extend_from_slice(&mask.to_ne_bytes());
out.extend_from_slice(&cookie.to_ne_bytes());
match name {
None => out.extend_from_slice(&0u32.to_ne_bytes()), Some(n) => {
let mut bytes = n.as_bytes().to_vec();
bytes.push(0);
while bytes.len() % 4 != 0 {
bytes.push(0);
}
out.extend_from_slice(&(bytes.len() as u32).to_ne_bytes());
out.extend_from_slice(&bytes);
}
}
}
#[test]
fn parses_a_single_named_record() {
let mut buf = Vec::new();
push_record(
&mut buf,
1,
0x0000_0080,
0,
Some("config.json"),
);
let ev = parse_events(&buf);
assert_eq!(ev.len(), 1);
assert_eq!(ev[0].0, 0x0000_0080);
assert_eq!(ev[0].1.as_deref(), Some("config.json"));
}
#[test]
fn parses_multiple_records_in_one_read() {
let mut buf = Vec::new();
push_record(
&mut buf,
1,
0x0000_0100,
0,
Some("..data_tmp"),
);
push_record(
&mut buf,
1,
0x0000_0080,
7,
Some("..data"),
);
push_record(
&mut buf,
1,
0x0000_0200,
0,
Some("..2026_08_30_18_00_00.111"),
);
let ev = parse_events(&buf);
assert_eq!(ev.len(), 3);
assert_eq!(ev[0].1.as_deref(), Some("..data_tmp"));
assert_eq!(ev[1].1.as_deref(), Some("..data"));
assert_eq!(ev[2].1.as_deref(), Some("..2026_08_30_18_00_00.111"));
}
#[test]
fn a_kubelet_configmap_swap_fires_a_reload() {
let kubelet = [
(
0x0000_4000u32,
Some("..2026_08_30_18_08_34.157".to_string()),
), (0x0000_0100, Some("..data_tmp".to_string())), (0x0000_0040, Some("..data_tmp".to_string())), (0x0000_0080, Some("..data".to_string())), (0x0000_0200, Some("..2026_08_30_18_00_00.111".to_string())), ];
let (fire, rearm) = decide(&kubelet, "agentd.json");
assert!(
fire,
"the ..data rename IS the new revision being published"
);
assert!(!rearm, "the mount dir is never removed, so nothing re-arms");
}
#[test]
fn a_plain_write_to_the_watched_file_fires() {
let edit = [(0x0000_0008u32, Some("agentd.json".to_string()))];
assert_eq!(decide(&edit, "agentd.json"), (true, false));
}
#[test]
fn unrelated_names_in_the_watched_dir_do_not_fire() {
let noise = [
(0x0000_0100u32, Some("services.json.swp".to_string())),
(0x0000_0008, Some("other.json".to_string())),
(0x0000_0200, Some("..data-old".to_string())),
];
assert_eq!(decide(&noise, "agentd.json"), (false, false));
}
#[test]
fn in_ignored_requests_a_rearm() {
let dropped = [(0x0000_8000u32, None)]; assert_eq!(decide(&dropped, "agentd.json"), (false, true));
}
#[test]
fn parses_a_nameless_record() {
let mut buf = Vec::new();
push_record(&mut buf, 1, 0x0000_8000 , 0, None);
let ev = parse_events(&buf);
assert_eq!(ev.len(), 1);
assert_eq!(ev[0].0, 0x0000_8000);
assert!(ev[0].1.is_none());
}
#[test]
fn trims_at_the_first_nul_in_a_padded_name() {
let mut buf = Vec::new();
push_record(&mut buf, 1, 0x0000_0080, 0, Some("a")); let ev = parse_events(&buf);
assert_eq!(ev.len(), 1);
assert_eq!(ev[0].1.as_deref(), Some("a"));
}
#[test]
fn skips_a_truncated_trailing_record() {
let mut buf = Vec::new();
push_record(&mut buf, 1, 0x0000_0080, 0, Some("config.json"));
buf.extend_from_slice(&[0u8, 1, 2]); let ev = parse_events(&buf);
assert_eq!(ev.len(), 1);
assert_eq!(ev[0].1.as_deref(), Some("config.json"));
}
#[test]
fn empty_buffer_yields_no_events() {
assert!(parse_events(&[]).is_empty());
}
#[test]
fn skips_a_record_whose_len_overruns_the_buffer() {
let mut buf = Vec::new();
buf.extend_from_slice(&1i32.to_ne_bytes()); buf.extend_from_slice(&0x80u32.to_ne_bytes()); buf.extend_from_slice(&0u32.to_ne_bytes()); buf.extend_from_slice(&64u32.to_ne_bytes()); let ev = parse_events(&buf);
assert!(ev.is_empty());
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn e2e_rename_fires_a_reload() {
use crate::obs::log::{Comp, Level, LogCtx, Logger};
use std::io::Write as _;
use std::time::{Duration, Instant};
let _g = crate::signals::test_guard();
assert!(!crate::signals::reload_requested(), "clean slate");
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.json");
std::fs::write(&cfg, b"{}\n").unwrap();
let log = Logger::new(
LogCtx {
run_id: "r".into(),
agent_id: "0".into(),
agent_path: "0".into(),
comp: Comp::Supervisor,
pid: std::process::id(),
trace_id: None,
},
Level::Error,
);
spawn_config_watcher(&cfg, &log);
std::thread::sleep(Duration::from_millis(50));
let deadline = Instant::now() + Duration::from_secs(5);
let mut fired = false;
let mut i = 0u32;
while Instant::now() < deadline {
let tmp = dir.path().join(format!(".tmp-{i}"));
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(format!("{{\"max_tokens\": {}}}\n", 1000 + i).as_bytes())
.unwrap();
f.flush().unwrap();
std::fs::rename(&tmp, &cfg).unwrap();
i += 1;
for _ in 0..10 {
if crate::signals::reload_requested() {
fired = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
if fired {
break;
}
}
assert!(
fired,
"the file-watch trigger should set the RELOAD latch within 5s of a rename"
);
assert!(
crate::signals::take_reload_was_watch(),
"the reload should be attributed to the watch trigger"
);
}
}