console: signal readiness to systemd after the listener is open
With Type=simple, systemctl restart succeeds the instant the binary is exec'd — so a console that dies on its first request restarts 'successfully' as far as the adopt pass can tell, and the pass stamps a version nothing is running. Readiness signalled after migrate + listen makes a broken build fail its restart, which adoption's rollback already handles. Placed in cmd/console (not internal/host, per controller ruling): nothing else in this slice needs it, and importing internal/host into the console binary for one 15-line function pulls a large package and its cross-compile surface into a binary that does not otherwise use it. Inert until console.service is declared Type=notify, which is the pinfra half of this change.
3 files changed,
+107
−1
cmd/console/main.go+15 −1cmd/console/sdnotify.go+46 −0cmd/console/sdnotify_test.go+46 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index e10202bca..38b55b902 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -57,6 +57,7 @@ import ( |
| "flag" |
| "fmt" |
| "log" |
| + "net" |
| "net/http" |
| "os" |
| "sort" |
| @@ -272,8 +273,21 @@ func serve() { |
| // NewServer, so constructing a Server stays goroutine-free. |
| go srv.StartEconomicsPass(context.Background()) |
| addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090") |
| + ln, err := net.Listen("tcp", addr) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| log.Printf("console: %s listening on %s", origin, addr) |
| - log.Fatal(http.ListenAndServe(addr, srv)) |
| + // AFTER the listener is open and the store has migrated (openConsoleDB |
| + // and NewServer both ran above), so readiness means what it says. With |
| + // console.service declared Type=notify, a build that dies before this |
| + // line fails its restart — and the adopt pass rolls the symlink back and |
| + // starts the previous version, instead of stamping a build that never |
| + // served (internal/release/adopt.go). |
| + if err := NotifyReady(); err != nil { |
| + log.Printf("console: notifying systemd of readiness: %v", err) |
| + } |
| + log.Fatal(http.Serve(ln, srv)) |
| } |
| func openConsoleDB() *console.Store { |
diff --git a/cmd/console/sdnotify.go b/cmd/console/sdnotify.go| new file mode 100644 |
| index 000000000..2487a6fd0 |
| --- /dev/null |
| +++ b/cmd/console/sdnotify.go |
| @@ -0,0 +1,46 @@ |
| +package main |
| + |
| +import ( |
| + "net" |
| + "os" |
| + "strings" |
| +) |
| + |
| +// NotifyReady tells systemd this process is ready to serve, for a unit |
| +// declared Type=notify. |
| +// |
| +// It exists so that "the process was exec'd" and "the process is serving" |
| +// stop being the same fact to systemd. With Type=simple, `systemctl restart` |
| +// succeeds the instant the binary is exec'd — so a console that starts and |
| +// then dies on its first request, or half-applies a migration, restarts |
| +// SUCCESSFULLY as far as the adopt pass can tell, and the pass then stamps a |
| +// version nothing is really running. Signalled after the listener is open, |
| +// a broken build fails its restart instead, and adoption's existing rollback |
| +// takes over. |
| +// |
| +// No dependency: the protocol is one datagram on the socket systemd names in |
| +// NOTIFY_SOCKET. An unset variable means "not under systemd" — a laptop, a |
| +// test, `go run` — and is not an error. |
| +// |
| +// An "@" prefix denotes a Linux abstract socket, which net.Dial expects as a |
| +// leading NUL. |
| +// |
| +// This is the code half only: it is INERT until console.service is declared |
| +// Type=notify, a pinfra change outside this repo — see the runbook in |
| +// .superpowers/sdd/2026-08-23-console-as-an-app/. |
| +func NotifyReady() error { |
| + sock := os.Getenv("NOTIFY_SOCKET") |
| + if sock == "" { |
| + return nil |
| + } |
| + if strings.HasPrefix(sock, "@") { |
| + sock = "\x00" + sock[1:] |
| + } |
| + conn, err := net.Dial("unixgram", sock) |
| + if err != nil { |
| + return err |
| + } |
| + defer conn.Close() |
| + _, err = conn.Write([]byte("READY=1")) |
| + return err |
| +} |
diff --git a/cmd/console/sdnotify_test.go b/cmd/console/sdnotify_test.go| new file mode 100644 |
| index 000000000..7d269adee |
| --- /dev/null |
| +++ b/cmd/console/sdnotify_test.go |
| @@ -0,0 +1,46 @@ |
| +package main |
| + |
| +import ( |
| + "net" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// TestNotifyReadyWritesToTheSocket: the whole contract. systemd hands the |
| +// process a unixgram socket path in NOTIFY_SOCKET and waits for READY=1 |
| +// before calling the unit started — which is what turns "exec'd" into |
| +// "actually serving" for the adopt pass's restart check. |
| +func TestNotifyReadyWritesToTheSocket(t *testing.T) { |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "notify.sock") |
| + conn, err := net.ListenPacket("unixgram", sock) |
| + if err != nil { |
| + t.Skipf("unixgram unavailable here: %v", err) |
| + } |
| + defer conn.Close() |
| + t.Setenv("NOTIFY_SOCKET", sock) |
| + |
| + if err := NotifyReady(); err != nil { |
| + t.Fatalf("NotifyReady: %v", err) |
| + } |
| + buf := make([]byte, 64) |
| + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) |
| + n, _, err := conn.ReadFrom(buf) |
| + if err != nil { |
| + t.Fatalf("nothing arrived on the notify socket: %v", err) |
| + } |
| + if got := string(buf[:n]); got != "READY=1" { |
| + t.Fatalf("sent %q, want READY=1", got) |
| + } |
| +} |
| + |
| +// TestNotifyReadyIsANoOpOutsideSystemd: the console runs under `go run`, in |
| +// tests and on a laptop, and none of those set NOTIFY_SOCKET. Absent is |
| +// normal, not an error. |
| +func TestNotifyReadyIsANoOpOutsideSystemd(t *testing.T) { |
| + t.Setenv("NOTIFY_SOCKET", "") |
| + if err := NotifyReady(); err != nil { |
| + t.Fatalf("NotifyReady with no socket = %v, want nil", err) |
| + } |
| +} |