fix(ecash): recover from a truncated/corrupt Minibits state file
archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since removed) while a client had the ecash receive tab open. save_state's write landed mid-truncate, leaving wallet/minibits.json at 0 bytes. load_state then hard-failed every wallet.ecash-lnaddress call with "EOF while parsing a value", surfaced in the UI as "Lightning address unavailable" — permanently, since nothing ever cleared the bad file. Registration is idempotent per pubkey (re-registering returns the same lud16 Minibits already assigned), so there's no reason a corrupt local mirror of that state should be fatal. load_state now treats an empty or unparseable state file the same as a missing one — re-register and recover the same address — instead of erroring. Manually cleared the stuck file on archy-x250-pa3 as an immediate fix; this closes the gap so it self-heals next time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
This commit is contained in:
@@ -191,15 +191,26 @@ fn state_path(data_dir: &Path) -> std::path::PathBuf {
|
|||||||
async fn load_state(data_dir: &Path) -> Result<Option<MinibitsState>> {
|
async fn load_state(data_dir: &Path) -> Result<Option<MinibitsState>> {
|
||||||
let path = state_path(data_dir);
|
let path = state_path(data_dir);
|
||||||
match fs::read_to_string(&path).await {
|
match fs::read_to_string(&path).await {
|
||||||
Ok(s) => {
|
Ok(s) if s.trim().is_empty() => Ok(None),
|
||||||
let st: MinibitsState = serde_json::from_str(&s)
|
Ok(s) => match serde_json::from_str::<MinibitsState>(&s) {
|
||||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
Ok(st) if st.wallet_id.is_empty() => Ok(None),
|
||||||
if st.wallet_id.is_empty() {
|
Ok(st) => Ok(Some(st)),
|
||||||
|
// Unlike the accepted-mints file, nothing here is a user-editable
|
||||||
|
// security setting — it's a pure mirror of state Minibits already
|
||||||
|
// holds server-side, and registration is idempotent per pubkey
|
||||||
|
// (§ module docs), so re-registering after a corrupt/truncated
|
||||||
|
// read always recovers the *same* address. A node whose disk
|
||||||
|
// filled up mid-write (observed on archy-x250-pa3, 2026-09-08:
|
||||||
|
// this file truncated to 0 bytes) must self-heal on the next open
|
||||||
|
// rather than permanently show "Lightning address unavailable".
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Minibits: {} is corrupt/unreadable ({e}); treating as no profile yet and re-registering",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
|
||||||
Ok(Some(st))
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
|
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
|
||||||
}
|
}
|
||||||
@@ -728,6 +739,36 @@ mod tests {
|
|||||||
assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1);
|
assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_state_treats_empty_file_as_no_profile() {
|
||||||
|
// Reproduces archy-x250-pa3, 2026-09-08: a disk-full write truncated
|
||||||
|
// wallet/minibits.json to 0 bytes, which then made every
|
||||||
|
// wallet.ecash-lnaddress call fail with "EOF while parsing a value"
|
||||||
|
// instead of just re-registering (idempotent per pubkey, so safe).
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join(STATE_FILE);
|
||||||
|
tokio::fs::create_dir_all(path.parent().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(&path, b"").await.unwrap();
|
||||||
|
|
||||||
|
let st = load_state(tmp.path()).await.unwrap();
|
||||||
|
assert!(st.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_state_treats_corrupt_json_as_no_profile() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join(STATE_FILE);
|
||||||
|
tokio::fs::create_dir_all(path.parent().unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(&path, b"{ not valid json").await.unwrap();
|
||||||
|
|
||||||
|
let st = load_state(tmp.path()).await.unwrap();
|
||||||
|
assert!(st.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
/// Live end-to-end against the production Minibits API: register a throwaway
|
/// Live end-to-end against the production Minibits API: register a throwaway
|
||||||
/// profile with a random ecash phrase and claim (nothing pending → 0). Run
|
/// profile with a random ecash phrase and claim (nothing pending → 0). Run
|
||||||
/// with `cargo test -- --ignored --nocapture`. It creates one disposable
|
/// with `cargo test -- --ignored --nocapture`. It creates one disposable
|
||||||
|
|||||||
Reference in New Issue
Block a user