fix(state): installed apps are held, never dropped, while their container is gone

Quadlet renders --rm, so every stop DELETES the container: a scan during
the stop->start window legitimately sees nothing where an installed app
lives. The state layer treated that as the app not existing — after three
absent scans it removed the entry entirely, so dashboard tiles vanished
mid-restart, gate waits read 'absent' (grafana, at load ~2, ruling out
contention), and under daemon-restart churn the FIRST scan could publish
a completely empty My Apps map (observed twice on 2026-08-09).

Two guards, both keyed to the durable installed-apps.json registry:

- Eviction: an id the registry says is installed, and the user has not
  uninstalled, is held as Stopped (health/exit cleared) instead of being
  removed. The next scan that sees the container restores live state;
  desired-state recovery still recreates genuinely lost containers; a
  deliberate uninstall clears the registry first and still disappears.
- Empty scans: the first-scan exemption no longer applies when the
  registry is non-empty — better to keep saying "scanning" than "empty".

Verified: scan-merge/absence/registry test filters 34/34.

Also carries the v1.7.127-alpha changelog (user-benefit entries for the
Tor self-heal, trusted invites, the three-layer mempool fix, vanishing
apps, the Bitcoin version pin, and the smaller UI fixes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 13:39:21 -04:00
co-authored by Claude Fable 5
parent e898f138d1
commit 89b03c47bd
2 changed files with 43 additions and 2 deletions
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- **Tor now tells you the truth, heals itself, and the Restart button really restarts it.** Three nodes ran for days with Tor completely dead while the dashboard said "Connected" — the indicator was reading a leftover address file, not the daemon, and the restart button reported success without checking. The cause was a configuration line Tor can never bind on our systems; a node could re-break itself from a single settings change. The node now refuses to write that line, checks Tor with a real connection instead of a leftover file, repairs its own Tor configuration at every start, and the Restart button only claims success once Tor is actually answering. Onion addresses that had silently never been published (BTCPay's included) come back with it.
- **Inviting another node as Trusted works again — on every node.** Generating a Trusted invite, or promoting a peer from the dropdown, silently failed everywhere: the security prompt that asks for your node password could never appear, because the message requesting it was being scrubbed out of the reply on its way to your browser. The prompt now opens, and if a trust change fails, the error appears inside the window you are looking at instead of hidden behind it.
- **The mempool explorer actually connects now.** The page loaded but sat empty forever. Three separate causes stacked up: the block index had spent days rebuilding without anything saying so, and then two different layers of the node's plumbing were dropping the live-data connection the page depends on — so everything reported healthy while your screen showed nothing. All three are fixed, and the node's own health checks now test the real connection a browser makes, so this cannot pass unnoticed again.
- **Apps no longer vanish after stopping cleanly.** A stopped app's container is deleted by design, but the restart policy meant an app that exited cleanly was never brought back — it simply disappeared until reinstalled. Backends now restart in every case, the node remembers what you have installed so a missing app is recreated rather than forgotten, and this release repairs the incorrect policy on apps installed by earlier versions.
- **Your Bitcoin node will not silently change software versions anymore.** "Latest" previously meant different things in different places — one path installed a newer build that deliberately halts until you make a network-rules decision, which froze one node's sync at a fixed block while it reported itself fully synced. Bitcoin Knots is now pinned to an explicit, known-good version; changing it is a decision you make, never a side effect of an update.
- **Smaller fixes:** the AI data-access settings now say plainly which categories the assistant can see but not act on; the transactions window's tab bar is transparent glass instead of a black block; BTCPay logins no longer fail with a server error when the node is under heavy load right at that moment.
- **You can now replace your Lightning connection keys from Settings, without touching a terminal.** The tokens wallet apps like Zeus use to reach your node are bearer keys: anything that has ever seen one can spend from your node until they are replaced, and there is no way to cancel one individually. Replacing them was previously a script you had to SSH in and run, which in practice meant it never happened. Settings → Lightning credentials now shows when yours were issued, which node they belong to and how many channels must survive, then does the whole job behind your node password — with a step-by-step progress list, and a refusal to call it a success unless it has confirmed your node identity and every channel came back. Your coins and channels are not touched: nothing is closed, and the wallet is never re-created. Afterwards you re-pair Zeus by scanning the Lightning app's QR code again.
- **Replacing those keys no longer silently breaks BTCPay Server.** BTCPay holds its own copy of the key, and that copy cannot repair itself — so a node that replaced its keys ended up with BTCPay running, healthy, and unable to take a single Lightning payment, with nothing anywhere saying why. The dashboard now updates BTCPay's copy as part of the run and restarts it around its existing data, and the Settings screen warns you if it finds a node already stuck in that state. The command-line script fixes the same gap.
- **Lightning stops getting stuck locked on a busy node.** Lightning opens its databases before it will accept the password that unlocks the wallet, and on a loaded node that took nearly three minutes — longer than the node was willing to wait. Giving up restarted Lightning, which started the slow open again, so the wallet stayed locked forever and everything depending on it stayed broken. The node now waits as long as it takes. A genuinely wrong password still fails immediately.
+36 -2
View File
@@ -1768,8 +1768,19 @@ async fn scan_and_update_packages(
.unwrap_or(false);
let update_changed = update_available != current_data.server_info.status_info.updated;
// Empty scan result = podman failure or timeout, preserve existing state
if packages.is_empty() && !first_scan {
// The durable installed set is the truth the scan must never contradict:
// quadlet renders --rm, so every stop DELETES the container and a scan mid
// stop->start legitimately sees nothing where an installed app lives.
let installed_registry = crate::crash_recovery::load_installed_apps(data_dir).await;
let user_uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
// Empty scan result = podman failure or timeout, preserve existing state.
// The first scan is NOT exempt when the durable registry says apps exist:
// the daemon restarts mid-churn (gate runs, OTAs), and publishing that
// first empty scan blanked the whole My Apps map — the dashboard showed a
// node with zero apps until the next scan (observed twice, 2026-08-09,
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
if tor_changed || update_changed {
let mut data = current_data;
data.server_info.tor_address = tor_addr.clone();
@@ -1919,6 +1930,29 @@ async fn scan_and_update_packages(
let count = absence_tracker.entry(id.clone()).or_insert(0);
*count += 1;
if *count >= CONTAINER_ABSENCE_THRESHOLD {
// An app the durable registry says is installed (and the user
// has not uninstalled) must NEVER be dropped from the map just
// because its container is momentarily gone — with --rm that
// is every restart's normal window. Dropping it here is what
// made dashboard tiles vanish mid-restart and gate waits read
// 'absent' (grafana, 2026-08-09, at load ~2). Hold it as
// Stopped instead; the next scan that sees the container
// restores the live state, and desired-state recovery still
// recreates genuinely lost containers.
if installed_registry.contains(&id) && !user_uninstalled.contains(&id) {
if let Some(entry) = merged.get(&id) {
if entry.state != crate::data_model::PackageState::Stopped {
let mut held = entry.clone();
held.state = crate::data_model::PackageState::Stopped;
held.health = None;
held.exit_code = None;
merged.insert(id.clone(), held);
changed = true;
}
}
absence_tracker.remove(&id);
continue;
}
debug!(
"Removing {} from state after {} consecutive absent scans",
id, count