← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad-buildd/gatherResults-in-thread into lp:launchpad-buildd

 

Colin Watson has proposed merging lp:~cjwatson/launchpad-buildd/gatherResults-in-thread into lp:launchpad-buildd.

Commit message:
Call gatherResults in a different thread so that it doesn't block responses to XML-RPC requests.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1795877 in launchpad-buildd: "livefs builds fail without logs (possibly due to multiple large artifacts)"
  https://bugs.launchpad.net/launchpad-buildd/+bug/1795877

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad-buildd/gatherResults-in-thread/+merge/356196

The test changes here really just keep the existing tests working.  I tried to get proper tests going for this, but I couldn't quite manage to hook up all the callbacks in a way that didn't crash or hang, and ran out of time for it.  However, I've done a fair amount of local testing of these changes, including manually inserting time.sleep calls into gatherResults for various build types and testing that I can usefully call status() and abort() while gatherResults is in progress.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad-buildd/gatherResults-in-thread into lp:launchpad-buildd.
=== modified file 'debian/changelog'
--- debian/changelog	2018-08-16 15:35:27 +0000
+++ debian/changelog	2018-10-05 15:49:20 +0000
@@ -1,3 +1,10 @@
+launchpad-buildd (165) UNRELEASED; urgency=medium
+
+  * Call gatherResults in a different thread so that it doesn't block
+    responses to XML-RPC requests (LP: #1795877).
+
+ -- Colin Watson <cjwatson@xxxxxxxxxx>  Fri, 05 Oct 2018 16:43:11 +0100
+
 launchpad-buildd (164) xenial; urgency=medium
 
   * Configure snap proxy settings for Subversion (LP: #1668358).

=== modified file 'lpbuildd/binarypackage.py'
--- lpbuildd/binarypackage.py	2017-08-03 13:13:08 +0000
+++ lpbuildd/binarypackage.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from __future__ import absolute_import, print_function
@@ -17,6 +17,10 @@
     PkgRelation,
     )
 from debian.debian_support import Version
+from twisted.internet import (
+    defer,
+    threads,
+    )
 
 from lpbuildd.debian import (
     DebianBuildManager,
@@ -343,14 +347,26 @@
         """Finished the sbuild run."""
         if success == SBuildExitCodes.OK:
             print("Returning build status: OK")
-            try:
-                self.gatherResults()
-            except Exception as e:
-                self._slave.log("Failed to gather results: %s" % e)
-                self._slave.buildFail()
+
+            # XXX cjwatson 2018-10-04: Refactor using inlineCallbacks once
+            # we're on Twisted >= 18.7.0
+            # (https://twistedmatrix.com/trac/ticket/4632).
+            def failed_to_gather(failure):
+                if failure.check(defer.CancelledError):
+                    if not self.alreadyfailed:
+                        self._slave.log("Build cancelled unexpectedly!")
+                        self._slave.buildFail()
+                else:
+                    self._slave.log(
+                        "Failed to gather results: %s" % failure.value)
+                    self._slave.buildFail()
                 self.alreadyfailed = True
-            self.doReapProcesses(self._state)
-            return
+
+            def reap(ignored):
+                self.doReapProcesses(self._state)
+
+            return threads.deferToThread(self.gatherResults).addErrback(
+                failed_to_gather).addCallback(reap)
 
         log_patterns = []
         stop_patterns = [["^Toolchain package versions:", re.M]]

=== modified file 'lpbuildd/debian.py'
--- lpbuildd/debian.py	2018-05-02 09:00:10 +0000
+++ lpbuildd/debian.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # Authors: Daniel Silverstone <daniel.silverstone@xxxxxxxxxxxxx>
@@ -13,6 +13,7 @@
 import re
 import signal
 
+from twisted.internet import defer
 from twisted.python import log
 
 from lpbuildd.slave import (
@@ -41,6 +42,7 @@
         self._state = DebianBuildState.INIT
         slave.emptyLog()
         self.alreadyfailed = False
+        self._iterator = None
 
     @property
     def initial_build_state(self):
@@ -115,6 +117,7 @@
         finally:
             chfile.close()
 
+    @defer.inlineCallbacks
     def iterate(self, success, quiet=False):
         # When a Twisted ProcessControl class is killed by SIGTERM,
         # which we call 'build process aborted', 'None' is returned as
@@ -129,7 +132,9 @@
         func = getattr(self, "iterate_" + self._state, None)
         if func is None:
             raise ValueError("Unknown internal state " + self._state)
-        func(success)
+        self._iterator = func(success)
+        yield self._iterator
+        self._iterator = None
 
     def iterateReap(self, state, success):
         log.msg("Iterating with success flag %s against stage %s after "
@@ -305,6 +310,13 @@
         """
         self.doReapProcesses(self._state, notify=False)
 
+    def abort(self):
+        """See `BuildManager`."""
+        super(DebianBuildManager, self).abort()
+        if self._iterator is not None:
+            self._iterator.cancel()
+            self._iterator = None
+
 
 def get_build_path(home, build_id, *extra):
     """Generate a path within the build directory.

=== modified file 'lpbuildd/livefs.py'
--- lpbuildd/livefs.py	2018-01-09 00:50:02 +0000
+++ lpbuildd/livefs.py	2018-10-05 15:49:20 +0000
@@ -1,10 +1,15 @@
-# Copyright 2013-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2013-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 
 import os
 
+from twisted.internet import (
+    defer,
+    threads,
+    )
+
 from lpbuildd.debian import (
     DebianBuildManager,
     DebianBuildState,
@@ -69,8 +74,23 @@
     def iterate_BUILD_LIVEFS(self, retcode):
         """Finished building the live filesystem."""
         if retcode == RETCODE_SUCCESS:
-            self.gatherResults()
             print("Returning build status: OK")
+
+            # XXX cjwatson 2018-10-04: Refactor using inlineCallbacks once
+            # we're on Twisted >= 18.7.0
+            # (https://twistedmatrix.com/trac/ticket/4632).
+            def failed_to_gather(failure):
+                failure.trap(defer.CancelledError)
+                if not self.alreadyfailed:
+                    self._slave.log("Build cancelled unexpectedly!")
+                    self._slave.buildFail()
+                self.alreadyfailed = True
+
+            def reap(ignored):
+                self.doReapProcesses(self._state)
+
+            return threads.deferToThread(self.gatherResults).addErrback(
+                failed_to_gather).addCallback(reap)
         elif (retcode >= RETCODE_FAILURE_INSTALL and
               retcode <= RETCODE_FAILURE_BUILD):
             if not self.alreadyfailed:

=== modified file 'lpbuildd/snap.py'
--- lpbuildd/snap.py	2018-06-07 20:08:26 +0000
+++ lpbuildd/snap.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from __future__ import print_function
@@ -30,7 +30,11 @@
     from urlparse import urlparse
 
 from twisted.application import strports
-from twisted.internet import reactor
+from twisted.internet import (
+    defer,
+    reactor,
+    threads,
+    )
 from twisted.internet.interfaces import IHalfCloseableProtocol
 from twisted.python.compat import intToBytes
 from twisted.web import (
@@ -356,8 +360,23 @@
         self.stopProxy()
         self.revokeProxyToken()
         if retcode == RETCODE_SUCCESS:
-            self.gatherResults()
             print("Returning build status: OK")
+
+            # XXX cjwatson 2018-10-04: Refactor using inlineCallbacks once
+            # we're on Twisted >= 18.7.0
+            # (https://twistedmatrix.com/trac/ticket/4632).
+            def failed_to_gather(failure):
+                failure.trap(defer.CancelledError)
+                if not self.alreadyfailed:
+                    self._slave.log("Build cancelled unexpectedly!")
+                    self._slave.buildFail()
+                self.alreadyfailed = True
+
+            def reap(ignored):
+                self.doReapProcesses(self._state)
+
+            return threads.deferToThread(self.gatherResults).addErrback(
+                failed_to_gather).addCallback(reap)
         elif (retcode >= RETCODE_FAILURE_INSTALL and
               retcode <= RETCODE_FAILURE_BUILD):
             if not self.alreadyfailed:

=== modified file 'lpbuildd/sourcepackagerecipe.py'
--- lpbuildd/sourcepackagerecipe.py	2016-01-05 14:05:42 +0000
+++ lpbuildd/sourcepackagerecipe.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 # pylint: disable-msg=E1002
 
@@ -7,11 +7,18 @@
 import os
 import re
 
+from twisted.internet import (
+    defer,
+    threads,
+    )
+
 from lpbuildd.debian import (
     DebianBuildManager,
     DebianBuildState,
     get_build_path,
 )
+
+
 RETCODE_SUCCESS = 0
 RETCODE_FAILURE_INSTALL = 200
 RETCODE_FAILURE_BUILD_TREE = 201
@@ -98,8 +105,23 @@
     def iterate_BUILD_RECIPE(self, retcode):
         """Move from BUILD_RECIPE to the next logical state."""
         if retcode == RETCODE_SUCCESS:
-            self.gatherResults()
             print("Returning build status: OK")
+
+            # XXX cjwatson 2018-10-04: Refactor using inlineCallbacks once
+            # we're on Twisted >= 18.7.0
+            # (https://twistedmatrix.com/trac/ticket/4632).
+            def failed_to_gather(failure):
+                failure.trap(defer.CancelledError)
+                if not self.alreadyfailed:
+                    self._slave.log("Build cancelled unexpectedly!")
+                    self._slave.buildFail()
+                self.alreadyfailed = True
+
+            def reap(ignored):
+                self.doReapProcesses(self._state)
+
+            return threads.deferToThread(self.gatherResults).addErrback(
+                failed_to_gather).addCallback(reap)
         elif retcode == RETCODE_FAILURE_INSTALL_BUILD_DEPS:
             if not self.alreadyfailed:
                 rx = (

=== modified file 'lpbuildd/tests/test_binarypackage.py'
--- lpbuildd/tests/test_binarypackage.py	2017-08-22 14:53:29 +0000
+++ lpbuildd/tests/test_binarypackage.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2013-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2013-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -13,6 +13,7 @@
 from debian.deb822 import PkgRelation
 from fixtures import MonkeyPatch
 from testtools import TestCase
+from testtools.deferredruntest import AsynchronousDeferredRunTest
 from testtools.matchers import (
     Contains,
     ContainsDict,
@@ -21,6 +22,7 @@
     MatchesListwise,
     Not,
     )
+from twisted.internet import defer
 from twisted.internet.task import Clock
 
 from lpbuildd.binarypackage import (
@@ -85,6 +87,9 @@
 
 class TestBinaryPackageBuildManagerIteration(TestCase):
     """Run BinaryPackageBuildManager through its iteration steps."""
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
+
     def setUp(self):
         super(TestBinaryPackageBuildManagerIteration, self).setUp()
         self.useFixture(DisableSudo())
@@ -108,6 +113,7 @@
         """Retrieve build manager's state."""
         return self.buildmanager._state
 
+    @defer.inlineCallbacks
     def startBuild(self, dscname=''):
         # The build manager's iterate() kicks off the consecutive states
         # after INIT.
@@ -123,7 +129,7 @@
         self.buildmanager._state = BinaryPackageBuildState.UPDATE
 
         # SBUILD: Build the package.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertState(
             BinaryPackageBuildState.SBUILD,
             [
@@ -147,9 +153,10 @@
             self.assertNotEqual(
                 self.buildmanager.iterate, self.buildmanager.iterators[-1])
 
+    @defer.inlineCallbacks
     def assertScansSanely(self, exit_code):
         # After building the package, reap processes.
-        self.buildmanager.iterate(exit_code)
+        yield self.buildmanager.iterate(exit_code)
         self.assertState(
             BinaryPackageBuildState.SBUILD,
             ['sharepath/slavebin/in-target', 'in-target',
@@ -166,9 +173,10 @@
              '--backend=chroot', '--series=warty', '--arch=i386',
              self.buildid], final=True)
 
+    @defer.inlineCallbacks
     def test_iterate(self):
         # The build manager iterates a normal build from start to finish.
-        self.startBuild()
+        yield self.startBuild()
 
         write_file(
             os.path.join(self.buildmanager._cachepath, 'buildlog'),
@@ -179,7 +187,7 @@
         write_file(changes_path, "I am a changes file.")
 
         # After building the package, reap processes.
-        self.assertScansSanely(SBuildExitCodes.OK)
+        yield self.assertScansSanely(SBuildExitCodes.OK)
         self.assertFalse(self.slave.wasCalled('buildFail'))
         self.assertThat(self.slave, HasWaitingFiles.byEquality({
             'foo_1_i386.changes': b'I am a changes file.',
@@ -189,6 +197,7 @@
         self.assertUnmountsSanely()
         self.assertFalse(self.slave.wasCalled('buildFail'))
 
+    @defer.inlineCallbacks
     def test_with_debug_symbols(self):
         # A build with debug symbols sets up /CurrentlyBuilding
         # appropriately, and does not pass DEB_BUILD_OPTIONS.
@@ -199,7 +208,7 @@
              'build_debug_symbols': True})
         os.makedirs(self.chrootdir)
         self.buildmanager._state = BinaryPackageBuildState.UPDATE
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertState(
             BinaryPackageBuildState.SBUILD,
             ['sharepath/slavebin/sbuild-package', 'sbuild-package',
@@ -218,6 +227,7 @@
                 Build-Debug-Symbols: yes
                 """), cb.read())
 
+    @defer.inlineCallbacks
     def test_without_debug_symbols(self):
         # A build with debug symbols sets up /CurrentlyBuilding
         # appropriately, and passes DEB_BUILD_OPTIONS=noautodbgsym.
@@ -228,7 +238,7 @@
              'build_debug_symbols': False})
         os.makedirs(self.chrootdir)
         self.buildmanager._state = BinaryPackageBuildState.UPDATE
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertState(
             BinaryPackageBuildState.SBUILD,
             ['sharepath/slavebin/sbuild-package', 'sbuild-package',
@@ -248,9 +258,10 @@
                 Purpose: PRIMARY
                 """), cb.read())
 
+    @defer.inlineCallbacks
     def test_abort_sbuild(self):
         # Aborting sbuild kills processes in the chroot.
-        self.startBuild()
+        yield self.startBuild()
 
         # Send an abort command.  The build manager reaps processes.
         self.buildmanager.abort()
@@ -267,10 +278,11 @@
         self.assertUnmountsSanely()
         self.assertFalse(self.slave.wasCalled('buildFail'))
 
+    @defer.inlineCallbacks
     def test_abort_sbuild_fail(self):
         # If killing processes in the chroot hangs, the build manager does
         # its best to clean up and fails the builder.
-        self.startBuild()
+        yield self.startBuild()
         sbuild_subprocess = self.buildmanager._subprocess
 
         # Send an abort command.  The build manager reaps processes.
@@ -300,7 +312,7 @@
 
         # When sbuild exits, it does not reap processes again, but proceeds
         # directly to UMOUNT.
-        self.buildmanager.iterate(128 + 9)  # SIGKILL
+        yield self.buildmanager.iterate(128 + 9)  # SIGKILL
         self.assertState(
             BinaryPackageBuildState.UMOUNT,
             ['sharepath/slavebin/in-target', 'in-target',
@@ -308,6 +320,7 @@
              '--backend=chroot', '--series=warty', '--arch=i386',
              self.buildid], final=True)
 
+    @defer.inlineCallbacks
     def test_abort_between_subprocesses(self):
         # If a build is aborted between subprocesses, the build manager
         # pretends that it was terminated by a signal.
@@ -324,7 +337,7 @@
              '--backend=chroot', '--series=warty', '--arch=i386',
              self.buildid], final=False)
 
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertState(
             BinaryPackageBuildState.CLEANUP,
             ['sharepath/slavebin/in-target', 'in-target',
@@ -334,10 +347,11 @@
             final=True)
         self.assertFalse(self.slave.wasCalled('builderFail'))
 
+    @defer.inlineCallbacks
     def test_missing_changes(self):
         # The build manager recovers if the expected .changes file does not
         # exist, and considers it a package build failure.
-        self.startBuild()
+        yield self.startBuild()
         write_file(
             os.path.join(self.buildmanager._cachepath, 'buildlog'),
             "I am a build log.")
@@ -348,7 +362,7 @@
             "I am a changes file.")
 
         # After building the package, reap processes.
-        self.assertScansSanely(SBuildExitCodes.OK)
+        yield self.assertScansSanely(SBuildExitCodes.OK)
         self.assertTrue(self.slave.wasCalled('buildFail'))
         self.assertThat(self.slave, HasWaitingFiles({}))
 
@@ -564,8 +578,9 @@
                 PkgRelation.parse_relations("foo <!nocheck>, bar <stage1>"),
                 {}))
 
+    @defer.inlineCallbacks
     def startDepFail(self, error, dscname=''):
-        self.startBuild(dscname=dscname)
+        yield self.startBuild(dscname=dscname)
         write_file(
             os.path.join(self.buildmanager._cachepath, 'buildlog'),
             "The following packages have unmet dependencies:\n"
@@ -574,9 +589,10 @@
             + ("a" * 4096) + "\n"
             + "Fail-Stage: install-deps\n")
 
+    @defer.inlineCallbacks
     def assertMatchesDepfail(self, error, dep):
-        self.startDepFail(error)
-        self.assertScansSanely(SBuildExitCodes.GIVENBACK)
+        yield self.startDepFail(error)
+        yield self.assertScansSanely(SBuildExitCodes.GIVENBACK)
         self.assertUnmountsSanely()
         if dep is not None:
             self.assertFalse(self.slave.wasCalled('buildFail'))
@@ -585,27 +601,32 @@
             self.assertFalse(self.slave.wasCalled('depFail'))
             self.assertTrue(self.slave.wasCalled('buildFail'))
 
+    @defer.inlineCallbacks
     def test_detects_depfail(self):
         # The build manager detects dependency installation failures.
-        self.assertMatchesDepfail(
+        yield self.assertMatchesDepfail(
             "enoent but it is not installable", "enoent")
 
+    @defer.inlineCallbacks
     def test_detects_versioned_depfail(self):
         # The build manager detects dependency installation failures.
-        self.assertMatchesDepfail(
+        yield self.assertMatchesDepfail(
             "ebadver (< 2.0) but 3.0 is to be installed", "ebadver (< 2.0)")
 
+    @defer.inlineCallbacks
     def test_detects_versioned_current_depfail(self):
         # The build manager detects dependency installation failures.
-        self.assertMatchesDepfail(
+        yield self.assertMatchesDepfail(
             "ebadver (< 2.0) but 3.0 is installed", "ebadver (< 2.0)")
 
+    @defer.inlineCallbacks
     def test_strips_depfail(self):
         # The build manager strips qualifications and restrictions from
         # dependency installation failures.
-        self.assertMatchesDepfail(
+        yield self.assertMatchesDepfail(
             "ebadver:any (>= 3.0) but 2.0 is installed", "ebadver (>= 3.0)")
 
+    @defer.inlineCallbacks
     def test_uninstallable_deps_analysis_failure(self):
         # If there are uninstallable build-dependencies and analysis can't
         # find any missing direct build-dependencies, the build manager
@@ -618,7 +639,7 @@
                 Version: 1
                 Build-Depends: uninstallable (>= 1)
                 """))
-        self.startDepFail(
+        yield self.startDepFail(
             "uninstallable (>= 1) but it is not going to be installed",
             dscname="123")
         apt_lists = os.path.join(self.chrootdir, "var", "lib", "apt", "lists")
@@ -629,11 +650,12 @@
                 Package: uninstallable
                 Version: 1
                 """))
-        self.assertScansSanely(SBuildExitCodes.GIVENBACK)
+        yield self.assertScansSanely(SBuildExitCodes.GIVENBACK)
         self.assertUnmountsSanely()
         self.assertFalse(self.slave.wasCalled('depFail'))
         self.assertTrue(self.slave.wasCalled('buildFail'))
 
+    @defer.inlineCallbacks
     def test_uninstallable_deps_analysis_depfail(self):
         # If there are uninstallable build-dependencies and analysis reports
         # some missing direct build-dependencies, the build manager marks
@@ -645,7 +667,7 @@
                 Version: 1
                 Build-Depends: ebadver (>= 2)
                 """))
-        self.startDepFail(
+        yield self.startDepFail(
             "ebadver (>= 2) but it is not going to be installed",
             dscname="123")
         apt_lists = os.path.join(self.chrootdir, "var", "lib", "apt", "lists")
@@ -656,11 +678,12 @@
                 Package: ebadver
                 Version: 1
                 """))
-        self.assertScansSanely(SBuildExitCodes.GIVENBACK)
+        yield self.assertScansSanely(SBuildExitCodes.GIVENBACK)
         self.assertUnmountsSanely()
         self.assertFalse(self.slave.wasCalled('buildFail'))
         self.assertEqual([(("ebadver (>= 2)",), {})], self.slave.depFail.calls)
 
+    @defer.inlineCallbacks
     def test_uninstallable_deps_analysis_mixed_depfail(self):
         # If there is a mix of definite and dubious dep-wait output, then
         # the build manager analyses the situation rather than trusting just
@@ -672,7 +695,7 @@
                 Version: 1
                 Build-Depends: ebadver (>= 2), uninstallable
                 """))
-        self.startBuild(dscname="123")
+        yield self.startBuild(dscname="123")
         write_file(
             os.path.join(self.buildmanager._cachepath, 'buildlog'),
             "The following packages have unmet dependencies:\n"
@@ -694,19 +717,20 @@
                 Package: uninstallable
                 Version: 1
                 """))
-        self.assertScansSanely(SBuildExitCodes.GIVENBACK)
+        yield self.assertScansSanely(SBuildExitCodes.GIVENBACK)
         self.assertUnmountsSanely()
         self.assertFalse(self.slave.wasCalled('buildFail'))
         self.assertEqual([(("ebadver (>= 2)",), {})], self.slave.depFail.calls)
 
+    @defer.inlineCallbacks
     def test_depfail_with_unknown_error_converted_to_packagefail(self):
         # The build manager converts a DEPFAIL to a PACKAGEFAIL if the
         # missing dependency can't be determined from the log.
-        self.startBuild()
+        yield self.startBuild()
         write_file(
             os.path.join(self.buildmanager._cachepath, 'buildlog'),
             "E: Everything is broken.\n")
 
-        self.assertScansSanely(SBuildExitCodes.GIVENBACK)
+        yield self.assertScansSanely(SBuildExitCodes.GIVENBACK)
         self.assertTrue(self.slave.wasCalled('buildFail'))
         self.assertFalse(self.slave.wasCalled('depFail'))

=== modified file 'lpbuildd/tests/test_livefs.py'
--- lpbuildd/tests/test_livefs.py	2017-08-26 09:51:15 +0000
+++ lpbuildd/tests/test_livefs.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2013-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -10,6 +10,8 @@
     TempDir,
     )
 from testtools import TestCase
+from testtools.deferredruntest import AsynchronousDeferredRunTest
+from twisted.internet import defer
 
 from lpbuildd.livefs import (
     LiveFilesystemBuildManager,
@@ -35,6 +37,9 @@
 
 class TestLiveFilesystemBuildManagerIteration(TestCase):
     """Run LiveFilesystemBuildManager through its iteration steps."""
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
+
     def setUp(self):
         super(TestLiveFilesystemBuildManagerIteration, self).setUp()
         self.working_dir = self.useFixture(TempDir()).path
@@ -52,6 +57,7 @@
         """Retrieve build manager's state."""
         return self.buildmanager._state
 
+    @defer.inlineCallbacks
     def startBuild(self):
         # The build manager's iterate() kicks off the consecutive states
         # after INIT.
@@ -71,7 +77,7 @@
         self.buildmanager._state = LiveFilesystemBuildState.UPDATE
 
         # BUILD_LIVEFS: Run the slave's payload to build the live filesystem.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertEqual(
             LiveFilesystemBuildState.BUILD_LIVEFS, self.getState())
         expected_command = [
@@ -85,9 +91,10 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled("chrootFail"))
 
+    @defer.inlineCallbacks
     def test_iterate(self):
         # The build manager iterates a normal build from start to finish.
-        self.startBuild()
+        yield self.startBuild()
 
         log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
         with open(log_path, "w") as log:
@@ -97,7 +104,7 @@
             "/build/livecd.ubuntu.manifest", b"I am a manifest file.")
 
         # After building the package, reap processes.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         expected_command = [
             "sharepath/slavebin/in-target", "in-target",
             "scan-for-processes",
@@ -126,9 +133,10 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled("buildFail"))
 
+    @defer.inlineCallbacks
     def test_omits_symlinks(self):
         # Symlinks in the build output are not included in gathered results.
-        self.startBuild()
+        yield self.startBuild()
 
         log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
         with open(log_path, "w") as log:
@@ -139,7 +147,7 @@
         self.buildmanager.backend.add_link(
             "/build/livecd.ubuntu.kernel", "livefs.ubuntu.kernel-generic")
 
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertThat(self.slave, HasWaitingFiles.byEquality({
             "livecd.ubuntu.kernel-generic": b"I am a kernel.",
             }))

=== modified file 'lpbuildd/tests/test_snap.py'
--- lpbuildd/tests/test_snap.py	2018-06-05 20:59:48 +0000
+++ lpbuildd/tests/test_snap.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -70,6 +70,7 @@
         """Retrieve build manager's state."""
         return self.buildmanager._state
 
+    @defer.inlineCallbacks
     def startBuild(self, args=None, options=None):
         # The build manager's iterate() kicks off the consecutive states
         # after INIT.
@@ -92,7 +93,7 @@
         self.buildmanager._state = SnapBuildState.UPDATE
 
         # BUILD_SNAP: Run the slave's payload to build the snap package.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertEqual(SnapBuildState.BUILD_SNAP, self.getState())
         expected_command = [
             "sharepath/slavebin/in-target", "in-target",
@@ -119,9 +120,10 @@
             status_file.write('{"revision_id": "dummy"}')
         self.assertEqual({"revision_id": "dummy"}, self.buildmanager.status())
 
+    @defer.inlineCallbacks
     def test_iterate(self):
         # The build manager iterates a normal build from start to finish.
-        self.startBuild()
+        yield self.startBuild()
 
         log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
         with open(log_path, "w") as log:
@@ -131,7 +133,7 @@
             "/build/test-snap/test-snap_0_all.snap", b"I am a snap package.")
 
         # After building the package, reap processes.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         expected_command = [
             "sharepath/slavebin/in-target", "in-target",
             "scan-for-processes",
@@ -159,10 +161,11 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled("buildFail"))
 
+    @defer.inlineCallbacks
     def test_iterate_with_manifest(self):
         # The build manager iterates a build that uploads a manifest from
         # start to finish.
-        self.startBuild()
+        yield self.startBuild()
 
         log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
         with open(log_path, "w") as log:
@@ -174,7 +177,7 @@
             "/build/test-snap/test-snap_0_all.manifest", b"I am a manifest.")
 
         # After building the package, reap processes.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         expected_command = [
             "sharepath/slavebin/in-target", "in-target",
             "scan-for-processes",
@@ -203,10 +206,11 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled("buildFail"))
 
+    @defer.inlineCallbacks
     def test_iterate_with_build_source_tarball(self):
         # The build manager iterates a build that uploads a source tarball
         # from start to finish.
-        self.startBuild(
+        yield self.startBuild(
             {"build_source_tarball": True}, ["--build-source-tarball"])
 
         log_path = os.path.join(self.buildmanager._cachepath, "buildlog")
@@ -219,7 +223,7 @@
             "/build/test-snap.tar.gz", b"I am a source tarball.")
 
         # After building the package, reap processes.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         expected_command = [
             "sharepath/slavebin/in-target", "in-target",
             "scan-for-processes",

=== modified file 'lpbuildd/tests/test_sourcepackagerecipe.py'
--- lpbuildd/tests/test_sourcepackagerecipe.py	2017-08-22 14:53:29 +0000
+++ lpbuildd/tests/test_sourcepackagerecipe.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2013-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2013-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -9,6 +9,8 @@
 from textwrap import dedent
 
 from testtools import TestCase
+from testtools.deferredruntest import AsynchronousDeferredRunTest
+from twisted.internet import defer
 
 from lpbuildd.sourcepackagerecipe import (
     RETCODE_FAILURE_INSTALL_BUILD_DEPS,
@@ -35,6 +37,9 @@
 
 class TestSourcePackageRecipeBuildManagerIteration(TestCase):
     """Run SourcePackageRecipeBuildManager through its iteration steps."""
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
+
     def setUp(self):
         super(TestSourcePackageRecipeBuildManagerIteration, self).setUp()
         self.working_dir = tempfile.mkdtemp()
@@ -55,6 +60,7 @@
         """Retrieve build manager's state."""
         return self.buildmanager._state
 
+    @defer.inlineCallbacks
     def startBuild(self, git=False):
         # The build manager's iterate() kicks off the consecutive states
         # after INIT.
@@ -84,7 +90,7 @@
         self.buildmanager._state = SourcePackageRecipeBuildState.UPDATE
 
         # BUILD_RECIPE: Run the slave's payload to build the source package.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertEqual(
             SourcePackageRecipeBuildState.BUILD_RECIPE, self.getState())
         expected_command = [
@@ -101,9 +107,10 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled('chrootFail'))
 
+    @defer.inlineCallbacks
     def test_iterate(self):
         # The build manager iterates a normal build from start to finish.
-        self.startBuild()
+        yield self.startBuild()
 
         log_path = os.path.join(self.buildmanager._cachepath, 'buildlog')
         with open(log_path, 'w') as log:
@@ -121,7 +128,7 @@
             manifest.write("I am a manifest file.")
 
         # After building the package, reap processes.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         expected_command = [
             'sharepath/slavebin/in-target', 'in-target',
             'scan-for-processes',
@@ -153,9 +160,10 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled('buildFail'))
 
+    @defer.inlineCallbacks
     def test_iterate_BUILD_RECIPE_install_build_deps_depfail(self):
         # The build manager can detect dependency wait states.
-        self.startBuild()
+        yield self.startBuild()
 
         log_path = os.path.join(self.buildmanager._cachepath, 'buildlog')
         with open(log_path, 'w') as log:
@@ -166,7 +174,7 @@
                 " but it is not going to be installed.\n")
 
         # The buildmanager calls depFail correctly and reaps processes.
-        self.buildmanager.iterate(RETCODE_FAILURE_INSTALL_BUILD_DEPS)
+        yield self.buildmanager.iterate(RETCODE_FAILURE_INSTALL_BUILD_DEPS)
         expected_command = [
             'sharepath/slavebin/in-target', 'in-target',
             'scan-for-processes',
@@ -196,17 +204,18 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled('buildFail'))
 
+    @defer.inlineCallbacks
     def test_iterate_BUILD_RECIPE_install_build_deps_buildfail(self):
         # If the build manager cannot detect a dependency wait from a
         # build-dependency installation failure, it fails the build.
-        self.startBuild()
+        yield self.startBuild()
 
         log_path = os.path.join(self.buildmanager._cachepath, 'buildlog')
         with open(log_path, 'w') as log:
             log.write("I am a failing build log.")
 
         # The buildmanager calls buildFail correctly and reaps processes.
-        self.buildmanager.iterate(RETCODE_FAILURE_INSTALL_BUILD_DEPS)
+        yield self.buildmanager.iterate(RETCODE_FAILURE_INSTALL_BUILD_DEPS)
         expected_command = [
             'sharepath/slavebin/in-target', 'in-target',
             'scan-for-processes',
@@ -234,8 +243,9 @@
         self.assertEqual(
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
 
+    @defer.inlineCallbacks
     def test_iterate_git(self):
         # Starting a git-based recipe build passes the correct option.  (The
         # rest of the build is identical to bzr-based recipe builds from the
         # build manager's point of view.)
-        self.startBuild(git=True)
+        yield self.startBuild(git=True)

=== modified file 'lpbuildd/tests/test_translationtemplatesbuildmanager.py'
--- lpbuildd/tests/test_translationtemplatesbuildmanager.py	2017-09-08 15:57:18 +0000
+++ lpbuildd/tests/test_translationtemplatesbuildmanager.py	2018-10-05 15:49:20 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -10,6 +10,8 @@
     TempDir,
     )
 from testtools import TestCase
+from testtools.deferredruntest import AsynchronousDeferredRunTest
+from twisted.internet import defer
 
 from lpbuildd.target.generate_translation_templates import (
     RETCODE_FAILURE_BUILD,
@@ -39,6 +41,9 @@
 
 class TestTranslationTemplatesBuildManagerIteration(TestCase):
     """Run TranslationTemplatesBuildManager through its iteration steps."""
+
+    run_tests_with = AsynchronousDeferredRunTest.make_factory(timeout=5)
+
     def setUp(self):
         super(TestTranslationTemplatesBuildManagerIteration, self).setUp()
         self.working_dir = self.useFixture(TempDir()).path
@@ -57,6 +62,7 @@
         """Retrieve build manager's state."""
         return self.buildmanager._state
 
+    @defer.inlineCallbacks
     def test_iterate(self):
         # Two iteration steps are specific to this build manager.
         url = 'lp:~my/branch'
@@ -74,7 +80,7 @@
 
         # GENERATE: Run the slave's payload, the script that generates
         # templates.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         self.assertEqual(
             TranslationTemplatesBuildState.GENERATE, self.getState())
         expected_command = [
@@ -95,7 +101,7 @@
             outfile_path, b"I am a template tarball. Seriously.")
 
         # After generating templates, reap processes.
-        self.buildmanager.iterate(0)
+        yield self.buildmanager.iterate(0)
         expected_command = [
             'sharepath/slavebin/in-target', 'in-target',
             'scan-for-processes',
@@ -128,6 +134,7 @@
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
         self.assertFalse(self.slave.wasCalled('buildFail'))
 
+    @defer.inlineCallbacks
     def test_iterate_fail_GENERATE_install(self):
         # See that a GENERATE that fails at the install step is handled
         # properly.
@@ -141,7 +148,7 @@
         self.buildmanager._state = TranslationTemplatesBuildState.GENERATE
 
         # The buildmanager fails and reaps processes.
-        self.buildmanager.iterate(RETCODE_FAILURE_INSTALL)
+        yield self.buildmanager.iterate(RETCODE_FAILURE_INSTALL)
         self.assertEqual(
             TranslationTemplatesBuildState.GENERATE, self.getState())
         expected_command = [
@@ -169,6 +176,7 @@
         self.assertEqual(
             self.buildmanager.iterate, self.buildmanager.iterators[-1])
 
+    @defer.inlineCallbacks
     def test_iterate_fail_GENERATE_build(self):
         # See that a GENERATE that fails at the build step is handled
         # properly.
@@ -182,7 +190,7 @@
         self.buildmanager._state = TranslationTemplatesBuildState.GENERATE
 
         # The buildmanager fails and reaps processes.
-        self.buildmanager.iterate(RETCODE_FAILURE_BUILD)
+        yield self.buildmanager.iterate(RETCODE_FAILURE_BUILD)
         expected_command = [
             'sharepath/slavebin/in-target', 'in-target',
             'scan-for-processes',

=== modified file 'lpbuildd/translationtemplates.py'
--- lpbuildd/translationtemplates.py	2017-09-08 15:57:18 +0000
+++ lpbuildd/translationtemplates.py	2018-10-05 15:49:20 +0000
@@ -1,10 +1,15 @@
-# Copyright 2010-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 
 import os
 
+from twisted.internet import (
+    defer,
+    threads,
+    )
+
 from lpbuildd.debian import (
     DebianBuildManager,
     DebianBuildState,
@@ -63,7 +68,21 @@
         """Template generation finished."""
         if retcode == 0:
             # It worked! Now let's bring in the harvest.
-            self.gatherResults()
+            # XXX cjwatson 2018-10-04: Refactor using inlineCallbacks once
+            # we're on Twisted >= 18.7.0
+            # (https://twistedmatrix.com/trac/ticket/4632).
+            def failed_to_gather(failure):
+                failure.trap(defer.CancelledError)
+                if not self.alreadyfailed:
+                    self._slave.log("Build cancelled unexpectedly!")
+                    self._slave.buildFail()
+                self.alreadyfailed = True
+
+            def reap(ignored):
+                self.doReapProcesses(self._state)
+
+            return threads.deferToThread(self.gatherResults).addErrback(
+                failed_to_gather).addCallback(reap)
         else:
             if not self.alreadyfailed:
                 if retcode == RETCODE_FAILURE_INSTALL: