Symbolize android logcat on the gtest and instrumentation tests

This adds symbolization to the logcat using the android stack tool [1]
on the CQ, impacting gtest and instrumentation tests.
To minimize the impact on runtime, this performs the symbolization
using logcat_monitor's transform_func [2]. The implementation of
stack_symbolizer.py copies the deobfuscator. The use of the pool being
helpful for handling crashes/restarts, and not parallelizing the
processing.
This required to update the stack tool, which was not flushing its
output. Trying to work around that by not necessarily returning lines
in the transform_func led to jumbled logcats.

Alternatively, I tried to process the whole logcat after the tests'
completion, but that slowed significantly the tests, e.g.:
  - content_browsertests almost doubled from a typical 230 minutes
    (11 per shard) to around 430 minutes(20 per shard).
  - android_browsertests from a typical 55 minutes to 70 minutes
Using the transform_func led to similar runtimes than without this
patch.

[1] crsrc.org/c/third_party/android_platform/development/scripts/stack
[2] https://crsrc.org/c/third_party/catapult/devil/devil/android/logcat_monitor.py;drc=dc3e9bf012e68f78efc07dff48cdc023fc130739;l=40
[3] https://luci-logdog.appspot.com/logs/chromium/android/swarming/logcats/6127afedb798ec11/+/logcat_logcat_4331566088169232177_shard18_20230323T142855-UTC_FA7961A07067

Change-Id: Ia00f5e2ec4da739fa7ce6cfa18ee7800cfaa9857
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4370495
Commit-Queue: Pâris Meuleman <[email protected]>
Reviewed-by: Sam Maier <[email protected]>
Auto-Submit: Pâris Meuleman <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1131110}
diff --git a/build/android/apk_operations.pydeps b/build/android/apk_operations.pydeps
index 26f69e89..d20bcf2 100644
--- a/build/android/apk_operations.pydeps
+++ b/build/android/apk_operations.pydeps
@@ -105,6 +105,7 @@
 pylib/constants/host_paths.py
 pylib/symbols/__init__.py
 pylib/symbols/deobfuscator.py
+pylib/symbols/expensive_line_transformer.py
 pylib/utils/__init__.py
 pylib/utils/app_bundle_utils.py
 pylib/utils/simpleperf.py
diff --git a/build/android/pylib/local/device/local_device_gtest_run.py b/build/android/pylib/local/device/local_device_gtest_run.py
index 796f614d..4562399 100644
--- a/build/android/pylib/local/device/local_device_gtest_run.py
+++ b/build/android/pylib/local/device/local_device_gtest_run.py
@@ -31,6 +31,7 @@
 from pylib.local import local_test_server_spawner
 from pylib.local.device import local_device_environment
 from pylib.local.device import local_device_test_run
+from pylib.symbols import stack_symbolizer
 from pylib.utils import google_storage_helper
 from pylib.utils import logdog_helper
 from py_trace_event import trace_event
@@ -772,14 +773,18 @@
     try:
       with self._env.output_manager.ArchivedTempfile(stream_name,
                                                      'logcat') as logcat_file:
-        with logcat_monitor.LogcatMonitor(
-            device.adb,
-            filter_specs=local_device_environment.LOGCAT_FILTERS,
-            output_file=logcat_file.name,
-            check_error=False) as logmon:
-          with contextlib_ext.Optional(trace_event.trace(str(test)),
-                                       self._env.trace_output):
-            yield logcat_file
+        symbolizer = stack_symbolizer.PassThroughSymbolizerPool(
+            device.product_cpu_abi)
+        with symbolizer:
+          with logcat_monitor.LogcatMonitor(
+              device.adb,
+              filter_specs=local_device_environment.LOGCAT_FILTERS,
+              output_file=logcat_file.name,
+              transform_func=symbolizer.TransformLines,
+              check_error=False) as logmon:
+            with contextlib_ext.Optional(trace_event.trace(str(test)),
+                                         self._env.trace_output):
+              yield logcat_file
     finally:
       if logmon:
         logmon.Close()
diff --git a/build/android/pylib/local/device/local_device_instrumentation_test_run.py b/build/android/pylib/local/device/local_device_instrumentation_test_run.py
index 0bbfbe1..6b2af04 100644
--- a/build/android/pylib/local/device/local_device_instrumentation_test_run.py
+++ b/build/android/pylib/local/device/local_device_instrumentation_test_run.py
@@ -41,6 +41,7 @@
 from pylib.local.device import local_device_environment
 from pylib.local.device import local_device_test_run
 from pylib.output import remote_output_manager
+from pylib.symbols import stack_symbolizer
 from pylib.utils import chrome_proxy_utils
 from pylib.utils import gold_utils
 from pylib.utils import instrumentation_tracing
@@ -1208,18 +1209,20 @@
     logcat_file = None
     logmon = None
     try:
-      with self._env.output_manager.ArchivedTempfile(
-          stream_name, 'logcat') as logcat_file:
-        with logcat_monitor.LogcatMonitor(
-            device.adb,
-            filter_specs=local_device_environment.LOGCAT_FILTERS,
-            output_file=logcat_file.name,
-            transform_func=self._test_instance.MaybeDeobfuscateLines,
-            check_error=False) as logmon:
-          with _LogTestEndpoints(device, test_name):
-            with contextlib_ext.Optional(
-                trace_event.trace(test_name),
-                self._env.trace_output):
+      with self._env.output_manager.ArchivedTempfile(stream_name,
+                                                     'logcat') as logcat_file:
+        symbolizer = stack_symbolizer.PassThroughSymbolizerPool(
+            device.product_cpu_abi)
+        with symbolizer:
+          with logcat_monitor.LogcatMonitor(
+              device.adb,
+              filter_specs=local_device_environment.LOGCAT_FILTERS,
+              output_file=logcat_file.name,
+              transform_func=lambda lines: symbolizer.TransformLines(
+                  self._test_instance.MaybeDeobfuscateLines(lines)),
+              check_error=False) as logmon:
+            with contextlib_ext.Optional(trace_event.trace(test_name),
+                                         self._env.trace_output):
               yield logcat_file
     finally:
       if logmon:
diff --git a/build/android/pylib/symbols/deobfuscator.py b/build/android/pylib/symbols/deobfuscator.py
index c694711..e27be9ce 100644
--- a/build/android/pylib/symbols/deobfuscator.py
+++ b/build/android/pylib/symbols/deobfuscator.py
@@ -2,188 +2,48 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
-import logging
 import os
-import subprocess
-import threading
-import time
-import uuid
 
-from devil.utils import reraiser_thread
 from pylib import constants
+from .expensive_line_transformer import ExpensiveLineTransformer
+from .expensive_line_transformer import ExpensiveLineTransformerPool
 
-
-_MINIUMUM_TIMEOUT = 10.0
+_MINIMUM_TIMEOUT = 10.0
 _PER_LINE_TIMEOUT = .005  # Should be able to process 200 lines per second.
 _PROCESS_START_TIMEOUT = 20.0
 _MAX_RESTARTS = 4  # Should be plenty unless tool is crashing on start-up.
+_POOL_SIZE = 4
 
 
-class Deobfuscator:
+class Deobfuscator(ExpensiveLineTransformer):
   def __init__(self, mapping_path):
+    super().__init__(_PROCESS_START_TIMEOUT, _MINIMUM_TIMEOUT,
+                     _PER_LINE_TIMEOUT)
     script_path = os.path.join(constants.DIR_SOURCE_ROOT, 'build', 'android',
                                'stacktrace', 'java_deobfuscate.py')
-    cmd = [script_path, mapping_path]
-    # Allow only one thread to call TransformLines() at a time.
-    self._lock = threading.Lock()
-    # Ensure that only one thread attempts to kill self._proc in Close().
-    self._close_lock = threading.Lock()
-    self._closed_called = False
-    # Assign to None so that attribute exists if Popen() throws.
-    self._proc = None
-    # Start process eagerly to hide start-up latency.
-    self._proc_start_time = time.time()
-    self._proc = subprocess.Popen(cmd,
-                                  bufsize=1,
-                                  stdin=subprocess.PIPE,
-                                  stdout=subprocess.PIPE,
-                                  universal_newlines=True,
-                                  close_fds=True)
+    self._command = [script_path, mapping_path]
+    self.start()
 
-  def IsClosed(self):
-    return self._closed_called or self._proc.returncode is not None
+  @property
+  def name(self):
+    return "deobfuscator"
 
-  def IsBusy(self):
-    return self._lock.locked()
-
-  def IsReady(self):
-    return not self.IsClosed() and not self.IsBusy()
-
-  def TransformLines(self, lines):
-    """Deobfuscates obfuscated names found in the given lines.
-
-    If anything goes wrong (process crashes, timeout, etc), returns |lines|.
-
-    Args:
-      lines: A list of strings without trailing newlines.
-
-    Returns:
-      A list of strings without trailing newlines.
-    """
-    if not lines:
-      return []
-
-    # Deobfuscated stacks contain more frames than obfuscated ones when method
-    # inlining occurs. To account for the extra output lines, keep reading until
-    # this eof_line token is reached.
-    eof_line = uuid.uuid4().hex
-    out_lines = []
-
-    def deobfuscate_reader():
-      while True:
-        line = self._proc.stdout.readline()
-        # Return an empty string at EOF (when stdin is closed).
-        if not line:
-          break
-        line = line[:-1]
-        if line == eof_line:
-          break
-        out_lines.append(line)
-
-    if self.IsBusy():
-      logging.warning('deobfuscator: Having to wait for Java deobfuscation.')
-
-    # Allow only one thread to operate at a time.
-    with self._lock:
-      if self.IsClosed():
-        if not self._closed_called:
-          logging.warning('deobfuscator: Process exited with code=%d.',
-                          self._proc.returncode)
-          self.Close()
-        return lines
-
-      # TODO(agrieve): Can probably speed this up by only sending lines through
-      #     that might contain an obfuscated name.
-      reader_thread = reraiser_thread.ReraiserThread(deobfuscate_reader)
-      reader_thread.start()
-
-      try:
-        self._proc.stdin.write('\n'.join(lines))
-        self._proc.stdin.write('\n{}\n'.format(eof_line))
-        self._proc.stdin.flush()
-        time_since_proc_start = time.time() - self._proc_start_time
-        timeout = (max(0, _PROCESS_START_TIMEOUT - time_since_proc_start) +
-                   max(_MINIUMUM_TIMEOUT, len(lines) * _PER_LINE_TIMEOUT))
-        reader_thread.join(timeout)
-        if self.IsClosed():
-          logging.warning(
-              'deobfuscator: Close() called by another thread during join().')
-          return lines
-        if reader_thread.is_alive():
-          logging.error('deobfuscator: Timed out after %f seconds with input:',
-                        timeout)
-          # We are seeing timeouts but don't know why. Hopefully seeing the
-          # lines that cause timeouts can make it obvious what the deobfuscator
-          # is struggling with.
-          for l in lines:
-            logging.error(l)
-          logging.error('deobfuscator: End of timed out input.')
-          logging.error('deobfuscator: Timed out output was:')
-          for l in out_lines:
-            logging.error(l)
-          logging.error('deobfuscator: End of timed out output.')
-          self.Close()
-          return lines
-        return out_lines
-      except IOError:
-        logging.exception('deobfuscator: Exception during java_deobfuscate')
-        self.Close()
-        return lines
-
-  def Close(self):
-    with self._close_lock:
-      needs_closing = not self.IsClosed()
-      self._closed_called = True
-
-    if needs_closing:
-      self._proc.stdin.close()
-      self._proc.kill()
-      self._proc.wait()
-
-  def __del__(self):
-    # self._proc is None when Popen() fails.
-    if not self._closed_called and self._proc:
-      logging.error('deobfuscator: Forgot to Close()')
-      self.Close()
+  @property
+  def command(self):
+    return self._command
 
 
-class DeobfuscatorPool:
-  # As of Sep 2017, each instance requires about 500MB of RAM, as measured by:
-  # /usr/bin/time -v build/android/stacktrace/java_deobfuscate.py \
-  #     out/Release/apks/ChromePublic.apk.mapping
-  def __init__(self, mapping_path, pool_size=4):
-    self._mapping_path = mapping_path
-    self._pool = [Deobfuscator(mapping_path) for _ in range(pool_size)]
-    # Allow only one thread to select from the pool at a time.
-    self._lock = threading.Lock()
-    self._num_restarts = 0
+class DeobfuscatorPool(ExpensiveLineTransformerPool):
+  def __init__(self, mapping_path):
+    # As of Sep 2017, each instance requires about 500MB of RAM, as measured by:
+    # /usr/bin/time -v build/android/stacktrace/java_deobfuscate.py \
+    #     out/Release/apks/ChromePublic.apk.mapping
+    self.mapping_path = mapping_path
+    super().__init__(_MAX_RESTARTS, _POOL_SIZE)
 
-  def TransformLines(self, lines):
-    with self._lock:
-      assert self._pool, 'TransformLines() called on a closed DeobfuscatorPool.'
+  @property
+  def name(self):
+    return "deobfuscator-pool"
 
-      # De-obfuscation is broken.
-      if self._num_restarts == _MAX_RESTARTS:
-        raise Exception('Deobfuscation seems broken.')
-
-      # Restart any closed Deobfuscators.
-      for i, d in enumerate(self._pool):
-        if d.IsClosed():
-          logging.warning('deobfuscator: Restarting closed instance.')
-          self._pool[i] = Deobfuscator(self._mapping_path)
-          self._num_restarts += 1
-          if self._num_restarts == _MAX_RESTARTS:
-            logging.warning('deobfuscator: MAX_RESTARTS reached.')
-
-      selected = next((x for x in self._pool if x.IsReady()), self._pool[0])
-      # Rotate the order so that next caller will not choose the same one.
-      self._pool.remove(selected)
-      self._pool.append(selected)
-
-    return selected.TransformLines(lines)
-
-  def Close(self):
-    with self._lock:
-      for d in self._pool:
-        d.Close()
-      self._pool = None
+  def CreateTransformer(self):
+    return Deobfuscator(self.mapping_path)
diff --git a/build/android/pylib/symbols/expensive_line_transformer.py b/build/android/pylib/symbols/expensive_line_transformer.py
new file mode 100644
index 0000000..4b07277a
--- /dev/null
+++ b/build/android/pylib/symbols/expensive_line_transformer.py
@@ -0,0 +1,227 @@
+# Copyright 2023 The Chromium Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from abc import ABC, abstractmethod
+import logging
+import subprocess
+import threading
+import time
+import uuid
+
+from devil.utils import reraiser_thread
+
+
+class ExpensiveLineTransformer(ABC):
+  def __init__(self, process_start_timeout, minimum_timeout, per_line_timeout):
+    self._process_start_timeout = process_start_timeout
+    self._minimum_timeout = minimum_timeout
+    self._per_line_timeout = per_line_timeout
+    self._started = False
+    # Allow only one thread to call TransformLines() at a time.
+    self._lock = threading.Lock()
+    # Ensure that only one thread attempts to kill self._proc in Close().
+    self._close_lock = threading.Lock()
+    self._closed_called = False
+    # Assign to None so that attribute exists if Popen() throws.
+    self._proc = None
+    # Start process eagerly to hide start-up latency.
+    self._proc_start_time = None
+
+  def start(self):
+    # delay the start of the process, to allow the initialization of the
+    # descendant classes first.
+    if self._started:
+      logging.error('%s: Trying to start an already started command', self.name)
+      return
+
+    # Start process eagerly to hide start-up latency.
+    self._proc_start_time = time.time()
+
+    if not self.command:
+      logging.error('%s: No command available', self.name)
+      return
+
+    self._proc = subprocess.Popen(self.command,
+                                  bufsize=1,
+                                  stdin=subprocess.PIPE,
+                                  stdout=subprocess.PIPE,
+                                  universal_newlines=True,
+                                  close_fds=True)
+    self._started = True
+
+  def IsClosed(self):
+    return (not self._started or self._closed_called
+            or self._proc.returncode is not None)
+
+  def IsBusy(self):
+    return self._lock.locked()
+
+  def IsReady(self):
+    return self._started and not self.IsClosed() and not self.IsBusy()
+
+  def TransformLines(self, lines):
+    """Symbolizes names found in the given lines.
+
+    If anything goes wrong (process crashes, timeout, etc), returns |lines|.
+
+    Args:
+      lines: A list of strings without trailing newlines.
+
+    Returns:
+      A list of strings without trailing newlines.
+    """
+    if not lines:
+      return []
+
+    # symbolized output contain more lines than the input, as the symbolized
+    # stacktraces will be added. To account for the extra output lines, keep
+    # reading until this eof_line token is reached. Using a format that will
+    # be considered a "useful line" without modifying its output by
+    # third_party/android_platform/development/scripts/stack_core.py
+    eof_line = self.getEofLine()
+    out_lines = []
+
+    def _reader():
+      while True:
+        line = self._proc.stdout.readline()
+        # Return an empty string at EOF (when stdin is closed).
+        if not line:
+          break
+        line = line[:-1]
+        if line == eof_line:
+          break
+        out_lines.append(line)
+
+    if self.IsBusy():
+      logging.warning('%s: Having to wait for transformation.', self.name)
+
+    # Allow only one thread to operate at a time.
+    with self._lock:
+      if self.IsClosed():
+        if self._started and not self._closed_called:
+          logging.warning('%s: Process exited with code=%d.', self.name,
+                          self._proc.returncode)
+          self.Close()
+        return lines
+
+      reader_thread = reraiser_thread.ReraiserThread(_reader)
+      reader_thread.start()
+
+      try:
+        self._proc.stdin.write('\n'.join(lines))
+        self._proc.stdin.write('\n{}\n'.format(eof_line))
+        self._proc.stdin.flush()
+        time_since_proc_start = time.time() - self._proc_start_time
+        timeout = (max(0, self._process_start_timeout - time_since_proc_start) +
+                   max(self._minimum_timeout,
+                       len(lines) * self._per_line_timeout))
+        reader_thread.join(timeout)
+        if self.IsClosed():
+          logging.warning('%s: Close() called by another thread during join().',
+                          self.name)
+          return lines
+        if reader_thread.is_alive():
+          logging.error('%s: Timed out after %f seconds with input:', self.name,
+                        timeout)
+          for l in lines:
+            logging.error(l)
+          logging.error(eof_line)
+          logging.error('%s: End of timed out input.', self.name)
+          logging.error('%s: Timed out output was:', self.name)
+          for l in out_lines:
+            logging.error(l)
+          logging.error('%s: End of timed out output.', self.name)
+          self.Close()
+          return lines
+        return out_lines
+      except IOError:
+        logging.exception('%s: Exception during transformation', self.name)
+        self.Close()
+        return lines
+
+  def Close(self):
+    with self._close_lock:
+      needs_closing = not self.IsClosed()
+      self._closed_called = True
+
+    if needs_closing:
+      self._proc.stdin.close()
+      self._proc.kill()
+      self._proc.wait()
+
+  def __del__(self):
+    # self._proc is None when Popen() fails.
+    if not self._closed_called and self._proc:
+      logging.error('%s: Forgot to Close()', self.name)
+      self.Close()
+
+  @property
+  @abstractmethod
+  def name(self):
+    ...
+
+  @property
+  @abstractmethod
+  def command(self):
+    ...
+
+  @staticmethod
+  def getEofLine():
+    # Use a format that will be considered a "useful line" without modifying its
+    # output by third_party/android_platform/development/scripts/stack_core.py
+    return "Generic useful log header: \'{}\'".format(uuid.uuid4().hex)
+
+
+class ExpensiveLineTransformerPool(ABC):
+  def __init__(self, max_restarts, pool_size):
+    self._max_restarts = max_restarts
+    self._pool = [self.CreateTransformer() for _ in range(pool_size)]
+    # Allow only one thread to select from the pool at a time.
+    self._lock = threading.Lock()
+    self._num_restarts = 0
+
+  def __enter__(self):
+    pass
+
+  def __exit__(self, *args):
+    self.Close()
+
+  def TransformLines(self, lines):
+    with self._lock:
+      assert self._pool, 'TransformLines() called on a closed Pool.'
+
+      # transformation is broken.
+      if self._num_restarts == self._max_restarts:
+        raise Exception('%s is broken.' % self.name)
+
+      # Restart any closed transformer.
+      for i, d in enumerate(self._pool):
+        if d.IsClosed():
+          logging.warning('%s: Restarting closed instance.', self.name)
+          self._pool[i] = self.CreateTransformer()
+          self._num_restarts += 1
+          if self._num_restarts == self._max_restarts:
+            logging.warning('%s: MAX_RESTARTS reached.', self.name)
+
+      selected = next((x for x in self._pool if x.IsReady()), self._pool[0])
+      # Rotate the order so that next caller will not choose the same one.
+      self._pool.remove(selected)
+      self._pool.append(selected)
+
+    return selected.TransformLines(lines)
+
+  def Close(self):
+    with self._lock:
+      for d in self._pool:
+        d.Close()
+      self._pool = None
+
+  @abstractmethod
+  def CreateTransformer(self):
+    ...
+
+  @property
+  @abstractmethod
+  def name(self):
+    ...
diff --git a/build/android/pylib/symbols/stack_symbolizer.py b/build/android/pylib/symbols/stack_symbolizer.py
index e6183bc..d1cc721 100644
--- a/build/android/pylib/symbols/stack_symbolizer.py
+++ b/build/android/pylib/symbols/stack_symbolizer.py
@@ -10,10 +10,17 @@
 
 from devil.utils import cmd_helper
 from pylib import constants
+from pylib.constants import host_paths
+from .expensive_line_transformer import ExpensiveLineTransformer
+from .expensive_line_transformer import ExpensiveLineTransformerPool
 
-_STACK_TOOL = os.path.join(os.path.dirname(__file__), '..', '..', '..', '..',
-                          'third_party', 'android_platform', 'development',
-                          'scripts', 'stack')
+_STACK_TOOL = os.path.join(host_paths.ANDROID_PLATFORM_DEVELOPMENT_SCRIPTS_PATH,
+                           'stack')
+_MINIMUM_TIMEOUT = 10.0
+_PER_LINE_TIMEOUT = .005  # Should be able to process 200 lines per second.
+_PROCESS_START_TIMEOUT = 20.0
+_MAX_RESTARTS = 4  # Should be plenty unless tool is crashing on start-up.
+_POOL_SIZE = 1
 ABI_REG = re.compile('ABI: \'(.+?)\'')
 
 
@@ -84,3 +91,46 @@
       if not include_stack and 'Stack Data:' in line:
         break
       yield line
+
+
+class PassThroughSymbolizer(ExpensiveLineTransformer):
+  def __init__(self, device_abi):
+    self._command = None
+    super().__init__(_PROCESS_START_TIMEOUT, _MINIMUM_TIMEOUT,
+                     _PER_LINE_TIMEOUT)
+    if not os.path.exists(_STACK_TOOL):
+      logging.warning('%s: %s missing. Unable to resolve native stack traces.',
+                      PassThroughSymbolizer.name, _STACK_TOOL)
+      return
+    arch = _DeviceAbiToArch(device_abi)
+    if not arch:
+      logging.warning('%s: No device_abi can be found.',
+                      PassThroughSymbolizer.name)
+      return
+    self._command = [
+        _STACK_TOOL, '--arch', arch, '--output-directory',
+        constants.GetOutDirectory(), '--more-info', '--pass-through', '--flush',
+        '--quiet', '-'
+    ]
+    self.start()
+
+  @property
+  def name(self):
+    return "symbolizer"
+
+  @property
+  def command(self):
+    return self._command
+
+
+class PassThroughSymbolizerPool(ExpensiveLineTransformerPool):
+  def __init__(self, device_abi):
+    self._device_abi = device_abi
+    super().__init__(_MAX_RESTARTS, _POOL_SIZE)
+
+  def CreateTransformer(self):
+    return PassThroughSymbolizer(self._device_abi)
+
+  @property
+  def name(self):
+    return "symbolizer-pool"
diff --git a/build/android/test_runner.pydeps b/build/android/test_runner.pydeps
index f81b0d49..5c1cd13 100644
--- a/build/android/test_runner.pydeps
+++ b/build/android/test_runner.pydeps
@@ -209,6 +209,7 @@
 pylib/results/report_results.py
 pylib/symbols/__init__.py
 pylib/symbols/deobfuscator.py
+pylib/symbols/expensive_line_transformer.py
 pylib/symbols/stack_symbolizer.py
 pylib/utils/__init__.py
 pylib/utils/chrome_proxy_utils.py
diff --git a/third_party/android_platform/README.chromium b/third_party/android_platform/README.chromium
index bf7bf9f7..7fee3ad 100644
--- a/third_party/android_platform/README.chromium
+++ b/third_party/android_platform/README.chromium
@@ -47,3 +47,10 @@
 
 Made the symbolizer agnostic to the width of pointers.
 Clamping the padding on symbolized names to 80 columns.
+
+Added an option to the symbolizer passthrough mode to flush the output regularly
+    which simplifies its intereaction with logcat_monitor's transform_func [1]
+    within the test runners [2] and [3].
+    [1] https://crsrc.org/c/third_party/catapult/devil/devil/android/logcat_monitor.py;drc=dc3e9bf012e68f78efc07dff48cdc023fc130739;l=40
+    [2] https://source.chromium.org/chromium/chromium/src/+/main:build/android/pylib/local/device/local_device_instrumentation_test_run.py?q=local_device_instrumentation_test#:~:text=def%20_ArchiveLogcat(self%2C%20device%2C%20test_name)%3A
+    [3] https://source.chromium.org/chromium/chromium/src/+/main:build/android/pylib/local/device/local_device_gtest_run.py#:~:text=def%20_ArchiveLogcat(self%2C%20device%2C%20test)%3A
\ No newline at end of file
diff --git a/third_party/android_platform/development/scripts/stack.py b/third_party/android_platform/development/scripts/stack.py
index 276bf1ba..6765a550 100755
--- a/third_party/android_platform/development/scripts/stack.py
+++ b/third_party/android_platform/development/scripts/stack.py
@@ -135,6 +135,7 @@
   try:
     options, arguments = getopt.getopt(argv, "p", [
         "pass-through",
+        "flush",
         "more-info",
         "less-info",
         "chrome-symbols-dir=",
@@ -152,6 +153,7 @@
     PrintUsage()
 
   pass_through = False
+  flush = False
   zip_arg = None
   more_info = False
   fallback_so_file = None
@@ -165,6 +167,8 @@
       pass_through = True
     elif option == "-p":
       pass_through = True
+    elif option == "--flush":
+      flush = True
     elif option == "--symbols-dir":
       symbol.SYMBOLS_DIR = os.path.abspath(os.path.expanduser(value))
     elif option == "--symbols-zip":
@@ -218,7 +222,8 @@
     with llvm_symbolizer.LLVMSymbolizer() as symbolizer:
       stack_core.StreamingConvertTrace(sys.stdin, {}, more_info,
                                        fallback_so_file, arch_defined,
-                                       symbolizer, apks_directory, pass_through)
+                                       symbolizer, apks_directory, pass_through,
+                                       flush)
   else:
     logging.info('Searching for native crashes in: %s',
                  os.path.realpath(arguments[0]))
diff --git a/third_party/android_platform/development/scripts/stack_core.py b/third_party/android_platform/development/scripts/stack_core.py
index 545fcf4..dd96e83 100755
--- a/third_party/android_platform/development/scripts/stack_core.py
+++ b/third_party/android_platform/development/scripts/stack_core.py
@@ -53,6 +53,9 @@
 _JAVA_STDERR_LINE = re.compile("([0-9]+)\s+[0-9]+\s+.\s+System.err:\s*(.+)")
 _MISC_HEADER = re.compile(
     '(?:Tombstone written to:|Abort message:|Revision:|Build fingerprint:).*')
+# A header used by tooling to mark a line that should be considered useful.
+# e.g. build/android/pylib/symbols/expensive_line_transformer.py
+_GENERIC_USEFUL_LOG_HEADER = re.compile('Generic useful log header: .*')
 
 # Matches LOG(FATAL) lines, like the following example:
 #   [FATAL:source_file.cc(33)] Check failed: !instances_.empty()
@@ -165,7 +168,7 @@
 
 def StreamingConvertTrace(_, load_vaddrs, more_info, fallback_so_file,
                           arch_defined, llvm_symbolizer, apks_directory,
-                          pass_through):
+                          pass_through, flush):
   """Symbolize stacks on the fly as they are read from an input stream."""
 
   if fallback_so_file:
@@ -192,12 +195,16 @@
       break
     if pass_through:
       sys.stdout.write(line)
+      if flush:
+        sys.stdout.flush()
     maybe_line, maybe_so_dir = preprocessor([line])
     useful_lines.extend(maybe_line)
     so_dirs.extend(maybe_so_dir)
     if in_stack:
       if not maybe_line:
         ConvertStreamingChunk()
+        if flush:
+          sys.stdout.flush()
         so_dirs = []
         useful_lines = []
         in_stack = False
@@ -340,7 +347,8 @@
           or _DEBUG_TRACE_LINE.search(line)
           or _ABI_LINE.search(line)
           or _JAVA_STDERR_LINE.search(line)
-          or _MISC_HEADER.search(line)):
+          or _MISC_HEADER.search(line)
+          or _GENERIC_USEFUL_LOG_HEADER.search(line)):
         useful_log.append(line)
         continue