From d9a6ce9b0d9e9ec657e37ee7ac38a1561d31e770 Mon Sep 17 00:00:00 2001 From: Peter Jausovec Date: Thu, 5 Mar 2026 10:21:53 -0800 Subject: [PATCH 1/2] fix: wait for daemon readiness after auto-start Co-Authored-By: Joel Klabo --- pkg/cli/root.go | 3 +++ pkg/cli/root_test.go | 1 + pkg/daemon/daemon.go | 20 ++++++++++++++++++++ pkg/types/types.go | 2 ++ 4 files changed, 26 insertions(+) diff --git a/pkg/cli/root.go b/pkg/cli/root.go index d1a66a16..66c1c37e 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -234,6 +234,9 @@ func preRunSetup(ctx context.Context, cmd *cobra.Command, baseURL, token string, if err := dm.Start(); err != nil { return nil, fmt.Errorf("failed to start daemon: %w", err) } + if err := dm.WaitForReady(baseURL); err != nil { + return nil, fmt.Errorf("daemon started but not ready: %w", err) + } } } diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index a8c2118c..7f583bdd 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -335,6 +335,7 @@ func (m *mockDaemonManager) Start() error { m.startCalled = true return nil } +func (m *mockDaemonManager) WaitForReady(baseURL string) error { return nil } // mockAuthnProvider for unit tests. type mockAuthnProvider struct { diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 625e4962..7d1c682f 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -151,6 +151,26 @@ func (d *DefaultDaemonManager) Start() error { return nil } +func (d *DefaultDaemonManager) WaitForReady(baseURL string) error { + pingURL := strings.TrimRight(baseURL, "/") + "/ping" + httpClient := &http.Client{Timeout: 2 * time.Second} + deadline := time.Now().Add(30 * time.Second) + delay := 500 * time.Millisecond + + for time.Now().Before(deadline) { + resp, err := httpClient.Get(pingURL) + if err == nil { + resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + time.Sleep(delay) + delay = min(delay*2, 4*time.Second) + } + return fmt.Errorf("daemon did not become ready within 30 seconds") +} + func (d *DefaultDaemonManager) IsRunning() bool { // First check if a server is responding on the API port (local or Docker) if isServerResponding() { diff --git a/pkg/types/types.go b/pkg/types/types.go index 445058a1..c50e1f1b 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -152,6 +152,8 @@ type DaemonManager interface { IsRunning() bool // Start starts the daemon, blocking until it's ready Start() error + // WaitForReady polls the daemon API until it responds or the timeout expires + WaitForReady(baseURL string) error } // CLIAuthnProvider provides authentication for CLI commands. From 4778d00f314bc700685ba5299291586f699bea28 Mon Sep 17 00:00:00 2001 From: Peter Jausovec Date: Thu, 5 Mar 2026 11:12:14 -0800 Subject: [PATCH 2/2] fix lint issues, add a test Signed-off-by: Peter Jausovec --- pkg/cli/root.go | 42 +++++++++++++-------- pkg/cli/root_test.go | 89 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 20 deletions(-) diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 66c1c37e..2da4b702 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -46,9 +46,11 @@ type CLIOptions struct { } var ( - cliOptions CLIOptions - registryURL string - registryToken string + cliOptions CLIOptions + registryURL string + registryToken string + // Package-level var so tests can stub out the real docker-compose check. + isDockerComposeAvailable = utils.IsDockerComposeAvailable ) // Configure applies options to the root command (e.g. for tests or alternate entry points). @@ -224,19 +226,8 @@ func preRunSetup(ctx context.Context, cmd *cobra.Command, baseURL, token string, } if autoStartDaemon { - if !utils.IsDockerComposeAvailable() { - fmt.Println("Docker compose is not available. Please install docker compose and try again.") - fmt.Println("See https://docs.docker.com/compose/install/ for installation instructions.") - fmt.Println("agent registry uses docker compose to start the server and the agent gateway.") - return nil, fmt.Errorf("docker compose is not available") - } - if !dm.IsRunning() { - if err := dm.Start(); err != nil { - return nil, fmt.Errorf("failed to start daemon: %w", err) - } - if err := dm.WaitForReady(baseURL); err != nil { - return nil, fmt.Errorf("daemon started but not ready: %w", err) - } + if err := ensureDaemonRunning(dm, baseURL); err != nil { + return nil, err } } @@ -268,3 +259,22 @@ func preRunSetup(ctx context.Context, cmd *cobra.Command, baseURL, token string, } return c, nil } + +func ensureDaemonRunning(dm types.DaemonManager, baseURL string) error { + if !isDockerComposeAvailable() { + fmt.Println("Docker compose is not available. Please install docker compose and try again.") + fmt.Println("See https://docs.docker.com/compose/install/ for installation instructions.") + fmt.Println("agent registry uses docker compose to start the server and the agent gateway.") + return fmt.Errorf("docker compose is not available") + } + if dm.IsRunning() { + return nil + } + if err := dm.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + if err := dm.WaitForReady(baseURL); err != nil { + return fmt.Errorf("daemon started but not ready: %w", err) + } + return nil +} diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 7f583bdd..8149891a 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "errors" + "strings" "testing" "github.com/agentregistry-dev/agentregistry/internal/client" @@ -326,16 +327,18 @@ func TestPreRunSetup(t *testing.T) { // mockDaemonManager for unit tests. type mockDaemonManager struct { - running bool - startCalled bool + running bool + startCalled bool + startErr error + waitReadyErr error } func (m *mockDaemonManager) IsRunning() bool { return m.running } func (m *mockDaemonManager) Start() error { m.startCalled = true - return nil + return m.startErr } -func (m *mockDaemonManager) WaitForReady(baseURL string) error { return nil } +func (m *mockDaemonManager) WaitForReady(baseURL string) error { return m.waitReadyErr } // mockAuthnProvider for unit tests. type mockAuthnProvider struct { @@ -352,3 +355,81 @@ func (m *mockAuthnProvider) Authenticate(context.Context) (string, error) { var _ types.DaemonManager = (*mockDaemonManager)(nil) var _ types.CLIAuthnProvider = (*mockAuthnProvider)(nil) + +func TestEnsureDaemonRunning(t *testing.T) { + startErr := errors.New("start failed") + waitErr := errors.New("not ready") + + tests := []struct { + name string + dockerAvail bool + dm *mockDaemonManager + wantErr string + wantStartCalled bool + }{ + { + name: "docker compose not available", + dockerAvail: false, + dm: &mockDaemonManager{}, + wantErr: "docker compose is not available", + }, + { + name: "daemon already running", + dockerAvail: true, + dm: &mockDaemonManager{running: true}, + wantErr: "", + wantStartCalled: false, + }, + { + name: "daemon not running starts successfully", + dockerAvail: true, + dm: &mockDaemonManager{running: false}, + wantErr: "", + wantStartCalled: true, + }, + { + name: "daemon start fails", + dockerAvail: true, + dm: &mockDaemonManager{running: false, startErr: startErr}, + wantErr: "failed to start daemon", + wantStartCalled: true, + }, + { + name: "daemon started but not ready", + dockerAvail: true, + dm: &mockDaemonManager{running: false, waitReadyErr: waitErr}, + wantErr: "daemon started but not ready", + wantStartCalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origCheck := isDockerComposeAvailable + isDockerComposeAvailable = func() bool { return tt.dockerAvail } + defer func() { isDockerComposeAvailable = origCheck }() + + err := ensureDaemonRunning(tt.dm, "http://localhost:12121") + + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } else { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr) + } + } + + if tt.wantStartCalled && !tt.dm.startCalled { + t.Error("expected Start() to be called") + } + if !tt.wantStartCalled && tt.dm.startCalled { + t.Error("expected Start() not to be called") + } + }) + } +}