Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 3dac80e5 authored by Colin Cross's avatar Colin Cross
Browse files

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
parent 4355ee64
Loading
Loading
Loading
Loading
+216 −6
Original line number Diff line number Diff line
@@ -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)
}
+6 −0
Original line number Diff line number Diff line
@@ -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)
+5 −5
Original line number Diff line number Diff line
@@ -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
}