fix(async): finish the blocking-call sweep — scan, 3 SSH handlers, DNS

A codebase sweep for siblings of e282c059 (blocking network I/O parked
on the tokio runtime) found the openwrt fix was incomplete:

- openwrt.scan: scan_subnet is async in name only — up to 255 SEQUENTIAL
  blocking TCP probes at 500ms each (~2 min on a /24 that silently
  drops) plus a blocking SSH verify per candidate. One click of 'scan
  for routers' held a worker for that whole time. Now spawn_blocking.
- provision-tollgate / scan-wifi / configure-wan still ran their SSH
  exchanges inline; bounded_tcp caps each socket op but a session is
  many sequential ops (provision runs opkg install over SSH), so worst
  case was minutes. All three now spawn_blocking.
- network::check_dns: blocking glibc to_socket_addrs with no app-level
  bound, on every Server-tab load via network.diagnostics. Against a
  stale resolver — the moved-network case — that is 5-40s per refresh.
  Now spawn_blocking plus a 5s cap, so the tile reports 'no DNS'
  instead of hanging.

Verified false positives left alone: every other bare TcpStream::connect
targets 127.0.0.1 (fails instantly), and every remote reqwest client
already sets a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-15 11:03:08 -04:00
co-authored by Claude Fable 5
parent 6e89acced7
commit 203c2b6e50
2 changed files with 70 additions and 14 deletions
+49 -8
View File
@@ -38,7 +38,16 @@ impl RpcHandler {
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await; // scan_subnet is `async` in name only: up to 255 SEQUENTIAL blocking
// TCP probes at 500ms each (~2 min on a /24 that silently drops),
// plus a blocking SSH verify per candidate. Inline, one click of
// "scan for routers" held a tokio worker for that whole time.
let routers = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current()
.block_on(detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password))
})
.await
.context("openwrt scan task")?;
let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect(); let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect();
Ok(serde_json::json!({ "routers": ips })) Ok(serde_json::json!({ "routers": ips }))
@@ -245,9 +254,21 @@ impl RpcHandler {
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true), enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
}; };
// Blocking SSH session, and provision runs `opkg install` over it —
// minutes of held worker if the router stalls mid-exchange.
{
let host = host.clone();
let ssh_user = ssh_user.clone();
let ssh_password = ssh_password.clone();
let config = config.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?; router.verify_openwrt()?;
tollgate::provision(&router, &config).await?; tokio::runtime::Handle::current().block_on(tollgate::provision(&router, &config))
})
.await
.context("openwrt provision task")??;
}
Ok(serde_json::json!({ Ok(serde_json::json!({
"ok": true, "ok": true,
@@ -296,10 +317,20 @@ impl RpcHandler {
.or_else(|| saved.password.clone()) .or_else(|| saved.password.clone())
.unwrap_or_default(); .unwrap_or_default();
// A radio scan is seconds of SSH round-trips even on a healthy
// router; keep it off the runtime.
let networks = {
let host = host.clone();
let ssh_user = ssh_user.clone();
let ssh_password = ssh_password.clone();
tokio::task::spawn_blocking(move || -> Result<Vec<wifi_scan::ScannedNetwork>> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?; let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?; router.verify_openwrt()?;
wifi_scan::scan_networks(&router)
let networks = wifi_scan::scan_networks(&router)?; })
.await
.context("openwrt wifi scan task")??
};
let result: Vec<serde_json::Value> = networks let result: Vec<serde_json::Value> = networks
.iter() .iter()
.map(|n| { .map(|n| {
@@ -374,9 +405,6 @@ impl RpcHandler {
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32; let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true); let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let config = wan::WispConfig { let config = wan::WispConfig {
ssid: ssid.clone(), ssid: ssid.clone(),
password, password,
@@ -385,7 +413,20 @@ impl RpcHandler {
dhcp_limit, dhcp_limit,
masq, masq,
}; };
wan::configure_wisp(&router, &config)?; // Reconfiguring WAN drops and re-establishes the router's uplink, so
// the SSH exchange can stall for its full timeout budget mid-command.
{
let host = host.clone();
let ssh_user = ssh_user.clone();
let ssh_password = ssh_password.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
wan::configure_wisp(&router, &config)
})
.await
.context("openwrt configure-wan task")??;
}
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid })) Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
} }
+15
View File
@@ -302,9 +302,24 @@ async fn check_tor_connectivity() -> bool {
} }
/// Check DNS resolution works. /// Check DNS resolution works.
///
/// `to_socket_addrs` is blocking glibc resolution with no app-level bound:
/// against a dead or stale resolver — the moved-network case — it can block
/// 540s (timeout × attempts × nameservers). This runs on every Server-tab
/// load via `network.diagnostics`, so inline it parked a tokio worker each
/// refresh. Off the runtime, and bounded so the tile reports "no DNS"
/// instead of hanging.
async fn check_dns() -> bool { async fn check_dns() -> bool {
let probe = tokio::task::spawn_blocking(|| {
use std::net::ToSocketAddrs; use std::net::ToSocketAddrs;
"cloudflare.com:443".to_socket_addrs().is_ok() "cloudflare.com:443".to_socket_addrs().is_ok()
});
match tokio::time::timeout(std::time::Duration::from_secs(5), probe).await {
Ok(Ok(ok)) => ok,
// Timed out or the task failed: the blocking resolve may still be
// running on the pool, but the caller is no longer waiting on it.
_ => false,
}
} }
// --- Router Compatibility Abstraction --- // --- Router Compatibility Abstraction ---