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
+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