eleven / messenger Public

server: update status reads sleeping hosts' prefs from the parked DB (#774)

After #778, update run honors a hibernating host's parked-DB prefs but
update status still probed only the socket — every sleeping host showed
'?' for CHANNEL/AUTO, hiding exactly the pin that fleet operations must
not stomp. statusPrefs falls back to hibernatingPrefs (read-only,
fail-closed, WAL-ownership repair included) for hibernate-flagged hosts
whose socket doesn't answer; an unreadable DB keeps the honest '?'.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8f793b8d26eaea3f071a39db56915f82034f77f6 parent ba92d87
3 files changed, +56 −2
  • docs/server-releases.md +4 −1
  • updater.go +14 −1
  • updater_test.go +38 −0
diff --git a/docs/server-releases.md b/docs/server-releases.md
index 2aeda4c..1f37462 100644
--- a/docs/server-releases.md
+++ b/docs/server-releases.md
@@ -168,7 +168,10 @@ recover with `lchat rollback <host>`, which repoints back without waking;
`lchat deploy <host> --version <ver>` likewise repoints), and a
currently-awake child keeps its old binary until it next sleeps. A
sidecar-bearing space that never sleeps therefore still needs the flip-off
-flow (`lchat hibernate <host> --off`, update, `--on`).
+flow (`lchat hibernate <host> --off`, update, `--on`). `lchat update status`
+reads a sleeping host's prefs the same parked-DB way, so its CHANNEL/AUTO
+columns (including a pin) show real values instead of `?` — an invisible pin
+is how a fleet operation stomps a canary.
Same rule as deploys and TestFlight: **publish from landed `main`**, never a
feature branch (the script refuses a dirty tree). Publishing is manual for now;
diff --git a/updater.go b/updater.go
index 64851df..74bb0cc 100644
--- a/updater.go
+++ b/updater.go
@@ -686,6 +686,19 @@ func runUpdateRun(args []string) {
}
}
+// statusPrefs resolves the prefs a status row shows: the live socket when the
+// host answers; a hibernating host's parked DB (the same fail-closed read the
+// update pass trusts) when it doesn't. The error keeps the caller's honest
+// "?" for a host unreadable either way — status must never guess what an
+// admin chose.
+func statusPrefs(rt Route) (updatePrefs, error) {
+ p, err := fetchUpdatePrefs(rt.Socket)
+ if err != nil && rt.Hibernate {
+ return hibernatingPrefs(rt.Host)
+ }
+ return p, err
+}
+
// runUpdateStatus prints where every instance stands relative to its channel.
func runUpdateStatus(args []string) {
fs := flag.NewFlagSet("update status", flag.ExitOnError)
@@ -708,7 +721,7 @@ func runUpdateStatus(args []string) {
continue
}
channel, auto := "?", "?"
- if prefs, err := fetchUpdatePrefs(rt.Socket); err == nil {
+ if prefs, err := statusPrefs(rt); err == nil {
channel = prefs.Channel
switch {
case prefs.Pin != "":
diff --git a/updater_test.go b/updater_test.go
index 5897281..0b6fde1 100644
--- a/updater_test.go
+++ b/updater_test.go
@@ -928,3 +928,41 @@ func TestUpdatePassRepointsHibernating(t *testing.T) {
}
assertLive("h1.hib", "new1234")
}
+
+func TestStatusPrefsReadsParkedDB(t *testing.T) {
+ oldData := instanceDataDir
+ instanceDataDir = t.TempDir()
+ t.Cleanup(func() { instanceDataDir = oldData })
+ deadSocket := filepath.Join(t.TempDir(), "no.sock")
+
+ // A sleeping pinned canary: nothing listens on its socket, its prefs live
+ // only in the parked DB — status must show them, not "?" (an invisible pin
+ // is how a fleet operation stomps a canary).
+ db, err := openDB(filepath.Join(instanceDataDir, "h1.hib.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range map[string]string{"update_channel": "edge", "update_pin": "pin1234"} {
+ if err := db.setConfig(k, v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ db.sql.Close()
+
+ p, err := statusPrefs(Route{Host: "h1.hib", Hibernate: true, Socket: deadSocket})
+ if err != nil {
+ t.Fatalf("statusPrefs: %v", err)
+ }
+ if p.Channel != "edge" || p.Pin != "pin1234" {
+ t.Fatalf("prefs = %+v, want edge/pin1234", p)
+ }
+
+ // Fail-closed both ways: a sleeping host with no local DB stays an honest
+ // "?", and a unit-managed host's dead socket never falls back to a DB read.
+ if _, err := statusPrefs(Route{Host: "h2.hib", Hibernate: true, Socket: deadSocket}); err == nil {
+ t.Fatal("expected an error for an absent parked DB")
+ }
+ if _, err := statusPrefs(Route{Host: "h1.hib", Hibernate: false, Socket: deadSocket}); err == nil {
+ t.Fatal("expected an error for a dead socket on a unit-managed host")
+ }
+}