Description
The #13639 fix (25b29d7) makes ttyWriter.Done send on the unbuffered done channel before taking the render mutex, which fixes the completion-path deadlock. A second wedge path survives it.
The render goroutine started by ttyWriter.Start has three exits: ctx.Done(), receiving from w.done, and the 100ms tick. The done channel has exactly two users in the codebase: that receive, and the send in Done. AdaptCmd cancels the command context on the first SIGTERM/SIGINT. When that happens, the render goroutine exits through the ctx.Done() branch and never receives. pkg/compose.Run then calls bus.Done unconditionally, and its send blocks forever: the only receiver is gone. There is also a narrower window when the cancel and the send race: the select can pick the ctx.Done() branch while the sender is already blocked.
Interactive use masks this: the signal handler unregisters itself after the first signal, so a second Ctrl-C kills the process by default disposition. An unattended caller that sends a single TERM to a TTY-attached compose command gets a process that never exits.
Expected: Done returns after a canceled operation and the process exits with status 130.
Steps To Reproduce
The mechanism reduces to a deterministic unit test. On current main (f729794), add this to cmd/display/tty_cancel_test.go:
// A single SIGTERM/SIGINT cancels the command context (AdaptCmd). The render
// goroutine started by Start then exits through its ctx.Done() branch and never
// receives from the unbuffered done channel. pkg/compose.Run still calls
// bus.Done unconditionally afterward, and its send blocks forever: the only
// receiver is gone.
func TestDoneAfterContextCancelDoesNotHang(t *testing.T) {
w, _ := newTestWriter()
ctx, cancel := context.WithCancel(context.Background())
w.Start(ctx, "down")
cancel()
// Let the render goroutine observe the cancellation and exit.
time.Sleep(100 * time.Millisecond)
finished := make(chan struct{})
go func() {
w.Done("down", false)
close(finished)
}()
select {
case <-finished:
case <-time.After(2 * time.Second):
t.Fatal("ttyWriter.Done blocked forever after context cancellation")
}
}
--- FAIL: TestDoneAfterContextCancelDoesNotHang (2.10s)
tty_cancel_test.go:46: ttyWriter.Done blocked forever after context cancellation
FAIL github.com/docker/compose/v5/cmd/display
Operationally this corresponds to: docker compose down on a TTY with a slow-stopping service, one SIGTERM while the progress UI renders, and the process never exits. We found it while root-causing #13639 on an embedded device fleet, where we captured on-target goroutine dumps of that deadlock.
Compose Version
Built from main at e8c21434 plus a backport of 25b29d77; the involved code paths are unchanged at f729794.
Docker Environment
Embedded Linux devices, docker 29.x; the analysis is environment-independent.
Anything else?
Replacing the send in Done with close(w.done) fixes it: close never blocks, and the select exits on the closed channel. With that one-line change, the test above and the whole cmd/display suite pass, race detector included.
Description
The #13639 fix (25b29d7) makes
ttyWriter.Donesend on the unbuffereddonechannel before taking the render mutex, which fixes the completion-path deadlock. A second wedge path survives it.The render goroutine started by
ttyWriter.Starthas three exits:ctx.Done(), receiving fromw.done, and the 100ms tick. Thedonechannel has exactly two users in the codebase: that receive, and the send inDone.AdaptCmdcancels the command context on the first SIGTERM/SIGINT. When that happens, the render goroutine exits through thectx.Done()branch and never receives.pkg/compose.Runthen callsbus.Doneunconditionally, and its send blocks forever: the only receiver is gone. There is also a narrower window when the cancel and the send race: the select can pick thectx.Done()branch while the sender is already blocked.Interactive use masks this: the signal handler unregisters itself after the first signal, so a second Ctrl-C kills the process by default disposition. An unattended caller that sends a single TERM to a TTY-attached compose command gets a process that never exits.
Expected:
Donereturns after a canceled operation and the process exits with status 130.Steps To Reproduce
The mechanism reduces to a deterministic unit test. On current main (f729794), add this to
cmd/display/tty_cancel_test.go:Operationally this corresponds to:
docker compose downon a TTY with a slow-stopping service, one SIGTERM while the progress UI renders, and the process never exits. We found it while root-causing #13639 on an embedded device fleet, where we captured on-target goroutine dumps of that deadlock.Compose Version
Docker Environment
Anything else?
Replacing the send in
Donewithclose(w.done)fixes it: close never blocks, and the select exits on the closed channel. With that one-line change, the test above and the wholecmd/displaysuite pass, race detector included.