Skip to content

Commit c475ac1

Browse files
pmaxhoganclaude
andauthored
feat(power): real metered-network detection on macOS and Linux (#95)
## What Metered-network detection was real on Windows only (`INetworkCostManager::GetCost`); macOS and Linux returned a conservative unmetered stub, so `skip_on_metered` was inert on those platforms (issue #32). This wires both. - **Linux** - reads NetworkManager's aggregate `Metered` property (`org.freedesktop.NetworkManager`) over the system bus via zbus's blocking API, dispatched through `tokio::task::spawn_blocking` (zbus's blocking API must not be driven from inside an async runtime) and bounded by a 5 s timeout so a wedged bus can never stall the AC/battery poll. `NMMetered` YES/GUESS_YES -> metered, NO/GUESS_NO -> not metered, UNKNOWN or a failed read (no NetworkManager / no system bus) -> `Unknown` -> not metered. - **macOS** - there is no literal "metered" bit. A long-lived `NWPathMonitor` (Network.framework C API; its update handler is an Objective-C block via `block2`, delivered on a libdispatch global queue) caches `nw_path_is_expensive` (cellular / personal hotspot) `|| nw_path_is_constrained` (Low Data Mode) - the documented metered proxies - into an `AtomicU8` that the 30 s power poll reads cheaply. ## Design The per-OS raw read is separated from the decision logic. Three pure `classify_*` functions (`classify_windows_cost`, `classify_nm_metered`, `classify_nw_path`) plus `MeteredStatus`/`on_metered` turn each OS's raw value into the `on_metered_network` bool. They are `#[cfg(any(test, target_os = ...))]`, so **all three are compiled and unit-tested on every OS** - the classification is CI-covered on Windows, macOS, and Linux alike; only the thin OS-call adapters (COM / D-Bus / `NWPathMonitor`) are compile-checked-only off their native OS. Every read collapses ambiguity to `MeteredStatus::Unknown`, which maps to `false` (not metered) - the safe direction, since a wrong "not metered" only fails to skip a rare metered link whereas a wrong "metered" would stall ALL sync. No stored-format or public-API changes: `PowerState` shape and the `PowerSource` trait are unchanged; this is purely additive. ## Tested vs compile-only Development was on Windows, so the non-Windows adapters are compile-verified via the 3-OS CI matrix, not locally: - **Unit-tested on all three CI OSes** (run on Windows locally too): the three `classify_*` functions, `MeteredStatus::on_metered`, `from_u8`, `reachable_hint` - the full decision logic. - **Runtime-tested on the Windows CI runner** (and locally): `detect_metered()` real COM read is total (`windows_detect_metered_is_total`); the existing `RealPowerSource` construction tests exercise the read path. - **Runtime-tested on the macOS CI runner**: `macos_monitor_start_and_read_is_total` starts the real `NWPathMonitor` and reads its cache - the only runtime exercise of the Network.framework / `block2` / libdispatch FFI (a wrong signature or bad link fails here). - **Compile-only (CI matrix)**: the macOS FFI adapter body and the Linux zbus D-Bus adapter body. There is no metered hardware in CI, and NetworkManager may be absent on the Linux runner, so the Linux read's happy path is not asserted in CI - the D-Bus/classification split keeps the classification (the part with real logic) fully covered, and the adapter is a thin, safe-defaulting wrapper. APIs were taken from documented sources (zbus proxy/blocking docs; Apple Network.framework; objc2/block2 encoding), not guessed. ## Nothing deferred macOS `isExpensive`/`isConstrained` is the documented, feasible proxy (macOS exposes no literal metered bit), so #32 is fully addressed rather than scope-reduced. Closes #32. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fb5ea27 commit c475ac1

8 files changed

Lines changed: 536 additions & 192 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/driven-power/Cargo.toml

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ tokio.workspace = true
1616
async-trait.workspace = true
1717

1818
# Per-OS power / metered-network / sleep-wake backends (DESIGN s5.7,
19-
# s5.10.1). Declared now so the M3 implement phase has them; features are
20-
# kept minimal here (the impl widens them to the specific Win32 / DBus
21-
# modules it calls) to keep `cargo build --workspace` light.
19+
# s5.10.1). Features are kept minimal (the impl widens them only to the
20+
# specific Win32 / DBus / Network.framework modules it calls) to keep
21+
# `cargo build --workspace` light.
2222
[target.'cfg(windows)'.dependencies]
2323
# GetSystemPowerStatus (Win32_System_Power) for AC/battery (DESIGN s5.7).
2424
# Win32_Foundation pulls the shared BOOL/handle types its signature uses.
@@ -35,15 +35,21 @@ windows = { version = "0.62", features = [
3535
] }
3636

3737
[target.'cfg(target_os = "linux")'.dependencies]
38-
# Battery/AC is read from /sys/class/power_supply with std only. The
39-
# systemd-logind PrepareForSleep DBus signal + NetworkManager Metered /
40-
# Connectivity are TODO seams (linux.rs / network.rs); zbus is declared now
41-
# for when those DBus hooks land (DESIGN s5.7, s5.10.1).
38+
# Battery/AC is read from /sys/class/power_supply with std only. zbus backs the
39+
# real metered-network read in network.rs: the aggregate `Metered` property on
40+
# org.freedesktop.NetworkManager over the system bus, via the default
41+
# (async-io) blocking API dispatched through tokio's spawn_blocking (DESIGN
42+
# s5.7). The systemd-logind PrepareForSleep sleep/wake signal remains a TODO
43+
# seam (linux.rs, s5.10.1).
4244
zbus = "5"
4345

4446
[target.'cfg(target_os = "macos")'.dependencies]
4547
# IOKit IOPowerSources for AC/battery, read via CoreFoundation types
46-
# (DESIGN s5.7). NSWorkspace sleep/wake bridging via objc2 is a TODO seam
47-
# in macos.rs; objc2 is declared now for when that hook lands (s5.10.1).
48+
# (DESIGN s5.7). block2 backs the real metered-network read in network.rs: an
49+
# NWPathMonitor whose update handler (an Objective-C block) caches
50+
# nw_path_is_expensive / nw_path_is_constrained (DESIGN s5.7's NWPath.isExpensive
51+
# proxy). NSWorkspace sleep/wake bridging via objc2 is a TODO seam in macos.rs;
52+
# objc2 is declared now for when that hook lands (s5.10.1).
4853
core-foundation = "0.10"
4954
objc2 = "0.6"
55+
block2 = "0.6"

crates/driven-power/src/linux.rs

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use async_trait::async_trait;
2323
use tokio::sync::broadcast;
2424
use tokio::sync::Mutex;
2525

26-
use crate::network::detect_metered_and_reachable;
26+
use crate::network::{detect_metered_blocking, reachable_hint, MeteredStatus};
2727
use crate::{PowerSource, PowerState};
2828

2929
/// Poll cadence for the sysfs power read (DESIGN s5.7).
@@ -32,6 +32,13 @@ const POLL_INTERVAL: Duration = Duration::from_secs(30);
3232
/// Broadcast channel capacity (transitions are rare).
3333
const BROADCAST_CAPACITY: usize = 16;
3434

35+
/// Upper bound on the per-poll NetworkManager D-Bus read. It runs on a blocking
36+
/// thread (via [`tokio::task::spawn_blocking`]) so it never parks a tokio
37+
/// worker, but this cap guarantees a wedged system bus can never stall the
38+
/// AC/battery poll for more than this: on timeout the metered value falls back
39+
/// to [`MeteredStatus::Unknown`] (not metered) and the poll proceeds.
40+
const METERED_READ_TIMEOUT: Duration = Duration::from_secs(5);
41+
3542
/// sysfs root for power-supply devices. A constant so tests can reason
3643
/// about the layout; the reader takes the root as a parameter.
3744
const POWER_SUPPLY_ROOT: &str = "/sys/class/power_supply";
@@ -47,8 +54,13 @@ impl RealPowerSource {
4754
/// Builds the source with an initial snapshot read synchronously from
4855
/// sysfs. Never fails: a host with no power-supply devices (server /
4956
/// container) resolves to "on AC, no battery".
57+
///
58+
/// The initial snapshot reports metered as [`MeteredStatus::Unknown`] (not
59+
/// metered): the NetworkManager read is a blocking D-Bus call that cannot be
60+
/// awaited here, so the first real metered verdict lands on the first poll
61+
/// tick. The safe default in the meantime never wrongly stalls sync.
5062
pub fn new() -> anyhow::Result<Self> {
51-
let initial = read_power_state();
63+
let initial = read_power_state(MeteredStatus::Unknown);
5264
let (tx, _rx) = broadcast::channel(BROADCAST_CAPACITY);
5365
Ok(Self {
5466
latest: Arc::new(Mutex::new(initial)),
@@ -69,7 +81,8 @@ impl RealPowerSource {
6981
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
7082
loop {
7183
ticker.tick().await;
72-
let next = read_power_state();
84+
let metered = read_metered().await;
85+
let next = read_power_state(metered);
7386
let mut guard = latest.lock().await;
7487
if *guard != next {
7588
tracing::debug!(?next, "power state transition");
@@ -81,6 +94,27 @@ impl RealPowerSource {
8194
}
8295
}
8396

97+
/// Reads NetworkManager's metered state off the async executor, bounded by
98+
/// [`METERED_READ_TIMEOUT`]. The blocking D-Bus read runs on a
99+
/// [`tokio::task::spawn_blocking`] thread (zbus's blocking API must not be
100+
/// driven from within an async runtime); a join error or timeout resolves to
101+
/// [`MeteredStatus::Unknown`] (not metered) so the AC/battery poll is never
102+
/// blocked by a wedged bus.
103+
async fn read_metered() -> MeteredStatus {
104+
let read = tokio::task::spawn_blocking(detect_metered_blocking);
105+
match tokio::time::timeout(METERED_READ_TIMEOUT, read).await {
106+
Ok(Ok(status)) => status,
107+
Ok(Err(join_err)) => {
108+
tracing::warn!(error = %join_err, "metered read task failed; treating as unknown");
109+
MeteredStatus::Unknown
110+
}
111+
Err(_elapsed) => {
112+
tracing::warn!("metered read timed out; treating as unknown");
113+
MeteredStatus::Unknown
114+
}
115+
}
116+
}
117+
84118
#[async_trait]
85119
impl PowerSource for RealPowerSource {
86120
async fn current(&self) -> PowerState {
@@ -92,15 +126,17 @@ impl PowerSource for RealPowerSource {
92126
}
93127
}
94128

95-
/// Reads a full [`PowerState`] snapshot.
96-
fn read_power_state() -> PowerState {
129+
/// Reads a full [`PowerState`] snapshot. AC / battery come from sysfs; the
130+
/// `metered` verdict is supplied by the caller (read separately off the async
131+
/// executor via [`read_metered`], or [`MeteredStatus::Unknown`] for the initial
132+
/// synchronous snapshot).
133+
fn read_power_state(metered: MeteredStatus) -> PowerState {
97134
let (ac_connected, battery_percent) = read_ac_and_battery(Path::new(POWER_SUPPLY_ROOT));
98-
let (on_metered_network, network_reachable) = detect_metered_and_reachable();
99135
PowerState {
100136
ac_connected,
101137
battery_percent,
102-
on_metered_network,
103-
network_reachable,
138+
on_metered_network: metered.on_metered(),
139+
network_reachable: reachable_hint(),
104140
}
105141
}
106142

crates/driven-power/src/macos.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use async_trait::async_trait;
2828
use tokio::sync::broadcast;
2929
use tokio::sync::Mutex;
3030

31-
use crate::network::detect_metered_and_reachable;
31+
use crate::network::{reachable_hint, MacosMeteredMonitor, MeteredStatus};
3232
use crate::{PowerSource, PowerState};
3333

3434
/// Poll cadence for the OS power query (DESIGN s5.7).
@@ -56,18 +56,29 @@ type CFTypeRefRaw = *const c_void;
5656
pub struct RealPowerSource {
5757
latest: Arc<Mutex<PowerState>>,
5858
tx: broadcast::Sender<PowerState>,
59+
/// Live `NWPathMonitor` caching the active path's metered proxy
60+
/// (`isExpensive` / `isConstrained`). Cloneable (shares an `Arc<AtomicU8>`)
61+
/// so the poll loop reads the latest verdict cheaply each tick.
62+
metered: MacosMeteredMonitor,
5963
}
6064

6165
impl RealPowerSource {
6266
/// Builds the source with an initial snapshot read synchronously from
6367
/// IOKit. Never fails: a host with no battery / unreadable IOPS info
6468
/// resolves to "on AC, no battery".
69+
///
70+
/// Starts the `NWPathMonitor` here; its first path arrives asynchronously,
71+
/// so the initial snapshot may read metered as [`MeteredStatus::Unknown`]
72+
/// (not metered) until the first update lands - a safe default that never
73+
/// wrongly stalls sync.
6574
pub fn new() -> anyhow::Result<Self> {
66-
let initial = read_power_state();
75+
let metered = MacosMeteredMonitor::start();
76+
let initial = read_power_state(metered.status());
6777
let (tx, _rx) = broadcast::channel(BROADCAST_CAPACITY);
6878
Ok(Self {
6979
latest: Arc::new(Mutex::new(initial)),
7080
tx,
81+
metered,
7182
})
7283
}
7384

@@ -79,12 +90,13 @@ impl RealPowerSource {
7990
pub fn spawn_poller(&self) -> tokio::task::JoinHandle<()> {
8091
let latest = Arc::clone(&self.latest);
8192
let tx = self.tx.clone();
93+
let metered = self.metered.clone();
8294
tokio::spawn(async move {
8395
let mut ticker = tokio::time::interval(POLL_INTERVAL);
8496
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
8597
loop {
8698
ticker.tick().await;
87-
let next = read_power_state();
99+
let next = read_power_state(metered.status());
88100
let mut guard = latest.lock().await;
89101
if *guard != next {
90102
tracing::debug!(?next, "power state transition");
@@ -107,15 +119,15 @@ impl PowerSource for RealPowerSource {
107119
}
108120
}
109121

110-
/// Reads a full [`PowerState`] snapshot.
111-
fn read_power_state() -> PowerState {
122+
/// Reads a full [`PowerState`] snapshot. AC / battery come from IOKit; the
123+
/// `metered` verdict is the caller-supplied cached `NWPathMonitor` reading.
124+
fn read_power_state(metered: MeteredStatus) -> PowerState {
112125
let (ac_connected, battery_percent) = read_ac_and_battery();
113-
let (on_metered_network, network_reachable) = detect_metered_and_reachable();
114126
PowerState {
115127
ac_connected,
116128
battery_percent,
117-
on_metered_network,
118-
network_reachable,
129+
on_metered_network: metered.on_metered(),
130+
network_reachable: reachable_hint(),
119131
}
120132
}
121133

0 commit comments

Comments
 (0)