ソースを参照

THE leak: recipe watcher watched cwd ('.') — in the add-on container cwd=/ and notify followed /sys symlink loops, walking the container filesystem forever (~4.6 GB/h churn → allocator ratchet → daily Pi OOM). Watch only real recipe workspaces (watchable_recipe_dirs). Also: one shared HA client (a reqwest+rustls cert-store build per probe tick was another ~2 GB/h churn), shutdown now closes SSE connections on signal

Alexander Korolev 1 週間 前
親
コミット
e87b6706bf
6 ファイル変更、70 行追加、17 行削除
  1. BIN
      sm121-dhat-aarch64
  2. 34 0
      src/config.rs
  3. 21 4
      src/home_assistant.rs
  4. 2 4
      src/report/serve.rs
  5. 11 5
      src/web/mod.rs
  6. 2 4
      src/web/probe.rs

BIN
sm121-dhat-aarch64


+ 34 - 0
src/config.rs

@@ -325,6 +325,40 @@ pub fn recipe_dirs(config: &Config) -> Vec<std::path::PathBuf> {
     v
 }
 
+/// Dirs worth a RECURSIVE inotify watch: only real recipe workspaces.
+///
+/// `recipe_dirs` always includes the cwd itself (`.`) so flat-layout
+/// recipes (`<cwd>/<name>/recipe.toml`) are found — but watching `.`
+/// recursively is dangerous: in the HA add-on container the cwd is `/`, and
+/// notify's walk followed `/sys` symlink loops (subsystem → device →
+/// subsystem → …), re-adding watches forever — ~4.6 GB/h of allocation
+/// churn that the allocator ratcheted into the Pi's daily OOM
+/// (2026-10-13/14, dmesg `oom-kill … task=sm121`).
+///
+/// Rules: watch `<dir>/recipes` when it exists (covers `reports/` and every
+/// `recipe.toml` beneath it); else watch a dir that directly holds
+/// `<name>/recipe.toml` recipes; never a filesystem root.
+pub fn watchable_recipe_dirs(config: &Config) -> Vec<std::path::PathBuf> {
+    let mut out: Vec<std::path::PathBuf> = Vec::new();
+    for d in recipe_dirs(config) {
+        if d.as_os_str() == "/" || d.parent().is_none() {
+            continue;
+        }
+        if d.join("recipes").is_dir() {
+            out.push(d.join("recipes"));
+        } else if d.is_dir()
+            && std::fs::read_dir(&d)
+                .ok()
+                .is_some_and(|rd| rd.flatten().any(|e| e.path().join("recipe.toml").is_file()))
+        {
+            out.push(d);
+        }
+    }
+    out.sort();
+    out.dedup();
+    out
+}
+
 /// Locate the recipe file (same search order as [`load_recipe`]) so reports
 /// land next to it.
 pub(crate) fn recipe_file(recipe_name: &str, sources: &[String]) -> Option<PathBuf> {

+ 21 - 4
src/home_assistant.rs

@@ -7,11 +7,31 @@
 
 use anyhow::{Context, Result, anyhow};
 use serde_json::json;
+use std::sync::OnceLock;
 use std::time::Duration;
 use tracing::{debug, info, warn};
 
 use crate::config::{Cluster, Config, HomeAssistantConfig};
 
+/// Process-wide shared HA client, built once. The fleet probe rebuilds HA
+/// state every 30 s across several clusters — a fresh `Client` per call
+/// re-parses the system CA bundle (rustls) and allocates ~1.7 MB of TLS
+/// setup each time, ~2 GB/h of churn in the long-running dashboard.
+static SHARED_CLIENT: OnceLock<Option<Client>> = OnceLock::new();
+
+/// The shared client for long-lived callers (the web dashboard's probe
+/// loop); CLI paths (one-shot `power`) may keep using `Client::new`.
+pub fn shared_client(config: &Config) -> Option<Client> {
+    SHARED_CLIENT
+        .get_or_init(|| {
+            config
+                .home_assistant
+                .as_ref()
+                .and_then(|ha| Client::new(ha).ok())
+        })
+        .clone()
+}
+
 /// Thin async wrapper around the HA REST API.
 ///
 /// `base_url` is the HA root URL (`http://homeassistant.local:8123`),
@@ -153,10 +173,7 @@ pub async fn extra_entities_states(config: &Config, cluster: &Cluster) -> Entity
     if cluster.extra_entities.is_empty() {
         return EntityStates::default();
     }
-    let ha_client: Option<Client> = config
-        .home_assistant
-        .as_ref()
-        .and_then(|ha| Client::new(ha).ok());
+    let ha_client: Option<Client> = shared_client(config);
     let has_ha = ha_client.is_some();
     let mut handles = Vec::new();
     for e in &cluster.extra_entities {

+ 2 - 4
src/report/serve.rs

@@ -91,10 +91,8 @@ pub(crate) async fn refresh_loop(
     });
     let mut watcher = watcher.ok();
     if let Some(w) = &mut watcher {
-        for dir in crate::config::recipe_dirs(&cfg) {
-            if dir.is_dir() {
-                let _ = w.watch(&dir, notify::RecursiveMode::Recursive);
-            }
+        for dir in crate::config::watchable_recipe_dirs(&cfg) {
+            let _ = w.watch(&dir, notify::RecursiveMode::Recursive);
         }
     }
     let mut last = String::new();

+ 11 - 5
src/web/mod.rs

@@ -375,12 +375,18 @@ pub async fn run(
             _ = tokio::signal::ctrl_c() => {},
             _ = term.recv() => {},
         }
-        tracing::info!("sm121 web: shutdown signal — finishing in-flight requests (10 s cap)");
-        tokio::time::sleep(Duration::from_secs(10)).await;
+        tracing::info!("sm121 web: shutdown signal — closing");
+        tokio::time::sleep(Duration::from_secs(1)).await;
     };
-    axum::serve(listener, router(app))
-        .with_graceful_shutdown(shutdown)
-        .await?;
+    // The serve future is dropped when the signal fires: all connections
+    // (incl. never-finishing SSE streams) close and the process exits
+    // cleanly. A graceful drain would hang on live SSE and force the
+    // supervisor to SIGKILL the container instead.
+    let server = axum::serve(listener, router(app));
+    tokio::select! {
+        _ = server => {},
+        _ = shutdown => {},
+    }
     Ok(())
 }
 

+ 2 - 4
src/web/probe.rs

@@ -43,10 +43,8 @@ pub struct Fleet {
 /// `None` when no backend is configured or the entity is unknown.
 /// Mikrotik devices are probed through the RouterOS API (not HA).
 async fn ha_states(config: &Config) -> HashMap<String, Option<bool>> {
-    let ha_client: Option<crate::home_assistant::Client> = config
-        .home_assistant
-        .as_ref()
-        .and_then(|ha| crate::home_assistant::Client::new(ha).ok());
+    let ha_client: Option<crate::home_assistant::Client> =
+        crate::home_assistant::shared_client(config);
     let mut ents: Vec<String> = Vec::new();
     for c in &config.clusters {
         for n in &c.nodes {