Don't pickle TestRunner

Avoids pickling TestRunner by separating state used to run individual
tests from state used to orchestrate the test suite as a whole.

This change has a relatively large delta due to moving code from the
TestRunner class to the _PerProcessState class, but most of the changes
focus on moving properties and initialization code from TestRunner to
_PerProcessConfig or _PerProcessState, along with necessary adjustments
to any call sites in TestRunner.

Bug: pdfium:1641
Change-Id: Ic648f1e72c2bf0f591c2e8c8eff4047b87d0df46
Reviewed-on: https://pdfium-review.googlesource.com/c/pdfium/+/99610
Commit-Queue: K. Moon <kmoon@chromium.org>
Reviewed-by: Lei Zhang <thestig@chromium.org>
diff --git a/testing/tools/test_runner.py b/testing/tools/test_runner.py
index 08e0783..7251174 100644
--- a/testing/tools/test_runner.py
+++ b/testing/tools/test_runner.py
@@ -35,26 +35,7 @@
 
 
 class KeyboardInterruptError(Exception):
-  pass
-
-
-# This is a class, rather than a closure, to make it picklable.
-class _WrapKeyboardInterrupt:
-  """Wraps `KeyboardInterrupt` thrown from a callable.
-
-  This wrapper prevents `KeyboardInterrupt` from escaping the wrapped callable,
-  by wrapping the exception in a custom `KeyboardInterruptError` exception
-  instead.
-  """
-
-  def __init__(self, wrapped):
-    self.wrapped = wrapped
-
-  def __call__(self, *args):
-    try:
-      return self.wrapped(*args)
-    except KeyboardInterrupt as exc:
-      raise KeyboardInterruptError() from exc
+  """Custom exception used to wrap `KeyboardInterrupt` exceptions."""
 
 
 def DeleteFiles(files):
@@ -64,7 +45,6 @@
       os.remove(f)
 
 
-# TODO(crbug.com/pdfium/1641): Don't pickle TestRunner.
 class TestRunner:
 
   def __init__(self, dirname):
@@ -72,10 +52,427 @@
     # which all correspond directly to the type for the test being run. In the
     # future if there are tests that don't have this clean correspondence, then
     # an argument for the type will need to be added.
-    self.test_dir = dirname
-    self.test_type = dirname
-    self.delete_output_on_success = False
-    self.enforce_expected_images = False
+    self.per_process_config = _PerProcessConfig(
+        test_dir=dirname, test_type=dirname)
+
+  @property
+  def options(self):
+    return self.per_process_config.options
+
+  def IsSkiaGoldEnabled(self):
+    return (self.options.run_skia_gold and
+            not self.per_process_config.test_type in TEXT_TESTS)
+
+  def IsExecutionSuppressed(self, input_path):
+    return self.per_process_state.test_suppressor.IsExecutionSuppressed(
+        input_path)
+
+  def IsResultSuppressed(self, input_filename):
+    return self.per_process_state.test_suppressor.IsResultSuppressed(
+        input_filename)
+
+  def HandleResult(self, test_case, test_result):
+    input_filename = os.path.basename(test_case.input_path)
+
+    if self.IsResultSuppressed(input_filename):
+      self.result_suppressed_cases.append(input_filename)
+      if test_result.IsPass():
+        self.surprises.append(test_case.input_path)
+
+        # There isn't an actual status for succeeded-but-ignored, so use the
+        # "abort" status to differentiate this from failed-but-ignored.
+        #
+        # Note that this appears as a preliminary failure in Gerrit.
+        result_status = result_types.UNKNOWN
+      else:
+        # There isn't an actual status for failed-but-ignored, so use the
+        # "skip" status to differentiate this from succeeded-but-ignored.
+        result_status = result_types.SKIP
+    else:
+      if test_result.IsPass():
+        result_status = result_types.PASS
+      else:
+        self.failures.append(test_case.input_path)
+        result_status = result_types.FAIL
+
+    if self.resultdb:
+      # TODO(crbug.com/pdfium/1916): Populate more ResultDB fields.
+      self.resultdb.Post(
+          test_id=test_result.test_id,
+          status=result_status,
+          duration=None,
+          test_log=None,
+          test_file=None)
+
+  def Run(self):
+    # Running a test defines a number of attributes on the fly.
+    # pylint: disable=attribute-defined-outside-init
+
+    relative_test_dir = self.per_process_config.test_dir
+    if relative_test_dir != 'corpus':
+      relative_test_dir = os.path.join('resources', relative_test_dir)
+
+    parser = argparse.ArgumentParser()
+
+    parser.add_argument(
+        '--build-dir',
+        default=os.path.join('out', 'Debug'),
+        help='relative path from the base source directory')
+
+    parser.add_argument(
+        '-j',
+        default=multiprocessing.cpu_count(),
+        dest='num_workers',
+        type=int,
+        help='run NUM_WORKERS jobs in parallel')
+
+    parser.add_argument(
+        '--disable-javascript',
+        action="store_true",
+        dest="disable_javascript",
+        help='Prevents JavaScript from executing in PDF files.')
+
+    parser.add_argument(
+        '--disable-xfa',
+        action="store_true",
+        dest="disable_xfa",
+        help='Prevents processing XFA forms.')
+
+    parser.add_argument(
+        '--render-oneshot',
+        action="store_true",
+        dest="render_oneshot",
+        help='Sets whether to use the oneshot renderer.')
+
+    parser.add_argument(
+        '--run-skia-gold',
+        action='store_true',
+        default=False,
+        help='When flag is on, skia gold tests will be run.')
+
+    # TODO: Remove when pdfium recipe stops passing this argument
+    parser.add_argument(
+        '--gold_properties',
+        default='',
+        dest="gold_properties",
+        help='Key value pairs that are written to the top level '
+        'of the JSON file that is ingested by Gold.')
+
+    # TODO: Remove when pdfium recipe stops passing this argument
+    parser.add_argument(
+        '--gold_ignore_hashes',
+        default='',
+        dest="gold_ignore_hashes",
+        help='Path to a file with MD5 hashes we wish to ignore.')
+
+    parser.add_argument(
+        '--regenerate_expected',
+        default='',
+        dest="regenerate_expected",
+        help='Regenerates expected images. Valid values are '
+        '"all" to regenerate all expected pngs, and '
+        '"platform" to regenerate only platform-specific '
+        'expected pngs.')
+
+    parser.add_argument(
+        '--reverse-byte-order',
+        action='store_true',
+        dest="reverse_byte_order",
+        help='Run image-based tests using --reverse-byte-order.')
+
+    parser.add_argument(
+        '--ignore_errors',
+        action="store_true",
+        dest="ignore_errors",
+        help='Prevents the return value from being non-zero '
+        'when image comparison fails.')
+
+    parser.add_argument(
+        'inputted_file_paths',
+        nargs='*',
+        help='Path to test files to run, relative to '
+        f'testing/{relative_test_dir}. If omitted, runs all test files under '
+        f'testing/{relative_test_dir}.',
+        metavar='relative/test/path')
+
+    skia_gold.add_skia_gold_args(parser)
+
+    self.per_process_config.options = parser.parse_args()
+
+    if (self.options.regenerate_expected and
+        self.options.regenerate_expected not in ['all', 'platform']):
+      print('FAILURE: --regenerate_expected must be "all" or "platform"')
+      return 1
+
+    finder = self.per_process_config.NewFinder()
+    pdfium_test_path = self.per_process_config.GetPdfiumTestPath(finder)
+    if not os.path.exists(pdfium_test_path):
+      print(f"FAILURE: Can't find test executable '{pdfium_test_path}'")
+      print('Use --build-dir to specify its location.')
+      return 1
+    self.per_process_config.InitializeFeatures(pdfium_test_path)
+
+    self.per_process_state = _PerProcessState(self.per_process_config)
+    shutil.rmtree(self.per_process_state.working_dir, ignore_errors=True)
+    os.makedirs(self.per_process_state.working_dir)
+
+    error_message = self.per_process_state.image_differ.CheckMissingTools(
+        self.options.regenerate_expected)
+    if error_message:
+      print('FAILURE:', error_message)
+      return 1
+
+    self.resultdb = result_sink.TryInitClient()
+    if self.resultdb:
+      print('Detected ResultSink environment')
+
+    # Collect test cases.
+    walk_from_dir = finder.TestingDir(relative_test_dir)
+
+    self.test_cases = TestCaseManager()
+    self.execution_suppressed_cases = []
+    input_file_re = re.compile('^.+[.](in|pdf)$')
+    if self.options.inputted_file_paths:
+      for file_name in self.options.inputted_file_paths:
+        input_path = os.path.join(walk_from_dir, file_name)
+        if not os.path.isfile(input_path):
+          print(f"Can't find test file '{file_name}'")
+          return 1
+
+        self.test_cases.NewTestCase(input_path)
+    else:
+      for file_dir, _, filename_list in os.walk(walk_from_dir):
+        for input_filename in filename_list:
+          if input_file_re.match(input_filename):
+            input_path = os.path.join(file_dir, input_filename)
+            if self.IsExecutionSuppressed(input_path):
+              self.execution_suppressed_cases.append(input_path)
+              continue
+            if not os.path.isfile(input_path):
+              continue
+
+            self.test_cases.NewTestCase(input_path)
+
+    # Execute test cases.
+    self.failures = []
+    self.surprises = []
+    self.skia_gold_successes = []
+    self.skia_gold_unexpected_successes = []
+    self.skia_gold_failures = []
+    self.result_suppressed_cases = []
+
+    if self.IsSkiaGoldEnabled():
+      assert self.options.gold_output_dir
+      # Clear out and create top level gold output directory before starting
+      skia_gold.clear_gold_output_dir(self.options.gold_output_dir)
+
+    with multiprocessing.Pool(
+        processes=self.options.num_workers,
+        initializer=_InitializePerProcessState,
+        initargs=[self.per_process_config]) as pool:
+      skia_gold_test_cases = TestCaseManager()
+      for result in pool.imap(_RunPdfiumTest, self.test_cases):
+        self.HandleResult(self.test_cases.GetTestCase(result.test_id), result)
+
+        if self.IsSkiaGoldEnabled():
+          for artifact in result.image_artifacts:
+            # The output filename without image extension becomes the test ID.
+            # For example, "/path/to/.../testing/corpus/example_005.pdf.0.png"
+            # becomes "example_005.pdf.0".
+            skia_gold_test_cases.NewTestCase(artifact.image_path)
+
+      for result in pool.imap(_RunSkiaTest, skia_gold_test_cases):
+        if result.IsPass():
+          test_case = skia_gold_test_cases.GetTestCase(result.test_id)
+          if self.IsResultSuppressed(test_case.input_path):
+            self.skia_gold_unexpected_successes.append(result.test_id)
+          else:
+            self.skia_gold_successes.append(result.test_id)
+        else:
+          self.skia_gold_failures.append(result.test_id)
+
+    # Report test results.
+    #
+    # For some reason, summary will be cut off from stdout on windows if
+    # _PrintSummary() is called at the end
+    # TODO(crbug.com/pdfium/1657): Once resolved, move _PrintSummary() back
+    # down to the end
+    self._PrintSummary()
+
+    if self.surprises:
+      self.surprises.sort()
+      print('\nUnexpected Successes:')
+      for surprise in self.surprises:
+        print(surprise)
+
+    if self.failures:
+      self.failures.sort()
+      print('\nSummary of Failures:')
+      for failure in self.failures:
+        print(failure)
+
+    if self.skia_gold_unexpected_successes:
+      self.skia_gold_unexpected_successes.sort()
+      print('\nUnexpected Skia Gold Successes:')
+      for surprise in self.skia_gold_unexpected_successes:
+        print(surprise)
+
+    if self.skia_gold_failures:
+      self.skia_gold_failures.sort()
+      print('\nSummary of Skia Gold Failures:')
+      for failure in self.skia_gold_failures:
+        print(failure)
+
+    if self.failures:
+      if not self.options.ignore_errors:
+        return 1
+
+    return 0
+
+  def _PrintSummary(self):
+    number_test_cases = len(self.test_cases)
+    number_failures = len(self.failures)
+    number_suppressed = len(self.result_suppressed_cases)
+    number_successes = number_test_cases - number_failures - number_suppressed
+    number_surprises = len(self.surprises)
+    print('\nTest cases executed:', number_test_cases)
+    print('  Successes:', number_successes)
+    print('  Suppressed:', number_suppressed)
+    print('  Surprises:', number_surprises)
+    print('  Failures:', number_failures)
+    if self.IsSkiaGoldEnabled():
+      number_gold_failures = len(self.skia_gold_failures)
+      number_gold_successes = len(self.skia_gold_successes)
+      number_gold_surprises = len(self.skia_gold_unexpected_successes)
+      number_total_gold_tests = sum(
+          [number_gold_failures, number_gold_successes, number_gold_surprises])
+      print('\nSkia Gold Test cases executed:', number_total_gold_tests)
+      print('  Skia Gold Successes:', number_gold_successes)
+      print('  Skia Gold Surprises:', number_gold_surprises)
+      print('  Skia Gold Failures:', number_gold_failures)
+      skia_tester = self.per_process_state.GetSkiaGoldTester()
+      if self.skia_gold_failures and skia_tester.IsTryjobRun():
+        cl_triage_link = skia_tester.GetCLTriageLink()
+        print('  Triage link for CL:', cl_triage_link)
+        skia_tester.WriteCLTriageLink(cl_triage_link)
+    print()
+    print('Test cases not executed:', len(self.execution_suppressed_cases))
+
+  def SetDeleteOutputOnSuccess(self, new_value):
+    """Set whether to delete generated output if the test passes."""
+    self.per_process_config.delete_output_on_success = new_value
+
+  def SetEnforceExpectedImages(self, new_value):
+    """Set whether to enforce that each test case provide an expected image."""
+    self.per_process_config.enforce_expected_images = new_value
+
+
+def _RunPdfiumTest(test_case):
+  """Runs a PDFium test case."""
+  try:
+    return _per_process_state.GenerateAndTest(test_case)
+  except KeyboardInterrupt as exc:
+    raise KeyboardInterruptError() from exc
+
+
+def _RunSkiaTest(test_case):
+  """Runs a Skia Gold test case."""
+  try:
+    skia_tester = _per_process_state.GetSkiaGoldTester()
+    skia_success = skia_tester.UploadTestResultToSkiaGold(
+        test_case.test_id, test_case.input_path)
+    sys.stdout.flush()
+
+    return test_case.NewResult(
+        result_types.PASS if skia_success else result_types.FAIL)
+  except KeyboardInterrupt as exc:
+    raise KeyboardInterruptError() from exc
+
+
+# `_PerProcessState` singleton. This is initialized when creating the
+# `multiprocessing.Pool()`. `TestRunner.Run()` creates its own separate
+# instance of `_PerProcessState` as well.
+_per_process_state = None
+
+
+def _InitializePerProcessState(config):
+  """Initializes the `_per_process_state` singleton."""
+  global _per_process_state
+  assert not _per_process_state
+  _per_process_state = _PerProcessState(config)
+
+
+@dataclass
+class _PerProcessConfig:
+  """Configuration for initializing `_PerProcessState`.
+
+  Attributes:
+    test_dir: The name of the test directory.
+    test_type: The test type.
+    delete_output_on_success: Whether to delete output on success.
+    enforce_expected_images: Whether to enforce expected images.
+    options: The dictionary of command line options.
+    features: The list of features supported by `pdfium_test`.
+  """
+  test_dir: str
+  test_type: str
+  delete_output_on_success: bool = False
+  enforce_expected_images: bool = False
+  options: dict = None
+  features: list = None
+
+  def NewFinder(self):
+    return common.DirectoryFinder(self.options.build_dir)
+
+  def GetPdfiumTestPath(self, finder):
+    return finder.ExecutablePath('pdfium_test')
+
+  def InitializeFeatures(self, pdfium_test_path):
+    output = subprocess.check_output([pdfium_test_path, '--show-config'])
+    self.features = output.decode('utf-8').strip().split(',')
+
+
+class _PerProcessState:
+  """State defined per process."""
+
+  def __init__(self, config):
+    self.test_dir = config.test_dir
+    self.test_type = config.test_type
+    self.delete_output_on_success = config.delete_output_on_success
+    self.enforce_expected_images = config.enforce_expected_images
+    self.options = config.options
+    self.features = config.features
+
+    finder = config.NewFinder()
+    self.pdfium_test_path = config.GetPdfiumTestPath(finder)
+    self.fixup_path = finder.ScriptPath('fixup_pdf_template.py')
+    self.text_diff_path = finder.ScriptPath('text_diff.py')
+    self.font_dir = os.path.join(finder.TestingDir(), 'resources', 'fonts')
+    self.third_party_font_dir = finder.ThirdPartyFontsDir()
+
+    self.source_dir = finder.TestingDir()
+    self.working_dir = finder.WorkingDir(os.path.join('testing', self.test_dir))
+
+    self.test_suppressor = suppressor.Suppressor(
+        finder, self.features, self.options.disable_javascript,
+        self.options.disable_xfa)
+    self.image_differ = pngdiffer.PNGDiffer(finder, self.features,
+                                            self.options.reverse_byte_order)
+
+    self.process_name = multiprocessing.current_process().name
+    self.skia_tester = None
+
+  def __getstate__(self):
+    raise RuntimeError('Cannot pickle per-process state')
+
+  def GetSkiaGoldTester(self):
+    """Gets the `SkiaGoldTester` singleton for this worker."""
+    if not self.skia_tester:
+      self.skia_tester = skia_gold.SkiaGoldTester(
+          source_type=self.test_type,
+          skia_gold_args=self.options,
+          process_name=self.process_name)
+    return self.skia_tester
 
   def GenerateAndTest(self, test_case):
     """Generate test input and run pdfium_test."""
@@ -245,368 +642,6 @@
       ]
     return raised_exception, results
 
-  def HandleResult(self, test_case, test_result):
-    input_filename = os.path.basename(test_case.input_path)
-
-    if self.test_suppressor.IsResultSuppressed(input_filename):
-      self.result_suppressed_cases.append(input_filename)
-      if test_result.IsPass():
-        self.surprises.append(test_case.input_path)
-
-        # There isn't an actual status for succeeded-but-ignored, so use the
-        # "abort" status to differentiate this from failed-but-ignored.
-        #
-        # Note that this appears as a preliminary failure in Gerrit.
-        result_status = result_types.UNKNOWN
-      else:
-        # There isn't an actual status for failed-but-ignored, so use the
-        # "skip" status to differentiate this from succeeded-but-ignored.
-        result_status = result_types.SKIP
-    else:
-      if test_result.IsPass():
-        result_status = result_types.PASS
-      else:
-        self.failures.append(test_case.input_path)
-        result_status = result_types.FAIL
-
-    if self.resultdb:
-      # TODO(crbug.com/pdfium/1916): Populate more ResultDB fields.
-      self.resultdb.Post(
-          test_id=test_result.test_id,
-          status=result_status,
-          duration=None,
-          test_log=None,
-          test_file=None)
-
-  def Run(self):
-    # Running a test defines a number of attributes on the fly.
-    # pylint: disable=attribute-defined-outside-init
-
-    if self.test_dir == 'corpus':
-      relative_test_dir = self.test_dir
-    else:
-      relative_test_dir = os.path.join('resources', self.test_dir)
-
-    parser = argparse.ArgumentParser()
-
-    parser.add_argument(
-        '--build-dir',
-        default=os.path.join('out', 'Debug'),
-        help='relative path from the base source directory')
-
-    parser.add_argument(
-        '-j',
-        default=multiprocessing.cpu_count(),
-        dest='num_workers',
-        type=int,
-        help='run NUM_WORKERS jobs in parallel')
-
-    parser.add_argument(
-        '--disable-javascript',
-        action="store_true",
-        dest="disable_javascript",
-        help='Prevents JavaScript from executing in PDF files.')
-
-    parser.add_argument(
-        '--disable-xfa',
-        action="store_true",
-        dest="disable_xfa",
-        help='Prevents processing XFA forms.')
-
-    parser.add_argument(
-        '--render-oneshot',
-        action="store_true",
-        dest="render_oneshot",
-        help='Sets whether to use the oneshot renderer.')
-
-    parser.add_argument(
-        '--run-skia-gold',
-        action='store_true',
-        default=False,
-        help='When flag is on, skia gold tests will be run.')
-
-    # TODO: Remove when pdfium recipe stops passing this argument
-    parser.add_argument(
-        '--gold_properties',
-        default='',
-        dest="gold_properties",
-        help='Key value pairs that are written to the top level '
-        'of the JSON file that is ingested by Gold.')
-
-    # TODO: Remove when pdfium recipe stops passing this argument
-    parser.add_argument(
-        '--gold_ignore_hashes',
-        default='',
-        dest="gold_ignore_hashes",
-        help='Path to a file with MD5 hashes we wish to ignore.')
-
-    parser.add_argument(
-        '--regenerate_expected',
-        default='',
-        dest="regenerate_expected",
-        help='Regenerates expected images. Valid values are '
-        '"all" to regenerate all expected pngs, and '
-        '"platform" to regenerate only platform-specific '
-        'expected pngs.')
-
-    parser.add_argument(
-        '--reverse-byte-order',
-        action='store_true',
-        dest="reverse_byte_order",
-        help='Run image-based tests using --reverse-byte-order.')
-
-    parser.add_argument(
-        '--ignore_errors',
-        action="store_true",
-        dest="ignore_errors",
-        help='Prevents the return value from being non-zero '
-        'when image comparison fails.')
-
-    parser.add_argument(
-        'inputted_file_paths',
-        nargs='*',
-        help='Path to test files to run, relative to '
-        f'testing/{relative_test_dir}. If omitted, runs all test files under '
-        f'testing/{relative_test_dir}.',
-        metavar='relative/test/path')
-
-    skia_gold.add_skia_gold_args(parser)
-
-    self.options = parser.parse_args()
-
-    if (self.options.regenerate_expected and
-        self.options.regenerate_expected not in ['all', 'platform']):
-      print('FAILURE: --regenerate_expected must be "all" or "platform"')
-      return 1
-
-    finder = common.DirectoryFinder(self.options.build_dir)
-    self.fixup_path = finder.ScriptPath('fixup_pdf_template.py')
-    self.text_diff_path = finder.ScriptPath('text_diff.py')
-    self.font_dir = os.path.join(finder.TestingDir(), 'resources', 'fonts')
-    self.third_party_font_dir = finder.ThirdPartyFontsDir()
-
-    self.source_dir = finder.TestingDir()
-
-    self.pdfium_test_path = finder.ExecutablePath('pdfium_test')
-    if not os.path.exists(self.pdfium_test_path):
-      print("FAILURE: Can't find test executable '{}'".format(
-          self.pdfium_test_path))
-      print('Use --build-dir to specify its location.')
-      return 1
-
-    self.working_dir = finder.WorkingDir(os.path.join('testing', self.test_dir))
-    shutil.rmtree(self.working_dir, ignore_errors=True)
-    os.makedirs(self.working_dir)
-
-    self.features = subprocess.check_output(
-        [self.pdfium_test_path,
-         '--show-config']).decode('utf-8').strip().split(',')
-    self.test_suppressor = suppressor.Suppressor(
-        finder, self.features, self.options.disable_javascript,
-        self.options.disable_xfa)
-    self.image_differ = pngdiffer.PNGDiffer(finder, self.features,
-                                            self.options.reverse_byte_order)
-    error_message = self.image_differ.CheckMissingTools(
-        self.options.regenerate_expected)
-    if error_message:
-      print('FAILURE:', error_message)
-      return 1
-
-    self.resultdb = result_sink.TryInitClient()
-    if self.resultdb:
-      print('Detected ResultSink environment')
-
-    # Collect test cases.
-    walk_from_dir = finder.TestingDir(relative_test_dir)
-
-    self.test_cases = TestCaseManager()
-    self.execution_suppressed_cases = []
-    input_file_re = re.compile('^.+[.](in|pdf)$')
-    if self.options.inputted_file_paths:
-      for file_name in self.options.inputted_file_paths:
-        input_path = os.path.join(walk_from_dir, file_name)
-        if not os.path.isfile(input_path):
-          print(f"Can't find test file '{file_name}'")
-          return 1
-
-        self.test_cases.NewTestCase(input_path)
-    else:
-      for file_dir, _, filename_list in os.walk(walk_from_dir):
-        for input_filename in filename_list:
-          if input_file_re.match(input_filename):
-            input_path = os.path.join(file_dir, input_filename)
-            if self.test_suppressor.IsExecutionSuppressed(input_path):
-              self.execution_suppressed_cases.append(input_path)
-              continue
-            if not os.path.isfile(input_path):
-              continue
-
-            self.test_cases.NewTestCase(input_path)
-
-    # Execute test cases.
-    self.failures = []
-    self.surprises = []
-    self.skia_gold_successes = []
-    self.skia_gold_unexpected_successes = []
-    self.skia_gold_failures = []
-    self.result_suppressed_cases = []
-
-    if self.test_type not in TEXT_TESTS and self.options.run_skia_gold:
-      assert self.options.gold_output_dir
-      # Clear out and create top level gold output directory before starting
-      skia_gold.clear_gold_output_dir(self.options.gold_output_dir)
-
-    per_process_state_args = [self.test_type, self.options]
-    per_process_state = _PerProcessState(*per_process_state_args)
-    with multiprocessing.Pool(
-        processes=self.options.num_workers,
-        initializer=_InitializePerProcessState,
-        initargs=per_process_state_args) as pool:
-      skia_gold_test_cases = TestCaseManager()
-      for result in pool.imap(
-          _WrapKeyboardInterrupt(self.GenerateAndTest), self.test_cases):
-        self.HandleResult(self.test_cases.GetTestCase(result.test_id), result)
-
-        if self.test_type not in TEXT_TESTS and self.options.run_skia_gold:
-          for artifact in result.image_artifacts:
-            # The output filename without image extension becomes the test ID.
-            # For example, "/path/to/.../testing/corpus/example_005.pdf.0.png"
-            # becomes "example_005.pdf.0".
-            skia_gold_test_cases.NewTestCase(artifact.image_path)
-
-      for result in pool.imap(
-          _WrapKeyboardInterrupt(_RunSkiaTest), skia_gold_test_cases):
-        if result.IsPass():
-          test_case = skia_gold_test_cases.GetTestCase(result.test_id)
-          if self.test_suppressor.IsResultSuppressed(test_case.input_path):
-            self.skia_gold_unexpected_successes.append(result.test_id)
-          else:
-            self.skia_gold_successes.append(result.test_id)
-        else:
-          self.skia_gold_failures.append(result.test_id)
-
-    # Report test results.
-    #
-    # For some reason, summary will be cut off from stdout on windows if
-    # _PrintSummary() is called at the end
-    # TODO(crbug.com/pdfium/1657): Once resolved, move _PrintSummary() back
-    # down to the end
-    self._PrintSummary(per_process_state)
-
-    if self.surprises:
-      self.surprises.sort()
-      print('\nUnexpected Successes:')
-      for surprise in self.surprises:
-        print(surprise)
-
-    if self.failures:
-      self.failures.sort()
-      print('\nSummary of Failures:')
-      for failure in self.failures:
-        print(failure)
-
-    if self.skia_gold_unexpected_successes:
-      self.skia_gold_unexpected_successes.sort()
-      print('\nUnexpected Skia Gold Successes:')
-      for surprise in self.skia_gold_unexpected_successes:
-        print(surprise)
-
-    if self.skia_gold_failures:
-      self.skia_gold_failures.sort()
-      print('\nSummary of Skia Gold Failures:')
-      for failure in self.skia_gold_failures:
-        print(failure)
-
-    if self.failures:
-      if not self.options.ignore_errors:
-        return 1
-
-    return 0
-
-  def _PrintSummary(self, per_process_state):
-    number_test_cases = len(self.test_cases)
-    number_failures = len(self.failures)
-    number_suppressed = len(self.result_suppressed_cases)
-    number_successes = number_test_cases - number_failures - number_suppressed
-    number_surprises = len(self.surprises)
-    print('\nTest cases executed:', number_test_cases)
-    print('  Successes:', number_successes)
-    print('  Suppressed:', number_suppressed)
-    print('  Surprises:', number_surprises)
-    print('  Failures:', number_failures)
-    if self.test_type not in TEXT_TESTS and self.options.run_skia_gold:
-      number_gold_failures = len(self.skia_gold_failures)
-      number_gold_successes = len(self.skia_gold_successes)
-      number_gold_surprises = len(self.skia_gold_unexpected_successes)
-      number_total_gold_tests = sum(
-          [number_gold_failures, number_gold_successes, number_gold_surprises])
-      print('\nSkia Gold Test cases executed:', number_total_gold_tests)
-      print('  Skia Gold Successes:', number_gold_successes)
-      print('  Skia Gold Surprises:', number_gold_surprises)
-      print('  Skia Gold Failures:', number_gold_failures)
-      skia_tester = per_process_state.GetSkiaGoldTester()
-      if self.skia_gold_failures and skia_tester.IsTryjobRun():
-        cl_triage_link = skia_tester.GetCLTriageLink()
-        print('  Triage link for CL:', cl_triage_link)
-        skia_tester.WriteCLTriageLink(cl_triage_link)
-    print()
-    print('Test cases not executed:', len(self.execution_suppressed_cases))
-
-  def SetDeleteOutputOnSuccess(self, new_value):
-    """Set whether to delete generated output if the test passes."""
-    self.delete_output_on_success = new_value
-
-  def SetEnforceExpectedImages(self, new_value):
-    """Set whether to enforce that each test case provide an expected image."""
-    self.enforce_expected_images = new_value
-
-
-def _RunSkiaTest(test_case):
-  """Runs a Skia Gold test case."""
-  skia_tester = _per_process_state.GetSkiaGoldTester()
-  skia_success = skia_tester.UploadTestResultToSkiaGold(test_case.test_id,
-                                                        test_case.input_path)
-  sys.stdout.flush()
-
-  return test_case.NewResult(
-      result_types.PASS if skia_success else result_types.FAIL)
-
-
-# `_PerProcessState` singleton. This is initialized when creating the
-# `multiprocessing.Pool()`. `TestRunner.Run()` creates its own separate
-# instance of `_PerProcessState` as well.
-_per_process_state = None
-
-
-def _InitializePerProcessState(test_type, options):
-  """Initializes the `_per_process_state` singleton."""
-  global _per_process_state
-  assert not _per_process_state
-  _per_process_state = _PerProcessState(test_type, options)
-
-
-class _PerProcessState:
-  """State defined per process."""
-
-  def __init__(self, test_type, options):
-    self.test_type = test_type
-    self.options = options
-    self.process_name = multiprocessing.current_process().name
-
-    self.skia_tester = None
-
-  def __getstate__(self):
-    raise RuntimeError('Cannot pickle per-process state')
-
-  def GetSkiaGoldTester(self):
-    """Gets the `SkiaGoldTester` singleton for this worker."""
-    if not self.skia_tester:
-      self.skia_tester = skia_gold.SkiaGoldTester(
-          source_type=self.test_type,
-          skia_gold_args=self.options,
-          process_name=self.process_name)
-    return self.skia_tester
-
 
 @dataclass
 class TestCase: