diff --git a/core/archipelago/src/api/rpc/lnd/channels.rs b/core/archipelago/src/api/rpc/lnd/channels.rs index c5e58ee4..62271591 100644 --- a/core/archipelago/src/api/rpc/lnd/channels.rs +++ b/core/archipelago/src/api/rpc/lnd/channels.rs @@ -5,6 +5,21 @@ use tracing::info; use super::LND_REST_BASE_URL; +/// LND rejects RPCs with "server is still in the process of starting" for a +/// short window after wallet unlock (p2p/graph subsystems still loading). +/// Transient by design — match it so callers retry and, if it persists, +/// surface a calm notice instead of a scary failure. The exact phrase below +/// is what the frontend keys its softer (non-red) styling on. +fn lnd_still_starting(msg: &str) -> bool { + let m = msg.to_ascii_lowercase(); + m.contains("in the process of starting") || m.contains("server is still starting") +} + +/// User-facing text for the still-starting state. Deliberately calm: this is +/// a "wait a moment", not an error. +const LND_STARTING_MSG: &str = "Your Lightning node is still finishing its startup — this \ + usually takes a minute or two after the node comes online. Please try again shortly."; + #[derive(Debug, Serialize)] struct ChannelInfo { chan_id: String, @@ -251,25 +266,45 @@ impl RpcHandler { "perm": false, "timeout": "30" }); - let connect_resp = client - .post(format!("{LND_REST_BASE_URL}/v1/peers")) - .header("Grpc-Metadata-macaroon", &macaroon_hex) - .json(&connect_body) - .timeout(std::time::Duration::from_secs(35)) - .send() - .await - .context("Failed to connect to peer")?; + // Right after wallet unlock, LND's RPC answers while its p2p + // server is still spinning up, and every connect attempt gets + // "server is still in the process of starting". That's a + // transient state, not a failure — it clears in seconds — so + // retry quietly for ~30s before surfacing a calm, non-scary + // notice (the frontend styles LND_STARTING_MSG as info, not red). + let mut attempt = 0u32; + loop { + let connect_resp = client + .post(format!("{LND_REST_BASE_URL}/v1/peers")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .json(&connect_body) + .timeout(std::time::Duration::from_secs(35)) + .send() + .await + .context("Failed to connect to peer")?; - if !connect_resp.status().is_success() { + if connect_resp.status().is_success() { + break; + } let body: serde_json::Value = connect_resp.json().await.unwrap_or_default(); let msg = body .get("message") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); // LND returns an error if we already have this peer — that is fine - if !msg.contains("already connected") { - return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg)); + if msg.contains("already connected") { + break; } + if lnd_still_starting(msg) && attempt < 5 { + attempt += 1; + info!(attempt, "LND still starting — retrying peer connect in 6s"); + tokio::time::sleep(std::time::Duration::from_secs(6)).await; + continue; + } + if lnd_still_starting(msg) { + return Err(anyhow::anyhow!("{LND_STARTING_MSG}")); + } + return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg)); } } @@ -305,6 +340,9 @@ impl RpcHandler { .get("message") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); + if lnd_still_starting(msg) { + return Err(anyhow::anyhow!("{LND_STARTING_MSG}")); + } return Err(anyhow::anyhow!("Failed to open channel: {}", msg)); } diff --git a/neode-ui/src/components/LightningChannelsPanel.vue b/neode-ui/src/components/LightningChannelsPanel.vue index 266b71e4..fdb09fcc 100644 --- a/neode-ui/src/components/LightningChannelsPanel.vue +++ b/neode-ui/src/components/LightningChannelsPanel.vue @@ -245,8 +245,18 @@ -
-

{{ openError }}

+ +
+

+ {{ openError }} +

@@ -356,6 +366,14 @@ const openForm = ref(defaultOpenForm()) const openingChannel = ref(false) const openError = ref(null) +/** LND's transient post-unlock state ("still finishing its startup") is a + * wait-a-moment notice, not a failure — the template styles it amber. */ +function isStartupNotice(msg: string | null): boolean { + if (!msg) return false + const m = msg.toLowerCase() + return m.includes('still finishing its startup') || m.includes('in the process of starting') +} + const closeTarget = ref(null) const closingChannel = ref(false) const closeError = ref(null)