fix(install): mesh app-port relay no longer kills the daemon on install

The v6 app-port relay preemptively bound [::]:<port> for ALL catalog
ports, even apps not installed. Installing such an app (grafana:3000,
photoprism, uptime-kuma, jellyfin — framework-pt 2026-07-27) then hit
'address already in use' from the relay, which triggered
cleanup_stale_pasta_port ->  -> killed archipelago
itself (it held the port) mid-install. The daemon crash-looped and the
half-created apps were rolled back and vanished.

Two fixes:
- relay only bridges a port that a running app already answers on over
  IPv4 (probe 127.0.0.1:port first) — an uninstalled app's port is never
  held, so its install sees a free port and never triggers the cleanup.
- cleanup_stale_pasta_port excludes our own PID from both the ss-based
  kill and the fuser kill, so freeing a port can never terminate the
  daemon even when the relay legitimately holds it (reinstall case).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-27 07:29:54 +01:00
parent 70bcbc4acc
commit 365f3d7d18
2 changed files with 37 additions and 6 deletions

View File

@ -2324,17 +2324,28 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
} }
async fn cleanup_stale_pasta_port(port: &str) { 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 framework-pt 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!( 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", "ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
port while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
); );
let _ = tokio::process::Command::new("sh") let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener]) .args(["-c", &kill_listener])
.output() .output()
.await; .await;
let _ = tokio::process::Command::new("sudo") // sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
.args(["fuser", "-k", &format!("{}/tcp", port)]) // 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() .output()
.await; .await;

View File

@ -1103,12 +1103,32 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
if bridged.contains(&port) { if bridged.contains(&port) {
continue; continue;
} }
// ONLY bridge a port that a running app already answers on
// over IPv4. Binding [::]:port for an app that isn't
// installed is actively harmful: it makes that app's
// later install hit "address already in use", and the
// install's port-free step (`fuser -k <port>/tcp`) then
// kills THIS daemon, which holds the port — the exact
// cause of installs failing + apps vanishing on
// framework-pt 2026-07-27. No v4 listener → skip; the
// next rescan picks it up once the app is up.
let v4_up = tokio::time::timeout(
std::time::Duration::from_millis(300),
tokio::net::TcpStream::connect(("127.0.0.1", port)),
)
.await
.ok()
.and_then(|r| r.ok())
.is_some();
if !v4_up {
continue;
}
let addr = SocketAddr::new( let addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port, port,
); );
// EADDRINUSE = the app itself (or a prior relay) already // EADDRINUSE = the app itself already answers on v6 —
// answers on v6 — exactly when we must stay out of the way. // exactly when we must stay out of the way.
let Ok(listener) = bind_v6_only(addr) else { continue }; let Ok(listener) = bind_v6_only(addr) else { continue };
bridged.insert(port); bridged.insert(port);
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}"); debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");