Improve error messages for early Bazel failures

Tulsi previously only used stdout/stderr from Bazel's BEP during builds
inside of the IDE. This is of course problematic for Bazel failures
which occur before BEP can be initialized.

As a workaround, if we are unable to read any build events via BEP, we
output Bazel's stdout/stderr directly.

This change also makes the error messages a bit more useful by pointing
Xcode to the bazel_build.py script, so the user has more insight into
errors generated by our script.

PiperOrigin-RevId: 209837296
diff --git a/src/TulsiGenerator/Scripts/bazel_build.py b/src/TulsiGenerator/Scripts/bazel_build.py
index 4d96661..11990a6 100755
--- a/src/TulsiGenerator/Scripts/bazel_build.py
+++ b/src/TulsiGenerator/Scripts/bazel_build.py
@@ -1,4 +1,5 @@
 #!/usr/bin/python
+# -*- coding: utf-8 -*-
 # Copyright 2016 The Tulsi Authors. All rights reserved.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -18,6 +19,7 @@
 import atexit
 import errno
 import fcntl
+import inspect
 import io
 import json
 import os
@@ -53,6 +55,11 @@
 _logger = None
 
 
+def _PrintUnbuffered(msg):
+  sys.stdout.write('%s\n' % msg)
+  sys.stdout.flush()
+
+
 def _PrintXcodeWarning(msg):
   sys.stdout.write(':: warning: %s\n' % msg)
   sys.stdout.flush()
@@ -63,6 +70,15 @@
   sys.stderr.flush()
 
 
+def _Fatal(msg, fatal_frame=None):
+  """Print a fatal error pointing to the failure line inside the script."""
+  if not fatal_frame:
+    fatal_frame = inspect.currentframe().f_back
+  filename, line_number, _, _, _ = inspect.getframeinfo(fatal_frame)
+  _PrintUnbuffered('%s:%d: error: %s' % (os.path.abspath(filename),
+                                         line_number, msg))
+
+
 CLEANUP_BEP_FILE_AT_EXIT = False
 
 
@@ -82,7 +98,7 @@
 def _InterruptHandler(signum, frame):
   """Gracefully exit on SIGINT."""
   del signum, frame  # Unused.
-  sys.stdout.write('Caught interrupt signal. Exiting...\n')
+  _PrintUnbuffered('Caught interrupt signal. Exiting...')
   sys.exit(0)
 
 
@@ -134,8 +150,7 @@
     lock_path: Path to the lock file.
   """
   # TODO(b/74119354): Add lockfile timer back and lazily instantiate logger.
-  sys.stdout.write('Queuing Tulsi build...\n')
-  sys.stdout.flush()
+  _PrintUnbuffered('Queuing Tulsi build...')
   # TODO(b/69414272): See if we can improve this for multiple WORKSPACEs.
   lockfile = open(lock_path, 'w')
   # Register "fclose(...)" as early as possible, before acquiring lock.
@@ -280,9 +295,8 @@
     return bazel, start_up, all_build
 
   def _WarnUnknownPlatform(self):
-    sys.stdout.write('Warning: unknown platform "%s" will be treated as '
-                     'iOS\n' % self.platform_name)
-    sys.stdout.flush()
+    _PrintUnbuffered('Warning: unknown platform "%s" will be treated as '
+                     'iOS' % self.platform_name)
 
   def _ParseVariableOptions(self, args):
     """Parses flag-based args, returning (message, exit_code)."""
@@ -317,9 +331,8 @@
     reported_version = os.environ['XCODE_VERSION_ACTUAL']
     match = re.match(r'(\d{2})(\d)(\d)$', reported_version)
     if not match:
-      sys.stdout.write('Warning: Failed to extract Xcode version from %s\n' % (
+      _PrintUnbuffered('Warning: Failed to extract Xcode version from %s' % (
           reported_version))
-      sys.stdout.flush()
       return None
     major_version = int(match.group(1))
     minor_version = int(match.group(2))
@@ -512,16 +525,18 @@
     exit_code, outputs = self._RunBazelAndPatchOutput(command)
     timer.End()
     if exit_code:
-      _PrintXcodeError('Bazel build failed.')
+      _Fatal('Bazel build failed with exit code %d. Please check the build '
+             'log in Report Navigator (⌘9) for more information.'
+             % exit_code)
       return exit_code
 
     post_bazel_timer = Timer('Total Tulsi Post-Bazel time', 'total_post_bazel')
     post_bazel_timer.Start()
 
     if not os.path.exists(self.bazel_exec_root):
-      _PrintXcodeError('No Bazel execution root was found at %s. Debugging '
-                       'experience will be compromised. Please report a Tulsi '
-                       'bug.' % self.bazel_exec_root)
+      _Fatal('No Bazel execution root was found at %r. Debugging experience '
+             'will be compromised. Please report a Tulsi bug.'
+             % self.bazel_exec_root)
       return 404
 
     # This needs to run after `bazel build`, since it depends on the Bazel
@@ -699,22 +714,33 @@
           new_outputs.extend(outputs)
       return new_outputs
 
+    def ReaderThread(file_handle, out_buffer):
+      out_buffer.append(file_handle.read())
+      file_handle.close()
+
     # Make sure the BEP JSON file exists and is empty. We do this to prevent
     # any sort of race between the watcher, bazel, and the old file contents.
     open(self.build_events_file_path, 'w').close()
 
-    # Start Bazel without any extra files open besides /dev/null, which is
-    # used to ignore the output.
-    with open(os.devnull, 'w') as devnull:
-      process = subprocess.Popen(command,
-                                 stdout=devnull,
-                                 stderr=subprocess.STDOUT)
+    # Capture the stderr and stdout from Bazel. We only display it if it we're
+    # unable to read any BEP events.
+    process = subprocess.Popen(command,
+                               stdout=subprocess.PIPE,
+                               stderr=subprocess.STDOUT,
+                               bufsize=1)
 
     # Register atexit function to clean up BEP file.
     atexit.register(_BEPFileExitCleanup, self.build_events_file_path)
     global CLEANUP_BEP_FILE_AT_EXIT
     CLEANUP_BEP_FILE_AT_EXIT = True
 
+    # Start capturing output from Bazel.
+    reader_buffer = []
+    reader_thread = threading.Thread(target=ReaderThread,
+                                     args=(process.stdout, reader_buffer))
+    reader_thread.daemon = True
+    reader_thread.start()
+
     with io.open(self.build_events_file_path, 'r', -1, 'utf-8', 'ignore'
                 ) as bep_file:
       watcher = bazel_build_events.BazelBuildEventsWatcher(bep_file,
@@ -727,6 +753,12 @@
 
       output_locations.extend(WatcherUpdate(watcher))
 
+      # If BEP JSON parsing failed, we should display the raw stdout and
+      # stderr from Bazel.
+      reader_thread.join()
+      if not watcher.has_read_events():
+        HandleOutput(reader_buffer[0])
+
       if process.returncode == 0 and not output_locations:
         CLEANUP_BEP_FILE_AT_EXIT = False
         _PrintXcodeError('Unable to find location of the .tulsiouts file.'
@@ -1626,15 +1658,13 @@
 
   def _PrintVerbose(self, msg, level=0):
     if self.verbose > level:
-      sys.stdout.write(msg + '\n')
-      sys.stdout.flush()
+      _PrintUnbuffered(msg)
 
 
 def main(argv):
   build_settings = bazel_build_settings.BUILD_SETTINGS
   if build_settings is None:
-    _PrintXcodeError('Unable to resolve build settings. '
-                     'Please report a Tulsi bug.')
+    _Fatal('Unable to resolve build settings. Please report a Tulsi bug.')
     return 1
   return BazelBuildBridge(build_settings).Run(argv)
 
diff --git a/src/TulsiGenerator/Scripts/bazel_build_events.py b/src/TulsiGenerator/Scripts/bazel_build_events.py
index 53722b5..cc6e3c5 100755
--- a/src/TulsiGenerator/Scripts/bazel_build_events.py
+++ b/src/TulsiGenerator/Scripts/bazel_build_events.py
@@ -106,6 +106,10 @@
     """
     self.file_reader = _FileLineReader(json_file)
     self.warning_handler = warning_handler
+    self._read_any_events = False
+
+  def has_read_events(self):
+    return self._read_any_events
 
   def check_for_new_events(self):
     """Checks the file for new BazelBuildEvents.
@@ -126,6 +130,7 @@
           handler('Could not decode BEP event "%s"\n' % line)
           handler('Received error of %s, "%s"\n' % (type(e), e))
         break
+      self._read_any_events = True
       build_event = BazelBuildEvent(build_event_dict)
       new_events.append(build_event)
     return new_events