From f34365114af2c3fb5eff46365a712375bad1a7e5 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 19 Aug 2026 10:19:42 +0200 Subject: [PATCH] windows: identify standard streams by console handle, not value identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit newMaster rejected any File that was not literally os.Stdin, os.Stdout or os.Stderr — a Go pointer-identity check. Callers commonly hand over a wrapper decorating a standard stream to satisfy the File interface (their CLI stream types don't implement it directly); such a wrapper reports the very same console handle through Fd() and designates the same console object, yet was rejected with "creating a console from a file is not supported on windows". Concrete fallout: docker/compose#14086 — the compose build progress passes its CLI stream wrapped as a File, so the TTY rendering could never engage on Windows (silent fallback to plain in auto mode, hard failure with --progress=tty). Compare the underlying handles instead: the invariant is unchanged — only the process's standard streams are accepted, since the console-mode operations act on those handles — but it is now expressed against the console object rather than the Go value. Signed-off-by: Nicolas De Loof --- console_windows.go | 9 ++++++--- console_windows_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/console_windows.go b/console_windows.go index 39ba505..8380c21 100644 --- a/console_windows.go +++ b/console_windows.go @@ -212,8 +212,11 @@ func checkConsole(f File) error { return nil } -// newMaster creates a Console from one of the process's standard streams -// (os.Stdin, os.Stdout, or os.Stderr); any other file is rejected. +// newMaster creates a Console from one of the process's standard streams, +// identified by its underlying console handle: os.Stdin, os.Stdout, +// os.Stderr, or any File reporting one of their handles through Fd() — a +// wrapper decorating a standard stream is accepted, since the handle is what +// designates the console object. Files with any other handle are rejected. // // Read, Write, Fd, and Name are delegated to f. The console-mode operations // (SetRaw, Reset, Size, and DisableEcho) act on the process's standard @@ -221,7 +224,7 @@ func checkConsole(f File) error { // underlying console object, so mode and size queries apply to that console // as a whole. func newMaster(f File) (Console, error) { - if f != os.Stdin && f != os.Stdout && f != os.Stderr { + if fd := f.Fd(); fd != os.Stdin.Fd() && fd != os.Stdout.Fd() && fd != os.Stderr.Fd() { return nil, errors.New("creating a console from a file is not supported on windows") } m := &master{f: f} diff --git a/console_windows_test.go b/console_windows_test.go index 5b88666..c87957a 100644 --- a/console_windows_test.go +++ b/console_windows_test.go @@ -175,3 +175,40 @@ func TestConsoleFromFile_Delegation(t *testing.T) { t.Fatalf("subprocess failed: %v\n%s", err, stderr.String()) } } + +// fileWrapper decorates an *os.File without being one of the os.Std* +// package variables — the shape callers hand over when their CLI streams +// wrap a standard stream (see docker/compose#14086). +type fileWrapper struct { + *os.File +} + +// TestNewMaster_StandardStreamIdentity verifies that newMaster identifies a +// standard stream by its console handle rather than by Go value identity: a +// File decorating a standard stream reports the same handle through Fd() and +// designates the same console object, so it must be accepted like the os.Std* +// value it wraps. Files carrying any other handle stay rejected. +// +// initStdios tolerates redirected streams, so this runs in headless CI. +func TestNewMaster_StandardStreamIdentity(t *testing.T) { + for _, f := range []*os.File{os.Stdin, os.Stdout, os.Stderr} { + if _, err := newMaster(f); err != nil { + t.Errorf("newMaster(%s): %v", f.Name(), err) + } + if _, err := newMaster(fileWrapper{f}); err != nil { + t.Errorf("newMaster(wrapper{%s}): %v", f.Name(), err) + } + } + + f, err := os.CreateTemp(t.TempDir(), "console") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := newMaster(f); err == nil { + t.Error("newMaster accepted a regular file") + } + if _, err := newMaster(fileWrapper{f}); err == nil { + t.Error("newMaster accepted a wrapped regular file") + } +}