Skip to content

Commit

Permalink
cli: sequence cli.Ui operations
Browse files Browse the repository at this point in the history
Fixes a bug where if a command flag parsing errors, the resulting error
and help usage messages get interleaved in unexpected and non-user
friendly way.

The reason is that we have flag parsing library effectively writes to
ui.Error in a goroutine.  This is problematic: first, we lose the sequencing between help
usage and error message; second, cli.Ui methods are not concurrent safe.

Here, we introduce a custom error writer that buffers result and calls
ui.Error() in the write method and in the same goroutine.

For context, we need to wrap ui.Error because it's line-oriented, while
flags library expects a io.Writer which is bytes oriented.
  • Loading branch information
Mahmood Ali committed Dec 16, 2019
1 parent 7700d38 commit af2e2bc
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 14 deletions.
43 changes: 43 additions & 0 deletions command/helpers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package command

import (
"bufio"
"bytes"
"fmt"
"io"
Expand All @@ -14,6 +15,7 @@ import (
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/jobspec"
"github.com/kr/text"
"github.com/mitchellh/cli"
"github.com/posener/complete"

"github.com/ryanuber/columnize"
Expand Down Expand Up @@ -464,3 +466,44 @@ func sanitizeUUIDPrefix(prefix string) string {
func commandErrorText(cmd NamedCommand) string {
return fmt.Sprintf("For additional help try 'nomad %s -help'", cmd.Name())
}

// uiErrorWriter is a io.Writer that wraps underlying ui.ErrorWriter().
// ui.ErrorWriter expects full lines as inputs and it emits its own line breaks.
//
// uiErrorWriter scans input for individual lines to pass to ui.ErrorWriter. If data
// doesn't contain a new line, it buffers result until next new line or writer is closed.
type uiErrorWriter struct {
ui cli.Ui
buf bytes.Buffer
}

func (w *uiErrorWriter) Write(data []byte) (int, error) {
read := 0
for len(data) != 0 {
a, token, err := bufio.ScanLines(data, false)
if err != nil {
return read, err
}

if a == 0 {
r, err := w.buf.Write(data)
return read + r, err
}

w.ui.Error(w.buf.String() + string(token))
data = data[a:]
w.buf.Reset()
read += a
}

return read, nil
}

func (w *uiErrorWriter) Close() error {
// emit what's remaining
if w.buf.Len() != 0 {
w.ui.Error(w.buf.String())
w.buf.Reset()
}
return nil
}
50 changes: 50 additions & 0 deletions command/helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package command

import (
"bytes"
"fmt"
"io"
"io/ioutil"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/hashicorp/nomad/helper/flatmap"
"github.com/kr/pretty"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/require"
)

func TestHelpers_FormatKV(t *testing.T) {
Expand Down Expand Up @@ -342,3 +344,51 @@ func TestPrettyTimeDiff(t *testing.T) {
}

}

// TestUiErrorWriter asserts that writer buffers and
func TestUiErrorWriter(t *testing.T) {
t.Parallel()

var outBuf, errBuf bytes.Buffer
ui := &cli.BasicUi{
Writer: &outBuf,
ErrorWriter: &errBuf,
}

w := &uiErrorWriter{ui: ui}

inputs := []string{
"some line\n",
"multiple\nlines\r\nhere",
" with followup\nand",
" more lines ",
" without new line ",
"until here\nand then",
"some more",
}

partialAcc := ""
for _, in := range inputs {
n, err := w.Write([]byte(in))
require.NoError(t, err)
require.Equal(t, len(in), n)

// assert that writer emits partial result until last new line
partialAcc += strings.ReplaceAll(in, "\r\n", "\n")
lastNL := strings.LastIndex(partialAcc, "\n")
require.Equal(t, partialAcc[:lastNL+1], errBuf.String())
}

require.Empty(t, outBuf.String())

// note that the \r\n got replaced by \n
expectedErr := "some line\nmultiple\nlines\nhere with followup\nand more lines without new line until here\n"
require.Equal(t, expectedErr, errBuf.String())

// close emits the final line
err := w.Close()
require.NoError(t, err)

expectedErr += "and thensome more\n"
require.Equal(t, expectedErr, errBuf.String())
}
15 changes: 1 addition & 14 deletions command/meta.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package command

import (
"bufio"
"flag"
"io"
"os"
"strings"

Expand Down Expand Up @@ -83,18 +81,7 @@ func (m *Meta) FlagSet(n string, fs FlagSetFlags) *flag.FlagSet {

}

// Create an io.Writer that writes to our UI properly for errors.
// This is kind of a hack, but it does the job. Basically: create
// a pipe, use a scanner to break it into lines, and output each line
// to the UI. Do this forever.
errR, errW := io.Pipe()
errScanner := bufio.NewScanner(errR)
go func() {
for errScanner.Scan() {
m.Ui.Error(errScanner.Text())
}
}()
f.SetOutput(errW)
f.SetOutput(&uiErrorWriter{ui: m.Ui})

return f
}
Expand Down

0 comments on commit af2e2bc

Please sign in to comment.