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