fix: v0.7.3 — disable EDM scanner, bootstrap batching, stale-anchor prune

Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with
v0.7.0/v0.7.1/v0.7.2; no protocol changes.

EDM port scanner DISABLED
- hole_punch_with_scanning() now does only single quick punch + parallel
  punch over 30s window. The EDM port-scanner branch is gone from the
  live path because per-probe endpoint.connect() amplifies catastrophically:
  iroh accumulates every connect() target into a per-endpoint paths set
  and probes them all under QUIC NAT-traversal in the background. A
  100-probes/sec / 5-min scan inserted ~30k paths; iroh probed all of
  them. Observed at 22MB/s outbound from one client — DoS-grade.
- Scanner body preserved as edm_port_scan_disabled_v0_7_3() with all
  supporting helpers (PortWalkIter, scanner_semaphore, role-based
  scanner/puncher split, found_tx/found_rx channel pattern,
  deadline + tokio::select! orchestration) marked #[allow(dead_code)].
  Refactor target: replace per-probe endpoint.connect() with raw
  socket.send_to() so probes don't enter iroh's path store.

Bootstrap probing batched
- New probe_anchors_batched() helper: 3 anchors in flight at a time,
  2s stagger between batch dispatches, 10s per-anchor timeout, no abort
  on success. First success unblocks the bootstrap flow; remaining
  probes continue in background and fill peer connections naturally.
- Phase 2 (bootstrap fallback) still only fires when every discovered
  anchor failed — preserves load-distribution intent. Replaces the
  sequential 50s+ timeout cascade users observed with old data dirs.

Stale-anchor self-pruning
- New storage.get_known_anchor_last_seen() and storage.delete_known_anchor().
- maybe_prune_stale_anchor(): when a probe fails AND last_seen_ms > 3 days,
  delete the entry from known_anchors immediately. Recoverable anchors
  (failed once, succeeded recently) are preserved. Self-healing for old
  data dirs whose discovered anchors point to keypairs that rotated
  months ago.

Android close button kills NodeService
- New NodeService.stopFromNative() Kotlin static method called via JNI
  from android_wifi::stop_node_service(). exit_app invokes it on Android
  before app.exit(0). Previously the button ended the Activity but the
  foreground service kept networking running.

Cosmetic
- Power-icon SVG (inline) replaces ⏻ so Android webviews lacking
  U+23FB don't render a missing-image tofu box.

Docs
- design.html section 11 rewritten for portmapper (UPnP+NAT-PMP+PCP,
  v0.7.2) including per-platform contract and bidirectional anchor
  watcher.
- design.html section 10 marks session relay as opt-in (v0.7.2) and EDM
  scanner as disabled-pending-refactor (v0.7.3).
- download.html carries v0.7.3 release notes.
- MEMORY.md updated; older v0.7.0/v0.7.1 status sections condensed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-15 14:33:45 -06:00
parent 4706e81603
commit 6ef11fa61c
14 changed files with 425 additions and 73 deletions

View file

@ -92,6 +92,175 @@ async fn ensure_initial_v_me(
generate_and_store_initial_v_me(&s, persona_id, now_ms)
}
/// Probe a list of anchors with batched parallelism, returning the first
/// successful NodeId. Remaining probes continue in background tasks after
/// first success and naturally register additional mesh connections.
///
/// **Parameters fixed in v0.7.3:**
/// - 3 anchors in flight at a time
/// - 2-second stagger between batch dispatches
/// - 10s per-anchor connect timeout
/// - Failed probes to anchors with `last_seen_ms` older than 3 days
/// auto-delete from `known_anchors` (self-healing pruning)
///
/// Returns `None` only when every probe completed without success.
async fn probe_anchors_batched(
anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)>,
network: Arc<crate::network::Network>,
storage: Arc<StoragePool>,
self_node_id: NodeId,
label: &'static str,
) -> Option<NodeId> {
use std::sync::atomic::{AtomicUsize, Ordering};
const BATCH_SIZE: usize = 3;
const BATCH_STAGGER_SECS: u64 = 2;
const PER_ANCHOR_TIMEOUT_SECS: u64 = 10;
const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000;
let total = anchors.len();
if total == 0 {
return None;
}
let (success_tx, success_rx) = tokio::sync::oneshot::channel::<NodeId>();
let success_tx = Arc::new(tokio::sync::Mutex::new(Some(success_tx)));
let completed = Arc::new(AtomicUsize::new(0));
let all_done = Arc::new(tokio::sync::Notify::new());
// Dispatcher: spawns per-anchor tasks in batches of BATCH_SIZE,
// sleeping BATCH_STAGGER_SECS between batches. The per-anchor tasks
// continue running after the dispatcher exits.
let dispatcher = {
let network = Arc::clone(&network);
let storage = Arc::clone(&storage);
let success_tx = Arc::clone(&success_tx);
let completed = Arc::clone(&completed);
let all_done = Arc::clone(&all_done);
tokio::spawn(async move {
let mut iter = anchors.into_iter();
loop {
let batch: Vec<_> = (&mut iter).take(BATCH_SIZE).collect();
if batch.is_empty() {
break;
}
let more = iter.size_hint().0 > 0;
for (nid, addrs) in batch {
let network = Arc::clone(&network);
let storage = Arc::clone(&storage);
let success_tx = Arc::clone(&success_tx);
let completed = Arc::clone(&completed);
let all_done = Arc::clone(&all_done);
tokio::spawn(async move {
let result = probe_one_anchor(&network, &storage, nid, addrs, self_node_id, label).await;
if let Some(nid) = result {
let mut guard = success_tx.lock().await;
if let Some(sender) = guard.take() {
let _ = sender.send(nid);
}
}
let prev = completed.fetch_add(1, Ordering::SeqCst);
if prev + 1 == total {
all_done.notify_one();
}
});
}
if more {
tokio::time::sleep(std::time::Duration::from_secs(BATCH_STAGGER_SECS)).await;
}
}
})
};
// Race: first success vs all probes complete unsuccessfully.
let result = tokio::select! {
Ok(nid) = success_rx => Some(nid),
_ = all_done.notified() => None,
};
// Detach the dispatcher; in-flight per-anchor tasks continue.
drop(dispatcher);
let _ = BATCH_STAGGER_SECS; // silence unused-const if compiler is picky
let _ = PER_ANCHOR_TIMEOUT_SECS;
let _ = STALE_THRESHOLD_MS;
result
}
async fn probe_one_anchor(
network: &crate::network::Network,
storage: &Arc<StoragePool>,
nid: NodeId,
addrs: Vec<std::net::SocketAddr>,
self_node_id: NodeId,
label: &'static str,
) -> Option<NodeId> {
const PER_ANCHOR_TIMEOUT_SECS: u64 = 10;
const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000;
if nid == self_node_id || network.is_peer_connected_or_session(&nid).await {
return None;
}
let endpoint_id = match iroh::EndpointId::from_bytes(&nid) {
Ok(eid) => eid,
Err(_) => return None,
};
let mut addr = iroh::EndpointAddr::from(endpoint_id);
for sa in &addrs {
addr = addr.with_ip_addr(*sa);
}
info!(peer = hex::encode(&nid), label, "Trying anchor");
let result = tokio::time::timeout(
std::time::Duration::from_secs(PER_ANCHOR_TIMEOUT_SECS),
network.connect_to_anchor(nid, addr),
).await;
match result {
Ok(Ok(())) => {
info!(peer = hex::encode(&nid), label, "Connected to anchor");
Some(nid)
}
Ok(Err(e)) => {
debug!(error = %e, peer = hex::encode(&nid), label, "Anchor connect failed");
maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await;
None
}
Err(_) => {
debug!(peer = hex::encode(&nid), label, "Anchor connect timed out");
maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await;
None
}
}
}
/// If the anchor's last successful contact was more than `threshold_ms`
/// ago, delete it from `known_anchors`. Future startups won't waste a
/// probe slot on it. Anchors that were recently successful are preserved
/// even when they fail a single probe (likely transient).
async fn maybe_prune_stale_anchor(
storage: &Arc<StoragePool>,
nid: &NodeId,
threshold_ms: u64,
) {
let s = storage.get().await;
let last_seen_ms = match s.get_known_anchor_last_seen(nid) {
Ok(Some(ms)) => ms,
_ => return,
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
if now_ms > last_seen_ms && now_ms - last_seen_ms > threshold_ms {
let _ = s.delete_known_anchor(nid);
debug!(
peer = hex::encode(nid),
age_ms = now_ms - last_seen_ms,
"Pruned stale anchor (>3 days since last success + failed probe)"
);
}
}
impl Node {
/// Create or open a node in the given data directory (Desktop profile)
pub async fn open(data_dir: impl AsRef<Path>) -> anyhow::Result<Self> {
@ -272,6 +441,11 @@ impl Node {
/// Bootstrap: connect to anchors, pull initial data, NAT probe, referrals.
/// Can be called during open_with_bind (blocking startup) or deferred to background.
///
/// v0.7.3: anchor probing is batched (3 in flight, 2s stagger between batches,
/// 10s per-anchor timeout, first success unblocks downstream, remaining probes
/// continue in background and naturally fill peer connections). Failed probes
/// to anchors >3 days stale auto-prune from `known_anchors`.
pub async fn run_bootstrap(&self, data_dir: &Path) -> anyhow::Result<()> {
let storage = &self.storage;
let network = &self.network;
@ -479,57 +653,28 @@ impl Node {
let (discovered, bootstrap_known): (Vec<_>, Vec<_>) = known.into_iter()
.partition(|(nid, _)| !bootstrap_anchor_ids.contains(nid));
// Phase 1: Try discovered (non-bootstrap) anchors first
let mut connected_anchor = None;
for (anchor_nid, anchor_addrs) in &discovered {
if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await {
continue;
}
let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) {
Ok(eid) => eid,
Err(_) => continue,
};
let mut addr = iroh::EndpointAddr::from(endpoint_id);
for sa in anchor_addrs {
addr = addr.with_ip_addr(*sa);
}
info!(peer = hex::encode(anchor_nid), "Trying discovered anchor");
match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await {
Ok(Ok(())) => {
info!(peer = hex::encode(anchor_nid), "Connected to discovered anchor");
connected_anchor = Some(*anchor_nid);
break;
}
Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Discovered anchor: connect failed"),
Err(_) => debug!(peer = hex::encode(anchor_nid), "Discovered anchor: connect timed out"),
}
}
// Phase 1: probe discovered (non-bootstrap) anchors in batches.
// First success returns immediately; remaining probes continue in
// background. Failed probes to anchors >3 days stale auto-prune.
let mut connected_anchor = probe_anchors_batched(
discovered.clone(),
network.clone(),
Arc::clone(storage),
node_id,
"discovered",
).await;
// Phase 2: Fall back to bootstrap anchors only if no discovered anchor worked
// Phase 2: bootstrap anchors as fallback — only fires if every
// Phase 1 entry failed. Preserves the load-distribution intent
// (don't smash the central anchor when discovered anchors work).
if connected_anchor.is_none() {
for (anchor_nid, anchor_addrs) in &bootstrap_known {
if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await {
continue;
}
let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) {
Ok(eid) => eid,
Err(_) => continue,
};
let mut addr = iroh::EndpointAddr::from(endpoint_id);
for sa in anchor_addrs {
addr = addr.with_ip_addr(*sa);
}
info!(peer = hex::encode(anchor_nid), "Trying bootstrap anchor (fallback)");
match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await {
Ok(Ok(())) => {
info!(peer = hex::encode(anchor_nid), "Connected to bootstrap anchor");
connected_anchor = Some(*anchor_nid);
break;
}
Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Bootstrap anchor: connect failed"),
Err(_) => debug!(peer = hex::encode(anchor_nid), "Bootstrap anchor: connect timed out"),
}
}
connected_anchor = probe_anchors_batched(
bootstrap_known.clone(),
network.clone(),
Arc::clone(storage),
node_id,
"bootstrap",
).await;
}
// Phase 3: NAT probe + referrals from whichever anchor we connected to