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)