Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/compose/convergence.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
41 changes: 41 additions & 0 deletions pkg/compose/convergence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
}
9 changes: 9 additions & 0 deletions pkg/e2e/compose_up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions pkg/e2e/testdata/TestUpWaitTimeout/compose.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading