1// Copyright 2019 Google Inc. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package terminal
16
17import (
18	"fmt"
19	"io"
20	"os"
21	"os/signal"
22	"strconv"
23	"strings"
24	"sync"
25	"syscall"
26	"time"
27
28	"android/soong/ui/status"
29)
30
31const tableHeightEnVar = "SOONG_UI_TABLE_HEIGHT"
32
33type actionTableEntry struct {
34	action    *status.Action
35	startTime time.Time
36}
37
38type smartStatusOutput struct {
39	writer    io.Writer
40	formatter formatter
41
42	lock sync.Mutex
43
44	haveBlankLine bool
45
46	tableMode             bool
47	tableHeight           int
48	requestedTableHeight  int
49	termWidth, termHeight int
50
51	runningActions  []actionTableEntry
52	ticker          *time.Ticker
53	done            chan bool
54	sigwinch        chan os.Signal
55	sigwinchHandled chan bool
56
57	// Once there is a failure, we stop printing command output so the error
58	// is easier to find
59	haveFailures bool
60	// If we are dropping errors, then at the end, we report a message to go
61	// look in the verbose log if you want that command output.
62	postFailureActionCount int
63}
64
65// NewSmartStatusOutput returns a StatusOutput that represents the
66// current build status similarly to Ninja's built-in terminal
67// output.
68func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput {
69	s := &smartStatusOutput{
70		writer:    w,
71		formatter: formatter,
72
73		haveBlankLine: true,
74
75		tableMode: true,
76
77		done:     make(chan bool),
78		sigwinch: make(chan os.Signal),
79	}
80
81	if env, ok := os.LookupEnv(tableHeightEnVar); ok {
82		h, _ := strconv.Atoi(env)
83		s.tableMode = h > 0
84		s.requestedTableHeight = h
85	}
86
87	if w, h, ok := termSize(s.writer); ok {
88		s.termWidth, s.termHeight = w, h
89		s.computeTableHeight()
90	} else {
91		s.tableMode = false
92	}
93
94	if s.tableMode {
95		// Add empty lines at the bottom of the screen to scroll back the existing history
96		// and make room for the action table.
97		// TODO: read the cursor position to see if the empty lines are necessary?
98		for i := 0; i < s.tableHeight; i++ {
99			fmt.Fprintln(w)
100		}
101
102		// Hide the cursor to prevent seeing it bouncing around
103		fmt.Fprintf(s.writer, ansi.hideCursor())
104
105		// Configure the empty action table
106		s.actionTable()
107
108		// Start a tick to update the action table periodically
109		s.startActionTableTick()
110	}
111
112	s.startSigwinch()
113
114	return s
115}
116
117func (s *smartStatusOutput) Message(level status.MsgLevel, message string) {
118	if level < status.StatusLvl {
119		return
120	}
121
122	str := s.formatter.message(level, message)
123
124	s.lock.Lock()
125	defer s.lock.Unlock()
126
127	if level > status.StatusLvl {
128		s.print(str)
129	} else {
130		s.statusLine(str)
131	}
132}
133
134func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) {
135	startTime := time.Now()
136
137	str := action.Description
138	if str == "" {
139		str = action.Command
140	}
141
142	progress := s.formatter.progress(counts)
143
144	s.lock.Lock()
145	defer s.lock.Unlock()
146
147	s.runningActions = append(s.runningActions, actionTableEntry{
148		action:    action,
149		startTime: startTime,
150	})
151
152	s.statusLine(progress + str)
153}
154
155func (s *smartStatusOutput) FinishAction(result status.ActionResult, counts status.Counts) {
156	str := result.Description
157	if str == "" {
158		str = result.Command
159	}
160
161	progress := s.formatter.progress(counts) + str
162
163	output := s.formatter.result(result)
164
165	s.lock.Lock()
166	defer s.lock.Unlock()
167
168	for i, runningAction := range s.runningActions {
169		if runningAction.action == result.Action {
170			s.runningActions = append(s.runningActions[:i], s.runningActions[i+1:]...)
171			break
172		}
173	}
174
175	s.statusLine(progress)
176
177	// Stop printing when there are failures, but don't skip actions that also have their own errors.
178	if output != "" {
179		if !s.haveFailures || result.Error != nil {
180			s.requestLine()
181			s.print(output)
182		} else {
183			s.postFailureActionCount++
184		}
185	}
186
187	if result.Error != nil {
188		s.haveFailures = true
189	}
190}
191
192func (s *smartStatusOutput) Flush() {
193	if s.tableMode {
194		// Stop the action table tick outside of the lock to avoid lock ordering issues between s.done and
195		// s.lock, the goroutine in startActionTableTick can get blocked on the lock and be unable to read
196		// from the channel.
197		s.stopActionTableTick()
198	}
199
200	s.lock.Lock()
201	defer s.lock.Unlock()
202
203	s.stopSigwinch()
204
205	if s.postFailureActionCount > 0 {
206		s.requestLine()
207		if s.postFailureActionCount == 1 {
208			s.print(fmt.Sprintf("There was 1 action that completed after the action that failed. See verbose.log.gz for its output."))
209		} else {
210			s.print(fmt.Sprintf("There were %d actions that completed after the action that failed. See verbose.log.gz for their output.", s.postFailureActionCount))
211		}
212	}
213
214	s.requestLine()
215
216	s.runningActions = nil
217
218	if s.tableMode {
219		// Update the table after clearing runningActions to clear it
220		s.actionTable()
221
222		// Reset the scrolling region to the whole terminal
223		fmt.Fprintf(s.writer, ansi.resetScrollingMargins())
224		_, height, _ := termSize(s.writer)
225		// Move the cursor to the top of the now-blank, previously non-scrolling region
226		fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 1))
227		// Turn the cursor back on
228		fmt.Fprintf(s.writer, ansi.showCursor())
229	}
230}
231
232func (s *smartStatusOutput) Write(p []byte) (int, error) {
233	s.lock.Lock()
234	defer s.lock.Unlock()
235	s.print(string(p))
236	return len(p), nil
237}
238
239func (s *smartStatusOutput) requestLine() {
240	if !s.haveBlankLine {
241		fmt.Fprintln(s.writer)
242		s.haveBlankLine = true
243	}
244}
245
246func (s *smartStatusOutput) print(str string) {
247	if !s.haveBlankLine {
248		fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine())
249		s.haveBlankLine = true
250	}
251	fmt.Fprint(s.writer, str)
252	if len(str) == 0 || str[len(str)-1] != '\n' {
253		fmt.Fprint(s.writer, "\n")
254	}
255}
256
257func (s *smartStatusOutput) statusLine(str string) {
258	idx := strings.IndexRune(str, '\n')
259	if idx != -1 {
260		str = str[0:idx]
261	}
262
263	// Limit line width to the terminal width, otherwise we'll wrap onto
264	// another line and we won't delete the previous line.
265	str = elide(str, s.termWidth)
266
267	// Move to the beginning on the line, turn on bold, print the output,
268	// turn off bold, then clear the rest of the line.
269	start := "\r" + ansi.bold()
270	end := ansi.regular() + ansi.clearToEndOfLine()
271	fmt.Fprint(s.writer, start, str, end)
272	s.haveBlankLine = false
273}
274
275func elide(str string, width int) string {
276	if width > 0 && len(str) > width {
277		// TODO: Just do a max. Ninja elides the middle, but that's
278		// more complicated and these lines aren't that important.
279		str = str[:width]
280	}
281
282	return str
283}
284
285func (s *smartStatusOutput) startActionTableTick() {
286	s.ticker = time.NewTicker(time.Second)
287	go func() {
288		for {
289			select {
290			case <-s.ticker.C:
291				s.lock.Lock()
292				s.actionTable()
293				s.lock.Unlock()
294			case <-s.done:
295				return
296			}
297		}
298	}()
299}
300
301func (s *smartStatusOutput) stopActionTableTick() {
302	s.ticker.Stop()
303	s.done <- true
304}
305
306func (s *smartStatusOutput) startSigwinch() {
307	signal.Notify(s.sigwinch, syscall.SIGWINCH)
308	go func() {
309		for _ = range s.sigwinch {
310			s.lock.Lock()
311			s.updateTermSize()
312			if s.tableMode {
313				s.actionTable()
314			}
315			s.lock.Unlock()
316			if s.sigwinchHandled != nil {
317				s.sigwinchHandled <- true
318			}
319		}
320	}()
321}
322
323func (s *smartStatusOutput) stopSigwinch() {
324	signal.Stop(s.sigwinch)
325	close(s.sigwinch)
326}
327
328// computeTableHeight recomputes s.tableHeight based on s.termHeight and s.requestedTableHeight.
329func (s *smartStatusOutput) computeTableHeight() {
330	tableHeight := s.requestedTableHeight
331	if tableHeight == 0 {
332		tableHeight = s.termHeight / 4
333		if tableHeight < 1 {
334			tableHeight = 1
335		} else if tableHeight > 10 {
336			tableHeight = 10
337		}
338	}
339	if tableHeight > s.termHeight-1 {
340		tableHeight = s.termHeight - 1
341	}
342	s.tableHeight = tableHeight
343}
344
345// updateTermSize recomputes the table height after a SIGWINCH and pans any existing text if
346// necessary.
347func (s *smartStatusOutput) updateTermSize() {
348	if w, h, ok := termSize(s.writer); ok {
349		oldScrollingHeight := s.termHeight - s.tableHeight
350
351		s.termWidth, s.termHeight = w, h
352
353		if s.tableMode {
354			s.computeTableHeight()
355
356			scrollingHeight := s.termHeight - s.tableHeight
357
358			// If the scrolling region has changed, attempt to pan the existing text so that it is
359			// not overwritten by the table.
360			if scrollingHeight < oldScrollingHeight {
361				pan := oldScrollingHeight - scrollingHeight
362				if pan > s.tableHeight {
363					pan = s.tableHeight
364				}
365				fmt.Fprint(s.writer, ansi.panDown(pan))
366			}
367		}
368	}
369}
370
371func (s *smartStatusOutput) actionTable() {
372	scrollingHeight := s.termHeight - s.tableHeight
373
374	// Update the scrolling region in case the height of the terminal changed
375
376	fmt.Fprint(s.writer, ansi.setScrollingMargins(1, scrollingHeight))
377
378	// Write as many status lines as fit in the table
379	for tableLine := 0; tableLine < s.tableHeight; tableLine++ {
380		if tableLine >= s.tableHeight {
381			break
382		}
383		// Move the cursor to the correct line of the non-scrolling region
384		fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1+tableLine, 1))
385
386		if tableLine < len(s.runningActions) {
387			runningAction := s.runningActions[tableLine]
388
389			seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds())
390
391			desc := runningAction.action.Description
392			if desc == "" {
393				desc = runningAction.action.Command
394			}
395
396			color := ""
397			if seconds >= 60 {
398				color = ansi.red() + ansi.bold()
399			} else if seconds >= 30 {
400				color = ansi.yellow() + ansi.bold()
401			}
402
403			durationStr := fmt.Sprintf("   %2d:%02d ", seconds/60, seconds%60)
404			desc = elide(desc, s.termWidth-len(durationStr))
405			durationStr = color + durationStr + ansi.regular()
406			fmt.Fprint(s.writer, durationStr, desc)
407		}
408		fmt.Fprint(s.writer, ansi.clearToEndOfLine())
409	}
410
411	// Move the cursor back to the last line of the scrolling region
412	fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 1))
413}
414
415var ansi = ansiImpl{}
416
417type ansiImpl struct{}
418
419func (ansiImpl) clearToEndOfLine() string {
420	return "\x1b[K"
421}
422
423func (ansiImpl) setCursor(row, column int) string {
424	// Direct cursor address
425	return fmt.Sprintf("\x1b[%d;%dH", row, column)
426}
427
428func (ansiImpl) setScrollingMargins(top, bottom int) string {
429	// Set Top and Bottom Margins DECSTBM
430	return fmt.Sprintf("\x1b[%d;%dr", top, bottom)
431}
432
433func (ansiImpl) resetScrollingMargins() string {
434	// Set Top and Bottom Margins DECSTBM
435	return fmt.Sprintf("\x1b[r")
436}
437
438func (ansiImpl) red() string {
439	return "\x1b[31m"
440}
441
442func (ansiImpl) yellow() string {
443	return "\x1b[33m"
444}
445
446func (ansiImpl) bold() string {
447	return "\x1b[1m"
448}
449
450func (ansiImpl) regular() string {
451	return "\x1b[0m"
452}
453
454func (ansiImpl) showCursor() string {
455	return "\x1b[?25h"
456}
457
458func (ansiImpl) hideCursor() string {
459	return "\x1b[?25l"
460}
461
462func (ansiImpl) panDown(lines int) string {
463	return fmt.Sprintf("\x1b[%dS", lines)
464}
465
466func (ansiImpl) panUp(lines int) string {
467	return fmt.Sprintf("\x1b[%dT", lines)
468}
469