Skip to content

Commit 319e85f

Browse files
pmaxhoganclaude
andauthored
feat(net): native OS reachability backends with automatic fallback (#138)
Adds native per-OS network reachability backends for the DESIGN s5.8.2 probe-1 (OS connectivity) check in `driven-net`, tried before the existing generic TCP probe with transparent runtime fallback. Refs #34. Until now `Backend::os_online` was a plain TCP connect to anycast DNS, with the native OS APIs explicitly deferred (`crates/driven-net/src/lib.rs`). This wires them in, mirroring `driven-power`'s cfg-gated per-OS module + pure-classifier precedent. No public API change: `ReqwestBackend::new()` and the `driven_core::network::Backend` trait are unchanged, so no caller was touched. ## Per-OS backends (new `crates/driven-net/src/reachability.rs`) - **Windows:** `INetworkListManager::GetConnectivity` via the `windows` crate COM (`CoCreateInstance` after `CoInitializeEx`, balanced by an RAII `CoUninitialize` guard, `RPC_E_CHANGED_MODE` tolerated) - same COM discipline as `driven-power`'s metered read. The `NLM_CONNECTIVITY` bitmask is classified by the pure `classify_nlm_connectivity`. - **Linux:** NetworkManager's aggregate `Connectivity` property on `org.freedesktop.NetworkManager` over the system bus, read via zbus's BLOCKING API on a `spawn_blocking` thread. NetworkManager / bus absent -> read fails -> `Unknown` -> TCP fallback (handled gracefully). Classified by `classify_nm_connectivity`. - **macOS:** a process-lifetime `NWPathMonitor` whose update handler caches `nw_path_get_status` in an `AtomicU8` (declared via `extern "C"` + `#[link(name = "Network", kind = "framework")]`, block from `block2`). Classified by `classify_nw_path_status`. The native result feeds the SAME transport-agnostic `classify.rs` pipeline unchanged - only probe 1 (`os_online`) gains a native source; probes 2-3 (captive + service) stay reqwest-based. ## Selection + fallback semantics `os_online` consults `detect_reachability()` (compile-time-selected per OS) FIRST, resolved by the pure `resolve_native`: - **Online** (any active connection - internet, link-local, limited, portal) -> `true`, proceed to captive + service probes. - **Offline** (confident no active connection - airplane mode / rfkill / no interface) -> `false`, short-circuit to `NetworkState::Offline`. - **Unknown** (API unavailable, ambiguous verdict, not-yet-delivered NWPath, or a target with no native backend) -> transparent fallback to the existing TCP probe. Logged once at debug level, never per-call. **Deliberate direction (never Offline on a connected link):** only a confident no-connection verdict maps to `Offline`; every ambiguity collapses to `Unknown` -> TCP fallback, never a wrong `Offline`. This protects the distinct CaptivePortal (drives the "Sign in to network" tray action) and NoInternet (30s re-probe) states from being hidden behind Offline's "re-probe only on interface-up" path. **Behavior note (Windows, positive change):** a connected-but-no-Internet or captive-portal machine reports `IPV4_LOCALNETWORK` with no internet bit (NOT `DISCONNECTED`). The old TCP probe rendered that as Offline; the native read now maps it to `Online`, so the HTTP probes classify it precisely as NoInternet / CaptivePortal. Verified locally on this Windows host: normal network -> Online. ## Dependency additions (per-OS, minimal - justification) - Windows: `windows = "0.62"` with `Win32_Foundation`, `Win32_System_Com`, `Win32_Networking_NetworkListManager`. Same crate + version already used by `driven-power`; only the NLM + COM surface is enabled. - Linux: `zbus = "5"` (`tokio` feature). Same crate + version + feature set `driven-power` already resolves, so the workspace keeps ONE zbus version and no new transitive tree; chosen over a raw D-Bus binding because it is already in the tree and gives a typed `#[proxy]` for the one property read. - macOS: `block2 = "0.6"` only (the Network.framework + libdispatch C functions are hand-declared `extern "C"`, so no `core-foundation`/`objc2` needed for this read). ## Tests - Pure selection/fallback + classifier logic (`resolve_native`, `classify_nlm_connectivity`, `classify_nm_connectivity`, `classify_nw_path_status`, `Reachability::from_u8`) - compiled and run on EVERY OS, including the Windows-regression guard that a local-only NLM mask maps to Online, not Offline. - Per-OS native-read smoke tests gated like `driven-power` (compile everywhere, run natively): `windows_read_connectivity_is_total` exercises the live NLM COM read; Linux/macOS equivalents run on their CI legs. - `os_online_is_total` exercises the whole probe-1 path (native read + fallback) end to end on this Windows host. - Existing `classify.rs` + DNS/topology tests unchanged and still green. ## Gates Run locally on this Windows host, all green: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings` (the `--all-targets` superset also type-checks every target, covering `cargo check --workspace`), `cargo check -p driven-net --locked` (lockfile consistent post-rebase), `cargo test -p driven-net` (23 passed). The Windows leg exercises the live NLM COM read; the Linux + macOS backends are compile-everywhere and verified on their CI legs (they cannot run on this Windows host). LF, no em-dashes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QZQVP2tUuTLh8oL31D8heC Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent dbd4809 commit 319e85f

4 files changed

Lines changed: 741 additions & 51 deletions

File tree

Cargo.lock

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

crates/driven-net/Cargo.toml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,37 @@ futures.workspace = true
2626
# s5.8.5 escalation and is not wired in V1.
2727
reqwest.workspace = true
2828

29+
# Native per-OS reachability backends for the DESIGN s5.8.2 probe-1 read
30+
# (reachability.rs), tried before the generic anycast-DNS TCP probe. Features
31+
# are kept minimal (only the Win32 / D-Bus / Network.framework surface the reads
32+
# call) to keep `cargo build --workspace` light, mirroring driven-power.
33+
[target.'cfg(windows)'.dependencies]
34+
# INetworkListManager::GetConnectivity, instantiated via CoCreateInstance after
35+
# CoInitializeEx (Win32_System_Com), classified in reachability.rs. Win32_Foundation
36+
# pulls the shared handle/HRESULT types the COM signatures use.
37+
windows = { version = "0.62", features = [
38+
"Win32_Foundation",
39+
"Win32_System_Com",
40+
"Win32_Networking_NetworkListManager",
41+
] }
42+
43+
[target.'cfg(target_os = "linux")'.dependencies]
44+
# NetworkManager's aggregate `Connectivity` property on org.freedesktop.NetworkManager
45+
# over the system bus, read via zbus's BLOCKING API dispatched through
46+
# tokio::spawn_blocking (reachability.rs). Same crate + `tokio` feature as
47+
# driven-power so the workspace resolves ONE zbus version; the default features
48+
# (`async-io` + `blocking-api`) are kept because the blocking read needs
49+
# `blocking-api`.
50+
zbus = { version = "5", features = ["tokio"] }
51+
52+
[target.'cfg(target_os = "macos")'.dependencies]
53+
# NWPathMonitor whose update handler (an Objective-C block) caches
54+
# nw_path_get_status (reachability.rs). block2 supplies the block type; the
55+
# Network.framework and libdispatch C functions are declared via `extern "C"`
56+
# and linked with `#[link(name = "Network", kind = "framework")]`, so no
57+
# core-foundation / objc2 crates are needed for this read.
58+
block2 = "0.6"
59+
2960
[dev-dependencies]
3061
# `test-util` enables `#[tokio::test(start_paused = true)]` so the DNS-no-hang
3162
# acceptance test drives `tokio::time::timeout` on virtual time (no wall-clock

crates/driven-net/src/lib.rs

Lines changed: 105 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
//! stays I/O-free. This crate fills the [`Backend`] seam with the concrete
77
//! clients (DESIGN s5.8.2 probe topology, s5.8.4 per-service timeouts):
88
//!
9-
//! - [`Backend::os_online`] - a lightweight, honest reachability check
10-
//! (documented on the method): a fast dual-stack TCP connect to a
11-
//! well-known anycast resolver within the 3s OS-probe budget. It does NOT
12-
//! read `INetworkListManager` / `NWPathMonitor` / NetworkManager (a real
13-
//! OS-API integration is deferred); a hard connect failure short-circuits
14-
//! the topology to offline, and any softer ambiguity defers to the captive
15-
//! + service probes.
9+
//! - [`Backend::os_online`] - the DESIGN s5.8.2 probe-1 reachability check. It
10+
//! consults the NATIVE per-OS connectivity API FIRST
11+
//! (`INetworkListManager::GetConnectivity` on Windows, NetworkManager's
12+
//! `Connectivity` enum on Linux, `NWPathMonitor` on macOS - see
13+
//! [`reachability`]); a confident native verdict (online / offline) is used
14+
//! directly, and when the native read is unavailable or ambiguous it FALLS
15+
//! BACK transparently to a fast dual-stack TCP connect to a well-known anycast
16+
//! resolver within the 3s OS-probe budget. A hard offline verdict
17+
//! short-circuits the topology to offline; any softer ambiguity defers to the
18+
//! captive + service probes.
1619
//! - [`Backend::probe_captive`] - an HTTP GET to
1720
//! `http://www.gstatic.com/generate_204` via a redirect-disabled
1821
//! `reqwest::Client` with the 3s-connect / 5s-total captive-portal
@@ -35,8 +38,10 @@
3538
//! `.no_proxy()`, so the env vars are honoured.
3639
3740
mod classify;
41+
mod reachability;
3842

3943
use std::net::SocketAddr;
44+
use std::sync::atomic::{AtomicBool, Ordering};
4045
use std::sync::Mutex;
4146
use std::time::Duration;
4247

@@ -157,6 +162,11 @@ pub struct ReqwestBackend {
157162
drive: Mutex<reqwest::Client>,
158163
update_endpoint: Mutex<reqwest::Client>,
159164
github: Mutex<reqwest::Client>,
165+
/// Latches `true` the first time [`Backend::os_online`] falls back from the
166+
/// native reachability read to the TCP probe, so the fallback is logged at
167+
/// debug level exactly ONCE (not per-probe) on a host whose native API is
168+
/// permanently unavailable (e.g. no NetworkManager).
169+
native_fallback_logged: AtomicBool,
160170
}
161171

162172
impl ReqwestBackend {
@@ -183,6 +193,7 @@ impl ReqwestBackend {
183193
drive: Mutex::new(drive),
184194
update_endpoint: Mutex::new(update_endpoint),
185195
github: Mutex::new(github),
196+
native_fallback_logged: AtomicBool::new(false),
186197
})
187198
}
188199

@@ -212,6 +223,48 @@ impl ReqwestBackend {
212223
self.service_client(service).map(|m| Self::lock(m).clone())
213224
}
214225

226+
/// The TCP-probe fallback for [`Backend::os_online`]: a fast dual-stack TCP
227+
/// connect to a small set of well-known anycast DNS resolvers (Google
228+
/// `8.8.8.8` / Cloudflare `1.1.1.1`, both IPv4 and IPv6) within the 3s
229+
/// OS-probe budget.
230+
///
231+
/// If ANY connect succeeds, routing to the Internet exists -> `true`. If ALL
232+
/// fail (no route / refused / timeout on every endpoint and address family),
233+
/// there is no working route -> `false`. Endpoints span IPv4 + IPv6 so a
234+
/// single-stack outage does not read as fully offline (DESIGN s5.8.1 "No
235+
/// IPv4 / no IPv6" row). This is the transparent fallback used whenever the
236+
/// native OS connectivity read is unavailable or ambiguous.
237+
async fn tcp_os_online(&self) -> bool {
238+
// Try each endpoint with the 3s connect budget; the first success wins.
239+
for endpoint in OS_ONLINE_ENDPOINTS {
240+
let addr: SocketAddr = match endpoint.parse() {
241+
Ok(a) => a,
242+
// A malformed constant is a programming error, not a runtime
243+
// network condition; skip it rather than panic.
244+
Err(_) => continue,
245+
};
246+
match tokio::time::timeout(PROBE_CONNECT_TIMEOUT, tokio::net::TcpStream::connect(addr))
247+
.await
248+
{
249+
Ok(Ok(_stream)) => {
250+
tracing::trace!(target: TARGET, %addr, "tcp_os_online: reachable");
251+
return true;
252+
}
253+
Ok(Err(e)) => {
254+
tracing::trace!(target: TARGET, %addr, error = %e, "tcp_os_online: connect failed");
255+
}
256+
Err(_elapsed) => {
257+
tracing::trace!(target: TARGET, %addr, "tcp_os_online: connect timed out");
258+
}
259+
}
260+
}
261+
tracing::debug!(
262+
target: TARGET,
263+
"tcp_os_online: all reachability endpoints failed -> offline"
264+
);
265+
false
266+
}
267+
215268
/// Re-resolves `host` via `tokio::net::lookup_host` with no application-
216269
/// layer cache (DESIGN s5.8.1: re-resolve every call, never cache a failed
217270
/// resolve - `lookup_host` defers to the OS resolver, whose own
@@ -289,56 +342,42 @@ where
289342

290343
#[async_trait]
291344
impl Backend for ReqwestBackend {
292-
/// A lightweight, HONEST reachability check (DESIGN s5.8.2 probe 1).
345+
/// The DESIGN s5.8.2 probe-1 reachability check: native OS connectivity
346+
/// API first, TCP probe as the transparent fallback.
293347
///
294-
/// What this actually does: attempts a fast TCP connect to a small set of
295-
/// well-known anycast DNS resolvers (Google `8.8.8.8` / Cloudflare
296-
/// `1.1.1.1`, both IPv4 and IPv6) within the 3s OS-probe budget. If ANY
297-
/// connect succeeds, routing to the Internet exists -> `true`. If ALL
298-
/// fail (no route / refused / timeout on every endpoint and address
299-
/// family), there is no working route -> `false`, and the prober
300-
/// short-circuits to [`NetworkState::Offline`](driven_core::network::NetworkState)
301-
/// without firing the captive / service probes (DESIGN s5.8.2).
348+
/// Consults the native per-OS connectivity API
349+
/// ([`reachability::detect_reachability`]:
350+
/// `INetworkListManager::GetConnectivity` on Windows, NetworkManager's
351+
/// `Connectivity` enum on Linux, `NWPathMonitor` on macOS) FIRST. A
352+
/// confident native verdict is used directly - an active connection
353+
/// (internet, or link-local / limited / captive-portal) returns `true` so
354+
/// the captive + service probes (DESIGN s5.8.2 probes 2-3) classify Online
355+
/// vs NoInternet vs CaptivePortal; a confident no-connection verdict returns
356+
/// `false`, short-circuiting the topology to
357+
/// [`NetworkState::Offline`](driven_core::network::NetworkState) without
358+
/// firing them (airplane mode / rfkill / no interface, DESIGN s5.8.1).
302359
///
303-
/// What this deliberately does NOT do: it does NOT read the OS
304-
/// connectivity API (`INetworkListManager::IsConnectedToInternet` on
305-
/// Windows, `NWPathMonitor` on macOS, NetworkManager `Connectivity` on
306-
/// Linux). A native OS-API integration is deferred to a later phase; this
307-
/// V1 check is a real cheap probe, not a stub, and is documented as such
308-
/// so the topology's "cheapest first probe" remains honest. The downstream
309-
/// captive + service probes (DESIGN s5.8.2 probes 2-3) provide the
310-
/// authoritative classification when this returns `true`.
360+
/// When the native read is unavailable or ambiguous (no NetworkManager, a
361+
/// COM error, a not-yet-delivered `NWPath`, or a target with no native
362+
/// backend), it FALLS BACK transparently to [`Self::tcp_os_online`] - a fast
363+
/// dual-stack TCP connect to well-known anycast resolvers. The fallback is
364+
/// logged once at debug level, never per-call.
311365
async fn os_online(&self) -> bool {
312-
// Try each endpoint with the 3s connect budget; the first success
313-
// wins. Endpoints span IPv4 + IPv6 so a single-stack outage does not
314-
// read as fully offline (DESIGN s5.8.1 "No IPv4 / no IPv6" row).
315-
for endpoint in OS_ONLINE_ENDPOINTS {
316-
let addr: SocketAddr = match endpoint.parse() {
317-
Ok(a) => a,
318-
// A malformed constant is a programming error, not a runtime
319-
// network condition; skip it rather than panic.
320-
Err(_) => continue,
321-
};
322-
match tokio::time::timeout(PROBE_CONNECT_TIMEOUT, tokio::net::TcpStream::connect(addr))
323-
.await
324-
{
325-
Ok(Ok(_stream)) => {
326-
tracing::trace!(target: TARGET, %addr, "os_online: reachable");
327-
return true;
328-
}
329-
Ok(Err(e)) => {
330-
tracing::trace!(target: TARGET, %addr, error = %e, "os_online: connect failed");
331-
}
332-
Err(_elapsed) => {
333-
tracing::trace!(target: TARGET, %addr, "os_online: connect timed out");
366+
match reachability::resolve_native(reachability::detect_reachability().await) {
367+
Some(online) => online,
368+
None => {
369+
// Native read inconclusive (API unavailable or an ambiguous
370+
// verdict): fall back to the TCP probe. Log the switch once so a
371+
// host with no native API does not spam the log every probe.
372+
if !self.native_fallback_logged.swap(true, Ordering::Relaxed) {
373+
tracing::debug!(
374+
target: TARGET,
375+
"native reachability inconclusive, using TCP probe (logged once)"
376+
);
334377
}
378+
self.tcp_os_online().await
335379
}
336380
}
337-
tracing::debug!(
338-
target: TARGET,
339-
"os_online: all reachability endpoints failed -> offline"
340-
);
341-
false
342381
}
343382

344383
/// Runs the captive-portal `generate_204` probe (DESIGN s5.8.2 probe 2).
@@ -570,6 +609,21 @@ mod tests {
570609
assert!(backend.service_client(ServiceName::Github).is_some());
571610
}
572611

612+
// --- os_online is total: native read (+ TCP fallback) returns a bool ---
613+
//
614+
// Exercises the DESIGN s5.8.2 probe-1 path end to end: the native OS
615+
// reachability read (on this Windows host, the live
616+
// `INetworkListManager::GetConnectivity` COM call) resolved via
617+
// `resolve_native`, falling back to the TCP probe only on an inconclusive
618+
// verdict. The value is environment-dependent (a connected host yields
619+
// `true`), so we only assert the call is total and never panics across the
620+
// native/fallback boundary.
621+
#[tokio::test]
622+
async fn os_online_is_total() {
623+
let backend = ReqwestBackend::new().expect("construct backend");
624+
let _: bool = backend.os_online().await;
625+
}
626+
573627
// --- the os_online endpoint constants are well-formed + dual-stack ---
574628

575629
#[test]

0 commit comments

Comments
 (0)