| index 95ae102..40eceb6 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -619,10 +619,15 @@ func runHibernate(args []string) { |
| die("hibernate: %s is %s, not ready — fix the instance first", host, rt.Status) |
| } |
| if *on { |
| - if rt.Hibernate { |
| + if rt.Hibernate && !unitActive(*unitTmpl+host) { |
| fmt.Printf("%s is already hibernating\n", host) |
| return |
| } |
| + // Flag set but the unit still runs = a re-run after the litestream |
| + // guard's two-step (the error message PROMISES this re-run works — |
| + // hit live on the first bulk flip, where the early-exit above |
| + // stranded hosts flagged-but-materialized). Fall through and finish |
| + // the handover; hibernateOn is idempotent over an already-set flag. |
| if err := hibernateOn(reg, rt, *unitTmpl, *botUnit, *routerUnit); err != nil { |
| die("hibernate %s --on: %v", host, err) |
| } |
| @@ -667,6 +672,13 @@ func runHibernate(args []string) { |
| // Nothing is checkpointed here and no S3 is touched: the DB is already local, |
| // so the first wake's restore sees it as the warm cache and skips; replication |
| // under the fresh lease is what backfills S3. |
| +// unitActive reports whether the systemd unit is running — the discriminator |
| +// between "already fully hibernating" and "flag set, handover unfinished" |
| +// (the litestream guard's two-step leaves exactly that state behind). |
| +func unitActive(unit string) bool { |
| + return systemctlQuery("is-active", "--quiet", unit) == nil |
| +} |
| + |
| func hibernateOn(reg *Registry, rt *Route, unitTmpl, botUnit, routerUnit string) error { |
| // Existing instances' env files were written for the systemd path (root |
| // reads them); the router's activator reads them as the service user, so |
| @@ -1110,6 +1122,13 @@ func waitReachable(socket string, timeout time.Duration) error { |
| // systemctl is a var so tests can stand in for systemd (e.g. bind the instance |
| // socket themselves when they see "enable --now"); everything else in the |
| // creation path — env files, registry, symlinks, health checks — runs for real. |
| +// systemctlQuery is the read-only twin of systemctl (is-active etc.) — its own |
| +// seam so tests asserting exact mutating-call sequences aren't polluted by |
| +// state queries. |
| +var systemctlQuery = func(args ...string) error { |
| + return exec.Command("systemctl", args...).Run() |
| +} |
| + |
| var systemctl = func(args ...string) error { |
| out, err := exec.Command("systemctl", args...).CombinedOutput() |
| if err != nil { |