From 79461b5e3eb4d5bc469b39e73b3717b9128ac30d Mon Sep 17 00:00:00 2001 From: Naveenkumar Date: Sat, 5 Sep 2026 11:38:11 +0000 Subject: [PATCH] feat(up): allow --wait to stream logs via --attach/--attach-dependencies --wait has always implied detached mode, so there was no way to see a service's logs while waiting for it to become running|healthy short of running `compose logs -f` in parallel. --wait can now be combined with --attach or --attach-dependencies: the attached services' logs stream normally while --wait polls for the condition, then the session detaches (leaving containers running) once the condition is met, exactly like the existing keyboard detach shortcut, instead of following logs forever like a plain foreground `up`. Plain --wait (no --attach/--attach-dependencies) keeps its historical silent, detached behavior unchanged. --wait remains incompatible with --abort-on-container-exit, --abort-on-container-failure and --watch. Closes #9122 Co-Authored-By: Claude Sonnet 5 Signed-off-by: Naveenkumar --- AI_AGENT_DISCLOSURE.md | 4 ++ cmd/compose/up.go | 17 ++++++-- cmd/compose/up_test.go | 39 +++++++++++++++++++ docs/reference/compose_up.md | 2 +- docs/reference/docker_compose_up.yaml | 3 +- pkg/compose/up.go | 8 ++++ pkg/e2e/compose_up_test.go | 26 +++++++++++++ .../TestWaitAloneStaysSilent/compose.yaml | 10 +++++ .../compose.yaml | 10 +++++ 9 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 AI_AGENT_DISCLOSURE.md create mode 100644 pkg/e2e/testdata/TestWaitAloneStaysSilent/compose.yaml create mode 100644 pkg/e2e/testdata/TestWaitStreamsLogsThenDetaches/compose.yaml diff --git a/AI_AGENT_DISCLOSURE.md b/AI_AGENT_DISCLOSURE.md new file mode 100644 index 00000000000..ca3cbb415d4 --- /dev/null +++ b/AI_AGENT_DISCLOSURE.md @@ -0,0 +1,4 @@ +> *"This contribution was prepared by an AI agent acting on a human's behalf. +> The human submitter may not have independently reviewed or tested the change."* + +2026-09-05 diff --git a/cmd/compose/up.go b/cmd/compose/up.go index e69e455e27c..65ea7fb4030 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -170,7 +170,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backend flags.StringArrayVar(&up.attach, "attach", []string{}, "Restrict attaching to the specified services. Incompatible with --attach-dependencies.") flags.StringArrayVar(&up.noAttach, "no-attach", []string{}, "Do not attach (stream logs) to the specified services") flags.BoolVar(&up.attachDependencies, "attach-dependencies", false, "Automatically attach to log output of dependent services") - flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode.") + flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode, unless combined with --attach or --attach-dependencies to also stream logs while waiting.") flags.IntVar(&up.waitTimeout, "wait-timeout", 0, "Maximum duration in seconds to wait for the project to be running|healthy") flags.BoolVarP(&up.watch, "watch", "w", false, "Watch source code and rebuild/refresh containers when files are updated.") flags.BoolVar(&up.navigationMenu, "menu", false, "Enable interactive shortcuts when running attached. Incompatible with --detach. Can also be enable/disable by setting COMPOSE_MENU environment var.") @@ -197,10 +197,19 @@ func validateFlags(up *upOptions, create *createOptions) error { return fmt.Errorf("--abort-on-container-failure cannot be combined with --abort-on-container-exit") } if up.wait { - if up.attachDependencies || up.cascadeStop || len(up.attach) > 0 { - return fmt.Errorf("--wait cannot be combined with --abort-on-container-exit, --attach or --attach-dependencies") + if up.cascadeStop || up.cascadeFail { + return fmt.Errorf("--wait cannot be combined with --abort-on-container-exit or --abort-on-container-failure") + } + if up.watch { + return fmt.Errorf("--wait cannot be combined with --watch") + } + if !up.attachDependencies && len(up.attach) == 0 { + // Nothing was asked to stream logs, so --wait keeps its historical + // silent, detached behavior. Passing --attach or + // --attach-dependencies alongside --wait streams logs while + // waiting, then detaches once the wait condition is met. + up.Detach = true } - up.Detach = true } if create.Build && create.noBuild { return fmt.Errorf("--build and --no-build are incompatible") diff --git a/cmd/compose/up_test.go b/cmd/compose/up_test.go index e6e7fd2224f..746667be20b 100644 --- a/cmd/compose/up_test.go +++ b/cmd/compose/up_test.go @@ -179,3 +179,42 @@ services: assert.Assert(t, strings.Contains(output, "LXKNS_PORT"), output) assert.Assert(t, !strings.Contains(fmt.Sprint(err), "invalid ip address"), fmt.Sprint(err)) } + +// https://github.com/docker/compose/issues/9122 +// --wait must be combinable with --attach/--attach-dependencies, to stream +// logs while waiting instead of only polling silently. +func TestValidateFlags_WaitWithAttach(t *testing.T) { + up := &upOptions{wait: true, attach: []string{"web"}} + err := validateFlags(up, &createOptions{}) + assert.NilError(t, err) + assert.Assert(t, !up.Detach, "--wait --attach must not force detached mode") + + up = &upOptions{wait: true, attachDependencies: true} + err = validateFlags(up, &createOptions{}) + assert.NilError(t, err) + assert.Assert(t, !up.Detach, "--wait --attach-dependencies must not force detached mode") +} + +func TestValidateFlags_WaitAloneStaysDetached(t *testing.T) { + up := &upOptions{wait: true} + err := validateFlags(up, &createOptions{}) + assert.NilError(t, err) + assert.Assert(t, up.Detach, "--wait alone must keep its historical detached behavior") +} + +func TestValidateFlags_WaitIncompatibleFlags(t *testing.T) { + tests := []struct { + name string + up *upOptions + }{ + {"abort-on-container-exit", &upOptions{wait: true, cascadeStop: true}}, + {"abort-on-container-failure", &upOptions{wait: true, cascadeFail: true}}, + {"watch", &upOptions{wait: true, watch: true}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateFlags(tc.up, &createOptions{}) + assert.ErrorContains(t, err, "--wait cannot be combined with") + }) + } +} diff --git a/docs/reference/compose_up.md b/docs/reference/compose_up.md index 78b89ed9bed..ba7fcabb39d 100644 --- a/docs/reference/compose_up.md +++ b/docs/reference/compose_up.md @@ -64,7 +64,7 @@ hook runs again. Running `compose down` also removes them. | `--scale` | `stringArray` | | Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present. | | `-t`, `--timeout` | `int` | `0` | Use this timeout in seconds for container shutdown when attached or when containers are already running | | `--timestamps` | `bool` | | Show timestamps | -| `--wait` | `bool` | | Wait for services to be running\|healthy. Implies detached mode. | +| `--wait` | `bool` | | Wait for services to be running\|healthy. Implies detached mode, unless combined with --attach or --attach-dependencies to also stream logs while waiting. | | `--wait-timeout` | `int` | `0` | Maximum duration in seconds to wait for the project to be running\|healthy | | `-w`, `--watch` | `bool` | | Watch source code and rebuild/refresh containers when files are updated. | | `-y`, `--yes` | `bool` | | Assume "yes" as answer to all prompts and run non-interactively | diff --git a/docs/reference/docker_compose_up.yaml b/docs/reference/docker_compose_up.yaml index 7068ba4df5f..8375e9f5a68 100644 --- a/docs/reference/docker_compose_up.yaml +++ b/docs/reference/docker_compose_up.yaml @@ -302,7 +302,8 @@ options: - option: wait value_type: bool default_value: "false" - description: Wait for services to be running|healthy. Implies detached mode. + description: | + Wait for services to be running|healthy. Implies detached mode, unless combined with --attach or --attach-dependencies to also stream logs while waiting. deprecated: false hidden: false experimental: false diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 0beb3637359..5b9ec3d8d91 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -192,6 +192,14 @@ func (s *composeService) runInteractiveUp(ctx context.Context, project *types.Pr return err } + if options.Start.Wait && !u.isTerminated.Load() { + // --wait attached long enough to stream logs while services became + // running|healthy; detach now instead of following logs forever like + // a plain foreground `up`, exactly as if the user had pressed the + // detach shortcut. Containers are left running. + cancel() + } + _ = u.eg.Wait() err = errors.Join(u.errs...) if u.exitCode != 0 { diff --git a/pkg/e2e/compose_up_test.go b/pkg/e2e/compose_up_test.go index 66e8837a023..54cd4e88765 100644 --- a/pkg/e2e/compose_up_test.go +++ b/pkg/e2e/compose_up_test.go @@ -42,6 +42,32 @@ func TestUpWaitTimeout(t *testing.T) { ServiceState("app", "running")) } +// https://github.com/docker/compose/issues/9122 +// --wait combined with --attach must stream the waited-for service's logs +// while polling for health, then detach (leaving it running) once the wait +// condition is met, instead of following logs forever like a plain +// foreground `up`. +func TestWaitStreamsLogsThenDetaches(t *testing.T) { + NewScenario(t, "up --wait --attach must stream logs while waiting, then detach with the service left running"). + Step("up --wait --attach prints the service's early log line and returns once healthy", + ComposeCmd("up", "--wait", "--attach", "test").Within(30*time.Second), + StdoutContains("hello-while-waiting"), + ServiceState("test", "running"), + ServiceHealthy("test")) +} + +// https://github.com/docker/compose/issues/9122 +// Without --attach or --attach-dependencies, --wait must keep its historical +// silent, detached behavior. +func TestWaitAloneStaysSilent(t *testing.T) { + NewScenario(t, "up --wait alone must not stream logs, preserving its historical silent behavior"). + Step("up --wait completes without echoing the service's logs", + ComposeCmd("up", "--wait", "test").Within(30*time.Second), + OutputNotContains("hello-while-waiting"), + ServiceState("test", "running"), + ServiceHealthy("test")) +} + 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/TestWaitAloneStaysSilent/compose.yaml b/pkg/e2e/testdata/TestWaitAloneStaysSilent/compose.yaml new file mode 100644 index 00000000000..0d3318ba820 --- /dev/null +++ b/pkg/e2e/testdata/TestWaitAloneStaysSilent/compose.yaml @@ -0,0 +1,10 @@ +services: + test: + image: alpine + init: true + command: sh -c "echo hello-while-waiting; sleep 3; touch /tmp/ready; sleep infinity" + healthcheck: + test: ["CMD", "test", "-f", "/tmp/ready"] + interval: 30s + start_period: 10s + start_interval: 1s diff --git a/pkg/e2e/testdata/TestWaitStreamsLogsThenDetaches/compose.yaml b/pkg/e2e/testdata/TestWaitStreamsLogsThenDetaches/compose.yaml new file mode 100644 index 00000000000..0d3318ba820 --- /dev/null +++ b/pkg/e2e/testdata/TestWaitStreamsLogsThenDetaches/compose.yaml @@ -0,0 +1,10 @@ +services: + test: + image: alpine + init: true + command: sh -c "echo hello-while-waiting; sleep 3; touch /tmp/ready; sleep infinity" + healthcheck: + test: ["CMD", "test", "-f", "/tmp/ready"] + interval: 30s + start_period: 10s + start_interval: 1s