fix(progress): probe stderr (not stdout) for TTY auto-detection

The progress UI writes to dockerCli.Err() but the auto-mode selector
was probing dockerCli.Out().IsTerminal(), introduced when the
EventProcessor was moved to the CLI layer. Any context that pipes
stdout while keeping stderr attached — `docker compose up | tee log`,
some CI wrappers, PowerShell native-command capture — silently
dropped to plain mode.

Align the detection with the stream the renderer actually targets,
restoring v4 behavior. Extract the switch into selectEventProcessor
so the auto-mode logic can be unit-tested with a real pty pair.

Fixes docker/compose#13570

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-06-11 23:11:38 +02:00 committed by Nicolas De loof
parent cd4f21647d
commit b8fba27032
2 changed files with 253 additions and 34 deletions

View file

@ -499,40 +499,9 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
}
detached, _ := cmd.Flags().GetBool("detach")
var ep api.EventProcessor
switch opts.Progress {
case "", display.ModeAuto:
switch {
case ansi == "never":
display.Mode = display.ModePlain
ep = display.Plain(dockerCli.Err())
case dockerCli.Out().IsTerminal():
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached)
default:
ep = display.Plain(dockerCli.Err())
}
case display.ModeTTY:
if ansi == "never" {
return fmt.Errorf("can't use --progress tty while ANSI support is disabled")
}
display.Mode = display.ModeTTY
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached)
case display.ModePlain:
if ansi == "always" {
return fmt.Errorf("can't use --progress plain while ANSI support is forced")
}
display.Mode = display.ModePlain
ep = display.Plain(dockerCli.Err())
case display.ModeQuiet, "none":
display.Mode = display.ModeQuiet
ep = display.Quiet()
case display.ModeJSON:
display.Mode = display.ModeJSON
logrus.SetFormatter(&logrus.JSONFormatter{})
ep = display.JSON(dockerCli.Err())
default:
return fmt.Errorf("unsupported --progress value %q", opts.Progress)
ep, err := selectEventProcessor(dockerCli, opts.Progress, ansi, detached)
if err != nil {
return err
}
backendOptions.Add(compose.WithEventProcessor(ep))
@ -670,6 +639,47 @@ func stdinfo(dockerCli command.Cli) io.Writer {
return dockerCli.Err()
}
// selectEventProcessor picks the EventProcessor for Compose progress rendering.
//
// In auto mode we probe Err() (not Out()) because the renderer writes to stderr;
// probing stdout would force plain mode whenever stdout is redirected (e.g.
// `docker compose up | tee log`) while stderr is still a terminal.
func selectEventProcessor(dockerCli command.Cli, progress, ansi string, detached bool) (api.EventProcessor, error) {
switch progress {
case "", display.ModeAuto:
switch {
case ansi == "never":
display.Mode = display.ModePlain
return display.Plain(dockerCli.Err()), nil
case dockerCli.Err().IsTerminal():
return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil
default:
return display.Plain(dockerCli.Err()), nil
}
case display.ModeTTY:
if ansi == "never" {
return nil, fmt.Errorf("can't use --progress tty while ANSI support is disabled")
}
display.Mode = display.ModeTTY
return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil
case display.ModePlain:
if ansi == "always" {
return nil, fmt.Errorf("can't use --progress plain while ANSI support is forced")
}
display.Mode = display.ModePlain
return display.Plain(dockerCli.Err()), nil
case display.ModeQuiet, "none":
display.Mode = display.ModeQuiet
return display.Quiet(), nil
case display.ModeJSON:
display.Mode = display.ModeJSON
logrus.SetFormatter(&logrus.JSONFormatter{})
return display.JSON(dockerCli.Err()), nil
default:
return nil, fmt.Errorf("unsupported --progress value %q", progress)
}
}
func setEnvWithDotEnv(opts ProjectOptions, dockerCli command.Cli) error {
// Check if we're using a remote config (OCI or Git)
// If so, skip env loading as remote loaders haven't been initialized yet

View file

@ -0,0 +1,209 @@
//go:build !windows
/*
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 compose
import (
"fmt"
"os"
"testing"
"github.com/creack/pty"
"github.com/docker/cli/cli/streams"
"github.com/sirupsen/logrus"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"
"github.com/docker/compose/v5/cmd/display"
"github.com/docker/compose/v5/pkg/mocks"
)
// saveGlobalState snapshots package-level state that selectEventProcessor
// mutates (display.Mode and, in JSON mode, the logrus standard formatter)
// and restores it on test cleanup.
func saveGlobalState(t *testing.T) {
t.Helper()
originalMode := display.Mode
originalFormatter := logrus.StandardLogger().Formatter
t.Cleanup(func() {
display.Mode = originalMode
logrus.SetFormatter(originalFormatter)
})
}
// newStream returns a *streams.Out whose IsTerminal() matches tty. When tty is
// true it is backed by a pseudo-terminal slave; otherwise by an os.Pipe writer.
func newStream(t *testing.T, tty bool) *streams.Out {
t.Helper()
if tty {
ptmx, slave, err := pty.Open()
assert.NilError(t, err)
t.Cleanup(func() {
_ = ptmx.Close()
_ = slave.Close()
})
return streams.NewOut(slave)
}
r, w, err := os.Pipe()
assert.NilError(t, err)
t.Cleanup(func() {
_ = r.Close()
_ = w.Close()
})
return streams.NewOut(w)
}
func newMockCli(t *testing.T, out, errStream *streams.Out) *mocks.MockCli {
t.Helper()
cli := mocks.NewMockCli(gomock.NewController(t))
cli.EXPECT().Out().Return(out).AnyTimes()
cli.EXPECT().Err().Return(errStream).AnyTimes()
return cli
}
// TestSelectEventProcessor_AutoMode covers the regression from docker/compose#13570:
// auto mode must probe Err() (not Out()) so `docker compose up | tee log` still
// renders the colorized UI on stderr.
func TestSelectEventProcessor_AutoMode(t *testing.T) {
tests := []struct {
name string
outIsTTY bool
errIsTTY bool
ansi string
wantType string
}{
{
name: "stderr TTY, stdout piped -> Full",
errIsTTY: true,
ansi: "auto",
wantType: "*display.ttyWriter",
},
{
name: "stderr piped, stdout TTY -> Plain (do not fall back to stdout)",
outIsTTY: true,
ansi: "auto",
wantType: "*display.plainWriter",
},
{
name: "both TTY -> Full",
outIsTTY: true,
errIsTTY: true,
ansi: "auto",
wantType: "*display.ttyWriter",
},
{
name: "both piped -> Plain",
ansi: "auto",
wantType: "*display.plainWriter",
},
{
name: "ansi never forces Plain even when stderr is TTY",
outIsTTY: true,
errIsTTY: true,
ansi: "never",
wantType: "*display.plainWriter",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
saveGlobalState(t)
cli := newMockCli(t, newStream(t, tc.outIsTTY), newStream(t, tc.errIsTTY))
ep, err := selectEventProcessor(cli, "", tc.ansi, false)
assert.NilError(t, err)
assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType)
})
}
}
func TestSelectEventProcessor_ExplicitMode(t *testing.T) {
tests := []struct {
name string
progress string
ansi string
wantType string
wantErrText string
}{
{
name: "progress=tty forces Full regardless of streams",
progress: display.ModeTTY,
ansi: "auto",
wantType: "*display.ttyWriter",
},
{
name: "progress=tty with ansi=never is rejected",
progress: display.ModeTTY,
ansi: "never",
wantErrText: "can't use --progress tty while ANSI support is disabled",
},
{
name: "progress=plain forces Plain",
progress: display.ModePlain,
ansi: "auto",
wantType: "*display.plainWriter",
},
{
name: "progress=plain with ansi=always is rejected",
progress: display.ModePlain,
ansi: "always",
wantErrText: "can't use --progress plain while ANSI support is forced",
},
{
name: "progress=quiet returns Quiet",
progress: display.ModeQuiet,
ansi: "auto",
wantType: "*display.quiet",
},
{
name: `progress="none" aliases to Quiet`,
progress: "none",
ansi: "auto",
wantType: "*display.quiet",
},
{
name: "progress=json returns JSON",
progress: display.ModeJSON,
ansi: "auto",
wantType: "*display.jsonWriter",
},
{
name: "unknown progress value is rejected",
progress: "bogus",
ansi: "auto",
wantErrText: `unsupported --progress value "bogus"`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
saveGlobalState(t)
// Explicit modes don't probe IsTerminal; pipes are fine for both.
cli := newMockCli(t, newStream(t, false), newStream(t, false))
ep, err := selectEventProcessor(cli, tc.progress, tc.ansi, false)
if tc.wantErrText != "" {
assert.ErrorContains(t, err, tc.wantErrText)
assert.Assert(t, ep == nil)
return
}
assert.NilError(t, err)
assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType)
})
}
}