fix(containers): preserve graceful shutdown through Quadlet and prepare 1.8.21

This commit is contained in:
archipelago
2026-09-30 04:59:30 -04:00
parent c7ce35bd43
commit 33d2b3ce60
8 changed files with 207 additions and 10 deletions
+6
View File
@@ -2,6 +2,12 @@
## Unreleased ## Unreleased
## v1.8.21-alpha (2026-09-30)
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
## v1.8.20-alpha (2026-09-29) ## v1.8.20-alpha (2026-09-29)
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change. - Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.20-alpha" version = "1.8.21-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.20-alpha" version = "1.8.21-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
+155 -5
View File
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
pub no_new_privileges: bool, pub no_new_privileges: bool,
pub cpu_quota: Option<u32>, pub cpu_quota: Option<u32>,
pub restart_policy: RestartPolicy, pub restart_policy: RestartPolicy,
pub stop_grace_secs: Option<u64>,
} }
impl QuadletUnit { impl QuadletUnit {
@@ -216,6 +217,10 @@ impl QuadletUnit {
let _ = writeln!(s, "[Container]"); let _ = writeln!(s, "[Container]");
let _ = writeln!(s, "ContainerName={}", self.name); let _ = writeln!(s, "ContainerName={}", self.name);
let _ = writeln!(s, "Image={}", self.image); let _ = writeln!(s, "Image={}", self.image);
let grace = self
.stop_grace_secs
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
let _ = writeln!(s, "StopTimeout={grace}");
// Pull=never: companions are pre-pulled or built. A missing image // Pull=never: companions are pre-pulled or built. A missing image
// must surface as a unit start failure, not a silent retry storm. // must surface as a unit start failure, not a silent retry storm.
let _ = writeln!(s, "Pull=never"); let _ = writeln!(s, "Pull=never");
@@ -350,6 +355,15 @@ impl QuadletUnit {
// the unit stuck in deactivating. Health/status remains app-level state, // the unit stuck in deactivating. Health/status remains app-level state,
// not a systemd start gate. // not a systemd start gate.
let _ = writeln!(s, "TimeoutStartSec=0"); let _ = writeln!(s, "TimeoutStartSec=0");
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
// Stop explicitly before Quadlet's generated `podman rm -f`. The
// existing container may still carry Podman's old 10-second default;
// StopTimeout alone only protects containers created after migration.
let _ = writeln!(s, "ExecStop=");
let _ = writeln!(
s,
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
);
// Restart policy + 10s backoff. RestartSec keeps a crash-loop // Restart policy + 10s backoff. RestartSec keeps a crash-loop
// from saturating the journal. Companions: Always. Backends: // from saturating the journal. Companions: Always. Backends:
// OnFailure (clean stops stay stopped). // OnFailure (clean stops stay stopped).
@@ -525,6 +539,9 @@ impl QuadletUnit {
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a // Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
// cleanly-exited app deleted and unrestarted. See RestartPolicy. // cleanly-exited app deleted and unrestarted. See RestartPolicy.
restart_policy: RestartPolicy::Always, restart_policy: RestartPolicy::Always,
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
manifest, name,
)),
} }
} }
} }
@@ -792,7 +809,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
/// corruption — so the orchestrator passes the per-app grace here. Never waits /// corruption — so the orchestrator passes the per-app grace here. Never waits
/// less than `QUADLET_STOP_TIMEOUT`. /// less than `QUADLET_STOP_TIMEOUT`.
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> { pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
let timeout = timeout.max(QUADLET_STOP_TIMEOUT); let name = service.strip_suffix(".service").unwrap_or(service);
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
.await
.unwrap_or_default();
let timeout = timeout.max(stop_wait_timeout(name, &body));
match systemctl_user_status(&["stop", service], timeout).await { match systemctl_user_status(&["stop", service], timeout).await {
Ok(status) if status.success() => Ok(()), Ok(status) if status.success() => Ok(()),
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")), Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
@@ -813,6 +834,20 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
} }
} }
/// The command waiter must outlive both the container grace and systemd's
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
.max(QUADLET_STOP_TIMEOUT)
}
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
directive_values(unit_body, "StopTimeout=")
.last()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
}
async fn systemctl_user_status( async fn systemctl_user_status(
args: &[&str], args: &[&str],
timeout: Duration, timeout: Duration,
@@ -939,6 +974,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
/// that systemd no longer knows about. /// that systemd no longer knows about.
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> { pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
let svc = format!("{unit_name}.service"); let svc = format!("{unit_name}.service");
let path = dir.join(format!("{unit_name}.container"));
let body = fs::read_to_string(&path).await.unwrap_or_default();
let timeout = stop_wait_timeout(unit_name, &body);
let grace = stop_grace_from_unit(unit_name, &body).to_string();
// Stop first; ignore failure (unit may already be down). BOUNDED — on // Stop first; ignore failure (unit may already be down). BOUNDED — on
// rootless podman a generated unit can wedge in "deactivating" while // rootless podman a generated unit can wedge in "deactivating" while
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop` // `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
@@ -946,13 +985,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
// the package entry is stranded in `Removing` (a ghost in My Apps that also // the package entry is stranded in `Removing` (a ghost in My Apps that also
// blocks reinstall). If the graceful stop times out, escalate to // blocks reinstall). If the graceful stop times out, escalate to
// SIGKILL + reset-failed so teardown always proceeds. // SIGKILL + reset-failed so teardown always proceeds.
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT) if systemctl_user_status(&["stop", &svc], timeout)
.await .await
.is_err() .is_err()
{ {
let _ = kill_and_reset_service(&svc).await; let _ = kill_and_reset_service(&svc).await;
} }
let path = dir.join(format!("{unit_name}.container"));
if fs::try_exists(&path).await.unwrap_or(false) { if fs::try_exists(&path).await.unwrap_or(false) {
match fs::remove_file(&path).await { match fs::remove_file(&path).await {
Ok(()) => {} Ok(()) => {}
@@ -965,9 +1003,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
// Bounded so a hung podman store can't re-introduce the stall this function // Bounded so a hung podman store can't re-introduce the stall this function
// exists to avoid. // exists to avoid.
let _ = tokio::time::timeout( let _ = tokio::time::timeout(
QUADLET_STOP_TIMEOUT, timeout,
Command::new("podman") Command::new("podman")
.args(["rm", "-f", unit_name]) .args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
.status(), .status(),
) )
.await; .await;
@@ -992,6 +1030,118 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
#[test]
fn shutdown_grace_covers_container_systemd_and_caller() {
for (name, grace) in [
("bitcoin-core", 600),
("bitcoin-knots", 600),
("lnd", 330),
("electrumx", 300),
("other", 30),
] {
let unit = QuadletUnit {
name: name.into(),
..Default::default()
};
let body = unit.render();
assert!(body.contains(&format!("StopTimeout={grace}\n")));
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
assert_eq!(
stop_wait_timeout(name, &body),
Duration::from_secs(grace + 30)
);
// Legacy units have no StopTimeout directive yet.
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
}
}
#[test]
fn custom_stop_grace_survives_render_and_restart_budget() {
let manifest: AppManifest = serde_yaml::from_str(
r#"
app:
id: custom-db
name: Custom database
version: 1.0.0
stop_grace_secs: 900
container:
image: example/db:1
"#,
)
.unwrap();
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
assert_eq!(unit.stop_grace_secs, Some(900));
assert_eq!(
stop_wait_timeout("custom-db", &unit.render()),
Duration::from_secs(930)
);
assert_eq!(
stop_wait_timeout("lnd", "StopTimeout=invalid"),
Duration::from_secs(360)
);
}
#[test]
fn stop_grace_migration_does_not_request_an_execution_restart() {
let unit = sample_unit();
let new = unit.render();
let old = new
.lines()
.filter(|line| {
!line.starts_with("StopTimeout=")
&& !line.starts_with("TimeoutStopSec=")
&& !line.starts_with("ExecStop=")
})
.collect::<Vec<_>>()
.join("\n");
assert!(!exec_changed(&old, &new));
assert!(!publish_ports_changed(&old, &new));
assert!(!network_aliases_changed(&old, &new));
assert!(!health_cmd_changed(&old, &new));
}
#[test]
fn actual_quadlet_generator_stops_before_forced_removal() {
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
if !generator.exists() {
eprintln!(
"Quadlet generator unavailable; run this regression on the Linux release host"
);
return;
}
let dir = tempdir().unwrap();
let unit = QuadletUnit {
name: "grace-test".into(),
image: "localhost/test:latest".into(),
stop_grace_secs: Some(600),
..Default::default()
};
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
let output = std::process::Command::new(generator)
.args(["--user", "--dryrun"])
.env("QUADLET_UNIT_DIRS", dir.path())
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let generated = String::from_utf8_lossy(&output.stdout).to_string()
+ &String::from_utf8_lossy(&output.stderr);
let stop = generated
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
.unwrap();
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
assert!(
stop < remove,
"Legacy container must stop gracefully before removal"
);
assert!(generated.contains("--stop-timeout 600"));
assert!(generated.contains("TimeoutStopSec=615"));
}
#[test] #[test]
fn render_emits_secret_env_by_reference_never_value() { fn render_emits_secret_env_by_reference_never_value() {
let u = QuadletUnit { let u = QuadletUnit {
+29
View File
@@ -257,3 +257,32 @@ Both catalog and OTA signatures verify against the pinned release root. Staged
artifact hash/size checks and catalog drift/trust checks pass. User accepted the artifact hash/size checks and catalog drift/trust checks pass. User accepted the
remaining Framework display check and explicitly authorized release. Publication remaining Framework display check and explicitly authorized release. Publication
and ISO build may proceed; do not regenerate the signed manifest or artifacts. and ISO build may proceed; do not regenerate the signed manifest or artifacts.
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
still used its ten-second default and killed Bitcoin. Core replayed its block
index; LND later lost its connection to the previous Bitcoin container IP.
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
Installed explicit graceful-stop systemd overrides on dev and Shorty without
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
and command-wait budgets, including existing containers and uninstall fallback.
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
Full tests, disposable slow-stop verification, build and deployment remain pending.
Disposable live regression passed: started an Alpine container with its legacy
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
second graceful stop, verified the same container ID and old internal timeout
remained running, then stopped it. Its twelve-second shutdown handler completed
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
Fixture had no network or wallet mounts and was removed afterward.
Core finished index loading and resumed unpruned initial sync. LND automatically
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
automatically unlocked again and reached chain-sync waiting. No manual wallet
unlock or restart was used for this recovery.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.19-alpha", "version": "1.8.21-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.19-alpha", "version": "1.8.21-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.20-alpha", "version": "1.8.21-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
@@ -362,6 +362,18 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.21-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.21-alpha</span>
<span class="text-xs text-white/40">September 30, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.</p>
<p>Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.</p>
<p>Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.</p>
</div>
</div>
<!-- v1.8.20-alpha --> <!-- v1.8.20-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">