From b857135cb95ee5ec31fbd43f5fa82ccd022c9bb4 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 20 Aug 2026 14:47:35 +0200 Subject: [PATCH] fix: hold startMx on the start path actually exercised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startMx exists to serialize ContainerStart calls because the engine assigns published ports from ranges non-atomically. It is held by the plan executor's execStartContainer — a nearly dead code path (plans only emit OpStartContainer for paused/dead containers) — and NOT by startServiceContainer, the path every up/start actually runs. The protection is thus inoperative today: concurrent services publishing overlapping port ranges can race in the engine. Take the mutex around the real ContainerStart call and document the contract on the variable. Starts of different services were already serialized per-service (replicas start sequentially); this serializes the API call across services too, matching what the plan engine will do once the start phase converges (#14081). Part of #14074 (section C) / #14081 (lot 0). Signed-off-by: Nicolas De Loof --- pkg/compose/convergence.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 5fa9027c2..bbead3746 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -354,7 +354,11 @@ func (s *composeService) createContainer(ctx context.Context, project *types.Pro return ctr, nil } -// force sequential calls to ContainerStart to prevent race condition in engine assigning ports from ranges +// startMx serializes ContainerStart calls across the whole process: the +// engine allocates published ports from ranges non-atomically, and two +// concurrent starts can be assigned the same port. Every code path calling +// ContainerStart on a service container must hold it (the plan executor's +// execStartContainer and the imperative startServiceContainer both do). var startMx sync.Mutex func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, @@ -603,7 +607,10 @@ func (s *composeService) startServiceContainer(ctx context.Context, project *typ eventName := getContainerProgressName(ctr) s.events.On(newEvent(eventName, api.Working, api.StatusStarting)) - if _, err := s.apiClient().ContainerStart(ctx, ctr.ID, client.ContainerStartOptions{}); err != nil { + startMx.Lock() + _, err := s.apiClient().ContainerStart(ctx, ctr.ID, client.ContainerStartOptions{}) + startMx.Unlock() + if err != nil { return err }