feat!: bridge on DongLoRa Protocol v2 and async client

The 1.0 bridge running on the async Dongle API from donglora-client 1.0.0 and the Protocol v2 wire format.

* Radio loop is now a tokio task, not a dedicated std::thread. Everything is async end to end.

* [radio] config schema: tx_power renamed to tx_power_dbm (plain i8, no "max" keyword), cad field removed (CAD is a per-TX flag in v1.0), default sync_word changed 0x3444 to 0x1424 to match the SX126x register encoding of MeshCore private sync byte.

* TX retry with backoff (new [tx] config section). Every TX runs through Dongle::tx_with_retry, retrying TX_DONE(CHANNEL_BUSY) and ERR(EBUSY) per PROTOCOL.md §6.10 / §C.5.5. Defaults: 3 attempts, 20-100 ms randomized backoff, doubling to 500 ms cap, 5 s per-attempt timeout. Configurable via max_attempts, backoff_min_ms, backoff_max_ms, backoff_cap_ms, backoff_multiplier, per_attempt_timeout_secs.

* TUI packet log shows retry state: in-flight retries render with a cycle marker in yellow and an N/M attempt counter, terminal TX failures render in red, post-retry success shows final attempt count.

* Stats gain tx_retries and tx_failures counters surfaced in StatsSnapshot.

* Measured airtime from TX_DONE replaces the theoretical estimate, feeding the rate limiter with the real on-air duration.

* The explicit 2 s liveness ping loop is gone. The Dongle runs a 500 ms internal keepalive, matching spec §3.4 inactivity window with 2x margin.
This commit is contained in:
swaits committed 2026-04-22 09:48:01 -06:00
1 parent 030280791e
commit 0b9775c310
11 files changed
+1010 -669

No files matched your search

+96 -73
View File
@@ -22,7 +22,7 @@ use mainline::{Dht, MutableItem, SigningKey};
use sha2::{Digest, Sha256};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use tracing::{debug, error, info, warn};
use crate::packet::GossipFrame;
@@ -31,14 +31,27 @@ use crate::packet::GossipFrame;
/// Conservative cap to stay under BEP44's 1000-byte value limit.
const MAX_DHT_PEERS: usize = 27;
/// DHT heartbeat interval when connected (base, before jitter). 5 minutes
/// balances freshness against DHT write amplification — connected nodes already
/// have live gossip links and only need DHT for newcomer discovery.
const DHT_HEARTBEAT_SECS: u64 = 300;
/// DHT heartbeat interval (base, before jitter). Each bridge periodically
/// publishes `[self] + live_neighbors` as a blind overwrite. Any live bridge
/// publishing at this cadence guarantees that the DHT record is overwritten
/// with a fresh snapshot of a real, connected sub-swarm within this window —
/// so stale entries from crashed / restarted peers age out within one cycle.
const DHT_HEARTBEAT_SECS: u64 = 60;
/// Maximum random jitter added to heartbeat interval. Prevents synchronized
/// publish storms when multiple bridges start simultaneously.
const DHT_HEARTBEAT_JITTER_SECS: u64 = 60;
/// Maximum random jitter added to heartbeat. Prevents synchronized publish
/// storms when multiple bridges start simultaneously.
const DHT_HEARTBEAT_JITTER_SECS: u64 = 15;
/// DHT read interval while we have no neighbors. Stays aggressive so two
/// freshly-started bridges find each other quickly.
const DHT_LONELY_READ_SECS: u64 = 3;
/// How long to try dialing DHT-discovered peers before we give up and publish
/// ourselves alone. If we connect to at least one peer in this window we
/// publish `[self, neighbors...]` right away. If we don't, we publish `[self]`
/// so newcomers find us — but we delay this so we don't overwrite a
/// potentially useful DHT record with just our own lonely entry.
const DHT_BOOTSTRAP_GRACE_SECS: u64 = 10;
// ── Key derivation ───────────────────────────────────────────────
@@ -321,12 +334,23 @@ fn spawn_receiver(
cancel: CancellationToken,
) {
tokio::spawn(async move {
loop {
// Log a hard warning if the receiver stream ever ends — this is the
// "silent one-way gossip" failure mode. Track why it exited so the
// log tells us whether it was a stream-end (None), an error
// (Some(Err)), or a cancel.
let exit_reason: &'static str = loop {
let event = tokio::select! {
event = receiver.next() => event,
() = cancel.cancelled() => break,
() = cancel.cancelled() => break "cancelled",
};
let event = match event {
None => break "stream ended (receiver closed)",
Some(Err(e)) => {
error!("gossip receiver error: {e:#}");
break "receiver error";
}
Some(Ok(ev)) => ev,
};
let Some(Ok(event)) = event else { break };
match event {
Event::Received(msg) => {
if let Some(frame) = GossipFrame::decode(&msg.content) {
@@ -364,8 +388,15 @@ fn spawn_receiver(
warn!("gossip receiver lagged — some messages were dropped");
}
}
};
// A shutdown cancel is expected; any other exit means we silently
// stopped receiving gossip. That's the "one-way bridge" bug —
// broadcast still works because spawn_broadcaster is independent.
if exit_reason == "cancelled" {
debug!("gossip receiver stopped ({exit_reason})");
} else {
error!("gossip receiver STOPPED ({exit_reason}) — this bridge will no longer receive from the swarm",);
}
debug!("gossip receiver stopped");
});
}
@@ -391,24 +422,25 @@ fn spawn_broadcaster(
});
}
/// DHT poll interval while alone. 3 seconds is aggressive but reads are cheap
/// (single DHT GET) and fast discovery matters when two nodes start together.
const DHT_ALONE_READ_SECS: u64 = 3;
/// How often a lonely node publishes (merge-only, never destructive). 30 seconds
/// is much slower than reads because DHT PUT is heavier and a lonely node only
/// adds itself to the peer list.
const DHT_ALONE_PUBLISH_SECS: u64 = 30;
/// Periodically interact with the DHT to discover and share peers.
/// Periodically publish `[self] + live_neighbors` to the DHT and read it to
/// discover new peers.
///
/// Strategy:
/// - **Alone:** Read every 3s (cheap). Publish every 30s via merge — reads
/// existing data first, appends ourselves, writes back. Two fresh nodes
/// both doing this will eventually see each other. Never blindly overwrites.
/// - **Connected:** Read + publish on relaxed 5-min schedule. We have
/// valuable neighbor info to share.
/// - **Transition to connected:** Publish immediately.
/// Strategy — deliberately simple:
/// 1. **Startup**: read DHT, try to join discovered peers. Do NOT publish
/// yet — we don't know if any of them are still alive, and publishing
/// `[self]` alone would overwrite a potentially useful record.
/// 2. **Bootstrap grace** (10 s): keep reading fast (every
/// `DHT_LONELY_READ_SECS`) looking for peers to connect to. If a
/// `NeighborUp` fires inside this window, publish immediately with the
/// live neighbor set. If nothing shows up, publish `[self]` so anyone
/// else joining finds us.
/// 3. **Steady state**: publish `[self, neighbors...]` every
/// `DHT_HEARTBEAT_SECS` (+ jitter), a blind overwrite. Any live bridge
/// doing this naturally ages out crashed/stale peers from the DHT
/// record within one cycle.
/// 4. **Re-read cadence**: fast (`DHT_LONELY_READ_SECS`) while
/// `neighbor_count == 0`, relaxed (= heartbeat interval) once
/// connected.
#[allow(clippy::too_many_arguments)]
fn spawn_dht_heartbeat(
my_id: PublicKey,
@@ -430,27 +462,29 @@ fn spawn_dht_heartbeat(
};
let pub_key_bytes = signing_key.verifying_key().to_bytes();
let mut last_publish: Option<Instant> = None;
let mut was_alone = true;
let mut last_publish = Instant::now();
let started = Instant::now();
// Initial: read to discover existing peers, then merge-publish ourselves.
// Step 1: read the DHT to find peers to dial.
dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await;
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
loop {
let alone = neighbor_count.load(Ordering::Relaxed) == 0;
// Transition: alone → connected. Publish immediately.
// Transition: alone → connected. Publish immediately with live
// neighbors so anyone who was about to dial us-as-lonely finds
// the full picture.
if was_alone && !alone {
info!("first neighbor joined — publishing to DHT");
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
last_publish = Instant::now();
dht_publish(&dht, &my_id, &signing_key, &salt, &neighbors, &state).await;
last_publish = Some(Instant::now());
}
was_alone = alone;
// Sleep: fast reads while alone, relaxed when connected.
// Sleep: fast reads while lonely, relaxed once connected.
let sleep_dur = if alone {
Duration::from_secs(DHT_ALONE_READ_SECS)
Duration::from_secs(DHT_LONELY_READ_SECS)
} else {
let jitter = rand::random::<u64>() % DHT_HEARTBEAT_JITTER_SECS.max(1);
Duration::from_secs(DHT_HEARTBEAT_SECS + jitter)
@@ -461,18 +495,21 @@ fn spawn_dht_heartbeat(
() = cancel.cancelled() => break,
}
// Always read — cheap, discovers peers to join.
// Always read — cheap, keeps fresh peers coming in.
dht_read_and_join(&dht, &my_id, &pub_key_bytes, &salt, &gossip_sender, &state).await;
// Publish cadence depends on state.
let publish_interval = if alone {
Duration::from_secs(DHT_ALONE_PUBLISH_SECS)
} else {
Duration::from_secs(DHT_HEARTBEAT_SECS)
};
if last_publish.elapsed() >= publish_interval {
dht_publish(&dht, &my_id, &signing_key, &salt, &pub_key_bytes, &neighbors, &state).await;
last_publish = Instant::now();
// Steady-state: publish on every heartbeat tick once we've
// published at least once. Pre-first-publish, hold off until
// either a neighbor appears OR the bootstrap grace elapses —
// this avoids wiping a potentially-useful DHT record with
// just our lonely [self] entry on startup.
let should_publish = last_publish.map_or_else(
|| !alone || started.elapsed() >= Duration::from_secs(DHT_BOOTSTRAP_GRACE_SECS),
|t| t.elapsed() >= Duration::from_secs(DHT_HEARTBEAT_SECS),
);
if should_publish {
dht_publish(&dht, &my_id, &signing_key, &salt, &neighbors, &state).await;
last_publish = Some(Instant::now());
}
}
debug!("DHT heartbeat stopped");
@@ -503,44 +540,30 @@ async fn dht_read_and_join(
}
}
/// Publish `[self] + live_neighbors` to the DHT as a blind overwrite.
///
/// No merge with existing DHT content. Stale peers age out naturally
/// because every live bridge publishes a snapshot of its actual live
/// connections within `DHT_HEARTBEAT_SECS`, and higher-seq records win.
async fn dht_publish(
dht: &AsyncDht,
my_id: &PublicKey,
signing_key: &SigningKey,
salt: &[u8; 20],
pub_key_bytes: &[u8; 32],
neighbors: &Arc<Mutex<HashSet<PublicKey>>>,
state: &Arc<Mutex<SwarmState>>,
) {
let has_neighbors = neighbors.lock().is_ok_and(|n| !n.is_empty());
let mut seen = HashSet::new();
let mut peer_list: Vec<[u8; 32]> = Vec::with_capacity(MAX_DHT_PEERS);
// Always include ourselves.
peer_list.push(*my_id.as_bytes());
seen.insert(*my_id.as_bytes());
if has_neighbors {
// Connected: publish authoritative data — self + live neighbors only.
// This purges dead/stale peers from the DHT. We know who's alive
// because they're our active gossip neighbors.
if let Ok(n) = neighbors.lock() {
for id in n.iter() {
if seen.insert(*id.as_bytes()) && peer_list.len() < MAX_DHT_PEERS {
peer_list.push(*id.as_bytes());
}
}
}
} else {
// Alone: merge-publish — read existing DHT data first, add ourselves,
// preserve other peers. We don't know who's alive so we can't purge.
if let Some(record) = dht.get_mutable_most_recent(pub_key_bytes, Some(salt)).await {
for existing in decode_peer_list(record.value()) {
if seen.insert(*existing.as_bytes()) && peer_list.len() < MAX_DHT_PEERS {
peer_list.push(*existing.as_bytes());
}
// Snapshot current live neighbors — those we have an active gossip
// connection to right now. No inherited junk from prior DHT content.
if let Ok(n) = neighbors.lock() {
for id in n.iter() {
if peer_list.len() >= MAX_DHT_PEERS {
break;
}
peer_list.push(*id.as_bytes());
}
}