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
This commit is contained in:
Colin Cross
2019-06-11 11:19:06 -07:00
parent 4355ee64a9
commit 3dac80e519
3 changed files with 227 additions and 11 deletions

View File

@@ -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)
}