carlos / platform Public

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.
Paul Campbell pushed by paul@keymail.dev 2bd906f76249d6e121eef8630bcae28a1efe8c92 parent 00152b3
3 files changed, +107 −1
  • cmd/console/main.go +15 −1
  • cmd/console/sdnotify.go +46 −0
  • cmd/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)
+ }
+}