From 74e5a7cedb3fc37e869bed6f1555fc9cb0508069 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 26 Apr 2026 21:22:31 -0300 Subject: [PATCH] cli: wait for the daemon socket to answer ping after install/restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systemd's Type=simple reports a unit "active" the moment its ExecStart binary is exec()'d, which for bangerd happens well before the daemon has read its config and bound /run/banger/bangerd.sock. 'banger system install' and 'banger system restart' both returned inside that window, so the very next 'banger ...' command would hit ensureDaemon, miss on a single ping, and exit with "service not reachable; run sudo banger system restart" — the same restart that had just succeeded. Smoke tripped over this on every run. Add waitForDaemonReady: poll daemonPing for up to 15s after the restart returns. Both the system install and restart paths now block until the daemon is genuinely accepting RPCs, so the next CLI invocation can talk to it without retrying. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/commands_system.go | 6 ++++++ internal/cli/daemon_lifecycle.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/internal/cli/commands_system.go b/internal/cli/commands_system.go index db9134b..7e72a2a 100644 --- a/internal/cli/commands_system.go +++ b/internal/cli/commands_system.go @@ -97,6 +97,9 @@ See docs/privileges.md for the full trust model. if err := d.runSystemctl(cmd.Context(), "restart", installmeta.DefaultService); err != nil { return err } + if err := d.waitForDaemonReady(cmd.Context(), paths.ResolveSystem().SocketPath); err != nil { + return err + } _, err := fmt.Fprintln(cmd.OutOrStdout(), "restarted") return err }, @@ -184,6 +187,9 @@ func (d *deps) runSystemInstall(ctx context.Context, out io.Writer, ownerFlag st if err := d.runSystemctl(ctx, "restart", installmeta.DefaultService); err != nil { return err } + if err := d.waitForDaemonReady(ctx, installmeta.DefaultSocketPath); err != nil { + return err + } _, err = fmt.Fprintf(out, "installed\nowner: %s\nsocket: %s\nhelper_socket: %s\nservice: %s\nhelper_service: %s\n", meta.OwnerUser, installmeta.DefaultSocketPath, installmeta.DefaultRootHelperSocketPath, installmeta.DefaultService, installmeta.DefaultRootHelperService) return err } diff --git a/internal/cli/daemon_lifecycle.go b/internal/cli/daemon_lifecycle.go index ec9f011..4c9f8c1 100644 --- a/internal/cli/daemon_lifecycle.go +++ b/internal/cli/daemon_lifecycle.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strings" + "time" "banger/internal/config" "banger/internal/installmeta" @@ -13,6 +14,37 @@ import ( "banger/internal/paths" ) +const ( + daemonReadyTimeout = 15 * time.Second + daemonReadyPollInterval = 100 * time.Millisecond +) + +// waitForDaemonReady blocks until the daemon at socketPath answers +// ping, the context is cancelled, or daemonReadyTimeout elapses. +// Used by `system install` and `system restart` so they don't return +// before the daemon has actually finished binding its socket — the +// systemd Type=simple unit reports "active" the moment the binary +// is exec()'d, well before bangerd has read its config and listened +// on the unix socket. +func (d *deps) waitForDaemonReady(ctx context.Context, socketPath string) error { + deadline := time.Now().Add(daemonReadyTimeout) + pingCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + for { + if _, err := d.daemonPing(pingCtx, socketPath); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("daemon did not become ready at %s within %s", socketPath, daemonReadyTimeout) + } + select { + case <-pingCtx.Done(): + return fmt.Errorf("daemon did not become ready at %s: %w", socketPath, pingCtx.Err()) + case <-time.After(daemonReadyPollInterval): + } + } +} + var ( loadInstallMetadata = func() (installmeta.Metadata, error) { return installmeta.Load(installmeta.DefaultPath)