fix(mesh): first-class Reticulum — probe boot-race, live config apply, name propagation, daemon-death detection
Root causes found and fixed after live debugging on archi-dev-box (all verified on real RNode hardware, archi-dev-box <-> archy-x250-dev E2E): - probe_rnode raced the board's own boot: opening the port pulses DTR/RTS through USB-UART bridges (CP2102/Heltec V3), the ESP32 power-cycles and spends ~2.5-3s in boot ROM, and the KISS DETECT written 300ms after open landed in the void — so an RNode could NEVER connect on these boards. Now: immediate probe (fast path), then drain-until-quiet boot settle and a second DETECT with a fresh response window. - MeshService::configure() only restarted the listener on enable/disable — device_kind/device_path/advert_name/RF-param changes were silent no-ops until a full process restart (the setup modal's apply/keep-as-is did nothing). Material config changes now bounce the listener; the open sequence races the shutdown signal so stop() no longer burns the full 15s timeout mid-probe; mesh.configure applies in the background instead of stalling every status poll behind the service write-lock. - The mesh name was write-only: config.advert_name had no reader, server.set-name never reached the mesh service, and Reticulum's set_advert_name was a no-op (daemon display name fixed at spawn, and the ARCHY:2 announce blob REPLACED the LXMF display name — every archy node was anonymous on RNS). Now: advert_name > server name precedence feeds the session, renames restart it live, the daemon gets --display-name at spawn plus a set_name RPC verb, and announces carry the LXMF-standard msgpack name with the identity blob appended as an extra list element stock clients (Sideband/NomadNet) ignore. - Dead reticulum-daemon was invisible for up to 30min (RX-stall watchdog): child exit / RPC-EOF now fails try_recv_frame so the session reconnects. - Setup modal re-trigger loop: plugged_at used the tty node's mtime, which bumps on every open — each probe invalidated the dismissal key. Use btime/ctime (only change on real plugs). - ARCHY:2 identity adverts (re-emitted every 60s over Reticulum) stomped the federation twin's real name with a synthetic Archy-… placeholder and nulled its position; blob-only announces no longer assert a name, blob strings can never become display names, and stale blob names are healed at peers.json load. - mesh.broadcast on Meshtastic sent heartbeat+time only (no identity); SendAdvert now also fires a want_response NodeInfo broadcast. - New mesh.refresh RPC: actively re-queries the radio contact table (the UI Refresh button previously only re-read server caches). - Reticulum peers now track last_advert (announce time) and mark existing peers reachable on inbound traffic. - Boot auto-enable no longer force-enables mesh when an operator explicitly disabled it (only fires when no config file exists). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
537c52d11c
commit
a8c4694c36
@@ -392,6 +392,7 @@ impl RpcHandler {
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.refresh" => self.handle_mesh_refresh().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||
"mesh.send" => self.handle_mesh_send(params).await,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -131,7 +132,14 @@ impl RpcHandler {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
// Empty clears the custom mesh name (falls back to the server
|
||||
// name) — without this, a name could be set but never unset.
|
||||
let trimmed = name.trim();
|
||||
config.advert_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(announce) = params
|
||||
.get("announce_block_headers")
|
||||
@@ -202,10 +210,24 @@ impl RpcHandler {
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
// Apply to the running service in the background: configure() may
|
||||
// stop+start the listener (config changes now restart the session so
|
||||
// they actually take effect), and that can take seconds when the old
|
||||
// session is mid-probe. Holding the service write-lock for that long
|
||||
// inside this handler stalled every concurrent mesh.status/mesh.peers
|
||||
// poll behind it — the UI froze and nginx surfaced 502s. The config is
|
||||
// already persisted above; the UI observes progress via mesh.status.
|
||||
{
|
||||
let service_arc = Arc::clone(&self.mesh_service);
|
||||
let config_for_apply = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut service = service_arc.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
if let Err(e) = svc.configure(config_for_apply).await {
|
||||
tracing::error!("Applying mesh config to running service failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
|
||||
@@ -110,6 +110,28 @@ impl RpcHandler {
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
|
||||
/// contact table and re-announce ourselves so quiet-but-alive neighbours
|
||||
/// answer. This is what the UI's Refresh button calls — before it existed
|
||||
/// the button only re-read server caches and never touched the radio.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let Some(svc) = service.as_ref() else {
|
||||
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
|
||||
};
|
||||
let status = svc.status().await;
|
||||
if status.device_connected {
|
||||
let state = svc.shared_state();
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
||||
.await;
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"refreshed": status.device_connected,
|
||||
"device_connected": status.device_connected,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
|
||||
@@ -61,6 +61,28 @@ impl RpcHandler {
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Propagate to the mesh: the listener advertises the server name (when
|
||||
// no explicit mesh advert_name overrides it), but it was only read at
|
||||
// process startup — a rename never reached the radio/RNS until the
|
||||
// next full restart. Push it into the service and bounce the listener
|
||||
// in the background (the restart re-probes the radio, which can take
|
||||
// seconds — don't block the rename response on it).
|
||||
{
|
||||
let mesh_arc = self.mesh_service_arc();
|
||||
let name_for_mesh = name.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut guard = mesh_arc.write().await;
|
||||
if let Some(svc) = guard.as_mut() {
|
||||
svc.set_server_name(Some(name_for_mesh));
|
||||
if svc.config().advert_name.is_none() {
|
||||
if let Err(e) = svc.restart_listener_if_running().await {
|
||||
warn!("Mesh listener restart after rename failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
|
||||
Reference in New Issue
Block a user