Repository: docker/compose
Pull request #13010 (2025-07-02 15:00:04)

keep containers attached on stop to capture termination logs
**What I did**

https://asciinema.org/a/dUq4g3wYLXsEQMn6okzvYGcIt

**Related issue**
fixes https://github.com/docker/compose/issues/12918

**(not mandatory) A picture of a cute animal, if possible in relation to what you did**


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/docker/compose/pull/13010?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) Report
Attention: Patch coverage is `37.89474%` with `118 lines` in your changes missing coverage. Please review.
> Project coverage is 53.62%. Comparing base [(`ec49bac`)](https://app.codecov.io/gh/docker/compose/commit/ec49baca56acbe5790f780aad4b407cdcd1e7a07?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) to head [(`d78b586`)](https://app.codecov.io/gh/docker/compose/commit/d78b586bf540b97938965d31fdd6a69a3fc5064e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker).
> Report is 69 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/docker/compose/pull/13010?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) | Patch % | Lines |
|---|---|---|
| [cmd/formatter/stopping.go](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&filepath=cmd%2Fformatter%2Fstopping.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-Y21kL2Zvcm1hdHRlci9zdG9wcGluZy5nbw==) | 33.33% | [44 Missing and 2 partials :warning: ](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |
| [pkg/progress/mixed.go](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&filepath=pkg%2Fprogress%2Fmixed.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-cGtnL3Byb2dyZXNzL21peGVkLmdv) | 29.03% | [22 Missing :warning: ](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |
| [cmd/formatter/shortcut.go](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&filepath=cmd%2Fformatter%2Fshortcut.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-Y21kL2Zvcm1hdHRlci9zaG9ydGN1dC5nbw==) | 0.00% | [20 Missing :warning: ](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |
| [pkg/mocks/mock\_docker\_api.go](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&filepath=pkg%2Fmocks%2Fmock_docker_api.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-cGtnL21vY2tzL21vY2tfZG9ja2VyX2FwaS5nbw==) | 0.00% | [19 Missing :warning: ](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |
| [cmd/formatter/ansi.go](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&filepath=cmd%2Fformatter%2Fansi.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-Y21kL2Zvcm1hdHRlci9hbnNpLmdv) | 9.09% | [10 Missing :warning: ](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |
| [pkg/progress/spinner.go](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&filepath=pkg%2Fprogress%2Fspinner.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-cGtnL3Byb2dyZXNzL3NwaW5uZXIuZ28=) | 80.00% | [1 Missing :warning: ](https://app.codecov.io/gh/docker/compose/pull/13010?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main   #13010      +/-   ##
==========================================
+ Coverage   53.37%   53.62%   +0.25%     
==========================================
  Files         160      167       +7     
  Lines       16642    17185     +543     
==========================================
+ Hits         8882     9215     +333     
- Misses       6848     7031     +183     
- Partials      912      939      +27     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/docker/compose/pull/13010?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/cmd/formatter/ansi.go b/cmd/formatter/ansi.go
index fb7ebaa2d..0d59cf501 100644
--- a/cmd/formatter/ansi.go
+++ b/cmd/formatter/ansi.go
@@ -28,49 +28,49 @@ func ansi(code string) string {
 	return fmt.Sprintf("\033%s", code)
 }
 
-func SaveCursor() {
+func saveCursor() {
 	if disableAnsi {
 		return
 	}
 	fmt.Print(ansi("7"))
 }
 
-func RestoreCursor() {
+func restoreCursor() {
 	if disableAnsi {
 		return
 	}
 	fmt.Print(ansi("8"))
 }
 
-func HideCursor() {
+func hideCursor() {
 	if disableAnsi {
 		return
 	}
 	fmt.Print(ansi("[?25l"))
 }
 
-func ShowCursor() {
+func showCursor() {
 	if disableAnsi {
 		return
 	}
 	fmt.Print(ansi("[?25h"))
 }
 
-func MoveCursor(y, x int) {
+func moveCursor(y, x int) {
 	if disableAnsi {
 		return
 	}
 	fmt.Print(ansi(fmt.Sprintf("[%d;%dH", y, x)))
 }
 
-func MoveCursorX(pos int) {
+func carriageReturn() {
 	if disableAnsi {
 		return
 	}
-	fmt.Print(ansi(fmt.Sprintf("[%dG", pos)))
+	fmt.Print(ansi(fmt.Sprintf("[%dG", 0)))
 }
 
-func ClearLine() {
+func clearLine() {
 	if disableAnsi {
 		return
 	}
@@ -78,7 +78,7 @@ func ClearLine() {
 	fmt.Print(ansi("[2K"))
 }
 
-func MoveCursorUp(lines int) {
+func moveCursorUp(lines int) {
 	if disableAnsi {
 		return
 	}
@@ -86,7 +86,7 @@ func MoveCursorUp(lines int) {
 	fmt.Print(ansi(fmt.Sprintf("[%dA", lines)))
 }
 
-func MoveCursorDown(lines int) {
+func moveCursorDown(lines int) {
 	if disableAnsi {
 		return
 	}
@@ -94,7 +94,7 @@ func MoveCursorDown(lines int) {
 	fmt.Print(ansi(fmt.Sprintf("[%dB", lines)))
 }
 
-func NewLine() {
+func newLine() {
 	// Like \n
 	fmt.Print("\012")
 }
diff --git a/cmd/formatter/logs.go b/cmd/formatter/logs.go
index 0c0cb1353..0e2d206c8 100644
--- a/cmd/formatter/logs.go
+++ b/cmd/formatter/logs.go
@@ -73,9 +73,12 @@ func (l *logConsumer) register(name string) *presenter {
 	} else {
 		cf := monochrome
 		if l.color {
-			if name == api.WatchLogger {
+			switch name {
+			case "":
+				cf = monochrome
+			case api.WatchLogger:
 				cf = makeColorFunc("92")
-			} else {
+			default:
 				cf = nextColor()
 			}
 		}
diff --git a/cmd/formatter/shortcut.go b/cmd/formatter/shortcut.go
index 7776e5f1e..d1c736319 100644
--- a/cmd/formatter/shortcut.go
+++ b/cmd/formatter/shortcut.go
@@ -48,8 +48,8 @@ func (ke *KeyboardError) printError(height int, info string) {
 	if ke.shouldDisplay() {
 		errMessage := ke.err.Error()
 
-		MoveCursor(height-1-extraLines(info)-extraLines(errMessage), 0)
-		ClearLine()
+		moveCursor(height-1-extraLines(info)-extraLines(errMessage), 0)
+		clearLine()
 
 		fmt.Print(errMessage)
 	}
@@ -133,7 +133,7 @@ func (lk *LogKeyboard) createBuffer(lines int) {
 
 	if lines > 0 {
 		allocateSpace(lines)
-		MoveCursorUp(lines)
+		moveCursorUp(lines)
 	}
 }
 
@@ -146,17 +146,17 @@ func (lk *LogKeyboard) printNavigationMenu() {
 		height := goterm.Height()
 		menu := lk.navigationMenu()
 
-		MoveCursorX(0)
-		SaveCursor()
+		carriageReturn()
+		saveCursor()
 
 		lk.kError.printError(height, menu)
 
-		MoveCursor(height-extraLines(menu), 0)
-		ClearLine()
+		moveCursor(height-extraLines(menu), 0)
+		clearLine()
 		fmt.Print(menu)
 
-		MoveCursorX(0)
-		RestoreCursor()
+		carriageReturn()
+		restoreCursor()
 	}
 }
 
@@ -188,15 +188,15 @@ func (lk *LogKeyboard) navigationMenu() string {
 
 func (lk *LogKeyboard) clearNavigationMenu() {
 	height := goterm.Height()
-	MoveCursorX(0)
-	SaveCursor()
+	carriageReturn()
+	saveCursor()
 
-	// ClearLine()
+	// clearLine()
 	for i := 0; i < height; i++ {
-		MoveCursorDown(1)
-		ClearLine()
+		moveCursorDown(1)
+		clearLine()
 	}
-	RestoreCursor()
+	restoreCursor()
 }
 
 func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) {
@@ -316,13 +316,13 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv
 	case keyboard.KeyCtrlC:
 		_ = keyboard.Close()
 		lk.clearNavigationMenu()
-		ShowCursor()
+		showCursor()
 
 		lk.logLevel = NONE
 		// will notify main thread to kill and will handle gracefully
 		lk.signalChannel <- syscall.SIGINT
 	case keyboard.KeyEnter:
-		NewLine()
+		newLine()
 		lk.printNavigationMenu()
 	}
 }
@@ -336,9 +336,9 @@ func (lk *LogKeyboard) EnableWatch(enabled bool, watcher Feature) {
 
 func allocateSpace(lines int) {
 	for i := 0; i < lines; i++ {
-		ClearLine()
-		NewLine()
-		MoveCursorX(0)
+		clearLine()
+		newLine()
+		carriageReturn()
 	}
 }
 
diff --git a/cmd/formatter/stopping.go b/cmd/formatter/stopping.go
new file mode 100644
index 000000000..afa248673
--- /dev/null
+++ b/cmd/formatter/stopping.go
@@ -0,0 +1,119 @@
+/*
+   Copyright 2024 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package formatter
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/buger/goterm"
+	"github.com/docker/compose/v2/pkg/api"
+	"github.com/docker/compose/v2/pkg/progress"
+)
+
+type Stopping struct {
+	api.LogConsumer
+	enabled   bool
+	spinner   *progress.Spinner
+	ticker    *time.Ticker
+	startedAt time.Time
+}
+
+func NewStopping(l api.LogConsumer) *Stopping {
+	s := &Stopping{}
+	s.LogConsumer = logDecorator{
+		decorated: l,
+		Before:    s.clear,
+		After:     s.print,
+	}
+	return s
+}
+
+func (s *Stopping) ApplicationTermination() {
+	if progress.Mode != progress.ModeAuto {
+		// User explicitly opted for output format
+		return
+	}
+	if disableAnsi {
+		return
+	}
+	s.enabled = true
+	s.spinner = progress.NewSpinner()
+	hideCursor()
+	s.startedAt = time.Now()
+	s.ticker = time.NewTicker(100 * time.Millisecond)
+	go func() {
+		for {
+			<-s.ticker.C
+			s.print()
+		}
+	}()
+}
+
+func (s *Stopping) Close() {
+	showCursor()
+	if s.ticker != nil {
+		s.ticker.Stop()
+	}
+	s.clear()
+}
+
+func (s *Stopping) clear() {
+	if !s.enabled {
+		return
+	}
+
+	height := goterm.Height()
+	carriageReturn()
+	saveCursor()
+
+	// clearLine()
+	for i := 0; i < height; i++ {
+		moveCursorDown(1)
+		clearLine()
+	}
+	restoreCursor()
+}
+
+const stoppingBanner = "Gracefully Stopping... (press Ctrl+C again to force)"
+
+func (s *Stopping) print() {
+	if !s.enabled {
+		return
+	}
+
+	height := goterm.Height()
+	width := goterm.Width()
+	carriageReturn()
+	saveCursor()
+
+	moveCursor(height, 0)
+	clearLine()
+	elapsed := time.Since(s.startedAt).Seconds()
+	timer := fmt.Sprintf("%.1fs ", elapsed)
+	pad := width - len(timer) - len(stoppingBanner) - 5
+	fmt.Printf("%s %s %s %s",
+		progress.CountColor(s.spinner.String()),
+		stoppingBanner,
+		strings.Repeat(" ", pad),
+		progress.TimerColor(timer),
+	)
+
+	carriageReturn()
+	restoreCursor()
+}
diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go
index 098420f71..a4b0d0e6c 100644
--- a/pkg/compose/convergence.go
+++ b/pkg/compose/convergence.go
@@ -236,7 +236,7 @@ func (c *convergence) stopDependentContainers(ctx context.Context, project *type
 	err := c.service.stop(ctx, project.Name, api.StopOptions{
 		Services: dependents,
 		Project:  project,
-	})
+	}, nil)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/compose/create.go b/pkg/compose/create.go
index e2fe3c6a6..b8b0afea4 100644
--- a/pkg/compose/create.go
+++ b/pkg/compose/create.go
@@ -1428,7 +1428,7 @@ func (s *composeService) removeDivergedNetwork(ctx context.Context, project *typ
 	err := s.stop(ctx, project.Name, api.StopOptions{
 		Services: services,
 		Project:  project,
-	})
+	}, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -1599,7 +1599,7 @@ func (s *composeService) removeDivergedVolume(ctx context.Context, name string,
 	err := s.stop(ctx, project.Name, api.StopOptions{
 		Services: services,
 		Project:  project,
-	})
+	}, nil)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/compose/down.go b/pkg/compose/down.go
index 03f456a6d..b64258dff 100644
--- a/pkg/compose/down.go
+++ b/pkg/compose/down.go
@@ -298,13 +298,17 @@ func (s *composeService) removeVolume(ctx context.Context, id string, w progress
 	return err
 }
 
-func (s *composeService) stopContainer(ctx context.Context, w progress.Writer, service *types.ServiceConfig, ctr containerType.Summary, timeout *time.Duration) error {
+func (s *composeService) stopContainer(
+	ctx context.Context, w progress.Writer,
+	service *types.ServiceConfig, ctr containerType.Summary,
+	timeout *time.Duration, listener api.ContainerEventListener,
+) error {
 	eventName := getContainerProgressName(ctr)
 	w.Event(progress.StoppingEvent(eventName))
 
 	if service != nil {
 		for _, hook := range service.PreStop {
-			err := s.runHook(ctx, ctr, *service, hook, nil)
+			err := s.runHook(ctx, ctr, *service, hook, listener)
 			if err != nil {
 				// Ignore errors indicating that some containers were already stopped or removed.
 				if cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err) {
@@ -325,11 +329,15 @@ func (s *composeService) stopContainer(ctx context.Context, w progress.Writer, s
 	return nil
 }
 
-func (s *composeService) stopContainers(ctx context.Context, w progress.Writer, serv *types.ServiceConfig, containers []containerType.Summary, timeout *time.Duration) error {
+func (s *composeService) stopContainers(
+	ctx context.Context, w progress.Writer,
+	serv *types.ServiceConfig, containers []containerType.Summary,
+	timeout *time.Duration, listener api.ContainerEventListener,
+) error {
 	eg, ctx := errgroup.WithContext(ctx)
 	for _, ctr := range containers {
 		eg.Go(func() error {
-			return s.stopContainer(ctx, w, serv, ctr, timeout)
+			return s.stopContainer(ctx, w, serv, ctr, timeout, listener)
 		})
 	}
 	return eg.Wait()
@@ -348,7 +356,7 @@ func (s *composeService) removeContainers(ctx context.Context, containers []cont
 func (s *composeService) stopAndRemoveContainer(ctx context.Context, ctr containerType.Summary, service *types.ServiceConfig, timeout *time.Duration, volumes bool) error {
 	w := progress.ContextWriter(ctx)
 	eventName := getContainerProgressName(ctr)
-	err := s.stopContainer(ctx, w, service, ctr, timeout)
+	err := s.stopContainer(ctx, w, service, ctr, timeout, nil)
 	if cerrdefs.IsNotFound(err) {
 		w.Event(progress.RemovedEvent(eventName))
 		return nil
diff --git a/pkg/compose/printer.go b/pkg/compose/printer.go
index 2f300dde0..338312fae 100644
--- a/pkg/compose/printer.go
+++ b/pkg/compose/printer.go
@@ -149,9 +149,7 @@ func (p *printer) Run(cascade api.Cascade, exitCodeFrom string, stopFn func() er
 					return exitCode, nil
 				}
 			case api.ContainerEventLog, api.HookEventLog:
-				if !aborting {
-					p.consumer.Log(container, event.Line)
-				}
+				p.consumer.Log(container, event.Line)
 			case api.ContainerEventErr:
 				if !aborting {
 					p.consumer.Err(container, event.Line)
diff --git a/pkg/compose/stop.go b/pkg/compose/stop.go
index b9514c4b4..8a9cf5aa3 100644
--- a/pkg/compose/stop.go
+++ b/pkg/compose/stop.go
@@ -27,11 +27,11 @@ import (
 
 func (s *composeService) Stop(ctx context.Context, projectName string, options api.StopOptions) error {
 	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
-		return s.stop(ctx, strings.ToLower(projectName), options)
+		return s.stop(ctx, strings.ToLower(projectName), options, nil)
 	}, s.stdinfo(), "Stopping")
 }
 
-func (s *composeService) stop(ctx context.Context, projectName string, options api.StopOptions) error {
+func (s *composeService) stop(ctx context.Context, projectName string, options api.StopOptions, event api.ContainerEventListener) error {
 	containers, err := s.getContainers(ctx, projectName, oneOffExclude, true)
 	if err != nil {
 		return err
@@ -55,6 +55,6 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a
 			return nil
 		}
 		serv := project.Services[service]
-		return s.stopContainers(ctx, w, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout)
+		return s.stopContainers(ctx, w, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event)
 	})
 }
diff --git a/pkg/compose/up.go b/pkg/compose/up.go
index 93dc7e78e..dda37f68e 100644
--- a/pkg/compose/up.go
+++ b/pkg/compose/up.go
@@ -90,6 +90,10 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 		}
 	}
 
+	tui := formatter.NewStopping(logConsumer)
+	defer tui.Close()
+	logConsumer = tui
+
 	watcher, err := NewWatcher(project, options, s.watch, logConsumer)
 	if err != nil && options.Start.Watch {
 		return err
@@ -105,16 +109,16 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 	eg.Go(func() error {
 		first := true
 		gracefulTeardown := func() {
-			printer.Cancel()
-			_, _ = fmt.Fprintln(s.stdinfo(), "Gracefully stopping... (press Ctrl+C again to force)")
+			tui.ApplicationTermination()
 			eg.Go(func() error {
-				err := s.Stop(context.WithoutCancel(ctx), project.Name, api.StopOptions{
-					Services: options.Create.Services,
-					Project:  project,
-				})
-				isTerminated.Store(true)
-				return err
+				return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error {
+					return s.stop(ctx, project.Name, api.StopOptions{
+						Services: options.Create.Services,
+						Project:  project,
+					}, printer.HandleEvent)
+				}, s.stdinfo(), logConsumer)
 			})
+			isTerminated.Store(true)
 			first = false
 		}
 
@@ -159,12 +163,15 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 	eg.Go(func() error {
 		code, err := printer.Run(options.Start.OnExit, options.Start.ExitCodeFrom, func() error {
 			_, _ = fmt.Fprintln(s.stdinfo(), "Aborting on container exit...")
-			return progress.Run(ctx, func(ctx context.Context) error {
-				return s.Stop(ctx, project.Name, api.StopOptions{
-					Services: options.Create.Services,
-					Project:  project,
-				})
-			}, s.stdinfo())
+			eg.Go(func() error {
+				return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error {
+					return s.stop(ctx, project.Name, api.StopOptions{
+						Services: options.Create.Services,
+						Project:  project,
+					}, printer.HandleEvent)
+				}, s.stdinfo(), logConsumer)
+			})
+			return nil
 		})
 		exitCode = code
 		return err
diff --git a/pkg/mocks/mock_docker_api.go b/pkg/mocks/mock_docker_api.go
index 818d84583..4a6ebaacc 100644
--- a/pkg/mocks/mock_docker_api.go
+++ b/pkg/mocks/mock_docker_api.go
@@ -5,6 +5,7 @@
 //
 //	mockgen -destination pkg/mocks/mock_docker_api.go -package mocks github.com/docker/docker/client APIClient
 //
+
 // Package mocks is a generated GoMock package.
 package mocks
 
@@ -16,6 +17,7 @@ import (
 	reflect "reflect"
 
 	types "github.com/docker/docker/api/types"
+	build "github.com/docker/docker/api/types/build"
 	checkpoint "github.com/docker/docker/api/types/checkpoint"
 	common "github.com/docker/docker/api/types/common"
 	container "github.com/docker/docker/api/types/container"
@@ -56,10 +58,10 @@ func (m *MockAPIClient) EXPECT() *MockAPIClientMockRecorder {
 }
 
 // BuildCachePrune mocks base method.
-func (m *MockAPIClient) BuildCachePrune(arg0 context.Context, arg1 types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error) {
+func (m *MockAPIClient) BuildCachePrune(arg0 context.Context, arg1 build.CachePruneOptions) (*build.CachePruneReport, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "BuildCachePrune", arg0, arg1)
-	ret0, _ := ret[0].(*types.BuildCachePruneReport)
+	ret0, _ := ret[0].(*build.CachePruneReport)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
@@ -156,10 +158,10 @@ func (mr *MockAPIClientMockRecorder) Close() *gomock.Call {
 }
 
 // ConfigCreate mocks base method.
-func (m *MockAPIClient) ConfigCreate(arg0 context.Context, arg1 swarm.ConfigSpec) (types.ConfigCreateResponse, error) {
+func (m *MockAPIClient) ConfigCreate(arg0 context.Context, arg1 swarm.ConfigSpec) (swarm.ConfigCreateResponse, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "ConfigCreate", arg0, arg1)
-	ret0, _ := ret[0].(types.ConfigCreateResponse)
+	ret0, _ := ret[0].(swarm.ConfigCreateResponse)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
@@ -187,7 +189,7 @@ func (mr *MockAPIClientMockRecorder) ConfigInspectWithRaw(arg0, arg1 any) *gomoc
 }
 
 // ConfigList mocks base method.
-func (m *MockAPIClient) ConfigList(arg0 context.Context, arg1 types.ConfigListOptions) ([]swarm.Config, error) {
+func (m *MockAPIClient) ConfigList(arg0 context.Context, arg1 swarm.ConfigListOptions) ([]swarm.Config, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "ConfigList", arg0, arg1)
 	ret0, _ := ret[0].([]swarm.Config)
@@ -802,10 +804,10 @@ func (mr *MockAPIClientMockRecorder) HTTPClient() *gomock.Call {
 }
 
 // ImageBuild mocks base method.
-func (m *MockAPIClient) ImageBuild(arg0 context.Context, arg1 io.Reader, arg2 types.ImageBuildOptions) (types.ImageBuildResponse, error) {
+func (m *MockAPIClient) ImageBuild(arg0 context.Context, arg1 io.Reader, arg2 build.ImageBuildOptions) (build.ImageBuildResponse, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "ImageBuild", arg0, arg1, arg2)
-	ret0, _ := ret[0].(types.ImageBuildResponse)
+	ret0, _ := ret[0].(build.ImageBuildResponse)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
@@ -1220,7 +1222,7 @@ func (mr *MockAPIClientMockRecorder) NodeInspectWithRaw(arg0, arg1 any) *gomock.
 }
 
 // NodeList mocks base method.
-func (m *MockAPIClient) NodeList(arg0 context.Context, arg1 types.NodeListOptions) ([]swarm.Node, error) {
+func (m *MockAPIClient) NodeList(arg0 context.Context, arg1 swarm.NodeListOptions) ([]swarm.Node, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "NodeList", arg0, arg1)
 	ret0, _ := ret[0].([]swarm.Node)
@@ -1235,7 +1237,7 @@ func (mr *MockAPIClientMockRecorder) NodeList(arg0, arg1 any) *gomock.Call {
 }
 
 // NodeRemove mocks base method.
-func (m *MockAPIClient) NodeRemove(arg0 context.Context, arg1 string, arg2 types.NodeRemoveOptions) error {
+func (m *MockAPIClient) NodeRemove(arg0 context.Context, arg1 string, arg2 swarm.NodeRemoveOptions) error {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "NodeRemove", arg0, arg1, arg2)
 	ret0, _ := ret[0].(error)
@@ -1439,10 +1441,10 @@ func (mr *MockAPIClientMockRecorder) RegistryLogin(arg0, arg1 any) *gomock.Call
 }
 
 // SecretCreate mocks base method.
-func (m *MockAPIClient) SecretCreate(arg0 context.Context, arg1 swarm.SecretSpec) (types.SecretCreateResponse, error) {
+func (m *MockAPIClient) SecretCreate(arg0 context.Context, arg1 swarm.SecretSpec) (swarm.SecretCreateResponse, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "SecretCreate", arg0, arg1)
-	ret0, _ := ret[0].(types.SecretCreateResponse)
+	ret0, _ := ret[0].(swarm.SecretCreateResponse)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
@@ -1470,7 +1472,7 @@ func (mr *MockAPIClientMockRecorder) SecretInspectWithRaw(arg0, arg1 any) *gomoc
 }
 
 // SecretList mocks base method.
-func (m *MockAPIClient) SecretList(arg0 context.Context, arg1 types.SecretListOptions) ([]swarm.Secret, error) {
+func (m *MockAPIClient) SecretList(arg0 context.Context, arg1 swarm.SecretListOptions) ([]swarm.Secret, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "SecretList", arg0, arg1)
 	ret0, _ := ret[0].([]swarm.Secret)
@@ -1528,7 +1530,7 @@ func (mr *MockAPIClientMockRecorder) ServerVersion(arg0 any) *gomock.Call {
 }
 
 // ServiceCreate mocks base method.
-func (m *MockAPIClient) ServiceCreate(arg0 context.Context, arg1 swarm.ServiceSpec, arg2 types.ServiceCreateOptions) (swarm.ServiceCreateResponse, error) {
+func (m *MockAPIClient) ServiceCreate(arg0 context.Context, arg1 swarm.ServiceSpec, arg2 swarm.ServiceCreateOptions) (swarm.ServiceCreateResponse, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "ServiceCreate", arg0, arg1, arg2)
 	ret0, _ := ret[0].(swarm.ServiceCreateResponse)
@@ -1543,7 +1545,7 @@ func (mr *MockAPIClientMockRecorder) ServiceCreate(arg0, arg1, arg2 any) *gomock
 }
 
 // ServiceInspectWithRaw mocks base method.
-func (m *MockAPIClient) ServiceInspectWithRaw(arg0 context.Context, arg1 string, arg2 types.ServiceInspectOptions) (swarm.Service, []byte, error) {
+func (m *MockAPIClient) ServiceInspectWithRaw(arg0 context.Context, arg1 string, arg2 swarm.ServiceInspectOptions) (swarm.Service, []byte, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "ServiceInspectWithRaw", arg0, arg1, arg2)
 	ret0, _ := ret[0].(swarm.Service)
@@ -1559,7 +1561,7 @@ func (mr *MockAPIClientMockRecorder) ServiceInspectWithRaw(arg0, arg1, arg2 any)
 }
 
 // ServiceList mocks base method.
-func (m *MockAPIClient) ServiceList(arg0 context.Context, arg1 types.ServiceListOptions) ([]swarm.Service, error) {
+func (m *MockAPIClient) ServiceList(arg0 context.Context, arg1 swarm.ServiceListOptions) ([]swarm.Service, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "ServiceList", arg0, arg1)
 	ret0, _ := ret[0].([]swarm.Service)
@@ -1603,7 +1605,7 @@ func (mr *MockAPIClientMockRecorder) ServiceRemove(arg0, arg1 any) *gomock.Call
 }
 
 // ServiceUpdate mocks base method.
-func (m *MockAPIClient) ServiceUpdate(arg0 context.Context, arg1 string, arg2 swarm.Version, arg3 swarm.ServiceSpec, arg4 types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) {
+func (m *MockAPIClient) ServiceUpdate(arg0 context.Context, arg1 string, arg2 swarm.Version, arg3 swarm.ServiceSpec, arg4 swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "ServiceUpdate", arg0, arg1, arg2, arg3, arg4)
 	ret0, _ := ret[0].(swarm.ServiceUpdateResponse)
@@ -1618,10 +1620,10 @@ func (mr *MockAPIClientMockRecorder) ServiceUpdate(arg0, arg1, arg2, arg3, arg4
 }
 
 // SwarmGetUnlockKey mocks base method.
-func (m *MockAPIClient) SwarmGetUnlockKey(arg0 context.Context) (types.SwarmUnlockKeyResponse, error) {
+func (m *MockAPIClient) SwarmGetUnlockKey(arg0 context.Context) (swarm.UnlockKeyResponse, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "SwarmGetUnlockKey", arg0)
-	ret0, _ := ret[0].(types.SwarmUnlockKeyResponse)
+	ret0, _ := ret[0].(swarm.UnlockKeyResponse)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
@@ -1735,7 +1737,7 @@ func (mr *MockAPIClientMockRecorder) TaskInspectWithRaw(arg0, arg1 any) *gomock.
 }
 
 // TaskList mocks base method.
-func (m *MockAPIClient) TaskList(arg0 context.Context, arg1 types.TaskListOptions) ([]swarm.Task, error) {
+func (m *MockAPIClient) TaskList(arg0 context.Context, arg1 swarm.TaskListOptions) ([]swarm.Task, error) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "TaskList", arg0, arg1)
 	ret0, _ := ret[0].([]swarm.Task)
diff --git a/pkg/mocks/mock_docker_cli.go b/pkg/mocks/mock_docker_cli.go
index 34ccb0446..a540662d7 100644
--- a/pkg/mocks/mock_docker_cli.go
+++ b/pkg/mocks/mock_docker_cli.go
@@ -5,6 +5,7 @@
 //
 //	mockgen -destination pkg/mocks/mock_docker_cli.go -package mocks github.com/docker/cli/cli/command Cli
 //
+
 // Package mocks is a generated GoMock package.
 package mocks
 
diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go
index 6bb065f34..830d7e1af 100644
--- a/pkg/mocks/mock_docker_compose_api.go
+++ b/pkg/mocks/mock_docker_compose_api.go
@@ -5,6 +5,7 @@
 //
 //	mockgen -destination pkg/mocks/mock_docker_compose_api.go -package mocks -source=./pkg/api/api.go Service
 //
+
 // Package mocks is a generated GoMock package.
 package mocks
 
diff --git a/pkg/progress/event.go b/pkg/progress/event.go
index 7b66946ee..b2fdcb6eb 100644
--- a/pkg/progress/event.go
+++ b/pkg/progress/event.go
@@ -60,7 +60,7 @@ type Event struct {
 	Total     int64
 	startTime time.Time
 	endTime   time.Time
-	spinner   *spinner
+	spinner   *Spinner
 }
 
 // ErrorMessageEvent creates a new Error Event with message
diff --git a/pkg/progress/mixed.go b/pkg/progress/mixed.go
new file mode 100644
index 000000000..d3180516a
--- /dev/null
+++ b/pkg/progress/mixed.go
@@ -0,0 +1,76 @@
+/*
+   Copyright 2020 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package progress
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/docker/cli/cli/streams"
+	"github.com/docker/compose/v2/pkg/api"
+)
+
+// NewMixedWriter creates a Writer which allows to mix output from progress.Writer with a api.LogConsumer
+func NewMixedWriter(out *streams.Out, consumer api.LogConsumer, dryRun bool) Writer {
+	isTerminal := out.IsTerminal()
+	if Mode != ModeAuto || !isTerminal {
+		return &plainWriter{
+			out:    out,
+			done:   make(chan bool),
+			dryRun: dryRun,
+		}
+	}
+	return &mixedWriter{
+		out:    consumer,
+		done:   make(chan bool),
+		dryRun: dryRun,
+	}
+}
+
+type mixedWriter struct {
+	done   chan bool
+	dryRun bool
+	out    api.LogConsumer
+}
+
+func (p *mixedWriter) Start(ctx context.Context) error {
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-p.done:
+		return nil
+	}
+}
+
+func (p *mixedWriter) Event(e Event) {
+	p.out.Status("", fmt.Sprintf("%s %s %s", e.ID, e.Text, SuccessColor(e.StatusText)))
+}
+
+func (p *mixedWriter) Events(events []Event) {
+	for _, e := range events {
+		p.Event(e)
+	}
+}
+
+func (p *mixedWriter) TailMsgf(msg string, args ...interface{}) {
+	msg = fmt.Sprintf(msg, args...)
+	p.out.Status("", WarningColor(msg))
+}
+
+func (p *mixedWriter) Stop() {
+	p.done <- true
+}
diff --git a/pkg/progress/spinner.go b/pkg/progress/spinner.go
index 3756e944f..4e434bd4f 100644
--- a/pkg/progress/spinner.go
+++ b/pkg/progress/spinner.go
@@ -21,7 +21,7 @@ import (
 	"time"
 )
 
-type spinner struct {
+type Spinner struct {
 	time  time.Time
 	index int
 	chars []string
@@ -29,7 +29,7 @@ type spinner struct {
 	done  string
 }
 
-func newSpinner() *spinner {
+func NewSpinner() *Spinner {
 	chars := []string{
 		"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
 	}
@@ -40,7 +40,7 @@ func newSpinner() *spinner {
 		done = "-"
 	}
 
-	return &spinner{
+	return &Spinner{
 		index: 0,
 		time:  time.Now(),
 		chars: chars,
@@ -48,7 +48,7 @@ func newSpinner() *spinner {
 	}
 }
 
-func (s *spinner) String() string {
+func (s *Spinner) String() string {
 	if s.stop {
 		return s.done
 	}
@@ -61,10 +61,10 @@ func (s *spinner) String() string {
 	return s.chars[s.index]
 }
 
-func (s *spinner) Stop() {
+func (s *Spinner) Stop() {
 	s.stop = true
 }
 
-func (s *spinner) Restart() {
+func (s *Spinner) Restart() {
 	s.stop = false
 }
diff --git a/pkg/progress/tty.go b/pkg/progress/tty.go
index e6b8738c8..cc4388aa1 100644
--- a/pkg/progress/tty.go
+++ b/pkg/progress/tty.go
@@ -110,7 +110,7 @@ func (w *ttyWriter) event(e Event) {
 		w.events[e.ID] = last
 	} else {
 		e.startTime = time.Now()
-		e.spinner = newSpinner()
+		e.spinner = NewSpinner()
 		if e.Status == Done || e.Status == Error {
 			e.stop()
 		}
diff --git a/pkg/progress/writer.go b/pkg/progress/writer.go
index 793adb762..4e9066c5f 100644
--- a/pkg/progress/writer.go
+++ b/pkg/progress/writer.go
@@ -65,6 +65,25 @@ func Run(ctx context.Context, pf progressFunc, out *streams.Out) error {
 	return err
 }
 
+func RunWithLog(ctx context.Context, pf progressFunc, out *streams.Out, logConsumer api.LogConsumer) error {
+	dryRun, ok := ctx.Value(api.DryRunKey{}).(bool)
+	if !ok {
+		dryRun = false
+	}
+	w := NewMixedWriter(out, logConsumer, dryRun)
+	eg, _ := errgroup.WithContext(ctx)
+	eg.Go(func() error {
+		return w.Start(context.Background())
+	})
+	eg.Go(func() error {
+		defer w.Stop()
+		ctx = WithContextWriter(ctx, w)
+		err := pf(ctx)
+		return err
+	})
+	return eg.Wait()
+}
+
 func RunWithTitle(ctx context.Context, pf progressFunc, out *streams.Out, progressTitle string) error {
 	_, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) {
 		return "", pf(ctx)


Test patch:
diff --git a/pkg/e2e/fixtures/stop/compose.yaml b/pkg/e2e/fixtures/stop/compose.yaml
new file mode 100644
index 000000000..f81462ae3
--- /dev/null
+++ b/pkg/e2e/fixtures/stop/compose.yaml
@@ -0,0 +1,9 @@
+services:
+  service1:
+    image: alpine
+    command: /bin/true
+  service2:
+    image: alpine
+    command: ping -c 2 localhost
+    pre_stop:
+      - command: echo "stop hook running..."
diff --git a/pkg/e2e/up_test.go b/pkg/e2e/up_test.go
index 65fecca30..0d3e3db0c 100644
--- a/pkg/e2e/up_test.go
+++ b/pkg/e2e/up_test.go
@@ -206,3 +206,20 @@ func TestUpImageID(t *testing.T) {
 	c = NewCLI(t, WithEnv(fmt.Sprintf("ID=%s", id)))
 	c.RunDockerComposeCmd(t, "-f", "./fixtures/simple-composefile/id.yaml", "--project-name", projectName, "up")
 }
+
+func TestUpStopWithLogsMixed(t *testing.T) {
+	c := NewCLI(t)
+	const projectName = "compose-e2e-stop-logs"
+
+	t.Cleanup(func() {
+		c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v")
+	})
+
+	res := c.RunDockerComposeCmd(t, "-f", "./fixtures/stop/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit")
+	// assert we still get service2 logs after service 1 Stopped event
+	res.Assert(t, icmd.Expected{
+		Err: "Container compose-e2e-stop-logs-service1-1  Stopped",
+	})
+	// assert we get stop hook logs
+	res.Assert(t, icmd.Expected{Out: "service2-1 ->  | stop hook running...\nservice2-1     | 64 bytes"})
+}
diff --git a/pkg/progress/tty_test.go b/pkg/progress/tty_test.go
index fc484b7c0..3b8c9ba76 100644
--- a/pkg/progress/tty_test.go
+++ b/pkg/progress/tty_test.go
@@ -34,7 +34,7 @@ func TestLineText(t *testing.T) {
 		StatusText: "Status",
 		endTime:    now,
 		startTime:  now,
-		spinner: &spinner{
+		spinner: &Spinner{
 			chars: []string{"."},
 		},
 	}
@@ -65,7 +65,7 @@ func TestLineTextSingleEvent(t *testing.T) {
 		Status:     Done,
 		StatusText: "Status",
 		startTime:  now,
-		spinner: &spinner{
+		spinner: &Spinner{
 			chars: []string{"."},
 		},
 	}
@@ -87,7 +87,7 @@ func TestErrorEvent(t *testing.T) {
 		Status:     Working,
 		StatusText: "Working",
 		startTime:  time.Now(),
-		spinner: &spinner{
+		spinner: &Spinner{
 			chars: []string{"."},
 		},
 	}
@@ -116,7 +116,7 @@ func TestWarningEvent(t *testing.T) {
 		Status:     Working,
 		StatusText: "Working",
 		startTime:  time.Now(),
-		spinner: &spinner{
+		spinner: &Spinner{
 			chars: []string{"."},
 		},
 	}