diff --git a/internal/cli/service_test.go b/internal/cli/service_test.go index 949e617..a4249ce 100644 --- a/internal/cli/service_test.go +++ b/internal/cli/service_test.go @@ -11,6 +11,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/spf13/cobra" @@ -102,3 +103,35 @@ func TestStopSetsTheClustersServingStopped(t *testing.T) { t.Errorf("%s", out.String()) } } + +// Started while the control plane has not yet taken the setting up: every +// model of a stopped system has the nothing it asks for, which is not +// serving. +func TestStartWaitsForTheControlPlaneToServe(t *testing.T) { + old := servicePoll + servicePoll = time.Millisecond + t.Cleanup(func() { servicePoll = old }) + answers := []adminapi.Summary{ + {Stopped: true, Models: adminapi.ModelTotals{Total: 2, Satisfied: 2}}, + {Models: adminapi.ModelTotals{Total: 2, Satisfied: 0}}, + {Models: adminapi.ModelTotals{Total: 2, Satisfied: 2}}, + } + asked := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(answers[min(asked, len(answers)-1)]) + asked++ + })) + defer srv.Close() + + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + cmd.SetContext(context.Background()) + c := &ctlClient{base: srv.URL, http: srv.Client()} + if err := waitForService(cmd, func() *ctlClient { return c }, false); err != nil { + t.Fatal(err) + } + if asked != 3 { + t.Errorf("returned after %d answers: %s", asked, out.String()) + } +} diff --git a/internal/cli/system.go b/internal/cli/system.go index 7de9502..e4e3faf 100644 --- a/internal/cli/system.go +++ b/internal/cli/system.go @@ -190,13 +190,21 @@ func newSystemCmds() []*cobra.Command { return []*cobra.Command{status, switchService(true), switchService(false), restart} } +// servicePoll is how often --wait asks; a variable so tests need not wait. +var servicePoll = 5 * time.Second + // waitForService waits until no replica runs (stopped) or every model has // the replicas it asks for (started). +// +// Only once the control plane says it is in the state asked for: it takes the +// setting up at its next look at the store, seconds later, and until then a +// stopped system's models, which ask for nothing, all have what they ask for — +// so llmgw start --wait returned at once, with nothing started. func waitForService(cmd *cobra.Command, call func() *ctlClient, stop bool) error { deadline := time.Now().Add(15 * time.Minute) for time.Now().Before(deadline) { var s adminapi.Summary - if err := call().get(cmd.Context(), "/api/v1/summary", &s); err == nil { + if err := call().get(cmd.Context(), "/api/v1/summary", &s); err == nil && s.Stopped == stop { if stop && s.Replicas.Ready+s.Replicas.Starting+s.Replicas.Draining == 0 { fmt.Fprintln(cmd.OutOrStdout(), "stopped: no replica runs") return nil @@ -210,7 +218,7 @@ func waitForService(cmd *cobra.Command, call func() *ctlClient, stop bool) error select { case <-cmd.Context().Done(): return cmd.Context().Err() - case <-time.After(5 * time.Second): + case <-time.After(servicePoll): } } return errors.New("not there within fifteen minutes; llmgw status says where it stands")