From 3dac80e519f330fb5025080bb1ccfa471d73e206 Mon Sep 17 00:00:00 2001 From: Colin Cross Date: Tue, 11 Jun 2019 11:19:06 -0700 Subject: [PATCH] Support an action table that shows longest running actions If SOONG_UI_TABLE_HEIGHT is set, enable a new smart terminal display that prints the normal scrolling build history in the top region of the screen and an action table of the longest currently running actions in the bottom region of the screen. This provides better visibility into which are the longest running actions and when the build parallelism is very low. Test: manual Change-Id: I677d7b6b008699febd259110d7f9e0f98d80c535 --- ui/terminal/smart_status.go | 222 +++++++++++++++++++++++++++++++++++- ui/terminal/status_test.go | 6 + ui/terminal/util.go | 10 +- 3 files changed, 227 insertions(+), 11 deletions(-) diff --git a/ui/terminal/smart_status.go b/ui/terminal/smart_status.go index 82c04d46c..9638cdf74 100644 --- a/ui/terminal/smart_status.go +++ b/ui/terminal/smart_status.go @@ -19,13 +19,22 @@ import ( "io" "os" "os/signal" + "strconv" "strings" "sync" "syscall" + "time" "android/soong/ui/status" ) +const tableHeightEnVar = "SOONG_UI_TABLE_HEIGHT" + +type actionTableEntry struct { + action *status.Action + startTime time.Time +} + type smartStatusOutput struct { writer io.Writer formatter formatter @@ -34,7 +43,14 @@ type smartStatusOutput struct { haveBlankLine bool - termWidth int + tableMode bool + tableHeight int + requestedTableHeight int + termWidth, termHeight int + + runningActions []actionTableEntry + ticker *time.Ticker + done chan bool sigwinch chan os.Signal sigwinchHandled chan bool } @@ -43,17 +59,41 @@ type smartStatusOutput struct { // current build status similarly to Ninja's built-in terminal // output. func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput { + tableHeight, _ := strconv.Atoi(os.Getenv(tableHeightEnVar)) + s := &smartStatusOutput{ writer: w, formatter: formatter, haveBlankLine: true, + tableMode: tableHeight > 0, + requestedTableHeight: tableHeight, + + done: make(chan bool), sigwinch: make(chan os.Signal), } s.updateTermSize() + if s.tableMode { + // Add empty lines at the bottom of the screen to scroll back the existing history + // and make room for the action table. + // TODO: read the cursor position to see if the empty lines are necessary? + for i := 0; i < s.tableHeight; i++ { + fmt.Fprintln(w) + } + + // Hide the cursor to prevent seeing it bouncing around + fmt.Fprintf(s.writer, ansi.hideCursor()) + + // Configure the empty action table + s.actionTable() + + // Start a tick to update the action table periodically + s.startActionTableTick() + } + s.startSigwinch() return s @@ -77,6 +117,8 @@ func (s *smartStatusOutput) Message(level status.MsgLevel, message string) { } func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) { + startTime := time.Now() + str := action.Description if str == "" { str = action.Command @@ -87,6 +129,11 @@ func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Cou s.lock.Lock() defer s.lock.Unlock() + s.runningActions = append(s.runningActions, actionTableEntry{ + action: action, + startTime: startTime, + }) + s.statusLine(progress + str) } @@ -103,6 +150,13 @@ func (s *smartStatusOutput) FinishAction(result status.ActionResult, counts stat s.lock.Lock() defer s.lock.Unlock() + for i, runningAction := range s.runningActions { + if runningAction.action == result.Action { + s.runningActions = append(s.runningActions[:i], s.runningActions[i+1:]...) + break + } + } + if output != "" { s.statusLine(progress) s.requestLine() @@ -119,6 +173,23 @@ func (s *smartStatusOutput) Flush() { s.stopSigwinch() s.requestLine() + + s.runningActions = nil + + if s.tableMode { + s.stopActionTableTick() + + // Update the table after clearing runningActions to clear it + s.actionTable() + + // Reset the scrolling region to the whole terminal + fmt.Fprintf(s.writer, ansi.resetScrollingMargins()) + _, height, _ := termSize(s.writer) + // Move the cursor to the top of the now-blank, previously non-scrolling region + fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 0)) + // Turn the cursor back on + fmt.Fprintf(s.writer, ansi.showCursor()) + } } func (s *smartStatusOutput) Write(p []byte) (int, error) { @@ -137,7 +208,7 @@ func (s *smartStatusOutput) requestLine() { func (s *smartStatusOutput) print(str string) { if !s.haveBlankLine { - fmt.Fprint(s.writer, "\r", "\x1b[K") + fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine()) s.haveBlankLine = true } fmt.Fprint(s.writer, str) @@ -160,8 +231,8 @@ func (s *smartStatusOutput) statusLine(str string) { // Move to the beginning on the line, turn on bold, print the output, // turn off bold, then clear the rest of the line. - start := "\r\x1b[1m" - end := "\x1b[0m\x1b[K" + start := "\r" + ansi.bold() + end := ansi.regular() + ansi.clearToEndOfLine() fmt.Fprint(s.writer, start, str, end) s.haveBlankLine = false } @@ -176,12 +247,36 @@ func (s *smartStatusOutput) elide(str string) string { return str } +func (s *smartStatusOutput) startActionTableTick() { + s.ticker = time.NewTicker(time.Second) + go func() { + for { + select { + case <-s.ticker.C: + s.lock.Lock() + s.actionTable() + s.lock.Unlock() + case <-s.done: + return + } + } + }() +} + +func (s *smartStatusOutput) stopActionTableTick() { + s.ticker.Stop() + s.done <- true +} + func (s *smartStatusOutput) startSigwinch() { signal.Notify(s.sigwinch, syscall.SIGWINCH) go func() { for _ = range s.sigwinch { s.lock.Lock() s.updateTermSize() + if s.tableMode { + s.actionTable() + } s.lock.Unlock() if s.sigwinchHandled != nil { s.sigwinchHandled <- true @@ -196,7 +291,122 @@ func (s *smartStatusOutput) stopSigwinch() { } func (s *smartStatusOutput) updateTermSize() { - if w, ok := termWidth(s.writer); ok { - s.termWidth = w + if w, h, ok := termSize(s.writer); ok { + firstUpdate := s.termHeight == 0 && s.termWidth == 0 + oldScrollingHeight := s.termHeight - s.tableHeight + + s.termWidth, s.termHeight = w, h + + if s.tableMode { + tableHeight := s.requestedTableHeight + if tableHeight > s.termHeight-1 { + tableHeight = s.termHeight - 1 + } + s.tableHeight = tableHeight + + scrollingHeight := s.termHeight - s.tableHeight + + if !firstUpdate { + // If the scrolling region has changed, attempt to pan the existing text so that it is + // not overwritten by the table. + if scrollingHeight < oldScrollingHeight { + pan := oldScrollingHeight - scrollingHeight + if pan > s.tableHeight { + pan = s.tableHeight + } + fmt.Fprint(s.writer, ansi.panDown(pan)) + } + } + } } } + +func (s *smartStatusOutput) actionTable() { + scrollingHeight := s.termHeight - s.tableHeight + + // Update the scrolling region in case the height of the terminal changed + fmt.Fprint(s.writer, ansi.setScrollingMargins(0, scrollingHeight)) + // Move the cursor to the first line of the non-scrolling region + fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1, 0)) + + // Write as many status lines as fit in the table + var tableLine int + var runningAction actionTableEntry + for tableLine, runningAction = range s.runningActions { + if tableLine >= s.tableHeight { + break + } + + seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds()) + + desc := runningAction.action.Description + if desc == "" { + desc = runningAction.action.Command + } + + str := fmt.Sprintf(" %2d:%02d %s", seconds/60, seconds%60, desc) + str = s.elide(str) + fmt.Fprint(s.writer, str, ansi.clearToEndOfLine()) + if tableLine < s.tableHeight-1 { + fmt.Fprint(s.writer, "\n") + } + } + + // Clear any remaining lines in the table + for ; tableLine < s.tableHeight; tableLine++ { + fmt.Fprint(s.writer, ansi.clearToEndOfLine()) + if tableLine < s.tableHeight-1 { + fmt.Fprint(s.writer, "\n") + } + } + + // Move the cursor back to the last line of the scrolling region + fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 0)) +} + +var ansi = ansiImpl{} + +type ansiImpl struct{} + +func (ansiImpl) clearToEndOfLine() string { + return "\x1b[K" +} + +func (ansiImpl) setCursor(row, column int) string { + // Direct cursor address + return fmt.Sprintf("\x1b[%d;%dH", row, column) +} + +func (ansiImpl) setScrollingMargins(top, bottom int) string { + // Set Top and Bottom Margins DECSTBM + return fmt.Sprintf("\x1b[%d;%dr", top, bottom) +} + +func (ansiImpl) resetScrollingMargins() string { + // Set Top and Bottom Margins DECSTBM + return fmt.Sprintf("\x1b[r") +} + +func (ansiImpl) bold() string { + return "\x1b[1m" +} + +func (ansiImpl) regular() string { + return "\x1b[0m" +} + +func (ansiImpl) showCursor() string { + return "\x1b[?25h" +} + +func (ansiImpl) hideCursor() string { + return "\x1b[?25l" +} + +func (ansiImpl) panDown(lines int) string { + return fmt.Sprintf("\x1b[%dS", lines) +} + +func (ansiImpl) panUp(lines int) string { + return fmt.Sprintf("\x1b[%dT", lines) +} diff --git a/ui/terminal/status_test.go b/ui/terminal/status_test.go index 106d6515f..81aa238b8 100644 --- a/ui/terminal/status_test.go +++ b/ui/terminal/status_test.go @@ -17,6 +17,7 @@ package terminal import ( "bytes" "fmt" + "os" "syscall" "testing" @@ -86,8 +87,11 @@ func TestStatusOutput(t *testing.T) { }, } + os.Setenv(tableHeightEnVar, "") + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Run("smart", func(t *testing.T) { smart := &fakeSmartTerminal{termWidth: 40} stat := NewStatusOutput(smart, "", false) @@ -251,6 +255,8 @@ func actionWithOuptutWithAnsiCodes(stat status.StatusOutput) { } func TestSmartStatusOutputWidthChange(t *testing.T) { + os.Setenv(tableHeightEnVar, "") + smart := &fakeSmartTerminal{termWidth: 40} stat := NewStatusOutput(smart, "", false) smartStat := stat.(*smartStatusOutput) diff --git a/ui/terminal/util.go b/ui/terminal/util.go index 3a11b79bb..c9377f156 100644 --- a/ui/terminal/util.go +++ b/ui/terminal/util.go @@ -35,7 +35,7 @@ func isSmartTerminal(w io.Writer) bool { return false } -func termWidth(w io.Writer) (int, bool) { +func termSize(w io.Writer) (width int, height int, ok bool) { if f, ok := w.(*os.File); ok { var winsize struct { ws_row, ws_column uint16 @@ -44,11 +44,11 @@ func termWidth(w io.Writer) (int, bool) { _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, f.Fd(), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&winsize)), 0, 0, 0) - return int(winsize.ws_column), err == 0 + return int(winsize.ws_column), int(winsize.ws_row), err == 0 } else if f, ok := w.(*fakeSmartTerminal); ok { - return f.termWidth, true + return f.termWidth, f.termHeight, true } - return 0, false + return 0, 0, false } // stripAnsiEscapes strips ANSI control codes from a byte array in place. @@ -106,5 +106,5 @@ func stripAnsiEscapes(input []byte) []byte { type fakeSmartTerminal struct { bytes.Buffer - termWidth int + termWidth, termHeight int }