fix(apps): preserve lifecycle state and wait for usable launch endpoints

This commit is contained in:
archipelago
2026-09-30 09:10:30 -04:00
parent 27d81e956d
commit 6ac26f637c
26 changed files with 933 additions and 403 deletions
@@ -89,6 +89,15 @@ impl RpcHandler {
match handler.handle_package_install(params).await { match handler.handle_package_install(params).await {
Ok(_) => { Ok(_) => {
info!("package.install {}: complete", package_id_spawn); info!("package.install {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
.await;
}
crate::crash_recovery::mark_installed(
&handler.config.data_dir,
&package_id_spawn,
)
.await;
// The install pipeline has verified the container is up // The install pipeline has verified the container is up
// and healthy (see install.rs post-start exit check). // and healthy (see install.rs post-start exit check).
// Kick the scanner first so the fresh manifest (with // Kick the scanner first so the fresh manifest (with
@@ -184,17 +193,20 @@ impl RpcHandler {
// phase is cleared (None) so no stale InstallPhase // phase is cleared (None) so no stale InstallPhase
// lingers on the card. // lingers on the card.
let err_msg = format!("Install failed: {:#}", e); let err_msg = format!("Install failed: {:#}", e);
let (mut data, _) = handler.state_manager.get_snapshot().await; handler
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) { .state_manager
entry.state = PackageState::Stopped; .mutate_data(|data| {
entry.install_progress = Some(crate::data_model::InstallProgress { if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
size: 0, entry.state = PackageState::Stopped;
downloaded: 0, entry.install_progress = Some(crate::data_model::InstallProgress {
phase: None, size: 0,
message: Some(err_msg), downloaded: 0,
}); phase: None,
handler.state_manager.update_data(data).await; message: Some(err_msg),
} });
}
})
.await;
} }
} }
}); });
@@ -252,6 +264,11 @@ impl RpcHandler {
match handler.handle_package_uninstall(params).await { match handler.handle_package_uninstall(params).await {
Ok(_) => { Ok(_) => {
info!("package.uninstall {}: complete", package_id_spawn); info!("package.uninstall {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
.await;
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
}
// Inner handler already removed the package entry on // Inner handler already removed the package entry on
// success. Nothing more to do here. // success. Nothing more to do here.
} }
@@ -382,52 +399,56 @@ impl RpcHandler {
/// call, but fires before the spawn so the UI sees it immediately. /// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) { async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles}; use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
let (mut data, _) = state_manager.get_snapshot().await; state_manager
let entry = data .mutate_data(|data| {
.package_data let entry = data
.entry(package_id.to_string()) .package_data
.or_insert_with(|| PackageDataEntry { .entry(package_id.to_string())
state: PackageState::Installing, .or_insert_with(|| PackageDataEntry {
health: None, ui_ready: None,
exit_code: None, state: PackageState::Installing,
static_files: StaticFiles { health: None,
license: String::new(), exit_code: None,
instructions: String::new(), static_files: StaticFiles {
// Leave icon empty during the transient Installing window: license: String::new(),
// hardcoding `<id>.png` is wrong for ~half our apps (many use instructions: String::new(),
// `.svg` / `.webp`), producing a broken-image flicker until // Leave icon empty during the transient Installing window:
// the scanner refreshes the entry. The frontend's `icon` // hardcoding `<id>.png` is wrong for ~half our apps (many use
// computed falls through to `curatedMap.get(id)?.icon` which // `.svg` / `.webp`), producing a broken-image flicker until
// has the correct extensions for known apps. // the scanner refreshes the entry. The frontend's `icon`
icon: String::new(), // computed falls through to `curatedMap.get(id)?.icon` which
}, // has the correct extensions for known apps.
manifest: Manifest { icon: String::new(),
id: package_id.to_string(), },
title: package_id.to_string(), manifest: Manifest {
version: String::new(), id: package_id.to_string(),
description: Description { title: package_id.to_string(),
short: "Installing...".to_string(), version: String::new(),
long: String::new(), description: Description {
}, short: "Installing...".to_string(),
release_notes: String::new(), long: String::new(),
license: String::new(), },
wrapper_repo: String::new(), release_notes: String::new(),
upstream_repo: String::new(), license: String::new(),
support_site: String::new(), wrapper_repo: String::new(),
marketing_site: String::new(), upstream_repo: String::new(),
donation_url: None, support_site: String::new(),
author: None, marketing_site: String::new(),
website: None, donation_url: None,
interfaces: None, author: None,
tier: None, website: None,
}, interfaces: None,
installed: None, tier: None,
install_progress: None, },
uninstall_stage: None, installed: None,
available_update: None, install_progress: None,
}); uninstall_stage: None,
entry.state = PackageState::Installing; available_update: None,
state_manager.update_data(data).await; });
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
})
.await;
} }
/// True when the failed install still has a real footprint: any container /// True when the failed install still has a real footprint: any container
@@ -485,20 +506,23 @@ async fn remove_entry_with_notification(
id_prefix: &str, id_prefix: &str,
message: &str, message: &str,
) { ) {
let (mut data, _) = handler.state_manager.get_snapshot().await; handler
data.package_data.remove(package_id); .state_manager
data.notifications.push(crate::data_model::Notification { .mutate_data(|data| {
id: format!("{id_prefix}-{package_id}"), data.package_data.remove(package_id);
level: crate::data_model::NotificationLevel::Error, data.notifications.push(crate::data_model::Notification {
title: format!("Could not install {package_id}"), id: format!("{id_prefix}-{package_id}"),
message: message.to_string(), level: crate::data_model::NotificationLevel::Error,
timestamp: chrono::Utc::now().to_rfc3339(), title: format!("Could not install {package_id}"),
app_id: Some(package_id.to_string()), message: message.to_string(),
}); timestamp: chrono::Utc::now().to_rfc3339(),
while data.notifications.len() > 20 { app_id: Some(package_id.to_string()),
data.notifications.remove(0); });
} while data.notifications.len() > 20 {
handler.state_manager.update_data(data).await; data.notifications.remove(0);
}
})
.await;
} }
/// Flip an existing entry's state and return the pre-flip value (or None if /// Flip an existing entry's state and return the pre-flip value (or None if
@@ -508,18 +532,23 @@ async fn flip_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) -> Option<PackageState> { ) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
let prev = data.package_data.get(package_id).map(|e| e.state.clone()); .mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { let prev = data.package_data.get(package_id).map(|e| e.state.clone());
entry.state = new_state; if let Some(entry) = data.package_data.get_mut(package_id) {
state_manager.update_data(data).await; if new_state != PackageState::Running {
} else { entry.ui_ready = Some(false);
warn!( }
"flip_package_state: no entry for {} — cannot flip", entry.state = new_state;
package_id } else {
); warn!(
} "flip_package_state: no entry for {} — cannot flip",
prev package_id
);
}
prev
})
.await
} }
/// Set state unconditionally (no-op if entry no longer exists). /// Set state unconditionally (no-op if entry no longer exists).
@@ -528,13 +557,18 @@ async fn set_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
if let Some(entry) = data.package_data.get_mut(package_id) { .mutate_data(|data| {
if entry.state != new_state { if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state; if entry.state != new_state {
state_manager.update_data(data).await; if new_state != PackageState::Running {
} entry.ui_ready = Some(false);
} }
entry.state = new_state;
}
}
})
.await
} }
/// Set state and clear the uninstall_stage label. Used when an uninstall /// Set state and clear the uninstall_stage label. Used when an uninstall
@@ -545,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
if let Some(entry) = data.package_data.get_mut(package_id) { .mutate_data(|data| {
entry.state = new_state; if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = None; if new_state != PackageState::Running {
state_manager.update_data(data).await; entry.ui_ready = Some(false);
} }
entry.state = new_state;
entry.uninstall_stage = None;
}
})
.await
} }
/// Kick the container scanner to run immediately and wait for it to finish /// Kick the container scanner to run immediately and wait for it to finish
+7 -106
View File
@@ -545,7 +545,7 @@ impl RpcHandler {
// Keep legacy install flow as default while migration is in progress. // Keep legacy install flow as default while migration is in progress.
if orchestrator_managed { if orchestrator_managed {
let orchestrator_app_id = orchestrator_install_app_id(package_id); let orchestrator_app_id = orchestrator_install_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::CreatingContainer) self.set_install_phase(package_id, InstallPhase::PreparingApp)
.await; .await;
install_log(&format!( install_log(&format!(
"INSTALL ORCH: {} — attempting orchestrator install as {}", "INSTALL ORCH: {} — attempting orchestrator install as {}",
@@ -2053,25 +2053,8 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
} }
async fn cleanup_stale_package_ports(package_id: &str) { async fn cleanup_stale_package_ports(package_id: &str) {
match package_id { // Never kill by port: another app or the management gate may own it.
"grafana" => cleanup_stale_pasta_port("3000").await, crate::container::ghost_reaper::reap_for_app(package_id).await;
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"portainer" => cleanup_stale_pasta_port("9000").await,
_ => {}
}
} }
fn install_command_tail( fn install_command_tail(
@@ -2196,93 +2179,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
return true; return true;
} }
match package_id { if stderr.contains("pasta failed") || stderr.contains("address already in use") {
"grafana" crate::container::ghost_reaper::reap_for_app(package_id).await;
if stderr.contains("pasta failed") || stderr.contains("address already in use") => return true;
{
cleanup_stale_pasta_port("3000").await;
true
}
"homeassistant" | "home-assistant"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8123").await;
true
}
"searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8888").await;
true
}
"uptime-kuma"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3002").await;
true
}
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
true
}
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
}
"nextcloud"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8085").await;
true
}
"portainer"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("9000").await;
true
}
_ => false,
} }
} false
async fn cleanup_stale_pasta_port(port: &str) {
// NEVER kill our own process. The daemon holds catalog app ports over
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
// terminate archipelago itself mid-install — installs failed and apps
// vanished on a test node 2026-07-27. Kill every listener on the port
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
let self_pid = std::process::id();
let kill_listener = format!(
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
let fuser_kill = format!(
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &fuser_kill])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
async fn repair_nextcloud_permissions() { async fn repair_nextcloud_permissions() {
@@ -14,20 +14,23 @@ impl RpcHandler {
/// the rare case where the pull stream actually parses, but podman /// the rare case where the pull stream actually parses, but podman
/// almost never emits parseable progress on a piped stderr. /// almost never emits parseable progress on a piped stderr.
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) { pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
let (mut data, _rev) = self.state_manager.get_snapshot().await; self.state_manager
let entry = data .mutate_data(|data| {
.package_data let entry = data
.entry(package_id.to_string()) .package_data
.or_insert_with(|| create_installing_entry(package_id)); .entry(package_id.to_string())
entry.state = PackageState::Installing; .or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase); entry.ui_ready = Some(false);
entry.install_progress = Some(InstallProgress { entry.state = PackageState::Installing;
size, let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
downloaded, entry.install_progress = Some(InstallProgress {
phase: existing_phase, size,
message: None, downloaded,
}); phase: existing_phase,
self.state_manager.update_data(data).await; message: None,
});
})
.await;
} }
/// Set the install pipeline phase and broadcast. This is the /// Set the install pipeline phase and broadcast. This is the
@@ -35,76 +38,86 @@ impl RpcHandler {
/// percentage and a user-facing label. Byte counters are retained /// percentage and a user-facing label. Byte counters are retained
/// for the rare case podman emits parseable progress. /// for the rare case podman emits parseable progress.
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) { pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
let (mut data, _rev) = self.state_manager.get_snapshot().await; self.state_manager
let entry = data .mutate_data(|data| {
.package_data let entry = data
.entry(package_id.to_string()) .package_data
.or_insert_with(|| create_installing_entry(package_id)); .entry(package_id.to_string())
// Preparing / PullingImage / CreatingContainer / StartingContainer / .or_insert_with(|| create_installing_entry(package_id));
// WaitingHealthy / PostInstall all map to the Installing state. // Preparing / PullingImage / CreatingContainer / StartingContainer /
// Updates use Updating state — the wrapper has already flipped // WaitingHealthy / PostInstall all map to the Installing state.
// state to Updating, so don't clobber it. // Updates use Updating state — the wrapper has already flipped
if entry.state != PackageState::Updating { // state to Updating, so don't clobber it.
entry.state = PackageState::Installing; if entry.state != PackageState::Updating {
} entry.ui_ready = Some(false);
let (size, downloaded) = entry entry.state = PackageState::Installing;
.install_progress }
.as_ref() let (size, downloaded) = entry
.map(|p| (p.size, p.downloaded)) .install_progress
.unwrap_or((0, 0)); .as_ref()
entry.install_progress = Some(InstallProgress { .map(|p| (p.size, p.downloaded))
size, .unwrap_or((0, 0));
downloaded, entry.install_progress = Some(InstallProgress {
phase: Some(phase), size,
message: None, downloaded,
}); phase: Some(phase),
self.state_manager.update_data(data).await; message: None,
});
})
.await;
} }
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin /// Set a user-facing install status message (e.g. "Waiting for Bitcoin
/// to start…") without disturbing the current phase/byte counters. /// to start…") without disturbing the current phase/byte counters.
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) { pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await; self.state_manager
let entry = data .mutate_data(|data| {
.package_data let entry = data
.entry(package_id.to_string()) .package_data
.or_insert_with(|| create_installing_entry(package_id)); .entry(package_id.to_string())
if entry.state != PackageState::Updating { .or_insert_with(|| create_installing_entry(package_id));
entry.state = PackageState::Installing; if entry.state != PackageState::Updating {
} entry.ui_ready = Some(false);
let (size, downloaded, phase) = entry entry.state = PackageState::Installing;
.install_progress }
.as_ref() let (size, downloaded, phase) = entry
.map(|p| (p.size, p.downloaded, p.phase)) .install_progress
.unwrap_or((0, 0, None)); .as_ref()
entry.install_progress = Some(InstallProgress { .map(|p| (p.size, p.downloaded, p.phase))
size, .unwrap_or((0, 0, None));
downloaded, entry.install_progress = Some(InstallProgress {
phase, size,
message: Some(message.to_string()), downloaded,
}); phase,
self.state_manager.update_data(data).await; message: Some(message.to_string()),
});
})
.await;
} }
/// Clear install progress after pull completes or fails. /// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) { pub(super) async fn clear_install_progress(&self, package_id: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await; self.state_manager
if let Some(entry) = data.package_data.get_mut(package_id) { .mutate_data(|data| {
entry.install_progress = None; if let Some(entry) = data.package_data.get_mut(package_id) {
} entry.install_progress = None;
self.state_manager.update_data(data).await; }
})
.await;
} }
/// Set the uninstall stage label so the UI can show what's happening /// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change /// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container). /// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) { pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await; self.state_manager
if let Some(entry) = data.package_data.get_mut(package_id) { .mutate_data(|data| {
entry.uninstall_stage = Some(stage.to_string()); if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = crate::data_model::PackageState::Removing; entry.uninstall_stage = Some(stage.to_string());
} entry.state = crate::data_model::PackageState::Removing;
self.state_manager.update_data(data).await; }
})
.await;
} }
/// Update install progress (static method for use in async closures). /// Update install progress (static method for use in async closures).
@@ -114,25 +127,28 @@ impl RpcHandler {
downloaded: u64, downloaded: u64,
total: u64, total: u64,
) { ) {
let (mut data, _rev) = state_manager.get_snapshot().await; state_manager
let entry = data .mutate_data(|data| {
.package_data let entry = data
.entry(package_id.to_string()) .package_data
.or_insert_with(|| create_installing_entry(package_id)); .entry(package_id.to_string())
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase); .or_insert_with(|| create_installing_entry(package_id));
entry.install_progress = Some(InstallProgress { let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
size: total, entry.install_progress = Some(InstallProgress {
downloaded, size: total,
phase: existing_phase, downloaded,
message: None, phase: existing_phase,
}); message: None,
state_manager.update_data(data).await; });
})
.await;
} }
} }
/// Create a minimal PackageDataEntry for a package being installed. /// Create a minimal PackageDataEntry for a package being installed.
fn create_installing_entry(package_id: &str) -> PackageDataEntry { fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state: PackageState::Installing, state: PackageState::Installing,
health: None, health: None,
exit_code: None, exit_code: None,
+46 -64
View File
@@ -1431,10 +1431,9 @@ async fn repair_before_package_start(container_name: &str) {
// published port and the data-dir file locks, so the replacement either // published port and the data-dir file locks, so the replacement either
// fails to bind (`address already in use`) or starts and dies on the // fails to bind (`address already in use`) or starts and dies on the
// lock — and `Restart=always` loops it there forever. Ordered before // lock — and `Restart=always` loops it there forever. Ordered before
// the port cleanup below: killing the owner is what actually frees the // starting the replacement. A port sweep cannot distinguish a ghost
// port, and the port sweep alone cannot tell a ghost from a live app. // from the dashboard gate or another live app and must never kill it.
crate::container::ghost_reaper::reap_for_app(container_name).await; crate::container::ghost_reaper::reap_for_app(container_name).await;
cleanup_runtime_host_ports(container_name).await;
} }
async fn wait_before_package_start(container_name: &str) { async fn wait_before_package_start(container_name: &str) {
@@ -1579,7 +1578,6 @@ async fn repair_netbird_network() {
async fn repair_nginx_proxy_manager_container() { async fn repair_nginx_proxy_manager_container() {
repair_nginx_proxy_manager_dirs().await; repair_nginx_proxy_manager_dirs().await;
if !nginx_proxy_manager_has_legacy_admin_port().await { if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return; return;
} }
@@ -1588,7 +1586,7 @@ async fn repair_nginx_proxy_manager_container() {
) )
.await; .await;
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await; let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
cleanup_nginx_proxy_manager_ports().await; crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
if let Err(err) = recreate_nginx_proxy_manager_container().await { if let Err(err) = recreate_nginx_proxy_manager_container().await {
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container"); tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
} }
@@ -1812,6 +1810,9 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> { pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps")); dirs.push(Path::new(&manifest_dir).join("../../apps"));
} }
@@ -2032,51 +2033,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return; return;
} }
let ports = runtime_host_ports(container_name); // Only reap processes proven to belong to an absent container. The app
if !ports.is_empty() { // gate shares the app's port on other addresses and lives in this daemon;
cleanup_ports(&ports).await; // killing port owners (or matching argv with pkill) kills the dashboard.
return; crate::container::ghost_reaper::reap_for_app(container_name).await;
}
}
async fn cleanup_runtime_host_ports(container_name: &str) {
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
}
}
async fn cleanup_nginx_proxy_manager_ports() {
cleanup_ports(&[8081, 8084, 8444]).await;
}
async fn cleanup_ports(ports: &[u16]) {
for port in ports {
cleanup_stale_pasta_port(&port.to_string()).await;
}
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool { pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
@@ -2095,13 +2055,16 @@ async fn flip_package_state(
package_id: &str, package_id: &str,
transitional: PackageState, transitional: PackageState,
) -> Option<PackageState> { ) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
let prev = data.package_data.get(package_id).map(|e| e.state.clone()); .mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) { let prev = data.package_data.get(package_id).map(|e| e.state.clone());
entry.state = transitional; if let Some(entry) = data.package_data.get_mut(package_id) {
state_manager.update_data(data).await; entry.ui_ready = Some(false);
} entry.state = transitional;
prev }
prev
})
.await
} }
/// Write the package entry's final state. No-op if the entry has since /// Write the package entry's final state. No-op if the entry has since
@@ -2111,13 +2074,18 @@ async fn set_package_state(
package_id: &str, package_id: &str,
new_state: PackageState, new_state: PackageState,
) { ) {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
if let Some(entry) = data.package_data.get_mut(package_id) { .mutate_data(|data| {
if entry.state != new_state { if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state; if entry.state != new_state {
state_manager.update_data(data).await; if new_state != PackageState::Running {
} entry.ui_ready = Some(false);
} }
entry.state = new_state;
}
}
})
.await
} }
pub(super) async fn reconcile_companions_for(package_id: &str) { pub(super) async fn reconcile_companions_for(package_id: &str) {
@@ -2185,6 +2153,20 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn port_conflict_cleanup_preserves_live_host_listener() {
// The previous ss|kill sweep terminated the daemon's app gate on a
// restart. Keep a real listening socket owned by this test process.
let listener = tokio::net::TcpListener::bind("127.0.0.2:2342")
.await
.unwrap();
let addr = listener.local_addr().unwrap();
cleanup_start_conflict("photoprism", "address already in use").await;
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
let _connection = listener.accept().await.unwrap();
drop(client);
}
#[test] #[test]
fn missing_container_classifier_covers_podman5_phrasings() { fn missing_container_classifier_covers_podman5_phrasings() {
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing // Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
+22 -14
View File
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
app_id: &str, app_id: &str,
transitional: PackageState, transitional: PackageState,
) -> Option<PackageState> { ) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
let prev = data.package_data.get(app_id).map(|e| e.state.clone()); .mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(app_id) { let prev = data.package_data.get(app_id).map(|e| e.state.clone());
entry.state = transitional; if let Some(entry) = data.package_data.get_mut(app_id) {
state_manager.update_data(data).await; entry.ui_ready = Some(false);
} entry.state = transitional;
prev }
prev
})
.await
} }
/// Set the entry's state to `new_state`. No-ops if the entry has since been /// Set the entry's state to `new_state`. No-ops if the entry has since been
/// removed (e.g. uninstall ran concurrently). /// removed (e.g. uninstall ran concurrently).
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) { async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
let (mut data, _) = state_manager.get_snapshot().await; state_manager
if let Some(entry) = data.package_data.get_mut(app_id) { .mutate_data(|data| {
if entry.state != new_state { if let Some(entry) = data.package_data.get_mut(app_id) {
entry.state = new_state; if entry.state != new_state {
state_manager.update_data(data).await; if new_state != PackageState::Running {
} entry.ui_ready = Some(false);
} }
entry.state = new_state;
}
}
})
.await
} }
+3
View File
@@ -114,6 +114,9 @@ impl PortMap {
/// there. /// there.
fn apps_dirs() -> Vec<PathBuf> { fn apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps")); dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
} }
+48 -3
View File
@@ -144,6 +144,34 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
.clone() .clone()
} }
static REFRESH_KICK: std::sync::LazyLock<tokio::sync::Notify> =
std::sync::LazyLock::new(tokio::sync::Notify::new);
static REFRESH_REV: std::sync::LazyLock<tokio::sync::watch::Sender<u64>> =
std::sync::LazyLock::new(|| tokio::sync::watch::channel(0).0);
/// Installation must not wait for the minute sweep before becoming reachable.
/// Wait for a completed sweep, bounded if shutdown/startup prevents one.
pub async fn refresh_now() {
let mut completed = REFRESH_REV.subscribe();
REFRESH_KICK.notify_one();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), completed.changed()).await;
}
pub fn port_claimed(status: &GateStatus, port: u16) -> bool {
let mut external = false;
let mut tor = false;
for (claimed_port, address) in &status.claimed {
if *claimed_port != port {
continue;
}
if let Ok(ip) = address.parse::<IpAddr>() {
tor |= ip == GATE_TOR_UPSTREAM;
external |= !ip.is_loopback();
}
}
external && tor
}
/// Run the gate. Returns only on shutdown. /// Run the gate. Returns only on shutdown.
pub async fn run( pub async fn run(
gate: Arc<AppGate>, gate: Arc<AppGate>,
@@ -162,11 +190,12 @@ pub async fn run(
loop { loop {
tokio::select! { tokio::select! {
_ = interval.tick() => { _ = interval.tick() => {}
sweep(&gate, &status, &mut held, &shutdown_rx).await; _ = REFRESH_KICK.notified() => {}
}
_ = shutdown_rx.changed() => return, _ = shutdown_rx.changed() => return,
} }
sweep(&gate, &status, &mut held, &shutdown_rx).await;
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
} }
} }
@@ -461,3 +490,19 @@ mod tests {
assert!(!status.is_fully_enforced()); assert!(!status.is_fully_enforced());
} }
} }
#[cfg(test)]
mod readiness_tests {
use super::*;
#[test]
fn readiness_requires_external_and_tor_claims_for_the_same_port() {
let mut status = GateStatus::default();
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "127.0.0.2".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3002, "192.0.2.10".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "192.0.2.10".into()));
assert!(port_claimed(&status, 3001));
}
}
+1
View File
@@ -322,6 +322,7 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry { fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles}; use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state: PackageState::Running, state: PackageState::Running,
health: None, health: None,
exit_code: None, exit_code: None,
+1
View File
@@ -1069,6 +1069,7 @@ mod tests {
Description, Manifest, PackageDataEntry, PackageState, StaticFiles, Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
}; };
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state: PackageState::Running, state: PackageState::Running,
health: None, health: None,
exit_code: None, exit_code: None,
@@ -3,8 +3,9 @@
use anyhow::Result; use anyhow::Result;
use archipelago_container::{ use archipelago_container::{
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient, ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
}; };
use futures_util::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tracing::{debug, info}; use tracing::{debug, info};
@@ -25,8 +26,15 @@ impl DockerPackageScanner {
} }
/// Scan Docker containers and convert to package data /// Scan Docker containers and convert to package data
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> { pub async fn scan_containers(
let containers = self.runtime.list_containers().await?; &self,
data_dir: &std::path::Path,
cached: &HashMap<String, PackageDataEntry>,
) -> Result<HashMap<String, PackageDataEntry>> {
let mut containers = self.runtime.list_containers().await?;
let installed = crate::crash_recovery::load_installed_apps(data_dir).await;
let uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
restore_absent_installed(&mut containers, &installed, &uninstalled);
debug!("Found {} containers", containers.len()); debug!("Found {} containers", containers.len());
@@ -139,6 +147,18 @@ impl DockerPackageScanner {
continue; continue;
} }
if container.id.is_empty() {
if let Some(previous) = cached.get(&app_id) {
let mut held = previous.clone();
held.state = PackageState::Stopped;
held.ui_ready = Some(false);
held.health = None;
held.exit_code = None;
packages.insert(app_id.clone(), held);
continue;
}
}
// Get metadata for this app // Get metadata for this app
let metadata = get_app_metadata(&app_id); let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the // Manifest-owned metadata (icon) wins over the static table: the
@@ -179,14 +199,22 @@ impl DockerPackageScanner {
let tor_address = read_tor_address(&app_id).await; let tor_address = read_tor_address(&app_id).await;
// Extract actual version from container image tag // Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image); let running_version = if container.id.is_empty() {
String::new() // Absence cannot establish the installed image version.
} else {
image_versions::extract_version_from_image(&container.image)
};
// Decoupled from the binary OTA: prefer the remote app catalog, // Decoupled from the binary OTA: prefer the remote app catalog,
// falling back to the image-versions.sh pin when uncovered/offline. // falling back to the image-versions.sh pin when uncovered/offline.
let available_update = let available_update = if container.id.is_empty() {
crate::container::app_catalog::available_update_for_app(&app_id, &container.image); None
} else {
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
};
let package = PackageDataEntry { let package = PackageDataEntry {
ui_ready: Some(false),
state: package_state.clone(), state: package_state.clone(),
health: container.health.clone(), health: container.health.clone(),
exit_code: if package_state == PackageState::Exited { exit_code: if package_state == PackageState::Exited {
@@ -283,10 +311,215 @@ impl DockerPackageScanner {
); );
} }
let probes: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.state != PackageState::Running {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.clone()?;
Some((id.clone(), url))
})
.collect();
let mut results = futures_util::stream::iter(
probes
.into_iter()
.map(|(id, url)| async move { (id, launch_http_ready(&url).await) }),
)
.buffer_unordered(8);
while let Some((id, ready)) = results.next().await {
if let Some(pkg) = packages.get_mut(&id) {
pkg.ui_ready = Some(ready);
}
}
// HTTP on loopback can precede the LAN/Tor listener after install.
let port_map = crate::appgate::identity::build_port_map();
let gated: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.ui_ready != Some(true) {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.as_deref()?;
let port = launch_url_port(url)?;
port_map
.gated(port)
.filter(|gate| gate.declared)
.map(|_| (id.clone(), port))
})
.collect();
if !gated.is_empty() {
use crate::appgate::listener::{port_claimed, refresh_now, shared_status};
let status = shared_status();
let needs_refresh = {
let current = status.read().await;
gated.iter().any(|(_, port)| !port_claimed(&current, *port))
};
if needs_refresh {
refresh_now().await;
}
let current = status.read().await;
for (id, port) in gated {
if !port_claimed(&current, port) {
packages.get_mut(&id).unwrap().ui_ready = Some(false);
}
}
}
Ok(packages) Ok(packages)
} }
} }
/// Quadlet removes containers during ordinary stops/restarts. Rebuild installed
/// entries even on the daemon's first scan; a runtime absence is not uninstall.
fn restore_absent_installed(
containers: &mut Vec<ContainerStatus>,
installed: &std::collections::HashSet<String>,
uninstalled: &std::collections::HashSet<String>,
) {
fn canonical(name: &str) -> &str {
let name = name.strip_prefix("archy-").unwrap_or(name);
match name {
"immich_server" => "immich",
_ => name,
}
}
let mut present: std::collections::HashSet<String> = containers
.iter()
.map(|c| canonical(&c.name).to_owned())
.collect();
let removed: std::collections::HashSet<_> =
uninstalled.iter().map(|id| canonical(id)).collect();
for name in installed {
let id = canonical(name);
if removed.contains(id) || !present.insert(id.to_owned()) {
continue;
}
containers.push(ContainerStatus {
id: String::new(),
name: id.to_owned(),
state: ContainerState::Stopped,
health: None,
exit_code: None,
started_at: None,
image: String::new(),
created: String::new(),
ports: Vec::new(),
lan_address: None,
});
}
}
/// Probe the actual loopback upstream, not the app gate's login page. A bound
/// TCP socket alone can still reset requests or serve a startup 503.
async fn launch_http_ready(candidate: &str) -> bool {
let Ok(mut url) = reqwest::Url::parse(candidate) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
if url.set_host(Some("127.0.0.1")).is_err() {
return false;
}
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
// Self-signed local app certificates are normal. This client only
// contacts loopback and never sends credentials or follows redirects.
.danger_accept_invalid_certs(true)
.build()
.expect("local readiness client")
});
match client.get(url).send().await {
Ok(response) => matches!(response.status().as_u16(), 200..=399 | 401 | 403),
Err(_) => false,
}
}
#[cfg(test)]
mod lifecycle_regression_tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
.into_iter()
.map(str::to_owned)
.collect();
let removed = ["removed".to_owned()].into_iter().collect();
let mut containers = Vec::new();
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert!(containers
.iter()
.all(|c| c.state == ContainerState::Stopped));
containers[0].state = ContainerState::Running;
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert_eq!(containers[0].state, ContainerState::Running);
}
#[tokio::test]
async fn readiness_rejects_startup_errors_and_accepts_auth_and_redirects() {
for (status, expected) in [
(200, true),
(302, true),
(401, true),
(403, true),
(404, false),
(500, false),
(502, false),
(503, false),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0; 2048];
let n = stream.read(&mut buf).await.unwrap();
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /start HTTP/1.1"));
stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
});
assert_eq!(
launch_http_ready(&format!("http://localhost:{port}/start")).await,
expected,
"status {status}"
);
task.await.unwrap();
}
}
#[tokio::test]
async fn readiness_rejects_tcp_accept_without_http() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
drop(stream);
});
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
task.await.unwrap();
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
assert!(!launch_http_ready("file:///tmp/test").await);
}
}
struct AppMetadata { struct AppMetadata {
title: String, title: String,
description: String, description: String,
@@ -3483,11 +3483,9 @@ impl ProdContainerOrchestrator {
} }
async fn cleanup_stale_grafana_port(&self) { async fn cleanup_stale_grafana_port(&self) {
let _ = tokio::process::Command::new("pkill") // Port 3001 can belong to Gitea or the daemon's gate. Reap only a
.args(["-f", "pasta.*3001"]) // Grafana container proven absent from Podman's inventory.
.output() crate::container::ghost_reaper::reap_for_app("grafana").await;
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
async fn detect_host_facts(&self) -> HostFacts { async fn detect_host_facts(&self) -> HostFacts {
+38 -1
View File
@@ -194,6 +194,7 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
// Installation is a decision, not a runtime observation, so it gets a record // Installation is a decision, not a runtime observation, so it gets a record
// of its own that no amount of downtime erodes. // of its own that no amount of downtime erodes.
const INSTALLED_APPS_FILE: &str = "installed-apps.json"; const INSTALLED_APPS_FILE: &str = "installed-apps.json";
static INSTALLED_APPS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Load the durable set of installed app ids / container names. /// Load the durable set of installed app ids / container names.
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> { pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
@@ -220,12 +221,23 @@ pub async fn load_installed_apps_if_recorded(
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) { async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
let path = data_dir.join(INSTALLED_APPS_FILE); let path = data_dir.join(INSTALLED_APPS_FILE);
if let Ok(json) = serde_json::to_string_pretty(installed) { if let Ok(json) = serde_json::to_string_pretty(installed) {
let _ = fs::write(&path, json).await; let tmp = path.with_extension("json.tmp");
let result = async {
fs::write(&tmp, json).await?;
fs::File::open(&tmp).await?.sync_all().await?;
fs::rename(&tmp, &path).await?;
fs::File::open(data_dir).await?.sync_all().await
}
.await;
if let Err(error) = result {
warn!(%error, "could not persist installed apps");
}
} }
} }
/// Record that an app is installed. Called when an install succeeds. /// Record that an app is installed. Called when an install succeeds.
pub async fn mark_installed(data_dir: &Path, name: &str) { pub async fn mark_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await; let mut installed = load_installed_apps(data_dir).await;
if installed.insert(name.to_string()) { if installed.insert(name.to_string()) {
save_installed_apps(data_dir, &installed).await; save_installed_apps(data_dir, &installed).await;
@@ -235,6 +247,7 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the /// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
/// two must move together or a reinstall-after-uninstall leaves a stale claim. /// two must move together or a reinstall-after-uninstall leaves a stale claim.
pub async fn clear_installed(data_dir: &Path, name: &str) { pub async fn clear_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await; let mut installed = load_installed_apps(data_dir).await;
if installed.remove(name) { if installed.remove(name) {
save_installed_apps(data_dir, &installed).await; save_installed_apps(data_dir, &installed).await;
@@ -252,6 +265,7 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
/// need it. Runs on every boot, so an app installed before the upgrade is /// need it. Runs on every boot, so an app installed before the upgrade is
/// still picked up whenever it is next seen alive. /// still picked up whenever it is next seen alive.
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) { pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
if present_container_names.is_empty() { if present_container_names.is_empty() {
return; return;
} }
@@ -1497,3 +1511,26 @@ mod tests {
); );
} }
} }
#[cfg(test)]
mod installed_concurrency_tests {
use super::*;
#[tokio::test]
async fn concurrent_install_records_are_not_lost() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for i in 0..24 {
let path = dir.path().to_owned();
tasks.push(tokio::spawn(async move {
mark_installed(&path, &format!("app-{i}")).await;
}));
}
for task in tasks {
task.await.unwrap();
}
assert_eq!(load_installed_apps(dir.path()).await.len(), 24);
clear_installed(dir.path(), "app-3").await;
assert_eq!(load_installed_apps(dir.path()).await.len(), 23);
assert!(!dir.path().join("installed-apps.json.tmp").exists());
}
}
+6
View File
@@ -146,6 +146,10 @@ pub enum PackageState {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PackageDataEntry { pub struct PackageDataEntry {
/// Whether the app's HTTP upstream answered this scan (independent of
/// container health and blockchain sync). Missing on older nodes.
#[serde(rename = "ui-ready", default, skip_serializing_if = "Option::is_none")]
pub ui_ready: Option<bool>,
pub state: PackageState, pub state: PackageState,
/// Container health: "healthy", "unhealthy", "starting", or null /// Container health: "healthy", "unhealthy", "starting", or null
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -297,6 +301,8 @@ pub enum InstallPhase {
/// `podman pull` in progress (the longest phase — up to several /// `podman pull` in progress (the longest phase — up to several
/// minutes for large images on slow networks). /// minutes for large images on slow networks).
PullingImage, PullingImage,
/// Orchestrator owns download/build and startup as one operation.
PreparingApp,
/// Creating data directories, writing app-specific configs /// Creating data directories, writing app-specific configs
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown). /// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
CreatingContainer, CreatingContainer,
+86 -18
View File
@@ -1765,12 +1765,17 @@ fn merge_preserving_transitional(
}; };
crate::data_model::PackageDataEntry { crate::data_model::PackageDataEntry {
state, state: state.clone(),
// install_progress and uninstall_stage are also owned by the // install_progress and uninstall_stage are also owned by the
// initiating op (same reason as state) — keep them. // initiating op (same reason as state) — keep them.
install_progress: existing.install_progress.clone(), install_progress: existing.install_progress.clone(),
uninstall_stage: existing.uninstall_stage.clone(), uninstall_stage: existing.uninstall_stage.clone(),
// Everything else comes from the fresh scan. // Everything else comes from the fresh scan.
ui_ready: if state == crate::data_model::PackageState::Running {
fresh.ui_ready
} else {
Some(false)
},
health: fresh.health.clone(), health: fresh.health.clone(),
exit_code: fresh.exit_code, exit_code: fresh.exit_code,
static_files: fresh.static_files.clone(), static_files: fresh.static_files.clone(),
@@ -1809,7 +1814,10 @@ async fn scan_and_update_packages(
absence_tracker: &mut HashMap<String, u32>, absence_tracker: &mut HashMap<String, u32>,
transitional_since: &mut HashMap<String, Instant>, transitional_since: &mut HashMap<String, Instant>,
) -> Result<()> { ) -> Result<()> {
let mut packages = scanner.scan_containers().await?; let (before_scan, _) = state.get_snapshot().await;
let mut packages = scanner
.scan_containers(data_dir, &before_scan.package_data)
.await?;
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await; let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
for (id, pkg) in packages.iter_mut() { for (id, pkg) in packages.iter_mut() {
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) { if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
@@ -1870,11 +1878,14 @@ async fn scan_and_update_packages(
// once at load ~2). Better to keep saying "scanning…" than to say "empty". // once at load ~2). Better to keep saying "scanning…" than to say "empty".
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) { if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
if tor_changed || update_changed { if tor_changed || update_changed {
let mut data = current_data; state
data.server_info.tor_address = tor_addr.clone(); .mutate_data(|data| {
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t)); data.server_info.tor_address = tor_addr.clone();
data.server_info.status_info.updated = update_available; data.server_info.node_address =
state.update_data(data).await; tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available;
})
.await;
} }
return Ok(()); return Ok(());
} }
@@ -1899,6 +1910,13 @@ async fn scan_and_update_packages(
// died without cleanup and let the scan override it. // died without cleanup and let the scan override it.
let now = Instant::now(); let now = Instant::now();
for (id, pkg) in &packages { for (id, pkg) in &packages {
if user_uninstalled.contains(id)
|| user_uninstalled.contains(&format!("archy-{id}"))
|| (before_scan.package_data.contains_key(id)
&& !current_data.package_data.contains_key(id))
{
continue;
}
absence_tracker.remove(id); absence_tracker.remove(id);
let existing = merged.get(id); let existing = merged.get(id);
let overwrite = match existing { let overwrite = match existing {
@@ -2054,22 +2072,40 @@ async fn scan_and_update_packages(
} }
if changed || tor_changed || first_scan || update_changed { if changed || tor_changed || first_scan || update_changed {
let mut data = current_data; state
data.package_data = merged; .mutate_data(|data| {
data.server_info.tor_address = tor_addr.clone(); // A lifecycle operation may have started/finished while this scan
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t)); // awaited probes or disk I/O. Never overwrite that newer entry or
data.server_info.status_info.containers_scanned = true; // resurrect one that an uninstall removed in the meantime.
data.server_info.status_info.updated = update_available; apply_scanned_packages(&mut data.package_data, &current_data.package_data, &merged);
state.update_data(data).await; data.server_info.tor_address = tor_addr.clone();
debug!( data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update", data.server_info.status_info.containers_scanned = true;
changed, tor_changed, first_scan, update_changed data.server_info.status_info.updated = update_available;
); })
.await;
} }
Ok(()) Ok(())
} }
fn apply_scanned_packages(
latest: &mut HashMap<String, crate::data_model::PackageDataEntry>,
base: &HashMap<String, crate::data_model::PackageDataEntry>,
scanned: &HashMap<String, crate::data_model::PackageDataEntry>,
) {
for (id, fresh) in scanned {
if latest.get(id) == base.get(id) {
latest.insert(id.clone(), fresh.clone());
}
}
for id in base.keys() {
if !scanned.contains_key(id) && latest.get(id) == base.get(id) {
latest.remove(id);
}
}
}
async fn normalize_reachable_package_health( async fn normalize_reachable_package_health(
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>, packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
) { ) {
@@ -2268,6 +2304,7 @@ mod merge_tests {
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry { fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
PackageDataEntry { PackageDataEntry {
ui_ready: None,
state, state,
health: health.map(|s| s.to_string()), health: health.map(|s| s.to_string()),
exit_code: None, exit_code: None,
@@ -2280,6 +2317,37 @@ mod merge_tests {
} }
} }
#[test]
fn stale_scan_cannot_remove_new_installs_or_overwrite_lifecycle_changes() {
let running = make_entry(PackageState::Running, Some("healthy"));
let restarting = make_entry(PackageState::Restarting, None);
let base = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
let mut latest = [
("restart".into(), restarting.clone()),
("new".into(), running.clone()),
]
.into_iter()
.collect();
let scanned = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
apply_scanned_packages(&mut latest, &base, &scanned);
assert_eq!(latest.get("restart"), Some(&restarting));
assert_eq!(latest.get("new"), Some(&running));
assert!(!latest.contains_key("uninstalled"));
apply_scanned_packages(&mut latest, &base, &HashMap::new());
assert_eq!(latest.get("restart"), Some(&restarting));
assert!(latest.contains_key("new"));
}
#[test] #[test]
fn peer_path_filter_allows_content_catalog_and_items() { fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing // Regression: the content *catalog* is exactly "/content" (no trailing
+41
View File
@@ -54,6 +54,21 @@ impl StateManager {
let _ = self.broadcast_tx.send(message); let _ = self.broadcast_tx.send(message);
} }
/// Apply a small state change while holding the write lock. A lifecycle
/// task must not replace the entire model from an earlier snapshot.
pub async fn mutate_data<T>(&self, change: impl FnOnce(&mut DataModel) -> T) -> T {
let mut data = self.data.write().await;
let result = change(&mut data);
let mut rev = self.revision.write().await;
*rev += 1;
let _ = self.broadcast_tx.send(WebSocketMessage {
rev: *rev,
data: Some(data.clone()),
patch: None,
});
result
}
/// Get a WebSocket message with the current state /// Get a WebSocket message with the current state
pub async fn get_initial_message(&self) -> WebSocketMessage { pub async fn get_initial_message(&self) -> WebSocketMessage {
let (data, rev) = self.get_snapshot().await; let (data, rev) = self.get_snapshot().await;
@@ -190,3 +205,29 @@ mod tests {
assert_eq!(rev, 1); assert_eq!(rev, 1);
} }
} }
#[cfg(test)]
mod atomic_mutation_tests {
use super::*;
#[tokio::test]
async fn concurrent_updates_preserve_independent_entries() {
let state = Arc::new(StateManager::new());
let mut tasks = Vec::new();
for i in 0..24 {
let state = state.clone();
tasks.push(tokio::spawn(async move {
state
.mutate_data(|data| {
data.peer_health.insert(format!("peer-{i}"), true);
})
.await;
}));
}
for task in tasks {
task.await.unwrap();
}
let (data, revision) = state.get_snapshot().await;
assert_eq!(data.peer_health.len(), 24);
assert_eq!(revision, 24);
}
}
@@ -34,6 +34,7 @@ vi.mock('@/api/rpc-client', () => ({
vi.stubGlobal('open', mockWindowOpen) vi.stubGlobal('open', mockWindowOpen)
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher' import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
import { useAppStore } from '../app'
describe('useAppLauncherStore', () => { describe('useAppLauncherStore', () => {
beforeEach(() => { beforeEach(() => {
@@ -54,6 +55,25 @@ describe('useAppLauncherStore', () => {
}) })
}) })
it('blocks both browser and embedded launch while HTTP is unready', () => {
const app = useAppStore()
app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never
const launcher = useAppLauncherStore()
launcher.openSession('gitea')
expect(launcher.panelAppId).toBeNull()
launcher.open({ url: 'http://192.0.2.10:3001/', title: 'Gitea', openInNewTab: true })
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(launcher.isOpen).toBe(false)
})
it('also gates a dynamic app resolved through its runtime URL', () => {
useAppStore().data = { 'package-data': { custom: { state: 'running', 'ui-ready': false, manifest: { id: 'custom', title: 'Custom' }, installed: { 'interface-addresses': { main: { 'lan-address': 'http://localhost:18993/' } } } } } } as never
const launcher = useAppLauncherStore()
launcher.open({ url: 'http://192.0.2.10:18993/', title: 'Custom', openInNewTab: true })
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(launcher.isOpen).toBe(false)
})
it('starts closed with empty state', () => { it('starts closed with empty state', () => {
const store = useAppLauncherStore() const store = useAppLauncherStore()
expect(store.isOpen).toBe(false) expect(store.isOpen).toBe(false)
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { reactive, nextTick } from 'vue'
const fake = reactive<{ packages: Record<string, unknown> }>({ packages: {} })
vi.mock('../sync', () => ({ useSyncStore: () => fake }))
vi.mock('../../api/rpc-client', () => ({ rpcClient: {} }))
import { useServerStore } from '../server'
function installing(phase = 'preparing-app') {
return { state: 'installing', manifest: { title: 'Git Workshop' }, 'install-progress': { phase, size: 0, downloaded: 0 } }
}
describe('installation state after hard refresh', () => {
beforeEach(() => {
setActivePinia(createPinia())
fake.packages = {}
})
it('restores an in-flight install from an already-loaded server snapshot', () => {
fake.packages = { 'archipelago-source': installing() }
const store = useServerStore()
expect(store.isInstalling('archipelago-source')).toBe(true)
expect(store.installingApps.get('archipelago-source')).toMatchObject({
progress: 20,
message: 'Downloading, building and starting app…',
})
})
it('keeps a long download visible and clears it on terminal success', async () => {
const store = useServerStore()
fake.packages = { 'nginx-proxy-manager': installing() }
await nextTick()
expect(store.isInstalling('nginx-proxy-manager')).toBe(true)
fake.packages = { 'nginx-proxy-manager': installing() }
await nextTick()
expect(store.installingApps.get('nginx-proxy-manager')?.progress).toBe(20)
fake.packages = { 'nginx-proxy-manager': { state: 'running' } }
await nextTick()
expect(store.isInstalling('nginx-proxy-manager')).toBe(false)
})
})
+23 -1
View File
@@ -239,6 +239,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const panelPath = ref<string | null>(null) const panelPath = ref<string | null>(null)
function openSessionNow(appId: string, opts: LaunchOptions = {}) { function openSessionNow(appId: string, opts: LaunchOptions = {}) {
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg?.['ui-ready'] === false) {
useToast().info(`${pkg.manifest?.title || appId} is not ready to open yet`)
return
}
recordAppLaunch(appId) recordAppLaunch(appId)
const mobile = isMobileViewport() const mobile = isMobileViewport()
@@ -295,7 +300,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Apply the same readiness gate here so a container that has just entered // Apply the same readiness gate here so a container that has just entered
// `running` cannot race nginx and show a transient 502 to the user. // `running` cannot race nginx and show a transient 502 to the user.
const pkg = useAppStore().data?.['package-data']?.[appId] const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) { if (pkg && (pkg['ui-ready'] === false || (pkg.state === 'running' && !isAppReadyForLaunch(pkg)))) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`) useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return return
} }
@@ -394,6 +399,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId) let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
const pkg = resolvedId ? useAppStore().data?.['package-data']?.[resolvedId] : undefined
if (pkg?.['ui-ready'] === false) {
useToast().info(`${pkg.manifest?.title || resolvedId} is not ready to open yet`)
return
}
// Scheme discipline for everything launched on this host. Ports fronted // Scheme discipline for everything launched on this host. Ports fronted
// by the node's app gate (manifest auth gated/open) serve TLS on the same // by the node's app gate (manifest auth gated/open) serve TLS on the same
// port — on an HTTPS connection those must open over https. Ports that // port — on an HTTPS connection those must open over https. Ports that
@@ -472,6 +483,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Check /app/{id}/ path-style routes first (HTTPS proxy mode) // Check /app/{id}/ path-style routes first (HTTPS proxy mode)
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i) const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
if (m?.[1]) return m[1].toLowerCase() if (m?.[1]) return m[1].toLowerCase()
// Dynamic/sideloaded apps have no entry in the static port map.
if (u.hostname === window.location.hostname && u.port) {
for (const [id, pkg] of Object.entries(useAppStore().data?.['package-data'] || {})) {
const address = pkg.installed?.['interface-addresses']?.main?.['lan-address']
if (!address) continue
try {
const runtime = new URL(address)
if (runtime.port === u.port && ['localhost', '127.0.0.1', window.location.hostname].includes(runtime.hostname)) return id
} catch { /* malformed runtime address is not a launch target */ }
}
}
// Check port-based apps // Check port-based apps
const appId = PORT_TO_APP_ID[u.port] const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId if (appId) return appId
+2 -1
View File
@@ -25,6 +25,7 @@ import type { InstallPhase } from '../types/api'
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = { const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' }, 'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' }, 'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
'preparing-app': { progress: 20, message: 'Downloading, building and starting app…', status: 'downloading' },
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' }, 'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' }, 'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' }, 'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
@@ -149,7 +150,7 @@ export const useServerStore = defineStore('server', () => {
uninstallingApps.value.delete(appId) uninstallingApps.value.delete(appId)
} }
} }
}, { deep: true }) }, { deep: true, immediate: true })
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) { function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
const existing = installingApps.value.get(appId) const existing = installingApps.value.get(appId)
+2
View File
@@ -88,6 +88,7 @@ export const PackageState = {
export type PackageState = typeof PackageState[keyof typeof PackageState] export type PackageState = typeof PackageState[keyof typeof PackageState]
export interface PackageDataEntry { export interface PackageDataEntry {
'ui-ready'?: boolean // HTTP upstream readiness, separate from container health
state: PackageState state: PackageState
health?: string | null // "healthy", "unhealthy", "starting", or null health?: string | null // "healthy", "unhealthy", "starting", or null
'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash 'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash
@@ -180,6 +181,7 @@ export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
export type InstallPhase = export type InstallPhase =
| 'preparing' | 'preparing'
| 'pulling-image' | 'pulling-image'
| 'preparing-app'
| 'creating-container' | 'creating-container'
| 'starting-container' | 'starting-container'
| 'waiting-healthy' | 'waiting-healthy'
+1 -1
View File
@@ -259,7 +259,7 @@ const canLaunch = computed(() => {
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address'] const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id) const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl) const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy' return hasUI && pkg.value['ui-ready'] !== false && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
}) })
const features = computed(() => { const features = computed(() => {
+18
View File
@@ -39,6 +39,7 @@
:must-open-new-tab="mustOpenNewTab" :must-open-new-tab="mustOpenNewTab"
:auto-retry-count="autoRetryCount" :auto-retry-count="autoRetryCount"
:refresh-key="refreshKey" :refresh-key="refreshKey"
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
:blocked-reason="blockedReason" :blocked-reason="blockedReason"
:blocked-title="blockedTitle" :blocked-title="blockedTitle"
:warming-up="warmingUp" :warming-up="warmingUp"
@@ -375,6 +376,20 @@ const panelClasses = computed(() => {
return `${base} app-session-overlay` return `${base} app-session-overlay`
}) })
// A cold/restarting upstream is held outside the iframe. Start one fresh
// load when the scanner observes HTTP readiness; no manual refresh required.
watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
if (ready === false) {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (autoRetryId) clearTimeout(autoRetryId)
if (iframeCheckId) clearTimeout(iframeCheckId)
loading.value = false
} else if (previous === false && ready === true) {
autoRetryCount.value = 0
refresh()
}
})
// --- Lifecycle handlers --- // --- Lifecycle handlers ---
function onLoad() { function onLoad() {
@@ -433,6 +448,7 @@ function refresh() {
function startLoadTimeout() { function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId) if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (packageEntry.value?.['ui-ready'] === false) return
loadTimeoutId = setTimeout(() => { loadTimeoutId = setTimeout(() => {
if (loading.value) { if (loading.value) {
loading.value = false loading.value = false
@@ -442,11 +458,13 @@ function startLoadTimeout() {
} }
function openNewTabAndBack() { function openNewTabAndBack() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value) if (appUrl.value) openExternalUrl(appUrl.value)
closeSession() closeSession()
} }
function openNewTab() { function openNewTab() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value) if (appUrl.value) openExternalUrl(appUrl.value)
} }
@@ -6,7 +6,7 @@
first, then sync status arrives), and the sync screen is strictly first, then sync status arrives), and the sync screen is strictly
more informative, so it takes precedence instead of the two more informative, so it takes precedence instead of the two
rendering on top of each other. --> rendering on top of each other. -->
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" /> <AppLoadingScreen v-if="loading && !uiReadyBlocked && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
</Transition> </Transition>
<!-- ElectrumX sync screen — shown before the real UI while the on-chain <!-- ElectrumX sync screen — shown before the real UI while the on-chain
@@ -43,7 +43,7 @@
</Transition> </Transition>
<div <div
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)" v-if="appUrl && !iframeBlocked && !uiReadyBlocked && (!electrsSync || electrsSync.stale)"
class="absolute inset-0 app-session-frame-scroll-host" class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1" tabindex="-1"
@pointerdown="focusIframe" @pointerdown="focusIframe"
@@ -66,7 +66,7 @@
reachable yet, so the "App not reachable / retry" overlay would just reachable yet, so the "App not reachable / retry" overlay would just
paint over the sync progress and read as a hard error. --> paint over the sync progress and read as a hard error. -->
<Transition name="content-fade"> <Transition name="content-fade">
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center"> <div v-if="(iframeBlocked || uiReadyBlocked) && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8"> <div class="text-center px-8">
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock: <!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
the padlock reads as "blocked/denied" and this state is neither. --> the padlock reads as "blocked/denied" and this state is neither. -->
@@ -78,7 +78,8 @@
</div> </div>
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3> <h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<p class="text-white/50 text-sm mb-6"> <p class="text-white/50 text-sm mb-6">
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template> <template v-if="uiReadyBlocked">{{ blockedReason }} This screen opens automatically when it is ready.</template>
<template v-else-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template> <template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template> <template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template> <template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
@@ -95,6 +96,7 @@
Retry now Retry now
</button> </button>
<button <button
v-if="!uiReadyBlocked"
@click="$emit('openNewTabAndBack')" @click="$emit('openNewTabAndBack')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2" class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
> >
@@ -108,7 +110,7 @@
</div> </div>
</Transition> </Transition>
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center"> <div v-if="!appUrl && !uiReadyBlocked" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8"> <div class="text-center px-8">
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3> <h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p> <p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
@@ -133,6 +135,7 @@ const props = defineProps<{
mustOpenNewTab: boolean mustOpenNewTab: boolean
autoRetryCount: number autoRetryCount: number
refreshKey: number refreshKey: number
uiReadyBlocked?: boolean
blockedReason?: string blockedReason?: string
blockedTitle?: string blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the // True while the container is up but its probe hasn't answered yet and the
@@ -66,3 +66,19 @@ describe('AppSessionFrame warm-up state', () => {
expect(text).toContain('This app opens in a new tab') expect(text).toContain('This app opens in a new tab')
}) })
}) })
describe('HTTP readiness gate', () => {
it('does not show a missing-configuration error during initial installation', () => {
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
expect(frame.text()).not.toContain('App not configured')
expect(frame.find('iframe').exists()).toBe(false)
})
it('does not mount an iframe before readiness, then opens automatically', async () => {
const frame = mountFrame({ iframeBlocked: false, uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…', blockedTitle: 'App not ready' })
expect(frame.find('iframe').exists()).toBe(false)
expect(frame.text()).toContain('opens automatically')
expect(frame.text()).not.toContain('Open in new tab')
await frame.setProps({ uiReadyBlocked: false, blockedReason: '' })
expect(frame.find('iframe').exists()).toBe(true)
})
})
@@ -184,3 +184,24 @@ describe('appsConfig service filtering', () => {
expect(canLaunch(pkg)).toBe(true) expect(canLaunch(pkg)).toBe(true)
}) })
}) })
describe('HTTP readiness independent of container health', () => {
it('blocks fixed launch URLs while the HTTP upstream is unavailable', () => {
for (const id of ['gitea', 'filebrowser', 'fedimint', 'lnd']) {
const pkg = makePkg(id, id, 'other')
pkg['ui-ready'] = false
pkg.health = 'healthy'
expect(canLaunch(pkg)).toBe(false)
expect(isAppReadyForLaunch(pkg)).toBe(false)
expect(launchBlockedReason(id, pkg)).toContain('Waiting')
pkg['ui-ready'] = true
expect(isAppReadyForLaunch(pkg)).toBe(true)
}
})
it('allows a ready companion while its backend is syncing', () => {
const pkg = makePkg('lnd', 'Lightning', 'bitcoin')
pkg.health = 'starting'
pkg['ui-ready'] = true
expect(isAppReadyForLaunch(pkg)).toBe(true)
})
})
+6
View File
@@ -244,6 +244,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean { export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true if (isWebOnlyApp(pkg.manifest.id)) return true
if (pkg['ui-ready'] === false) return false
// Headless backends never get a Launch button, even with a published port. // Headless backends never get a Launch button, even with a published port.
if (isServicePackage(pkg.manifest.id, pkg)) return false if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address'] const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
@@ -277,6 +278,7 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
* health check retain the legacy state/port behaviour. * health check retain the legacy state/port behaviour.
*/ */
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean { export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
if (pkg['ui-ready'] !== undefined) return pkg['ui-ready']
const manifest = pkg.manifest as unknown as Record<string, unknown> const manifest = pkg.manifest as unknown as Record<string, unknown>
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check']) const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy' if (!hasHealthCheck) return pkg.health !== 'unhealthy'
@@ -285,6 +287,10 @@ export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string { export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id const appId = pkg?.manifest?.id || id
if (pkg?.['ui-ready'] === false && !isServicePackage(appId, pkg)) {
if (pkg.state === PackageState.Stopped || pkg.state === PackageState.Exited) return 'App is stopped. Start it to open it.'
return 'Waiting for the app to be ready…'
}
if ( if (
(appId === 'fedimint' || appId === 'fedimintd') && (appId === 'fedimint' || appId === 'fedimintd') &&
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting')) (pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))