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
diff --git a/ui/terminal/smart_status.go b/ui/terminal/smart_status.go
index 82c04d4..9638cdf 100644
--- a/ui/terminal/smart_status.go
+++ b/ui/terminal/smart_status.go
@@ -19,13 +19,22 @@
"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 @@
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 @@
// 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) StartAction(action *status.Action, counts status.Counts) {
+ startTime := time.Now()
+
str := action.Description
if str == "" {
str = action.Command
@@ -87,6 +129,11 @@
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 @@
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 @@
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) 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 @@
// 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 @@
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) 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 106d651..81aa238 100644
--- a/ui/terminal/status_test.go
+++ b/ui/terminal/status_test.go
@@ -17,6 +17,7 @@
import (
"bytes"
"fmt"
+ "os"
"syscall"
"testing"
@@ -86,8 +87,11 @@
},
}
+ 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 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 3a11b79..c9377f1 100644
--- a/ui/terminal/util.go
+++ b/ui/terminal/util.go
@@ -35,7 +35,7 @@
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 @@
_, _, 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 @@
type fakeSmartTerminal struct {
bytes.Buffer
- termWidth int
+ termWidth, termHeight int
}