internal/terminal: Interrogate and initialize the terminal, if any

This is a helper package that creates a very thin abstraction over
terminal setup, with the main goal being to deal with all of the extra
setup we need to do in order to get a UTF-8-supporting virtual terminal
on a Windows system.
This commit is contained in:
Martin Atkins 2021-01-11 18:13:21 -08:00
parent 0a086030b1
commit 17728c8fe8
7 changed files with 452 additions and 1 deletions

3
go.mod
View File

@ -123,7 +123,8 @@ require (
golang.org/x/mod v0.3.0
golang.org/x/net v0.0.0-20201021035429-f5854403a974
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf
golang.org/x/text v0.3.3
golang.org/x/tools v0.0.0-20201028111035-eafbe7b904eb
google.golang.org/api v0.34.0

4
go.sum
View File

@ -746,6 +746,10 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -0,0 +1,53 @@
// +build !windows
package terminal
import (
"os"
"golang.org/x/term"
)
// This is the implementation for all operating systems except Windows, where
// we don't expect to need to do any special initialization to get a working
// Virtual Terminal.
//
// For this implementation we just delegate everything upstream to
// golang.org/x/term, since it already has a variety of different
// implementations for quirks of more esoteric operating systems like plan9,
// and will hopefully grow to include others as Go is ported to other platforms
// in future.
//
// For operating systems that golang.org/x/term doesn't support either, it
// defaults to indicating that nothing is a terminal and returns an error when
// asked for a size, which we'll handle below.
func configureOutputHandle(f *os.File) (*OutputStream, error) {
return &OutputStream{
File: f,
isTerminal: isTerminalGolangXTerm,
getColumns: getColumnsGolangXTerm,
}, nil
}
func configureInputHandle(f *os.File) (*InputStream, error) {
return &InputStream{
File: f,
isTerminal: isTerminalGolangXTerm,
}, nil
}
func isTerminalGolangXTerm(f *os.File) bool {
return term.IsTerminal(int(f.Fd()))
}
func getColumnsGolangXTerm(f *os.File) int {
width, _, err := term.GetSize(int(f.Fd()))
if err != nil {
// Suggests that it's either not a terminal at all or that we're on
// a platform that golang.org/x/term doesn't support. In both cases
// we'll just return the placeholder default value.
return defaultColumns
}
return width
}

View File

@ -0,0 +1,161 @@
// +build windows
package terminal
import (
"fmt"
"os"
"syscall"
"golang.org/x/sys/windows"
// We're continuing to use this third-party library on Windows because it
// has the additional IsCygwinTerminal function, which includes some useful
// heuristics for recognizing when a pipe seems to be connected to a
// legacy terminal emulator on Windows versions that lack true pty support.
// We now use golang.org/x/term's functionality on other platforms.
isatty "github.com/mattn/go-isatty"
)
func configureOutputHandle(f *os.File) (*OutputStream, error) {
ret := &OutputStream{
File: f,
}
if fd := f.Fd(); isatty.IsTerminal(fd) {
// We have a few things to deal with here:
// - Activating UTF-8 output support (mandatory)
// - Activating virtual terminal support (optional)
// These will not succeed on Windows 8 or early versions of Windows 10.
// UTF-8 support means switching the console "code page" to CP_UTF8.
// Notice that this doesn't take the specific file descriptor, because
// the console is just ambiently associated with our process.
err := SetConsoleOutputCP(CP_UTF8)
if err != nil {
return nil, fmt.Errorf("failed to set the console to UTF-8 mode; you may need to use a newer version of Windows: %s", err)
}
// If the console also allows us to turn on
// ENABLE_VIRTUAL_TERMINAL_PROCESSING then we can potentially use VT
// output, although the methods of Settings will make the final
// determination on that because we might have some handles pointing at
// terminals and other handles pointing at files/pipes.
ret.getColumns = getColumnsWindowsConsole
var mode uint32
err = windows.GetConsoleMode(windows.Handle(fd), &mode)
if err != nil {
return ret, nil // We'll treat this as success but without VT support
}
mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
err = windows.SetConsoleMode(windows.Handle(fd), mode)
if err != nil {
return ret, nil // We'll treat this as success but without VT support
}
// If we get here then we've successfully turned on VT processing, so
// we can return an OutputStream that answers true when asked if it
// is a Terminal.
ret.isTerminal = staticTrue
return ret, nil
} else if isatty.IsCygwinTerminal(fd) {
// Cygwin terminals -- and other VT100 "fakers" for older versions of
// Windows -- are not really terminals in the usual sense, but rather
// are pipes between the child process (Terraform) and the terminal
// emulator. isatty.IsCygwinTerminal uses some heuristics to
// distinguish those pipes from other pipes we might see if the user
// were, for example, using the | operator on the command line.
// If we get in here then we'll assume that we can send VT100 sequences
// to this stream, even though it isn't a terminal in the usual sense.
ret.isTerminal = staticTrue
// TODO: Is it possible to detect the width of these fake terminals?
return ret, nil
}
// If we fall out here then we have a non-terminal filehandle, so we'll
// just accept all of the default OutputStream behaviors
return ret, nil
}
func configureInputHandle(f *os.File) (*InputStream, error) {
ret := &InputStream{
File: f,
}
if fd := f.Fd(); isatty.IsTerminal(fd) {
// We have to activate UTF-8 input, or else we fail. This will not
// succeed on Windows 8 or early versions of Windows 10.
// Notice that this doesn't take the specific file descriptor, because
// the console is just ambiently associated with our process.
err := SetConsoleCP(CP_UTF8)
if err != nil {
return nil, fmt.Errorf("failed to set the console to UTF-8 mode; you may need to use a newer version of Windows: %s", err)
}
ret.isTerminal = staticTrue
return ret, nil
} else if isatty.IsCygwinTerminal(fd) {
// As with the output handles above, we'll use isatty's heuristic to
// pretend that a pipe from mintty or a similar userspace terminal
// emulator is actually a terminal.
ret.isTerminal = staticTrue
return ret, nil
}
// If we fall out here then we have a non-terminal filehandle, so we'll
// just accept all of the default InputStream behaviors
return ret, nil
}
func getColumnsWindowsConsole(f *os.File) int {
// We'll just unconditionally ask the given file for its console buffer
// info here, and let it fail if the file isn't actually a console.
// (In practice, the init functions above only hook up this function
// if the handle looks like a console, so this should succeed.)
var info windows.ConsoleScreenBufferInfo
err := windows.GetConsoleScreenBufferInfo(windows.Handle(f.Fd()), &info)
if err != nil {
return defaultColumns
}
return int(info.Size.X)
}
// Unfortunately not all of the Windows kernel functions we need are in
// x/sys/windows at the time of writing, so we need to call some of them
// directly. (If you're maintaining this in future and have the capacity to
// test it well, consider checking if these functions have been added upstream
// yet and switch to their wrapper stubs if so.
var modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
var procSetConsoleCP = modkernel32.NewProc("SetConsoleCP")
var procSetConsoleOutputCP = modkernel32.NewProc("SetConsoleOutputCP")
const CP_UTF8 = 65001
// (These are written in the style of the stubs in x/sys/windows, which is
// a little non-idiomatic just due to the awkwardness of the low-level syscall
// interface.)
func SetConsoleCP(codepageID uint32) (err error) {
r1, _, e1 := syscall.Syscall(procSetConsoleCP.Addr(), 1, uintptr(codepageID), 0, 0)
if r1 == 0 {
err = e1
}
return
}
func SetConsoleOutputCP(codepageID uint32) (err error) {
r1, _, e1 := syscall.Syscall(procSetConsoleOutputCP.Addr(), 1, uintptr(codepageID), 0, 0)
if r1 == 0 {
err = e1
}
return
}
func staticTrue(f *os.File) bool {
return true
}
func staticFalse(f *os.File) bool {
return false
}

View File

@ -0,0 +1,78 @@
package terminal
import "os"
// This file has some annoying nonsense to, yet again, work around the
// panicwrap hack.
//
// Specifically, typically when we're running Terraform the stderr handle is
// not directly connected to the terminal but is instead a pipe into a parent
// process gathering up the output just in case a panic message appears.
// However, this package needs to know whether the _real_ stderr is connected
// to a terminal and what its width is.
//
// To work around that, we'll first initialize the terminal in the parent
// process, and then capture information about stderr into an environment
// variable so we can pass it down to the child process. The child process
// will then use the environment variable to pretend that the panicwrap pipe
// has the same characteristics as the terminal that it's indirectly writing
// to.
//
// This file has some helpers for implementing that awkward handshake, but the
// handshake itself is in package main, interspersed with all of the other
// panicwrap machinery.
//
// You might think that the code in helper/wrappedstreams could avoid this
// problem, but that package is broken on Windows: it always fails to recover
// the real stderr, and it also gets an incorrect result if the user was
// redirecting or piping stdout/stdin. So... we have this hack instead, which
// gets a correct result even on Windows and even with I/O redirection.
// StateForAfterPanicWrap is part of the workaround for panicwrap that
// captures some characteristics of stderr that the caller can pass to the
// panicwrap child process somehow and then use ReinitInsidePanicWrap.
func (s *Streams) StateForAfterPanicWrap() *PrePanicwrapState {
return &PrePanicwrapState{
StderrIsTerminal: s.Stderr.IsTerminal(),
StderrWidth: s.Stderr.Columns(),
}
}
// ReinitInsidePanicwrap is part of the workaround for panicwrap that
// produces a Streams containing a potentially-lying Stderr that might
// claim to be a terminal even if it's actually a pipe connected to the
// parent process.
//
// That's an okay lie in practice because the parent process will copy any
// data it recieves via that pipe verbatim to the real stderr anyway. (The
// original call to Init in the parent process should've already done any
// necessary modesetting on the Stderr terminal, if any.)
//
// The state argument can be nil if we're not running in panicwrap mode,
// in which case this function behaves exactly the same as Init.
func ReinitInsidePanicwrap(state *PrePanicwrapState) (*Streams, error) {
ret, err := Init()
if err != nil {
return ret, err
}
if state != nil {
// A lying stderr, then.
ret.Stderr = &OutputStream{
File: ret.Stderr.File,
isTerminal: func(f *os.File) bool {
return state.StderrIsTerminal
},
getColumns: func(f *os.File) int {
return state.StderrWidth
},
}
}
return ret, nil
}
// PrePanicwrapState is a horrible thing we use to work around panicwrap,
// related to both Streams.StateForAfterPanicWrap and ReinitInsidePanicwrap.
type PrePanicwrapState struct {
StderrIsTerminal bool
StderrWidth int
}

View File

@ -0,0 +1,80 @@
package terminal
import (
"os"
)
const defaultColumns int = 78
const defaultIsTerminal bool = false
// OutputStream represents an output stream that might or might not be connected
// to a terminal.
//
// There are typically two instances of this: one representing stdout and one
// representing stderr.
type OutputStream struct {
File *os.File
// Interacting with a terminal is typically platform-specific, so we
// factor out these into virtual functions, although we have default
// behaviors suitable for non-Terminal output if any of these isn't
// set. (We're using function pointers rather than interfaces for this
// because it allows us to mix both normal methods and virtual methods
// on the same type, without a bunch of extra complexity.)
isTerminal func(*os.File) bool
getColumns func(*os.File) int
}
// Columns returns a number of character cell columns that we expect will
// fill the width of the terminal that stdout is connected to, or a reasonable
// placeholder value of 78 if the output doesn't seem to be a terminal.
//
// This is a best-effort sort of function which may give an inaccurate result
// in various cases. For example, callers storing the result will not react
// to subsequent changes in the terminal width, and indeed this function itself
// may not be able to either, depending on the constraints of the current
// execution context.
func (s *OutputStream) Columns() int {
if s.getColumns == nil {
return defaultColumns
}
return s.getColumns(s.File)
}
// IsTerminal returns true if we expect that the stream is connected to a
// terminal which supports VT100-style formatting and cursor control sequences.
func (s *OutputStream) IsTerminal() bool {
if s.isTerminal == nil {
return defaultIsTerminal
}
return s.isTerminal(s.File)
}
// InputStream represents an input stream that might or might not be a terminal.
//
// There is typically only one instance of this type, representing stdin.
type InputStream struct {
File *os.File
// Interacting with a terminal is typically platform-specific, so we
// factor out these into virtual functions, although we have default
// behaviors suitable for non-Terminal output if any of these isn't
// set. (We're using function pointers rather than interfaces for this
// because it allows us to mix both normal methods and virtual methods
// on the same type, without a bunch of extra complexity.)
isTerminal func(*os.File) bool
}
// IsTerminal returns true if we expect that the stream is connected to a
// terminal which can support interactive input.
//
// If this returns false, callers might prefer to skip elaborate input prompt
// functionality like tab completion and instead just treat the input as a
// raw byte stream, or perhaps skip prompting for input at all depending on the
// situation.
func (s *InputStream) IsTerminal() bool {
if s.isTerminal == nil {
return defaultIsTerminal
}
return s.isTerminal(s.File)
}

View File

@ -0,0 +1,74 @@
// Package terminal encapsulates some platform-specific logic for detecting
// if we're running in a terminal and, if so, properly configuring that
// terminal to meet the assumptions that the rest of Terraform makes.
//
// Specifically, Terraform requires a Terminal which supports virtual terminal
// sequences and which accepts UTF-8-encoded text.
//
// This is an abstraction only over the platform-specific detection of and
// possibly initialization of terminals. It's not intended to provide
// higher-level abstractions of the sort provided by packages like termcap or
// curses; ultimately we just assume that terminals are "standard" VT100-like
// terminals and use a subset of control codes that works across the various
// platforms we support. Our approximate target is "xterm-compatible"
// virtual terminals.
package terminal
import (
"os"
)
// Streams represents a collection of three streams that each may or may not
// be connected to a terminal.
//
// If a stream is connected to a terminal then there are more possibilities
// available, such as detecting the current terminal width. If we're connected
// to something else, such as a pipe or a file on disk, the stream will
// typically provide placeholder values or do-nothing stubs for
// terminal-requiring operatons.
//
// Note that it's possible for only a subset of the streams to be connected
// to a terminal. For example, this happens if the user runs Terraform with
// I/O redirection where Stdout might refer to a regular disk file while Stderr
// refers to a terminal, or various other similar combinations.
type Streams struct {
Stdout *OutputStream
Stderr *OutputStream
Stdin *InputStream
}
// Init tries to initialize a terminal, if Terraform is running in one, and
// returns an object describing what it was able to set up.
//
// An error for this function indicates that the current execution context
// can't meet Terraform's assumptions. For example, on Windows Init will return
// an error if Terraform is running in a Windows Console that refuses to
// activate UTF-8 mode, which can happen if we're running on an unsupported old
// version of Windows.
//
// Note that the success of this function doesn't mean that we're actually
// running in a terminal. It could also represent successfully detecting that
// one or more of the input/output streams is not a terminal.
func Init() (*Streams, error) {
// These configure* functions are platform-specific functions in other
// files that use //+build constraints to vary based on target OS.
stderr, err := configureOutputHandle(os.Stderr)
if err != nil {
return nil, err
}
stdout, err := configureOutputHandle(os.Stdout)
if err != nil {
return nil, err
}
stdin, err := configureInputHandle(os.Stdin)
if err != nil {
return nil, err
}
return &Streams{
Stdout: stdout,
Stderr: stderr,
Stdin: stdin,
}, nil
}