From dd3afbbac2fef475be68092e17f210c1e211add5 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 9 Jul 2026 02:59:19 -0400 Subject: [PATCH] fix(lifecycle): let the scanner resolve visibly-completed stop/start transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One legacy app per gate run stuck at 'stopping'/'starting' past the test window while its container was already settled (vaultwarden:stop run C, jellyfin:stop run D, uptime-kuma:start run E — .228, 2026-07-09). The package scanner already sees the truth every 60s but merge_preserving_transitional refused to report it until the RPC worker wrote the final state, and the workers can legitimately trail the container by minutes: - stop workers queue behind the orchestrator per-app lock, which the reconcile host-port repair path holds through multi-minute stability waits (repair_manifest_host_ports_after_stability: 5s + 5s probe + restart + 60-420s port wait + 15-90s stable-running); - start workers hold Starting through the full readiness wait — host_port_wait_timeout_secs is 420s for uptime-kuma (HTTP probe), longer than any UI/test patience — and the Starting stuck-timeout is the 20-minute INSTALLING one. New merge rules, both truth-driven by podman's live view: - (Stopping, Stopped) + user-stop marker → Stopped: the scanner only normalizes exited→Stopped for user-stopped apps, so this is exactly "the user asked for a stop and the container has exited". - (Starting, Running) → Running: the start visibly succeeded; live health readings are merged separately and keep reporting readiness. Restarting is deliberately NOT resolved by a Running scan — mid-restart readings are the pre-stop container. Tests: merge_tests 13/13 (4 new). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/server.rs | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 4a52851d..a125f6cd 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1184,6 +1184,30 @@ fn merge_preserving_transitional( { fresh.state.clone() } + // A user-initiated stop whose container podman now reports settled + // (the scanner maps exited+user-stopped → Stopped) has visibly + // completed — report it. The stop worker still owns cleanup, but it + // can trail the actual container exit by minutes when it is queued + // behind the orchestrator's per-app lock (reconcile host-port repair + // holds it through multi-minute stability waits). Holding the card + // (and the lifecycle gate) in "Stopping" that whole time reports a + // completed stop as stuck (vaultwarden/jellyfin, gate runs C/D, + // .228 2026-07-09). + (crate::data_model::PackageState::Stopping, crate::data_model::PackageState::Stopped) + if user_stop_requested => + { + fresh.state.clone() + } + // Same reasoning for start: once podman reports Running, the start + // has visibly succeeded. The start worker keeps Starting through its + // full readiness wait (host-port probe budgets reach 420s for + // uptime-kuma — longer than any UI/test patience) even though the + // container is up and its live health is already shown separately + // (uptime-kuma, gate run E). Restarting is deliberately NOT mapped: + // mid-restart Running readings are the pre-stop container. + (crate::data_model::PackageState::Starting, crate::data_model::PackageState::Running) => { + fresh.state.clone() + } // Removing with a live running container is stale: uninstall either // failed or Archipelago restarted before the spawned task could revert // state. Let the scanner recover the UI immediately instead of @@ -1708,6 +1732,49 @@ mod merge_tests { assert_eq!(merged.health.as_deref(), Some("healthy")); } + #[test] + fn user_stop_resolves_when_container_has_exited() { + // The container exited and the scanner already normalized + // exited+user-stopped to Stopped — the stop visibly completed, even + // if the stop worker is still queued behind the per-app lock. + let existing = make_entry(PackageState::Stopping, Some("unknown")); + let fresh = make_entry(PackageState::Stopped, None); + let merged = merge_preserving_transitional(&existing, &fresh, true); + assert_eq!(merged.state, PackageState::Stopped); + } + + #[test] + fn non_user_stopping_with_exited_container_stays_owned() { + // No user-stop marker → this Stopping belongs to some other flow; + // don't resolve it from a scan. + let existing = make_entry(PackageState::Stopping, Some("unknown")); + let fresh = make_entry(PackageState::Stopped, None); + let merged = merge_preserving_transitional(&existing, &fresh, false); + assert_eq!(merged.state, PackageState::Stopping); + } + + #[test] + fn starting_resolves_when_container_is_running() { + // Start worker may still be inside its readiness wait (up to 420s for + // uptime-kuma) — but podman reporting Running means the start visibly + // succeeded; live health is merged separately. + let existing = make_entry(PackageState::Starting, Some("starting")); + let fresh = make_entry(PackageState::Running, Some("healthy")); + let merged = merge_preserving_transitional(&existing, &fresh, false); + assert_eq!(merged.state, PackageState::Running); + assert_eq!(merged.health.as_deref(), Some("healthy")); + } + + #[test] + fn restarting_is_not_resolved_by_running_scan() { + // Mid-restart the pre-stop container still reads Running — the + // restart worker owns this state until it finishes. + let existing = make_entry(PackageState::Restarting, Some("healthy")); + let fresh = make_entry(PackageState::Running, Some("healthy")); + let merged = merge_preserving_transitional(&existing, &fresh, false); + assert_eq!(merged.state, PackageState::Restarting); + } + #[test] fn merges_fresh_observability_fields() { // Non-state observability fields (health, exit_code, installed)