fix(lnd): retry channel opens during LND startup + calm non-red notice

LND's RPC answers before its p2p server finishes loading, so connect/open
during that window failed red with the raw "server is still in the
process of starting". Now: quiet ~30s retry first; if still starting, a
calm amber "still finishing its startup" notice instead of an error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 17:30:47 -04:00
parent 97464779d4
commit 088b3e255a
2 changed files with 69 additions and 13 deletions

View File

@ -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));
}

View File

@ -245,8 +245,18 @@
</div>
</div>
<div v-if="openError" class="mt-3 alert-error">
<p class="text-xs">{{ openError }}</p>
<!-- "Still starting" is a wait-a-moment notice, not a failure show
it as calm amber info instead of the red error treatment. -->
<div
v-if="openError"
class="mt-3"
:class="isStartupNotice(openError)
? 'p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/90'
: 'alert-error'"
>
<p class="text-xs">
<span v-if="isStartupNotice(openError)" class="mr-1"></span>{{ openError }}
</p>
</div>
<div class="flex gap-3 mt-6">
@ -356,6 +366,14 @@ const openForm = ref(defaultOpenForm())
const openingChannel = ref(false)
const openError = ref<string | null>(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<Channel | null>(null)
const closingChannel = ref(false)
const closeError = ref<string | null>(null)