Repository: docker/compose

Issue:
Allow compose to show container shutdown logs. With an alternative UI.
### Description

Containers printing logs on shutdown don't get displayed.

### Steps To Reproduce

## Example code

```go
package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    fmt.Println("Hello, World!")

    <-sigChan

    fmt.Println("Goodbye, World!")
}
```

## Example Dockerfile

```dockerfile
FROM golang:alpine

WORKDIR /app

COPY . .

RUN go build -o hello .

CMD ["./hello"]
```

## Example compose.yml

```yaml
services:
  hello:
    build: .
```

1. Run `docker compose up --build`.
 
2. Press CTRL + C to gracefully shutdown all the services.

3. Notice how you won't see the "Goodbye, World!" log but if you run `docker compose logs hello`, you'll see the log.

### Compose Version

```Text
Docker Compose version v2.36.2
```

### Docker Environment

```Text
Client: Docker Engine - Community
 Version:    28.2.2
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.24.0
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v2.36.2
    Path:     /usr/libexec/docker/cli-plugins/docker-compose

Server:
 Containers: 25
  Running: 0
  Paused: 0
  Stopped: 25
 Images: 73
 Server Version: 28.2.2
 Storage Driver: overlay2
  Backing Filesystem: btrfs
  Supports d_type: true
  Using metacopy: false
  Native Overlay Diff: true
  userxattr: false
 Logging Driver: json-file
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local splunk syslog
 CDI spec directories:
  /etc/cdi
  /var/run/cdi
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 05044ec0a9a75232cad458027ca83437aae3f4da
 runc version: v1.2.5-0-g59923ef
 init version: de40ad0
 Security Options:
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 6.14.9-300.fc42.x86_64
 Operating System: Fedora Linux 42 (Workstation Edition)
 OSType: linux
 Architecture: x86_64
 CPUs: 12
 Total Memory: 31.26GiB
 Name: nzxt-pc
 ID: 243ae51b-a1fb-4b73-a42d-ef8cb1420620
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: false
 Insecure Registries:
  ::1/128
  127.0.0.0/8
 Live Restore Enabled: false
```

### Anything else?

I assume it's just detaching too early, causing it not to print the logs, but I don't know if this is intended behavior. To me, this seems like it should print the logs on shutdown.

Discussion:
@robinhickmann: Forgot to mention, I tested this using docker run, and when I shutdown the container, it does actually print the shutdown logs. @ndeloof: This is something I considered in the past but not a trivial one: as we show progress UI for the stop sequence, we can't mix with logs. Would need to create an alternative progress UI that would not lock access to the terminal. Could show status change events as single line messages + a one-line progress state bar on the bottom (same as menu). Not that simple 😅@robinhickmann: > This is something I considered in the past but not a trivial one: as we show progress UI for the stop sequence, we can't mix with logs. Would need to create an alternative progress UI that would not lock access to the terminal. Could show status change events as single line messages + a one-line progress state bar on the bottom (same as menu). Not that simple 😅

Yeah, I understand, that's fair. Hopefully this could get added this in the future, as I feel like it's a feature worth having.

Solution patch:
diff --git a/cmd/formatter/ansi.go b/cmd/formatter/ansi.go
index fb7ebaa2..0d59cf50 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 0c0cb135..0e2d206c 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 7776e5f1..d1c73631 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 00000000..afa24867
--- /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 098420f7..a4b0d0e6 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 e2fe3c6a..b8b0afea 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 03f456a6..b64258df 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 2f300dde..338312fa 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 b9514c4b..8a9cf5aa 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 93dc7e78..dda37f68 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/e2e/fixtures/stop/compose.yaml b/pkg/e2e/fixtures/stop/compose.yaml
new file mode 100644
index 00000000..f81462ae
--- /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/mocks/mock_docker_api.go b/pkg/mocks/mock_docker_api.go
index 818d8458..4a6ebaac 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 34ccb044..a540662d 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 6bb065f3..830d7e1a 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 7b66946e..b2fdcb6e 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 00000000..d3180516
--- /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 3756e944..4e434bd4 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 e6b8738c..cc4388aa 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 793adb76..4e9066c5 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/up_test.go b/pkg/e2e/up_test.go
index 65fecca3..0d3e3db0 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 fc484b7c..3b8c9ba7 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{"."},
 		},
 	}