Repository: docker/compose
Pull request #12150 (2024-09-22 03:22:13)

fix(convergence): Serialize access to observed state
**What I did**
fixing race condition while accessing `observedState` map

**Related issue**
Got an error while reloading container using `docker compose up -d` after updating the `docker-compose.yaml` file
<details>
  <summary>Log</summary>
  
  ```
  fatal error: concurrent map read and map write

  goroutine 69 [running]:
  github.com/docker/compose/v2/pkg/compose.(*convergence).stopDependentContainers(_, {_, _}, _, {{0xc000615468, 0x7}, {0x0, 0x0, 0x0}, 0x0, ...})
          github.com/docker/compose/v2/pkg/compose/convergence.go:226 +0x113
  github.com/docker/compose/v2/pkg/compose.(*convergence).ensureService(_, {_, _}, _, {{0xc000615468, 0x7}, {0x0, 0x0, 0x0}, 0x0, ...}, ...)
          github.com/docker/compose/v2/pkg/compose/convergence.go:162 +0x694
  github.com/docker/compose/v2/pkg/compose.(*composeService).create.(*convergence).apply.func1.1({0x2747e68, 0xc0006db5c0})
          github.com/docker/compose/v2/pkg/compose/convergence.go:103 +0x10b
  github.com/docker/compose/v2/internal/tracing.SpanWrapFunc.func1({0x2747ea0, 0xc0007da0a0})
          github.com/docker/compose/v2/internal/tracing/wrap.go:43 +0x1c4
  github.com/docker/compose/v2/pkg/compose.(*composeService).create.(*convergence).apply.func1({0x2747ea0, 0xc0007da0a0}, {0xc000615468, 0x7})
          github.com/docker/compose/v2/pkg/compose/convergence.go:104 +0x2e3
  github.com/docker/compose/v2/pkg/compose.(*graphTraversal).run.func1()
          github.com/docker/compose/v2/pkg/compose/dependencies.go:184 +0x99
  golang.org/x/sync/errgroup.(*Group).Go.func1()
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:78 +0x56
  created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 12
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:75 +0x96

  goroutine 1 [semacquire]:
  sync.runtime_Semacquire(0xc00059bad0?)
          runtime/sema.go:62 +0x25
  sync.(*WaitGroup).Wait(0x21ecf60?)
          sync/waitgroup.go:116 +0x48
  golang.org/x/sync/errgroup.(*Group).Wait(0xc0007f0dc0)
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:56 +0x25
  github.com/docker/compose/v2/pkg/progress.RunWithStatus({0x2747e68, 0xc000791cb0}, 0xc00032a980, 0xc0003537a0, {0x23a0d45, 0x7})
          github.com/docker/compose/v2/pkg/progress/writer.go:97 +0x1ef
  github.com/docker/compose/v2/pkg/progress.Run({0x2747e68, 0xc000791cb0}, 0xc0007f0d80, 0xc0003537a0)
          github.com/docker/compose/v2/pkg/progress/writer.go:61 +0x85
  github.com/docker/compose/v2/pkg/compose.(*composeService).Up(0xc000437640, {0x2747e68, 0xc000791cb0}, _, {{0xc0001cb300, {0xc000645e50, 0x0, 0x1}, 0x0, 0x0, ...}, ...})
          github.com/docker/compose/v2/pkg/compose/up.go:40 +0x233
  github.com/docker/compose/v2/cmd/compose.runUp({_, _}, {_, _}, {_, _}, {0x0, 0x0, {0x239f110, 0x6}, ...}, ...)
          github.com/docker/compose/v2/cmd/compose/up.go:295 +0xc14
  github.com/docker/compose/v2/cmd/compose.upCommand.func2({0x2747e68, 0xc000791cb0}, 0xc0005faab0, {0xc000645e50, 0x0, 0x1})
          github.com/docker/compose/v2/cmd/compose/up.go:136 +0x2d8
  github.com/docker/compose/v2/cmd/compose.upCommand.(*ProjectOptions).WithServices.func4({0x2747ea0, 0xc0000cc550}, {0xc000645e50, 0x0, 0x1})
          github.com/docker/compose/v2/cmd/compose/compose.go:170 +0x1a3
  github.com/docker/compose/v2/cmd/compose.upCommand.(*ProjectOptions).WithServices.Adapt.func6({0x2747ea0?, 0xc0000cc550?}, 0x2?, {0xc000645e50?, 0x272d670?, 0xb5c344?})
          github.com/docker/compose/v2/cmd/compose/compose.go:125 +0x30
  github.com/docker/compose/v2/cmd/compose.upCommand.(*ProjectOptions).WithServices.Adapt.AdaptCmd.func7(0xc000421508, {0xc000645e50, 0x0, 0x1})
          github.com/docker/compose/v2/cmd/compose/compose.go:101 +0x154
  github.com/docker/cli/cli-plugins/plugin.RunPlugin.func1.1.2(0xc000421508, {0xc000645e50, 0x0, 0x1})
          github.com/docker/cli@v27.3.0-rc.2+incompatible/cli-plugins/plugin/plugin.go:64 +0x6c
  github.com/docker/compose/v2/cmd/cmdtrace.Setup.wrapRunE.func2(0xc000421508?, {0xc000645e50?, 0x0?, 0x1?})
          github.com/docker/compose/v2/cmd/cmdtrace/cmd_span.go:85 +0x63
  github.com/spf13/cobra.(*Command).execute(0xc000421508, {0xc0003791f0, 0x1, 0x1})
          github.com/spf13/cobra@v1.8.1/command.go:985 +0xaca
  github.com/spf13/cobra.(*Command).ExecuteC(0xc0003d6f08)
          github.com/spf13/cobra@v1.8.1/command.go:1117 +0x3ff
  github.com/spf13/cobra.(*Command).Execute(...)
          github.com/spf13/cobra@v1.8.1/command.go:1041
  github.com/docker/cli/cli-plugins/plugin.RunPlugin(0xc0003b88c0, 0xc000421208, {{0x239dbc7, 0x5}, {0x23a7af5, 0xb}, {0x271ce78, 0x7}, {0x0, 0x0}, ...})
          github.com/docker/cli@v27.3.0-rc.2+incompatible/cli-plugins/plugin/plugin.go:79 +0x145
  github.com/docker/cli/cli-plugins/plugin.Run(0x24ec6a8, {{0x239dbc7, 0x5}, {0x23a7af5, 0xb}, {0x271ce78, 0x7}, {0x0, 0x0}, {0x0, ...}})
          github.com/docker/cli@v27.3.0-rc.2+incompatible/cli-plugins/plugin/plugin.go:94 +0x165
  main.pluginMain()
          github.com/docker/compose/v2/cmd/main.go:38 +0xa5
  main.main()
          github.com/docker/compose/v2/cmd/main.go:98 +0x19c

  goroutine 36 [IO wait]:
  internal/poll.runtime_pollWait(0x7a4fd6c35700, 0x72)
          runtime/netpoll.go:345 +0x85
  internal/poll.(*pollDesc).wait(0xc000427700?, 0xc000093fbf?, 0x0)
          internal/poll/fd_poll_runtime.go:84 +0x27
  internal/poll.(*pollDesc).waitRead(...)
          internal/poll/fd_poll_runtime.go:89
  internal/poll.(*FD).Read(0xc000427700, {0xc000093fbf, 0x1, 0x1})
          internal/poll/fd_unix.go:164 +0x27a
  net.(*netFD).Read(0xc000427700, {0xc000093fbf?, 0x0?, 0x0?})
          net/fd_posix.go:55 +0x25
  net.(*conn).Read(0xc00063c040, {0xc000093fbf?, 0x0?, 0x0?})
          net/net.go:185 +0x45
  github.com/docker/cli/cli-plugins/socket.ConnectAndWait.func1()
          github.com/docker/cli@v27.3.0-rc.2+incompatible/cli-plugins/socket/socket.go:162 +0x45
  created by github.com/docker/cli/cli-plugins/socket.ConnectAndWait in goroutine 1
          github.com/docker/cli@v27.3.0-rc.2+incompatible/cli-plugins/socket/socket.go:159 +0x118

  goroutine 11 [select]:
  github.com/docker/compose/v2/pkg/progress.(*ttyWriter).Start(0xc0003e0a80, {0x2747b28, 0x39ed700})
          github.com/docker/compose/v2/pkg/progress/tty.go:54 +0x105
  github.com/docker/compose/v2/pkg/progress.RunWithStatus.func1()
          github.com/docker/compose/v2/pkg/progress/writer.go:83 +0x2a
  golang.org/x/sync/errgroup.(*Group).Go.func1()
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:78 +0x56
  created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 1
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:75 +0x96

  goroutine 24 [select]:
  go.opentelemetry.io/otel/sdk/trace.(*batchSpanProcessor).processQueue(0xc0005940a0)
          go.opentelemetry.io/otel/sdk@v1.21.0/trace/batch_span_processor.go:312 +0x11d
  go.opentelemetry.io/otel/sdk/trace.NewBatchSpanProcessor.func1()
          go.opentelemetry.io/otel/sdk@v1.21.0/trace/batch_span_processor.go:128 +0x54
  created by go.opentelemetry.io/otel/sdk/trace.NewBatchSpanProcessor in goroutine 1
          go.opentelemetry.io/otel/sdk@v1.21.0/trace/batch_span_processor.go:126 +0x2e5

  goroutine 50 [syscall]:
  os/signal.signal_recv()
          runtime/sigqueue.go:152 +0x29
  os/signal.loop()
          os/signal/signal_unix.go:23 +0x13
  created by os/signal.Notify.func1.1 in goroutine 1
          os/signal/signal.go:151 +0x1f

  goroutine 51 [chan receive]:
  github.com/docker/compose/v2/cmd/compose.upCommand.AdaptCmd.func3.1()
          github.com/docker/compose/v2/cmd/compose/compose.go:95 +0x27
  created by github.com/docker/compose/v2/cmd/compose.upCommand.AdaptCmd.func3 in goroutine 1
          github.com/docker/compose/v2/cmd/compose/compose.go:94 +0x11c

  goroutine 52 [chan receive]:
  github.com/docker/compose/v2/cmd/compose.upCommand.(*ProjectOptions).WithServices.Adapt.AdaptCmd.func7.1()
          github.com/docker/compose/v2/cmd/compose/compose.go:95 +0x27
  created by github.com/docker/compose/v2/cmd/compose.upCommand.(*ProjectOptions).WithServices.Adapt.AdaptCmd.func7 in goroutine 1
          github.com/docker/compose/v2/cmd/compose/compose.go:94 +0x11c

  goroutine 12 [semacquire]:
  sync.runtime_Semacquire(0x1d21f88?)
          runtime/sema.go:62 +0x25
  sync.(*WaitGroup).Wait(0x5?)
          sync/waitgroup.go:116 +0x48
  golang.org/x/sync/errgroup.(*Group).Wait(0xc00012ca80)
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:56 +0x25
  github.com/docker/compose/v2/pkg/compose.(*graphTraversal).visit(0xc0007da050, {0x2747e68, 0xc0005565d0}, 0xc0007f63a0)
          github.com/docker/compose/v2/pkg/compose/dependencies.go:163 +0x226
  github.com/docker/compose/v2/pkg/compose.InDependencyOrder({0x2747e68, 0xc0005565d0}, 0xc0003e6f40?, 0xc00013a380, {0x0, 0x0, 0xc000437640?})
          github.com/docker/compose/v2/pkg/compose/dependencies.go:88 +0xf1
  github.com/docker/compose/v2/pkg/compose.(*convergence).apply(...)
          github.com/docker/compose/v2/pkg/compose/convergence.go:92
  github.com/docker/compose/v2/pkg/compose.(*composeService).create(0xc000437640, {0x2747e68, 0xc0005565d0}, 0xc0005faab0, {0xc0001cb300, {0xc0002c8700, 0xa, 0x10}, 0x0, 0x0, ...})
          github.com/docker/compose/v2/pkg/compose/create.go:118 +0x5c5
  github.com/docker/compose/v2/pkg/compose.(*composeService).Up.func1({0x2747e68, 0xc0005565d0})
          github.com/docker/compose/v2/pkg/compose/up.go:41 +0x85
  github.com/docker/compose/v2/pkg/compose.(*composeService).Up.SpanWrapFunc.func5({0x2747e68, 0xc0005565a0})
          github.com/docker/compose/v2/internal/tracing/wrap.go:43 +0x152
  github.com/docker/compose/v2/pkg/progress.Run.func1({0x2747e68?, 0xc0005565a0?})
          github.com/docker/compose/v2/pkg/progress/writer.go:62 +0x22
  github.com/docker/compose/v2/pkg/progress.RunWithStatus.func2()
          github.com/docker/compose/v2/pkg/progress/writer.go:90 +0x7f
  golang.org/x/sync/errgroup.(*Group).Go.func1()
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:78 +0x56
  created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 1
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:75 +0x96

  goroutine 68 [select]:
  github.com/docker/compose/v2/pkg/compose.(*graphTraversal).visit.func1()
          github.com/docker/compose/v2/pkg/compose/dependencies.go:147 +0x10c
  golang.org/x/sync/errgroup.(*Group).Go.func1()
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:78 +0x56
  created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 12
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:75 +0x96

  goroutine 71 [runnable]:
  fmt.(*pp).fmtBytes(0xc00083c0d0, {0xc00089e000, 0x20, 0x20?}, 0x78, {0x239f29c?, 0x6?})
          fmt/print.go:509 +0x6cc
  fmt.(*pp).printArg(0xc00083c0d0, {0x1e9b300, 0xc000012780}, 0x78)
          fmt/print.go:743 +0x11a
  fmt.(*pp).doPrintf(0xc00083c0d0, {0x239c5d2, 0x2}, {0xc00006f7c8, 0x1, 0x1})
          fmt/print.go:1075 +0x37e
  fmt.Sprintf({0x239c5d2, 0x2}, {0xc00006f7c8, 0x1, 0x1})
          fmt/print.go:239 +0x53
  github.com/opencontainers/go-digest.Algorithm.Encode(...)
          github.com/opencontainers/go-digest@v1.0.0/algorithm.go:143
  github.com/opencontainers/go-digest.NewDigestFromBytes({0x239f158, 0x6}, {0xc00089e000?, 0x0?, 0x240?})
          github.com/opencontainers/go-digest@v1.0.0/digest.go:49 +0x6a
  github.com/opencontainers/go-digest.NewDigest({0x239f158, 0x6}, {0x274b020?, 0xc0005a0000?})
          github.com/opencontainers/go-digest@v1.0.0/digest.go:41 +0x4a
  github.com/opencontainers/go-digest.(*digester).Digest(0xc0005a0000?)
          github.com/opencontainers/go-digest@v1.0.0/digester.go:39 +0x25
  github.com/opencontainers/go-digest.Algorithm.FromBytes({0x239f158, 0x6}, {0xc0001d0a00, 0x262, 0x280})
          github.com/opencontainers/go-digest@v1.0.0/algorithm.go:170 +0x9b
  github.com/docker/compose/v2/pkg/compose.ServiceHash({{0xc00052ad80, 0x9}, {0x0, 0x0, 0x0}, 0x0, 0x0, 0x0, 0x0, 0x0, ...})
          github.com/docker/compose/v2/pkg/compose/hash.go:41 +0xad
  github.com/docker/compose/v2/pkg/compose.mustRecreate({{0xc00052ad80, 0x9}, {0x0, 0x0, 0x0}, 0x0, 0x0, 0x0, 0x0, 0x0, ...}, ...)
          github.com/docker/compose/v2/pkg/compose/convergence.go:322 +0x8c
  github.com/docker/compose/v2/pkg/compose.(*convergence).ensureService(_, {_, _}, _, {{0xc00052ad80, 0x9}, {0x0, 0x0, 0x0}, 0x0, ...}, ...)
          github.com/docker/compose/v2/pkg/compose/convergence.go:157 +0x60e
  github.com/docker/compose/v2/pkg/compose.(*composeService).create.(*convergence).apply.func1.1({0x2747e68, 0xc0005e8030})
          github.com/docker/compose/v2/pkg/compose/convergence.go:103 +0x10b
  github.com/docker/compose/v2/internal/tracing.SpanWrapFunc.func1({0x2747ea0, 0xc0007da0a0})
          github.com/docker/compose/v2/internal/tracing/wrap.go:43 +0x1c4
  github.com/docker/compose/v2/pkg/compose.(*composeService).create.(*convergence).apply.func1({0x2747ea0, 0xc0007da0a0}, {0xc00052ad80, 0x9})
          github.com/docker/compose/v2/pkg/compose/convergence.go:104 +0x2e3
  github.com/docker/compose/v2/pkg/compose.(*graphTraversal).run.func1()
          github.com/docker/compose/v2/pkg/compose/dependencies.go:184 +0x99
  golang.org/x/sync/errgroup.(*Group).Go.func1()
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:78 +0x56
  created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 12
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:75 +0x96

  goroutine 98 [IO wait]:
  internal/poll.runtime_pollWait(0x7a4fd6c35418, 0x72)
          runtime/netpoll.go:345 +0x85
  internal/poll.(*pollDesc).wait(0xc00071e080?, 0xc000986000?, 0x0)
          internal/poll/fd_poll_runtime.go:84 +0x27
  internal/poll.(*pollDesc).waitRead(...)
          internal/poll/fd_poll_runtime.go:89
  internal/poll.(*FD).Read(0xc00071e080, {0xc000986000, 0x1000, 0x1000})
          internal/poll/fd_unix.go:164 +0x27a
  net.(*netFD).Read(0xc00071e080, {0xc000986000?, 0xc0002ca0c0?, 0xc00021d180?})
          net/fd_posix.go:55 +0x25
  net.(*conn).Read(0xc000984000, {0xc000986000?, 0x0?, 0xc000501180?})
          net/net.go:185 +0x45
  net/http.(*persistConn).Read(0xc0004a4900, {0xc000986000?, 0xc0009880c0?, 0xc000889d38?})
          net/http/transport.go:1977 +0x4a
  bufio.(*Reader).fill(0xc000564000)
          bufio/bufio.go:110 +0x103
  bufio.(*Reader).Peek(0xc000564000, 0x1)
          bufio/bufio.go:148 +0x53
  net/http.(*persistConn).readLoop(0xc0004a4900)
          net/http/transport.go:2141 +0x1b9
  created by net/http.(*Transport).dialConn in goroutine 53
          net/http/transport.go:1799 +0x152f

  goroutine 99 [select]:
  net/http.(*persistConn).writeLoop(0xc0004a4900)
          net/http/transport.go:2458 +0xf0
  created by net/http.(*Transport).dialConn in goroutine 53
          net/http/transport.go:1800 +0x1585

  goroutine 146 [IO wait]:
  internal/poll.runtime_pollWait(0x7a4fd6c34d50, 0x72)
          runtime/netpoll.go:345 +0x85
  internal/poll.(*pollDesc).wait(0xc00079e080?, 0xc000874000?, 0x0)
          internal/poll/fd_poll_runtime.go:84 +0x27
  internal/poll.(*pollDesc).waitRead(...)
          internal/poll/fd_poll_runtime.go:89
  internal/poll.(*FD).Read(0xc00079e080, {0xc000874000, 0x1000, 0x1000})
          internal/poll/fd_unix.go:164 +0x27a
  net.(*netFD).Read(0xc00079e080, {0xc000874000?, 0x0?, 0xc000876000?})
          net/fd_posix.go:55 +0x25
  net.(*conn).Read(0xc0008160c0, {0xc000874000?, 0x0?, 0xc000583a40?})
          net/net.go:185 +0x45
  net/http.(*persistConn).Read(0xc0004d2120, {0xc000874000?, 0xc00083e2a0?, 0xc0007a4d38?})
          net/http/transport.go:1977 +0x4a
  bufio.(*Reader).fill(0xc0008482a0)
          bufio/bufio.go:110 +0x103
  bufio.(*Reader).Peek(0xc0008482a0, 0x1)
          bufio/bufio.go:148 +0x53
  net/http.(*persistConn).readLoop(0xc0004d2120)
          net/http/transport.go:2141 +0x1b9
  created by net/http.(*Transport).dialConn in goroutine 38
          net/http/transport.go:1799 +0x152f

  goroutine 147 [select]:
  net/http.(*persistConn).writeLoop(0xc0004d2120)
          net/http/transport.go:2458 +0xf0
  created by net/http.(*Transport).dialConn in goroutine 38
          net/http/transport.go:1800 +0x1585

  goroutine 73 [runnable]:
  github.com/docker/compose/v2/internal/tracing.timeAttr({0x23ba4fd, 0x14}, {0x0?, 0xede2f2fe2?, 0x0?})
          github.com/docker/compose/v2/internal/tracing/attributes.go:167 +0x189
  github.com/docker/compose/v2/internal/tracing.unixTimeAttr({0x23ba4fd?, 0x0?}, 0x0?)
          github.com/docker/compose/v2/internal/tracing/attributes.go:172 +0x7e
  github.com/docker/compose/v2/internal/tracing.ContainerOptions({{0xc00005cc80, 0x40}, {0xc0003e6b30, 0x1, 0x1}, {0xc000322fc0, 0x24}, {0xc0006d43c0, 0x47}, {0xc0001794ea, ...}, ...})
          github.com/docker/compose/v2/internal/tracing/attributes.go:147 +0x219
  github.com/docker/compose/v2/pkg/compose.(*convergence).ensureService(_, {_, _}, _, {{0xc0000151d0, 0x9}, {0x0, 0x0, 0x0}, 0x0, ...}, ...)
          github.com/docker/compose/v2/pkg/compose/convergence.go:168 +0x73f
  github.com/docker/compose/v2/pkg/compose.(*composeService).create.(*convergence).apply.func1.1({0x2747e68, 0xc0007ee000})
          github.com/docker/compose/v2/pkg/compose/convergence.go:103 +0x10b
  github.com/docker/compose/v2/internal/tracing.SpanWrapFunc.func1({0x2747ea0, 0xc0007da0a0})
          github.com/docker/compose/v2/internal/tracing/wrap.go:43 +0x1c4
  github.com/docker/compose/v2/pkg/compose.(*composeService).create.(*convergence).apply.func1({0x2747ea0, 0xc0007da0a0}, {0xc0000151d0, 0x9})
          github.com/docker/compose/v2/pkg/compose/convergence.go:104 +0x2e3
  github.com/docker/compose/v2/pkg/compose.(*graphTraversal).run.func1()
          github.com/docker/compose/v2/pkg/compose/dependencies.go:184 +0x99
  golang.org/x/sync/errgroup.(*Group).Go.func1()
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:78 +0x56
  created by golang.org/x/sync/errgroup.(*Group).Go in goroutine 12
          golang.org/x/sync@v0.8.0/errgroup/errgroup.go:75 +0x96
  ```
</details>


Patch:
diff --git a/cmd/formatter/shortcut.go b/cmd/formatter/shortcut.go
index 1a4d73355..625ffc44e 100644
--- a/cmd/formatter/shortcut.go
+++ b/cmd/formatter/shortcut.go
@@ -288,19 +288,7 @@ func (lk *LogKeyboard) keyboardError(prefix string, err error) {
 
 func (lk *LogKeyboard) StartWatch(ctx context.Context, doneCh chan bool, project *types.Project, options api.UpOptions) {
 	if !lk.IsWatchConfigured {
-		if lk.IsDDComposeUIActive {
-			// we try to open watch docs
-			lk.openDDWatchDocs(ctx, project)
-		}
-		// either way we mark menu/watch as an error
-		eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
-			func(ctx context.Context) error {
-				err := fmt.Errorf("Watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
-				lk.keyboardError("Watch", err)
-				return err
-			}))
 		return
-
 	}
 	lk.Watch.switchWatching()
 	if !lk.Watch.isWatching() {
@@ -330,6 +318,20 @@ func (lk *LogKeyboard) HandleKeyEvents(event keyboard.KeyEvent, ctx context.Cont
 	case 'v':
 		lk.openDockerDesktop(ctx, project)
 	case 'w':
+		if !lk.IsWatchConfigured {
+			if lk.IsDDComposeUIActive {
+				// we try to open watch docs
+				lk.openDDWatchDocs(ctx, project)
+			}
+			// either way we mark menu/watch as an error
+			eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
+				func(ctx context.Context) error {
+					err := fmt.Errorf("watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
+					lk.keyboardError("Watch", err)
+					return err
+				}))
+			return
+		}
 		lk.StartWatch(ctx, doneCh, project, options)
 	case 'o':
 		lk.openDDComposeUI(ctx, project)
diff --git a/go.mod b/go.mod
index 7e97799e6..da8a67a32 100644
--- a/go.mod
+++ b/go.mod
@@ -13,9 +13,9 @@ require (
 	github.com/davecgh/go-spew v1.1.1
 	github.com/distribution/reference v0.6.0
 	github.com/docker/buildx v0.17.1
-	github.com/docker/cli v27.3.0-rc.2+incompatible
+	github.com/docker/cli v27.3.1+incompatible
 	github.com/docker/cli-docs-tool v0.8.0
-	github.com/docker/docker v27.3.0-rc.2+incompatible
+	github.com/docker/docker v27.3.1+incompatible
 	github.com/docker/go-connections v0.5.0
 	github.com/docker/go-units v0.5.0
 	github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
@@ -192,3 +192,5 @@ require (
 	sigs.k8s.io/yaml v1.3.0 // indirect
 	tags.cncf.io/container-device-interface v0.8.0 // indirect
 )
+
+replace github.com/compose-spec/compose-go/v2 => github.com/compose-spec/compose-go/v2 v2.2.1-0.20240926141145-d3fd7d94aa31
diff --git a/go.sum b/go.sum
index ab93a3a7e..c980ef2ce 100644
--- a/go.sum
+++ b/go.sum
@@ -85,8 +85,8 @@ github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa h1:jQCWAUqqlij9Pgj2i/P
 github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq8dk6e9PdstVsDgu9RuyIIJqAaF//0IM=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
-github.com/compose-spec/compose-go/v2 v2.2.0 h1:VsQosGhuO+H9wh5laiIiAe4TVd73kQ5NWwmNrdm0HRA=
-github.com/compose-spec/compose-go/v2 v2.2.0/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
+github.com/compose-spec/compose-go/v2 v2.2.1-0.20240926141145-d3fd7d94aa31 h1:1LUnOD+HiecazmN9NDFE9mtwywIehxt/I4g0jc/syc0=
+github.com/compose-spec/compose-go/v2 v2.2.1-0.20240926141145-d3fd7d94aa31/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
 github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
 github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
 github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
@@ -128,15 +128,15 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/docker/buildx v0.17.1 h1:9ob2jGp4+W9PxWw68GsoNFp+eYFc7eUoRL9VljLCSM4=
 github.com/docker/buildx v0.17.1/go.mod h1:kJOhOhS47LRvrLFRulFiO5SE6VJf54yYMn7DzjgO5W0=
-github.com/docker/cli v27.3.0-rc.2+incompatible h1:z/lQXreGSzWl0RySxyFOm6WygLQtre2EkB4SCwsEut0=
-github.com/docker/cli v27.3.0-rc.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
+github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli-docs-tool v0.8.0 h1:YcDWl7rQJC3lJ7WVZRwSs3bc9nka97QLWfyJQli8yJU=
 github.com/docker/cli-docs-tool v0.8.0/go.mod h1:8TQQ3E7mOXoYUs811LiPdUnAhXrcVsBIrW21a5pUbdk=
 github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v27.3.0-rc.2+incompatible h1:+WSuhLOwbip2IuVKZ4ecSQfG/grbPw96d3b7jBC8MSQ=
-github.com/docker/docker v27.3.0-rc.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
+github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
 github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
 github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
diff --git a/internal/ocipush/push.go b/internal/ocipush/push.go
index 8f632a878..0296f67da 100644
--- a/internal/ocipush/push.go
+++ b/internal/ocipush/push.go
@@ -179,7 +179,7 @@ func generateManifest(layers []v1.Descriptor, ociCompat api.OCIVersion) ([]Pusha
 		config = v1.DescriptorEmptyJSON
 		artifactType = ComposeProjectArtifactType
 		// N.B. the descriptor has the data embedded in it
-		toPush = append(toPush, Pushable{Descriptor: config, Data: nil})
+		toPush = append(toPush, Pushable{Descriptor: config, Data: make([]byte, len(config.Data))})
 	default:
 		return nil, fmt.Errorf("unsupported OCI version: %s", ociCompat)
 	}
diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go
index 5ad3c94b1..10918e127 100644
--- a/pkg/compose/convergence.go
+++ b/pkg/compose/convergence.go
@@ -223,7 +223,7 @@ func (c *convergence) stopDependentContainers(ctx context.Context, project *type
 	// Stop dependent containers, so they will be restarted after service is re-created
 	dependents := project.GetDependentsForService(service)
 	for _, name := range dependents {
-		dependents := c.observedState[name]
+		dependents := c.getObservedState(name)
 		err := c.service.stopContainers(ctx, w, dependents, nil)
 		if err != nil {
 			return err
@@ -232,7 +232,7 @@ func (c *convergence) stopDependentContainers(ctx context.Context, project *type
 			dependent.State = ContainerExited
 			dependents[i] = dependent
 		}
-		c.observedState[name] = dependents
+		c.setObservedState(name, dependents)
 	}
 	return nil
 }
@@ -364,7 +364,12 @@ func containerReasonEvents(containers Containers, eventFunc func(string, string)
 const ServiceConditionRunningOrHealthy = "running_or_healthy"
 
 //nolint:gocyclo
-func (s *composeService) waitDependencies(ctx context.Context, project *types.Project, dependant string, dependencies types.DependsOnConfig, containers Containers) error {
+func (s *composeService) waitDependencies(ctx context.Context, project *types.Project, dependant string, dependencies types.DependsOnConfig, containers Containers, timeout time.Duration) error {
+	if timeout > 0 {
+		withTimeout, cancelFunc := context.WithTimeout(ctx, timeout)
+		defer cancelFunc()
+		ctx = withTimeout
+	}
 	eg, _ := errgroup.WithContext(ctx)
 	w := progress.ContextWriter(ctx)
 	for dep, config := range dependencies {
@@ -454,7 +459,11 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
 			}
 		})
 	}
-	return eg.Wait()
+	err := eg.Wait()
+	if errors.Is(err, context.DeadlineExceeded) {
+		return fmt.Errorf("timeout waiting for dependencies")
+	}
+	return err
 }
 
 func shouldWaitForDependency(serviceName string, dependencyConfig types.ServiceDependency, project *types.Project) (bool, error) {
@@ -760,12 +769,12 @@ func (s *composeService) isServiceCompleted(ctx context.Context, containers Cont
 	return false, 0, nil
 }
 
-func (s *composeService) startService(ctx context.Context, project *types.Project, service types.ServiceConfig, containers Containers) error {
+func (s *composeService) startService(ctx context.Context, project *types.Project, service types.ServiceConfig, containers Containers, timeout time.Duration) error {
 	if service.Deploy != nil && service.Deploy.Replicas != nil && *service.Deploy.Replicas == 0 {
 		return nil
 	}
 
-	err := s.waitDependencies(ctx, project, service.Name, service.DependsOn, containers)
+	err := s.waitDependencies(ctx, project, service.Name, service.DependsOn, containers, timeout)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/compose/create.go b/pkg/compose/create.go
index c4d1734fa..e7626830c 100644
--- a/pkg/compose/create.go
+++ b/pkg/compose/create.go
@@ -1003,11 +1003,18 @@ func buildContainerSecretMounts(p types.Project, s types.ServiceConfig) ([]mount
 			continue
 		}
 
+		if _, err := os.Stat(definedSecret.File); os.IsNotExist(err) {
+			logrus.Warnf("secret file %s does not exist", definedSecret.Name)
+		}
+
 		mnt, err := buildMount(p, types.ServiceVolumeConfig{
 			Type:     types.VolumeTypeBind,
 			Source:   definedSecret.File,
 			Target:   target,
 			ReadOnly: true,
+			Bind: &types.ServiceVolumeBind{
+				CreateHostPath: false,
+			},
 		})
 		if err != nil {
 			return nil, err
@@ -1060,7 +1067,7 @@ func buildMount(project types.Project, volume types.ServiceVolumeConfig) (mount.
 		}
 	}
 
-	bind, vol, tmpfs := buildMountOptions(project, volume)
+	bind, vol, tmpfs := buildMountOptions(volume)
 
 	volume.Target = path.Clean(volume.Target)
 
@@ -1080,7 +1087,7 @@ func buildMount(project types.Project, volume types.ServiceVolumeConfig) (mount.
 	}, nil
 }
 
-func buildMountOptions(project types.Project, volume types.ServiceVolumeConfig) (*mount.BindOptions, *mount.VolumeOptions, *mount.TmpfsOptions) {
+func buildMountOptions(volume types.ServiceVolumeConfig) (*mount.BindOptions, *mount.VolumeOptions, *mount.TmpfsOptions) {
 	switch volume.Type {
 	case "bind":
 		if volume.Volume != nil {
@@ -1097,11 +1104,6 @@ func buildMountOptions(project types.Project, volume types.ServiceVolumeConfig)
 		if volume.Tmpfs != nil {
 			logrus.Warnf("mount of type `volume` should not define `tmpfs` option")
 		}
-		if v, ok := project.Volumes[volume.Source]; ok && v.DriverOpts["o"] == types.VolumeTypeBind {
-			return buildBindOption(&types.ServiceVolumeBind{
-				CreateHostPath: true,
-			}), nil, nil
-		}
 		return nil, buildVolumeOptions(volume.Volume), nil
 	case "tmpfs":
 		if volume.Bind != nil {
diff --git a/pkg/compose/down.go b/pkg/compose/down.go
index 2c2e75dc4..a9ecaeabd 100644
--- a/pkg/compose/down.go
+++ b/pkg/compose/down.go
@@ -67,11 +67,18 @@ func (s *composeService) down(ctx context.Context, projectName string, options a
 	}
 
 	// Check requested services exists in model
-	options.Services, err = checkSelectedServices(options, project)
+	services, err := checkSelectedServices(options, project)
 	if err != nil {
 		return err
 	}
 
+	if len(options.Services) > 0 && len(services) == 0 {
+		logrus.Infof("Any of the services %v not running in project %q", options.Services, projectName)
+		return nil
+	}
+
+	options.Services = services
+
 	if len(containers) > 0 {
 		resourceToRemove = true
 	}
diff --git a/pkg/compose/printer.go b/pkg/compose/printer.go
index fba8801a1..9f382f87d 100644
--- a/pkg/compose/printer.go
+++ b/pkg/compose/printer.go
@@ -98,7 +98,7 @@ func (p *printer) Run(cascade api.Cascade, exitCodeFrom string, stopFn func() er
 			case api.UserCancel:
 				aborting = true
 			case api.ContainerEventAttach:
-				if _, ok := containers[id]; ok {
+				if attached, ok := containers[id]; ok && attached {
 					continue
 				}
 				containers[id] = true
diff --git a/pkg/compose/run.go b/pkg/compose/run.go
index cdafb4b8a..1bd769dbb 100644
--- a/pkg/compose/run.go
+++ b/pkg/compose/run.go
@@ -93,7 +93,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
 	}
 
 	if !opts.NoDeps {
-		if err := s.waitDependencies(ctx, project, service.Name, service.DependsOn, observedState); err != nil {
+		if err := s.waitDependencies(ctx, project, service.Name, service.DependsOn, observedState, 0); err != nil {
 			return "", err
 		}
 	}
diff --git a/pkg/compose/start.go b/pkg/compose/start.go
index 7caeb3057..b5fcceebf 100644
--- a/pkg/compose/start.go
+++ b/pkg/compose/start.go
@@ -129,7 +129,7 @@ func (s *composeService) start(ctx context.Context, projectName string, options
 			return err
 		}
 
-		return s.startService(ctx, project, service, containers)
+		return s.startService(ctx, project, service, containers, options.WaitTimeout)
 	})
 	if err != nil {
 		return err
@@ -149,7 +149,7 @@ func (s *composeService) start(ctx context.Context, projectName string, options
 			defer cancel()
 		}
 
-		err = s.waitDependencies(ctx, project, project.Name, depends, containers)
+		err = s.waitDependencies(ctx, project, project.Name, depends, containers, 0)
 		if err != nil {
 			if errors.Is(ctx.Err(), context.DeadlineExceeded) {
 				return fmt.Errorf("application not healthy after %s", options.WaitTimeout)


Test patch:
diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go
index e25ccd9f6..dc981fdaf 100644
--- a/pkg/compose/convergence_test.go
+++ b/pkg/compose/convergence_test.go
@@ -239,7 +239,7 @@ func TestWaitDependencies(t *testing.T) {
 			"db":    {Condition: ServiceConditionRunningOrHealthy},
 			"redis": {Condition: ServiceConditionRunningOrHealthy},
 		}
-		assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil))
+		assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
 	})
 	t.Run("should skip dependencies with condition service_started", func(t *testing.T) {
 		dbService := types.ServiceConfig{Name: "db", Scale: intPtr(1)}
@@ -252,7 +252,7 @@ func TestWaitDependencies(t *testing.T) {
 			"db":    {Condition: types.ServiceConditionStarted, Required: true},
 			"redis": {Condition: types.ServiceConditionStarted, Required: true},
 		}
-		assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil))
+		assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
 	})
 }
 
diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go
index 93590f215..184d45d0e 100644
--- a/pkg/compose/down_test.go
+++ b/pkg/compose/down_test.go
@@ -96,6 +96,95 @@ func TestDown(t *testing.T) {
 	assert.NilError(t, err)
 }
 
+func TestDownWithGivenServices(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+
+	api, cli := prepareMocks(mockCtrl)
+	tested := composeService{
+		dockerCli: cli,
+	}
+
+	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
+		[]moby.Container{
+			testContainer("service1", "123", false),
+			testContainer("service2", "456", false),
+			testContainer("service2", "789", false),
+			testContainer("service_orphan", "321", true),
+		}, nil)
+	api.EXPECT().VolumeList(
+		gomock.Any(),
+		volume.ListOptions{
+			Filters: filters.NewArgs(projectFilter(strings.ToLower(testProject))),
+		}).
+		Return(volume.ListResponse{}, nil)
+
+	// network names are not guaranteed to be unique, ensure Compose handles
+	// cleanup properly if duplicates are inadvertently created
+	api.EXPECT().NetworkList(gomock.Any(), network.ListOptions{Filters: filters.NewArgs(projectFilter(strings.ToLower(testProject)))}).
+		Return([]network.Summary{
+			{ID: "abc123", Name: "myProject_default", Labels: map[string]string{compose.NetworkLabel: "default"}},
+			{ID: "def456", Name: "myProject_default", Labels: map[string]string{compose.NetworkLabel: "default"}},
+		}, nil)
+
+	stopOptions := containerType.StopOptions{}
+	api.EXPECT().ContainerStop(gomock.Any(), "123", stopOptions).Return(nil)
+
+	api.EXPECT().ContainerRemove(gomock.Any(), "123", containerType.RemoveOptions{Force: true}).Return(nil)
+
+	api.EXPECT().NetworkList(gomock.Any(), network.ListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(strings.ToLower(testProject)),
+			networkFilter("default")),
+	}).Return([]network.Summary{
+		{ID: "abc123", Name: "myProject_default"},
+	}, nil)
+	api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(network.Inspect{ID: "abc123"}, nil)
+	api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil)
+
+	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
+		Services: []string{"service1", "not-running-service"},
+	})
+	assert.NilError(t, err)
+}
+
+func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+
+	api, cli := prepareMocks(mockCtrl)
+	tested := composeService{
+		dockerCli: cli,
+	}
+
+	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
+		[]moby.Container{
+			testContainer("service1", "123", false),
+			testContainer("service2", "456", false),
+			testContainer("service2", "789", false),
+			testContainer("service_orphan", "321", true),
+		}, nil)
+	api.EXPECT().VolumeList(
+		gomock.Any(),
+		volume.ListOptions{
+			Filters: filters.NewArgs(projectFilter(strings.ToLower(testProject))),
+		}).
+		Return(volume.ListResponse{}, nil)
+
+	// network names are not guaranteed to be unique, ensure Compose handles
+	// cleanup properly if duplicates are inadvertently created
+	api.EXPECT().NetworkList(gomock.Any(), network.ListOptions{Filters: filters.NewArgs(projectFilter(strings.ToLower(testProject)))}).
+		Return([]network.Summary{
+			{ID: "abc123", Name: "myProject_default", Labels: map[string]string{compose.NetworkLabel: "default"}},
+			{ID: "def456", Name: "myProject_default", Labels: map[string]string{compose.NetworkLabel: "default"}},
+		}, nil)
+
+	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
+		Services: []string{"not-running-service1", "not-running-service2"},
+	})
+	assert.NilError(t, err)
+}
+
 func TestDownRemoveOrphans(t *testing.T) {
 	mockCtrl := gomock.NewController(t)
 	defer mockCtrl.Finish()