| // Copyright 2014 The Bazel Authors. All rights reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| package com.google.devtools.build.lib.runtime; |
| |
| import com.google.common.base.Splitter; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.Iterators; |
| import com.google.devtools.build.lib.events.Event; |
| import com.google.devtools.build.lib.events.EventKind; |
| import com.google.devtools.build.lib.util.Pair; |
| import com.google.devtools.build.lib.util.io.AnsiTerminal; |
| import com.google.devtools.build.lib.util.io.AnsiTerminalWriter; |
| import com.google.devtools.build.lib.util.io.LineCountingAnsiTerminalWriter; |
| import com.google.devtools.build.lib.util.io.LineWrappingAnsiTerminalWriter; |
| import com.google.devtools.build.lib.util.io.OutErr; |
| import com.google.devtools.build.lib.vfs.PathFragment; |
| import java.io.IOException; |
| import java.nio.charset.StandardCharsets; |
| import java.time.Duration; |
| import java.time.Instant; |
| import java.util.Calendar; |
| import java.util.Iterator; |
| import java.util.concurrent.ThreadLocalRandom; |
| import java.util.logging.Logger; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| /** |
| * An event handler for ANSI terminals which uses control characters to |
| * provide eye-candy, reduce scrolling, and generally improve usability |
| * for users running directly from the shell. |
| * |
| * <p/> |
| * This event handler differs from a normal terminal because it only adds |
| * control characters to stderr, not stdout. All blaze status feedback |
| * is sent to stderr, so adding control characters just to that stream gives |
| * the benefits described above without modifying the normal output stream. |
| * For commands like build that don't generate stdout output this doesn't |
| * matter, but for commands like query and ide_build_info, inserting these |
| * control characters in stdout invalidated their output. |
| * |
| * <p/> |
| * The underlying streams may be either line-bufferred or unbuffered. |
| * Normally each event will write out a sequence of output to a single |
| * stream, and will end with a newline, which ensures a flush. |
| * But care is required when outputting incomplete lines, or when mixing |
| * output between the two different streams (stdout and stderr): |
| * it may be necessary to explicitly flush the output in those cases. |
| * However, we also don't want to flush too often; that can lead to |
| * a choppy UI experience. |
| */ |
| public class FancyTerminalEventHandler extends BlazeCommandEventHandler { |
| private static final Logger logger = Logger.getLogger(FancyTerminalEventHandler.class.getName()); |
| private static final Pattern progressPattern = Pattern.compile( |
| // Match strings that look like they start with progress info: |
| // [42%] Compiling base/base.cc |
| // [1,442 / 23,476] Compiling base/base.cc |
| "^\\[(?:(?:\\d\\d?\\d?%)|(?:[\\d+,]+ / [\\d,]+))\\] "); |
| private static final Splitter LINEBREAK_SPLITTER = Splitter.on('\n'); |
| private static final ImmutableList<String> SPECIAL_MESSAGES = |
| ImmutableList.of( |
| "Reading startup options from " |
| + "HKEY_LOCAL_MACHINE\\Software\\Google\\Devtools\\CurrentVersion", |
| "Contacting ftp://microsoft.com/win3.1/downloadcenter", |
| "Downloading MSVCR71.DLL", |
| "Installing Windows Update 37 of 118...", |
| "Sending request to Azure server", |
| "Initializing HAL", |
| "Loading NDIS2SUP.VXD", |
| "Initializing DRM", |
| "Contacting license server", |
| "Starting EC2 instances", |
| "Starting MS-DOS 6.0", |
| "Updating virus database", |
| "Linking WIN32.DLL", |
| "Linking GGL32.EXE", |
| "Starting ActiveX controls", |
| "Launching Microsoft Visual Studio 2013", |
| "Launching IEXPLORE.EXE", |
| "Initializing BASIC v2.1 interpreter", |
| "Parsing COM object monikers", |
| "Notifying field agents", |
| "Negotiating with killer robots", |
| "Searching for cellular signal", |
| "Waiting for workstation CPU temperature to decrease"); |
| |
| private static final ImmutableSet<Character> PUNCTUATION_CHARACTERS = |
| ImmutableSet.<Character>of(',', '.', ':', '?', '!', ';'); |
| |
| private final Iterator<String> messageIterator = Iterators.cycle(SPECIAL_MESSAGES); |
| private volatile boolean trySpecial; |
| private volatile Instant skipUntil = Instant.now(); |
| |
| private final AnsiTerminal terminal; |
| |
| private final boolean useColor; |
| private final boolean useCursorControls; |
| private final boolean progressInTermTitle; |
| public final int terminalWidth; |
| |
| private boolean terminalClosed = false; |
| private boolean previousLineErasable = false; |
| private int numLinesPreviousErasable = 0; |
| |
| public FancyTerminalEventHandler( |
| OutErr outErr, BlazeCommandEventHandler.Options options, PathFragment workspacePathFragment) { |
| super(outErr, options, workspacePathFragment); |
| this.terminal = new AnsiTerminal(outErr.getErrorStream()); |
| this.terminalWidth = (options.terminalColumns > 0 ? options.terminalColumns : 80); |
| useColor = options.useColor(); |
| useCursorControls = options.useCursorControl(); |
| progressInTermTitle = options.progressInTermTitle; |
| |
| Calendar today = Calendar.getInstance(); |
| trySpecial = options.forceExternalRepositories |
| || (options.externalRepositories |
| && today.get(Calendar.MONTH) == Calendar.APRIL |
| && today.get(Calendar.DAY_OF_MONTH) == 1); |
| } |
| |
| @Override |
| public synchronized void handle(Event event) { |
| if (terminalClosed) { |
| return; |
| } |
| if (!eventMask.contains(event.getKind())) { |
| handleFollowUpEvents(event); |
| return; |
| } |
| if (trySpecial |
| && !EventKind.ERRORS_AND_WARNINGS_AND_OUTPUT.contains(event.getKind()) |
| && skipUntil.isAfter(Instant.now())) { |
| // Short-circuit here to avoid wiping out previous terminal contents. |
| handleFollowUpEvents(event); |
| return; |
| } |
| |
| try { |
| boolean previousLineErased = false; |
| if (previousLineErasable) { |
| previousLineErased = maybeOverwritePreviousMessage(); |
| } |
| switch (event.getKind()) { |
| case PROGRESS: |
| case START: |
| { |
| String message = event.getMessage(); |
| Pair<String, String> progressPair = matchProgress(message); |
| if (progressPair != null) { |
| progress(progressPair.getFirst(), progressPair.getSecond()); |
| if (trySpecial && ThreadLocalRandom.current().nextInt(0, 20) == 0) { |
| message = getExtraMessage(); |
| if (message != null) { |
| // Should always be true, but don't crash on that! |
| previousLineErased = maybeOverwritePreviousMessage(); |
| progress(progressPair.getFirst(), message); |
| // Skip unimportant messages for a bit so that this message gets some exposure. |
| skipUntil = |
| Instant.now() |
| .plus(Duration.ofMillis(ThreadLocalRandom.current().nextInt(3000, 8000))); |
| } |
| } |
| } else { |
| progress("INFO: ", message); |
| } |
| break; |
| } |
| case FINISH: |
| { |
| String message = event.getMessage(); |
| Pair<String,String> progressPair = matchProgress(message); |
| if (progressPair != null) { |
| String percentage = progressPair.getFirst(); |
| String rest = progressPair.getSecond(); |
| progress(percentage, rest + " DONE"); |
| } else { |
| progress("INFO: ", message + " DONE"); |
| } |
| break; |
| } |
| case PASS: |
| progress("PASS: ", event.getMessage()); |
| break; |
| case INFO: |
| info(event); |
| break; |
| case ERROR: |
| case FAIL: |
| case TIMEOUT: |
| // For errors, scroll the message, so it appears above the status |
| // line, and highlight the word "ERROR" or "FAIL" in boldface red. |
| errorOrFail(event); |
| break; |
| case WARNING: |
| // For warnings, highlight the word "Warning" in boldface magenta, |
| // and scroll it. |
| warning(event); |
| break; |
| case DEBUG: |
| // For debug messages, highlight the word "Debug" in boldface yellow, |
| // and scroll it. |
| debug(event); |
| break; |
| case SUBCOMMAND: |
| subcmd(event); |
| break; |
| case STDOUT: |
| if (previousLineErased) { |
| terminal.flush(); |
| } |
| previousLineErasable = false; |
| super.handle(event); |
| // We don't need to flush stdout here, because |
| // super.handle(event) will take care of that. |
| break; |
| case STDERR: |
| putOutput(event); |
| break; |
| default: |
| // Ignore all other event types. |
| break; |
| } |
| } catch (IOException e) { |
| // The terminal shouldn't have IO errors, unless the shell is killed, which |
| // should also kill the blaze client. So this isn't something that should |
| // occur here; it will show up in the client/server interface as a broken |
| // pipe. |
| logger.warning("Terminal was closed during build: " + e); |
| terminalClosed = true; |
| } |
| handleFollowUpEvents(event); |
| } |
| |
| private void handleFollowUpEvents(Event event) { |
| if (event.getStdErr() != null) { |
| handle( |
| Event.of( |
| EventKind.STDERR, null, event.getStdErr().getBytes(StandardCharsets.ISO_8859_1))); |
| } |
| if (event.getStdOut() != null) { |
| handle( |
| Event.of( |
| EventKind.STDOUT, null, event.getStdOut().getBytes(StandardCharsets.ISO_8859_1))); |
| } |
| } |
| |
| private String getExtraMessage() { |
| synchronized (messageIterator) { |
| if (messageIterator.hasNext()) { |
| return messageIterator.next(); |
| } |
| } |
| trySpecial = false; |
| return null; |
| } |
| |
| /** |
| * Displays a progress message that may be erased by subsequent messages. |
| * |
| * @param prefix a short string such as "[99%] " or "INFO: ", which will be highlighted |
| * @param rest the remainder of the message; may be multiple lines |
| */ |
| private void progress(String prefix, String rest) throws IOException { |
| previousLineErasable = true; |
| |
| if (progressInTermTitle) { |
| int newlinePos = rest.indexOf('\n'); |
| if (newlinePos == -1) { |
| terminal.setTitle(prefix + rest); |
| } else { |
| terminal.setTitle(prefix + rest.substring(0, newlinePos)); |
| } |
| } |
| |
| LineCountingAnsiTerminalWriter countingWriter = new LineCountingAnsiTerminalWriter(terminal); |
| AnsiTerminalWriter terminalWriter = countingWriter; |
| |
| if (useCursorControls) { |
| terminalWriter = new LineWrappingAnsiTerminalWriter(terminalWriter, terminalWidth - 1); |
| } |
| |
| if (useColor) { |
| terminalWriter.okStatus(); |
| } |
| terminalWriter.append(prefix); |
| terminalWriter.normal(); |
| if (showTimestamp) { |
| String timestamp = timestamp(); |
| terminalWriter.append(timestamp); |
| } |
| Iterator<String> lines = LINEBREAK_SPLITTER.split(rest).iterator(); |
| String firstLine = lines.next(); |
| terminalWriter.append(firstLine); |
| terminalWriter.newline(); |
| while (lines.hasNext()) { |
| String line = lines.next(); |
| terminalWriter.append(line); |
| terminalWriter.newline(); |
| } |
| numLinesPreviousErasable = countingWriter.getWrittenLines(); |
| } |
| |
| /** |
| * Try to match a message against the "progress message" pattern. If it |
| * matches, return the progress percentage, and the rest of the message. |
| * @param message the message to match |
| * @return a pair containing the progress percentage, and the rest of the |
| * progress message, or null if the message isn't a progress message. |
| */ |
| private Pair<String,String> matchProgress(String message) { |
| Matcher m = progressPattern.matcher(message); |
| if (m.find()) { |
| return Pair.of(message.substring(0, m.end()), message.substring(m.end())); |
| } else { |
| return null; |
| } |
| } |
| |
| /** |
| * Send the terminal controls that will put the cursor on the beginning |
| * of the same line if cursor control is on, or the next line if not. |
| * @return True if it did any output; if so, caller is responsible for |
| * flushing the terminal if needed. |
| */ |
| private boolean maybeOverwritePreviousMessage() throws IOException { |
| if (useCursorControls && numLinesPreviousErasable != 0) { |
| for (int i = 0; i < numLinesPreviousErasable; i++) { |
| terminal.cr(); |
| terminal.cursorUp(1); |
| terminal.clearLine(); |
| } |
| return true; |
| } else { |
| return false; |
| } |
| } |
| |
| private void errorOrFail(Event event) throws IOException { |
| previousLineErasable = false; |
| if (useColor) { |
| terminal.textRed(); |
| terminal.textBold(); |
| } |
| terminal.writeString(event.getKind() + ": "); |
| if (useColor) { |
| terminal.resetTerminal(); |
| } |
| writeTimestampAndLocation(event); |
| writeStringWithPotentialPeriod(event.getMessage()); |
| crlf(); |
| } |
| |
| private void warning(Event warning) throws IOException { |
| previousLineErasable = false; |
| if (useColor) { |
| terminal.textMagenta(); |
| } |
| terminal.writeString("WARNING: "); |
| terminal.resetTerminal(); |
| writeTimestampAndLocation(warning); |
| writeStringWithPotentialPeriod(warning.getMessage()); |
| crlf(); |
| } |
| |
| private void info(Event event) throws IOException { |
| previousLineErasable = false; |
| if (useColor) { |
| terminal.textGreen(); |
| } |
| terminal.writeString(event.getKind() + ": "); |
| terminal.resetTerminal(); |
| writeTimestampAndLocation(event); |
| terminal.writeString(event.getMessage()); |
| // No period; info messages may end with a URL. |
| crlf(); |
| } |
| |
| private void debug(Event debug) throws IOException { |
| previousLineErasable = false; |
| if (useColor) { |
| terminal.textYellow(); |
| } |
| terminal.writeString("DEBUG: "); |
| terminal.resetTerminal(); |
| writeTimestampAndLocation(debug); |
| writeStringWithPotentialPeriod(debug.getMessage()); |
| crlf(); |
| } |
| |
| /** |
| * Writes the given String to the terminal. This method also writes a trailing period if the |
| * message doesn't end with a punctuation character. |
| */ |
| private void writeStringWithPotentialPeriod(String message) throws IOException { |
| terminal.writeString(message); |
| if (!message.isEmpty()) { |
| char lastChar = message.charAt(message.length() - 1); |
| if (!PUNCTUATION_CHARACTERS.contains(lastChar)) { |
| terminal.writeString("."); |
| } |
| } |
| } |
| |
| private void subcmd(Event subcmd) throws IOException { |
| previousLineErasable = false; |
| if (useColor) { |
| terminal.textBlue(); |
| } |
| terminal.writeString(">>>>> "); |
| terminal.resetTerminal(); |
| writeTimestampAndLocation(subcmd); |
| terminal.writeString(subcmd.getMessage()); |
| crlf(); |
| } |
| |
| /* Handle STDERR events. */ |
| private void putOutput(Event event) throws IOException { |
| previousLineErasable = false; |
| terminal.writeBytes(event.getMessageBytes()); |
| /* |
| * The following code doesn't work because buildtool.TerminalTestNotifier |
| * writes ANSI-formatted text via this mechanism, one character at a time, |
| * and if we try to insert additional ANSI sequences in between the characters |
| * of another ANSI escape sequence, we screw things up. (?) |
| * TODO(bazel-team): (2009) fix this. TerminalTestNotifier should go via the Reporter |
| * rather than via an AnsiTerminalWriter. |
| */ |
| // terminal.resetTerminal(); |
| // writeTimestampAndLocation(event); |
| // if (useColor) { |
| // terminal.textNormal(); |
| // } |
| // terminal.writeBytes(event.getMessageBytes()); |
| // terminal.resetTerminal(); |
| } |
| |
| /** |
| * Add a carriage return, shifting to the next line on the terminal, while |
| * guaranteeing that the terminal control codes don't cause any strange |
| * effects. Without the CR before the "\n", the "\n" can cause a line-break |
| * moving text to the next line, where the new message will be generated. |
| * Emitting a "CR" before means that the actual terminal controls generated |
| * here are CR+CR+LF; the double-CR resets the terminal line state, which |
| * prevents the potentially ugly formatting issue. |
| */ |
| private void crlf() throws IOException { |
| terminal.cr(); |
| terminal.writeString("\n"); |
| } |
| |
| private void writeTimestampAndLocation(Event event) throws IOException { |
| if (showTimestamp) { |
| terminal.writeString(timestamp()); |
| } |
| if (event.getLocation() != null) { |
| terminal.writeString(locationPrinter.getLocationString(event.getLocation()) + ": "); |
| } |
| } |
| |
| public void resetTerminal() { |
| try { |
| terminal.resetTerminal(); |
| } catch (IOException e) { |
| logger.warning("IO Error writing to user terminal: " + e); |
| } |
| } |
| } |