Skip to content

[BUG] ttyWriter.Done still hangs forever when the context was canceled before Done runs #14114

Description

@wegel

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions