Talk to NetworkManager over D-Bus instead of shelling out to nmcli
breadcrumbs now speaks `org.freedesktop.NetworkManager` on the system bus directly (new `zbus` dependency) — no `nmcli` subprocesses for connect, scan, status, or the watch loop. Why: - Wi-Fi PSKs and 802.1x passwords no longer touch a command line. They travel inside `AddAndActivateConnection2` / `Update2` settings payloads, so they are never visible to other local users via `/proc/<pid>/cmdline`. This fully supersedes the earlier "feed the PSK to `nmcli --ask` on stdin" approach. - The watch loop reacts to real `Device.StateChanged` / connectivity signals instead of parsing `nmcli monitor` text. - Connect waits on the device actually reaching the ACTIVATED state rather than trusting `nmcli --wait`. Config: `settings.nmcli_wait` is renamed to `connect_wait`; the old key is still accepted via `#[serde(alias)]`. `status.rs` loses its private `ipv4()` nmcli helper in favour of `nm::ipv4_address`. `util::run_with_stdin` stays (tailscale still uses it) but no longer carries secrets.
This commit is contained in:
parent
13c7743d48
commit
b4c1d0b233
11 changed files with 1690 additions and 634 deletions
933
Cargo.lock
generated
933
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,7 @@ clap = { version = "4", features = ["derive"] }
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
serde_json = "1"
|
||||
zbus = "4"
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||
|
||||
[profile.release]
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -2,21 +2,21 @@
|
|||
|
||||
A profile-aware Wi-Fi state machine for Linux with Tailscale exit-node management and a self-healing watch daemon.
|
||||
|
||||
breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically.
|
||||
breadcrumbs sits on top of NetworkManager's **D-Bus API** (`org.freedesktop.NetworkManager` on the system bus — no `nmcli` subprocesses) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically.
|
||||
|
||||
## Features
|
||||
|
||||
- **Profile-based connection management** — define ordered network priority lists per location
|
||||
- **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network
|
||||
- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor`
|
||||
- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via NetworkManager D-Bus signals
|
||||
- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers
|
||||
- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). On that first connect the PSK is fed to `nmcli --ask` on stdin, never as a command argument, so it does not appear in `/proc/<pid>/cmdline`.
|
||||
- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Secrets are never exposed in a process command line: everything travels inside D-Bus `Update2`/`AddAndActivateConnection2` settings payloads, invisible to other local users via `/proc/<pid>/cmdline`.
|
||||
- **Desktop notifications** via `notify-send` (optional)
|
||||
- **systemd user service** generation via `breadcrumbs install-service`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux with NetworkManager (`nmcli` in `$PATH`)
|
||||
- Linux with NetworkManager running on the D-Bus system bus
|
||||
- Rust toolchain (to build from source)
|
||||
- `tailscale` (optional — only needed if any profile sets `tailscale = true`)
|
||||
- `notify-send` (optional — for desktop notifications)
|
||||
|
|
@ -52,7 +52,7 @@ Settings and location profiles live in `breadcrumbs.toml` — the file people ac
|
|||
```toml
|
||||
[settings]
|
||||
dns = "1.1.1.1" # DNS server pinned on every connection
|
||||
nmcli_wait = 8 # seconds to wait for nmcli connect
|
||||
connect_wait = 8 # seconds to wait for the device to reach the activated state (legacy key: nmcli_wait)
|
||||
exit_node = "myhostname" # default Tailscale exit node
|
||||
exit_nodes = ["a", "b"] # optional priority list; tried in order (fallback nodes)
|
||||
interface = "wlan0" # optional preferred Wi-Fi interface
|
||||
|
|
@ -169,7 +169,7 @@ breadcrumbs install-service
|
|||
`breadcrumbs watch` is the recommended way to run breadcrumbs for daily use. It:
|
||||
|
||||
1. Polls health every `watch_interval` seconds (adaptive backoff on repeated failures)
|
||||
2. Reacts immediately to link-state changes via `nmcli monitor`
|
||||
2. Reacts immediately to link-state changes via NetworkManager D-Bus signals (`Device.StateChanged`, `Connectivity` property changes, hotplug events)
|
||||
3. Runs `flow::run` (the connect state machine) on any detected drop
|
||||
4. Handles profile changes live — re-reads config and state on every tick
|
||||
5. Distinguishes captive portals from plain no-internet (a 200/301/302 instead
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
[settings]
|
||||
dns = "1.1.1.1"
|
||||
nmcli_wait = 8
|
||||
connect_wait = 8
|
||||
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
|
||||
default_profile = "away"
|
||||
watch_interval = 12
|
||||
|
|
|
|||
16
src/app.rs
16
src/app.rs
|
|
@ -465,8 +465,9 @@ fn prompt_line(msg: &str) -> String {
|
|||
/// response) means "this network has no password" (open Wi-Fi) — normalize
|
||||
/// it to `None` right at the point of entry so it flows the same way a
|
||||
/// genuinely absent/cleared password does. Without this, `Some("")` would
|
||||
/// make `nm::connect_verbose` treat it as a (blank) secret rather than an
|
||||
/// open network, and the connect fails against a real open SSID.
|
||||
/// make `nm::connect_verbose` send an empty PSK in the settings payload,
|
||||
/// which NetworkManager treats as "secured with a blank password" rather
|
||||
/// than "open", and the connect fails against a real open SSID.
|
||||
fn non_empty(s: String) -> Option<String> {
|
||||
if s.is_empty() {
|
||||
None
|
||||
|
|
@ -666,7 +667,7 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
|||
ca_cert: None,
|
||||
hidden: false,
|
||||
};
|
||||
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||
if !nm::connect(&iface, &def, cfg.settings.connect_wait, &cfg.settings.dns) {
|
||||
return Err(format!("failed to connect to {ssid}"));
|
||||
}
|
||||
// A successful connect means NetworkManager now durably holds the PSK
|
||||
|
|
@ -799,9 +800,9 @@ fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i
|
|||
let s = crate::status::gather(cfg, &p);
|
||||
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
||||
println!(
|
||||
" nmcli {}",
|
||||
if command_exists("nmcli") {
|
||||
"present"
|
||||
" network-manager {}",
|
||||
if nm::available() {
|
||||
"present (D-Bus)"
|
||||
} else {
|
||||
"MISSING"
|
||||
}
|
||||
|
|
@ -895,7 +896,8 @@ fn cmd_install_service(enable: bool) -> Result<i32, String> {
|
|||
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
|
||||
// login browser-open actually work. PATH is pinned because systemd --user
|
||||
// units do not get the login shell's PATH, and the watcher shells out to
|
||||
// nmcli/tailscale/sudo/xdg-open by name.
|
||||
// tailscale/sudo/xdg-open by name (NetworkManager is reached over D-Bus,
|
||||
// so no nmcli is needed).
|
||||
let unit = format!(
|
||||
"[Unit]\n\
|
||||
Description=breadcrumbs Wi-Fi state machine watcher\n\
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::util::home_dir;
|
|||
fn default_dns() -> String {
|
||||
"1.1.1.1".to_string()
|
||||
}
|
||||
fn default_nmcli_wait() -> u32 {
|
||||
fn default_connect_wait() -> u32 {
|
||||
8
|
||||
}
|
||||
fn default_exit_node() -> String {
|
||||
|
|
@ -77,8 +77,10 @@ fn is_false(b: &bool) -> bool {
|
|||
pub struct Settings {
|
||||
#[serde(default = "default_dns")]
|
||||
pub dns: String,
|
||||
#[serde(default = "default_nmcli_wait")]
|
||||
pub nmcli_wait: u32,
|
||||
/// Seconds to wait for a connect to reach the ACTIVATED device state.
|
||||
/// `nmcli_wait` is accepted as a legacy alias.
|
||||
#[serde(default = "default_connect_wait", alias = "nmcli_wait")]
|
||||
pub connect_wait: u32,
|
||||
#[serde(default = "default_exit_node")]
|
||||
pub exit_node: String,
|
||||
#[serde(default = "default_profile_name")]
|
||||
|
|
@ -118,7 +120,7 @@ impl Default for Settings {
|
|||
fn default() -> Self {
|
||||
Settings {
|
||||
dns: default_dns(),
|
||||
nmcli_wait: default_nmcli_wait(),
|
||||
connect_wait: default_connect_wait(),
|
||||
exit_node: default_exit_node(),
|
||||
default_profile: default_profile_name(),
|
||||
watch_interval: default_watch_interval(),
|
||||
|
|
@ -560,7 +562,7 @@ mod tests {
|
|||
fn settings_default_matches_documented_defaults() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.dns, "1.1.1.1");
|
||||
assert_eq!(s.nmcli_wait, 8);
|
||||
assert_eq!(s.connect_wait, 8);
|
||||
assert_eq!(s.default_profile, "away");
|
||||
assert_eq!(s.watch_interval, 12);
|
||||
assert_eq!(s.ping_host, "1.1.1.1");
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ fn learn_ssid(cfg: &mut Config, profile: &str, ssid: &str) {
|
|||
/// Try to connect + confirm the device actually landed on the *requested*
|
||||
/// SSID. Returns Ok(()) on success, Err(reason) on failure.
|
||||
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
|
||||
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, def.effective_dns(&cfg.settings.dns))?;
|
||||
nm::connect_verbose(iface, def, cfg.settings.connect_wait, def.effective_dns(&cfg.settings.dns))?;
|
||||
// Confirm the SSID, not just "device connected": NM autoconnect can win
|
||||
// a race and leave the device on a different network, and the wifi list
|
||||
// can lag activation by a moment — so poll briefly before giving up.
|
||||
|
|
|
|||
|
|
@ -65,23 +65,6 @@ pub fn internet_ok(cfg: &Config) -> bool {
|
|||
matches!(connectivity(cfg), Connectivity::Online)
|
||||
}
|
||||
|
||||
fn ipv4(iface: &str) -> Option<String> {
|
||||
let o = run(
|
||||
"nmcli",
|
||||
&["-g", "IP4.ADDRESS", "device", "show", iface],
|
||||
Duration::from_secs(6),
|
||||
);
|
||||
if !o.success {
|
||||
return None;
|
||||
}
|
||||
let s = o.stdout.trim();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.lines().next().unwrap_or(s).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Status {
|
||||
pub iface: Option<String>,
|
||||
pub ssid: Option<String>,
|
||||
|
|
@ -97,7 +80,7 @@ pub struct Status {
|
|||
pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
||||
let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref());
|
||||
let ssid = iface.as_deref().and_then(nm::active_ssid);
|
||||
let ip = iface.as_deref().and_then(ipv4);
|
||||
let ip = iface.as_deref().and_then(nm::ipv4_address);
|
||||
// Skip the (potentially 4s-blocking) connectivity probe when there's no
|
||||
// Wi-Fi interface at all: the watch loop classifies NoAdapter and would
|
||||
// otherwise burn a network round-trip (curl/ping) every tick for nothing.
|
||||
|
|
|
|||
|
|
@ -88,14 +88,14 @@ pub fn command_exists(name: &str) -> bool {
|
|||
}
|
||||
|
||||
/// Run a command with a hard timeout. The child is killed if it overruns so a
|
||||
/// hung nmcli/tailscale can never wedge the daemon.
|
||||
/// hung subprocess can never wedge the daemon.
|
||||
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
||||
run_with_stdin(prog, args, None, timeout)
|
||||
}
|
||||
|
||||
/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand
|
||||
/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv,
|
||||
/// where any local user could read them via `ps`.
|
||||
/// Like [`run`], but feeds `stdin` to the child's standard input.
|
||||
/// (Wi-Fi secrets no longer go through here: `nm` sends them inside D-Bus
|
||||
/// payloads, never on a command line.)
|
||||
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||
RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout))
|
||||
}
|
||||
|
|
|
|||
139
src/watch.rs
139
src/watch.rs
|
|
@ -1,19 +1,27 @@
|
|||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bread_utils::bread_client::BreadClient;
|
||||
use zbus::blocking::{Connection, Proxy};
|
||||
use zbus::zvariant::OwnedValue;
|
||||
|
||||
use crate::bread_events;
|
||||
use crate::config::Config;
|
||||
use crate::flow;
|
||||
use crate::nm;
|
||||
use crate::notify::{log, notify, Urgency};
|
||||
use crate::state::{self, State};
|
||||
use crate::status::{self};
|
||||
use crate::tailscale::TsHealth;
|
||||
|
||||
const NM_DEST: &str = "org.freedesktop.NetworkManager";
|
||||
const NM_PATH: &str = "/org/freedesktop/NetworkManager";
|
||||
const NM_IFACE: &str = "org.freedesktop.NetworkManager";
|
||||
const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device";
|
||||
const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties";
|
||||
|
||||
/// Coarse health classification the watch loop reacts to each tick. `pub`
|
||||
/// (and so is [`classify`]) purely so integration tests can drive the real
|
||||
/// classification logic in-process against a faked [`crate::util::Runner`],
|
||||
|
|
@ -146,58 +154,89 @@ enum Wake {
|
|||
SetProfile(String),
|
||||
}
|
||||
|
||||
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
||||
/// to drops within a second instead of waiting out the poll interval.
|
||||
fn spawn_nm_monitor(tx: mpsc::Sender<Wake>) {
|
||||
/// Whether a `PropertiesChanged` message on the NM root changes the
|
||||
/// `Connectivity` property of the NetworkManager interface — the signal
|
||||
/// that catches "still connected but lost the internet" (portal, DHCP
|
||||
/// failure) without waiting out the poll interval.
|
||||
fn props_changed_connectivity(msg: &zbus::Message) -> bool {
|
||||
let Ok(body) = msg.body().deserialize::<(String, HashMap<String, OwnedValue>, Vec<String>)>() else {
|
||||
return false;
|
||||
};
|
||||
body.0 == NM_IFACE && body.1.contains_key("Connectivity")
|
||||
}
|
||||
|
||||
/// Subscribe to a D-Bus signal from NetworkManager and ping the channel for
|
||||
/// each matching message, reconnecting on bus/NM restarts. One thread per
|
||||
/// subscription (a handful at most); each owns its own connection so a dead
|
||||
/// bus can't wedge the others.
|
||||
fn spawn_signal_watcher<F>(tx: mpsc::Sender<Wake>, path: String, iface: &'static str, signal: &'static str, mut on_msg: F)
|
||||
where
|
||||
F: FnMut(&zbus::Message) -> bool + Send + 'static,
|
||||
{
|
||||
thread::spawn(move || loop {
|
||||
let child = Command::new("nmcli")
|
||||
.arg("monitor")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
continue;
|
||||
}
|
||||
let Ok(conn) = Connection::system() else {
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
continue;
|
||||
};
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let reader = BufReader::new(out);
|
||||
// `None` means "haven't fired yet, so fire on the first interesting
|
||||
// line". Storing an `Option` instead of seeding with
|
||||
// `Instant::now() - 10s` avoids a panic: `Instant - Duration`
|
||||
// underflows (and panics) when the monotonic clock is younger than
|
||||
// the offset, which happens if `watch` starts within ~10s of boot —
|
||||
// exactly when the systemd unit (ordered after graphical-session)
|
||||
// tends to launch.
|
||||
let mut last: Option<Instant> = None;
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let l = line.to_lowercase();
|
||||
// `connectivity` lines catch drops that keep the device
|
||||
// "connected" but lose the internet (captive portal, DHCP
|
||||
// failure); `deactivating` covers teardown. Everything else
|
||||
// waits out the poll interval.
|
||||
let interesting = l.contains("disconnect")
|
||||
|| l.contains("unavailable")
|
||||
|| l.contains("failed")
|
||||
|| l.contains("deactivating")
|
||||
|| l.contains("connectivity");
|
||||
if interesting && debounce_ready(last, Duration::from_millis(1500)) {
|
||||
last = Some(Instant::now());
|
||||
let _ = tx.send(Wake::LinkChurn);
|
||||
}
|
||||
let Ok(proxy) = Proxy::new(&conn, NM_DEST, path.as_str(), iface) else {
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
continue;
|
||||
};
|
||||
let Ok(mut iter) = proxy.receive_signal(signal) else {
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
continue;
|
||||
};
|
||||
// `None` means "haven't fired yet, so fire on the first interesting
|
||||
// signal". Storing an `Option` instead of seeding with
|
||||
// `Instant::now() - 10s` avoids a panic: `Instant - Duration`
|
||||
// underflows (and panics) when the monotonic clock is younger than
|
||||
// the offset, which happens if `watch` starts within ~10s of boot —
|
||||
// exactly when the systemd unit (ordered after graphical-session)
|
||||
// tends to launch.
|
||||
let mut last: Option<Instant> = None;
|
||||
for msg in iter.by_ref() {
|
||||
if on_msg(&msg) && debounce_ready(last, Duration::from_millis(1500)) {
|
||||
last = Some(Instant::now());
|
||||
let _ = tx.send(Wake::LinkChurn);
|
||||
}
|
||||
}
|
||||
let _ = child.wait();
|
||||
// monitor died (NM restart?) — back off and respawn.
|
||||
// Subscription died (NM or bus restart) — back off and resubscribe.
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
});
|
||||
}
|
||||
|
||||
/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn or
|
||||
/// a `set_profile` command arrives. Returns the pending action, if any.
|
||||
/// Subscribe to NetworkManager D-Bus signals and ping the channel on
|
||||
/// link-state churn so we react to drops within a second instead of waiting
|
||||
/// out the poll interval. Replaces the old `nmcli monitor` subprocess: the
|
||||
/// same events are observed, but as structured D-Bus signals.
|
||||
///
|
||||
/// Watched signals:
|
||||
/// - `PropertiesChanged` on the NM root object, filtered to the
|
||||
/// `Connectivity` property — catches captive portals / DHCP failures that
|
||||
/// keep the device "connected" while losing the internet;
|
||||
/// - `DeviceAdded` / `DeviceRemoved` — hotplug;
|
||||
/// - `Device.StateChanged` on every Wi-Fi device — drops and reconnects.
|
||||
fn spawn_nm_monitor(tx: mpsc::Sender<Wake>) {
|
||||
spawn_signal_watcher(
|
||||
tx.clone(),
|
||||
NM_PATH.to_string(),
|
||||
PROPS_IFACE,
|
||||
"PropertiesChanged",
|
||||
props_changed_connectivity,
|
||||
);
|
||||
spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceAdded", |_| true);
|
||||
spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceRemoved", |_| true);
|
||||
// StateChanged on each Wi-Fi device. Devices added later (USB dongle
|
||||
// hotplug) are caught by the DeviceAdded watcher waking the loop; the
|
||||
// poll interval covers anything else.
|
||||
for path in nm::wifi_device_paths() {
|
||||
spawn_signal_watcher(tx.clone(), path, DEV_IFACE, "StateChanged", |_| true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep up to `dur`, but wake early if the D-Bus signal monitor signals
|
||||
/// link churn or a `set_profile` command arrives. Returns the pending
|
||||
/// action, if any.
|
||||
fn wait_for_tick(rx: &Receiver<Wake>, dur: Duration) -> Option<Wake> {
|
||||
match rx.recv_timeout(dur) {
|
||||
Ok(first) => {
|
||||
|
|
@ -301,10 +340,10 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
profile = State::load(&cfg.settings.default_profile).profile;
|
||||
|
||||
// Suspend/resume: `nmcli monitor` sees nothing while the machine
|
||||
// sleeps, so a large wall-clock gap means the network state may have
|
||||
// changed underneath us — allow an immediate recovery run instead of
|
||||
// waiting out any remaining flow cooldown.
|
||||
// Suspend/resume: the D-Bus signal monitor sees nothing while the
|
||||
// machine sleeps, so a large wall-clock gap means the network state
|
||||
// may have changed underneath us — allow an immediate recovery run
|
||||
// instead of waiting out any remaining flow cooldown.
|
||||
if last_tick_at.elapsed() > prev_wait + RESUME_SLACK {
|
||||
log("watch: large gap since last tick (suspend/resume?) — forcing recovery check");
|
||||
last_flow_at = None;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue