Browse Source

report serve: event-driven refresh — notify watcher (FSEvents/inotify) + 500 ms debounce replaces the 5 s poll; 30 s keepalive as safety net; events filtered to reports/ + recipe.toml

Alexander Korolev 1 tháng trước cách đây
mục cha
commit
476b5a5cde
5 tập tin đã thay đổi với 152 bổ sung và 25 xóa
  1. 78 0
      Cargo.lock
  2. 1 0
      Cargo.toml
  3. 1 6
      src/report/mod.rs
  4. 69 18
      src/report/serve.rs
  5. 3 1
      src/report/tests.rs

+ 78 - 0
Cargo.lock

@@ -940,6 +940,15 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
 
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "futures"
 version = "0.3.32"
@@ -1434,6 +1443,26 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "inotify"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "inout"
 version = "0.2.2"
@@ -1583,6 +1612,26 @@ dependencies = [
  "rand_core 0.10.1",
 ]
 
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.5.0"
@@ -1687,6 +1736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
 dependencies = [
  "libc",
+ "log",
  "wasi",
  "windows-sys 0.61.2",
 ]
@@ -1728,6 +1778,33 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
+]
+
 [[package]]
 name = "nu-ansi-term"
 version = "0.50.3"
@@ -2777,6 +2854,7 @@ dependencies = [
  "flate2",
  "fluent-i18n",
  "futures",
+ "notify",
  "reqwest",
  "russh",
  "serde",

+ 1 - 0
Cargo.toml

@@ -19,6 +19,7 @@ base64 = "0.22"
 futures = "0.3"
 walkdir = "2"
 dirs = "6"
+notify = "8"
 reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
 flate2 = "1"
 tar = "0.4"

+ 1 - 6
src/report/mod.rs

@@ -418,12 +418,7 @@ pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<
     let initial = serde_json::to_string(&rows).unwrap_or_default();
     let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
     let addr = listener.local_addr()?;
-    let app = serve::router(
-        std::sync::Arc::new(config.clone()),
-        page,
-        initial,
-        serve::REFRESH_TICK,
-    );
+    let app = serve::router(std::sync::Arc::new(config.clone()), page, initial);
     if open {
         let url = format!("http://{addr}/");
         tokio::spawn(async move {

+ 69 - 18
src/report/serve.rs

@@ -1,11 +1,20 @@
 //! Live-update plumbing for `report --serve`: a background refresher that
-//! re-scans the recipe `reports/` dirs on a tick, and an SSE endpoint
-//! (`/events`) that pushes the rows JSON to the page whenever anything
-//! changed (a new report file, a bench finishing, a log growing).
+//! re-scans the recipe `reports/` dirs when the file system says something
+//! changed, and an SSE endpoint (`/events`) that pushes the rows JSON to the
+//! page whenever the scan result differs (a new report file, a bench
+//! finishing, a log growing).
 //!
-//! One refresher serves all clients: it publishes the rows JSON on a `watch`
-//! channel only when the serialization differs from the last publish, and
-//! each SSE connection forwards its own `watch::Receiver`.
+//! The refresher is event-driven, not a fixed poll: a `notify` watcher
+//! (FSEvents on macOS, inotify on Linux) over the recipe dirs feeds an mpsc
+//! channel; after a short debounce burst the rows are re-collected and
+//! published on the `watch` channel only when the serialization changed.
+//! Filesystem events are filtered to the report-relevant paths (anything
+//! under a `reports/` dir, or a `recipe.toml`), so build churn elsewhere in
+//! the repo is ignored. A slow keepalive scan stays as a safety net for
+//! missed notifications (legacy log-only reports, dirs created late).
+//!
+//! One refresher serves all clients: each SSE connection forwards its own
+//! `watch::Receiver`.
 
 use crate::config::Config;
 use crate::report::collect;
@@ -16,14 +25,20 @@ use axum::{
     routing::get,
 };
 use futures::{Stream, StreamExt};
+use notify::Watcher;
 use std::convert::Infallible;
 use std::sync::Arc;
 use std::time::Duration;
 
-/// How often the refresher re-scans the report dirs. The scan is a local
-/// dir walk + small-file reads (a few dozen ms); 5 s keeps a finishing bench
-/// visible in the page within one poll while staying negligible I/O.
-pub(crate) const REFRESH_TICK: Duration = Duration::from_secs(5);
+/// Safety-net rescan interval: covers missed watcher notifications (a
+/// legacy `.log`-only report whose score lands in a Modify the filter
+/// ignores, a source dir created after startup). The normal path is the
+/// event-driven one, so this can stay slow.
+pub(crate) const KEEPALIVE: Duration = Duration::from_secs(30);
+
+/// Wait after a filesystem event before re-scanning, so a write burst
+/// (the launcher copying a report triplet) collapses into one collect.
+const DEBOUNCE: Duration = Duration::from_millis(500);
 
 /// Shared server state: the config (for `/r/...` file loads) plus the
 /// latest rows JSON (each SSE connection clones the receiver).
@@ -33,23 +48,59 @@ pub(crate) struct AppState {
     pub(crate) data: tokio::sync::watch::Receiver<String>,
 }
 
-/// Background refresher: collect the rows, publish the JSON when it changed,
-/// sleep, repeat. The first collect runs immediately so clients see current
-/// data without waiting a tick. Runs on the tokio runtime; the scan is fast
-/// local disk I/O.
+/// True when the event touches a report-relevant path: anything under a
+/// `reports/` directory, or a `recipe.toml` (row fields come from it).
+/// Everything else (build churn, git, the rest of the repo) is ignored so
+/// the watcher can watch a wide tree cheaply.
+fn is_relevant(ev: &notify::Event) -> bool {
+    ev.paths.iter().any(|p| {
+        p.ancestors()
+            .any(|a| a.file_name().is_some_and(|n| n == "reports"))
+            || p.file_name().is_some_and(|n| n == "recipe.toml")
+    })
+}
+
+/// Background refresher: watch the recipe dirs, re-collect after a debounced
+/// filesystem event (or the keepalive safety net), publish the rows JSON on
+/// the watch channel only when it changed. The first collect runs
+/// immediately so clients see current data without waiting. If the watcher
+/// cannot be created (unsupported FS), the keepalive alone keeps the data
+/// fresh.
 pub(crate) async fn refresh_loop(
     cfg: Arc<Config>,
     tx: tokio::sync::watch::Sender<String>,
-    tick: Duration,
+    keepalive: Duration,
 ) {
+    let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
+    let watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
+        if let Ok(ev) = res
+            && is_relevant(&ev)
+        {
+            let _ = ev_tx.send(());
+        }
+    });
+    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);
+            }
+        }
+    }
     let mut last = String::new();
     loop {
+        tokio::select! {
+            _ = ev_rx.recv() => {}
+            _ = tokio::time::sleep(keepalive) => {}
+        }
+        // debounce: let the burst settle, then drain the remaining events
+        tokio::time::sleep(DEBOUNCE).await;
+        while ev_rx.try_recv().is_ok() {}
         let json = serde_json::to_string(&collect(&cfg)).unwrap_or_default();
         if json != last {
             last = json;
             let _ = tx.send(last.clone());
         }
-        tokio::time::sleep(tick).await;
     }
 }
 
@@ -125,9 +176,9 @@ async fn report_file(
 /// rows JSON snapshot used to seed the watch channel (from the same
 /// `collect` that rendered the page, so the first paint and the first SSE
 /// message agree).
-pub(crate) fn router(config: Arc<Config>, page: String, initial: String, tick: Duration) -> Router {
+pub(crate) fn router(config: Arc<Config>, page: String, initial: String) -> Router {
     let (tx, rx) = tokio::sync::watch::channel(initial);
-    tokio::spawn(refresh_loop(config.clone(), tx, tick));
+    tokio::spawn(refresh_loop(config.clone(), tx, KEEPALIVE));
     Router::new()
         .route(
             "/",

+ 3 - 1
src/report/tests.rs

@@ -365,7 +365,9 @@ async fn refresher_publishes_on_change_only() {
         toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
     let cfg = std::sync::Arc::new(config);
     let (tx, mut rx) = tokio::sync::watch::channel(String::new());
-    let h = tokio::spawn(refresh_loop(cfg, tx, Duration::from_millis(20)));
+    // keepalive 100 ms: if the file watcher fires, the update lands via the
+    // event path; if not, the keepalive picks it up — either way within 3 s
+    let h = tokio::spawn(refresh_loop(cfg, tx, Duration::from_millis(100)));
     // the first collect runs immediately: the fixture's report is visible
     tokio::time::timeout(
         Duration::from_secs(3),