diff --git a/cmd/buildctl/build.go b/cmd/buildctl/build.go index e776a71e4ae8e..96f884eaf279d 100644 --- a/cmd/buildctl/build.go +++ b/cmd/buildctl/build.go @@ -46,7 +46,7 @@ var buildCommand = cli.Command{ }, cli.StringFlag{ Name: "progress", - Usage: "Set type of progress (auto, plain, tty). Use plain to show container output", + Usage: "Set type of progress (auto, plain, tty, json). Use plain to show container output", Value: "auto", }, cli.StringFlag{ diff --git a/docs/reference/buildctl.md b/docs/reference/buildctl.md index 61e8b9b5b504f..a60f3771cf13a 100644 --- a/docs/reference/buildctl.md +++ b/docs/reference/buildctl.md @@ -63,7 +63,7 @@ USAGE: OPTIONS: --output value, -o value Define exports for build result, e.g. --output type=image,name=docker.io/username/image,push=true - --progress value Set type of progress (auto, plain, tty). Use plain to show container output (default: "auto") + --progress value Set type of progress (auto, plain, tty, json). Use plain to show container output (default: "auto") --trace value Path to trace file. Defaults to no tracing. --local value Allow build access to the local directory --oci-layout value Allow build access to the local OCI layout diff --git a/util/progress/progressui/display.go b/util/progress/progressui/display.go index 4ceb4f5264e66..0ecb60b3a25d5 100644 --- a/util/progress/progressui/display.go +++ b/util/progress/progressui/display.go @@ -16,6 +16,7 @@ import ( "github.com/moby/buildkit/client" "github.com/morikuni/aec" digest "github.com/opencontainers/go-digest" + "github.com/pkg/errors" "github.com/tonistiigi/units" "github.com/tonistiigi/vt100" "golang.org/x/time/rate" @@ -42,22 +43,33 @@ func WithDesc(text string, console string) DisplaySolveStatusOpt { } } -func DisplaySolveStatus(ctx context.Context, c console.Console, w io.Writer, ch chan *client.SolveStatus, opts ...DisplaySolveStatusOpt) ([]client.VertexWarning, error) { - modeConsole := c != nil +// SolveStatusDisplay is a display that can show the updates to the solver status. +type SolveStatusDisplay interface { + // init initializes the display with options from Update. + init(dsso *displaySolveStatusOpts) + + // update updates the internal trace with the latest solve status message. + // It does not update the display. + update(ss *client.SolveStatus) + // display will update the visible display with the latest updates. + // If done is true, this will notify the display that this is the final + // time it will be updated so it can flush any final messages. + display(done bool) + + // warnings returns any warnings with the associated trace. + warnings() []client.VertexWarning +} + +// UpdateDisplay will stream updates from the channel and update the display from those updates. +// This function only returns when the update channel is closed. +func UpdateDisplay(ctx context.Context, d SolveStatusDisplay, ch chan *client.SolveStatus, opts ...DisplaySolveStatusOpt) ([]client.VertexWarning, error) { dsso := &displaySolveStatusOpts{} for _, opt := range opts { opt(dsso) } - disp := &display{c: c, phase: dsso.phase, desc: dsso.consoleDesc} - printer := &textMux{w: w, desc: dsso.textDesc} - - if disp.phase == "" { - disp.phase = "Building" - } - - t := newTrace(w, modeConsole) + d.init(dsso) tickerTimeout := 150 * time.Millisecond displayTimeout := 100 * time.Millisecond @@ -71,15 +83,10 @@ func DisplaySolveStatus(ctx context.Context, c console.Console, w io.Writer, ch var done bool ticker := time.NewTicker(tickerTimeout) - // implemented as closure because "ticker" can change - defer func() { - ticker.Stop() - }() + defer ticker.Stop() displayLimiter := rate.NewLimiter(rate.Every(displayTimeout), 1) - var height int - width, _ := disp.getSize() for { select { case <-ctx.Done(): @@ -87,37 +94,141 @@ func DisplaySolveStatus(ctx context.Context, c console.Console, w io.Writer, ch case <-ticker.C: case ss, ok := <-ch: if ok { - t.update(ss, width) + d.update(ss) } else { done = true } } - if modeConsole { - width, height = disp.getSize() - if done { - disp.print(t.displayInfo(), width, height, true) - t.printErrorLogs(c) - return t.warnings(), nil - } else if displayLimiter.Allow() { - ticker.Stop() - ticker = time.NewTicker(tickerTimeout) - disp.print(t.displayInfo(), width, height, false) - } - } else { - if done || displayLimiter.Allow() { - printer.print(t) - if done { - t.printErrorLogs(w) - return t.warnings(), nil - } - ticker.Stop() - ticker = time.NewTicker(tickerTimeout) - } + if done { + d.display(true) + return d.warnings(), nil + } else if displayLimiter.Allow() { + d.display(false) + ticker.Reset(tickerTimeout) } } } +type consoleDisplay struct { + t *trace + disp *display + width, height int +} + +func NewConsoleDisplay(c console.Console) SolveStatusDisplay { + return &consoleDisplay{ + t: newTrace(c, true), + disp: &display{c: c}, + } +} + +func (d *consoleDisplay) init(dsso *displaySolveStatusOpts) { + d.disp.phase = dsso.phase + d.disp.desc = dsso.consoleDesc + if d.disp.phase == "" { + d.disp.phase = "Building" + } + d.width, d.height = d.disp.getSize() +} + +func (d *consoleDisplay) update(ss *client.SolveStatus) { + d.t.update(ss, d.width) +} + +func (d *consoleDisplay) display(done bool) { + d.width, d.height = d.disp.getSize() + d.disp.print(d.t.displayInfo(), d.width, d.height, done) + if done { + d.t.printErrorLogs(d.t.w) + } +} + +func (d *consoleDisplay) warnings() []client.VertexWarning { + return d.t.warnings() +} + +type textDisplay struct { + t *trace + printer *textMux +} + +func newTextDisplay(w io.Writer, printer vertexPrinter) SolveStatusDisplay { + return &textDisplay{ + t: newTrace(w, false), + printer: newTextMux(w, printer), + } +} + +func newJSONDisplay(w io.Writer) SolveStatusDisplay { + return newTextDisplay(w, &jsonPrinter{}) +} + +func (d *textDisplay) init(dsso *displaySolveStatusOpts) { + d.printer.desc = dsso.textDesc +} + +func (d *textDisplay) update(ss *client.SolveStatus) { + d.t.update(ss, 80) +} + +func (d *textDisplay) display(done bool) { + d.printer.print(d.t) + if done { + d.t.printErrorLogs(d.t.w) + } +} + +func (d *textDisplay) warnings() []client.VertexWarning { + return d.t.warnings() +} + +type DisplayMode string + +const ( + AutoMode DisplayMode = "auto" + TtyMode DisplayMode = "tty" + PlainMode DisplayMode = "plain" + JSONMode DisplayMode = "json" +) + +// NewDisplay constructs a SolveStatusDisplay that outputs to the given console.File with the given DisplayMode. +// +// This method will return an error when the DisplayMode is invalid or if TtyMode is used but the console.File +// does not refer to a tty. AutoMode will choose TtyMode or PlainMode depending on if the output is a tty or not. +func NewDisplay(out console.File, mode DisplayMode) (SolveStatusDisplay, error) { + switch mode { + case AutoMode, TtyMode, "": + if c, err := console.ConsoleFromFile(out); err == nil { + return NewConsoleDisplay(c), nil + } else if mode == "tty" { + return nil, errors.Wrap(err, "failed to get console") + } + fallthrough + case PlainMode: + return newTextDisplay(out, nil), nil + case JSONMode: + return newJSONDisplay(out), nil + default: + return nil, errors.Errorf("invalid progress mode %s", mode) + } +} + +// DisplaySolveStatus will display solve status updates to the given console or writer. +// If console.Console is nil, this outputs status updates in the plain text format to the io.Writer. +// +// For more control over the display method, it is suggested to use NewDisplay and UpdateDisplay instead +// of this function. +func DisplaySolveStatus(ctx context.Context, c console.Console, w io.Writer, ch chan *client.SolveStatus, opts ...DisplaySolveStatusOpt) ([]client.VertexWarning, error) { + var d SolveStatusDisplay + if c != nil { + d = NewConsoleDisplay(c) + } else { + d = newTextDisplay(w, nil) + } + return UpdateDisplay(ctx, d, ch, opts...) +} + const termHeight = 6 const termPad = 10 @@ -164,7 +275,6 @@ type vertex struct { logsOffset int logsBuffer *ring.Ring // stores last logs to print them on error prev *client.Vertex - events []string lastBlockTime *time.Time count int statusUpdates map[string]struct{} diff --git a/util/progress/progressui/json.go b/util/progress/progressui/json.go new file mode 100644 index 0000000000000..8bbbb11887d55 --- /dev/null +++ b/util/progress/progressui/json.go @@ -0,0 +1,61 @@ +package progressui + +import ( + "time" + + "github.com/moby/buildkit/solver/pb" + digest "github.com/opencontainers/go-digest" +) + +// SolveStatus holds the latest status update for a vertex by its digest. +type SolveStatus struct { + Digest digest.Digest `json:"digest"` + Vertex Vertex `json:"vertex,omitempty"` + Statuses []VertexStatus `json:"statuses,omitempty"` + Warnings []VertexWarning `json:"warnings,omitempty"` + Logs []VertexLog `json:"logs,omitempty"` +} + +// Vertex contains information about the vertex itself. +// Information will only be included if it has changed since the last time +// that the information was sent or if it is the first time this vertex has +// been output. +type Vertex struct { + Name string `json:"name,omitempty"` + Started *time.Time `json:"started,omitempty"` + Completed *time.Time `json:"completed,omitempty"` + Cached bool `json:"cached,omitempty"` + Canceled bool `json:"canceled,omitempty"` + Error string `json:"error,omitempty"` +} + +// VertexStatus holds the status information for a continuous action performed by this vertex. +// As an example, downloading a file would result in a VertexStatus being created and updated. +// +// For non-progress messages, VertexLog is used instead. +type VertexStatus struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Total int64 `json:"total,omitempty"` + Current int64 `json:"current"` + Timestamp time.Time `json:"ts"` + Started *time.Time `json:"start,omitempty"` + Completed *time.Time `json:"completed,omitempty"` +} + +// VertexWarning holds a warning issued for a particular vertex in the solve graph. +type VertexWarning struct { + Level int `json:"level"` + Short []byte `json:"short"` + Detail [][]byte `json:"detail"` + URL string `json:"url,omitempty"` + SourceInfo *pb.SourceInfo `json:"sourceInfo,omitempty"` + Range []*pb.Range `json:"range,omitempty"` +} + +// VertexLog holds log messages produced by the build. For user output that gets updated +// over time, VertexStatus is used instead. +type VertexLog struct { + Message []byte `json:"msg"` + Partial bool `json:"partial,omitempty"` +} diff --git a/util/progress/progressui/printer.go b/util/progress/progressui/printer.go index 338079d4743bb..fab54380c5bee 100644 --- a/util/progress/progressui/printer.go +++ b/util/progress/progressui/printer.go @@ -3,6 +3,7 @@ package progressui import ( "container/ring" "context" + "encoding/json" "fmt" "io" "os" @@ -28,6 +29,7 @@ type lastStatus struct { type textMux struct { w io.Writer + printer vertexPrinter current digest.Digest last map[string]lastStatus notFirst bool @@ -35,6 +37,13 @@ type textMux struct { desc string } +func newTextMux(w io.Writer, printer vertexPrinter) *textMux { + if printer == nil { + printer = (*textPrinter)(nil) + } + return &textMux{w: w, printer: printer} +} + func (p *textMux) printVtx(t *trace, dgst digest.Digest) { if p.last == nil { p.last = make(map[string]lastStatus) @@ -49,43 +58,20 @@ func (p *textMux) printVtx(t *trace, dgst digest.Digest) { p.nextIndex++ v.index = p.nextIndex } + p.printer.printVtx(p, t, v) - if dgst != p.current { - if p.current != "" { - old := t.byDigest[p.current] - if old.logsPartial { - fmt.Fprintln(p.w, "") - } - old.logsOffset = 0 - old.count = 0 - fmt.Fprintf(p.w, "#%d ...\n", old.index) - } - - if p.notFirst { - fmt.Fprintln(p.w, "") - } else { - if p.desc != "" { - fmt.Fprintf(p.w, "#0 %s\n\n", p.desc) - } - p.notFirst = true - } - - if os.Getenv("PROGRESS_NO_TRUNC") == "0" { - fmt.Fprintf(p.w, "#%d %s\n", v.index, limitString(v.Name, 72)) - } else { - fmt.Fprintf(p.w, "#%d %s\n", v.index, v.Name) - } - } + delete(t.updates, dgst) +} - if len(v.events) != 0 { - v.logsOffset = 0 +func (p *textMux) getVtxName(v *vertex) string { + if os.Getenv("PROGRESS_NO_TRUNC") == "0" { + return limitString(v.Name, 72) } - for _, ev := range v.events { - fmt.Fprintf(p.w, "#%d %s\n", v.index, ev) - } - v.events = v.events[:0] + return v.Name +} - isOpenStatus := false // remote cache loading can currently produce status updates without active vertex +func (p *textMux) processStatusUpdates(v *vertex, fn func(s *status)) (isOpenStatus bool) { + isOpenStatus = false // remote cache loading can currently produce status updates without active vertex for _, s := range v.statuses { if _, ok := v.statusUpdates[s.ID]; ok { doPrint := true @@ -109,104 +95,15 @@ func (p *textMux) printVtx(t *trace, dgst digest.Digest) { Timestamp: s.Timestamp, Current: s.Current, } + fn(s) - var bytes string - if s.Total != 0 { - bytes = fmt.Sprintf(" %.2f / %.2f", units.Bytes(s.Current), units.Bytes(s.Total)) - } else if s.Current != 0 { - bytes = fmt.Sprintf(" %.2f", units.Bytes(s.Current)) - } - var tm string - endTime := s.Timestamp - if s.Completed != nil { - endTime = *s.Completed - } - if s.Started != nil { - diff := endTime.Sub(*s.Started).Seconds() - if diff > 0.01 { - tm = fmt.Sprintf(" %.1fs", diff) - } - } - if s.Completed != nil { - tm += " done" - } else { + if s.Completed == nil { isOpenStatus = true } - fmt.Fprintf(p.w, "#%d %s%s%s\n", v.index, s.ID, bytes, tm) } } v.statusUpdates = map[string]struct{}{} - - for _, w := range v.warnings[v.warningIdx:] { - fmt.Fprintf(p.w, "#%d WARN: %s\n", v.index, w.Short) - v.warningIdx++ - } - - for i, l := range v.logs { - if i == 0 && v.logsOffset != 0 { // index has already been printed - l = l[v.logsOffset:] - fmt.Fprintf(p.w, "%s", l) - } else { - fmt.Fprintf(p.w, "#%d %s", v.index, []byte(l)) - } - - if i != len(v.logs)-1 || !v.logsPartial { - fmt.Fprintln(p.w, "") - } - if v.logsBuffer == nil { - v.logsBuffer = ring.New(logsBufferSize) - } - v.logsBuffer.Value = l - if !v.logsPartial { - v.logsBuffer = v.logsBuffer.Next() - } - } - - if len(v.logs) > 0 { - if v.logsPartial { - v.logs = v.logs[len(v.logs)-1:] - v.logsOffset = len(v.logs[0]) - } else { - v.logs = nil - v.logsOffset = 0 - } - } - - p.current = dgst - if v.isCompleted() && !isOpenStatus { - p.current = "" - v.count = 0 - - if v.logsPartial { - fmt.Fprintln(p.w, "") - } - if v.Error != "" { - if strings.HasSuffix(v.Error, context.Canceled.Error()) { - fmt.Fprintf(p.w, "#%d CANCELED\n", v.index) - } else { - fmt.Fprintf(p.w, "#%d ERROR: %s\n", v.index, v.Error) - } - } else if v.Cached { - fmt.Fprintf(p.w, "#%d CACHED\n", v.index) - } else { - tm := "" - var ivals []interval - for _, ival := range v.intervals { - ivals = append(ivals, ival) - } - ivals = mergeIntervals(ivals) - if len(ivals) > 0 { - var dt float64 - for _, ival := range ivals { - dt += ival.duration().Seconds() - } - tm = fmt.Sprintf(" %.1fs", dt) - } - fmt.Fprintf(p.w, "#%d DONE%s\n", v.index, tm) - } - } - - delete(t.updates, dgst) + return isOpenStatus } func sortCompleted(t *trace, m map[digest.Digest]struct{}) []digest.Digest { @@ -325,6 +222,252 @@ func (p *textMux) print(t *trace) { } } +type vertexPrinter interface { + printVtx(p *textMux, t *trace, v *vertex) +} + +type textPrinter struct{} + +func (text *textPrinter) printVtx(p *textMux, t *trace, v *vertex) { + if v.Digest != p.current { + if p.current != "" { + old := t.byDigest[p.current] + if old.logsPartial { + fmt.Fprintln(p.w, "") + } + old.logsOffset = 0 + old.count = 0 + fmt.Fprintf(p.w, "#%d ...\n", old.index) + } + + if p.notFirst { + fmt.Fprintln(p.w, "") + } else { + if p.desc != "" { + fmt.Fprintf(p.w, "#0 %s\n\n", p.desc) + } + p.notFirst = true + } + + fmt.Fprintf(p.w, "#%d %s\n", v.index, p.getVtxName(v)) + } + + isOpenStatus := p.processStatusUpdates(v, func(s *status) { + text.printStatusUpdate(p, v, s) + }) + + for _, w := range v.warnings[v.warningIdx:] { + fmt.Fprintf(p.w, "#%d WARN: %s\n", v.index, w.Short) + v.warningIdx++ + } + + for i, l := range v.logs { + if i == 0 && v.logsOffset != 0 { // index has already been printed + l = l[v.logsOffset:] + fmt.Fprintf(p.w, "%s", l) + } else { + fmt.Fprintf(p.w, "#%d %s", v.index, []byte(l)) + } + + if i != len(v.logs)-1 || !v.logsPartial { + fmt.Fprintln(p.w, "") + } + if v.logsBuffer == nil { + v.logsBuffer = ring.New(logsBufferSize) + } + v.logsBuffer.Value = l + if !v.logsPartial { + v.logsBuffer = v.logsBuffer.Next() + } + } + + if len(v.logs) > 0 { + if v.logsPartial { + v.logs = v.logs[len(v.logs)-1:] + v.logsOffset = len(v.logs[0]) + } else { + v.logs = nil + v.logsOffset = 0 + } + } + + p.current = v.Digest + if v.isCompleted() && !isOpenStatus { + p.current = "" + v.count = 0 + + if v.logsPartial { + fmt.Fprintln(p.w, "") + } + if v.Error != "" { + if strings.HasSuffix(v.Error, context.Canceled.Error()) { + fmt.Fprintf(p.w, "#%d CANCELED\n", v.index) + } else { + fmt.Fprintf(p.w, "#%d ERROR: %s\n", v.index, v.Error) + } + } else if v.Cached { + fmt.Fprintf(p.w, "#%d CACHED\n", v.index) + } else { + tm := "" + var ivals []interval + for _, ival := range v.intervals { + ivals = append(ivals, ival) + } + ivals = mergeIntervals(ivals) + if len(ivals) > 0 { + var dt float64 + for _, ival := range ivals { + dt += ival.duration().Seconds() + } + tm = fmt.Sprintf(" %.1fs", dt) + } + fmt.Fprintf(p.w, "#%d DONE%s\n", v.index, tm) + } + } +} + +func (text *textPrinter) printStatusUpdate(p *textMux, v *vertex, s *status) { + var bytes string + if s.Total != 0 { + bytes = fmt.Sprintf(" %.2f / %.2f", units.Bytes(s.Current), units.Bytes(s.Total)) + } else if s.Current != 0 { + bytes = fmt.Sprintf(" %.2f", units.Bytes(s.Current)) + } + var tm string + endTime := s.Timestamp + if s.Completed != nil { + endTime = *s.Completed + } + if s.Started != nil { + diff := endTime.Sub(*s.Started).Seconds() + if diff > 0.01 { + tm = fmt.Sprintf(" %.1fs", diff) + } + } + if s.Completed != nil { + tm += " done" + } + fmt.Fprintf(p.w, "#%d %s%s%s\n", v.index, s.ID, bytes, tm) +} + +type jsonPrinter struct { + seen map[digest.Digest]struct{} + buf SolveStatus +} + +func (js *jsonPrinter) printVtx(p *textMux, t *trace, v *vertex) { + if js.seen == nil { + js.seen = make(map[digest.Digest]struct{}) + } + + if !p.notFirst { + if p.desc != "" { + js.buf.Vertex.Name = p.desc + js.print(p.w) + } + p.notFirst = true + } + + js.buf.Digest = v.Digest + if _, ok := js.seen[v.Digest]; !ok { + js.buf.Vertex.Name = p.getVtxName(v) + js.buf.Vertex.Started = v.Started + js.seen[v.Digest] = struct{}{} + } + + isOpenStatus := p.processStatusUpdates(v, func(s *status) { + js.buf.Statuses = append(js.buf.Statuses, VertexStatus{ + ID: s.VertexStatus.ID, + Name: s.VertexStatus.Name, + Total: s.VertexStatus.Total, + Current: s.VertexStatus.Current, + Timestamp: s.VertexStatus.Timestamp, + Started: s.VertexStatus.Started, + Completed: s.VertexStatus.Completed, + }) + }) + + for _, w := range v.warnings[v.warningIdx:] { + js.buf.Warnings = append(js.buf.Warnings, VertexWarning{ + Level: w.Level, + Short: w.Short, + Detail: w.Detail, + URL: w.URL, + SourceInfo: w.SourceInfo, + Range: w.Range, + }) + v.warningIdx++ + } + + for i, l := range v.logs { + log := VertexLog{ + Message: l[v.logsOffset:], + } + if i == len(v.logs)-1 { + log.Partial = v.logsPartial + } + js.buf.Logs = append(js.buf.Logs, log) + } + + if len(v.logs) > 0 { + if v.logsPartial { + v.logs = v.logs[len(v.logs)-1:] + v.logsOffset = len(v.logs[0]) + } else { + v.logs = nil + v.logsOffset = 0 + } + } + + p.current = v.Digest + if v.isCompleted() && !isOpenStatus { + p.current = "" + v.count = 0 + + if v.Error != "" { + if strings.HasSuffix(v.Error, context.Canceled.Error()) { + js.buf.Vertex.Canceled = true + } else { + js.buf.Vertex.Error = v.Error + } + } else if v.Cached { + js.buf.Vertex.Cached = true + } + js.buf.Vertex.Completed = v.Completed + } + js.print(t.w) +} + +// print will marshal the current status buffer and then reset it. +// Does nothing if the status buffer contains no data. +func (js *jsonPrinter) print(w io.Writer) { + if js.isEmpty() { + return + } + out, _ := json.Marshal(&js.buf) + fmt.Fprintf(w, "%s\n", out) + js.reset() +} + +func (js *jsonPrinter) isEmpty() bool { + return js.buf.Vertex.Name == "" && + js.buf.Vertex.Started == nil && + js.buf.Vertex.Completed == nil && + !js.buf.Vertex.Cached && + js.buf.Vertex.Error == "" && + len(js.buf.Statuses) == 0 && + len(js.buf.Warnings) == 0 && + len(js.buf.Logs) == 0 +} + +// reset resets the buffer to be reused in the future. +func (js *jsonPrinter) reset() { + js.buf.Vertex = Vertex{} + js.buf.Statuses = js.buf.Statuses[:0] + js.buf.Warnings = js.buf.Warnings[:0] + js.buf.Logs = js.buf.Logs[:0] +} + type vtxStat struct { blockTime time.Duration speed float64 diff --git a/util/progress/progresswriter/printer.go b/util/progress/progresswriter/printer.go index c400e4121531e..e674f31fa28bf 100644 --- a/util/progress/progresswriter/printer.go +++ b/util/progress/progresswriter/printer.go @@ -7,7 +7,6 @@ import ( "github.com/containerd/console" "github.com/moby/buildkit/client" "github.com/moby/buildkit/util/progress/progressui" - "github.com/pkg/errors" ) type printer struct { @@ -70,24 +69,14 @@ func NewPrinter(ctx context.Context, out console.File, mode string) (Writer, err mode = v } - var c console.Console - switch mode { - case "auto", "tty", "": - if cons, err := console.ConsoleFromFile(out); err == nil { - c = cons - } else { - if mode == "tty" { - return nil, errors.Wrap(err, "failed to get console") - } - } - case "plain": - default: - return nil, errors.Errorf("invalid progress mode %s", mode) + d, err := progressui.NewDisplay(out, progressui.DisplayMode(mode)) + if err != nil { + return nil, err } go func() { // not using shared context to not disrupt display but let is finish reporting errors - _, pw.err = progressui.DisplaySolveStatus(ctx, c, out, statusCh) + _, pw.err = progressui.UpdateDisplay(ctx, d, statusCh) close(doneCh) }() return pw, nil