diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index a5e0c146fe..5fa9027c2f 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -198,6 +198,11 @@ func (s *composeService) waitDependency(ctx context.Context, dep string, config select { case <-ticker.C: case <-ctx.Done(): + // An expired deadline is precisely the failure this wait is meant + // to detect; only a plain cancellation (Ctrl-C) stays silent. + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return ctx.Err() + } return nil } var ( diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go index 5b4514d067..616408ae7d 100644 --- a/pkg/compose/convergence_test.go +++ b/pkg/compose/convergence_test.go @@ -22,6 +22,7 @@ import ( "net/netip" "strings" "testing" + "time" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli/config/configfile" @@ -730,3 +731,43 @@ func TestRuntimeAPIVersionRetriesOnTransientError(t *testing.T) { assert.NilError(t, err) assert.Equal(t, version, "1.44") } + +// TestWaitDependencyDeadline locks the timeout semantics of the dependency +// wait: an expired deadline surfaces as "timeout waiting for dependencies", +// while a plain user cancellation is not a wait failure. +func TestWaitDependencyDeadline(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + tested, err := NewComposeService(cli) + assert.NilError(t, err) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + + project := types.Project{Name: strings.ToLower(testProject), Services: types.Services{ + "db": {Name: "db", Scale: intPtr(1)}, + }} + dependencies := types.DependsOnConfig{ + "db": {Condition: types.ServiceConditionHealthy, Required: true}, + } + containers := Containers{{ + ID: "db-ctr", + Names: []string{"/db-ctr"}, + Labels: map[string]string{api.ServiceLabel: "db"}, + }} + + t.Run("expired deadline is an error", func(t *testing.T) { + // Timeout shorter than the first 500ms poll tick: the deadline fires + // before any condition check, and must not be swallowed. + err := tested.(*composeService).waitDependencies(t.Context(), &project, "app", dependencies, containers, 50*time.Millisecond) + assert.Error(t, err, "timeout waiting for dependencies") + }) + + t.Run("user cancellation is not a wait failure", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := tested.(*composeService).waitDependencies(ctx, &project, "app", dependencies, containers, 0) + assert.NilError(t, err) + }) +} diff --git a/pkg/e2e/compose_up_test.go b/pkg/e2e/compose_up_test.go index 1acaf7393d..35296d8f2c 100644 --- a/pkg/e2e/compose_up_test.go +++ b/pkg/e2e/compose_up_test.go @@ -31,6 +31,15 @@ func TestUpWait(t *testing.T) { ServiceState("oneshot", "exited")) } +func TestUpWaitTimeout(t *testing.T) { + s := NewScenario(t, "up --wait --wait-timeout must fail once the timeout expires instead of silently succeeding") + s.Step("up --wait fails after the timeout with the service still starting", + ComposeCmd("up", "--wait", "--wait-timeout", "3", "-d").MayFail().Within(60*time.Second), + ExitCode(1), + OutputContains("application not healthy after 3s"), + ServiceState("app", "running")) +} + func TestUpExitCodeFrom(t *testing.T) { NewScenario(t, "up --exit-code-from must return the selected service's exit code"). Step("up returns the failing service's code once it exits", diff --git a/pkg/e2e/testdata/TestUpWaitTimeout/compose.yaml b/pkg/e2e/testdata/TestUpWaitTimeout/compose.yaml new file mode 100644 index 0000000000..25f423d6f9 --- /dev/null +++ b/pkg/e2e/testdata/TestUpWaitTimeout/compose.yaml @@ -0,0 +1,12 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + healthcheck: + # Keeps failing during a long start_period, so health stays "starting" + # and --wait can only end by hitting its timeout. + test: ["CMD", "false"] + interval: 1s + retries: 3 + start_period: 120s