mac signing: Parallelize notarization submissions.

Notarization submissions take a fair amount of time. By parallelizing
them I hope to decrease the latency of producing a signed Chrome
build.

Because we can now take advantage of more sophisticated python
parallelism, I combined the submission and the wait steps, which led
to less code overall.

Bug: 411130935
Change-Id: I92901e3ed2da6e0ef9f982dd664f3b3904ca6fbf
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6470354
Reviewed-by: Keybo Qian <keybo@google.com>
Auto-Submit: Joshua Pawlicki <waffles@chromium.org>
Commit-Queue: Keybo Qian <keybo@google.com>
Cr-Commit-Position: refs/branch-heads/7118@{#6}
Cr-Branched-From: d143a2e5953b2083b230257a6ae5302f7e678283-refs/heads/main@{#1444677}
diff --git a/chrome/installer/mac/signing/commands.py b/chrome/installer/mac/signing/commands.py
index 398a4ca..c1f1212 100644
--- a/chrome/installer/mac/signing/commands.py
+++ b/chrome/installer/mac/signing/commands.py
@@ -5,6 +5,7 @@
 The commands module wraps operations that have side-effects.
 """
 
+import asyncio
 import os
 import platform
 import plistlib
@@ -102,6 +103,21 @@
     return subprocess.check_output(args, **kwargs)
 
 
+async def run_command_output_async(args, **kwargs):
+    logger.info('Running command: %s', args)
+    process = await asyncio.create_subprocess_exec(
+        *args,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+        **kwargs)
+    stdout, stderr = await process.communicate()
+    if process.returncode:
+        logger.error('%s failed. stdout: %s stderr: %s', args, stdout, stderr)
+        raise subprocess.CalledProcessError(
+            process.returncode, args, output=stdout, stderr=stderr)
+    return stdout
+
+
 def lenient_run_command_output(args, **kwargs):
     """Runs a command, being fairly tolerant of errors.
 
diff --git a/chrome/installer/mac/signing/driver.py b/chrome/installer/mac/signing/driver.py
index 2fd03817..a4addbb4 100644
--- a/chrome/installer/mac/signing/driver.py
+++ b/chrome/installer/mac/signing/driver.py
@@ -6,7 +6,9 @@
 """
 
 import argparse
+import asyncio
 import os
+import subprocess
 
 from signing import config_factory, commands, invoker, logger, model, pipeline
 
@@ -70,7 +72,12 @@
 def _show_tool_versions():
     logger.info('Showing macOS and tool versions.')
     commands.run_command(['sw_vers'])
-    commands.run_command(['xcodebuild', '-version'])
+    try:
+        # xcodebuild -version fails with command-line xcode, but the rest of the
+        # signing script works.
+        commands.run_command(['xcodebuild', '-version'])
+    except subprocess.CalledProcessError as e:
+        print("xcodebuild failed: %s" % e.stderr)
     commands.run_command(['xcrun', '-show-sdk-path'])
 
 
@@ -171,9 +178,10 @@
 
     _show_tool_versions()
 
-    pipeline.sign_all(
-        paths,
-        config,
-        disable_packaging=args.disable_packaging,
-        skip_brands=args.skip_brands,
-        channels=args.channels)
+    asyncio.run(
+        pipeline.sign_all(
+            paths,
+            config,
+            disable_packaging=args.disable_packaging,
+            skip_brands=args.skip_brands,
+            channels=args.channels))
diff --git a/chrome/installer/mac/signing/driver_test.py b/chrome/installer/mac/signing/driver_test.py
index 8ee9534..6ec48b26 100644
--- a/chrome/installer/mac/signing/driver_test.py
+++ b/chrome/installer/mac/signing/driver_test.py
@@ -181,7 +181,7 @@
             '--identity',
             'G',
             '--notarize',
-            'nowait',
+            'wait-nostaple',
             '--notary-arg=--key',
             '--notary-arg',
             '/path/to/key',
@@ -192,7 +192,8 @@
         ])
         self.assertEquals(1, sign_all.call_count)
         config = sign_all.call_args.args[1]
-        self.assertEquals(model.NotarizeAndStapleLevel.NOWAIT, config.notarize)
+        self.assertEquals(model.NotarizeAndStapleLevel.WAIT_NOSTAPLE,
+                          config.notarize)
         self.assertEquals(
             ['--key', '/path/to/key', '--key-id=KeyId', '--issuer', 'Issuer'],
             config.invoker.notarizer.notary_args)
diff --git a/chrome/installer/mac/signing/invoker.py b/chrome/installer/mac/signing/invoker.py
index e38dee9c..2c2df88 100644
--- a/chrome/installer/mac/signing/invoker.py
+++ b/chrome/installer/mac/signing/invoker.py
@@ -88,7 +88,7 @@
         notarization-related operations.
         """
 
-        def submit(self, path, config):
+        async def submit(self, path, config):
             """Submits an artifact to Apple for notarization.
 
             Args:
diff --git a/chrome/installer/mac/signing/model.py b/chrome/installer/mac/signing/model.py
index b65876c..461bfcb 100644
--- a/chrome/installer/mac/signing/model.py
+++ b/chrome/installer/mac/signing/model.py
@@ -191,9 +191,6 @@
 
     `NONE` means no notarization tasks should be performed.
 
-    `NOWAIT` means to submit the signed application and packaging to Apple for
-    notarization, but not to wait for a reply.
-
     `WAIT_NOSTAPLE` means to submit the signed application and packaging to
     Apple for notarization, and wait for a reply, but not to staple the
     resulting notarization ticket.
@@ -203,16 +200,12 @@
     ticket.
     """
     NONE = 0
-    NOWAIT = 1
-    WAIT_NOSTAPLE = 2
-    STAPLE = 3
+    WAIT_NOSTAPLE = 1
+    STAPLE = 2
 
     def should_notarize(self):
         return self.value > self.NONE.value
 
-    def should_wait(self):
-        return self.value > self.NOWAIT.value
-
     def should_staple(self):
         return self.value > self.WAIT_NOSTAPLE.value
 
diff --git a/chrome/installer/mac/signing/notarize.py b/chrome/installer/mac/signing/notarize.py
index 802da61a..9d02d5cb 100644
--- a/chrome/installer/mac/signing/notarize.py
+++ b/chrome/installer/mac/signing/notarize.py
@@ -6,6 +6,7 @@
 for results, and stapling Apple Notary notarization tickets.
 """
 
+import asyncio
 import collections
 import enum
 import os
@@ -47,7 +48,8 @@
     def notary_args(self):
         return self._notary_args
 
-    def submit(self, path, config):
+    async def submit(self, path, config):
+        # Submit the notarization.
         command = [
             'xcrun',
             'notarytool',
@@ -57,15 +59,36 @@
             '--output-format',
             'plist',
         ] + self.notary_args
-
-        output = commands.run_command_output(command)
+        output = await commands.run_command_output_async(command)
         try:
-            plist = plistlib.loads(output)
-            return plist['id']
+            uuid = plistlib.loads(output)['id']
         except:
             raise NotarizationError(
                 'xcrun notarytool returned output that could not be parsed: {}'
                 .format(output))
+        logger.info('Submitted %s for notarization, request UUID: %s.', path,
+                    uuid)
+
+        # Wait for notarization to complete.
+        while True:
+            result = config.invoker.notarizer.get_result(uuid, config)
+            if result.status == Status.IN_PROGRESS:
+                await asyncio.sleep(5)
+                continue
+            elif result.status == Status.SUCCESS:
+                logger.info('Successfully notarized request %s. Log file: %s',
+                            uuid, result.log_file)
+                return
+            else:
+                logger.error(
+                    'Failed to notarize request %s.\n'
+                    'Output:\n%s\n'
+                    'Log file:\n%s', uuid, result.output, result.log_file)
+                raise NotarizationError(
+                    'Notarization request {} failed with status: "{}".'.format(
+                        uuid,
+                        result.status_string,
+                    ))
 
     def get_result(self, uuid, config):
         command = [
@@ -98,19 +121,14 @@
         return commands.run_command_output(command)
 
 
-def submit(path, config):
-    """Submits an artifact to Apple for notarization.
+async def submit(path, config):
+    """Submits an artifact to Apple for notarization and awaits its success.
 
     Args:
         path: The path to the artifact that will be uploaded for notarization.
         config: The |config.CodeSignConfig| for the artifact.
-
-    Returns:
-        A UUID from the notary service that represents the request.
     """
-    uuid = config.invoker.notarizer.submit(path, config)
-    logger.info('Submitted %s for notarization, request UUID: %s.', path, uuid)
-    return uuid
+    await config.invoker.notarizer.submit(path, config)
 
 
 class Status(enum.Enum):
@@ -127,66 +145,6 @@
     'NotarizationResult', ['status', 'status_string', 'output', 'log_file'])
 
 
-def wait_for_results(uuids, config):
-    """Waits for results from the notarization service. This iterates the list
-    of UUIDs and checks the status of each one. For each successful result, the
-    function yields to the caller. If a request failed, this raises a
-    NotarizationError. If no requests are ready, this operation blocks and
-    retries until a result is ready. After a certain amount of time, the
-    operation will time out with a NotarizationError if no results are
-    produced.
-
-    Args:
-        uuids: List of UUIDs to check for results. The list must not be empty.
-        config: The |config.CodeSignConfig| object.
-
-    Yields:
-        The UUID of a successful notarization request.
-    """
-    assert len(uuids)
-
-    wait_set = set(uuids)
-
-    sleep_time_seconds = 5
-    total_sleep_time_seconds = 0
-
-    while len(wait_set) > 0:
-        for uuid in list(wait_set):
-            result = config.invoker.notarizer.get_result(uuid, config)
-            if result.status == Status.IN_PROGRESS:
-                continue
-            elif result.status == Status.SUCCESS:
-                logger.info('Successfully notarized request %s. Log file: %s',
-                            uuid, result.log_file)
-                wait_set.remove(uuid)
-                yield uuid
-            else:
-                logger.error(
-                    'Failed to notarize request %s.\n'
-                    'Output:\n%s\n'
-                    'Log file:\n%s', uuid, result.output, result.log_file)
-                raise NotarizationError(
-                    'Notarization request {} failed with status: "{}".'.format(
-                        uuid,
-                        result.status_string,
-                    ))
-
-        if len(wait_set) > 0:
-            # Do not wait more than 60 minutes for all the operations to
-            # complete.
-            if total_sleep_time_seconds < 60 * 60:
-                # No results were available, so wait and try again in some
-                # number of seconds. Do not wait more than 1 minute for any
-                # iteration.
-                time.sleep(sleep_time_seconds)
-                total_sleep_time_seconds += sleep_time_seconds
-                sleep_time_seconds = min(sleep_time_seconds * 2, 60)
-            else:
-                raise NotarizationError(
-                    'Timed out waiting for notarization requests: {}'.format(
-                        wait_set))
-
-
 def staple_bundled_parts(parts, paths):
     """Staples all the bundled executable components of the app bundle.
 
diff --git a/chrome/installer/mac/signing/notarize_test.py b/chrome/installer/mac/signing/notarize_test.py
index 8a2abb2..9d4909a 100644
--- a/chrome/installer/mac/signing/notarize_test.py
+++ b/chrome/installer/mac/signing/notarize_test.py
@@ -2,6 +2,7 @@
 # Use of this source code is governed by a BSD-style license that can be
 # found in the LICENSE file.
 
+import asyncio
 import os
 import plistlib
 import subprocess
@@ -14,39 +15,53 @@
 
 class TestSubmitNotarytool(unittest.TestCase):
 
+    @mock.patch('signing.commands.run_command_output_async')
     @mock.patch('signing.commands.run_command_output')
-    def test_valid_upload(self, run_command_output):
-        run_command_output.return_value = plistlib.dumps({
+    def test_valid_upload(self, run_command_output, run_command_output_async):
+        run_command_output_async.return_value = plistlib.dumps({
             'id': '13d6aa9b-d204-4f0d-9164-4bda5e730258',
             'message': 'Successfully uploaded file',
             'path': '/tmp/file.dmg'
         })
+        run_command_output.return_value = plistlib.dumps({'status': 'Accepted'})
         config = test_config.TestConfig()
-        uuid = notarize.submit('/tmp/file.dmg', config)
+        asyncio.run(notarize.submit('/tmp/file.dmg', config))
 
-        self.assertEqual('13d6aa9b-d204-4f0d-9164-4bda5e730258', uuid)
-        run_command_output.assert_called_once_with([
+        run_command_output_async.assert_awaited_once_with([
             'xcrun', 'notarytool', 'submit', '/tmp/file.dmg', '--no-wait',
             '--output-format', 'plist'
         ])
 
+        run_command_output.assert_called_once_with([
+            'xcrun', 'notarytool', 'info',
+            '13d6aa9b-d204-4f0d-9164-4bda5e730258', '--output-format', 'plist'
+        ])
+
+    @mock.patch('signing.commands.run_command_output_async')
     @mock.patch('signing.commands.run_command_output')
-    def test_valid_upload_with_args(self, run_command_output):
-        run_command_output.return_value = plistlib.dumps(
+    def test_valid_upload_with_args(self, run_command_output,
+                                    run_command_output_async):
+        run_command_output_async.return_value = plistlib.dumps(
             {'id': 'b53b3ed1-82cb-41b4-9e12-b097b2c05f64'})
+        run_command_output.return_value = plistlib.dumps({'status': 'Accepted'})
         config = test_config.TestConfig(
             invoker=test_config.TestInvoker.factory_with_args(notary_arg=[
                 '--apple-id', '[NOTARY-USER]', '--team-id', '[NOTARY-TEAM]',
                 '--password', 'hunter2'
             ]))
-        uuid = notarize.submit('/tmp/file.dmg', config)
+        asyncio.run(notarize.submit('/tmp/file.dmg', config))
 
-        self.assertEqual('b53b3ed1-82cb-41b4-9e12-b097b2c05f64', uuid)
-        run_command_output.assert_called_once_with([
+        run_command_output_async.assert_awaited_once_with([
             'xcrun', 'notarytool', 'submit', '/tmp/file.dmg', '--no-wait',
             '--output-format', 'plist', '--apple-id', '[NOTARY-USER]',
             '--team-id', '[NOTARY-TEAM]', '--password', 'hunter2'
         ])
+        run_command_output.assert_called_once_with([
+            'xcrun', 'notarytool', 'info',
+            'b53b3ed1-82cb-41b4-9e12-b097b2c05f64', '--output-format', 'plist',
+            '--apple-id', '[NOTARY-USER]', '--team-id', '[NOTARY-TEAM]',
+            '--password', 'hunter2'
+        ])
 
 
 class TestGetResultNotarytool(unittest.TestCase):
@@ -183,69 +198,6 @@
         ])
 
 
-class TestWaitForResults(unittest.TestCase):
-
-    @mock.patch('signing.notarize.Invoker.get_result')
-    def test_success(self, get_result):
-        get_result.return_value = notarize.NotarizationResult(
-            notarize.Status.SUCCESS, 'success', None,
-            'https://example.com/log.json')
-        config = test_config.TestConfig()
-        uuid = 'cca0aec2-7c64-4ea4-b895-051ea3a17311'
-        uuids = [uuid]
-        self.assertEqual(uuids, list(notarize.wait_for_results(uuids, config)))
-        get_result.assert_called_once_with(uuid, config)
-
-    @mock.patch('signing.notarize.Invoker.get_result')
-    def test_failure(self, get_result):
-        get_result.return_value = notarize.NotarizationResult(
-            notarize.Status.ERROR, 'invalid', 'Package Invalid',
-            'https://example.com/log.json')
-        config = test_config.TestConfig()
-        uuid = 'cca0aec2-7c64-4ea4-b895-051ea3a17311'
-        uuids = [uuid]
-        with self.assertRaises(notarize.NotarizationError) as cm:
-            list(notarize.wait_for_results(uuids, config))
-
-        self.assertEqual(
-            'Notarization request cca0aec2-7c64-4ea4-b895-051ea3a17311 failed '
-            'with status: "invalid".', str(cm.exception))
-
-    @mock.patch('signing.notarize.Invoker.get_result')
-    def test_subprocess_errors(self, get_result):
-        get_result.side_effect = subprocess.CalledProcessError(
-            1, 'notarytool', 'A mysterious error occurred.')
-
-        with self.assertRaises(subprocess.CalledProcessError):
-            uuids = ['77c0ad17-479e-4b82-946a-73739cf6ca16']
-            list(notarize.wait_for_results(uuids, test_config.TestConfig()))
-
-    @mock.patch.multiple('time', **{'sleep': mock.DEFAULT})
-    @mock.patch.multiple('signing.notarize.Invoker',
-                         **{'get_result': mock.DEFAULT})
-    def test_timeout(self, **kwargs):
-        kwargs['get_result'].return_value = notarize.NotarizationResult(
-            notarize.Status.IN_PROGRESS, None, None, None)
-        config = test_config.TestConfig()
-        uuid = '0c652bb4-7d44-4904-8c59-1ee86a376ece'
-        uuids = [uuid]
-        with self.assertRaises(notarize.NotarizationError) as cm:
-            list(notarize.wait_for_results(uuids, config))
-
-        # Python 2 and 3 stringify set() differently.
-        self.assertIn(
-            str(cm.exception), [
-                "Timed out waiting for notarization requests: set(['0c652bb4-7d44-4904-8c59-1ee86a376ece'])",
-                "Timed out waiting for notarization requests: {'0c652bb4-7d44-4904-8c59-1ee86a376ece'}"
-            ])
-
-        for call in kwargs['get_result'].mock_calls:
-            self.assertEqual(call, mock.call(uuid, config))
-
-        total_time = sum([call[1][0] for call in kwargs['sleep'].mock_calls])
-        self.assertLess(total_time, 61 * 60)
-
-
 class TestStaple(unittest.TestCase):
 
     @mock.patch('signing.commands.run_command')
diff --git a/chrome/installer/mac/signing/pipeline.py b/chrome/installer/mac/signing/pipeline.py
index fbebaf4d..fea4466 100644
--- a/chrome/installer/mac/signing/pipeline.py
+++ b/chrome/installer/mac/signing/pipeline.py
@@ -9,6 +9,7 @@
     4. Signing and packaging the installer tools.
 """
 
+import asyncio
 import os.path
 
 from signing import commands, model, modification, notarize, parts, signing
@@ -696,11 +697,11 @@
     return filtered_distributions
 
 
-def sign_all(orig_paths,
-             config,
-             disable_packaging=False,
-             skip_brands=[],
-             channels=[]):
+async def sign_all(orig_paths,
+                   config,
+                   disable_packaging=False,
+                   skip_brands=[],
+                   channels=[]):
     """For each distribution in |config|, performs customization, signing, and
     DMG packaging and places the resulting signed DMG in |orig_paths.output|.
     The |paths.input| must contain the products to customize and sign.
@@ -725,32 +726,33 @@
 
         # First, sign all the distributions and optionally submit the
         # notarization requests.
-        uuids_to_config = _sign_and_maybe_notarize_distributions(
-            config, distributions, notary_paths, disable_packaging)
+        dist_configs = await asyncio.wait_for(
+            _sign_and_maybe_notarize_distributions(config, distributions,
+                                                   notary_paths,
+                                                   disable_packaging),
+            timeout=60 * 60 * 2)
 
-        # If needed, wait for app notarization results to come back, and staple
-        # if required.
-        if config.notarize.should_wait():
-            for result in notarize.wait_for_results(uuids_to_config.keys(),
-                                                    config):
-                if config.notarize.should_staple():
-                    dist_config = uuids_to_config[result]
-                    dest_dir = os.path.join(
-                        notary_paths.work,
-                        _intermediate_work_dir_name(dist_config.distribution))
-                    _staple_chrome(
-                        notary_paths.replace_work(dest_dir), dist_config)
+        # Staple if required.
+        if config.notarize.should_staple():
+            for dist_config in dist_configs:
+                dest_dir = os.path.join(
+                    notary_paths.work,
+                    _intermediate_work_dir_name(dist_config.distribution))
+                _staple_chrome(notary_paths.replace_work(dest_dir), dist_config)
 
         # After all apps are optionally notarized, package as required.
         if not disable_packaging:
-            _package_and_maybe_notarize_distributions(config, distributions,
-                                                      notary_paths)
+            await asyncio.wait_for(
+                _package_and_maybe_notarize_distributions(
+                    config, distributions, notary_paths),
+                timeout=60 * 60 * 2)
 
     _package_installer_tools(orig_paths, config)
 
 
-def _sign_and_maybe_notarize_distributions(config, distributions, notary_paths,
-                                           disable_packaging):
+async def _sign_and_maybe_notarize_distributions(config, distributions,
+                                                 notary_paths,
+                                                 disable_packaging):
     """Iterates each distribution in |distributions|, codesigns it according to
     the |config|, and potentially uploads it for notarization.
 
@@ -766,53 +768,60 @@
         |config.CodeSignConfig.dist_config| for the |model.Distribution|. If
         notarization is not performed, returns an empty dict.
     """
-    uuids_to_config = {}
+    dist_configs = []
     signed_frameworks = {}
     created_app_bundles = set()
 
-    for dist in distributions:
-        with commands.WorkDirectory(notary_paths) as paths:
-            dist_config = dist.to_config(config)
-            do_packaging = (dist.package_as_dmg or dist.package_as_pkg or
-                            dist.package_as_zip) and not disable_packaging
+    async with asyncio.TaskGroup() as tasks:
+        for dist in distributions:
+            with commands.WorkDirectory(notary_paths) as paths:
+                dist_config = dist.to_config(config)
+                dist_configs.append(dist_config)
+                do_packaging = (dist.package_as_dmg or dist.package_as_pkg or
+                                dist.package_as_zip) and not disable_packaging
 
-            # If not packaging and not notarizing, then simply drop the
-            # signed bundle in the output directory when done signing.
-            if not do_packaging and not config.notarize.should_notarize():
-                dest_dir = paths.output
-            else:
-                dest_dir = notary_paths.work
+                # If not packaging and not notarizing, then simply drop the
+                # signed bundle in the output directory when done signing.
+                if not do_packaging and not config.notarize.should_notarize():
+                    dest_dir = paths.output
+                else:
+                    dest_dir = notary_paths.work
 
-            dest_dir = os.path.join(dest_dir, _intermediate_work_dir_name(dist))
+                dest_dir = os.path.join(dest_dir,
+                                        _intermediate_work_dir_name(dist))
 
-            # Different distributions might share the same underlying app
-            # bundle, and if they do, then the _intermediate_work_dir_name
-            # function will return the same value. Skip creating another app
-            # bundle if that is the case.
-            if dest_dir in created_app_bundles:
-                continue
-            created_app_bundles.add(dest_dir)
+                # Different distributions might share the same underlying app
+                # bundle, and if they do, then the _intermediate_work_dir_name
+                # function will return the same value. Skip creating another app
+                # bundle if that is the case.
+                if dest_dir in created_app_bundles:
+                    continue
+                created_app_bundles.add(dest_dir)
 
-            _customize_and_sign_chrome(paths, dist_config, dest_dir,
-                                       signed_frameworks)
+                _customize_and_sign_chrome(paths, dist_config, dest_dir,
+                                           signed_frameworks)
 
-            # If the build products are to be notarized, ZIP the app bundle
-            # and submit it for notarization.
-            if config.notarize.should_notarize():
-                zip_file = os.path.join(notary_paths.work,
-                                        dist_config.packaging_basename + '.zip')
-                commands.run_command([
-                    'zip', '--recurse-paths', '--symlinks', '--quiet', zip_file,
-                    dist_config.app_dir
-                ],
-                                     cwd=dest_dir)
-                uuid = notarize.submit(zip_file, dist_config)
-                uuids_to_config[uuid] = dist_config
-    return uuids_to_config
+                # If the build products are to be notarized, ZIP the app bundle
+                # and submit it for notarization.
+                if config.notarize.should_notarize():
+                    zip_file = os.path.join(
+                        notary_paths.work,
+                        dist_config.packaging_basename + '.zip')
+                    commands.run_command([
+                        'zip', '--recurse-paths', '--symlinks', '--quiet',
+                        zip_file, dist_config.app_dir
+                    ],
+                                         cwd=dest_dir)
+                    tasks.create_task(notarize.submit(zip_file, dist_config))
+
+            # Yield the event loop to let the notarization subprocesses start
+            # before continuing to the next distribution.
+            asyncio.sleep(0)
+    return dist_configs
 
 
-def _package_and_maybe_notarize_distributions(config, distributions,
-                                              notary_paths):
+async def _package_and_maybe_notarize_distributions(config, distributions,
+                                                    notary_paths):
     """Iterates each |model.Distribution| in |distributions| and packages it
     according to its specification. If notarization is requested, that is
     performed on the assembled package.
@@ -823,43 +832,44 @@
         notary_paths: A |model.Paths| object where artifacts will be placed when
             notarizing.
     """
-    uuids_to_package_path = {}
-    for dist in distributions:
-        dist_config = dist.to_config(config)
-        paths = notary_paths.replace_work(
-            os.path.join(notary_paths.work,
-                         _intermediate_work_dir_name(dist_config.distribution)))
+    staple_paths = []
+    async with asyncio.TaskGroup() as tasks:
+        for dist in distributions:
+            dist_config = dist.to_config(config)
+            paths = notary_paths.replace_work(
+                os.path.join(
+                    notary_paths.work,
+                    _intermediate_work_dir_name(dist_config.distribution)))
 
-        if dist.inflation_kilobytes:
-            inflation_path = os.path.join(
-                paths.packaging_dir(config), 'inflation.bin')
-            commands.run_command([
-                'dd', 'if=/dev/urandom', 'of=' + inflation_path, 'bs=1000',
-                'count={}'.format(dist.inflation_kilobytes)
-            ])
+            if dist.inflation_kilobytes:
+                inflation_path = os.path.join(
+                    paths.packaging_dir(config), 'inflation.bin')
+                commands.run_command([
+                    'dd', 'if=/dev/urandom', 'of=' + inflation_path, 'bs=1000',
+                    'count={}'.format(dist.inflation_kilobytes)
+                ])
 
-        if dist.package_as_dmg:
-            dmg_path = _package_and_sign_dmg(paths, dist_config)
+            if dist.package_as_dmg:
+                dmg_path = _package_and_sign_dmg(paths, dist_config)
 
-            if config.notarize.should_notarize():
-                uuid = notarize.submit(dmg_path, dist_config)
-                uuids_to_package_path[uuid] = dmg_path
+                if config.notarize.should_notarize():
+                    tasks.create_task(notarize.submit(dmg_path, dist_config))
+                    staple_paths.append(dmg_path)
 
-        if dist.package_as_pkg:
-            pkg_path = _package_and_sign_pkg(paths, dist_config)
+            if dist.package_as_pkg:
+                pkg_path = _package_and_sign_pkg(paths, dist_config)
 
-            if config.notarize.should_notarize():
-                uuid = notarize.submit(pkg_path, dist_config)
-                uuids_to_package_path[uuid] = pkg_path
+                if config.notarize.should_notarize():
+                    tasks.create_task(notarize.submit(pkg_path, dist_config))
+                    staple_paths.append(pkg_path)
 
-        if dist.package_as_zip:
-            _package_zip(paths, dist_config)
+            if dist.package_as_zip:
+                _package_zip(paths, dist_config)
 
-    # If needed, wait for package notarization results to come back, and
-    # staple if required.
-    if config.notarize.should_wait():
-        for result in notarize.wait_for_results(uuids_to_package_path.keys(),
-                                                config):
-            if config.notarize.should_staple():
-                package_path = uuids_to_package_path[result]
-                notarize.staple(package_path)
+            # Yield the event loop to let the notarization subprocesses start
+            # before continuing to the next distribution.
+            asyncio.sleep(0)
+
+    if config.notarize.should_staple():
+        for path in staple_paths:
+            notarize.staple(path)
diff --git a/chrome/installer/mac/signing/pipeline_test.py b/chrome/installer/mac/signing/pipeline_test.py
index d422527..7f4c988 100644
--- a/chrome/installer/mac/signing/pipeline_test.py
+++ b/chrome/installer/mac/signing/pipeline_test.py
@@ -1107,9 +1107,8 @@
         m: mock.DEFAULT for m in ('move_file', 'copy_files', 'run_command',
                                   'make_dir', 'shutil', 'os')
     })
-@mock.patch.multiple('signing.notarize', **{
-    m: mock.DEFAULT for m in ('submit', 'wait_for_results', 'staple')
-})
+@mock.patch.multiple('signing.notarize',
+                     **{m: mock.DEFAULT for m in ('submit', 'staple')})
 @mock.patch.multiple(
     'signing.pipeline', **{
         m: mock.DEFAULT
@@ -1129,12 +1128,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = 'f38ee49c-c55b-4a10-a4f5-aaaa17636b76'
-        dmg_uuid = '9f49067e-a13d-436a-8016-3a22a4f6ef92'
-        kwargs['submit'].side_effect = [app_uuid, dmg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([dmg_uuid])
-        ]
         kwargs[
             '_package_and_sign_dmg'].return_value = '/$O/AppProduct-99.0.9999.99.dmg'
 
@@ -1167,7 +1160,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
 
@@ -1176,7 +1168,6 @@
 
             # Notarize the DMG.
             mock.call.submit('/$O/AppProduct-99.0.9999.99.dmg', mock.ANY),
-            mock.call.wait_for_results({dmg_uuid: None}.keys(), mock.ANY),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.dmg'),
             mock.call.shutil.rmtree('/$W_1'),
 
@@ -1189,12 +1180,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = 'f38ee49c-c55b-4a10-a4f5-aaaa17636b76'
-        kwargs['submit'].side_effect = [app_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]),
-            iter([]),
-        ]
         kwargs['_package_zip'].return_value = '/$O/AppProduct-99.0.9999.99.zip'
 
         class Config(test_config.TestConfig):
@@ -1226,7 +1211,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
 
@@ -1234,7 +1218,6 @@
             mock.call._package_zip(mock.ANY, mock.ANY),
 
             # Notarize the DMG.
-            mock.call.wait_for_results({}.keys(), mock.ANY),
             mock.call.shutil.rmtree('/$W_1'),
 
             # Package the installer tools.
@@ -1246,12 +1229,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = 'f38ee49c-c55b-4a10-a4f5-aaaa17636b76'
-        dmg_uuid = '9f49067e-a13d-436a-8016-3a22a4f6ef92'
-        kwargs['submit'].side_effect = [app_uuid, dmg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([dmg_uuid])
-        ]
         kwargs[
             '_package_and_sign_dmg'].return_value = '/$O/AppProduct-99.0.9999.99.dmg'
 
@@ -1286,7 +1263,6 @@
                                   cwd='/$W_1/stable-5000'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable-5000'), mock.ANY),
             mock.call.run_command([
@@ -1300,7 +1276,6 @@
 
             # Notarize the DMG.
             mock.call.submit('/$O/AppProduct-99.0.9999.99.dmg', mock.ANY),
-            mock.call.wait_for_results({dmg_uuid: None}.keys(), mock.ANY),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.dmg'),
             mock.call.shutil.rmtree('/$W_1'),
 
@@ -1313,12 +1288,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = 'b2ce64e5-4fae-4043-9c20-d9ff53065b2a'
-        pkg_uuid = 'cb811baf-5d35-4caa-adf4-1f61b4991eed'
-        kwargs['submit'].side_effect = [app_uuid, pkg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([pkg_uuid])
-        ]
         kwargs[
             '_package_and_sign_pkg'].return_value = '/$O/AppProduct-99.0.9999.99.pkg'
 
@@ -1351,7 +1320,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
 
@@ -1360,7 +1328,6 @@
 
             # Notarize the DMG.
             mock.call.submit('/$O/AppProduct-99.0.9999.99.pkg', mock.ANY),
-            mock.call.wait_for_results({pkg_uuid: None}.keys(), mock.ANY),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.pkg'),
             mock.call.shutil.rmtree('/$W_1'),
 
@@ -1373,12 +1340,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = '6de7df90-cf07-4213-9ce6-45f83588a386'
-        dmg_uuid = '7f77eefd-6c9d-4271-9367-760dc78a49dd'
-        kwargs['submit'].side_effect = [app_uuid, dmg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([dmg_uuid])
-        ]
         kwargs[
             '_package_and_sign_dmg'].return_value = '/$O/AppProduct-99.0.9999.99.dmg'
         kwargs['_package_zip'].return_value = '/$O/AppProduct-99.0.9999.99.zip'
@@ -1412,7 +1373,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
 
@@ -1424,7 +1384,6 @@
             mock.call._package_zip(mock.ANY, mock.ANY),
 
             # Notarize the DMG.
-            mock.call.wait_for_results({dmg_uuid: None}.keys(), mock.ANY),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.dmg'),
             mock.call.shutil.rmtree('/$W_1'),
 
@@ -1437,13 +1396,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = '6de7df90-cf07-4213-9ce6-45f83588a386'
-        dmg_uuid = '7f77eefd-6c9d-4271-9367-760dc78a49dd'
-        pkg_uuid = '364d9b29-a0a0-4661-b366-e35449197671'
-        kwargs['submit'].side_effect = [app_uuid, dmg_uuid, pkg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([dmg_uuid, pkg_uuid])
-        ]
         kwargs[
             '_package_and_sign_dmg'].return_value = '/$O/AppProduct-99.0.9999.99.dmg'
         kwargs[
@@ -1478,7 +1430,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
 
@@ -1491,10 +1442,6 @@
             mock.call.submit('/$O/AppProduct-99.0.9999.99.pkg', mock.ANY),
 
             # Wait for notarization results.
-            mock.call.wait_for_results({
-                dmg_uuid: None,
-                pkg_uuid: None
-            }.keys(), mock.ANY),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.dmg'),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.pkg'),
             mock.call.shutil.rmtree('/$W_1'),
@@ -1508,12 +1455,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = '6de7df90-cf07-4213-9ce6-45f83588a386'
-        pkg_uuid = '364d9b29-a0a0-4661-b366-e35449197671'
-        kwargs['submit'].side_effect = [app_uuid, pkg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([pkg_uuid])
-        ]
         kwargs[
             '_package_and_sign_pkg'].return_value = '/$O/AppProduct-99.0.9999.99.pkg'
         kwargs['_package_zip'].return_value = '/$O/AppProduct-99.0.9999.99.zip'
@@ -1547,7 +1488,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
 
@@ -1559,7 +1499,6 @@
             mock.call._package_zip(mock.ANY, mock.ANY),
 
             # Notarize the PKG.
-            mock.call.wait_for_results({pkg_uuid: None}.keys(), mock.ANY),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.pkg'),
             mock.call.shutil.rmtree('/$W_1'),
 
@@ -1572,13 +1511,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = '6de7df90-cf07-4213-9ce6-45f83588a386'
-        dmg_uuid = '7f77eefd-6c9d-4271-9367-760dc78a49dd'
-        pkg_uuid = '364d9b29-a0a0-4661-b366-e35449197671'
-        kwargs['submit'].side_effect = [app_uuid, dmg_uuid, pkg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([dmg_uuid, pkg_uuid])
-        ]
         kwargs[
             '_package_and_sign_dmg'].return_value = '/$O/AppProduct-99.0.9999.99.dmg'
         kwargs[
@@ -1614,7 +1546,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
 
@@ -1630,10 +1561,6 @@
             mock.call._package_zip(mock.ANY, mock.ANY),
 
             # Notarize the DMG.
-            mock.call.wait_for_results({
-                dmg_uuid: None,
-                pkg_uuid: None
-            }.keys(), mock.ANY),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.dmg'),
             mock.call.staple('/$O/AppProduct-99.0.9999.99.pkg'),
             mock.call.shutil.rmtree('/$W_1'),
@@ -1647,10 +1574,6 @@
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = '2d7bf857-c2b2-4ebc-8b51-b2f43ecfc13e'
-        kwargs['submit'].return_value = app_uuid
-        kwargs['wait_for_results'].return_value = iter([app_uuid])
-
         config = test_config.TestConfig()
         pipeline.sign_all(self.paths, config, disable_packaging=True)
 
@@ -1667,7 +1590,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
             mock.call._staple_chrome(
                 self.paths.replace_work('/$W_1/stable'), mock.ANY),
             mock.call.shutil.rmtree('/$W_1'),
@@ -1679,62 +1601,11 @@
         self.assertEqual(1, kwargs['_package_installer_tools'].call_count)
         self.assertEqual(1, kwargs['run_command'].call_count)
 
-    def test_sign_notarize_no_wait(self, **kwargs):
-        manager = mock.Mock()
-        for attr in kwargs:
-            manager.attach_mock(kwargs[attr], attr)
-
-        app_uuid = 'f38ee49c-c55b-4a10-a4f5-aaaa17636b76'
-        dmg_uuid = '9f49067e-a13d-436a-8016-3a22a4f6ef92'
-        kwargs['submit'].side_effect = [app_uuid, dmg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([dmg_uuid])
-        ]
-        kwargs['_package_and_sign_dmg'].return_value = (
-            '/$O/AppProduct-99.0.9999.99.dmg')
-
-        config = test_config.TestConfig(
-            notarize=model.NotarizeAndStapleLevel.NOWAIT)
-        pipeline.sign_all(self.paths, config)
-
-        self.assertEqual(1, kwargs['_package_installer_tools'].call_count)
-
-        manager.assert_has_calls([
-            # First customize the distribution and sign it.
-            mock.call._customize_and_sign_chrome(mock.ANY, mock.ANY,
-                                                 '/$W_1/stable', mock.ANY),
-
-            # Prepare the app for notarization.
-            mock.call.run_command([
-                'zip', '--recurse-paths', '--symlinks', '--quiet',
-                '/$W_1/AppProduct-99.0.9999.99.zip', 'App Product.app'
-            ],
-                                  cwd='/$W_1/stable'),
-            mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
-            mock.call.shutil.rmtree('/$W_2'),
-
-            # Make the DMG.
-            mock.call._package_and_sign_dmg(mock.ANY, mock.ANY),
-
-            # Notarize the DMG.
-            mock.call.submit('/$O/AppProduct-99.0.9999.99.dmg', mock.ANY),
-            mock.call.shutil.rmtree('/$W_1'),
-
-            # Package the installer tools.
-            mock.call._package_installer_tools(mock.ANY, mock.ANY),
-        ])
-
     def test_sign_notarize_wait_no_staple(self, **kwargs):
         manager = mock.Mock()
         for attr in kwargs:
             manager.attach_mock(kwargs[attr], attr)
 
-        app_uuid = 'f38ee49c-c55b-4a10-a4f5-aaaa17636b76'
-        dmg_uuid = '9f49067e-a13d-436a-8016-3a22a4f6ef92'
-        kwargs['submit'].side_effect = [app_uuid, dmg_uuid]
-        kwargs['wait_for_results'].side_effect = [
-            iter([app_uuid]), iter([dmg_uuid])
-        ]
         kwargs[
             '_package_and_sign_dmg'].return_value = '/$O/AppProduct-99.0.9999.99.dmg'
 
@@ -1757,7 +1628,6 @@
                                   cwd='/$W_1/stable'),
             mock.call.submit('/$W_1/AppProduct-99.0.9999.99.zip', mock.ANY),
             mock.call.shutil.rmtree('/$W_2'),
-            mock.call.wait_for_results({app_uuid: None}.keys(), mock.ANY),
 
             # Make the DMG.
             mock.call._package_and_sign_dmg(mock.ANY, mock.ANY),
@@ -1766,7 +1636,6 @@
             mock.call.submit('/$O/AppProduct-99.0.9999.99.dmg', mock.ANY),
 
             # Cleanup.
-            mock.call.wait_for_results({dmg_uuid: None}.keys(), mock.ANY),
             mock.call.shutil.rmtree('/$W_1'),
 
             # Package the installer tools.
diff --git a/chrome/updater/mac/signing/pipeline.py b/chrome/updater/mac/signing/pipeline.py
index ae33230..a52ff5a 100644
--- a/chrome/updater/mac/signing/pipeline.py
+++ b/chrome/updater/mac/signing/pipeline.py
@@ -8,6 +8,7 @@
     3. Signing the DMG.
 """
 
+import asyncio
 import os.path
 
 from signing import commands, model, notarize, parts, signing
@@ -167,7 +168,6 @@
     """
     with commands.WorkDirectory(orig_paths) as notary_paths:
         # First, sign and optionally submit the notarization requests.
-        uuid = None
         with commands.WorkDirectory(orig_paths) as paths:
             dest_dir = os.path.join(notary_paths.work,
                                     config.packaging_basename)
@@ -181,19 +181,15 @@
                     zip_file, config.app_dir
                 ],
                                      cwd=dest_dir)
-                uuid = notarize.submit(zip_file, config)
+                uuid = asyncio.run(notarize.submit(zip_file, config))
 
-        # Wait for the app notarization result to come back and staple.
-        if config.notarize.should_wait():
-            for _ in notarize.wait_for_results([uuid], config):
-                pass  # We are only waiting for a single notarization.
-            if config.notarize.should_staple():
-                notarize.staple_bundled_parts(
-                    # Only staple to the outermost app.
-                    parts.get_parts(config)[-1:],
-                    notary_paths.replace_work(
-                        os.path.join(notary_paths.work,
-                                     config.packaging_basename)))
+        if config.notarize.should_staple():
+            notarize.staple_bundled_parts(
+                # Only staple to the outermost app.
+                parts.get_parts(config)[-1:],
+                notary_paths.replace_work(
+                    os.path.join(notary_paths.work,
+                                 config.packaging_basename)))
 
         # Package.
         commands.move_file(
@@ -208,8 +204,7 @@
 
         # Notarize the packages, then staple.
         if config.notarize.should_notarize():
-            uuid_to_path = {}
-            uuid_to_path[notarize.submit(pkg_path, config)] = pkg_path
-            uuid_to_path[notarize.submit(dmg_path, config)] = dmg_path
-            for uuid in notarize.wait_for_results(uuid_to_path.keys(), config):
-                notarize.staple(uuid_to_path[uuid])
+            asyncio.run(notarize.submit(pkg_path, config))
+            asyncio.run(notarize.submit(dmg_path, config))
+            notarize.staple(dmg_path)
+            notarize.staple(pkg_path)