From 83db33e65529eb35267c933eaaaf83bb62e0b735 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Thu, 1 Oct 2026 08:33:34 -0700 Subject: [PATCH 1/2] Say which kind of empty the Live page is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a desktop: "I see nothing in live even I refreshed firefox". Nothing was wrong with the window, the daemon or the probes. Payload capture had run out — its session ended — so the daemon was reading nothing, and the Live page said "Nothing read in this window", which reads as "your machine was quiet" when it means "Flowlight stopped reading". The daemon knew. It logged `payload capture has run out and has stopped` when it happened, and `flowlightd budget` says it on demand. The window never asked. The handshake it does ask carries `storing`, which is built once at startup and hard-coded true, so it cannot express a thing that becomes true later. So the window now asks, every ten seconds, on whichever page is open — a session ending is not a second-by-second event, and the person who needs telling is the one who left the Live tab open. When nothing is being read a banner says so and carries a Renew button, and the empty page says which of the three silences it is: switched off, run out, or genuinely quiet. `Reading::of` is the mapping, extracted so it can be tested without a display. The tests were run against the old behaviour — one page for every silence — and two of them failed there before they were trusted here. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 28 ++--- Cargo.toml | 2 +- README.md | 9 ++ crates/flowlight-gui/src/main.rs | 173 +++++++++++++++++++++++++++++-- 4 files changed, 189 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 596e6a3..fe54c86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,7 +540,7 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flowlight-agents" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "serde", @@ -550,11 +550,11 @@ dependencies = [ [[package]] name = "flowlight-alerts" -version = "0.5.13" +version = "0.5.14" [[package]] name = "flowlight-ask" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "flowlight-store", @@ -565,14 +565,14 @@ dependencies = [ [[package]] name = "flowlight-common" -version = "0.5.13" +version = "0.5.14" dependencies = [ "aya", ] [[package]] name = "flowlight-daemon" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "aya", @@ -598,14 +598,14 @@ dependencies = [ [[package]] name = "flowlight-devices" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", ] [[package]] name = "flowlight-ebpf" -version = "0.5.13" +version = "0.5.14" dependencies = [ "aya-ebpf", "flowlight-common", @@ -613,7 +613,7 @@ dependencies = [ [[package]] name = "flowlight-gui" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "async-channel", @@ -625,14 +625,14 @@ dependencies = [ [[package]] name = "flowlight-owners" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", ] [[package]] name = "flowlight-platform" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "libc", @@ -640,7 +640,7 @@ dependencies = [ [[package]] name = "flowlight-proxy" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "flowlight-agents", @@ -656,7 +656,7 @@ dependencies = [ [[package]] name = "flowlight-rules" -version = "0.5.13" +version = "0.5.14" dependencies = [ "serde", "serde_json", @@ -664,7 +664,7 @@ dependencies = [ [[package]] name = "flowlight-store" -version = "0.5.13" +version = "0.5.14" dependencies = [ "anyhow", "flowlight-alerts", @@ -677,7 +677,7 @@ dependencies = [ [[package]] name = "flowlight-text" -version = "0.5.13" +version = "0.5.14" [[package]] name = "foldhash" diff --git a/Cargo.toml b/Cargo.toml index 724aab0..2627d5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ default-members = [ ] [workspace.package] -version = "0.5.13" +version = "0.5.14" edition = "2024" license = "GPL-3.0-only" repository = "https://github.com/xinbetween/flowlight-linux" diff --git a/README.md b/README.md index 14251f5..2957c64 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,15 @@ including an installation from it with a throwaway key, and goes live once a sig [`packaging/apt/README.md`](packaging/apt/README.md) says why that key is not something this code can create for you. Until it is, the commands above have nothing to answer them. +**An empty Live page says which kind of empty it is.** Payload capture runs for a session — eight hours by +default — and stops when that session ends. Until v0.5.14 the window then showed "Nothing read in this +window", which reads as "your machine was quiet" when it means "Flowlight stopped reading". It now says the +session ran out and offers a Renew button, on whichever page you are looking at. `flowlightd budget` always +said this; the window simply never asked. + +Note that the session is measured in wall-clock time. A machine whose clock jumps forward — a VM resuming +from suspend, say — can find its session already over. + **What the window shows is text, not markup.** Every row carries something that came off the network — a host, a path, a process name — and libadwaita reads a row's title as Pango markup unless it is told otherwise first. Until v0.5.13 a request with two query parameters rendered as an empty row, and a path containing diff --git a/crates/flowlight-gui/src/main.rs b/crates/flowlight-gui/src/main.rs index fd5eb3b..8cd9123 100644 --- a/crates/flowlight-gui/src/main.rs +++ b/crates/flowlight-gui/src/main.rs @@ -117,6 +117,38 @@ struct State { turns: RefCell>, /// Whether a question is in flight, so the page can say so rather than looking broken for a minute. thinking: std::cell::Cell, + /// Whether the daemon is reading payloads at all, as of the last time it was asked. + reading: std::cell::Cell, + /// When that was, so it is asked occasionally rather than four times a second. + asked_reading: std::cell::Cell>, +} + +/// Whether payloads are being read, which is the difference between "nothing happened" and "nothing was +/// read" — two empty pages that look identical and have different remedies. +#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize)] +enum Reading { + /// Being read, so an empty page means an empty hour. + Yes, + /// Switched off entirely. Nothing will appear until somebody turns it on. + Off, + /// The session ended. Renewing starts it again. + RunOut, + /// The daemon has not been asked yet, or did not answer. + Unknown, +} + +impl Reading { + /// What the budget amounts to, from the window's point of view. + /// + /// Switched off and run out are different sentences with different remedies, and the budget says both + /// in two fields: `payloads` is the decision, `reading` is whether it is in force right now. + fn of(budget: &protocol::Budget) -> Self { + match (budget.payloads, budget.reading) { + (false, _) => Self::Off, + (true, true) => Self::Yes, + (true, false) => Self::RunOut, + } + } } fn build(application: &adw::Application, socket: PathBuf) { @@ -124,6 +156,8 @@ fn build(application: &adw::Application, socket: PathBuf) { socket, window: WINDOWS.get(1).map_or(3_600, |(_, seconds)| *seconds), drawn: RefCell::new(Default::default()), + reading: std::cell::Cell::new(Reading::Unknown), + asked_reading: std::cell::Cell::new(None), turns: RefCell::new(Vec::new()), thinking: std::cell::Cell::new(false), }); @@ -164,8 +198,21 @@ fn build(application: &adw::Application, socket: PathBuf) { .revealed(false) .use_markup(false) .build(); + // A second banner, because the first belongs to the handshake and says something that was true when + // the window opened. This one says what is true now: whether anything is being read at all. It carries + // the remedy rather than naming it, since somebody reading "renew it" wants to renew it. + let capture = adw::Banner::builder() + .revealed(false) + .use_markup(false) + .build(); + { + let state = Rc::clone(&state); + capture.connect_button_clicked(move |_| change(&state, "renew", "true")); + } + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.append(&banner); + content.append(&capture); content.append(&stack); let toolbar = adw::ToolbarView::new(); @@ -215,7 +262,7 @@ fn build(application: &adw::Application, socket: PathBuf) { glib::spawn_future_local(async move { loop { let seconds = *window_seconds.borrow(); - refresh(&state, seconds, &stack, &status, &pages).await; + refresh(&state, seconds, &stack, &status, &pages, &capture).await; glib::timeout_future_seconds(REFRESH_SECONDS).await; } }); @@ -252,10 +299,15 @@ async fn refresh( stack: &adw::ViewStack, status: >k::Label, pages: &Pages, + capture: &adw::Banner, ) { let visible = stack.visible_child_name().unwrap_or_else(|| "live".into()); let socket = state.socket.clone(); + // Asked on every page, because whether anything is being read is true of the window rather than of a + // page, and somebody who left the Live tab open is exactly who needs telling. + watch_reading(state, capture).await; + match visible.as_str() { "agents" => { match fetch::>(socket, protocol::windowed("agents", seconds)).await { @@ -352,8 +404,11 @@ async fn refresh( } _ => match fetch::>(socket, protocol::recent(seconds, RECENT)).await { Ok(rows) => { - draw(state, 0, &rows, &pages.live, |column| { - render_live(column, &rows) + // Why the page is empty is part of what the page says, so it is part of what decides + // whether the page is redrawn. + let reading = state.reading.get(); + draw(state, 0, &(&rows, reading), &pages.live, |column| { + render_live(column, &rows, reading) }); say(status, ""); } @@ -362,6 +417,54 @@ async fn refresh( } } +/// Asks whether payloads are still being read, and says so in the banner when they are not. +/// +/// Asked at most every ten seconds: a session running out is not a second-by-second event, and the Live page +/// already asks a question every two. The answer is kept on the state so an empty page can say which kind of +/// empty it is without asking again. +/// +/// This exists because a window showing nothing looked the same whether nothing had happened or nothing was +/// being read. The daemon knew which — it says so in its own log and in `flowlightd budget` — and the window +/// never asked. +async fn watch_reading(state: &Rc, banner: &adw::Banner) { + const ASK_EVERY: std::time::Duration = std::time::Duration::from_secs(10); + if let Some(asked) = state.asked_reading.get() + && asked.elapsed() < ASK_EVERY + { + return; + } + state.asked_reading.set(Some(std::time::Instant::now())); + + let Ok(budget) = + fetch::(state.socket.clone(), r#"{"op":"budget"}"#.to_owned()).await + else { + // An unreachable daemon is already said in the corner. Saying it twice helps nobody. + return; + }; + + let reading = Reading::of(&budget); + state.reading.set(reading); + + match reading { + Reading::Off => { + banner.set_title( + "Payloads are not being read, so requests will not appear here. Budget has the switch.", + ); + banner.set_button_label(None); + banner.set_revealed(true); + } + Reading::RunOut => { + banner.set_title( + "Payload capture has run out, so nothing new is being read. Connections are still \ + attributed.", + ); + banner.set_button_label(Some("Renew")); + banner.set_revealed(true); + } + Reading::Yes | Reading::Unknown => banner.set_revealed(false), + } +} + /// Redraws a page, but only when the answer has changed. /// /// Rebuilding a list every two seconds throws away the scroll position somebody was reading from, which @@ -458,12 +561,27 @@ fn who(agent: Option<&str>, process: &str) -> String { } } -fn render_live(column: >k::Box, rows: &[Request]) { +fn render_live(column: >k::Box, rows: &[Request], reading: Reading) { if rows.is_empty() { - column.append(¬hing( - "Nothing read in this window", - "That is not the same as nothing happening. Coverage says what could not be read.", - )); + // Four different silences. Telling somebody "nothing read" when the answer is "the session you + // started eight hours ago ended" wastes their afternoon, which is how this was found. + let (title, about) = match reading { + Reading::Off => ( + "Payloads are not being read", + "This page shows what applications hand their TLS libraries, and that is switched off. \ + Budget has the switch.", + ), + Reading::RunOut => ( + "Payload capture has run out", + "The session ended, so nothing new is being read. Renew it in the banner above or in \ + Budget — `sudo flowlightd budget --renew` does the same.", + ), + Reading::Yes | Reading::Unknown => ( + "Nothing read in this window", + "That is not the same as nothing happening. Coverage says what could not be read.", + ), + }; + column.append(¬hing(title, about)); return; } let group = adw::PreferencesGroup::new(); @@ -1614,3 +1732,42 @@ fn duration(seconds: i64) -> String { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A budget with the parts this is about, and defaults for the rest. + fn budget(payloads: bool, reading: bool) -> protocol::Budget { + protocol::Budget { + payloads, + reading, + session_minutes: 480, + session_remaining: Some(if reading { 3_600 } else { 0 }), + daily_bytes: 64 * 1024 * 1024, + paths: "full".to_owned(), + detail_days: 7, + summary_days: 90, + described: Vec::new(), + } + } + + #[test] + fn a_session_that_ran_out_is_not_a_quiet_hour() { + // The case that sent somebody looking at Firefox for an afternoon: capture is on, the session + // ended, and the page used to say "nothing read in this window". + assert_eq!(Reading::of(&budget(true, false)), Reading::RunOut); + } + + #[test] + fn switched_off_and_run_out_are_different_things() { + assert_eq!(Reading::of(&budget(false, false)), Reading::Off); + // Off stays off even if the session clock would allow it, because the decision is the decision. + assert_eq!(Reading::of(&budget(false, true)), Reading::Off); + } + + #[test] + fn reading_means_an_empty_page_is_an_empty_hour() { + assert_eq!(Reading::of(&budget(true, true)), Reading::Yes); + } +} From ed838d469bb1a26564732c59ea041f6bc4609cb4 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Thu, 1 Oct 2026 08:46:38 -0700 Subject: [PATCH 2/2] A live view that is slow to admit it stopped being live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut asked whether payloads were still being read at most once every ten seconds, reasoning that a session ending is not a second-by-second event. That is true of the session and false of the window. The whole point of this change is that a Live page which shows nothing should say why — and a page that takes ten seconds to admit it has stopped being live is the same bug arriving later and more quietly. So it is asked on every tick, with everything else. The cost is one more question every two seconds down a Unix socket on the same machine, against a window that is right or wrong within one refresh like every other thing in it. The throttle and the timestamp it needed are gone. Co-Authored-By: Claude Opus 5 (1M context) --- crates/flowlight-gui/src/main.rs | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/crates/flowlight-gui/src/main.rs b/crates/flowlight-gui/src/main.rs index 8cd9123..299a230 100644 --- a/crates/flowlight-gui/src/main.rs +++ b/crates/flowlight-gui/src/main.rs @@ -117,10 +117,8 @@ struct State { turns: RefCell>, /// Whether a question is in flight, so the page can say so rather than looking broken for a minute. thinking: std::cell::Cell, - /// Whether the daemon is reading payloads at all, as of the last time it was asked. + /// Whether the daemon is reading payloads at all, as of the last refresh. reading: std::cell::Cell, - /// When that was, so it is asked occasionally rather than four times a second. - asked_reading: std::cell::Cell>, } /// Whether payloads are being read, which is the difference between "nothing happened" and "nothing was @@ -157,7 +155,6 @@ fn build(application: &adw::Application, socket: PathBuf) { window: WINDOWS.get(1).map_or(3_600, |(_, seconds)| *seconds), drawn: RefCell::new(Default::default()), reading: std::cell::Cell::new(Reading::Unknown), - asked_reading: std::cell::Cell::new(None), turns: RefCell::new(Vec::new()), thinking: std::cell::Cell::new(false), }); @@ -304,8 +301,9 @@ async fn refresh( let visible = stack.visible_child_name().unwrap_or_else(|| "live".into()); let socket = state.socket.clone(); - // Asked on every page, because whether anything is being read is true of the window rather than of a - // page, and somebody who left the Live tab open is exactly who needs telling. + // Asked on every page and every tick, because whether anything is being read is true of the window + // rather than of a page, and somebody who left the Live tab open is exactly who needs telling — at the + // speed the rest of the window moves, not slower. watch_reading(state, capture).await; match visible.as_str() { @@ -419,22 +417,16 @@ async fn refresh( /// Asks whether payloads are still being read, and says so in the banner when they are not. /// -/// Asked at most every ten seconds: a session running out is not a second-by-second event, and the Live page -/// already asks a question every two. The answer is kept on the state so an empty page can say which kind of -/// empty it is without asking again. +/// Asked on every tick, with everything else. It was throttled to once every ten seconds on the reasoning +/// that a session ending is not a second-by-second event — which is true of the session and false of the +/// window. A live view that takes ten seconds to admit it has stopped being live is the bug this fixes, +/// arriving later and quieter. One more question every two seconds, down a Unix socket on the same machine, +/// buys a window that is either right or wrong within one refresh like everything else in it. /// /// This exists because a window showing nothing looked the same whether nothing had happened or nothing was /// being read. The daemon knew which — it says so in its own log and in `flowlightd budget` — and the window /// never asked. async fn watch_reading(state: &Rc, banner: &adw::Banner) { - const ASK_EVERY: std::time::Duration = std::time::Duration::from_secs(10); - if let Some(asked) = state.asked_reading.get() - && asked.elapsed() < ASK_EVERY - { - return; - } - state.asked_reading.set(Some(std::time::Instant::now())); - let Ok(budget) = fetch::(state.socket.clone(), r#"{"op":"budget"}"#.to_owned()).await else {