Repository: mitogen-hq/mitogen (language: python)

Issue:
SyntaxError: invalid syntax during build
This happens with python3 (3.7) builds of mitogen 2.8 on Debian GNU/Linux "sid":

```
byte-compiling /<<PKGBUILDDIR>>/debian/python3-mitogen/usr/lib/python3.7/dist-packages/ansible_mitogen/__init__.py to __init__.cpython-37.pyc
byte-compiling /<<PKGBUILDDIR>>/debian/python3-mitogen/usr/lib/python3.7/dist-packages/ansible_mitogen/runner.py to runner.cpython-37.pyc
running install_egg_info
running egg_info
creating mitogen.egg-info
writing mitogen.egg-info/PKG-INFO
writing dependency_links to mitogen.egg-info/dependency_links.txt
writing top-level names to mitogen.egg-info/top_level.txt
writing manifest file 'mitogen.egg-info/SOURCES.txt'
reading manifest file 'mitogen.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
writing manifest file 'mitogen.egg-info/SOURCES.txt'
Copying mitogen.egg-info to /<<PKGBUILDDIR>>/debian/python3-mitogen/usr/lib/python3.7/dist-packages/mitogen-0.2.8.egg-info
Skipping SOURCES.txt
running install_scripts
  File "/usr/lib/python3.7/dist-packages/ansible_mitogen/compat/simplejson/decoder.py", line 148
    def JSONObject((s, end), encoding, strict, scan_once, object_hook, _w=WHITESPACE.match, _ws=WHITESPACE_STR):
                   ^
SyntaxError: invalid syntax

  File "/usr/lib/python3.7/dist-packages/ansible_mitogen/compat/simplejson/encoder.py", line 264
    False=False,
        ^
SyntaxError: invalid syntax
```

Full logs can be viewed at http://phd-sid.ethz.ch/debian/python-mitogen/

Ah Fedora is patching parts of it:
https://src.fedoraproject.org/rpms/python-mitogen/blob/master/f/remove-compat.patch
CI failure: faulthandler fallback package is no longer available
`faulthandler` is a stdlib module in Python 3.3+. For a long time a PyPI package of the same name was available for earlier Python releases. That package has since been removed from PyPI, and the source respoitory archived. So we should not rely on it.

See https://github.com/vstinner/faulthandler


Discussion / hints:
That's because of Python 3, hmm :)   Probably the simplest fix is to rename those files from `.py` -- setuptools shouldn't be looking at them at all. They are only used on Python 2.4. This is a super easy fix, will have it in 0.2.10.

Apologies for the convenience and thanks for reporting
To be clear, the `.py` rename requires an accompanying code change in (I believe) `ansible_mitogen/`
What I did is rm -rf */compat and then build. That is good enough?
FWIW seeing this on FreeBSD ports too, which builds both 3.x and 2.x python flavours.

```
byte-compiling /tmp/usr/ports/sysutils/py-mitogen/work-py36/stage/usr/local/lib/python3.6/site-packages/mitogen/parent.py to parent.cpython-36.pyc
byte-compiling /tmp/usr/ports/sysutils/py-mitogen/work-py36/stage/usr/local/lib/python3.6/site-packages/mitogen/buildah.py to buildah.cpython-36.pyc
writing byte-compilation script '/tmp/tmpx7e7q3q1.py'
/usr/local/bin/python3.6 /tmp/tmpx7e7q3q1.py
  File "/usr/local/lib/python3.6/site-packages/ansible_mitogen/compat/simplejson/decoder.py", line 148
    def JSONObject((s, end), encoding, strict, scan_once, object_hook, _w=WHITESPACE.match, _ws=WHITESPACE_STR):
                   ^
SyntaxError: invalid syntax

  File "/usr/local/lib/python3.6/site-packages/ansible_mitogen/compat/simplejson/encoder.py", line 264
    False=False,
        ^
SyntaxError: invalid syntax

removing /tmp/tmpx7e7q3q1.py
```


Fix patch:
diff --git a/.ci/azure-pipelines-steps.yml b/.ci/azure-pipelines-steps.yml
index 50f1a16b9..b7b094e3f 100644
--- a/.ci/azure-pipelines-steps.yml
+++ b/.ci/azure-pipelines-steps.yml
@@ -1,8 +1,9 @@
+# Each step entry runs a task (Azure Pipelines analog of an Ansible module).
+# https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/?view=azure-pipelines&viewFallbackFrom=azure-devops#tool
 
-parameters:
-  name: ''
-  pool: ''
-  sign: false
+# `{script: ...}` is shorthand for `{task: CmdLine@<mumble>, inputs: {script: ...}}`.
+# https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/steps-script?view=azure-pipelines
+# https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/cmd-line-v2?view=azure-pipelines
 
 steps:
 - task: UsePythonVersion@0
@@ -11,7 +12,18 @@ steps:
     versionSpec: '$(python.version)'
   condition: ne(variables['python.version'], '')
 
-- script: python -mpip install tox
+- script: |
+    type python
+    python --version
+  displayName: Show python version
+
+- script: |
+    sudo apt-get update
+    sudo apt-get install -y python2-dev python3-pip virtualenv
+  displayName: Install build deps
+  condition: and(eq(variables['python.version'], ''), eq(variables['Agent.OS'], 'Linux'))
+
+- script: python -mpip install "tox<4.0"
   displayName: Install tooling
 
 - script: python -mtox -e "$(tox.env)"
diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml
index 6f45397b7..4513ae9f5 100644
--- a/.ci/azure-pipelines.yml
+++ b/.ci/azure-pipelines.yml
@@ -9,69 +9,18 @@
   #ANSIBLE_VERBOSITY: 3
 
 jobs:
-- job: Mac1015
+- job: mac11
   # vanilla Ansible is really slow
   timeoutInMinutes: 120
   steps:
   - template: azure-pipelines-steps.yml
   pool:
-    # https://github.com/actions/virtual-environments/blob/main/images/macos/macos-10.15-Readme.md
-    vmImage: macOS-10.15
-  strategy:
-    matrix:
-      Mito_27:
-        python.version: '2.7'
-        tox.env: py27-mode_mitogen
-      Mito_36:
-        python.version: '3.6'
-        tox.env: py36-mode_mitogen
-      Mito_310:
-        python.version: '3.10'
-        tox.env: py310-mode_mitogen
-
-      # TODO: test python3, python3 tests are broken
-      Loc_27_210:
-        python.version: '2.7'
-        tox.env: py27-mode_localhost-ansible2.10
-      Loc_27_3:
-        python.version: '2.7'
-        tox.env: py27-mode_localhost-ansible3
-      Loc_27_4:
-        python.version: '2.7'
-        tox.env: py27-mode_localhost-ansible4
-
-      # NOTE: this hangs when ran in Ubuntu 18.04
-      Van_27_210:
-        python.version: '2.7'
-        tox.env: py27-mode_localhost-ansible2.10
-        STRATEGY: linear
-        ANSIBLE_SKIP_TAGS: resource_intensive
-      Van_27_3:
-        python.version: '2.7'
-        tox.env: py27-mode_localhost-ansible3
-        STRATEGY: linear
-        ANSIBLE_SKIP_TAGS: resource_intensive
-      Van_27_4:
-        python.version: '2.7'
-        tox.env: py27-mode_localhost-ansible4
-        STRATEGY: linear
-        ANSIBLE_SKIP_TAGS: resource_intensive
-
-- job: Mac11
-  # vanilla Ansible is really slow
-  timeoutInMinutes: 120
-  steps:
-  - template: azure-pipelines-steps.yml
-  pool:
-    # https://github.com/actions/virtual-environments/blob/main/images/macos/
+    # https://github.com/actions/runner-images/blob/main/images/macos/macos-11-Readme.md
     vmImage: macOS-11
   strategy:
     matrix:
       Mito_27:
         tox.env: py27-mode_mitogen
-      Mito_37:
-        python.version: '3.7'
-        tox.env: py37-mode_mitogen
       Mito_310:
         python.version: '3.10'
         tox.env: py310-mode_mitogen
@@ -79,8 +28,6 @@ jobs:
       # TODO: test python3, python3 tests are broken
       Loc_27_210:
         tox.env: py27-mode_localhost-ansible2.10
-      Loc_27_3:
-        tox.env: py27-mode_localhost-ansible3
       Loc_27_4:
         tox.env: py27-mode_localhost-ansible4
 
@@ -89,10 +36,6 @@ jobs:
         tox.env: py27-mode_localhost-ansible2.10
         STRATEGY: linear
         ANSIBLE_SKIP_TAGS: resource_intensive
-      Van_27_3:
-        tox.env: py27-mode_localhost-ansible3
-        STRATEGY: linear
-        ANSIBLE_SKIP_TAGS: resource_intensive
       Van_27_4:
         tox.env: py27-mode_localhost-ansible4
         STRATEGY: linear
@@ -100,38 +43,29 @@ jobs:
 
 - job: Linux
   pool:
-    # https://github.com/actions/virtual-environments/blob/main/images/linux/Ubuntu1804-README.md
-    vmImage: "Ubuntu 18.04"
+    # https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu2004-Readme.md
+    vmImage: ubuntu-20.04
   steps:
   - template: azure-pipelines-steps.yml
   strategy:
     matrix:
       Mito_27_centos6:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_centos6
       Mito_27_centos7:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_centos7
       Mito_27_centos8:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_centos8
       Mito_27_debian9:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_debian9
       Mito_27_debian10:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_debian10
       Mito_27_debian11:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_debian11
       Mito_27_ubuntu1604:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_ubuntu1604
       Mito_27_ubuntu1804:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_ubuntu1804
       Mito_27_ubuntu2004:
-        python.version: '2.7'
         tox.env: py27-mode_mitogen-distro_ubuntu2004
 
       Mito_36_centos6:
@@ -190,59 +124,14 @@ jobs:
         python.version: '3.10'
         tox.env: py310-mode_mitogen-distro_ubuntu2004
 
-      #DebOps_2460_27_27:
-        #python.version: '2.7'
-        #MODE: debops_common
-        #VER: 2.4.6.0
-
-      #DebOps_262_36_27:
-        #python.version: '3.6'
-        #MODE: debops_common
-        #VER: 2.6.2
-
-      #Ansible_2460_26:
-        #python.version: '2.7'
-        #MODE: ansible
-        #VER: 2.4.6.0
-
-      #Ansible_262_26:
-        #python.version: '2.7'
-        #MODE: ansible
-        #VER: 2.6.2
-
-      #Ansible_2460_36:
-        #python.version: '3.6'
-        #MODE: ansible
-        #VER: 2.4.6.0
-
-      #Ansible_262_36:
-        #python.version: '3.6'
-        #MODE: ansible
-        #VER: 2.6.2
-
-      #Vanilla_262_27:
-        #python.version: '2.7'
-        #MODE: ansible
-        #VER: 2.6.2
-        #DISTROS: debian
-        #STRATEGY: linear
-
       Ans_27_210:
-        python.version: '2.7'
         tox.env: py27-mode_ansible-ansible2.10
-      Ans_27_3:
-        python.version: '2.7'
-        tox.env: py27-mode_ansible-ansible3
       Ans_27_4:
-        python.version: '2.7'
         tox.env: py27-mode_ansible-ansible4
 
       Ans_36_210:
         python.version: '3.6'
         tox.env: py36-mode_ansible-ansible2.10
-      Ans_36_3:
-        python.version: '3.6'
-        tox.env: py36-mode_ansible-ansible3
       Ans_36_4:
         python.version: '3.6'
         tox.env: py36-mode_ansible-ansible4
@@ -259,3 +148,6 @@ jobs:
       Ans_310_5:
         python.version: '3.10'
         tox.env: py310-mode_ansible-ansible5
+      Ans_310_6:
+        python.version: '3.10'
+        tox.env: py310-mode_ansible-ansible6
diff --git a/.ci/ci_lib.py b/.ci/ci_lib.py
index 000a7a838..47a8ddf3d 100644
--- a/.ci/ci_lib.py
+++ b/.ci/ci_lib.py
@@ -2,13 +2,16 @@
 from __future__ import print_function
 
 import atexit
+import errno
 import os
+import re
 import shlex
 import shutil
-import subprocess
 import sys
 import tempfile
 
+import subprocess32 as subprocess
+
 try:
     import urlparse
 except ImportError:
@@ -30,40 +33,30 @@ def print(*args, **kwargs):
         file.flush()
 
 
-#
-# check_output() monkeypatch cutpasted from testlib.py
-#
-
-def subprocess__check_output(*popenargs, **kwargs):
-    # Missing from 2.6.
-    process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs)
-    output, _ = process.communicate()
-    retcode = process.poll()
-    if retcode:
-        cmd = kwargs.get("args")
-        if cmd is None:
-            cmd = popenargs[0]
-        raise subprocess.CalledProcessError(retcode, cmd)
-    return output
-
-if not hasattr(subprocess, 'check_output'):
-    subprocess.check_output = subprocess__check_output
-
+def _have_cmd(args):
+    try:
+        subprocess.run(
+            args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+        )
+    except OSError as exc:
+        if exc.errno == errno.ENOENT:
+            return False
+        raise
+    except subprocess.CallProcessError:
+        return False
+    return True
 
-# ------------------
 
 def have_apt():
-    proc = subprocess.Popen('apt --help >/dev/null 2>/dev/null', shell=True)
-    return proc.wait() == 0
+    return _have_cmd(['apt', '--help'])
+
 
 def have_brew():
-    proc = subprocess.Popen('brew help >/dev/null 2>/dev/null', shell=True)
-    return proc.wait() == 0
+    return _have_cmd(['brew', 'help'])
 
 
 def have_docker():
-    proc = subprocess.Popen('docker info >/dev/null 2>/dev/null', shell=True)
-    return proc.wait() == 0
+    return _have_cmd(['docker', 'info'])
 
 
 def _argv(s, *args):
@@ -229,31 +222,20 @@ def get_docker_hostname():
     return parsed.netloc.partition(':')[0]
 
 
-def image_for_distro(distro):
-    """Return the container image name or path for a test distro name.
-
-    The returned value is suitable for use with `docker pull`.
-
-    >>> image_for_distro('centos5')
-    'public.ecr.aws/n5z0e8q9/centos5-test'
-    >>> image_for_distro('centos5-something_custom')
-    'public.ecr.aws/n5z0e8q9/centos5-test'
-    """
-    return 'public.ecr.aws/n5z0e8q9/%s-test' % (distro.partition('-')[0],)
-
-
 def make_containers(name_prefix='', port_offset=0):
     """
     >>> import pprint
-    >>> BASE_PORT=2200; DISTROS=['debian', 'centos6']
+    >>> BASE_PORT=2200; DISTROS=['debian11', 'centos6']
     >>> pprint.pprint(make_containers())
-    [{'distro': 'debian',
+    [{'distro': 'debian11',
+      'family': 'debian',
       'hostname': 'localhost',
-      'image': 'public.ecr.aws/n5z0e8q9/debian-test',
-      'name': 'target-debian-1',
+      'image': 'public.ecr.aws/n5z0e8q9/debian11-test',
+      'name': 'target-debian11-1',
       'port': 2201,
       'python_path': '/usr/bin/python'},
      {'distro': 'centos6',
+      'family': 'centos',
       'hostname': 'localhost',
       'image': 'public.ecr.aws/n5z0e8q9/centos6-test',
       'name': 'target-centos6-2',
@@ -261,31 +243,39 @@ def make_containers(name_prefix='', port_offset=0):
       'python_path': '/usr/bin/python'}]
     """
     docker_hostname = get_docker_hostname()
-    firstbit = lambda s: (s+'-').split('-')[0]
-    secondbit = lambda s: (s+'-').split('-')[1]
-
+    distro_pattern = re.compile(r'''
+        (?P<distro>(?P<family>[a-z]+)[0-9]+)
+        (?:-(?P<py>py3))?
+        (?:\*(?P<count>[0-9]+))?
+        ''',
+        re.VERBOSE,
+    )
     i = 1
     lst = []
 
     for distro in DISTROS:
-        distro, star, count = distro.partition('*')
-        if star:
+        d = distro_pattern.match(distro).groupdict(default=None)
+        distro = d['distro']
+        family = d['family']
+        image = 'public.ecr.aws/n5z0e8q9/%s-test' % (distro,)
+
+        if d['py'] == 'py3':
+            python_path = '/usr/bin/python3'
+        else:
+            python_path = '/usr/bin/python'
+
+        if d['count']:
             count = int(count)
         else:
             count = 1
 
         for x in range(count):
             lst.append({
-                "distro": firstbit(distro),
-                "image": image_for_distro(distro),
+                "distro": distro, "family": family, "image": image,
                 "name": name_prefix + ("target-%s-%s" % (distro, i)),
                 "hostname": docker_hostname,
                 "port": BASE_PORT + i + port_offset,
-                "python_path": (
-                    '/usr/bin/python3'
-                    if secondbit(distro) == 'py3'
-                    else '/usr/bin/python'
-                )
+                "python_path": python_path,
             })
             i += 1
 
@@ -310,18 +300,24 @@ def proc_is_docker(pid):
 
 
 def get_interesting_procs(container_name=None):
+    """
+    Return a list of (pid, line) tuples for processes considered interesting.
+    """
     args = ['ps', 'ax', '-oppid=', '-opid=', '-ocomm=', '-ocommand=']
     if container_name is not None:
         args = ['docker', 'exec', container_name] + args
 
     out = []
-    for line in subprocess__check_output(args).decode().splitlines():
+    for line in subprocess.check_output(args).decode().splitlines():
         ppid, pid, comm, rest = line.split(None, 3)
         if (
             (
                 any(comm.startswith(s) for s in INTERESTING_COMMS) or
                 'mitogen:' in rest
             ) and
+            (
+                'WALinuxAgent' not in rest
+            ) and
             (
                 container_name is not None or
                 (not proc_is_docker(pid))
diff --git a/ansible_mitogen/compat/simplejson/__init__.py b/ansible_mitogen/compat/simplejson/__init__.py
deleted file mode 100644
index d5b4d3991..000000000
--- a/ansible_mitogen/compat/simplejson/__init__.py
+++ /dev/null
@@ -1,318 +0,0 @@
-r"""JSON (JavaScript Object Notation) <http://json.org> is a subset of
-JavaScript syntax (ECMA-262 3rd edition) used as a lightweight data
-interchange format.
-
-:mod:`simplejson` exposes an API familiar to users of the standard library
-:mod:`marshal` and :mod:`pickle` modules. It is the externally maintained
-version of the :mod:`json` library contained in Python 2.6, but maintains
-compatibility with Python 2.4 and Python 2.5 and (currently) has
-significant performance advantages, even without using the optional C
-extension for speedups.
-
-Encoding basic Python object hierarchies::
-
-    >>> import simplejson as json
-    >>> json.dumps(['foo', {'bar': ('baz', None, 1.0, 2)}])
-    '["foo", {"bar": ["baz", null, 1.0, 2]}]'
-    >>> print json.dumps("\"foo\bar")
-    "\"foo\bar"
-    >>> print json.dumps(u'\u1234')
-    "\u1234"
-    >>> print json.dumps('\\')
-    "\\"
-    >>> print json.dumps({"c": 0, "b": 0, "a": 0}, sort_keys=True)
-    {"a": 0, "b": 0, "c": 0}
-    >>> from StringIO import StringIO
-    >>> io = StringIO()
-    >>> json.dump(['streaming API'], io)
-    >>> io.getvalue()
-    '["streaming API"]'
-
-Compact encoding::
-
-    >>> import simplejson as json
-    >>> json.dumps([1,2,3,{'4': 5, '6': 7}], separators=(',',':'))
-    '[1,2,3,{"4":5,"6":7}]'
-
-Pretty printing::
-
-    >>> import simplejson as json
-    >>> s = json.dumps({'4': 5, '6': 7}, sort_keys=True, indent=4)
-    >>> print '\n'.join([l.rstrip() for l in  s.splitlines()])
-    {
-        "4": 5,
-        "6": 7
-    }
-
-Decoding JSON::
-
-    >>> import simplejson as json
-    >>> obj = [u'foo', {u'bar': [u'baz', None, 1.0, 2]}]
-    >>> json.loads('["foo", {"bar":["baz", null, 1.0, 2]}]') == obj
-    True
-    >>> json.loads('"\\"foo\\bar"') == u'"foo\x08ar'
-    True
-    >>> from StringIO import StringIO
-    >>> io = StringIO('["streaming API"]')
-    >>> json.load(io)[0] == 'streaming API'
-    True
-
-Specializing JSON object decoding::
-
-    >>> import simplejson as json
-    >>> def as_complex(dct):
-    ...     if '__complex__' in dct:
-    ...         return complex(dct['real'], dct['imag'])
-    ...     return dct
-    ...
-    >>> json.loads('{"__complex__": true, "real": 1, "imag": 2}',
-    ...     object_hook=as_complex)
-    (1+2j)
-    >>> import decimal
-    >>> json.loads('1.1', parse_float=decimal.Decimal) == decimal.Decimal('1.1')
-    True
-
-Specializing JSON object encoding::
-
-    >>> import simplejson as json
-    >>> def encode_complex(obj):
-    ...     if isinstance(obj, complex):
-    ...         return [obj.real, obj.imag]
-    ...     raise TypeError(repr(o) + " is not JSON serializable")
-    ...
-    >>> json.dumps(2 + 1j, default=encode_complex)
-    '[2.0, 1.0]'
-    >>> json.JSONEncoder(default=encode_complex).encode(2 + 1j)
-    '[2.0, 1.0]'
-    >>> ''.join(json.JSONEncoder(default=encode_complex).iterencode(2 + 1j))
-    '[2.0, 1.0]'
-
-
-Using simplejson.tool from the shell to validate and pretty-print::
-
-    $ echo '{"json":"obj"}' | python -m simplejson.tool
-    {
-        "json": "obj"
-    }
-    $ echo '{ 1.2:3.4}' | python -m simplejson.tool
-    Expecting property name: line 1 column 2 (char 2)
-"""
-__version__ = '2.0.9'
-__all__ = [
-    'dump', 'dumps', 'load', 'loads',
-    'JSONDecoder', 'JSONEncoder',
-]
-
-__author__ = 'Bob Ippolito <bob@redivi.com>'
-
-from decoder import JSONDecoder
-from encoder import JSONEncoder
-
-_default_encoder = JSONEncoder(
-    skipkeys=False,
-    ensure_ascii=True,
-    check_circular=True,
-    allow_nan=True,
-    indent=None,
-    separators=None,
-    encoding='utf-8',
-    default=None,
-)
-
-def dump(obj, fp, skipkeys=False, ensure_ascii=True, check_circular=True,
-        allow_nan=True, cls=None, indent=None, separators=None,
-        encoding='utf-8', default=None, **kw):
-    """Serialize ``obj`` as a JSON formatted stream to ``fp`` (a
-    ``.write()``-supporting file-like object).
-
-    If ``skipkeys`` is true then ``dict`` keys that are not basic types
-    (``str``, ``unicode``, ``int``, ``long``, ``float``, ``bool``, ``None``)
-    will be skipped instead of raising a ``TypeError``.
-
-    If ``ensure_ascii`` is false, then the some chunks written to ``fp``
-    may be ``unicode`` instances, subject to normal Python ``str`` to
-    ``unicode`` coercion rules. Unless ``fp.write()`` explicitly
-    understands ``unicode`` (as in ``codecs.getwriter()``) this is likely
-    to cause an error.
-
-    If ``check_circular`` is false, then the circular reference check
-    for container types will be skipped and a circular reference will
-    result in an ``OverflowError`` (or worse).
-
-    If ``allow_nan`` is false, then it will be a ``ValueError`` to
-    serialize out of range ``float`` values (``nan``, ``inf``, ``-inf``)
-    in strict compliance of the JSON specification, instead of using the
-    JavaScript equivalents (``NaN``, ``Infinity``, ``-Infinity``).
-
-    If ``indent`` is a non-negative integer, then JSON array elements and object
-    members will be pretty-printed with that indent level. An indent level
-    of 0 will only insert newlines. ``None`` is the most compact representation.
-
-    If ``separators`` is an ``(item_separator, dict_separator)`` tuple
-    then it will be used instead of the default ``(', ', ': ')`` separators.
-    ``(',', ':')`` is the most compact JSON representation.
-
-    ``encoding`` is the character encoding for str instances, default is UTF-8.
-
-    ``default(obj)`` is a function that should return a serializable version
-    of obj or raise TypeError. The default simply raises TypeError.
-
-    To use a custom ``JSONEncoder`` subclass (e.g. one that overrides the
-    ``.default()`` method to serialize additional types), specify it with
-    the ``cls`` kwarg.
-
-    """
-    # cached encoder
-    if (not skipkeys and ensure_ascii and
-        check_circular and allow_nan and
-        cls is None and indent is None and separators is None and
-        encoding == 'utf-8' and default is None and not kw):
-        iterable = _default_encoder.iterencode(obj)
-    else:
-        if cls is None:
-            cls = JSONEncoder
-        iterable = cls(skipkeys=skipkeys, ensure_ascii=ensure_ascii,
-            check_circular=check_circular, allow_nan=allow_nan, indent=indent,
-            separators=separators, encoding=encoding,
-            default=default, **kw).iterencode(obj)
-    # could accelerate with writelines in some versions of Python, at
-    # a debuggability cost
-    for chunk in iterable:
-        fp.write(chunk)
-
-
-def dumps(obj, skipkeys=False, ensure_ascii=True, check_circular=True,
-        allow_nan=True, cls=None, indent=None, separators=None,
-        encoding='utf-8', default=None, **kw):
-    """Serialize ``obj`` to a JSON formatted ``str``.
-
-    If ``skipkeys`` is false then ``dict`` keys that are not basic types
-    (``str``, ``unicode``, ``int``, ``long``, ``float``, ``bool``, ``None``)
-    will be skipped instead of raising a ``TypeError``.
-
-    If ``ensure_ascii`` is false, then the return value will be a
-    ``unicode`` instance subject to normal Python ``str`` to ``unicode``
-    coercion rules instead of being escaped to an ASCII ``str``.
-
-    If ``check_circular`` is false, then the circular reference check
-    for container types will be skipped and a circular reference will
-    result in an ``OverflowError`` (or worse).
-
-    If ``allow_nan`` is false, then it will be a ``ValueError`` to
-    serialize out of range ``float`` values (``nan``, ``inf``, ``-inf``) in
-    strict compliance of the JSON specification, instead of using the
-    JavaScript equivalents (``NaN``, ``Infinity``, ``-Infinity``).
-
-    If ``indent`` is a non-negative integer, then JSON array elements and
-    object members will be pretty-printed with that indent level. An indent
-    level of 0 will only insert newlines. ``None`` is the most compact
-    representation.
-
-    If ``separators`` is an ``(item_separator, dict_separator)`` tuple
-    then it will be used instead of the default ``(', ', ': ')`` separators.
-    ``(',', ':')`` is the most compact JSON representation.
-
-    ``encoding`` is the character encoding for str instances, default is UTF-8.
-
-    ``default(obj)`` is a function that should return a serializable version
-    of obj or raise TypeError. The default simply raises TypeError.
-
-    To use a custom ``JSONEncoder`` subclass (e.g. one that overrides the
-    ``.default()`` method to serialize additional types), specify it with
-    the ``cls`` kwarg.
-
-    """
-    # cached encoder
-    if (not skipkeys and ensure_ascii and
-        check_circular and allow_nan and
-        cls is None and indent is None and separators is None and
-        encoding == 'utf-8' and default is None and not kw):
-        return _default_encoder.encode(obj)
-    if cls is None:
-        cls = JSONEncoder
-    return cls(
-        skipkeys=skipkeys, ensure_ascii=ensure_ascii,
-        check_circular=check_circular, allow_nan=allow_nan, indent=indent,
-        separators=separators, encoding=encoding, default=default,
-        **kw).encode(obj)
-
-
-_default_decoder = JSONDecoder(encoding=None, object_hook=None)
-
-
-def load(fp, encoding=None, cls=None, object_hook=None, parse_float=None,
-        parse_int=None, parse_constant=None, **kw):
-    """Deserialize ``fp`` (a ``.read()``-supporting file-like object containing
-    a JSON document) to a Python object.
-
-    If the contents of ``fp`` is encoded with an ASCII based encoding other
-    than utf-8 (e.g. latin-1), then an appropriate ``encoding`` name must
-    be specified. Encodings that are not ASCII based (such as UCS-2) are
-    not allowed, and should be wrapped with
-    ``codecs.getreader(fp)(encoding)``, or simply decoded to a ``unicode``
-    object and passed to ``loads()``
-
-    ``object_hook`` is an optional function that will be called with the
-    result of any object literal decode (a ``dict``). The return value of
-    ``object_hook`` will be used instead of the ``dict``. This feature
-    can be used to implement custom decoders (e.g. JSON-RPC class hinting).
-
-    To use a custom ``JSONDecoder`` subclass, specify it with the ``cls``
-    kwarg.
-
-    """
-    return loads(fp.read(),
-        encoding=encoding, cls=cls, object_hook=object_hook,
-        parse_float=parse_float, parse_int=parse_int,
-        parse_constant=parse_constant, **kw)
-
-
-def loads(s, encoding=None, cls=None, object_hook=None, parse_float=None,
-        parse_int=None, parse_constant=None, **kw):
-    """Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON
-    document) to a Python object.
-
-    If ``s`` is a ``str`` instance and is encoded with an ASCII based encoding
-    other than utf-8 (e.g. latin-1) then an appropriate ``encoding`` name
-    must be specified. Encodings that are not ASCII based (such as UCS-2)
-    are not allowed and should be decoded to ``unicode`` first.
-
-    ``object_hook`` is an optional function that will be called with the
-    result of any object literal decode (a ``dict``). The return value of
-    ``object_hook`` will be used instead of the ``dict``. This feature
-    can be used to implement custom decoders (e.g. JSON-RPC class hinting).
-
-    ``parse_float``, if specified, will be called with the string
-    of every JSON float to be decoded. By default this is equivalent to
-    float(num_str). This can be used to use another datatype or parser
-    for JSON floats (e.g. decimal.Decimal).
-
-    ``parse_int``, if specified, will be called with the string
-    of every JSON int to be decoded. By default this is equivalent to
-    int(num_str). This can be used to use another datatype or parser
-    for JSON integers (e.g. float).
-
-    ``parse_constant``, if specified, will be called with one of the
-    following strings: -Infinity, Infinity, NaN, null, true, false.
-    This can be used to raise an exception if invalid JSON numbers
-    are encountered.
-
-    To use a custom ``JSONDecoder`` subclass, specify it with the ``cls``
-    kwarg.
-
-    """
-    if (cls is None and encoding is None and object_hook is None and
-            parse_int is None and parse_float is None and
-            parse_constant is None and not kw):
-        return _default_decoder.decode(s)
-    if cls is None:
-        cls = JSONDecoder
-    if object_hook is not None:
-        kw['object_hook'] = object_hook
-    if parse_float is not None:
-        kw['parse_float'] = parse_float
-    if parse_int is not None:
-        kw['parse_int'] = parse_int
-    if parse_constant is not None:
-        kw['parse_constant'] = parse_constant
-    return cls(encoding=encoding, **kw).decode(s)
diff --git a/ansible_mitogen/compat/simplejson/decoder.py b/ansible_mitogen/compat/simplejson/decoder.py
deleted file mode 100644
index b769ea486..000000000
--- a/ansible_mitogen/compat/simplejson/decoder.py
+++ /dev/null
@@ -1,354 +0,0 @@
-"""Implementation of JSONDecoder
-"""
-import re
-import sys
-import struct
-
-from simplejson.scanner import make_scanner
-try:
-    from simplejson._speedups import scanstring as c_scanstring
-except ImportError:
-    c_scanstring = None
-
-__all__ = ['JSONDecoder']
-
-FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
-
-def _floatconstants():
-    _BYTES = '7FF80000000000007FF0000000000000'.decode('hex')
-    if sys.byteorder != 'big':
-        _BYTES = _BYTES[:8][::-1] + _BYTES[8:][::-1]
-    nan, inf = struct.unpack('dd', _BYTES)
-    return nan, inf, -inf
-
-NaN, PosInf, NegInf = _floatconstants()
-
-
-def linecol(doc, pos):
-    lineno = doc.count('\n', 0, pos) + 1
-    if lineno == 1:
-        colno = pos
-    else:
-        colno = pos - doc.rindex('\n', 0, pos)
-    return lineno, colno
-
-
-def errmsg(msg, doc, pos, end=None):
-    # Note that this function is called from _speedups
-    lineno, colno = linecol(doc, pos)
-    if end is None:
-        #fmt = '{0}: line {1} column {2} (char {3})'
-        #return fmt.format(msg, lineno, colno, pos)
-        fmt = '%s: line %d column %d (char %d)'
-        return fmt % (msg, lineno, colno, pos)
-    endlineno, endcolno = linecol(doc, end)
-    #fmt = '{0}: line {1} column {2} - line {3} column {4} (char {5} - {6})'
-    #return fmt.format(msg, lineno, colno, endlineno, endcolno, pos, end)
-    fmt = '%s: line %d column %d - line %d column %d (char %d - %d)'
-    return fmt % (msg, lineno, colno, endlineno, endcolno, pos, end)
-
-
-_CONSTANTS = {
-    '-Infinity': NegInf,
-    'Infinity': PosInf,
-    'NaN': NaN,
-}
-
-STRINGCHUNK = re.compile(r'(.*?)(["\\\x00-\x1f])', FLAGS)
-BACKSLASH = {
-    '"': u'"', '\\': u'\\', '/': u'/',
-    'b': u'\b', 'f': u'\f', 'n': u'\n', 'r': u'\r', 't': u'\t',
-}
-
-DEFAULT_ENCODING = "utf-8"
-
-def py_scanstring(s, end, encoding=None, strict=True, _b=BACKSLASH, _m=STRINGCHUNK.match):
-    """Scan the string s for a JSON string. End is the index of the
-    character in s after the quote that started the JSON string.
-    Unescapes all valid JSON string escape sequences and raises ValueError
-    on attempt to decode an invalid string. If strict is False then literal
-    control characters are allowed in the string.
-    
-    Returns a tuple of the decoded string and the index of the character in s
-    after the end quote."""
-    if encoding is None:
-        encoding = DEFAULT_ENCODING
-    chunks = []
-    _append = chunks.append
-    begin = end - 1
-    while 1:
-        chunk = _m(s, end)
-        if chunk is None:
-            raise ValueError(
-                errmsg("Unterminated string starting at", s, begin))
-        end = chunk.end()
-        content, terminator = chunk.groups()
-        # Content is contains zero or more unescaped string characters
-        if content:
-            if not isinstance(content, unicode):
-                content = unicode(content, encoding)
-            _append(content)
-        # Terminator is the end of string, a literal control character,
-        # or a backslash denoting that an escape sequence follows
-        if terminator == '"':
-            break
-        elif terminator != '\\':
-            if strict:
-                msg = "Invalid control character %r at" % (terminator,)
-                #msg = "Invalid control character {0!r} at".format(terminator)
-                raise ValueError(errmsg(msg, s, end))
-            else:
-                _append(terminator)
-                continue
-        try:
-            esc = s[end]
-        except IndexError:
-            raise ValueError(
-                errmsg("Unterminated string starting at", s, begin))
-        # If not a unicode escape sequence, must be in the lookup table
-        if esc != 'u':
-            try:
-                char = _b[esc]
-            except KeyError:
-                msg = "Invalid \\escape: " + repr(esc)
-                raise ValueError(errmsg(msg, s, end))
-            end += 1
-        else:
-            # Unicode escape sequence
-            esc = s[end + 1:end + 5]
-            next_end = end + 5
-            if len(esc) != 4:
-                msg = "Invalid \\uXXXX escape"
-                raise ValueError(errmsg(msg, s, end))
-            uni = int(esc, 16)
-            # Check for surrogate pair on UCS-4 systems
-            if 0xd800 <= uni <= 0xdbff and sys.maxunicode > 65535:
-                msg = "Invalid \\uXXXX\\uXXXX surrogate pair"
-                if not s[end + 5:end + 7] == '\\u':
-                    raise ValueError(errmsg(msg, s, end))
-                esc2 = s[end + 7:end + 11]
-                if len(esc2) != 4:
-                    raise ValueError(errmsg(msg, s, end))
-                uni2 = int(esc2, 16)
-                uni = 0x10000 + (((uni - 0xd800) << 10) | (uni2 - 0xdc00))
-                next_end += 6
-            char = unichr(uni)
-            end = next_end
-        # Append the unescaped character
-        _append(char)
-    return u''.join(chunks), end
-
-
-# Use speedup if available
-scanstring = c_scanstring or py_scanstring
-
-WHITESPACE = re.compile(r'[ \t\n\r]*', FLAGS)
-WHITESPACE_STR = ' \t\n\r'
-
-def JSONObject((s, end), encoding, strict, scan_once, object_hook, _w=WHITESPACE.match, _ws=WHITESPACE_STR):
-    pairs = {}
-    # Use a slice to prevent IndexError from being raised, the following
-    # check will raise a more specific ValueError if the string is empty
-    nextchar = s[end:end + 1]
-    # Normally we expect nextchar == '"'
-    if nextchar != '"':
-        if nextchar in _ws:
-            end = _w(s, end).end()
-            nextchar = s[end:end + 1]
-        # Trivial empty object
-        if nextchar == '}':
-            return pairs, end + 1
-        elif nextchar != '"':
-            raise ValueError(errmsg("Expecting property name", s, end))
-    end += 1
-    while True:
-        key, end = scanstring(s, end, encoding, strict)
-
-        # To skip some function call overhead we optimize the fast paths where
-        # the JSON key separator is ": " or just ":".
-        if s[end:end + 1] != ':':
-            end = _w(s, end).end()
-            if s[end:end + 1] != ':':
-                raise ValueError(errmsg("Expecting : delimiter", s, end))
-
-        end += 1
-
-        try:
-            if s[end] in _ws:
-                end += 1
-                if s[end] in _ws:
-                    end = _w(s, end + 1).end()
-        except IndexError:
-            pass
-
-        try:
-            value, end = scan_once(s, end)
-        except StopIteration:
-            raise ValueError(errmsg("Expecting object", s, end))
-        pairs[key] = value
-
-        try:
-            nextchar = s[end]
-            if nextchar in _ws:
-                end = _w(s, end + 1).end()
-                nextchar = s[end]
-        except IndexError:
-            nextchar = ''
-        end += 1
-
-        if nextchar == '}':
-            break
-        elif nextchar != ',':
-            raise ValueError(errmsg("Expecting , delimiter", s, end - 1))
-
-        try:
-            nextchar = s[end]
-            if nextchar in _ws:
-                end += 1
-                nextchar = s[end]
-                if nextchar in _ws:
-                    end = _w(s, end + 1).end()
-                    nextchar = s[end]
-        except IndexError:
-            nextchar = ''
-
-        end += 1
-        if nextchar != '"':
-            raise ValueError(errmsg("Expecting property name", s, end - 1))
-
-    if object_hook is not None:
-        pairs = object_hook(pairs)
-    return pairs, end
-
-def JSONArray((s, end), scan_once, _w=WHITESPACE.match, _ws=WHITESPACE_STR):
-    values = []
-    nextchar = s[end:end + 1]
-    if nextchar in _ws:
-        end = _w(s, end + 1).end()
-        nextchar = s[end:end + 1]
-    # Look-ahead for trivial empty array
-    if nextchar == ']':
-        return values, end + 1
-    _append = values.append
-    while True:
-        try:
-            value, end = scan_once(s, end)
-        except StopIteration:
-            raise ValueError(errmsg("Expecting object", s, end))
-        _append(value)
-        nextchar = s[end:end + 1]
-        if nextchar in _ws:
-            end = _w(s, end + 1).end()
-            nextchar = s[end:end + 1]
-        end += 1
-        if nextchar == ']':
-            break
-        elif nextchar != ',':
-            raise ValueError(errmsg("Expecting , delimiter", s, end))
-
-        try:
-            if s[end] in _ws:
-                end += 1
-                if s[end] in _ws:
-                    end = _w(s, end + 1).end()
-        except IndexError:
-            pass
-
-    return values, end
-
-class JSONDecoder(object):
-    """Simple JSON <http://json.org> decoder
-
-    Performs the following translations in decoding by default:
-
-    +---------------+-------------------+
-    | JSON          | Python            |
-    +===============+===================+
-    | object        | dict              |
-    +---------------+-------------------+
-    | array         | list              |
-    +---------------+-------------------+
-    | string        | unicode           |
-    +---------------+-------------------+
-    | number (int)  | int, long         |
-    +---------------+-------------------+
-    | number (real) | float             |
-    +---------------+-------------------+
-    | true          | True              |
-    +---------------+-------------------+
-    | false         | False             |
-    +---------------+-------------------+
-    | null          | None              |
-    +---------------+-------------------+
-
-    It also understands ``NaN``, ``Infinity``, and ``-Infinity`` as
-    their corresponding ``float`` values, which is outside the JSON spec.
-
-    """
-
-    def __init__(self, encoding=None, object_hook=None, parse_float=None,
-            parse_int=None, parse_constant=None, strict=True):
-        """``encoding`` determines the encoding used to interpret any ``str``
-        objects decoded by this instance (utf-8 by default).  It has no
-        effect when decoding ``unicode`` objects.
-
-        Note that currently only encodings that are a superset of ASCII work,
-        strings of other encodings should be passed in as ``unicode``.
-
-        ``object_hook``, if specified, will be called with the result
-        of every JSON object decoded and its return value will be used in
-        place of the given ``dict``.  This can be used to provide custom
-        deserializations (e.g. to support JSON-RPC class hinting).
-
-        ``parse_float``, if specified, will be called with the string
-        of every JSON float to be decoded. By default this is equivalent to
-        float(num_str). This can be used to use another datatype or parser
-        for JSON floats (e.g. decimal.Decimal).
-
-        ``parse_int``, if specified, will be called with the string
-        of every JSON int to be decoded. By default this is equivalent to
-        int(num_str). This can be used to use another datatype or parser
-        for JSON integers (e.g. float).
-
-        ``parse_constant``, if specified, will be called with one of the
-        following strings: -Infinity, Infinity, NaN.
-        This can be used to raise an exception if invalid JSON numbers
-        are encountered.
-
-        """
-        self.encoding = encoding
-        self.object_hook = object_hook
-        self.parse_float = parse_float or float
-        self.parse_int = parse_int or int
-        self.parse_constant = parse_constant or _CONSTANTS.__getitem__
-        self.strict = strict
-        self.parse_object = JSONObject
-        self.parse_array = JSONArray
-        self.parse_string = scanstring
-        self.scan_once = make_scanner(self)
-
-    def decode(self, s, _w=WHITESPACE.match):
-        """Return the Python representation of ``s`` (a ``str`` or ``unicode``
-        instance containing a JSON document)
-
-        """
-        obj, end = self.raw_decode(s, idx=_w(s, 0).end())
-        end = _w(s, end).end()
-        if end != len(s):
-            raise ValueError(errmsg("Extra data", s, end, len(s)))
-        return obj
-
-    def raw_decode(self, s, idx=0):
-        """Decode a JSON document from ``s`` (a ``str`` or ``unicode`` beginning
-        with a JSON document) and return a 2-tuple of the Python
-        representation and the index in ``s`` where the document ended.
-
-        This can be used to decode a JSON document from a string that may
-        have extraneous data at the end.
-
-        """
-        try:
-            obj, end = self.scan_once(s, idx)
-        except StopIteration:
-            raise ValueError("No JSON object could be decoded")
-        return obj, end
diff --git a/ansible_mitogen/compat/simplejson/encoder.py b/ansible_mitogen/compat/simplejson/encoder.py
deleted file mode 100644
index cf5829036..000000000
--- a/ansible_mitogen/compat/simplejson/encoder.py
+++ /dev/null
@@ -1,440 +0,0 @@
-"""Implementation of JSONEncoder
-"""
-import re
-
-try:
-    from simplejson._speedups import encode_basestring_ascii as c_encode_basestring_ascii
-except ImportError:
-    c_encode_basestring_ascii = None
-try:
-    from simplejson._speedups import make_encoder as c_make_encoder
-except ImportError:
-    c_make_encoder = None
-
-ESCAPE = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t]')
-ESCAPE_ASCII = re.compile(r'([\\"]|[^\ -~])')
-HAS_UTF8 = re.compile(r'[\x80-\xff]')
-ESCAPE_DCT = {
-    '\\': '\\\\',
-    '"': '\\"',
-    '\b': '\\b',
-    '\f': '\\f',
-    '\n': '\\n',
-    '\r': '\\r',
-    '\t': '\\t',
-}
-for i in range(0x20):
-    #ESCAPE_DCT.setdefault(chr(i), '\\u{0:04x}'.format(i))
-    ESCAPE_DCT.setdefault(chr(i), '\\u%04x' % (i,))
-
-# Assume this produces an infinity on all machines (probably not guaranteed)
-INFINITY = float('1e66666')
-FLOAT_REPR = repr
-
-def encode_basestring(s):
-    """Return a JSON representation of a Python string
-
-    """
-    def replace(match):
-        return ESCAPE_DCT[match.group(0)]
-    return '"' + ESCAPE.sub(replace, s) + '"'
-
-
-def py_encode_basestring_ascii(s):
-    """Return an ASCII-only JSON representation of a Python string
-
-    """
-    if isinstance(s, str) and HAS_UTF8.search(s) is not None:
-        s = s.decode('utf-8')
-    def replace(match):
-        s = match.group(0)
-        try:
-            return ESCAPE_DCT[s]
-        except KeyError:
-            n = ord(s)
-            if n < 0x10000:
-                #return '\\u{0:04x}'.format(n)
-                return '\\u%04x' % (n,)
-            else:
-                # surrogate pair
-                n -= 0x10000
-                s1 = 0xd800 | ((n >> 10) & 0x3ff)
-                s2 = 0xdc00 | (n & 0x3ff)
-                #return '\\u{0:04x}\\u{1:04x}'.format(s1, s2)
-                return '\\u%04x\\u%04x' % (s1, s2)
-    return '"' + str(ESCAPE_ASCII.sub(replace, s)) + '"'
-
-
-encode_basestring_ascii = c_encode_basestring_ascii or py_encode_basestring_ascii
-
-class JSONEncoder(object):
-    """Extensible JSON <http://json.org> encoder for Python data structures.
-
-    Supports the following objects and types by default:
-
-    +-------------------+---------------+
-    | Python            | JSON          |
-    +===================+===============+
-    | dict              | object        |
-    +-------------------+---------------+
-    | list, tuple       | array         |
-    +-------------------+---------------+
-    | str, unicode      | string        |
-    +-------------------+---------------+
-    | int, long, float  | number        |
-    +-------------------+---------------+
-    | True              | true          |
-    +-------------------+---------------+
-    | False             | false         |
-    +-------------------+---------------+
-    | None              | null          |
-    +-------------------+---------------+
-
-    To extend this to recognize other objects, subclass and implement a
-    ``.default()`` method with another method that returns a serializable
-    object for ``o`` if possible, otherwise it should call the superclass
-    implementation (to raise ``TypeError``).
-
-    """
-    item_separator = ', '
-    key_separator = ': '
-    def __init__(self, skipkeys=False, ensure_ascii=True,
-            check_circular=True, allow_nan=True, sort_keys=False,
-            indent=None, separators=None, encoding='utf-8', default=None):
-        """Constructor for JSONEncoder, with sensible defaults.
-
-        If skipkeys is false, then it is a TypeError to attempt
-        encoding of keys that are not str, int, long, float or None.  If
-        skipkeys is True, such items are simply skipped.
-
-        If ensure_ascii is true, the output is guaranteed to be str
-        objects with all incoming unicode characters escaped.  If
-        ensure_ascii is false, the output will be unicode object.
-
-        If check_circular is true, then lists, dicts, and custom encoded
-        objects will be checked for circular references during encoding to
-        prevent an infinite recursion (which would cause an OverflowError).
-        Otherwise, no such check takes place.
-
-        If allow_nan is true, then NaN, Infinity, and -Infinity will be
-        encoded as such.  This behavior is not JSON specification compliant,
-        but is consistent with most JavaScript based encoders and decoders.
-        Otherwise, it will be a ValueError to encode such floats.
-
-        If sort_keys is true, then the output of dictionaries will be
-        sorted by key; this is useful for regression tests to ensure
-        that JSON serializations can be compared on a day-to-day basis.
-
-        If indent is a non-negative integer, then JSON array
-        elements and object members will be pretty-printed with that
-        indent level.  An indent level of 0 will only insert newlines.
-        None is the most compact representation.
-
-        If specified, separators should be a (item_separator, key_separator)
-        tuple.  The default is (', ', ': ').  To get the most compact JSON
-        representation you should specify (',', ':') to eliminate whitespace.
-
-        If specified, default is a function that gets called for objects
-        that can't otherwise be serialized.  It should return a JSON encodable
-        version of the object or raise a ``TypeError``.
-
-        If encoding is not None, then all input strings will be
-        transformed into unicode using that encoding prior to JSON-encoding.
-        The default is UTF-8.
-
-        """
-
-        self.skipkeys = skipkeys
-        self.ensure_ascii = ensure_ascii
-        self.check_circular = check_circular
-        self.allow_nan = allow_nan
-        self.sort_keys = sort_keys
-        self.indent = indent
-        if separators is not None:
-            self.item_separator, self.key_separator = separators
-        if default is not None:
-            self.default = default
-        self.encoding = encoding
-
-    def default(self, o):
-        """Implement this method in a subclass such that it returns
-        a serializable object for ``o``, or calls the base implementation
-        (to raise a ``TypeError``).
-
-        For example, to support arbitrary iterators, you could
-        implement default like this::
-
-            def default(self, o):
-                try:
-                    iterable = iter(o)
-                except TypeError:
-                    pass
-                else:
-                    return list(iterable)
-                return JSONEncoder.default(self, o)
-
-        """
-        raise TypeError(repr(o) + " is not JSON serializable")
-
-    def encode(self, o):
-        """Return a JSON string representation of a Python data structure.
-
-        >>> JSONEncoder().encode({"foo": ["bar", "baz"]})
-        '{"foo": ["bar", "baz"]}'
-
-        """
-        # This is for extremely simple cases and benchmarks.
-        if isinstance(o, basestring):
-            if isinstance(o, str):
-                _encoding = self.encoding
-                if (_encoding is not None
-                        and not (_encoding == 'utf-8')):
-                    o = o.decode(_encoding)
-            if self.ensure_ascii:
-                return encode_basestring_ascii(o)
-            else:
-                return encode_basestring(o)
-        # This doesn't pass the iterator directly to ''.join() because the
-        # exceptions aren't as detailed.  The list call should be roughly
-        # equivalent to the PySequence_Fast that ''.join() would do.
-        chunks = self.iterencode(o, _one_shot=True)
-        if not isinstance(chunks, (list, tuple)):
-            chunks = list(chunks)
-        return ''.join(chunks)
-
-    def iterencode(self, o, _one_shot=False):
-        """Encode the given object and yield each string
-        representation as available.
-
-        For example::
-
-            for chunk in JSONEncoder().iterencode(bigobject):
-                mysocket.write(chunk)
-
-        """
-        if self.check_circular:
-            markers = {}
-        else:
-            markers = None
-        if self.ensure_ascii:
-            _encoder = encode_basestring_ascii
-        else:
-            _encoder = encode_basestring
-        if self.encoding != 'utf-8':
-            def _encoder(o, _orig_encoder=_encoder, _encoding=self.encoding):
-                if isinstance(o, str):
-                    o = o.decode(_encoding)
-                return _orig_encoder(o)
-
-        def floatstr(o, allow_nan=self.allow_nan, _repr=FLOAT_REPR, _inf=INFINITY, _neginf=-INFINITY):
-            # Check for specials.  Note that this type of test is processor- and/or
-            # platform-specific, so do tests which don't depend on the internals.
-
-            if o != o:
-                text = 'NaN'
-            elif o == _inf:
-                text = 'Infinity'
-            elif o == _neginf:
-                text = '-Infinity'
-            else:
-                return _repr(o)
-
-            if not allow_nan:
-                raise ValueError(
-                    "Out of range float values are not JSON compliant: " +
-                    repr(o))
-
-            return text
-
-
-        if _one_shot and c_make_encoder is not None and not self.indent and not self.sort_keys:
-            _iterencode = c_make_encoder(
-                markers, self.default, _encoder, self.indent,
-                self.key_separator, self.item_separator, self.sort_keys,
-                self.skipkeys, self.allow_nan)
-        else:
-            _iterencode = _make_iterencode(
-                markers, self.default, _encoder, self.indent, floatstr,
-                self.key_separator, self.item_separator, self.sort_keys,
-                self.skipkeys, _one_shot)
-        return _iterencode(o, 0)
-
-def _make_iterencode(markers, _default, _encoder, _indent, _floatstr, _key_separator, _item_separator, _sort_keys, _skipkeys, _one_shot,
-        ## HACK: hand-optimized bytecode; turn globals into locals
-        False=False,
-        True=True,
-        ValueError=ValueError,
-        basestring=basestring,
-        dict=dict,
-        float=float,
-        id=id,
-        int=int,
-        isinstance=isinstance,
-        list=list,
-        long=long,
-        str=str,
-        tuple=tuple,
-    ):
-
-    def _iterencode_list(lst, _current_indent_level):
-        if not lst:
-            yield '[]'
-            return
-        if markers is not None:
-            markerid = id(lst)
-            if markerid in markers:
-                raise ValueError("Circular reference detected")
-            markers[markerid] = lst
-        buf = '['
-        if _indent is not None:
-            _current_indent_level += 1
-            newline_indent = '\n' + (' ' * (_indent * _current_indent_level))
-            separator = _item_separator + newline_indent
-            buf += newline_indent
-        else:
-            newline_indent = None
-            separator = _item_separator
-        first = True
-        for value in lst:
-            if first:
-                first = False
-            else:
-                buf = separator
-            if isinstance(value, basestring):
-                yield buf + _encoder(value)
-            elif value is None:
-                yield buf + 'null'
-            elif value is True:
-                yield buf + 'true'
-            elif value is False:
-                yield buf + 'false'
-            elif isinstance(value, (int, long)):
-                yield buf + str(value)
-            elif isinstance(value, float):
-                yield buf + _floatstr(value)
-            else:
-                yield buf
-                if isinstance(value, (list, tuple)):
-                    chunks = _iterencode_list(value, _current_indent_level)
-                elif isinstance(value, dict):
-                    chunks = _iterencode_dict(value, _current_indent_level)
-                else:
-                    chunks = _iterencode(value, _current_indent_level)
-                for chunk in chunks:
-                    yield chunk
-        if newline_indent is not None:
-            _current_indent_level -= 1
-            yield '\n' + (' ' * (_indent * _current_indent_level))
-        yield ']'
-        if markers is not None:
-            del markers[markerid]
-
-    def _iterencode_dict(dct, _current_indent_level):
-        if not dct:
-            yield '{}'
-            return
-        if markers is not None:
-            markerid = id(dct)
-            if markerid in markers:
-                raise ValueError("Circular reference detected")
-            markers[markerid] = dct
-        yield '{'
-        if _indent is not None:
-            _current_indent_level += 1
-            newline_indent = '\n' + (' ' * (_indent * _current_indent_level))
-            item_separator = _item_separator + newline_indent
-            yield newline_indent
-        else:
-            newline_indent = None
-            item_separator = _item_separator
-        first = True
-        if _sort_keys:
-            items = dct.items()
-            items.sort(key=lambda kv: kv[0])
-        else:
-            items = dct.iteritems()
-        for key, value in items:
-            if isinstance(key, basestring):
-                pass
-            # JavaScript is weakly typed for these, so it makes sense to
-            # also allow them.  Many encoders seem to do something like this.
-            elif isinstance(key, float):
-                key = _floatstr(key)
-            elif key is True:
-                key = 'true'
-            elif key is False:
-                key = 'false'
-            elif key is None:
-                key = 'null'
-            elif isinstance(key, (int, long)):
-                key = str(key)
-            elif _skipkeys:
-                continue
-            else:
-                raise TypeError("key " + repr(key) + " is not a string")
-            if first:
-                first = False
-            else:
-                yield item_separator
-            yield _encoder(key)
-            yield _key_separator
-            if isinstance(value, basestring):
-                yield _encoder(value)
-            elif value is None:
-                yield 'null'
-            elif value is True:
-                yield 'true'
-            elif value is False:
-                yield 'false'
-            elif isinstance(value, (int, long)):
-                yield str(value)
-            elif isinstance(value, float):
-                yield _floatstr(value)
-            else:
-                if isinstance(value, (list, tuple)):
-                    chunks = _iterencode_list(value, _current_indent_level)
-                elif isinstance(value, dict):
-                    chunks = _iterencode_dict(value, _current_indent_level)
-                else:
-                    chunks = _iterencode(value, _current_indent_level)
-                for chunk in chunks:
-                    yield chunk
-        if newline_indent is not None:
-            _current_indent_level -= 1
-            yield '\n' + (' ' * (_indent * _current_indent_level))
-        yield '}'
-        if markers is not None:
-            del markers[markerid]
-
-    def _iterencode(o, _current_indent_level):
-        if isinstance(o, basestring):
-            yield _encoder(o)
-        elif o is None:
-            yield 'null'
-        elif o is True:
-            yield 'true'
-        elif o is False:
-            yield 'false'
-        elif isinstance(o, (int, long)):
-            yield str(o)
-        elif isinstance(o, float):
-            yield _floatstr(o)
-        elif isinstance(o, (list, tuple)):
-            for chunk in _iterencode_list(o, _current_indent_level):
-                yield chunk
-        elif isinstance(o, dict):
-            for chunk in _iterencode_dict(o, _current_indent_level):
-                yield chunk
-        else:
-            if markers is not None:
-                markerid = id(o)
-                if markerid in markers:
-                    raise ValueError("Circular reference detected")
-                markers[markerid] = o
-            o = _default(o)
-            for chunk in _iterencode(o, _current_indent_level):
-                yield chunk
-            if markers is not None:
-                del markers[markerid]
-
-    return _iterencode
diff --git a/ansible_mitogen/compat/simplejson/scanner.py b/ansible_mitogen/compat/simplejson/scanner.py
deleted file mode 100644
index adbc6ec97..000000000
--- a/ansible_mitogen/compat/simplejson/scanner.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""JSON token scanner
-"""
-import re
-try:
-    from simplejson._speedups import make_scanner as c_make_scanner
-except ImportError:
-    c_make_scanner = None
-
-__all__ = ['make_scanner']
-
-NUMBER_RE = re.compile(
-    r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
-    (re.VERBOSE | re.MULTILINE | re.DOTALL))
-
-def py_make_scanner(context):
-    parse_object = context.parse_object
-    parse_array = context.parse_array
-    parse_string = context.parse_string
-    match_number = NUMBER_RE.match
-    encoding = context.encoding
-    strict = context.strict
-    parse_float = context.parse_float
-    parse_int = context.parse_int
-    parse_constant = context.parse_constant
-    object_hook = context.object_hook
-
-    def _scan_once(string, idx):
-        try:
-            nextchar = string[idx]
-        except IndexError:
-            raise StopIteration
-
-        if nextchar == '"':
-            return parse_string(string, idx + 1, encoding, strict)
-        elif nextchar == '{':
-            return parse_object((string, idx + 1), encoding, strict, _scan_once, object_hook)
-        elif nextchar == '[':
-            return parse_array((string, idx + 1), _scan_once)
-        elif nextchar == 'n' and string[idx:idx + 4] == 'null':
-            return None, idx + 4
-        elif nextchar == 't' and string[idx:idx + 4] == 'true':
-            return True, idx + 4
-        elif nextchar == 'f' and string[idx:idx + 5] == 'false':
-            return False, idx + 5
-
-        m = match_number(string, idx)
-        if m is not None:
-            integer, frac, exp = m.groups()
-            if frac or exp:
-                res = parse_float(integer + (frac or '') + (exp or ''))
-            else:
-                res = parse_int(integer)
-            return res, m.end()
-        elif nextchar == 'N' and string[idx:idx + 3] == 'NaN':
-            return parse_constant('NaN'), idx + 3
-        elif nextchar == 'I' and string[idx:idx + 8] == 'Infinity':
-            return parse_constant('Infinity'), idx + 8
-        elif nextchar == '-' and string[idx:idx + 9] == '-Infinity':
-            return parse_constant('-Infinity'), idx + 9
-        else:
-            raise StopIteration
-
-    return _scan_once
-
-make_scanner = c_make_scanner or py_make_scanner
diff --git a/ansible_mitogen/connection.py b/ansible_mitogen/connection.py
index 6df3dfcff..44caf9ac1 100644
--- a/ansible_mitogen/connection.py
+++ b/ansible_mitogen/connection.py
@@ -484,6 +484,7 @@ class Connection(ansible.plugins.connection.ConnectionBase):
     login_context = None
 
     #: Only sudo, su, and doas are supported for now.
+    # Ansible ConnectionBase attribute, removed in Ansible >= 2.8
     become_methods = ['sudo', 'su', 'doas']
 
     #: Dict containing init_child() return value as recorded at startup by
@@ -521,15 +522,6 @@ class Connection(ansible.plugins.connection.ConnectionBase):
     # set by `_get_task_vars()` for interpreter discovery
     _action = None
 
-    def __del__(self):
-        """
-        Ansible cannot be trusted to always call close() e.g. the synchronize
-        action constructs a local connection like this. So provide a destructor
-        in the hopes of catching these cases.
-        """
-        # https://github.com/dw/mitogen/issues/140
-        self.close()
-
     def on_action_run(self, task_vars, delegate_to_hostname, loader_basedir):
         """
         Invoked by ActionModuleMixin to indicate a new task is about to start
@@ -684,6 +676,9 @@ def get_binding(self):
 
     @property
     def connected(self):
+        """
+        Ansible connection plugin property. Used by ansible-connection command.
+        """
         return self.context is not None
 
     def _spec_from_via(self, proxied_inventory_name, via_spec):
@@ -842,7 +837,11 @@ def _connect(self):
         the _connect_*() service calls defined above to cause the master
         process to establish the real connection on our behalf, or return a
         reference to the existing one.
+
+        Ansible connection plugin method.
         """
+        # In some Ansible connection plugins this method returns self.
+        # However nothing I've found uses it, it's not even assigned.
         if self.connected:
             return
 
@@ -880,6 +879,8 @@ def close(self):
         Arrange for the mitogen.master.Router running in the worker to
         gracefully shut down, and wait for shutdown to complete. Safe to call
         multiple times.
+
+        Ansible connection plugin method.
         """
         self._put_connection()
         if self.binding:
@@ -896,6 +897,8 @@ def reset(self):
         any local state we hold for the connection, returns the Connection to
         the 'disconnected' state, and informs ContextService the connection is
         bad somehow, and should be shut down and discarded.
+
+        Ansible connection plugin method.
         """
         if self._play_context.remote_addr is None:
             # <2.5.6 incorrectly populate PlayContext for reset_connection
@@ -1002,6 +1005,8 @@ def exec_command(self, cmd, in_data='', sudoable=True, mitogen_chdir=None):
             Data to supply on ``stdin`` of the process.
         :returns:
             (return code, stdout bytes, stderr bytes)
+
+        Ansible connection plugin method.
         """
         emulate_tty = (not in_data and sudoable)
         rc, stdout, stderr = self.get_chain().call(
@@ -1027,6 +1032,8 @@ def fetch_file(self, in_path, out_path):
             Remote filesystem path to read.
         :param str out_path:
             Local filesystem path to write.
+
+        Ansible connection plugin method.
         """
         self._connect()
         ansible_mitogen.target.transfer_file(
@@ -1076,6 +1083,8 @@ def put_file(self, in_path, out_path):
             Local filesystem path to read.
         :param str out_path:
             Remote filesystem path to write.
+
+        Ansible connection plugin method.
         """
         try:
             st = os.stat(in_path)
diff --git a/ansible_mitogen/loaders.py b/ansible_mitogen/loaders.py
index cd05feac0..1f4d8fc6a 100644
--- a/ansible_mitogen/loaders.py
+++ b/ansible_mitogen/loaders.py
@@ -39,6 +39,7 @@
 
 __all__ = [
     'action_loader',
+    'become_loader',
     'connection_loader',
     'module_loader',
     'module_utils_loader',
@@ -48,7 +49,7 @@
 
 
 ANSIBLE_VERSION_MIN = (2, 10)
-ANSIBLE_VERSION_MAX = (2, 12)
+ANSIBLE_VERSION_MAX = (2, 13)
 
 NEW_VERSION_MSG = (
     "Your Ansible version (%s) is too recent. The most recent version\n"
@@ -90,6 +91,7 @@ def assert_supported_release():
 
 
 from ansible.plugins.loader import action_loader
+from ansible.plugins.loader import become_loader
 from ansible.plugins.loader import connection_loader
 from ansible.plugins.loader import module_loader
 from ansible.plugins.loader import module_utils_loader
diff --git a/ansible_mitogen/planner.py b/ansible_mitogen/planner.py
index 86690f7d9..0b1b7aab0 100644
--- a/ansible_mitogen/planner.py
+++ b/ansible_mitogen/planner.py
@@ -323,6 +323,7 @@ def get_module_deps(self):
         'dnf',  # issue #280; py-dnf/hawkey need therapy
         'firewalld',  # issue #570: ansible module_utils caches dbus conn
         'ansible.legacy.dnf',  # issue #776
+        'ansible.builtin.dnf', # issue #832
     ])
 
     def should_fork(self):
diff --git a/ansible_mitogen/plugins/connection/mitogen_ssh.py b/ansible_mitogen/plugins/connection/mitogen_ssh.py
index 5a6abaa92..75f2d42fb 100644
--- a/ansible_mitogen/plugins/connection/mitogen_ssh.py
+++ b/ansible_mitogen/plugins/connection/mitogen_ssh.py
@@ -42,6 +42,24 @@
           accepts.
     version_added: "2.5"
     options:
+        ssh_args:
+            type: str
+            vars:
+                - name: ssh_args
+                - name: ansible_ssh_args
+                - name: ansible_mitogen_ssh_args
+        ssh_common_args:
+            type: str
+            vars:
+                - name: ssh_args
+                - name: ansible_ssh_common_args
+                - name: ansible_mitogen_ssh_common_args
+        ssh_extra_args:
+            type: str
+            vars:
+                - name: ssh_args
+                - name: ansible_ssh_extra_args
+                - name: ansible_mitogen_ssh_extra_args
 """
 
 try:
diff --git a/ansible_mitogen/process.py b/ansible_mitogen/process.py
index 9276614b0..63caa88a9 100644
--- a/ansible_mitogen/process.py
+++ b/ansible_mitogen/process.py
@@ -180,42 +180,6 @@ def setup_pool(pool):
     LOG.debug('Service pool configured: size=%d', pool.size)
 
 
-def _setup_simplejson(responder):
-    """
-    We support serving simplejson for Python 2.4 targets on Ansible 2.3, at
-    least so the package's own CI Docker scripts can run without external
-    help, however newer versions of simplejson no longer support Python
-    2.4. Therefore override any installed/loaded version with a
-    2.4-compatible version we ship in the compat/ directory.
-    """
-    responder.whitelist_prefix('simplejson')
-
-    # issue #536: must be at end of sys.path, in case existing newer
-    # version is already loaded.
-    compat_path = os.path.join(os.path.dirname(__file__), 'compat')
-    sys.path.append(compat_path)
-
-    for fullname, is_pkg, suffix in (
-        (u'simplejson', True, '__init__.py'),
-        (u'simplejson.decoder', False, 'decoder.py'),
-        (u'simplejson.encoder', False, 'encoder.py'),
-        (u'simplejson.scanner', False, 'scanner.py'),
-    ):
-        path = os.path.join(compat_path, 'simplejson', suffix)
-        fp = open(path, 'rb')
-        try:
-            source = fp.read()
-        finally:
-            fp.close()
-
-        responder.add_source_override(
-            fullname=fullname,
-            path=path,
-            source=source,
-            is_pkg=is_pkg,
-        )
-
-
 def _setup_responder(responder):
     """
     Configure :class:`mitogen.master.ModuleResponder` to only permit
@@ -223,7 +187,6 @@ def _setup_responder(responder):
     """
     responder.whitelist_prefix('ansible')
     responder.whitelist_prefix('ansible_mitogen')
-    _setup_simplejson(responder)
 
     # Ansible 2.3 is compatible with Python 2.4 targets, however
     # ansible/__init__.py is not. Instead, executor/module_common.py writes
diff --git a/ansible_mitogen/runner.py b/ansible_mitogen/runner.py
index 31ccf1cf8..c4cb71ff4 100644
--- a/ansible_mitogen/runner.py
+++ b/ansible_mitogen/runner.py
@@ -41,6 +41,7 @@
 
 import atexit
 import imp
+import json
 import os
 import re
 import shlex
@@ -63,12 +64,6 @@
     # Python 2.4
     ctypes = None
 
-try:
-    import json
-except ImportError:
-    # Python 2.4
-    import simplejson as json
-
 try:
     # Cannot use cStringIO as it does not support Unicode.
     from StringIO import StringIO
diff --git a/ansible_mitogen/target.py b/ansible_mitogen/target.py
index 2c65c516b..7d907d620 100644
--- a/ansible_mitogen/target.py
+++ b/ansible_mitogen/target.py
@@ -38,6 +38,7 @@
 
 import errno
 import grp
+import json
 import operator
 import os
 import pwd
@@ -58,11 +59,6 @@
 import mitogen.service
 from mitogen.core import b
 
-try:
-    import json
-except ImportError:
-    import simplejson as json
-
 try:
     reduce
 except NameError:
@@ -371,11 +367,6 @@ def init_child(econtext, log_level, candidate_temp_dirs):
     LOG.setLevel(log_level)
     logging.getLogger('ansible_mitogen').setLevel(log_level)
 
-    # issue #536: if the json module is available, remove simplejson from the
-    # importer whitelist to avoid confusing certain Ansible modules.
-    if json.__name__ == 'json':
-        econtext.importer.whitelist.remove('simplejson')
-
     global _fork_parent
     if FORK_SUPPORTED:
         mitogen.parent.upgrade_router(econtext)
diff --git a/ansible_mitogen/transport_config.py b/ansible_mitogen/transport_config.py
index b488b8510..5fc781856 100644
--- a/ansible_mitogen/transport_config.py
+++ b/ansible_mitogen/transport_config.py
@@ -79,6 +79,7 @@
 except ImportError:
     from ansible.vars.unsafe_proxy import AnsibleUnsafeText
 
+import ansible_mitogen.loaders
 import mitogen.core
 
 
@@ -435,7 +436,10 @@ def become_user(self):
         return self._play_context.become_user
 
     def become_pass(self):
-        return optional_secret(self._play_context.become_pass)
+        become_method = self.become_method()
+        become_plugin = ansible_mitogen.loaders.become_loader.get(become_method)
+        become_pass = become_plugin.get_option('become_pass', hostvars=self._task_vars)
+        return optional_secret(become_pass)
 
     def password(self):
         return optional_secret(self._play_context.password)
@@ -652,8 +656,8 @@ def become_user(self):
 
     def become_pass(self):
         return optional_secret(
-            self._host_vars.get('ansible_become_password') or
-            self._host_vars.get('ansible_become_pass')
+            self._host_vars.get('ansible_become_pass') or
+            self._host_vars.get('ansible_become_password')
         )
 
     def password(self):
@@ -749,7 +753,7 @@ def mitogen_kubectl_path(self):
         return self._host_vars.get('mitogen_kubectl_path')
 
     def mitogen_lxc_path(self):
-        return self.host_vars.get('mitogen_lxc_path')
+        return self._host_vars.get('mitogen_lxc_path')
 
     def mitogen_lxc_attach_path(self):
         return self._host_vars.get('mitogen_lxc_attach_path')
diff --git a/docs/ansible_detailed.rst b/docs/ansible_detailed.rst
index d32980740..449771b8e 100644
--- a/docs/ansible_detailed.rst
+++ b/docs/ansible_detailed.rst
@@ -148,7 +148,7 @@ Noteworthy Differences
 * Mitogen 0.2.x supports Ansible 2.3-2.9; with Python 2.6, 2.7, or 3.6.
   Mitogen 0.3.1+ supports
     - Ansible 2.10, 3, and 4; with Python 2.7, or 3.6-3.10
-    - Ansible 5; with Python 3.8-3.10
+    - Ansible 5 and 6; with Python 3.8-3.10
   Verify your installation is running one of these versions by checking
   ``ansible --version`` output.
 
@@ -1248,18 +1248,17 @@ with ``-vvv``.
 
 However, certain controller hangs may render ``MITOGEN_DUMP_THREAD_STACKS``
 ineffective, or occur too infrequently for interactive reproduction. In these
-cases `faulthandler <https://faulthandler.readthedocs.io/>`_ may be used:
+cases :py:mod:`faulthandler` may be used with Python >= 3.3:
 
-1. For Python 2, ``pip install faulthandler``. This is unnecessary on Python 3.
-2. Once the hang occurs, observe the process tree using ``pstree`` or ``ps
+1. Once the hang occurs, observe the process tree using ``pstree`` or ``ps
    --forest``.
-3. The most likely process to be hung is the connection multiplexer, which can
+2. The most likely process to be hung is the connection multiplexer, which can
    easily be identified as the parent of all SSH client processes.
-4. Send ``kill -SEGV <pid>`` to the multiplexer PID, causing it to print all
+3. Send ``kill -SEGV <pid>`` to the multiplexer PID, causing it to print all
    thread stacks.
-5. `File a bug <https://github.com/dw/mitogen/issues/new/>`_ including a copy
-   of the stacks, along with a description of the last task executing prior to
-   the hang.
+4. `File a bug <https://github.com/mitogen-hq/mitogen/issues/new/>`_
+   including a copy of the stacks and a description of the last task executing
+   before the hang
 
 It is possible the hang occurred in a process on a target. If ``strace`` is
 available, look for the host name not listed in Ansible output as reporting a
diff --git a/docs/changelog.rst b/docs/changelog.rst
index c6af65f22..d9a54bc85 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -17,6 +17,18 @@ Release Notes
 To avail of fixes in an unreleased version, please download a ZIP file
 `directly from GitHub <https://github.com/dw/mitogen/>`_.
 
+v0.3.4 (2023-07-02)
+-------------------
+
+* :gh:issue:`929` Support Ansible 6 and ansible-core 2.13
+* :gh:issue:`832` Fix runtime error when using the ansible.builtin.dnf module multiple times
+* :gh:issue:`925` :class:`ansible_mitogen.connection.Connection` no longer tries to close the 
+  connection on destruction. This is expected to reduce cases of `mitogen.core.Error: An attempt
+  was made to enqueue a message with a Broker that has already exitted`. However it may result in
+  resource leaks.
+* :gh:issue:`659` Removed :mod:`mitogen.compat.simplejson`, not needed with Python 2.7+, contained Python 3.x syntax errors
+* :gh:issue:`983` CI: Removed PyPI faulthandler requirement from tests
+* :gh:issue:`1001` CI: Fixed Debian 9 & 11 tests
 
 v0.3.3 (2022-06-03)
 -------------------
@@ -53,7 +65,7 @@ v0.3.0 (2021-10-28)
 -------------------
 
 This release separates itself from the v0.2.X releases. Ansible's API changed too much to support backwards compatibility so from now on, v0.2.X releases will be for Ansible < 2.10 and v0.3.X will be for Ansible 2.10+.
-`See here for details <https://github.com/dw/mitogen pull/715#issuecomment-750697248>`_.
+`See here for details <https://github.com/dw/mitogen/pull/715#issuecomment-750697248>`_.
 
 * :gh:issue:`827` NewStylePlanner: detect `ansible_collections` imports
 * :gh:issue:`770` better check for supported Ansible version
diff --git a/mitogen/__init__.py b/mitogen/__init__.py
index 0cd434a8d..8b32cbcc7 100644
--- a/mitogen/__init__.py
+++ b/mitogen/__init__.py
@@ -35,7 +35,7 @@
 
 
 #: Library version as a tuple.
-__version__ = (0, 3, 3)
+__version__ = (0, 3, 4)
 
 
 #: This is :data:`False` in slave contexts. Previously it was used to prevent
diff --git a/mitogen/master.py b/mitogen/master.py
index 3a163a8b0..4fb535f0a 100644
--- a/mitogen/master.py
+++ b/mitogen/master.py
@@ -536,7 +536,7 @@ def find(self, fullname):
 
         try:
             path = loader.get_filename(fullname)
-        except (AttributeError, ImportError):
+        except (AttributeError, ImportError, ValueError):
             # - get_filename() may throw ImportError if pkgutil.find_loader()
             #   picks a "parent" package's loader for some crap that's been
             #   stuffed in sys.modules, for example in the case of urllib3:
diff --git a/tox.ini b/tox.ini
index 6b2addc71..da7109333 100644
--- a/tox.ini
+++ b/tox.ini
@@ -26,6 +26,7 @@
 # ansible == 3.*     ansible-base ~= 2.10.0
 # ansible == 4.*     ansible-core ~= 2.11.0
 # ansible == 5.*     ansible-core ~= 2.12.0
+# ansible == 6.*     ansible-core ~= 2.13.0
 
 # pip --no-python-version-warning
 # pip --disable-pip-version-check
@@ -36,13 +37,11 @@
 envlist =
     init,
     py{27,36}-mode_ansible-ansible{2.10,3,4},
-    py{310}-mode_ansible-ansible{2.10,3,4,5},
+    py{310}-mode_ansible-ansible{2.10,3,4,5,6},
     py{27,36,310}-mode_mitogen-distro_centos{6,7,8},
     py{27,36,310}-mode_mitogen-distro_debian{9,10,11},
     py{27,36,310}-mode_mitogen-distro_ubuntu{1604,1804,2004},
     report,
-requires =
-    tox-factor
 
 [testenv]
 basepython =
@@ -57,16 +56,11 @@ basepython =
 deps =
     -r{toxinidir}/tests/requirements.txt
     mode_ansible: -r{toxinidir}/tests/ansible/requirements.txt
-    ansible2.3: ansible==2.3.3.0
-    ansible2.4: ansible==2.4.6.0
-    ansible2.8: ansible==2.8.3
-    ansible2.9: ansible==2.9.6
-    ansible2.10: ansible-base<2.10.14
-    ansible2.10: ansible==2.10.0
-    ansible3: ansible-base<2.10.14
+    ansible2.10: ansible==2.10.7
     ansible3: ansible==3.4.0
-    ansible4: ansible==4.8.0
-    ansible5: ansible==5.0.1
+    ansible4: ansible==4.10.0
+    ansible5: ansible==5.8.0
+    ansible6: ansible==6.0.0
 install_command =
     python -m pip --no-python-version-warning --disable-pip-version-check install {opts} {packages}
 commands_pre =
@@ -86,6 +80,7 @@ passenv =
     AWS_SECRET_ACCESS_KEY
     HOME
 setenv =
+    # See also azure-pipelines.yml
     ANSIBLE_SKIP_TAGS = requires_local_sudo,resource_intensive
     ANSIBLE_STRATEGY = mitogen_linear
     NOCOVERAGE_ERASE = 1
@@ -102,6 +97,8 @@ setenv =
     distro_ubuntu1804: DISTRO=ubuntu1804
     distro_ubuntu2004: DISTRO=ubuntu2004
     # Note the plural, only applicable to MODE=ansible
+    # Ansible 6 (ansible-core 2.13) requires Python >= 2.7 or >= 3.5 on targets
+    ansible6: DISTROS=centos7 centos8 debian9 debian10 debian11 ubuntu1604 ubuntu1804 ubuntu2004
     distros_centos: DISTROS=centos6 centos7 centos8
     distros_centos5: DISTROS=centos5
     distros_centos6: DISTROS=centos6


Test patch:
diff --git a/.ci/ansible_tests.py b/.ci/ansible_tests.py
index 3c3684f48..43d4f4cbd 100755
--- a/.ci/ansible_tests.py
+++ b/.ci/ansible_tests.py
@@ -1,6 +1,7 @@
 #!/usr/bin/env python
 # Run tests/ansible/all.yml under Ansible and Ansible-Mitogen
 
+import collections
 import glob
 import os
 import signal
@@ -44,6 +45,12 @@ def pause_if_interactive():
         if not path.endswith('default.hosts'):
             ci_lib.run("ln -s %s %s", path, HOSTS_DIR)
 
+    distros = collections.defaultdict(list)
+    families = collections.defaultdict(list)
+    for container in containers:
+        distros[container['distro']].append(container['name'])
+        families[container['family']].append(container['name'])
+
     inventory_path = os.path.join(HOSTS_DIR, 'target')
     with open(inventory_path, 'w') as fp:
         fp.write('[test-targets]\n')
@@ -59,6 +66,16 @@ def pause_if_interactive():
             for container in containers
         )
 
+        for distro, hostnames in sorted(distros.items(), key=lambda t: t[0]):
+            fp.write('\n[%s]\n' % distro)
+            fp.writelines('%s\n' % name for name in hostnames)
+
+        for family, hostnames in sorted(families.items(), key=lambda t: t[0]):
+            fp.write('\n[%s]\n' % family)
+            fp.writelines('%s\n' % name for name in hostnames)
+
+        fp.write('\n[linux:children]\ntest-targets\n')
+
     ci_lib.dump_file(inventory_path)
 
     if not ci_lib.exists_in_path('sshpass'):
diff --git a/tests/ansible/ansible.cfg b/tests/ansible/ansible.cfg
index dfc2858e7..ce4511f67 100644
--- a/tests/ansible/ansible.cfg
+++ b/tests/ansible/ansible.cfg
@@ -1,4 +1,12 @@
 [defaults]
+any_errors_fatal = true
+# callback_whitelist naming will be deprecated in ansible-core >= 2.15.
+# callbacks_enabled naming was added in ansible-core 2.11
+# profile_tasks: Displays timing for each task and summary table of top N tasks
+# timer: Displays "Playbook run took 0 days, 0 hours, ..."
+callback_whitelist =
+    profile_tasks,
+    timer
 inventory = hosts
 gathering = explicit
 strategy_plugins = ../../ansible_mitogen/plugins/strategy
@@ -6,15 +14,12 @@ inventory_plugins = lib/inventory
 action_plugins = lib/action
 callback_plugins = lib/callback
 stdout_callback = yaml
-stdout_whitelist =
-    profile_roles,
-    timer,
-    yaml
 vars_plugins = lib/vars
 library = lib/modules
 filter_plugins = lib/filters
 module_utils = lib/module_utils
 retry_files_enabled = False
+show_task_path_on_failure = true  # Added in ansible-core 2.11
 display_args_to_stdout = True
 forks = 100
 
@@ -30,11 +35,15 @@ transport = ssh
 no_target_syslog = True
 
 # Required by integration/ssh/timeouts.yml
-timeout = 10
+timeout = 30
 
 # On Travis, paramiko check fails due to host key checking enabled.
 host_key_checking = False
 
+[inventory]
+any_unparsed_is_failed = true
+host_pattern_mismatch = error
+
 [callback_profile_tasks]
 task_output_limit = 10
 
diff --git a/tests/ansible/bench/file_transfer.yml b/tests/ansible/bench/file_transfer.yml
index f6702f587..09534af5c 100644
--- a/tests/ansible/bench/file_transfer.yml
+++ b/tests/ansible/bench/file_transfer.yml
@@ -1,7 +1,6 @@
 
 - name: bench/file_transfer.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - name: Make 32MiB file
@@ -52,6 +51,8 @@
     with_items:
     - /tmp/bigfile.out
     - /tmp/bigbigfile.out
+    tags:
+      - requires_local_sudo
 
   - name: Copy 32MiB file via localhost sudo
     delegate_to: localhost
@@ -59,6 +60,8 @@
     copy:
       src: /tmp/bigfile.in
       dest: /tmp/bigfile.out
+    tags:
+      - requires_local_sudo
 
   - name: Copy 320MiB file via localhost sudo
     delegate_to: localhost
@@ -66,6 +69,8 @@
     copy:
       src: /tmp/bigbigfile.in
       dest: /tmp/bigbigfile.out
+    tags:
+      - requires_local_sudo
 
   tags:
   - resource_intensive
diff --git a/tests/ansible/bench/includes.yml b/tests/ansible/bench/includes.yml
index 960798747..6241a4853 100644
--- a/tests/ansible/bench/includes.yml
+++ b/tests/ansible/bench/includes.yml
@@ -1,4 +1,5 @@
-- hosts: test-targets
+- name: bench/includes.yml
+  hosts: test-targets
   tasks:
   - include_tasks: _includes.yml
     with_sequence: start=1 end=1000
diff --git a/tests/ansible/bench/loop-100-copies.yml b/tests/ansible/bench/loop-100-copies.yml
index e25ae552d..2a8e7d6f0 100644
--- a/tests/ansible/bench/loop-100-copies.yml
+++ b/tests/ansible/bench/loop-100-copies.yml
@@ -1,6 +1,5 @@
-
-- hosts: all
-  any_errors_fatal: true
+- name: bench/loop-100-copies.yml
+  hosts: all
   tasks:
 
   - name: Create file tree
diff --git a/tests/ansible/bench/loop-100-items.yml b/tests/ansible/bench/loop-100-items.yml
index e711301dc..ad2986387 100644
--- a/tests/ansible/bench/loop-100-items.yml
+++ b/tests/ansible/bench/loop-100-items.yml
@@ -4,7 +4,8 @@
 #
 # See also: loop-100-tasks.yml
 #
-- hosts: all
+- name: bench/loop-100-items.yml
+  hosts: all
   tasks:
     - command: hostname
       with_sequence: start=1 end="{{end|default(100)}}"
diff --git a/tests/ansible/bench/loop-100-tasks.yml b/tests/ansible/bench/loop-100-tasks.yml
index 4a76c4fec..211cecd1d 100644
--- a/tests/ansible/bench/loop-100-tasks.yml
+++ b/tests/ansible/bench/loop-100-tasks.yml
@@ -8,7 +8,8 @@
 #
 # See also: loop-100-items.yml
 #
-- hosts: all
+- name: bench/loop-100-tasks.yml
+  hosts: all
   tasks:
     - command: hostname
     - command: hostname
diff --git a/tests/ansible/bench/loop-20-templates.yml b/tests/ansible/bench/loop-20-templates.yml
index 17dc77779..4035ea34a 100644
--- a/tests/ansible/bench/loop-20-templates.yml
+++ b/tests/ansible/bench/loop-20-templates.yml
@@ -1,12 +1,14 @@
-
-- hosts: all
+- name: bench/loop-20-templates.yml
+  hosts: all
   tasks:
-  - file:
+  - name: Create loop templates dir
+    file:
       dest: /tmp/templates
       state: "{{item}}"
     with_items: ["absent", "directory"]
 
-  - copy:
+  - name: Copy loop files
+    copy:
       dest: /tmp/templates/{{item}}
       mode: 0755
       content:
diff --git a/tests/ansible/hosts/group_vars/all.yml b/tests/ansible/hosts/group_vars/all.yml
new file mode 100644
index 000000000..cea46113d
--- /dev/null
+++ b/tests/ansible/hosts/group_vars/all.yml
@@ -0,0 +1,2 @@
+---
+pkg_mgr_python_interpreter: python
diff --git a/tests/ansible/hosts/group_vars/centos8.yml b/tests/ansible/hosts/group_vars/centos8.yml
new file mode 100644
index 000000000..7b9e34f63
--- /dev/null
+++ b/tests/ansible/hosts/group_vars/centos8.yml
@@ -0,0 +1,2 @@
+---
+pkg_mgr_python_interpreter: /usr/libexec/platform-python
diff --git a/tests/ansible/hosts/transport_config.hosts b/tests/ansible/hosts/transport_config.hosts
index 856c6a0f0..7d7b526a7 100644
--- a/tests/ansible/hosts/transport_config.hosts
+++ b/tests/ansible/hosts/transport_config.hosts
@@ -1,49 +1,76 @@
 # integration/transport_config
 # Hosts with twiddled configs that need to be checked somehow.
 
+[transport_config:children]
+transport_config_undiscover
+tc_python_path
 
-# tansport()
+[transport_config_undiscover:children]
+tc_become
+tc_become_method
+tc_become_pass
+tc_become_user
+tc_password
+tc_port
+tc_remote_addr
+tc_remote_user
+tc_transport
+
+[transport_config_undiscover:vars]
+# If python interpreter path is unset, Ansible tries to connect & discover it.
+# That causes approx 10 seconds timeout per task - there's no host to connect to.
+# This optimisation should not be relied in any test.
+# Note: tc-python-path-* are intentionally not included.
+ansible_python_interpreter = python3000  # Not expected to exist
+
+[tc_transport]
 tc-transport-unset
 tc-transport-local ansible_connection=local
 tc-transport-smart ansible_connection=smart
 
-# python_path()
+[tc_python_path]
 tc-python-path-unset
 tc-python-path-hostvar  ansible_python_interpreter=/hostvar/path/to/python
 tc-python-path-local-unset ansible_connection=local
 tc-python-path-local-explicit ansible_connection=local ansible_python_interpreter=/a/b/c
 
-# remote_addr()
+[tc_remote_addr]
 tc-remote-addr-unset  # defaults to inventory_hostname
 tc-remote-addr-explicit-ssh ansible_ssh_host=ansi.ssh.host
 tc-remote-addr-explicit-host ansible_host=ansi.host
 tc-remote-addr-explicit-both ansible_ssh_host=a.b.c ansible_host=b.c.d
 
-# password()
+[tc_password]
 tc-password-unset
 tc-password-explicit-ssh  ansible_ssh_pass=ansi-ssh-pass
-tc-password-explicit-user ansible_password=ansi-pass
+tc-password-explicit-pass ansible_password=ansi-pass
 tc-password-explicit-both ansible_password=a.b.c ansible_ssh_pass=c.b.a
 
-# become()
+[tc_remote_user]
+tc-remote-user-unset  # defaults to C.DEFAULT_REMOTE_USER
+tc-remote-user-explicit-ssh  ansible_ssh_user=ansi-ssh-user
+tc-remote-user-explicit-user ansible_user=ansi-user
+tc-remote-user-explicit-both ansible_user=a.b.c ansible_ssh_user=c.b.a
+
+[tc_become]
 tc-become-unset
 tc-become-set
 
-# become_method()
+[tc_become_method]
 tc-become-method-unset
 tc-become-method-su ansible_become_method=su
 
-# become_user()
+[tc_become_user]
 tc-become-user-unset
 tc-become-user-set ansible_become_user=ansi-become-user
 
-# become_pass()
+[tc_become_pass]
 tc-become-pass-unset
 tc-become-pass-password ansible_become_password=apassword
 tc-become-pass-pass ansible_become_pass=apass
-tc-become-pass-both ansible_become_password=a.b.c ansible_become_pass=c.b.a
+tc-become-pass-both ansible_become_pass=bpass ansible_become_password=bpassword
 
-# port()
+[tc_port]
 tc-port-unset
 tc-port-explicit-port ansible_port=1234
 tc-port-explicit-ssh ansible_ssh_port=4321
diff --git a/tests/ansible/integration/action/copy.yml b/tests/ansible/integration/action/copy.yml
index 611ff3f25..5dadff9aa 100644
--- a/tests/ansible/integration/action/copy.yml
+++ b/tests/ansible/integration/action/copy.yml
@@ -2,23 +2,23 @@
 
 - name: integration/action/copy.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
-    - copy:
+    - name: Create tiny file
+      copy:
         dest: /tmp/copy-tiny-file
         content:
           this is a tiny file.
       delegate_to: localhost
 
-    - copy:
+    - name: Create large file
+      copy:
         dest: /tmp/copy-large-file
         # Must be larger than Connection.SMALL_SIZE_LIMIT.
         content: "{% for x in range(200000) %}x{% endfor %}"
       delegate_to: localhost
 
-    # end of making files
-
-    - file:
+    - name: Cleanup copied files
+      file:
         state: absent
         path: "{{item}}"
       with_items:
@@ -27,28 +27,31 @@
       - /tmp/copy-tiny-inline-file.out
       - /tmp/copy-large-inline-file.out
 
-    # end of cleaning out files
-
-    - copy:
+    - name: Copy large file
+      copy:
         dest: /tmp/copy-large-file.out
         src: /tmp/copy-large-file
 
-    - copy:
+    - name: Copy tiny file
+      copy:
         dest: /tmp/copy-tiny-file.out
         src: /tmp/copy-tiny-file
 
-    - copy:
+    - name: Copy tiny inline file
+      copy:
         dest: /tmp/copy-tiny-inline-file.out
         content: "tiny inline content"
 
-    - copy:
+    - name: Copy large inline file
+      copy:
         dest: /tmp/copy-large-inline-file.out
         content: |
           {% for x in range(200000) %}y{% endfor %}
 
     # stat results
 
-    - stat:
+    - name: Stat copied files
+      stat:
         path: "{{item}}"
       with_items:
       - /tmp/copy-tiny-file.out
@@ -65,7 +68,8 @@
         - stat.results[3].stat.checksum == "d675f47e467eae19e49032a2cc39118e12a6ee72"
         fail_msg: stat={{stat}}
 
-    - file:
+    - name: Cleanup files
+      file:
         state: absent
         path: "{{item}}"
       with_items:
diff --git a/tests/ansible/integration/action/fixup_perms2__copy.yml b/tests/ansible/integration/action/fixup_perms2__copy.yml
index 1ba19e6fc..fa4765f81 100644
--- a/tests/ansible/integration/action/fixup_perms2__copy.yml
+++ b/tests/ansible/integration/action/fixup_perms2__copy.yml
@@ -5,12 +5,7 @@
 
 - name: integration/action/fixup_perms2__copy.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
-    #
-    # copy module (no mode).
-    #
-
     - name: "Copy files (no mode)"
       copy:
         content: ""
@@ -23,10 +18,6 @@
         -  out.stat.mode in ("0644", "0664")
         fail_msg: out={{out}}
 
-    #
-    # copy module (explicit mode).
-    #
-
     - name: "Copy files from content: arg"
       copy:
         content: ""
@@ -40,22 +31,20 @@
         -  out.stat.mode == "0400"
         fail_msg: out={{out}}
 
-    #
-    # copy module (existing disk files, no mode).
-    #
-
-    - file:
+    - name: Cleanup local weird mode file
+      file:
         path: /tmp/weird-mode.out
         state: absent
 
-    - name: Create local test file.
+    - name: Create local weird mode file
       delegate_to: localhost
       copy:
         content: "weird mode"
         dest: "/tmp/weird-mode"
         mode: "1462"
 
-    - copy:
+    - name: Copy file with weird mode
+      copy:
         src: "/tmp/weird-mode"
         dest: "/tmp/weird-mode.out"
 
@@ -67,11 +56,8 @@
         - out.stat.mode in ("0644", "0664")
         fail_msg: out={{out}}
 
-    #
-    # copy module (existing disk files, preserve mode).
-    #
-
-    - copy:
+    - name: Copy file with weird mode, preserving mode
+      copy:
         src: "/tmp/weird-mode"
         dest: "/tmp/weird-mode"
         mode: preserve
@@ -84,11 +70,8 @@
         - out.stat.mode == "1462"
         fail_msg: out={{out}}
 
-    #
-    # copy module (existing disk files, explicit mode).
-    #
-
-    - copy:
+    - name: Copy file with weird mode, explicit mode
+      copy:
         src: "/tmp/weird-mode"
         dest: "/tmp/weird-mode"
         mode: "1461"
@@ -102,7 +85,8 @@
         - out.stat.mode == "1461"
         fail_msg: out={{out}}
 
-    - file:
+    - name: Cleanup
+      file:
         state: absent
         path: "{{item}}"
       with_items:
diff --git a/tests/ansible/integration/action/low_level_execute_command.yml b/tests/ansible/integration/action/low_level_execute_command.yml
index 7f91bdf89..246494242 100644
--- a/tests/ansible/integration/action/low_level_execute_command.yml
+++ b/tests/ansible/integration/action/low_level_execute_command.yml
@@ -2,7 +2,6 @@
 
 - name: integration/action/low_level_execute_command.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     # "echo -en" to test we actually hit bash shell too.
diff --git a/tests/ansible/integration/action/make_tmp_path.yml b/tests/ansible/integration/action/make_tmp_path.yml
index bfebeffd6..8e9812506 100644
--- a/tests/ansible/integration/action/make_tmp_path.yml
+++ b/tests/ansible/integration/action/make_tmp_path.yml
@@ -9,7 +9,6 @@
 
 - name: integration/action/make_tmp_path.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - meta: end_play
       when: not is_mitogen
diff --git a/tests/ansible/integration/action/make_tmp_path__double.yml b/tests/ansible/integration/action/make_tmp_path__double.yml
index 811e159d4..fd66c13d4 100644
--- a/tests/ansible/integration/action/make_tmp_path__double.yml
+++ b/tests/ansible/integration/action/make_tmp_path__double.yml
@@ -1,7 +1,8 @@
 # issue #554: double calls to make_tmp_path() fail with assertion error. Ensure
 # they succeed and are cleaned up correctly.
 
-- hosts: target
+- name: integration/action/make_tmp_path__double.yml
+  hosts: test-targets
   tasks:
   - mitogen_action_script:
       script: |
diff --git a/tests/ansible/integration/action/remote_expand_user.yml b/tests/ansible/integration/action/remote_expand_user.yml
index 8e31f6579..3a6756353 100644
--- a/tests/ansible/integration/action/remote_expand_user.yml
+++ b/tests/ansible/integration/action/remote_expand_user.yml
@@ -3,7 +3,6 @@
 
 - name: integration/action/remote_expand_user.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - name: "Find out root's homedir."
       # Runs first because it blats regular Ansible facts with junk, so
diff --git a/tests/ansible/integration/action/remote_file_exists.yml b/tests/ansible/integration/action/remote_file_exists.yml
index 2d206930e..577e64a0c 100644
--- a/tests/ansible/integration/action/remote_file_exists.yml
+++ b/tests/ansible/integration/action/remote_file_exists.yml
@@ -1,38 +1,33 @@
 
 - name: integration/action/remote_file_exists.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
-
-    - file:
+    - name: Ensure does-not-exist doesnt
+      file:
         path: /tmp/does-not-exist
         state: absent
-
     - action_passthrough:
         method: _remote_file_exists
         args: ['/tmp/does-not-exist']
       register: out
-
     - assert:
         that: out.result == False
         fail_msg: out={{out}}
 
-    # ---
-
-    - copy:
+    - name: Ensure does-exist does
+      copy:
         dest: /tmp/does-exist
         content: "I think, therefore I am"
-
     - action_passthrough:
         method: _remote_file_exists
         args: ['/tmp/does-exist']
       register: out
-
     - assert:
         that: out.result == True
         fail_msg: out={{out}}
 
-    - file:
+    - name: Cleanup
+      file:
         path: /tmp/does-exist
         state: absent
   tags:
diff --git a/tests/ansible/integration/action/remove_tmp_path.yml b/tests/ansible/integration/action/remove_tmp_path.yml
index 19ae22e38..d90c90dca 100644
--- a/tests/ansible/integration/action/remove_tmp_path.yml
+++ b/tests/ansible/integration/action/remove_tmp_path.yml
@@ -4,14 +4,14 @@
 #
 - name: integration/action/remove_tmp_path.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     #
     # Use the copy module to cause a temporary directory to be created, and
     # return a result with a 'src' attribute pointing into that directory.
     #
 
-    - copy:
+    - name: Ensure remove_tmp_path_test
+      copy:
         dest: /tmp/remove_tmp_path_test
         content: "{{ 123123 | random }}"
       register: out
diff --git a/tests/ansible/integration/action/synchronize.yml b/tests/ansible/integration/action/synchronize.yml
index a6cd277ea..aa87deafc 100644
--- a/tests/ansible/integration/action/synchronize.yml
+++ b/tests/ansible/integration/action/synchronize.yml
@@ -2,7 +2,6 @@
 
 - name: integration/action/synchronize.yml
   hosts: test-targets
-  any_errors_fatal: true
   vars:
     ansible_user: mitogen__has_sudo_pubkey
     ansible_become_pass: has_sudo_pubkey_password
@@ -13,23 +12,27 @@
     ansible_password: ''
   tasks:
     # must copy git file to set proper file mode.
-    - copy:
+    - name: Copy synchronize-action-key
+      copy:
         dest: /tmp/synchronize-action-key
         src: ../../../data/docker/mitogen__has_sudo_pubkey.key
         mode: u=rw,go=
       delegate_to: localhost
 
-    - file:
+    - name: Cleanup sync-test
+      file:
         path: /tmp/sync-test
         state: absent
       delegate_to: localhost
 
-    - file:
+    - name: Create sync-test
+      file:
         path: /tmp/sync-test
         state: directory
       delegate_to: localhost
 
-    - copy:
+    - name: Create syn-test item
+      copy:
         dest: /tmp/sync-test/item
         content: "item!"
       delegate_to: localhost
diff --git a/tests/ansible/integration/action/transfer_data.yml b/tests/ansible/integration/action/transfer_data.yml
index e0f42d0e1..6d4faab55 100644
--- a/tests/ansible/integration/action/transfer_data.yml
+++ b/tests/ansible/integration/action/transfer_data.yml
@@ -1,49 +1,46 @@
 
 - name: integration/action/transfer_data.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
-
-    - file:
+    - name: Cleanup transfer data
+      file:
         path: /tmp/transfer-data
         state: absent
 
-    # Ensure it JSON-encodes dicts.
-    - action_passthrough:
+    - name: Create JSON transfer data
+      action_passthrough:
         method: _transfer_data
         kwargs:
           remote_path: /tmp/transfer-data
           data: {
             "I am JSON": true
           }
-
-    - slurp:
+    - name: Slurp JSON transfer data
+      slurp:
         src: /tmp/transfer-data
       register: out
-
     - assert:
         that: |
           out.content|b64decode == '{"I am JSON": true}'
         fail_msg: out={{out}}
 
-
-    # Ensure it handles strings.
-    - action_passthrough:
+    - name: Create text transfer data
+      action_passthrough:
         method: _transfer_data
         kwargs:
           remote_path: /tmp/transfer-data
           data: "I am text."
-
-    - slurp:
+    - name: Slurp text transfer data
+      slurp:
         src: /tmp/transfer-data
       register: out
-
     - assert:
         that:
           out.content|b64decode == 'I am text.'
         fail_msg: out={{out}}
 
-    - file:
+    - name: Cleanup transfer data
+      file:
         path: /tmp/transfer-data
         state: absent
   tags:
diff --git a/tests/ansible/integration/async/multiple_items_loop.yml b/tests/ansible/integration/async/multiple_items_loop.yml
index 24d43083f..5d0702555 100644
--- a/tests/ansible/integration/async/multiple_items_loop.yml
+++ b/tests/ansible/integration/async/multiple_items_loop.yml
@@ -2,7 +2,6 @@
 
 - name: integration/async/multiple_items_loop.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - name: start long running ops
diff --git a/tests/ansible/integration/async/result_binary_producing_json.yml b/tests/ansible/integration/async/result_binary_producing_json.yml
index b05b24bf5..942b356a0 100644
--- a/tests/ansible/integration/async/result_binary_producing_json.yml
+++ b/tests/ansible/integration/async/result_binary_producing_json.yml
@@ -2,7 +2,6 @@
 - name: integration/async/result_binary_producing_json.yml
   gather_facts: true
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - block:
@@ -38,7 +37,8 @@
     retries: 100000
     delay: 0
 
-  - slurp:
+  - name: Slurp async busy-poll
+    slurp:
       src: "{{ansible_user_dir}}/.ansible_async/{{job.ansible_job_id}}"
     register: result
 
diff --git a/tests/ansible/integration/async/result_binary_producing_junk.yml b/tests/ansible/integration/async/result_binary_producing_junk.yml
index afdf0c2f7..734f0e125 100644
--- a/tests/ansible/integration/async/result_binary_producing_junk.yml
+++ b/tests/ansible/integration/async/result_binary_producing_junk.yml
@@ -2,7 +2,6 @@
 - name: integration/async/result_binary_producing_junk.yml
   gather_facts: true
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - block:
diff --git a/tests/ansible/integration/async/result_shell_echo_hi.yml b/tests/ansible/integration/async/result_shell_echo_hi.yml
index 6eb317023..f327a965b 100644
--- a/tests/ansible/integration/async/result_shell_echo_hi.yml
+++ b/tests/ansible/integration/async/result_shell_echo_hi.yml
@@ -2,17 +2,19 @@
 - name: integration/async/result_shell_echo_hi.yml
   gather_facts: true
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
-  - shell: echo hi; echo there >&2
+  - name: Async shell
+    shell: echo hi; echo there >&2
     async: 100
     poll: 0
     register: job
 
-  - shell: sleep 1
+  - name: Sleepy shell
+    shell: sleep 1
 
-  - slurp:
+  - name: Slurp async shell
+    slurp:
       src: "{{ansible_user_dir}}/.ansible_async/{{job.ansible_job_id}}"
     register: result
 
diff --git a/tests/ansible/integration/async/runner_new_process.yml b/tests/ansible/integration/async/runner_new_process.yml
index fbc7261fc..0113b6589 100644
--- a/tests/ansible/integration/async/runner_new_process.yml
+++ b/tests/ansible/integration/async/runner_new_process.yml
@@ -2,7 +2,6 @@
 
 - name: integration/async/runner_new_process.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - name: get process ID.
diff --git a/tests/ansible/integration/async/runner_one_job.yml b/tests/ansible/integration/async/runner_one_job.yml
index 15e02efa5..bfb50bfa8 100644
--- a/tests/ansible/integration/async/runner_one_job.yml
+++ b/tests/ansible/integration/async/runner_one_job.yml
@@ -3,7 +3,6 @@
 
 - name: integration/async/runner_one_job.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   # Verify output of a single async job.
diff --git a/tests/ansible/integration/async/runner_timeout_then_polling.yml b/tests/ansible/integration/async/runner_timeout_then_polling.yml
index 6fc46ba75..2f71ebe42 100644
--- a/tests/ansible/integration/async/runner_timeout_then_polling.yml
+++ b/tests/ansible/integration/async/runner_timeout_then_polling.yml
@@ -2,7 +2,6 @@
 
 - name: integration/async/runner_timeout_then_polling.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   # Verify async-with-timeout-then-poll behaviour.
diff --git a/tests/ansible/integration/async/runner_two_simultaneous_jobs.yml b/tests/ansible/integration/async/runner_two_simultaneous_jobs.yml
index 6eda88aac..5678dbdd7 100644
--- a/tests/ansible/integration/async/runner_two_simultaneous_jobs.yml
+++ b/tests/ansible/integration/async/runner_two_simultaneous_jobs.yml
@@ -1,12 +1,12 @@
 
 - name: integration/async/runner_two_simultaneous_jobs.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   # Start 2 duplicate jobs, verify they run concurrently.
 
-  - file:
+  - name: Cleanup semaphore file
+    file:
       path: /tmp/flurp
       state: absent
 
diff --git a/tests/ansible/integration/async/runner_with_polling_and_timeout.yml b/tests/ansible/integration/async/runner_with_polling_and_timeout.yml
index a5cbabef6..ba4133725 100644
--- a/tests/ansible/integration/async/runner_with_polling_and_timeout.yml
+++ b/tests/ansible/integration/async/runner_with_polling_and_timeout.yml
@@ -2,7 +2,6 @@
 
 - name: integration/async/runner_with_polling_and_timeout.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   # Verify async-with-polling-and-timeout behaviour.
diff --git a/tests/ansible/integration/become/su_password.yml b/tests/ansible/integration/become/su_password.yml
index 7bd6db5aa..bd6a0aee6 100644
--- a/tests/ansible/integration/become/su_password.yml
+++ b/tests/ansible/integration/become/su_password.yml
@@ -4,7 +4,6 @@
 - name: integration/become/su_password.yml
   hosts: test-targets
   become_method: su
-  any_errors_fatal: true
   tasks:
 
     - name: Ensure su password absent but required.
diff --git a/tests/ansible/integration/become/sudo_flags_failure.yml b/tests/ansible/integration/become/sudo_flags_failure.yml
index fdbb712c6..75ecfebf6 100644
--- a/tests/ansible/integration/become/sudo_flags_failure.yml
+++ b/tests/ansible/integration/become/sudo_flags_failure.yml
@@ -1,6 +1,5 @@
 - name: integration/become/sudo_flags_failure.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - name: Verify behaviour for bad sudo flags.
diff --git a/tests/ansible/integration/become/sudo_nonexistent.yml b/tests/ansible/integration/become/sudo_nonexistent.yml
index bc3de7b81..e7a849c22 100644
--- a/tests/ansible/integration/become/sudo_nonexistent.yml
+++ b/tests/ansible/integration/become/sudo_nonexistent.yml
@@ -1,6 +1,5 @@
 - name: integration/become/sudo_nonexistent.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - name: Verify behaviour for non-existent accounts.
diff --git a/tests/ansible/integration/become/sudo_nopassword.yml b/tests/ansible/integration/become/sudo_nopassword.yml
index 110c95b43..98dd9cfd7 100644
--- a/tests/ansible/integration/become/sudo_nopassword.yml
+++ b/tests/ansible/integration/become/sudo_nopassword.yml
@@ -2,7 +2,6 @@
 
 - name: integration/become/sudo_nopassword.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - name: Verify we aren't root
diff --git a/tests/ansible/integration/become/sudo_password.yml b/tests/ansible/integration/become/sudo_password.yml
index a0a1dec9d..931cba27c 100644
--- a/tests/ansible/integration/become/sudo_password.yml
+++ b/tests/ansible/integration/become/sudo_password.yml
@@ -2,7 +2,6 @@
 
 - name: integration/become/sudo_password.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - name: Ensure sudo password absent but required.
diff --git a/tests/ansible/integration/become/sudo_requiretty.yml b/tests/ansible/integration/become/sudo_requiretty.yml
index 0bdfaa26e..19b49b285 100644
--- a/tests/ansible/integration/become/sudo_requiretty.yml
+++ b/tests/ansible/integration/become/sudo_requiretty.yml
@@ -2,7 +2,6 @@
 
 - name: integration/become/sudo_requiretty.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     # TODO: https://github.com/dw/mitogen/issues/692
diff --git a/tests/ansible/integration/connection/_put_file.yml b/tests/ansible/integration/connection/_put_file.yml
index 3092e1994..fb5c60144 100644
--- a/tests/ansible/integration/connection/_put_file.yml
+++ b/tests/ansible/integration/connection/_put_file.yml
@@ -1,19 +1,23 @@
 ---
 
-- shell: dd if=/dev/urandom of=/tmp/{{file_name}} bs=1024 count={{file_size}}
+- name: Create {{ file_name }}
+  shell: dd if=/dev/urandom of=/tmp/{{ file_name }} bs=1024 count={{ file_size }}
   args:
     creates: /tmp/{{file_name}}
   delegate_to: localhost
 
-- copy:
+- name: Copy {{ file_name }}
+  copy:
     dest: /tmp/{{file_name}}.out
     src: /tmp/{{file_name}}
 
-- stat: path=/tmp/{{file_name}}
+- name: Stat created {{ file_name }}
+  stat: path=/tmp/{{ file_name }}
   register: original
   delegate_to: localhost
 
-- stat: path=/tmp/{{file_name}}.out
+- name: Stat copied {{ file_name }}
+  stat: path=/tmp/{{ file_name }}.out
   register: copied
 
 - assert:
diff --git a/tests/ansible/integration/connection/become_same_user.yml b/tests/ansible/integration/connection/become_same_user.yml
index 9f720e5ca..5ff4f95b3 100644
--- a/tests/ansible/integration/connection/become_same_user.yml
+++ b/tests/ansible/integration/connection/become_same_user.yml
@@ -4,7 +4,6 @@
 - name: integration/connection/become_same_user.yml
   hosts: bsu-joe
   gather_facts: no
-  any_errors_fatal: true
   tasks:
 
   # bsu-joe's login user is joe, so become should be ignored.
diff --git a/tests/ansible/integration/connection/disconnect_during_module.yml b/tests/ansible/integration/connection/disconnect_during_module.yml
index 1c7b544a2..5c74b0ed8 100644
--- a/tests/ansible/integration/connection/disconnect_during_module.yml
+++ b/tests/ansible/integration/connection/disconnect_during_module.yml
@@ -9,10 +9,13 @@
   - meta: end_play
     when: not is_mitogen
 
-  - delegate_to: localhost
+  - name: Run _disconnect_during_module.yml
+    delegate_to: localhost
     command: |
       ansible-playbook
-      -i "{{MITOGEN_INVENTORY_FILE}}"
+      {% for inv in ansible_inventory_sources %}
+      -i "{{ inv }}"
+      {% endfor %}
       integration/connection/_disconnect_during_module.yml
     args:
       chdir: ../..
diff --git a/tests/ansible/integration/connection/disconnect_resets_connection.yml b/tests/ansible/integration/connection/disconnect_resets_connection.yml
index 3a6f712a3..95f969106 100644
--- a/tests/ansible/integration/connection/disconnect_resets_connection.yml
+++ b/tests/ansible/integration/connection/disconnect_resets_connection.yml
@@ -12,7 +12,6 @@
 - name: integration/connection/disconnect_resets_connection.yml
   hosts: test-targets
   gather_facts: no
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/integration/connection/exec_command.yml b/tests/ansible/integration/connection/exec_command.yml
index 125def1bb..f0ec1c1b0 100644
--- a/tests/ansible/integration/connection/exec_command.yml
+++ b/tests/ansible/integration/connection/exec_command.yml
@@ -4,7 +4,6 @@
 - name: integration/connection/exec_command.yml
   hosts: test-targets
   gather_facts: no
-  any_errors_fatal: true
   tasks:
   - connection_passthrough:
       method: exec_command
diff --git a/tests/ansible/integration/connection/home_dir.yml b/tests/ansible/integration/connection/home_dir.yml
index 1dbaa5d3b..d41657bcb 100644
--- a/tests/ansible/integration/connection/home_dir.yml
+++ b/tests/ansible/integration/connection/home_dir.yml
@@ -2,7 +2,6 @@
 
 - name: integration/connection/home_dir.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - name: "Find out root's homedir."
       # Runs first because it blats regular Ansible facts with junk, so
diff --git a/tests/ansible/integration/connection/put_large_file.yml b/tests/ansible/integration/connection/put_large_file.yml
index 90a13999c..ce8f8fe06 100644
--- a/tests/ansible/integration/connection/put_large_file.yml
+++ b/tests/ansible/integration/connection/put_large_file.yml
@@ -4,7 +4,6 @@
 - name: integration/connection/put_large_file.yml
   hosts: test-targets
   gather_facts: no
-  any_errors_fatal: true
   vars:
     file_name: large-file
     file_size: 512
diff --git a/tests/ansible/integration/connection/put_small_file.yml b/tests/ansible/integration/connection/put_small_file.yml
index 49be87a9d..3e0eaaa9f 100644
--- a/tests/ansible/integration/connection/put_small_file.yml
+++ b/tests/ansible/integration/connection/put_small_file.yml
@@ -4,7 +4,6 @@
 - name: integration/connection/put_small_file.yml
   hosts: test-targets
   gather_facts: no
-  any_errors_fatal: true
   vars:
     file_name: small-file
     file_size: 123
diff --git a/tests/ansible/integration/connection/reset_become.yml b/tests/ansible/integration/connection/reset_become.yml
index a36cbf1a3..003c375e1 100644
--- a/tests/ansible/integration/connection/reset_become.yml
+++ b/tests/ansible/integration/connection/reset_become.yml
@@ -1,7 +1,8 @@
 # issue #633: Connection.reset() should ignore "become", and apply to the login
 # account.
 
-- hosts: test-targets
+- name: integration/connection/reset_become.yml
+  hosts: test-targets
   become: true
   gather_facts: false
   tasks:
diff --git a/tests/ansible/integration/connection_delegation/all.yml b/tests/ansible/integration/connection_delegation/all.yml
index 8d5ffe038..cb55bdc75 100644
--- a/tests/ansible/integration/connection_delegation/all.yml
+++ b/tests/ansible/integration/connection_delegation/all.yml
@@ -1,5 +1,5 @@
 - import_playbook: delegate_to_template.yml
 - import_playbook: local_action.yml
-- import_playbook: osa_container_standalone.yml
-- import_playbook: osa_delegate_to_self.yml
+#- import_playbook: osa_container_standalone.yml
+#- import_playbook: osa_delegate_to_self.yml
 - import_playbook: stack_construction.yml
diff --git a/tests/ansible/integration/connection_delegation/delegate_to_template.yml b/tests/ansible/integration/connection_delegation/delegate_to_template.yml
index be083ff9a..36de1657f 100644
--- a/tests/ansible/integration/connection_delegation/delegate_to_template.yml
+++ b/tests/ansible/integration/connection_delegation/delegate_to_template.yml
@@ -14,7 +14,6 @@
     physical_hosts: ["cd-normal-alias", "cd-normal-normal"]
   hosts: test-targets
   gather_facts: no
-  any_errors_fatal: true
   tasks:
     - meta: end_play
       when: not is_mitogen
@@ -34,7 +33,7 @@
             'kwargs': {
               'check_host_keys': 'ignore',
               'compression': True,
-              'connect_timeout': 10,
+              'connect_timeout': 30,
               'hostname': 'alias-host',
               'identities_only': False,
               'identity_file': null,
@@ -64,7 +63,7 @@
             'kwargs': {
               'check_host_keys': 'ignore',
               'compression': True,
-              'connect_timeout': 10,
+              'connect_timeout': 30,
               'hostname': 'cd-normal-alias',
               'identities_only': False,
               'identity_file': null,
diff --git a/tests/ansible/integration/connection_delegation/local_action.yml b/tests/ansible/integration/connection_delegation/local_action.yml
index 1c9a25acb..6176d770c 100644
--- a/tests/ansible/integration/connection_delegation/local_action.yml
+++ b/tests/ansible/integration/connection_delegation/local_action.yml
@@ -1,7 +1,7 @@
-
 # issue #251: local_action with mitogen_via= builds wrong stack.
 
-- hosts: cd-newuser-normal-normal
+- name: integration/connection_delegation/local_action.yml
+  hosts: cd-newuser-normal-normal
   tasks:
     - meta: end_play
       when: not is_mitogen
@@ -22,7 +22,7 @@
           {
             'enable_lru': true,
             'kwargs': {
-              'connect_timeout': 10,
+              'connect_timeout': 30,
               'python_path': ["{{ansible_playbook_python}}"],
               'remote_name': null,
               'password': null,
diff --git a/tests/ansible/integration/connection_delegation/stack_construction.yml b/tests/ansible/integration/connection_delegation/stack_construction.yml
index ebf0f54f9..380b31985 100644
--- a/tests/ansible/integration/connection_delegation/stack_construction.yml
+++ b/tests/ansible/integration/connection_delegation/stack_construction.yml
@@ -30,7 +30,6 @@
 
 
 - hosts: cd-normal
-  any_errors_fatal: true
   tasks:
     - meta: end_play
       when: not is_mitogen
@@ -42,7 +41,7 @@
         right: [
           {
             "kwargs": {
-              "connect_timeout": 10,
+              "connect_timeout": 30,
               "doas_path": null,
               "password": null,
               "python_path": ["/usr/bin/python"],
@@ -71,7 +70,7 @@
                   'kwargs': {
                       'check_host_keys': 'ignore',
                       'compression': True,
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'hostname': 'alias-host',
                       'identities_only': False,
                       'identity_file': null,
@@ -116,7 +115,7 @@
                   'kwargs': {
                       'check_host_keys': 'ignore',
                       'compression': True,
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'hostname': 'alias-host',
                       'identities_only': False,
                       'identity_file': null,
@@ -159,7 +158,7 @@
         right: [
               {
                   'kwargs': {
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'doas_path': null,
                       'password': null,
                       "python_path": ["/usr/bin/python"],
@@ -172,7 +171,7 @@
                   'kwargs': {
                       'check_host_keys': 'ignore',
                       'compression': True,
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'hostname': 'cd-normal-normal',
                       'identities_only': False,
                       'identity_file': null,
@@ -217,7 +216,7 @@
                   'kwargs': {
                       'check_host_keys': 'ignore',
                       'compression': True,
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'hostname': 'alias-host',
                       'identities_only': False,
                       'identity_file': null,
@@ -247,7 +246,7 @@
                   'kwargs': {
                       'check_host_keys': 'ignore',
                       'compression': True,
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'hostname': 'cd-normal-alias',
                       'identities_only': False,
                       'identity_file': null,
@@ -290,7 +289,7 @@
         right: [
               {
                   'kwargs': {
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'doas_path': null,
                       'password': null,
                       "python_path": ["/usr/bin/python"],
@@ -303,7 +302,7 @@
                   'kwargs': {
                       'check_host_keys': 'ignore',
                       'compression': True,
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'hostname': 'cd-newuser-normal-normal',
                       'identities_only': False,
                       'identity_file': null,
@@ -349,7 +348,7 @@
                   'kwargs': {
                       'check_host_keys': 'ignore',
                       'compression': True,
-                      'connect_timeout': 10,
+                      'connect_timeout': 30,
                       'hostname': 'alias-host',
                       'identities_only': False,
                       'identity_file': null,
@@ -413,7 +412,7 @@
         right: [
           {
             'kwargs': {
-              'connect_timeout': 10,
+              'connect_timeout': 30,
               'doas_path': null,
               'password': null,
               'python_path': ["/usr/bin/python"],
@@ -424,7 +423,7 @@
           },
           {
             'kwargs': {
-              'connect_timeout': 10,
+              'connect_timeout': 30,
               'doas_path': null,
               'password': null,
               'python_path': ["/usr/bin/python"],
@@ -433,6 +432,6 @@
             },
             'method': 'doas',
           },
-      ]
+        ]
   tags:
     - stack_construction
diff --git a/tests/ansible/integration/connection_loader/local_blemished.yml b/tests/ansible/integration/connection_loader/local_blemished.yml
index 85224dbff..1452abd29 100644
--- a/tests/ansible/integration/connection_loader/local_blemished.yml
+++ b/tests/ansible/integration/connection_loader/local_blemished.yml
@@ -2,7 +2,6 @@
 
 - name: integration/connection_loader/local_blemished.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - determine_strategy:
 
diff --git a/tests/ansible/integration/connection_loader/paramiko_unblemished.yml b/tests/ansible/integration/connection_loader/paramiko_unblemished.yml
index 1bc5e2f63..74b3c7434 100644
--- a/tests/ansible/integration/connection_loader/paramiko_unblemished.yml
+++ b/tests/ansible/integration/connection_loader/paramiko_unblemished.yml
@@ -3,7 +3,6 @@
 
 - name: integration/connection_loader/paramiko_unblemished.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - debug:
         msg: "skipped for now"
diff --git a/tests/ansible/integration/connection_loader/ssh_blemished.yml b/tests/ansible/integration/connection_loader/ssh_blemished.yml
index 58d35cee3..c019f2084 100644
--- a/tests/ansible/integration/connection_loader/ssh_blemished.yml
+++ b/tests/ansible/integration/connection_loader/ssh_blemished.yml
@@ -2,7 +2,6 @@
 
 - name: integration/connection_loader__ssh_blemished.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - determine_strategy:
 
diff --git a/tests/ansible/integration/context_service/disconnect_cleanup.yml b/tests/ansible/integration/context_service/disconnect_cleanup.yml
index 48304120d..2dd10b2cb 100644
--- a/tests/ansible/integration/context_service/disconnect_cleanup.yml
+++ b/tests/ansible/integration/context_service/disconnect_cleanup.yml
@@ -3,7 +3,6 @@
 
 - name: integration/context_service/disconnect_cleanup.yml
   hosts: test-targets[0]
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/integration/context_service/lru_one_target.yml b/tests/ansible/integration/context_service/lru_one_target.yml
index 2d6dc6cfb..570581e30 100644
--- a/tests/ansible/integration/context_service/lru_one_target.yml
+++ b/tests/ansible/integration/context_service/lru_one_target.yml
@@ -2,7 +2,6 @@
 
 - name: integration/context_service/lru_one_target.yml
   hosts: test-targets
-  any_errors_fatal: true
   vars:
     max_interps: "{{lookup('env', 'MITOGEN_MAX_INTERPRETERS')}}"
     ubound: "{{max_interps|int + 1}}"
diff --git a/tests/ansible/integration/context_service/reconnection.yml b/tests/ansible/integration/context_service/reconnection.yml
index 2d7dbacf8..5ab2309ff 100644
--- a/tests/ansible/integration/context_service/reconnection.yml
+++ b/tests/ansible/integration/context_service/reconnection.yml
@@ -3,7 +3,6 @@
 
 - name: integration/context_service/reconnection.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - mitogen_shutdown_all:
 
@@ -14,7 +13,8 @@
       custom_python_detect_environment:
       register: old_become_env
 
-    - become: true
+    - name: Kill ssh process
+      become: true
       shell: |
         bash -c "( sleep 3; kill -9 {{ssh_account_env.pid}}; ) & disown"
 
diff --git a/tests/ansible/integration/context_service/remote_name.yml b/tests/ansible/integration/context_service/remote_name.yml
index cce209e46..79fb8dead 100644
--- a/tests/ansible/integration/context_service/remote_name.yml
+++ b/tests/ansible/integration/context_service/remote_name.yml
@@ -2,7 +2,6 @@
 
 - name: integration/context_service/remote_name.yml
   hosts: test-targets[0]
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
@@ -11,7 +10,8 @@
   - meta: end_play
     when: ansible_system != 'Linux'
 
-  - shell: 'cat /proc/$PPID/cmdline | tr \\0 \\n'
+  - name: Get cmdline
+    shell: 'cat /proc/$PPID/cmdline | tr \\0 \\n'
     register: out
   - debug: var=out
 
@@ -20,7 +20,8 @@
       - out.stdout is match('.*python([0-9.]+)?\(mitogen:[a-z]+@[^:]+:[0-9]+\)')
       fail_msg: out={{out}}
 
-  - shell: 'cat /proc/$PPID/cmdline | tr \\0 \\n'
+  - name: Get cmdline, with mitogen_mask_remote_name
+    shell: 'cat /proc/$PPID/cmdline | tr \\0 \\n'
     register: out
     vars:
       mitogen_mask_remote_name: true
diff --git a/tests/ansible/integration/glibc_caches/resolv_conf.yml b/tests/ansible/integration/glibc_caches/resolv_conf.yml
index e83ce38ef..1b22b66c4 100644
--- a/tests/ansible/integration/glibc_caches/resolv_conf.yml
+++ b/tests/ansible/integration/glibc_caches/resolv_conf.yml
@@ -15,12 +15,14 @@
         - ansible_facts.virtualization_type == "docker"
         - ansible_facts.python.version_info[:2] >= [2, 5]
 
-    - shell: cp /etc/resolv.conf /tmp/resolv.conf
+    - name: Backup resolv.conf
+      shell: cp /etc/resolv.conf /tmp/resolv.conf
       when:
         - ansible_facts.virtualization_type == "docker"
         - ansible_facts.python.version_info[:2] >= [2, 5]
 
-    - shell: echo > /etc/resolv.conf
+    - name: Truncate resolv.conf
+      shell: echo > /etc/resolv.conf
       when:
         - ansible_facts.virtualization_type == "docker"
         - ansible_facts.python.version_info[:2] >= [2, 5]
@@ -33,7 +35,8 @@
         - ansible_facts.virtualization_type == "docker"
         - ansible_facts.python.version_info[:2] >= [2, 5]
 
-    - shell: cat /tmp/resolv.conf > /etc/resolv.conf
+    - name: Restore resolv.conf
+      shell: cat /tmp/resolv.conf > /etc/resolv.conf
       when:
         - ansible_facts.virtualization_type == "docker"
         - ansible_facts.python.version_info[:2] >= [2, 5]
diff --git a/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml b/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml
index 201ef8b48..ea828aa86 100644
--- a/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml
+++ b/tests/ansible/integration/interpreter_discovery/ansible_2_8_tests.yml
@@ -3,7 +3,6 @@
 
 - name: integration/interpreter_discovery/ansible_2_8_tests.yml
   hosts: test-targets
-  any_errors_fatal: true
   gather_facts: true
   tasks:
     - name: can only run these tests on ansible >= 2.8.0
diff --git a/tests/ansible/integration/interpreter_discovery/complex_args.yml b/tests/ansible/integration/interpreter_discovery/complex_args.yml
index ca35a4026..38d101247 100644
--- a/tests/ansible/integration/interpreter_discovery/complex_args.yml
+++ b/tests/ansible/integration/interpreter_discovery/complex_args.yml
@@ -3,7 +3,6 @@
 
 - name: integration/interpreter_discovery/complex_args.yml
   hosts: test-targets
-  any_errors_fatal: true
   gather_facts: true
   tasks:
     - name: create temp file to source
diff --git a/tests/ansible/integration/local/cwd_preserved.yml b/tests/ansible/integration/local/cwd_preserved.yml
index 24a34ba8a..70aec6136 100644
--- a/tests/ansible/integration/local/cwd_preserved.yml
+++ b/tests/ansible/integration/local/cwd_preserved.yml
@@ -5,10 +5,10 @@
 # https://github.com/ansible/ansible/issues/14489
 
 - name: integration/local/cwd_preserved.yml
-  any_errors_fatal: true
   hosts: test-targets
   tasks:
-  - connection: local
+  - name: Get local cwd
+    connection: local
     command: pwd
     register: pwd
 
diff --git a/tests/ansible/integration/local/env_preserved.yml b/tests/ansible/integration/local/env_preserved.yml
index 647947561..e074a4904 100644
--- a/tests/ansible/integration/local/env_preserved.yml
+++ b/tests/ansible/integration/local/env_preserved.yml
@@ -1,8 +1,8 @@
-
 # Execution environment should be that of WorkerProcess --
 # https://github.com/dw/mitogen/issues/297
 
-- hosts: localhost
+- name: integration/local/env_preserved.yml
+  hosts: localhost
   connection: local
   tasks:
     - shell: "env | grep EVIL_VARS_PLUGIN"
diff --git a/tests/ansible/integration/module_utils/adjacent_to_playbook.yml b/tests/ansible/integration/module_utils/adjacent_to_playbook.yml
index edd65db7d..40da94d09 100644
--- a/tests/ansible/integration/module_utils/adjacent_to_playbook.yml
+++ b/tests/ansible/integration/module_utils/adjacent_to_playbook.yml
@@ -3,7 +3,6 @@
 
 - name: integration/module_utils/adjacent_to_playbook.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - custom_python_external_module:
diff --git a/tests/ansible/integration/module_utils/adjacent_to_role.yml b/tests/ansible/integration/module_utils/adjacent_to_role.yml
index 4430c2d96..c4fb813fa 100644
--- a/tests/ansible/integration/module_utils/adjacent_to_role.yml
+++ b/tests/ansible/integration/module_utils/adjacent_to_role.yml
@@ -3,7 +3,6 @@
 
 - name: integration/module_utils/adjacent_to_playbook.yml
   hosts: test-targets
-  any_errors_fatal: true
   roles:
   - modrole
   tags:
diff --git a/tests/ansible/integration/module_utils/from_config_path.yml b/tests/ansible/integration/module_utils/from_config_path.yml
index 0fb0f3ead..f4d9186b1 100644
--- a/tests/ansible/integration/module_utils/from_config_path.yml
+++ b/tests/ansible/integration/module_utils/from_config_path.yml
@@ -2,7 +2,6 @@
 
 - name: integration/module_utils/from_config_path.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - custom_python_external_module:
diff --git a/tests/ansible/integration/module_utils/from_config_path_pkg.yml b/tests/ansible/integration/module_utils/from_config_path_pkg.yml
index 3453cf293..3e81f10f8 100644
--- a/tests/ansible/integration/module_utils/from_config_path_pkg.yml
+++ b/tests/ansible/integration/module_utils/from_config_path_pkg.yml
@@ -2,7 +2,6 @@
 
 - name: integration/module_utils/from_config_path.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - custom_python_external_pkg:
diff --git a/tests/ansible/integration/module_utils/overrides_builtin.yml b/tests/ansible/integration/module_utils/overrides_builtin.yml
index 86d883128..9d841e2ed 100644
--- a/tests/ansible/integration/module_utils/overrides_builtin.yml
+++ b/tests/ansible/integration/module_utils/overrides_builtin.yml
@@ -1,7 +1,6 @@
 
 - name: integration/module_utils/overrides_builtin.yml
   hosts: test-targets
-  any_errors_fatal: true
   roles:
   - overrides_modrole
   tags:
diff --git a/tests/ansible/integration/module_utils/roles/modrole/library/uses_external3.py b/tests/ansible/integration/module_utils/roles/modrole/library/uses_external3.py
index 78f2d71d8..faf8a9a5b 100644
--- a/tests/ansible/integration/module_utils/roles/modrole/library/uses_external3.py
+++ b/tests/ansible/integration/module_utils/roles/modrole/library/uses_external3.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 
 from ansible.module_utils.basic import AnsibleModule
 from ansible.module_utils import external3
diff --git a/tests/ansible/integration/module_utils/roles/overrides_modrole/library/uses_custom_known_hosts.py b/tests/ansible/integration/module_utils/roles/overrides_modrole/library/uses_custom_known_hosts.py
index 688fc45ab..4fc4e04c6 100644
--- a/tests/ansible/integration/module_utils/roles/overrides_modrole/library/uses_custom_known_hosts.py
+++ b/tests/ansible/integration/module_utils/roles/overrides_modrole/library/uses_custom_known_hosts.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 
 import json
 import ansible.module_utils.basic
diff --git a/tests/ansible/integration/playbook_semantics/become_flags.yml b/tests/ansible/integration/playbook_semantics/become_flags.yml
index ac7626437..9c6c8d904 100644
--- a/tests/ansible/integration/playbook_semantics/become_flags.yml
+++ b/tests/ansible/integration/playbook_semantics/become_flags.yml
@@ -4,7 +4,6 @@
 
 - name: integration/playbook_semantics/become_flags.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - name: "without -E"
@@ -19,7 +18,6 @@
     - become_flags
 
 - hosts: test-targets
-  any_errors_fatal: true
   become_flags: -E
   tasks:
     - name: "with -E"
diff --git a/tests/ansible/integration/playbook_semantics/delegate_to.yml b/tests/ansible/integration/playbook_semantics/delegate_to.yml
index 7536258f8..c8d9e6071 100644
--- a/tests/ansible/integration/playbook_semantics/delegate_to.yml
+++ b/tests/ansible/integration/playbook_semantics/delegate_to.yml
@@ -1,6 +1,5 @@
 - name: integration/playbook_semantics/delegate_to.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     #
     # delegate_to, no sudo
diff --git a/tests/ansible/integration/playbook_semantics/environment.yml b/tests/ansible/integration/playbook_semantics/environment.yml
index 01e8ce04b..eea000bd1 100644
--- a/tests/ansible/integration/playbook_semantics/environment.yml
+++ b/tests/ansible/integration/playbook_semantics/environment.yml
@@ -2,9 +2,9 @@
 
 - name: integration/playbook_semantics/environment.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
-    - shell: echo $SOME_ENV
+    - name: Echo $SOME_ENV
+      shell: echo $SOME_ENV
       environment:
         SOME_ENV: 123
       register: result
diff --git a/tests/ansible/integration/playbook_semantics/with_items.yml b/tests/ansible/integration/playbook_semantics/with_items.yml
index 12a92bba5..4de29c4b8 100644
--- a/tests/ansible/integration/playbook_semantics/with_items.yml
+++ b/tests/ansible/integration/playbook_semantics/with_items.yml
@@ -3,7 +3,6 @@
 
 - name: integration/playbook_semantics/with_items.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     # TODO: https://github.com/dw/mitogen/issues/692
diff --git a/tests/ansible/integration/process/unix_socket_cleanup.yml b/tests/ansible/integration/process/unix_socket_cleanup.yml
index 6aeac609c..625ad7b3a 100644
--- a/tests/ansible/integration/process/unix_socket_cleanup.yml
+++ b/tests/ansible/integration/process/unix_socket_cleanup.yml
@@ -1,5 +1,5 @@
-
-- hosts: test-targets[0]
+- name: integration/process/unix_socket_cleanup.yml
+  hosts: test-targets[0]
   tasks:
   - mitogen_action_script:
       script: |
@@ -10,7 +10,11 @@
   - shell: >
       ANSIBLE_STRATEGY=mitogen_linear
       ANSIBLE_SSH_ARGS=""
-      ansible -m shell -c local -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+      ansible -m shell -c local -a whoami
+      {% for inv in ansible_inventory_sources %}
+      -i "{{ inv }}"
+      {% endfor %}
+      test-targets
     args:
       chdir: ../..
     register: out
diff --git a/tests/ansible/integration/runner/_etc_environment_global.yml b/tests/ansible/integration/runner/_etc_environment_global.yml
index 7b769ef42..2d883d062 100644
--- a/tests/ansible/integration/runner/_etc_environment_global.yml
+++ b/tests/ansible/integration/runner/_etc_environment_global.yml
@@ -1,18 +1,21 @@
 # /etc/environment
 
-- file:
+- name: Remove /etc/environment
+  file:
     path: /etc/environment
     state: absent
   become: true
 
-- shell: echo $MAGIC_ETC_ENV
+- name: Check MAGIC_ETC_ENV without
+  shell: echo $MAGIC_ETC_ENV
   register: echo
 
 - assert:
     that: echo.stdout == ""
     fail_msg: echo={{echo}}
 
-- copy:
+- name: Create /etc/environment
+  copy:
     dest: /etc/environment
     content: |
       MAGIC_ETC_ENV=555
@@ -23,14 +26,16 @@
 #- mitogen_shutdown_all:
   #when: not is_mitogen
 
-- shell: echo $MAGIC_ETC_ENV
+- name: Check MAGIC_ETC_ENV with
+  shell: echo $MAGIC_ETC_ENV
   register: echo
 
 - assert:
     that: echo.stdout == "555"
     fail_msg: echo={{echo}}
 
-- file:
+- name: Cleanup /etc/environment
+  file:
     path: /etc/environment
     state: absent
   become: true
@@ -40,7 +45,8 @@
 - mitogen_shutdown_all:
   when: not is_mitogen
 
-- shell: echo $MAGIC_ETC_ENV
+- name: Check MAGIC_ETC_ENV without+shutdown
+  shell: echo $MAGIC_ETC_ENV
   register: echo
 
 - assert:
diff --git a/tests/ansible/integration/runner/_etc_environment_user.yml b/tests/ansible/integration/runner/_etc_environment_user.yml
index 9d9b831a3..939ee8dc6 100644
--- a/tests/ansible/integration/runner/_etc_environment_user.yml
+++ b/tests/ansible/integration/runner/_etc_environment_user.yml
@@ -1,6 +1,7 @@
 # ~/.pam_environment
 
-- file:
+- name: Remove pam_environment
+  file:
     path: ~/.pam_environment
     state: absent
 
@@ -11,7 +12,8 @@
     that: echo.stdout == ""
     fail_msg: echo={{echo}}
 
-- copy:
+- name: Copy pam_environment
+  copy:
     dest: ~/.pam_environment
     content: |
       MAGIC_PAM_ENV=321
@@ -23,7 +25,8 @@
     that: echo.stdout == "321"
     fail_msg: echo={{echo}}
 
-- file:
+- name: Cleanup pam_environment
+  file:
     path: ~/.pam_environment
     state: absent
 
diff --git a/tests/ansible/integration/runner/atexit.yml b/tests/ansible/integration/runner/atexit.yml
index 656735613..755098771 100644
--- a/tests/ansible/integration/runner/atexit.yml
+++ b/tests/ansible/integration/runner/atexit.yml
@@ -9,20 +9,16 @@
   vars:
     path: /tmp/atexit-should-delete-this
   tasks:
-
-    #
-    # Verify a run with a healthy atexit handler. Broken handlers cause an
-    # exception to be raised.
-    #
-
-    - custom_python_run_script:
+    - name: Verify a run with a healthy atexit handler
+      custom_python_run_script:
         script: |
           import atexit, os, shutil
           path = '{{path}}'
           os.mkdir(path, int('777', 8))
           atexit.register(shutil.rmtree, path)
 
-    - stat:
+    - name: Stat atexit file
+      stat:
         path: "{{path}}"
       register: out
 
diff --git a/tests/ansible/integration/runner/builtin_command_module.yml b/tests/ansible/integration/runner/builtin_command_module.yml
index b567bee6d..06e058cd2 100644
--- a/tests/ansible/integration/runner/builtin_command_module.yml
+++ b/tests/ansible/integration/runner/builtin_command_module.yml
@@ -1,10 +1,10 @@
 
 - name: integration/runner/builtin_command_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   gather_facts: true
   tasks:
-    - command: hostname
+    - name: Run hostname
+      command: hostname
       with_sequence: start=1 end={{end|default(1)}}
       register: out
 
diff --git a/tests/ansible/integration/runner/custom_bash_hashbang_argument.yml b/tests/ansible/integration/runner/custom_bash_hashbang_argument.yml
index d2d2281cd..ebb7966e0 100644
--- a/tests/ansible/integration/runner/custom_bash_hashbang_argument.yml
+++ b/tests/ansible/integration/runner/custom_bash_hashbang_argument.yml
@@ -1,7 +1,6 @@
 # https://github.com/dw/mitogen/issues/291
 - name: integration/runner/custom_bash_hashbang_argument.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - custom_bash_old_style_module:
diff --git a/tests/ansible/integration/runner/custom_bash_old_style_module.yml b/tests/ansible/integration/runner/custom_bash_old_style_module.yml
index 122b8fded..81ab5d07a 100644
--- a/tests/ansible/integration/runner/custom_bash_old_style_module.yml
+++ b/tests/ansible/integration/runner/custom_bash_old_style_module.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_bash_old_style_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - custom_bash_old_style_module:
diff --git a/tests/ansible/integration/runner/custom_bash_want_json_module.yml b/tests/ansible/integration/runner/custom_bash_want_json_module.yml
index b4242e9a3..24d9a064f 100644
--- a/tests/ansible/integration/runner/custom_bash_want_json_module.yml
+++ b/tests/ansible/integration/runner/custom_bash_want_json_module.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_bash_want_json_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - custom_bash_want_json_module:
         foo: true
diff --git a/tests/ansible/integration/runner/custom_binary_producing_json.yml b/tests/ansible/integration/runner/custom_binary_producing_json.yml
index 6dfeefc34..4c6e2468e 100644
--- a/tests/ansible/integration/runner/custom_binary_producing_json.yml
+++ b/tests/ansible/integration/runner/custom_binary_producing_json.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_binary_producing_json.yml
   hosts: test-targets
-  any_errors_fatal: true
   gather_facts: true
   tasks:
     - block:
diff --git a/tests/ansible/integration/runner/custom_binary_producing_junk.yml b/tests/ansible/integration/runner/custom_binary_producing_junk.yml
index 97fc92fe4..c2fb7ccdc 100644
--- a/tests/ansible/integration/runner/custom_binary_producing_junk.yml
+++ b/tests/ansible/integration/runner/custom_binary_producing_junk.yml
@@ -24,7 +24,6 @@
 
 
 - hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - assert:
         that:
diff --git a/tests/ansible/integration/runner/custom_binary_single_null.yml b/tests/ansible/integration/runner/custom_binary_single_null.yml
index 353cc58db..a666ad688 100644
--- a/tests/ansible/integration/runner/custom_binary_single_null.yml
+++ b/tests/ansible/integration/runner/custom_binary_single_null.yml
@@ -10,7 +10,6 @@
     - custom_binary_single_null
 
 - hosts: test-targets
-  any_errors_fatal: true
   tasks:
   - assert:
       that:
diff --git a/tests/ansible/integration/runner/custom_perl_json_args_module.yml b/tests/ansible/integration/runner/custom_perl_json_args_module.yml
index 682304202..76ead9859 100644
--- a/tests/ansible/integration/runner/custom_perl_json_args_module.yml
+++ b/tests/ansible/integration/runner/custom_perl_json_args_module.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_perl_json_args_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - custom_perl_json_args_module:
         foo: true
diff --git a/tests/ansible/integration/runner/custom_perl_want_json_module.yml b/tests/ansible/integration/runner/custom_perl_want_json_module.yml
index 44c39e646..2e5203299 100644
--- a/tests/ansible/integration/runner/custom_perl_want_json_module.yml
+++ b/tests/ansible/integration/runner/custom_perl_want_json_module.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_perl_want_json_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - custom_perl_want_json_module:
         foo: true
diff --git a/tests/ansible/integration/runner/custom_python_json_args_module.yml b/tests/ansible/integration/runner/custom_python_json_args_module.yml
index 33a03ba02..d72ff0fa5 100644
--- a/tests/ansible/integration/runner/custom_python_json_args_module.yml
+++ b/tests/ansible/integration/runner/custom_python_json_args_module.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_python_json_args_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - custom_python_json_args_module:
         foo: true
diff --git a/tests/ansible/integration/runner/custom_python_new_style_missing_interpreter.yml b/tests/ansible/integration/runner/custom_python_new_style_missing_interpreter.yml
index 1562e307d..0c620dac2 100644
--- a/tests/ansible/integration/runner/custom_python_new_style_missing_interpreter.yml
+++ b/tests/ansible/integration/runner/custom_python_new_style_missing_interpreter.yml
@@ -1,7 +1,6 @@
 
 - name: integration/runner/custom_python_new_style_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - custom_python_new_style_missing_interpreter:
         foo: true
diff --git a/tests/ansible/integration/runner/custom_python_new_style_module.yml b/tests/ansible/integration/runner/custom_python_new_style_module.yml
index b22fdc14b..e2384f814 100644
--- a/tests/ansible/integration/runner/custom_python_new_style_module.yml
+++ b/tests/ansible/integration/runner/custom_python_new_style_module.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_python_new_style_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     # without Mitogen Ansible 2.10 hangs on this play
     - meta: end_play
diff --git a/tests/ansible/integration/runner/custom_python_prehistoric_module.yml b/tests/ansible/integration/runner/custom_python_prehistoric_module.yml
index 4ddec8c57..f2a3eefd5 100644
--- a/tests/ansible/integration/runner/custom_python_prehistoric_module.yml
+++ b/tests/ansible/integration/runner/custom_python_prehistoric_module.yml
@@ -2,7 +2,6 @@
 
 - name: integration/runner/custom_python_prehistoric_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - custom_python_prehistoric_module:
       register: out
diff --git a/tests/ansible/integration/runner/custom_python_want_json_module.yml b/tests/ansible/integration/runner/custom_python_want_json_module.yml
index d704ee115..a9bbd5b27 100644
--- a/tests/ansible/integration/runner/custom_python_want_json_module.yml
+++ b/tests/ansible/integration/runner/custom_python_want_json_module.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_python_want_json_module.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
     - custom_python_want_json_module:
         foo: true
diff --git a/tests/ansible/integration/runner/custom_script_interpreter.yml b/tests/ansible/integration/runner/custom_script_interpreter.yml
index 03ea67189..9849912ef 100644
--- a/tests/ansible/integration/runner/custom_script_interpreter.yml
+++ b/tests/ansible/integration/runner/custom_script_interpreter.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/custom_script_interpreter.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
     - custom_bash_old_style_module:
diff --git a/tests/ansible/integration/runner/environment_isolation.yml b/tests/ansible/integration/runner/environment_isolation.yml
index 90110d018..b195a469f 100644
--- a/tests/ansible/integration/runner/environment_isolation.yml
+++ b/tests/ansible/integration/runner/environment_isolation.yml
@@ -1,52 +1,38 @@
 # issue #309: ensure process environment is restored after a module runs.
 
-
 - name: integration/runner/environment_isolation.yml
   hosts: test-targets
-  any_errors_fatal: true
   gather_facts: true
   tasks:
-
-    # ---
-    # Verify custom env setting is cleared out.
-    # ---
-
-    # Verify sane state first.
-    - custom_python_detect_environment:
+    - name: Verify custom env setting is cleared, control
+      custom_python_detect_environment:
       register: out
     - assert:
         that: not out.env.evil_key is defined
         fail_msg: out={{out}}
-
-    - shell: echo 'hi'
+    - name: Verify custom env setting is cleared, with evil_key
+      shell: echo 'hi'
       environment:
         evil_key: evil
-
-    # Verify environment was cleaned up.
-    - custom_python_detect_environment:
+    - name: Verify custom env setting is cleared, without evil_key
+      custom_python_detect_environment:
       register: out
     - assert:
         that: not out.env.evil_key is defined
         fail_msg: out={{out}}
 
-
-    # ---
-    # Verify non-explicit module env mutations are cleared out.
-    # ---
-
-    # Verify sane state first.
-    - custom_python_detect_environment:
+    - name: Verify non-explicit module env mutations are cleared, control
+      custom_python_detect_environment:
       register: out
     - assert:
         that: not out.env.evil_key is defined
         fail_msg: out={{out}}
-
-    - custom_python_modify_environ:
+    - name: Verify non-explicit module env mutations are cleared, mutate evil_key
+      custom_python_modify_environ:
         key: evil_key
         val: evil
-
-    # Verify environment was cleaned up.
-    - custom_python_detect_environment:
+    - name: Verify non-explicit module env mutations are cleared, without evil_key
+      custom_python_detect_environment:
       register: out
     - assert:
         that: not out.env.evil_key is defined
diff --git a/tests/ansible/integration/runner/etc_environment.yml b/tests/ansible/integration/runner/etc_environment.yml
index 3a44c753c..6ed3e9c26 100644
--- a/tests/ansible/integration/runner/etc_environment.yml
+++ b/tests/ansible/integration/runner/etc_environment.yml
@@ -4,7 +4,6 @@
 
 - name: integration/runner/etc_environment.yml
   hosts: test-targets[0]
-  any_errors_fatal: true
   gather_facts: true
   tasks:
     - include: _etc_environment_user.yml
diff --git a/tests/ansible/integration/runner/forking_active.yml b/tests/ansible/integration/runner/forking_active.yml
index 6a0e322d7..5198ee1c6 100644
--- a/tests/ansible/integration/runner/forking_active.yml
+++ b/tests/ansible/integration/runner/forking_active.yml
@@ -1,6 +1,5 @@
 - name: integration/runner/forking_active.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   # Verify mitogen_task_isolation=fork triggers forking.
diff --git a/tests/ansible/integration/runner/forking_correct_parent.yml b/tests/ansible/integration/runner/forking_correct_parent.yml
index 7fe54e7b9..8f360bb74 100644
--- a/tests/ansible/integration/runner/forking_correct_parent.yml
+++ b/tests/ansible/integration/runner/forking_correct_parent.yml
@@ -1,7 +1,6 @@
 
 - name: integration/runner/forking_correct_parent.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   # Verify mitogen_task_isolation=fork forks from "virginal fork parent", not
diff --git a/tests/ansible/integration/runner/forking_inactive.yml b/tests/ansible/integration/runner/forking_inactive.yml
index 37282f82a..913554898 100644
--- a/tests/ansible/integration/runner/forking_inactive.yml
+++ b/tests/ansible/integration/runner/forking_inactive.yml
@@ -2,7 +2,6 @@
 
 - name: integration/runner/forking_inactive.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - name: get process ID.
diff --git a/tests/ansible/integration/runner/missing_module.yml b/tests/ansible/integration/runner/missing_module.yml
index 676012d99..979cdf21b 100644
--- a/tests/ansible/integration/runner/missing_module.yml
+++ b/tests/ansible/integration/runner/missing_module.yml
@@ -3,10 +3,13 @@
   hosts: test-targets[0]
   connection: local
   tasks:
-    - connection: local
+    - name: Run missing_module
+      connection: local
       command: |
         ansible -vvv
-        -i "{{MITOGEN_INVENTORY_FILE}}"
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
         test-targets
         -m missing_module
       args:
diff --git a/tests/ansible/integration/ssh/timeouts.yml b/tests/ansible/integration/ssh/timeouts.yml
index d697905af..d4aba5d2a 100644
--- a/tests/ansible/integration/ssh/timeouts.yml
+++ b/tests/ansible/integration/ssh/timeouts.yml
@@ -3,10 +3,15 @@
 - name: integration/ssh/timeouts.yml
   hosts: test-targets
   tasks:
-    - connection: local
+    - name: Cause Ansible connection timeout
+      connection: local
+      environment:
+        ANSIBLE_SSH_TIMEOUT: 10
       command: |
         ansible -vvv
-        -i "{{MITOGEN_INVENTORY_FILE}}"
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
         test-targets
         -m custom_python_detect_environment
         -e ansible_user=mitogen__slow_user -e ansible_password=slow_user_password
@@ -16,7 +21,8 @@
       ignore_errors: true
       when: is_mitogen
 
-    - assert:
+    - name: Verify connection timeout occurred
+      assert:
         that:
         - |
           '"changed": false' in out.stdout
diff --git a/tests/ansible/integration/ssh/variables.yml b/tests/ansible/integration/ssh/variables.yml
index d05ac2887..8e912b2ec 100644
--- a/tests/ansible/integration/ssh/variables.yml
+++ b/tests/ansible/integration/ssh/variables.yml
@@ -13,12 +13,16 @@
       -o "ControlPath /tmp/mitogen-ansible-test-{{18446744073709551615|random}}"
 
   tasks:
-    - name: ansible_ssh_user
-      # Remaining tests just use "ansible_user".
+    - name: ansible_ssh_user, ansible_ssh_pass
       shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_ssh_user=mitogen__has_sudo
         -e ansible_ssh_pass=has_sudo_password
       args:
@@ -26,10 +30,16 @@
       register: out
       when: is_mitogen
 
-    - shell: >
+    - name: ansible_ssh_user, wrong ansible_ssh_pass
+      shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_ssh_user=mitogen__has_sudo
         -e ansible_ssh_pass=wrong_password
       args:
@@ -39,16 +49,22 @@
       when: is_mitogen
 
     - assert:
-        that: out.rc == 4  # unreachable
+        that:
+          - out.rc == 4  # ansible.executor.task_queue_manager.TaskQueueManager.RUN_UNREACHABLE_HOSTS
         fail_msg: out={{out}}
       when: is_mitogen
 
 
-    - name: ansible_ssh_pass
+    - name: ansible_user, ansible_ssh_pass
       shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_user=mitogen__has_sudo
         -e ansible_ssh_pass=has_sudo_password
       args:
@@ -56,10 +72,16 @@
       register: out
       when: is_mitogen
 
-    - shell: >
+    - name: ansible_user, wrong ansible_ssh_pass
+      shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_user=mitogen__has_sudo
         -e ansible_ssh_pass=wrong_password
       args:
@@ -69,16 +91,22 @@
       when: is_mitogen
 
     - assert:
-        that: out.rc == 4  # unreachable
+        that:
+          - out.rc == 4  # ansible.executor.task_queue_manager.TaskQueueManager.RUN_UNREACHABLE_HOSTS
         fail_msg: out={{out}}
       when: is_mitogen
 
 
-    - name: ansible_password
+    - name: ansible_user, ansible_password
       shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_user=mitogen__has_sudo
         -e ansible_password=has_sudo_password
       args:
@@ -86,10 +114,16 @@
       register: out
       when: is_mitogen
 
-    - shell: >
+    - name: ansible_user, wrong ansible_password
+      shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_user=mitogen__has_sudo
         -e ansible_password=wrong_password
       args:
@@ -99,21 +133,27 @@
       when: is_mitogen
 
     - assert:
-        that: out.rc == 4  # unreachable
+        that:
+          - out.rc == 4  # ansible.executor.task_queue_manager.TaskQueueManager.RUN_UNREACHABLE_HOSTS
         fail_msg: out={{out}}
       when: is_mitogen
 
 
-    - name: ansible_ssh_private_key_file
+    - name: setup ansible_ssh_private_key_file
       shell: chmod 0600 ../data/docker/mitogen__has_sudo_pubkey.key
       args:
         chdir: ../..
 
-    - name: ansible_ssh_private_key_file
+    - name: ansible_user, ansible_ssh_private_key_file
       shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_user=mitogen__has_sudo_pubkey
         -e ansible_ssh_private_key_file=../data/docker/mitogen__has_sudo_pubkey.key
       args:
@@ -121,10 +161,16 @@
       register: out
       when: is_mitogen
 
-    - shell: >
+    - name: ansible_user, wrong ansible_ssh_private_key_file
+      shell: >
+        ANSIBLE_ANY_ERRORS_FATAL=false
         ANSIBLE_STRATEGY=mitogen_linear
         ANSIBLE_SSH_ARGS=""
-        ansible -m shell -a whoami -i "{{MITOGEN_INVENTORY_FILE}}" test-targets
+        ansible -m shell -a whoami
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
+        test-targets
         -e ansible_user=mitogen__has_sudo
         -e ansible_ssh_private_key_file=/dev/null
       args:
@@ -134,6 +180,7 @@
       when: is_mitogen
 
     - assert:
-        that: out.rc == 4  # unreachable
+        that:
+          - out.rc == 4  # ansible.executor.task_queue_manager.TaskQueueManager.RUN_UNREACHABLE_HOSTS
         fail_msg: out={{out}}
       when: is_mitogen
diff --git a/tests/ansible/integration/strategy/_mixed_mitogen_vanilla.yml b/tests/ansible/integration/strategy/_mixed_mitogen_vanilla.yml
index b2ae7641d..e4df93789 100644
--- a/tests/ansible/integration/strategy/_mixed_mitogen_vanilla.yml
+++ b/tests/ansible/integration/strategy/_mixed_mitogen_vanilla.yml
@@ -3,7 +3,6 @@
 
 - name: integration/strategy/_mixed_mitogen_vanilla.yml (mitogen_linear)
   hosts: test-targets[0]
-  any_errors_fatal: true
   strategy: mitogen_linear
   run_once: true
   tasks:
@@ -22,7 +21,6 @@
 
 - name: integration/strategy/_mixed_mitogen_vanilla.yml (linear)
   hosts: test-targets[0]
-  any_errors_fatal: true
   strategy: linear
   tasks:
     - custom_python_detect_environment:
@@ -40,7 +38,6 @@
 
 - name: integration/strategy/_mixed_mitogen_vanilla.yml (mitogen_linear)
   hosts: test-targets[0]
-  any_errors_fatal: true
   strategy: mitogen_linear
   tasks:
     - custom_python_detect_environment:
diff --git a/tests/ansible/integration/strategy/_mixed_vanilla_mitogen.yml b/tests/ansible/integration/strategy/_mixed_vanilla_mitogen.yml
index e073d11ad..d2bdfdb80 100644
--- a/tests/ansible/integration/strategy/_mixed_vanilla_mitogen.yml
+++ b/tests/ansible/integration/strategy/_mixed_vanilla_mitogen.yml
@@ -3,7 +3,6 @@
 
 - name: integration/strategy/_mixed_vanilla_mitogen.yml (linear)
   hosts: test-targets[0]
-  any_errors_fatal: true
   strategy: linear
   tasks:
     - custom_python_detect_environment:
@@ -21,7 +20,6 @@
 
 - name: integration/strategy/_mixed_vanilla_mitogen.yml (mitogen_linear)
   hosts: test-targets[0]
-  any_errors_fatal: true
   strategy: mitogen_linear
   tasks:
     - custom_python_detect_environment:
@@ -39,7 +37,6 @@
 
 - name: integration/strategy/_mixed_vanilla_mitogen.yml (linear)
   hosts: test-targets[0]
-  any_errors_fatal: true
   strategy: linear
   tasks:
     - custom_python_detect_environment:
diff --git a/tests/ansible/integration/strategy/mixed_vanilla_mitogen.yml b/tests/ansible/integration/strategy/mixed_vanilla_mitogen.yml
index 0f462fea8..d183564fb 100644
--- a/tests/ansible/integration/strategy/mixed_vanilla_mitogen.yml
+++ b/tests/ansible/integration/strategy/mixed_vanilla_mitogen.yml
@@ -1,12 +1,13 @@
 
 - name: integration/strategy/mixed_vanilla_mitogen.yml (linear->mitogen->linear)
   hosts: test-targets[0]
-  any_errors_fatal: true
   tasks:
     - connection: local
       command: |
         ansible-playbook
-        -i "{{MITOGEN_INVENTORY_FILE}}"
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
         -vvv
         integration/strategy/_mixed_mitogen_vanilla.yml
       args:
@@ -16,7 +17,9 @@
     - connection: local
       command: |
         ansible-playbook
-        -i "{{MITOGEN_INVENTORY_FILE}}"
+        {% for inv in ansible_inventory_sources %}
+        -i "{{ inv }}"
+        {% endfor %}
         -vvv
         integration/strategy/_mixed_vanilla_mitogen.yml
       args:
diff --git a/tests/ansible/integration/stub_connections/kubectl.yml b/tests/ansible/integration/stub_connections/kubectl.yml
index 5303f7c7d..47777ba8e 100644
--- a/tests/ansible/integration/stub_connections/kubectl.yml
+++ b/tests/ansible/integration/stub_connections/kubectl.yml
@@ -2,7 +2,6 @@
 - name: integration/stub_connections/kubectl.yml
   hosts: test-targets
   gather_facts: false
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/integration/stub_connections/lxc.yml b/tests/ansible/integration/stub_connections/lxc.yml
index ec79cdac3..abe354b6e 100644
--- a/tests/ansible/integration/stub_connections/lxc.yml
+++ b/tests/ansible/integration/stub_connections/lxc.yml
@@ -2,7 +2,6 @@
 - name: integration/stub_connections/lxc.yml
   hosts: test-targets
   gather_facts: false
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/integration/stub_connections/lxd.yml b/tests/ansible/integration/stub_connections/lxd.yml
index cb03232f5..5314f82ce 100644
--- a/tests/ansible/integration/stub_connections/lxd.yml
+++ b/tests/ansible/integration/stub_connections/lxd.yml
@@ -2,7 +2,6 @@
 - name: integration/stub_connections/lxd.yml
   hosts: test-targets
   gather_facts: false
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/integration/stub_connections/mitogen_doas.yml b/tests/ansible/integration/stub_connections/mitogen_doas.yml
index 5e5459352..87764d125 100644
--- a/tests/ansible/integration/stub_connections/mitogen_doas.yml
+++ b/tests/ansible/integration/stub_connections/mitogen_doas.yml
@@ -2,7 +2,6 @@
 - name: integration/stub_connections/mitogen_doas.yml
   hosts: test-targets
   gather_facts: false
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/integration/stub_connections/mitogen_sudo.yml b/tests/ansible/integration/stub_connections/mitogen_sudo.yml
index 36f69814f..6da0e5d69 100644
--- a/tests/ansible/integration/stub_connections/mitogen_sudo.yml
+++ b/tests/ansible/integration/stub_connections/mitogen_sudo.yml
@@ -2,7 +2,6 @@
 - name: integration/stub_connections/mitogen_sudo.yml
   hosts: test-targets
   gather_facts: false
-  any_errors_fatal: true
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/integration/stub_connections/setns_lxc.yml b/tests/ansible/integration/stub_connections/setns_lxc.yml
index 447175047..75d4207b2 100644
--- a/tests/ansible/integration/stub_connections/setns_lxc.yml
+++ b/tests/ansible/integration/stub_connections/setns_lxc.yml
@@ -13,7 +13,8 @@
 
     - include: _end_play_if_not_sudo_linux.yml
 
-    - command: |
+    - name: Run stub-lxc-info.py
+      command: |
         sudo -nE "{{lookup('env', 'VIRTUAL_ENV')}}/bin/ansible"
         -i localhost,
         -c setns
diff --git a/tests/ansible/integration/stub_connections/setns_lxd.yml b/tests/ansible/integration/stub_connections/setns_lxd.yml
index 841f78055..3f3bc291a 100644
--- a/tests/ansible/integration/stub_connections/setns_lxd.yml
+++ b/tests/ansible/integration/stub_connections/setns_lxd.yml
@@ -13,7 +13,8 @@
 
     - include: _end_play_if_not_sudo_linux.yml
 
-    - command: |
+    - name: Run ansible stub-lxc.py
+      command: |
         sudo -nE "{{lookup('env', 'VIRTUAL_ENV')}}/bin/ansible"
         -i localhost,
         -c setns
diff --git a/tests/ansible/integration/transport_config/become_method.yml b/tests/ansible/integration/transport_config/become_method.yml
index a6eac9efa..e4efbabd9 100644
--- a/tests/ansible/integration/transport_config/become_method.yml
+++ b/tests/ansible/integration/transport_config/become_method.yml
@@ -2,7 +2,7 @@
 
 
 # No become-method set.
-- name: integration/transport_config/become-method.yml
+- name: integration/transport_config/become_method.yml
   hosts: tc-become-method-unset
   become: true
   tasks:
diff --git a/tests/ansible/integration/transport_config/become_pass.yml b/tests/ansible/integration/transport_config/become_pass.yml
index 6287e708e..317e0522b 100644
--- a/tests/ansible/integration/transport_config/become_pass.yml
+++ b/tests/ansible/integration/transport_config/become_pass.yml
@@ -2,7 +2,7 @@
 
 
 # No become-pass set, defaults to "root"
-- name: integration/transport_config/become-pass.yml
+- name: integration/transport_config/become_pass.yml
   hosts: tc-become-pass-unset
   become: true
   tasks:
@@ -119,9 +119,6 @@
       fail_msg: out={{out}}
 
 
-
-# ansible_become_pass & ansible_become_password set, password used to take precedence
-# but it's possible since https://github.com/ansible/ansible/pull/69629/files#r428376864, now it doesn't
 - hosts: tc-become-pass-both
   become: true
   tasks:
@@ -132,7 +129,9 @@
       - out.result|length == 2
       - out.result[0].method == "ssh"
       - out.result[1].method == "sudo"
-      - out.result[1].kwargs.password == "c.b.a"
+      # Ansible >= 2.10 builtin become plugins (e.g. sudo, su) give priority
+      # to ansible_become_pass over ansible_become_password.
+      - out.result[1].kwargs.password == "bpass"
       fail_msg: out={{out}}
 
 
@@ -147,6 +146,6 @@
       - out.result|length == 3
       - out.result[0].method == "ssh"
       - out.result[1].method == "sudo"
-      - out.result[1].kwargs.password == "a.b.c"
+      - out.result[1].kwargs.password == "bpass"
       - out.result[2].method == "ssh"
       fail_msg: out={{out}}
diff --git a/tests/ansible/integration/transport_config/become_user.yml b/tests/ansible/integration/transport_config/become_user.yml
index 3c8397ad7..1a8b9fc94 100644
--- a/tests/ansible/integration/transport_config/become_user.yml
+++ b/tests/ansible/integration/transport_config/become_user.yml
@@ -2,7 +2,7 @@
 
 
 # No become-user set, defaults to "root"
-- name: integration/transport_config/become-user.yml
+- name: integration/transport_config/become_user.yml
   hosts: tc-become-user-unset
   become: true
   tasks:
diff --git a/tests/ansible/integration/transport_config/port.yml b/tests/ansible/integration/transport_config/port.yml
index 6014ffaee..340eae799 100644
--- a/tests/ansible/integration/transport_config/port.yml
+++ b/tests/ansible/integration/transport_config/port.yml
@@ -41,7 +41,7 @@
       - out.result[0].kwargs.port == 4321
       fail_msg: out={{out}}
 
-- hosts: tc-port-explicit-unset
+- hosts: tc-port-unset
   vars: {mitogen_via: tc-port-explicit-ssh}
   tasks:
   - include: ../_mitogen_only.yml
@@ -50,9 +50,9 @@
       that:
       - out.result|length == 2
       - out.result[0].method == "ssh"
-      - out.result[1].kwargs.port == 4321
+      - out.result[0].kwargs.port == 4321
       - out.result[1].method == "ssh"
-      - out.result[0].kwargs.port == None
+      - out.result[1].kwargs.port == None
       fail_msg: out={{out}}
 
 # ansible_port=
diff --git a/tests/ansible/integration/transport_config/remote_user.yml b/tests/ansible/integration/transport_config/remote_user.yml
index b873fcbef..5ddc5aa37 100644
--- a/tests/ansible/integration/transport_config/remote_user.yml
+++ b/tests/ansible/integration/transport_config/remote_user.yml
@@ -60,7 +60,7 @@
       left: out.result[0].kwargs.username
       right: "ansi-user"
 
-- hosts: tc-remote-user-explicit-host
+- hosts: tc-remote-user-explicit-user
   vars: {mitogen_via: tc-remote-user-unset}
   tasks:
   - include: ../_mitogen_only.yml
diff --git a/tests/ansible/integration/transport_config/transport__smart.yml b/tests/ansible/integration/transport_config/transport__smart.yml
index a4c1959fc..f263d67e4 100644
--- a/tests/ansible/integration/transport_config/transport__smart.yml
+++ b/tests/ansible/integration/transport_config/transport__smart.yml
@@ -4,7 +4,7 @@
 
 # When no ansible_connection= is set, transport comes via ansible.cfg ("smart"
 # is parsed away to either paramiko or ssh).
-- name: integration/transport_config/transport.yml
+- name: integration/transport_config/transport__smart.yml
   hosts: tc-transport-smart
   tasks:
   - include: ../_mitogen_only.yml
diff --git a/tests/ansible/lib/callback/profile_tasks.py b/tests/ansible/lib/callback/profile_tasks.py
deleted file mode 100644
index 89d956ac6..000000000
--- a/tests/ansible/lib/callback/profile_tasks.py
+++ /dev/null
@@ -1,82 +0,0 @@
-# profile_tasks.py: an Ansible plugin for timing tasks
-
-# Copyright (C) 2014 Jharrod LaFon <jharrod.lafon@gmail.com>
-# https://github.com/jlafon/ansible-profile/
-# Included with permission
-
-
-# The MIT License (MIT)
-#
-# Copyright (c) 2014 Jharrod LaFon
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-
-from ansible.plugins.callback import CallbackBase
-import time
-
-
-class CallbackModule(CallbackBase):
-    """
-    A plugin for timing tasks
-    """
-    def __init__(self):
-        super(CallbackModule, self).__init__()
-        self.stats = {}
-        self.current = None
-
-    def playbook_on_task_start(self, name, is_conditional):
-        """
-        Logs the start of each task
-        """
-        if self.current is not None:
-            # Record the running time of the last executed task
-            self.stats[self.current] = time.time() - self.stats[self.current]
-
-        # Record the start time of the current task
-        self.current = name
-        self.stats[self.current] = time.time()
-
-    called = False
-
-    def playbook_on_stats(self, stats):
-        """
-        Prints the timings
-        """
-        if CallbackModule.called:
-            return
-        CallbackModule.called = True
-
-        # Record the timing of the very last task
-        if self.current is not None:
-            self.stats[self.current] = time.time() - self.stats[self.current]
-
-        # Sort the tasks by their running time
-        results = sorted(self.stats.items(),
-                         key=lambda value: value[1], reverse=True)
-
-        # Just keep the top 10
-        results = results[:10]
-
-        # Print the timings
-        for name, elapsed in results:
-            print("{0:-<70}{1:->9}".format(
-                    '{0} '.format(name),
-                    ' {0:.02f}s'.format(elapsed)))
-
diff --git a/tests/ansible/lib/modules/custom_python_detect_environment.py b/tests/ansible/lib/modules/custom_python_detect_environment.py
index 65f660a88..c7a222e7d 100644
--- a/tests/ansible/lib/modules/custom_python_detect_environment.py
+++ b/tests/ansible/lib/modules/custom_python_detect_environment.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I am an Ansible new-style Python module. I return details about the Python
 # interpreter I run within.
 
diff --git a/tests/ansible/lib/modules/custom_python_external_module.py b/tests/ansible/lib/modules/custom_python_external_module.py
index 507e53dd6..ae1b78cb6 100644
--- a/tests/ansible/lib/modules/custom_python_external_module.py
+++ b/tests/ansible/lib/modules/custom_python_external_module.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I expect the quote from modules2/module_utils/joker.py.
 
 from ansible.module_utils.basic import AnsibleModule
diff --git a/tests/ansible/lib/modules/custom_python_external_pkg.py b/tests/ansible/lib/modules/custom_python_external_pkg.py
index 95bd0c7bd..be9acb24e 100644
--- a/tests/ansible/lib/modules/custom_python_external_pkg.py
+++ b/tests/ansible/lib/modules/custom_python_external_pkg.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 
 from ansible.module_utils.basic import AnsibleModule
 from ansible.module_utils.externalpkg import extmod
diff --git a/tests/ansible/lib/modules/custom_python_json_args_module.py b/tests/ansible/lib/modules/custom_python_json_args_module.py
index 846037ecd..616405790 100755
--- a/tests/ansible/lib/modules/custom_python_json_args_module.py
+++ b/tests/ansible/lib/modules/custom_python_json_args_module.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I am an Ansible Python JSONARGS module. I should receive an encoding string.
 
 json_arguments = """<<INCLUDE_ANSIBLE_MODULE_JSON_ARGS>>"""
diff --git a/tests/ansible/lib/modules/custom_python_leaky_class_vars.py b/tests/ansible/lib/modules/custom_python_leaky_class_vars.py
index 1d3423293..255e37293 100644
--- a/tests/ansible/lib/modules/custom_python_leaky_class_vars.py
+++ b/tests/ansible/lib/modules/custom_python_leaky_class_vars.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I am an Ansible new-style Python module. I leak state from each invocation
 # into a class variable and a global variable.
 
diff --git a/tests/ansible/lib/modules/custom_python_modify_environ.py b/tests/ansible/lib/modules/custom_python_modify_environ.py
index 9767f8553..51b745269 100644
--- a/tests/ansible/lib/modules/custom_python_modify_environ.py
+++ b/tests/ansible/lib/modules/custom_python_modify_environ.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I am an Ansible new-style Python module. I modify the process environment and
 # don't clean up after myself.
 
diff --git a/tests/ansible/lib/modules/custom_python_new_style_module.py b/tests/ansible/lib/modules/custom_python_new_style_module.py
index f9c176c17..1e7270cd6 100755
--- a/tests/ansible/lib/modules/custom_python_new_style_module.py
+++ b/tests/ansible/lib/modules/custom_python_new_style_module.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I am an Ansible new-style Python module. I should receive an encoding string.
 
 import sys
diff --git a/tests/ansible/lib/modules/custom_python_os_getcwd.py b/tests/ansible/lib/modules/custom_python_os_getcwd.py
index 7fe3fd1b9..c5e264ae4 100644
--- a/tests/ansible/lib/modules/custom_python_os_getcwd.py
+++ b/tests/ansible/lib/modules/custom_python_os_getcwd.py
@@ -1,13 +1,10 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # #591: call os.getcwd() before AnsibleModule ever gets a chance to fix up the
 # process environment.
 
+import json
 import os
 
-try:
-    import json
-except ImportError:
-    import simplejson as json
 
 print(json.dumps({
     'cwd': os.getcwd()
diff --git a/tests/ansible/lib/modules/custom_python_prehistoric_module.py b/tests/ansible/lib/modules/custom_python_prehistoric_module.py
index 7ce15208b..0cf9774d7 100644
--- a/tests/ansible/lib/modules/custom_python_prehistoric_module.py
+++ b/tests/ansible/lib/modules/custom_python_prehistoric_module.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # issue #555: I'm a module that cutpastes an old hack.
 
 from ansible.module_utils.basic import AnsibleModule
diff --git a/tests/ansible/lib/modules/custom_python_run_script.py b/tests/ansible/lib/modules/custom_python_run_script.py
index 4a6243d0a..d6a839ae7 100644
--- a/tests/ansible/lib/modules/custom_python_run_script.py
+++ b/tests/ansible/lib/modules/custom_python_run_script.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I am an Ansible new-style Python module. I run the script provided in the
 # parameter.
 
diff --git a/tests/ansible/lib/modules/custom_python_uses_distro.py b/tests/ansible/lib/modules/custom_python_uses_distro.py
index 6b3a356b1..03f3b6aae 100644
--- a/tests/ansible/lib/modules/custom_python_uses_distro.py
+++ b/tests/ansible/lib/modules/custom_python_uses_distro.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # issue #590: I am an Ansible new-style Python module that tries to use
 # ansible.module_utils.distro.
 
diff --git a/tests/ansible/lib/modules/custom_python_want_json_module.py b/tests/ansible/lib/modules/custom_python_want_json_module.py
index d9ea7113e..f5e338621 100755
--- a/tests/ansible/lib/modules/custom_python_want_json_module.py
+++ b/tests/ansible/lib/modules/custom_python_want_json_module.py
@@ -1,13 +1,9 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # I am an Ansible Python WANT_JSON module. I should receive a JSON-encoded file.
 
+import json
 import sys
 
-try:
-    import json
-except ImportError:
-    import simplejson as json
-
 
 WANT_JSON = 1
 
diff --git a/tests/ansible/lib/modules/mitogen_test_gethostbyname.py b/tests/ansible/lib/modules/mitogen_test_gethostbyname.py
index 289e9662f..1b80a48b8 100644
--- a/tests/ansible/lib/modules/mitogen_test_gethostbyname.py
+++ b/tests/ansible/lib/modules/mitogen_test_gethostbyname.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 
 # I am a module that indirectly depends on glibc cached /etc/resolv.conf state.
 
diff --git a/tests/ansible/lib/modules/test_echo_module.py b/tests/ansible/lib/modules/test_echo_module.py
index 37ab655c4..1f71e8795 100644
--- a/tests/ansible/lib/modules/test_echo_module.py
+++ b/tests/ansible/lib/modules/test_echo_module.py
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/env python
 # -*- coding: utf-8 -*-
 
 # (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
diff --git a/tests/ansible/regression/all.yml b/tests/ansible/regression/all.yml
index 875d520e3..0e5994763 100644
--- a/tests/ansible/regression/all.yml
+++ b/tests/ansible/regression/all.yml
@@ -1,30 +1,16 @@
 - import_playbook: issue_109__target_has_old_ansible_installed.yml
-  tags: regression
 - import_playbook: issue_113__duplicate_module_imports.yml
-  tags: regression
 - import_playbook: issue_118__script_not_marked_exec.yml
-  tags: regression
 - import_playbook: issue_122__environment_difference.yml
-  tags: regression
 - import_playbook: issue_140__thread_pileup.yml
-  tags: regression
 - import_playbook: issue_152__local_action_wrong_interpreter.yml
-  tags: regression
 - import_playbook: issue_152__virtualenv_python_fails.yml
-  tags: regression
 - import_playbook: issue_154__module_state_leaks.yml
-  tags: regression
 - import_playbook: issue_177__copy_module_failing.yml
-  tags: regression
 - import_playbook: issue_332_ansiblemoduleerror_first_occurrence.yml
-  tags: regression
 - import_playbook: issue_558_unarchive_failed.yml
-  tags: regression
 - import_playbook: issue_590__sys_modules_crap.yml
-  tags: regression
 - import_playbook: issue_591__setuptools_cwd_crash.yml
-  tags: regression
 - import_playbook: issue_615__streaming_transfer.yml
-  tags: regression
 - import_playbook: issue_655__wait_for_connection_error.yml
-  tags: regression
+- import_playbook: issue_776__load_plugins_called_twice.yml
diff --git a/tests/ansible/regression/files/debian-archive-bookworm-automatic.gpg b/tests/ansible/regression/files/debian-archive-bookworm-automatic.gpg
new file mode 100644
index 000000000..ae9cfa19d
Binary files /dev/null and b/tests/ansible/regression/files/debian-archive-bookworm-automatic.gpg differ
diff --git a/tests/ansible/regression/files/debian-archive-bullseye-automatic.gpg b/tests/ansible/regression/files/debian-archive-bullseye-automatic.gpg
new file mode 100644
index 000000000..66f1a94b5
Binary files /dev/null and b/tests/ansible/regression/files/debian-archive-bullseye-automatic.gpg differ
diff --git a/tests/ansible/regression/issue_109__target_has_old_ansible_installed.yml b/tests/ansible/regression/issue_109__target_has_old_ansible_installed.yml
index 1188b6ce8..e6677e2da 100644
--- a/tests/ansible/regression/issue_109__target_has_old_ansible_installed.yml
+++ b/tests/ansible/regression/issue_109__target_has_old_ansible_installed.yml
@@ -3,15 +3,14 @@
 
 - name: regression/issue_109__target_has_old_ansible_installed.yml
   hosts: test-targets
-  any_errors_fatal: true
   gather_facts: true
   tasks:
     - meta: end_play
       when:
         - ansible_version.full is version('2.6', '<', strict=True)
 
-    # Copy the naughty 'ansible' into place.
-    - copy:
+    - name: Copy the naughty ansible into place
+      copy:
         dest: "{{ansible_user_dir}}/ansible.py"
         src: ansible.py
 
@@ -21,15 +20,15 @@
     - custom_python_detect_environment:
       register: env
 
-    # Verify interpreter config would actually trigger the bug.
-    - assert:
+    - name: Verify interpreter config would actually trigger the bug
+      assert:
         that:
           - env.cwd == ansible_user_dir
           - (not env.mitogen_loaded) or (env.python_path.count("") == 1)
         fail_msg: env={{env}}
 
-    # Run some new-style modules that 'from ansible.module_utils...'
-    - stat:
+    - name: Run some new-style from ansible.module_utils... modules
+      stat:
         path: /
   tags:
     - issue_109
diff --git a/tests/ansible/regression/issue_113__duplicate_module_imports.yml b/tests/ansible/regression/issue_113__duplicate_module_imports.yml
index b479e025e..4975a07c8 100644
--- a/tests/ansible/regression/issue_113__duplicate_module_imports.yml
+++ b/tests/ansible/regression/issue_113__duplicate_module_imports.yml
@@ -2,7 +2,6 @@
 # by exercisizng the uri package.
 
 - name: regression/issue_113__duplicate_module_imports.yml
-  any_errors_fatal: true
   hosts: test-targets
   tasks:
 
diff --git a/tests/ansible/regression/issue_140__thread_pileup.yml b/tests/ansible/regression/issue_140__thread_pileup.yml
index e6ea83ce7..1f614634f 100644
--- a/tests/ansible/regression/issue_140__thread_pileup.yml
+++ b/tests/ansible/regression/issue_140__thread_pileup.yml
@@ -4,7 +4,6 @@
 
 - name: regression/issue_140__thread_pileup.yml
   hosts: test-targets
-  any_errors_fatal: true
   tasks:
 
   - name: Create file tree
@@ -18,7 +17,8 @@
   - name: Delete remote file tree
     file: path=/tmp/filetree.out state=absent
 
-  - file:
+  - name: Recreate file tree
+    file:
       state: directory
       path: /tmp/filetree.out
 
diff --git a/tests/ansible/regression/issue_152__local_action_wrong_interpreter.yml b/tests/ansible/regression/issue_152__local_action_wrong_interpreter.yml
index 4da6f5449..fe7f5f42c 100644
--- a/tests/ansible/regression/issue_152__local_action_wrong_interpreter.yml
+++ b/tests/ansible/regression/issue_152__local_action_wrong_interpreter.yml
@@ -6,10 +6,10 @@
 - name: regression/issue_152__local_action_wrong_interpreter.yml
   hosts: test-targets
   connection: local
-  any_errors_fatal: true
   tasks:
 
-    - copy:
+    - name: Create /tmp/issue_152_interpreter.sh
+      copy:
         dest: /tmp/issue_152_interpreter.sh
         mode: u+x
         content: |
@@ -27,7 +27,8 @@
           - out.env.CUSTOM_INTERPRETER == "1"
         fail_msg: out={{out}}
 
-    - file:
+    - name: Cleanup /tmp/issue_152_interpreter.sh
+      file:
         path: /tmp/issue_152_interpreter.sh
         state: absent
   tags:
diff --git a/tests/ansible/regression/issue_152__virtualenv_python_fails.yml b/tests/ansible/regression/issue_152__virtualenv_python_fails.yml
index 11f94edd1..f4c47aba2 100644
--- a/tests/ansible/regression/issue_152__virtualenv_python_fails.yml
+++ b/tests/ansible/regression/issue_152__virtualenv_python_fails.yml
@@ -1,5 +1,4 @@
 - name: regression/issue_152__virtualenv_python_fails.yml
-  any_errors_fatal: true
   gather_facts: true
   hosts: test-targets
   tasks:
@@ -8,11 +7,12 @@
 
     # Can't use pip module because it can't create virtualenvs, must call it
     # directly.
-    - shell: virtualenv /tmp/issue_152_virtualenv
+    - name: Create /tmp/issue_152_virtualenv
       environment:
         https_proxy: "{{ lookup('env', 'https_proxy')|default('') }}"
         no_proxy: "{{ lookup('env', 'no_proxy')|default('') }}"
         PATH: "{{ lookup('env', 'PATH') }}"
+      shell: virtualenv /tmp/issue_152_virtualenv
       when:
         - lout.python.version.full is version('2.7', '>=', strict=True)
 
@@ -30,7 +30,8 @@
       when:
         - lout.python.version.full is version('2.7', '>=', strict=True)
 
-    - file:
+    - name: Cleanup /tmp/issue_152_virtualenv
+      file:
         path: /tmp/issue_152_virtualenv
         state: absent
       when:
diff --git a/tests/ansible/regression/issue_154__module_state_leaks.yml b/tests/ansible/regression/issue_154__module_state_leaks.yml
index 59b970d97..3a7ee309f 100644
--- a/tests/ansible/regression/issue_154__module_state_leaks.yml
+++ b/tests/ansible/regression/issue_154__module_state_leaks.yml
@@ -2,7 +2,6 @@
 # must be reinitialized or cleared out somehow on each invocation.
 
 - name: regression/issue_154__module_state_leaks.yml
-  any_errors_fatal: true
   hosts: test-targets
   tasks:
 
diff --git a/tests/ansible/regression/issue_177__copy_module_failing.yml b/tests/ansible/regression/issue_177__copy_module_failing.yml
index 7da39962b..c8dfc6355 100644
--- a/tests/ansible/regression/issue_177__copy_module_failing.yml
+++ b/tests/ansible/regression/issue_177__copy_module_failing.yml
@@ -1,9 +1,9 @@
 - name: regression/issue_177__copy_module_failing.yml
-  any_errors_fatal: true
   hosts: test-targets
   tasks:
 
-  - copy:
+  - name: Copy files
+    copy:
       src: /etc/{{item}}
       dest: /tmp/{{item}}
       mode: 0644
@@ -11,7 +11,8 @@
       - passwd
       - hosts
 
-  - file:
+  - name: Cleanup files
+    file:
       path: /tmp/{{item}}
       state: absent
     with_items:
diff --git a/tests/ansible/regression/issue_332_ansiblemoduleerror_first_occurrence.yml b/tests/ansible/regression/issue_332_ansiblemoduleerror_first_occurrence.yml
index 2433e566a..be069b1eb 100644
--- a/tests/ansible/regression/issue_332_ansiblemoduleerror_first_occurrence.yml
+++ b/tests/ansible/regression/issue_332_ansiblemoduleerror_first_occurrence.yml
@@ -4,7 +4,12 @@
 - name: regression/issue_332_ansiblemoduleerror_first_occurrence.yml
   hosts: test-targets
   tasks:
-  - file: path=/usr/bin/does-not-exist mode='a-s' state=file follow=yes
+  - name: Attempt to modify /usr/bin/does-not-exist
+    file:
+      path: /usr/bin/does-not-exist
+      mode: a-s
+      state: file
+      follow: true
     ignore_errors: true
     register: out
 
diff --git a/tests/ansible/regression/issue_558_unarchive_failed.yml b/tests/ansible/regression/issue_558_unarchive_failed.yml
index 23e9caad0..829101cb2 100644
--- a/tests/ansible/regression/issue_558_unarchive_failed.yml
+++ b/tests/ansible/regression/issue_558_unarchive_failed.yml
@@ -4,9 +4,16 @@
 - name: regression/issue_558_unarchive_failed.yml
   hosts: test-targets
   tasks:
-  - file: state=absent path=/tmp/foo
-  - file: state=directory path=/tmp/foo
-  - unarchive:
+  - name: Cleanup /tmp/foo
+    file:
+      path: /tmp/foo
+      state: absent
+  - name: Create /tmp/foo
+    file:
+      path: /tmp/foo
+      state: directory
+  - name: Unarchive unarchive_test.tar
+    unarchive:
       src: "{{git_basedir}}/tests/data/unarchive_test.tar"
       dest: /tmp/foo
     # garbage doesn't work with BSD tar
diff --git a/tests/ansible/regression/issue_590__sys_modules_crap.yml b/tests/ansible/regression/issue_590__sys_modules_crap.yml
index 81d9b4b2c..476701e3e 100644
--- a/tests/ansible/regression/issue_590__sys_modules_crap.yml
+++ b/tests/ansible/regression/issue_590__sys_modules_crap.yml
@@ -1,5 +1,5 @@
-
-- hosts: test-targets
+- name: regression/issue_590__sys_modules_crap.yml
+  hosts: test-targets
   tasks:
   - meta: end_play
     when:
diff --git a/tests/ansible/regression/issue_591__setuptools_cwd_crash.yml b/tests/ansible/regression/issue_591__setuptools_cwd_crash.yml
index 02676cb5d..80680063c 100644
--- a/tests/ansible/regression/issue_591__setuptools_cwd_crash.yml
+++ b/tests/ansible/regression/issue_591__setuptools_cwd_crash.yml
@@ -3,7 +3,8 @@
 # to call getcwd() before AnsibleModule has had a chance to clean up the
 # process environment.
 
-- hosts: test-targets
+- name: regression/issue_591__setuptools_cwd_crash.yml
+  hosts: test-targets
   tasks:
   - meta: end_play
     when: not is_mitogen
diff --git a/tests/ansible/regression/issue_615__streaming_transfer.yml b/tests/ansible/regression/issue_615__streaming_transfer.yml
index 3e174bed5..8ec28499c 100644
--- a/tests/ansible/regression/issue_615__streaming_transfer.yml
+++ b/tests/ansible/regression/issue_615__streaming_transfer.yml
@@ -1,7 +1,7 @@
 # issue #615: 'fetch' with become: was internally using slurp.
 
-- hosts: test-targets
-  any_errors_fatal: True
+- name: regression/issue_615_streaming_transfer.yml
+  hosts: test-targets
   gather_facts: no
   # Without Mitogen this causes Ansible to use the slurp module, which is *slow*
   become: true
@@ -9,21 +9,26 @@
     mitogen_ssh_compression: false
   tasks:
     - block:
-        - shell: |
+        - name: Create /tmp/512mb.zero
+          shell: |
             dd if=/dev/zero of=/tmp/512mb.zero bs=1048576 count=512;
             chmod go= /tmp/512mb.zero
 
-        - fetch:
+        - name: Fetch /tmp/512mb.zero
+          fetch:
             src: /tmp/512mb.zero
             dest: /tmp/fetch-out
 
-        - file:
+        - name: Cleanup /tmp/512mb.zero
+          file:
             path: /tmp/512mb.zero
             state: absent
 
-        - file:
+        - name: Cleanup fetched file
+          file:
             path: /tmp/fetch-out
             state: absent
+          become: false
           delegate_to: localhost
           run_once: true
       when:
diff --git a/tests/ansible/regression/issue_655__wait_for_connection_error.yml b/tests/ansible/regression/issue_655__wait_for_connection_error.yml
index cf0ff359d..fbdb9f2b1 100644
--- a/tests/ansible/regression/issue_655__wait_for_connection_error.yml
+++ b/tests/ansible/regression/issue_655__wait_for_connection_error.yml
@@ -4,9 +4,8 @@
 #   since things are ran on localhost; Azure DevOps loses connection and fails
 # TODO: do we want to install docker a different way to be able to do this for other tests too
 ---
-# this should only run on our Mac hosts
-- hosts: target
-  any_errors_fatal: True
+- name: regression/issue_655_wait_for_connection_error.yml
+  hosts: localhost
   gather_facts: yes
   become: no
   tasks:
@@ -84,5 +83,7 @@
           loop:
             - cmd: podman stop testMitogen
             - cmd: podman machine stop
+      when:
+        - ansible_facts.pkg_mgr in ['homebrew']
   tags:
     - issue_655
diff --git a/tests/ansible/regression/issue_776__load_plugins_called_twice.yml b/tests/ansible/regression/issue_776__load_plugins_called_twice.yml
new file mode 100755
index 000000000..ad5bbd693
--- /dev/null
+++ b/tests/ansible/regression/issue_776__load_plugins_called_twice.yml
@@ -0,0 +1,70 @@
+# https://github.com/mitogen-hq/mitogen/issues/776
+---
+- name: regression/issue_776__load_plugins_called_twice.yml
+  hosts: test-targets
+  become: "{{ groups.linux is defined and inventory_hostname in groups.linux }}"
+  gather_facts: yes
+  tags:
+    - issue_776
+  vars:
+    ansible_python_interpreter: "{{ pkg_mgr_python_interpreter }}"
+    package: rsync  # Chosen to exist in all tested distros/package managers
+  tasks:
+    - name: Switch to centos-stream
+      command: dnf --assumeyes --disablerepo="*" --enablerepo=extras swap centos-linux-repos centos-stream-repos
+      when:
+        - ansible_facts.pkg_mgr in ["dnf"]
+
+    - name: Switch to archive.debian.org
+      # Debian 9 has been archived https://lists.debian.org/debian-devel-announce/2023/03/msg00006.html
+      copy:
+        content: |
+          deb http://archive.debian.org/debian stretch main contrib non-free
+        dest: /etc/apt/sources.list
+        mode: u=rw,go=r
+      when:
+        - ansible_facts.distribution == "Debian"
+        - ansible_facts.distribution_major_version == "9"
+
+    - name: Add signing keys
+      copy:
+        src: "{{ item.src }}"
+        dest: "/etc/apt/trusted.gpg.d/{{ item.src | basename }}"
+        mode: u=rw,go=r
+      loop:
+        - src: debian-archive-bullseye-automatic.gpg  # Debian 11
+        - src: debian-archive-bookworm-automatic.gpg  # Debian 12
+      when:
+        # Ideally this would check for Debian 11, but distribution_major_version
+        # is unpopulated sometimes.
+        - ansible_facts.distribution == "Debian"
+
+    - name: Update package index
+      apt:
+        update_cache: true
+      when:
+        - ansible_facts.pkg_mgr in ["apt"]
+
+    - name: Test package module 1st call
+      package:
+        name: "{{ package }}"
+        state: present
+
+    - name: Test package module 2nd call
+      package:
+        name: "{{ package }}"
+        state: present
+
+    - name: Test dnf module 2nd call
+      dnf:
+        name: "{{ package }}"
+        state: present
+      when:
+        - ansible_facts.pkg_mgr == 'dnf'
+
+    - name: Test dnf module 2nd call
+      dnf:
+        name: "{{ package }}"
+        state: present
+      when:
+        - ansible_facts.pkg_mgr == 'dnf'
diff --git a/tests/ansible/run_ansible_playbook.py b/tests/ansible/run_ansible_playbook.py
index b2b619d28..04c0c9db1 100755
--- a/tests/ansible/run_ansible_playbook.py
+++ b/tests/ansible/run_ansible_playbook.py
@@ -40,15 +40,6 @@
     'git_basedir': GIT_BASEDIR,
 }
 
-if '-i' in sys.argv:
-    extra['MITOGEN_INVENTORY_FILE'] = (
-        os.path.abspath(sys.argv[1 + sys.argv.index('-i')])
-    )
-else:
-    extra['MITOGEN_INVENTORY_FILE'] = (
-        os.path.join(GIT_BASEDIR, 'tests/ansible/hosts')
-    )
-
 if 'ANSIBLE_ARGV' in os.environ:
     args = eval(os.environ['ANSIBLE_ARGV'])
 else:
diff --git a/tests/ansible/setup/report.yml b/tests/ansible/setup/report.yml
index 9077158f7..450e4fb09 100644
--- a/tests/ansible/setup/report.yml
+++ b/tests/ansible/setup/report.yml
@@ -17,3 +17,4 @@
     - debug: {var: ansible_run_tags}
     - debug: {var: ansible_skip_tags}
     - debug: {var: ansible_version.full}
+    - debug: {var: is_mitogen}
diff --git a/tests/ansible/soak/_file_service_loop.yml b/tests/ansible/soak/_file_service_loop.yml
index 96111b3c2..474abd077 100644
--- a/tests/ansible/soak/_file_service_loop.yml
+++ b/tests/ansible/soak/_file_service_loop.yml
@@ -1,6 +1,8 @@
-  - file:
+  - name: Delete existing /tmp/foo-<inventory_hostname>
+    file:
       path: /tmp/foo-{{inventory_hostname}}
       state: absent
-  - copy:
+  - name: Create /tmp/foo-<inventory_hostname>
+    copy:
       dest: /tmp/foo-{{inventory_hostname}}
       content: "{{content}}"
diff --git a/tests/ansible/soak/file_service.yml b/tests/ansible/soak/file_service.yml
index 65b10b2d1..90462cbf4 100644
--- a/tests/ansible/soak/file_service.yml
+++ b/tests/ansible/soak/file_service.yml
@@ -1,4 +1,5 @@
-- hosts: all
+- name: soak/file_service.yml
+  hosts: all
   tasks:
   - set_fact:
       content: "{% for x in range(126977) %}x{% endfor %}"
diff --git a/tests/ansible/tests/affinity_test.py b/tests/ansible/tests/affinity_test.py
index 0051a7018..b968071be 100644
--- a/tests/ansible/tests/affinity_test.py
+++ b/tests/ansible/tests/affinity_test.py
@@ -205,7 +205,7 @@ def test_clear_on_popen(self):
             proc.wait()
 
             his_cpu = self._get_cpus(tf.name)
-            self.assertNotEquals(my_cpu, his_cpu)
+            self.assertNotEqual(my_cpu, his_cpu)
             self.policy._clear()
         finally:
             tf.close()
diff --git a/tests/ansible/tests/connection_test.py b/tests/ansible/tests/connection_test.py
index 8a831a5e8..0ade69946 100644
--- a/tests/ansible/tests/connection_test.py
+++ b/tests/ansible/tests/connection_test.py
@@ -41,7 +41,7 @@ def make_connection(self):
         conn = self.klass(play_context, new_stdin=False)
         # conn functions don't fetch ActionModuleMixin objs from _get_task_vars()
         # through the usual walk-the-stack approach so we'll not run interpreter discovery here
-        conn._action = mock.MagicMock(_possible_python_interpreter='/usr/bin/python')
+        conn._action = mock.MagicMock(_possible_python_interpreter=testlib.base_executable())
         conn.on_action_run(
             task_vars={},
             delegate_to_hostname=None,
diff --git a/tests/ansible/tests/env_file_watcher_test.py b/tests/ansible/tests/env_file_watcher_test.py
index 6ca1b7e08..62d437cdd 100644
--- a/tests/ansible/tests/env_file_watcher_test.py
+++ b/tests/ansible/tests/env_file_watcher_test.py
@@ -42,7 +42,7 @@ def test_key_deleted(self):
         self.tf.seek(0)
         self.tf.truncate(0)
         watcher.check()
-        self.assertTrue(b('SOMEKEY') not in environb)
+        self.assertNotIn(b('SOMEKEY'), environb)
 
     def test_key_added(self):
         watcher = klass(self.tf.name)
diff --git a/tests/call_error_test.py b/tests/call_error_test.py
index 1d098bab2..9c1174b8f 100644
--- a/tests/call_error_test.py
+++ b/tests/call_error_test.py
@@ -13,18 +13,18 @@ class ConstructorTest(testlib.TestCase):
     def test_string_noargs(self):
         e = self.klass('%s%s')
         self.assertEqual(e.args[0], '%s%s')
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_string_args(self):
         e = self.klass('%s%s', 1, 1)
         self.assertEqual(e.args[0], '11')
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_from_exc(self):
         ve = plain_old_module.MyError('eek')
         e = self.klass(ve)
         self.assertEqual(e.args[0], 'plain_old_module.MyError: eek')
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_form_base_exc(self):
         ve = SystemExit('eek')
@@ -33,7 +33,7 @@ def test_form_base_exc(self):
         self.assertEqual(e.args[0],
             # varies across 2/3.
             '%s.%s: eek' % (cls.__module__, cls.__name__))
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_from_exc_tb(self):
         try:
@@ -43,18 +43,18 @@ def test_from_exc_tb(self):
             e = self.klass(ve)
 
         self.assertTrue(e.args[0].startswith('plain_old_module.MyError: eek'))
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
-        self.assertTrue('test_from_exc_tb' in e.args[0])
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
+        self.assertIn('test_from_exc_tb', e.args[0])
 
     def test_bytestring_conversion(self):
         e = self.klass(mitogen.core.b('bytes'))
         self.assertEqual(u'bytes', e.args[0])
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_reduce(self):
         e = self.klass('eek')
         func, (arg,) = e.__reduce__()
-        self.assertTrue(func is mitogen.core._unpickle_call_error)
+        self.assertIs(func, mitogen.core._unpickle_call_error)
         self.assertEqual(arg, e.args[0])
 
 
@@ -105,4 +105,4 @@ def test_from_exc_tb(self):
 
         e2 = pickle.loads(pickle.dumps(e))
         self.assertTrue(e2.args[0].startswith('plain_old_module.MyError: eek'))
-        self.assertTrue('test_from_exc_tb' in e2.args[0])
+        self.assertIn('test_from_exc_tb', e2.args[0])
diff --git a/tests/connection_test.py b/tests/connection_test.py
index 5b54e090b..5c3e678d3 100644
--- a/tests/connection_test.py
+++ b/tests/connection_test.py
@@ -39,7 +39,7 @@ def thread():
         th.join()
 
         exc, = result
-        self.assertTrue(isinstance(exc, mitogen.parent.CancelledError))
+        self.assertIsInstance(exc, mitogen.parent.CancelledError)
         self.assertEqual(mitogen.parent.BROKER_SHUTDOWN_MSG, exc.args[0])
 
 
diff --git a/tests/create_child_test.py b/tests/create_child_test.py
index d9bbd1a16..acf3ea668 100644
--- a/tests/create_child_test.py
+++ b/tests/create_child_test.py
@@ -101,6 +101,7 @@ def test_stdin(self):
         self.assertTrue(flags & os.O_RDWR)
         self.assertTrue(info['buf'], 'TEST')
         self.assertTrue(info['flags'] & os.O_RDWR)
+        close_proc(proc)
 
 
 class StdoutSockMixin(object):
@@ -115,6 +116,7 @@ def test_stdout(self):
         self.assertTrue(flags & os.O_RDWR)
         self.assertTrue(buf, 'TEST')
         self.assertTrue(info['flags'] & os.O_RDWR)
+        close_proc(proc)
 
 
 class CreateChildTest(StdinSockMixin, StdoutSockMixin, testlib.TestCase):
@@ -126,6 +128,7 @@ def test_stderr(self):
         self.assertEqual(st.st_dev, info['st_dev'])
         self.assertEqual(st.st_mode, info['st_mode'])
         self.assertEqual(st.st_ino, info['st_ino'])
+        close_proc(proc)
 
 
 class CreateChildMergedTest(StdinSockMixin, StdoutSockMixin,
@@ -144,6 +147,7 @@ def test_stderr(self):
         self.assertTrue(flags & os.O_RDWR)
         self.assertTrue(buf, 'TEST')
         self.assertTrue(info['flags'] & os.O_RDWR)
+        close_proc(proc)
 
 
 class CreateChildStderrPipeTest(StdinSockMixin, StdoutSockMixin,
@@ -164,6 +168,7 @@ def test_stderr(self):
         self.assertFalse(flags & os.O_RDWR)
         self.assertTrue(buf, 'TEST')
         self.assertTrue(info['flags'] & os.O_WRONLY)
+        close_proc(proc)
 
 
 class TtyCreateChildTest(testlib.TestCase):
@@ -191,9 +196,9 @@ def test_dev_tty_open_succeeds(self):
             self.assertEqual(proc.pid, waited_pid)
             self.assertEqual(0, status)
             self.assertEqual(mitogen.core.b(''), tf.read())
-            proc.stdout.close()
         finally:
             tf.close()
+            close_proc(proc)
 
     def test_stdin(self):
         proc, info, _ = run_fd_check(self.func, 0, 'read',
@@ -202,14 +207,14 @@ def test_stdin(self):
         self.assertTrue(stat.S_ISCHR(st.st_mode))
         self.assertTrue(stat.S_ISCHR(info['st_mode']))
 
-        self.assertTrue(isinstance(info['ttyname'],
-                        mitogen.core.UnicodeType))
+        self.assertIsInstance(info['ttyname'], mitogen.core.UnicodeType)
         self.assertTrue(os.isatty(proc.stdin.fileno()))
 
         flags = fcntl.fcntl(proc.stdin.fileno(), fcntl.F_GETFL)
         self.assertTrue(flags & os.O_RDWR)
         self.assertTrue(info['flags'] & os.O_RDWR)
         self.assertTrue(info['buf'], 'TEST')
+        close_proc(proc)
 
     def test_stdout(self):
         proc, info, buf = run_fd_check(self.func, 1, 'write',
@@ -219,8 +224,7 @@ def test_stdout(self):
         self.assertTrue(stat.S_ISCHR(st.st_mode))
         self.assertTrue(stat.S_ISCHR(info['st_mode']))
 
-        self.assertTrue(isinstance(info['ttyname'],
-                        mitogen.core.UnicodeType))
+        self.assertIsInstance(info['ttyname'], mitogen.core.UnicodeType)
         self.assertTrue(os.isatty(proc.stdout.fileno()))
 
         flags = fcntl.fcntl(proc.stdout.fileno(), fcntl.F_GETFL)
@@ -229,6 +233,7 @@ def test_stdout(self):
 
         self.assertTrue(flags & os.O_RDWR)
         self.assertTrue(buf, 'TEST')
+        close_proc(proc)
 
     def test_stderr(self):
         # proc.stderr is None in the parent since there is no separate stderr
@@ -240,8 +245,7 @@ def test_stderr(self):
         self.assertTrue(stat.S_ISCHR(st.st_mode))
         self.assertTrue(stat.S_ISCHR(info['st_mode']))
 
-        self.assertTrue(isinstance(info['ttyname'],
-                        mitogen.core.UnicodeType))
+        self.assertIsInstance(info['ttyname'], mitogen.core.UnicodeType)
         self.assertTrue(os.isatty(proc.stdout.fileno()))
 
         flags = fcntl.fcntl(proc.stdout.fileno(), fcntl.F_GETFL)
@@ -250,6 +254,7 @@ def test_stderr(self):
 
         self.assertTrue(flags & os.O_RDWR)
         self.assertTrue(buf, 'TEST')
+        close_proc(proc)
 
     def test_dev_tty_open_succeeds(self):
         # In the early days of UNIX, a process that lacked a controlling TTY
@@ -274,6 +279,7 @@ def test_dev_tty_open_succeeds(self):
             proc.stdout.close()
         finally:
             tf.close()
+            close_proc(proc)
 
 
 class StderrDiagTtyMixin(object):
@@ -286,8 +292,7 @@ def test_stderr(self):
         self.assertTrue(stat.S_ISCHR(st.st_mode))
         self.assertTrue(stat.S_ISCHR(info['st_mode']))
 
-        self.assertTrue(isinstance(info['ttyname'],
-                        mitogen.core.UnicodeType))
+        self.assertIsInstance(info['ttyname'], mitogen.core.UnicodeType)
         self.assertTrue(os.isatty(proc.stderr.fileno()))
 
         flags = fcntl.fcntl(proc.stderr.fileno(), fcntl.F_GETFL)
@@ -296,6 +301,7 @@ def test_stderr(self):
 
         self.assertTrue(flags & os.O_RDWR)
         self.assertTrue(buf, 'TEST')
+        close_proc(proc)
 
 
 class HybridTtyCreateChildTest(StdinSockMixin, StdoutSockMixin,
@@ -321,6 +327,7 @@ def test_stdin(self):
             self.assertTrue(buf, 'TEST')
             self.assertFalse(info['flags'] & os.O_WRONLY)
             self.assertFalse(info['flags'] & os.O_RDWR)
+            close_proc(proc)
 
         def test_stdout(self):
             proc, info, buf = run_fd_check(self.func, 1, 'write',
@@ -334,3 +341,4 @@ def test_stdout(self):
             self.assertFalse(flags & os.O_RDWR)
             self.assertTrue(info['flags'] & os.O_WRONLY)
             self.assertTrue(buf, 'TEST')
+            close_proc(proc)
diff --git a/tests/data/minimize_samples/hashbang.py b/tests/data/minimize_samples/hashbang.py
index 0245c48f2..1d358671c 100644
--- a/tests/data/minimize_samples/hashbang.py
+++ b/tests/data/minimize_samples/hashbang.py
@@ -1,3 +1,3 @@
-#/usr/bin/python -c
+#/usr/bin/env python -c
 # coding: utf-8
 # comment
diff --git a/tests/data/minimize_samples/hashbang_min.py b/tests/data/minimize_samples/hashbang_min.py
index 5a74f4815..19836fa51 100644
--- a/tests/data/minimize_samples/hashbang_min.py
+++ b/tests/data/minimize_samples/hashbang_min.py
@@ -1,3 +1,3 @@
-#/usr/bin/python -c
+#/usr/bin/env python -c
 # coding: utf-8
 
diff --git a/tests/doas_test.py b/tests/doas_test.py
index 4e8479427..b00355407 100644
--- a/tests/doas_test.py
+++ b/tests/doas_test.py
@@ -37,7 +37,7 @@ def test_okay(self):
 #         e = self.assertRaises(mitogen.core.StreamError,
 #             lambda: self.router.doas(via=ssh)
 #         )
-#         self.assertTrue(mitogen.doas.password_required_msg in str(e))
+#         self.assertIn(mitogen.doas.password_required_msg, str(e))
 
 #     def test_password_incorrect(self):
 #         ssh = self.docker_ssh(
@@ -47,7 +47,7 @@ def test_okay(self):
 #         e = self.assertRaises(mitogen.core.StreamError,
 #             lambda: self.router.doas(via=ssh, password='x')
 #         )
-#         self.assertTrue(mitogen.doas.password_incorrect_msg in str(e))
+#         self.assertIn(mitogen.doas.password_incorrect_msg, str(e))
 
 #     def test_password_okay(self):
 #         ssh = self.docker_ssh(
diff --git a/tests/error_test.py b/tests/error_test.py
index a34d801e5..3138d7cde 100644
--- a/tests/error_test.py
+++ b/tests/error_test.py
@@ -8,19 +8,19 @@ class ConstructorTest(testlib.TestCase):
     def test_literal_no_format(self):
         e = self.klass('error')
         self.assertEqual(e.args[0], 'error')
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_literal_format_chars_present(self):
         e = self.klass('error%s')
         self.assertEqual(e.args[0], 'error%s')
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_format(self):
         e = self.klass('error%s', 123)
         self.assertEqual(e.args[0], 'error123')
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
 
     def test_bytes_to_unicode(self):
         e = self.klass(mitogen.core.b('error'))
         self.assertEqual(e.args[0], 'error')
-        self.assertTrue(isinstance(e.args[0], mitogen.core.UnicodeType))
+        self.assertIsInstance(e.args[0], mitogen.core.UnicodeType)
diff --git a/tests/file_service_test.py b/tests/file_service_test.py
index 2a2008b3c..b400ab3ea 100644
--- a/tests/file_service_test.py
+++ b/tests/file_service_test.py
@@ -40,7 +40,7 @@ def test_unauthorized(self):
             pool.stop()
 
         expect = service.unregistered_msg % ('/etc/shadow',)
-        self.assertTrue(expect in e.args[0])
+        self.assertIn(expect, e.args[0])
 
     if sys.platform == 'darwin':
         ROOT_GROUP = 'wheel'
@@ -48,13 +48,13 @@ def test_unauthorized(self):
         ROOT_GROUP = 'root'
 
     def _validate_response(self, resp):
-        self.assertTrue(isinstance(resp, dict))
+        self.assertIsInstance(resp, dict)
         self.assertEqual('root', resp['owner'])
         self.assertEqual(self.ROOT_GROUP, resp['group'])
-        self.assertTrue(isinstance(resp['mode'], int))
-        self.assertTrue(isinstance(resp['mtime'], float))
-        self.assertTrue(isinstance(resp['atime'], float))
-        self.assertTrue(isinstance(resp['size'], int))
+        self.assertIsInstance(resp['mode'], int)
+        self.assertIsInstance(resp['mtime'], float)
+        self.assertIsInstance(resp['atime'], float)
+        self.assertIsInstance(resp['size'], int)
 
     def test_path_authorized(self):
         recv = mitogen.core.Receiver(self.router)
@@ -117,7 +117,7 @@ def test_prefix_authorized_abspath_bad(self):
             pool.stop()
 
         expect = service.unregistered_msg % (path,)
-        self.assertTrue(expect in e.args[0])
+        self.assertIn(expect, e.args[0])
 
     def test_prefix_authorized_abspath_good(self):
         l1 = self.router.local()
@@ -144,4 +144,4 @@ def test_prefix_authorized_abspath_good(self):
             pool.stop()
 
         expect = service.unregistered_msg % (path,)
-        self.assertTrue(expect in e.args[0])
+        self.assertIn(expect, e.args[0])
diff --git a/tests/fork_test.py b/tests/fork_test.py
index 86e60cfcf..35acfe137 100644
--- a/tests/fork_test.py
+++ b/tests/fork_test.py
@@ -1,5 +1,6 @@
 import os
 import random
+import subprocess
 import sys
 import unittest
 
@@ -26,15 +27,28 @@
 
 
 def _find_ssl_linux():
-    s = testlib.subprocess__check_output(['ldd', _ssl.__file__])
-    for line in s.decode().splitlines():
+    proc = subprocess.Popen(
+        ['ldd', _ssl.__file__],
+        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+    )
+    b_stdout, b_stderr = proc.communicate()
+    assert proc.returncode == 0
+    assert b_stderr.decode() == ''
+    for line in b_stdout.decode().splitlines():
         bits = line.split()
         if bits[0].startswith('libssl'):
             return bits[2]
 
+
 def _find_ssl_darwin():
-    s = testlib.subprocess__check_output(['otool', '-l', _ssl.__file__])
-    for line in s.decode().splitlines():
+    proc = subprocess.Popen(
+        ['otool', '-l', _ssl.__file__],
+        stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+    )
+    b_stdout, b_stderr = proc.communicate()
+    assert proc.returncode == 0
+    assert b_stderr.decode() == ''
+    for line in b_stdout.decode().splitlines():
         bits = line.split()
         if bits[0] == 'name' and 'libssl' in bits[1]:
             return bits[1]
diff --git a/tests/latch_test.py b/tests/latch_test.py
index e7e2768de..1fac8219d 100644
--- a/tests/latch_test.py
+++ b/tests/latch_test.py
@@ -198,7 +198,7 @@ def test_one_thread(self):
         self.join()
         self.assertEqual(self.results, [None])
         for exc in self.excs:
-            self.assertTrue(isinstance(exc, mitogen.core.LatchError))
+            self.assertIsInstance(exc, mitogen.core.LatchError)
 
     def test_five_threads(self):
         latch = self.klass()
@@ -208,4 +208,4 @@ def test_five_threads(self):
         self.join()
         self.assertEqual(self.results, [None]*5)
         for exc in self.excs:
-            self.assertTrue(isinstance(exc, mitogen.core.LatchError))
+            self.assertIsInstance(exc, mitogen.core.LatchError)
diff --git a/tests/log_handler_test.py b/tests/log_handler_test.py
index c270c20ab..43b986cbb 100644
--- a/tests/log_handler_test.py
+++ b/tests/log_handler_test.py
@@ -65,8 +65,8 @@ def test_earliest_messages_logged(self):
         c1.shutdown(wait=True)
 
         logs = log.stop()
-        self.assertTrue('Python version is' in logs)
-        self.assertTrue('Parent is context 0 (master)' in logs)
+        self.assertIn('Python version is', logs)
+        self.assertIn('Parent is context 0 (master)', logs)
 
     def test_earliest_messages_logged_via(self):
         c1 = self.router.local(name='c1')
@@ -80,10 +80,10 @@ def test_earliest_messages_logged_via(self):
         c2.shutdown(wait=True)
 
         logs = log.stop()
-        self.assertTrue('Python version is' in logs)
+        self.assertIn('Python version is', logs)
 
         expect = 'Parent is context %s (%s)' % (c1.context_id, 'parent')
-        self.assertTrue(expect in logs)
+        self.assertIn(expect, logs)
 
 StartupTest = unittest.skipIf(
     condition=sys.version_info < (2, 7) or sys.version_info >= (3, 6),
diff --git a/tests/lxc_test.py b/tests/lxc_test.py
index 81d273187..b9ebfa538 100644
--- a/tests/lxc_test.py
+++ b/tests/lxc_test.py
@@ -26,7 +26,7 @@ def test_okay(self):
 
         argv = eval(context.call(os.getenv, 'ORIGINAL_ARGV'))
         self.assertEqual(argv[0], self.lxc_attach_path)
-        self.assertTrue('--clear-env' in argv)
+        self.assertIn('--clear-env', argv)
         self.assertTrue(has_subseq(argv, ['--name', 'container_name']))
 
     def test_eof(self):
diff --git a/tests/message_test.py b/tests/message_test.py
index b5f5cf084..2d2299d1b 100644
--- a/tests/message_test.py
+++ b/tests/message_test.py
@@ -49,12 +49,12 @@ def test_reply_to_explicit(self):
     def test_data_default(self):
         m = self.klass()
         self.assertEqual(m.data, b(''))
-        self.assertTrue(isinstance(m.data, mitogen.core.BytesType))
+        self.assertIsInstance(m.data, mitogen.core.BytesType)
 
     def test_data_explicit(self):
         m = self.klass(data=b('asdf'))
         self.assertEqual(m.data, b('asdf'))
-        self.assertTrue(isinstance(m.data, mitogen.core.BytesType))
+        self.assertIsInstance(m.data, mitogen.core.BytesType)
 
     def test_data_hates_unicode(self):
         self.assertRaises(Exception,
@@ -185,9 +185,9 @@ def test_dict(self):
         d = {1: 2, u'a': 3, b('b'): 4, 'c': {}}
         roundtrip = self.roundtrip(d)
         self.assertEqual(d, roundtrip)
-        self.assertTrue(isinstance(roundtrip, dict))
+        self.assertIsInstance(roundtrip, dict)
         for k in d:
-            self.assertTrue(isinstance(roundtrip[k], type(d[k])))
+            self.assertIsInstance(roundtrip[k], type(d[k]))
 
     def test_int(self):
         self.assertEqual(123, self.klass.pickled(123).unpickle())
@@ -195,10 +195,10 @@ def test_int(self):
     def test_list(self):
         l = [1, u'b', b('c')]
         roundtrip = self.roundtrip(l)
-        self.assertTrue(isinstance(roundtrip, list))
+        self.assertIsInstance(roundtrip, list)
         self.assertEqual(l, roundtrip)
         for k in range(len(l)):
-            self.assertTrue(isinstance(roundtrip[k], type(l[k])))
+            self.assertIsInstance(roundtrip[k], type(l[k]))
 
     @unittest.skipIf(condition=sys.version_info > (3, 0),
                       reason='long missing in >3.x')
@@ -206,21 +206,21 @@ def test_long(self):
         l = long(0xffffffffffff)
         roundtrip = self.roundtrip(l)
         self.assertEqual(l, roundtrip)
-        self.assertTrue(isinstance(roundtrip, long))
+        self.assertIsInstance(roundtrip, long)
 
     def test_tuple(self):
         l = (1, u'b', b('c'))
         roundtrip = self.roundtrip(l)
         self.assertEqual(l, roundtrip)
-        self.assertTrue(isinstance(roundtrip, tuple))
+        self.assertIsInstance(roundtrip, tuple)
         for k in range(len(l)):
-            self.assertTrue(isinstance(roundtrip[k], type(l[k])))
+            self.assertIsInstance(roundtrip[k], type(l[k]))
 
     def test_unicode(self):
         u = u'abcd'
         roundtrip = self.roundtrip(u)
         self.assertEqual(u, roundtrip)
-        self.assertTrue(isinstance(roundtrip, mitogen.core.UnicodeType))
+        self.assertIsInstance(roundtrip, mitogen.core.UnicodeType)
 
     #### custom types. see also: types_test.py, call_error_test.py
 
@@ -231,25 +231,25 @@ def test_unicode(self):
     def test_blob_nonempty(self):
         v = mitogen.core.Blob(b('dave'))
         roundtrip = self.roundtrip(v)
-        self.assertTrue(isinstance(roundtrip, mitogen.core.Blob))
+        self.assertIsInstance(roundtrip, mitogen.core.Blob)
         self.assertEqual(b('dave'), roundtrip)
 
     def test_blob_empty(self):
         v = mitogen.core.Blob(b(''))
         roundtrip = self.roundtrip(v)
-        self.assertTrue(isinstance(roundtrip, mitogen.core.Blob))
+        self.assertIsInstance(roundtrip, mitogen.core.Blob)
         self.assertEqual(b(''), v)
 
     def test_secret_nonempty(self):
         s = mitogen.core.Secret(u'dave')
         roundtrip = self.roundtrip(s)
-        self.assertTrue(isinstance(roundtrip, mitogen.core.Secret))
+        self.assertIsInstance(roundtrip, mitogen.core.Secret)
         self.assertEqual(u'dave', roundtrip)
 
     def test_secret_empty(self):
         s = mitogen.core.Secret(u'')
         roundtrip = self.roundtrip(s)
-        self.assertTrue(isinstance(roundtrip, mitogen.core.Secret))
+        self.assertIsInstance(roundtrip, mitogen.core.Secret)
         self.assertEqual(u'', roundtrip)
 
     def test_call_error(self):
@@ -263,7 +263,7 @@ def test_context(self):
         try:
             c = router.context_by_id(1234)
             roundtrip = self.roundtrip(c)
-            self.assertTrue(isinstance(roundtrip, mitogen.core.Context))
+            self.assertIsInstance(roundtrip, mitogen.core.Context)
             self.assertEqual(c.context_id, 1234)
         finally:
             router.broker.shutdown()
@@ -275,7 +275,7 @@ def test_sender(self):
             recv = mitogen.core.Receiver(router)
             sender = recv.to_sender()
             roundtrip = self.roundtrip(sender, router=router)
-            self.assertTrue(isinstance(roundtrip, mitogen.core.Sender))
+            self.assertIsInstance(roundtrip, mitogen.core.Sender)
             self.assertEqual(roundtrip.context.context_id, mitogen.context_id)
             self.assertEqual(roundtrip.dst_handle, sender.dst_handle)
         finally:
@@ -314,7 +314,7 @@ def test_reply_uses_preformatted_message(self):
         my_reply = mitogen.core.Message.pickled(4444)
         msg.reply(my_reply, router=router)
         _, (reply,), _ = router.route.mock_calls[0]
-        self.assertTrue(my_reply is reply)
+        self.assertIs(my_reply, reply)
         self.assertEqual(reply.dst_id, 1234)
         self.assertEqual(reply.unpickle(), 4444)
 
diff --git a/tests/mitogen_protocol_test.py b/tests/mitogen_protocol_test.py
index 03e6517ce..d6e3cc959 100644
--- a/tests/mitogen_protocol_test.py
+++ b/tests/mitogen_protocol_test.py
@@ -25,4 +25,4 @@ def test_corruption(self):
 
         self.assertEqual(1, stream.on_disconnect.call_count)
         expect = self.klass.corrupt_msg % (stream.name, junk)
-        self.assertTrue(expect in capture.raw())
+        self.assertIn(expect, capture.raw())
diff --git a/tests/module_finder_test.py b/tests/module_finder_test.py
index 171c8b4cf..ff18bbc58 100644
--- a/tests/module_finder_test.py
+++ b/tests/module_finder_test.py
@@ -66,7 +66,7 @@ def test_main(self):
         import __main__
 
         path, source, is_pkg = self.call('__main__')
-        self.assertTrue(path is not None)
+        self.assertIsNotNone(path)
         self.assertTrue(os.path.exists(path))
         self.assertEqual(path, __main__.__file__)
         fp = open(path, 'rb')
diff --git a/tests/parent_test.py b/tests/parent_test.py
index b291f6569..fa7e4fb71 100644
--- a/tests/parent_test.py
+++ b/tests/parent_test.py
@@ -132,7 +132,7 @@ def test_via_eof(self):
             )
         )
         expect = mitogen.parent.Connection.eof_error_msg
-        self.assertTrue(expect in e.args[0])
+        self.assertIn(expect, e.args[0])
 
     def test_direct_enoent(self):
         e = self.assertRaises(mitogen.core.StreamError,
@@ -154,7 +154,7 @@ def test_via_enoent(self):
             )
         )
         s = 'Child start failed: [Errno 2] No such file or directory'
-        self.assertTrue(s in e.args[0])
+        self.assertIn(s, e.args[0])
 
 
 class ContextTest(testlib.RouterMixin, testlib.TestCase):
@@ -173,9 +173,9 @@ def test_pty_returned(self):
         master_fp, slave_fp = self.func()
         try:
             self.assertTrue(master_fp.isatty())
-            self.assertTrue(isinstance(master_fp, file))
+            self.assertIsInstance(master_fp, file)
             self.assertTrue(slave_fp.isatty())
-            self.assertTrue(isinstance(slave_fp, file))
+            self.assertIsInstance(slave_fp, file)
         finally:
             master_fp.close()
             slave_fp.close()
diff --git a/tests/poller_test.py b/tests/poller_test.py
index 4b6bdc0ef..f915df0ad 100644
--- a/tests/poller_test.py
+++ b/tests/poller_test.py
@@ -163,12 +163,12 @@ class PollMixin(PollerMixin):
     def test_empty_zero_timeout(self):
         t0 = mitogen.core.now()
         self.assertEqual([], list(self.p.poll(0)))
-        self.assertTrue((mitogen.core.now() - t0) < .1)  # vaguely reasonable
+        self.assertLess((mitogen.core.now() - t0), .1)  # vaguely reasonable
 
     def test_empty_small_timeout(self):
         t0 = mitogen.core.now()
         self.assertEqual([], list(self.p.poll(.2)))
-        self.assertTrue((mitogen.core.now() - t0) >= .2)
+        self.assertGreaterEqual((mitogen.core.now() - t0), .2)
 
 
 class ReadableMixin(PollerMixin, SockMixin):
@@ -394,21 +394,16 @@ class AllMixin(ReceiveStateMixin,
     """
 
 
-class SelectTest(AllMixin, testlib.TestCase):
+class CorePollerTest(AllMixin, testlib.TestCase):
     klass = mitogen.core.Poller
 
-SelectTest = unittest.skipIf(
-    condition=(not SelectTest.klass.SUPPORTED),
-    reason='select.select() not supported'
-)(SelectTest)
-
 
 class PollTest(AllMixin, testlib.TestCase):
     klass = mitogen.parent.PollPoller
 
 PollTest = unittest.skipIf(
     condition=(not PollTest.klass.SUPPORTED),
-    reason='select.poll() not supported'
+    reason='select.poll() not available',
 )(PollTest)
 
 
@@ -417,7 +412,7 @@ class KqueueTest(AllMixin, testlib.TestCase):
 
 KqueueTest = unittest.skipIf(
     condition=(not KqueueTest.klass.SUPPORTED),
-    reason='select.kqueue() not supported'
+    reason='select.kqueue() not available',
 )(KqueueTest)
 
 
@@ -426,5 +421,5 @@ class EpollTest(AllMixin, testlib.TestCase):
 
 EpollTest = unittest.skipIf(
     condition=(not EpollTest.klass.SUPPORTED),
-    reason='select.epoll() not supported'
+    reason='select.epoll() not available',
 )(EpollTest)
diff --git a/tests/polyfill_functions_test.py b/tests/polyfill_functions_test.py
index 222da7b5f..c3441a63e 100644
--- a/tests/polyfill_functions_test.py
+++ b/tests/polyfill_functions_test.py
@@ -9,27 +9,27 @@ class BytesPartitionTest(testlib.TestCase):
 
     def test_no_sep(self):
         left, sep, right = self.func(b('dave'), b('x'))
-        self.assertTrue(isinstance(left, mitogen.core.BytesType))
-        self.assertTrue(isinstance(sep, mitogen.core.BytesType))
-        self.assertTrue(isinstance(right, mitogen.core.BytesType))
+        self.assertIsInstance(left, mitogen.core.BytesType)
+        self.assertIsInstance(sep, mitogen.core.BytesType)
+        self.assertIsInstance(right, mitogen.core.BytesType)
         self.assertEqual(left, b('dave'))
         self.assertEqual(sep, b(''))
         self.assertEqual(right, b(''))
 
     def test_one_sep(self):
         left, sep, right = self.func(b('davexdave'), b('x'))
-        self.assertTrue(isinstance(left, mitogen.core.BytesType))
-        self.assertTrue(isinstance(sep, mitogen.core.BytesType))
-        self.assertTrue(isinstance(right, mitogen.core.BytesType))
+        self.assertIsInstance(left, mitogen.core.BytesType)
+        self.assertIsInstance(sep, mitogen.core.BytesType)
+        self.assertIsInstance(right, mitogen.core.BytesType)
         self.assertEqual(left, b('dave'))
         self.assertEqual(sep, b('x'))
         self.assertEqual(right, b('dave'))
 
     def test_two_seps(self):
         left, sep, right = self.func(b('davexdavexdave'), b('x'))
-        self.assertTrue(isinstance(left, mitogen.core.BytesType))
-        self.assertTrue(isinstance(sep, mitogen.core.BytesType))
-        self.assertTrue(isinstance(right, mitogen.core.BytesType))
+        self.assertIsInstance(left, mitogen.core.BytesType)
+        self.assertIsInstance(sep, mitogen.core.BytesType)
+        self.assertIsInstance(right, mitogen.core.BytesType)
         self.assertEqual(left, b('dave'))
         self.assertEqual(sep, b('x'))
         self.assertEqual(right, b('davexdave'))
@@ -40,27 +40,27 @@ class StrPartitionTest(testlib.TestCase):
 
     def test_no_sep(self):
         left, sep, right = self.func(u'dave', u'x')
-        self.assertTrue(isinstance(left, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(sep, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(right, mitogen.core.UnicodeType))
+        self.assertIsInstance(left, mitogen.core.UnicodeType)
+        self.assertIsInstance(sep, mitogen.core.UnicodeType)
+        self.assertIsInstance(right, mitogen.core.UnicodeType)
         self.assertEqual(left, u'dave')
         self.assertEqual(sep, u'')
         self.assertEqual(right, u'')
 
     def test_one_sep(self):
         left, sep, right = self.func(u'davexdave', u'x')
-        self.assertTrue(isinstance(left, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(sep, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(right, mitogen.core.UnicodeType))
+        self.assertIsInstance(left, mitogen.core.UnicodeType)
+        self.assertIsInstance(sep, mitogen.core.UnicodeType)
+        self.assertIsInstance(right, mitogen.core.UnicodeType)
         self.assertEqual(left, u'dave')
         self.assertEqual(sep, u'x')
         self.assertEqual(right, u'dave')
 
     def test_two_seps(self):
         left, sep, right = self.func(u'davexdavexdave', u'x')
-        self.assertTrue(isinstance(left, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(sep, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(right, mitogen.core.UnicodeType))
+        self.assertIsInstance(left, mitogen.core.UnicodeType)
+        self.assertIsInstance(sep, mitogen.core.UnicodeType)
+        self.assertIsInstance(right, mitogen.core.UnicodeType)
         self.assertEqual(left, u'dave')
         self.assertEqual(sep, u'x')
         self.assertEqual(right, u'davexdave')
@@ -71,27 +71,27 @@ class StrRpartitionTest(testlib.TestCase):
 
     def test_no_sep(self):
         left, sep, right = self.func(u'dave', u'x')
-        self.assertTrue(isinstance(left, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(sep, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(right, mitogen.core.UnicodeType))
+        self.assertIsInstance(left, mitogen.core.UnicodeType)
+        self.assertIsInstance(sep, mitogen.core.UnicodeType)
+        self.assertIsInstance(right, mitogen.core.UnicodeType)
         self.assertEqual(left, u'')
         self.assertEqual(sep, u'')
         self.assertEqual(right, u'dave')
 
     def test_one_sep(self):
         left, sep, right = self.func(u'davexdave', u'x')
-        self.assertTrue(isinstance(left, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(sep, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(right, mitogen.core.UnicodeType))
+        self.assertIsInstance(left, mitogen.core.UnicodeType)
+        self.assertIsInstance(sep, mitogen.core.UnicodeType)
+        self.assertIsInstance(right, mitogen.core.UnicodeType)
         self.assertEqual(left, u'dave')
         self.assertEqual(sep, u'x')
         self.assertEqual(right, u'dave')
 
     def test_two_seps(self):
         left, sep, right = self.func(u'davexdavexdave', u'x')
-        self.assertTrue(isinstance(left, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(sep, mitogen.core.UnicodeType))
-        self.assertTrue(isinstance(right, mitogen.core.UnicodeType))
+        self.assertIsInstance(left, mitogen.core.UnicodeType)
+        self.assertIsInstance(sep, mitogen.core.UnicodeType)
+        self.assertIsInstance(right, mitogen.core.UnicodeType)
         self.assertEqual(left, u'davexdave')
         self.assertEqual(sep, u'x')
         self.assertEqual(right, u'dave')
diff --git a/tests/receiver_test.py b/tests/receiver_test.py
index aaf15b2b6..0cb9b15de 100644
--- a/tests/receiver_test.py
+++ b/tests/receiver_test.py
@@ -16,8 +16,8 @@ def yield_stuff_then_die(sender):
 class ConstructorTest(testlib.RouterMixin, testlib.TestCase):
     def test_handle(self):
         recv = mitogen.core.Receiver(self.router)
-        self.assertTrue(isinstance(recv.handle, int))
-        self.assertTrue(recv.handle > 100)
+        self.assertIsInstance(recv.handle, int)
+        self.assertGreater(recv.handle, 100)
         self.router.route(
             mitogen.core.Message.pickled(
                 'hi',
diff --git a/tests/requirements.txt b/tests/requirements.txt
index 319e0f519..7301bee11 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,13 +1,14 @@
 psutil==5.4.8
-coverage==4.5.1
+coverage==5.5;      python_version < '3.7'
+coverage==6.4.4;    python_version >= '3.7'
 Django==1.6.11  # Last version supporting 2.6.
-faulthandler==3.2; python_version < '3.3'
 mock==2.0.0
 pytz==2018.5
 cffi==1.14.3  # Random pin to try and fix pyparser==2.18 not having effect
 pycparser==2.18  # Last version supporting 2.6.
 pytest-catchlog==1.2.2
 pytest==3.1.2
+subprocess32==3.5.4
 timeoutcontext==1.2.0
 # Fix InsecurePlatformWarning while creating py26 tox environment
 # https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings
diff --git a/tests/responder_test.py b/tests/responder_test.py
index 3ab60be30..1131b421e 100644
--- a/tests/responder_test.py
+++ b/tests/responder_test.py
@@ -25,7 +25,7 @@ def test_missing_exec_guard(self):
         _, stderr = proc.communicate()
         self.assertEqual(1, proc.returncode)
         expect = self.klass.main_guard_msg % (path,)
-        self.assertTrue(expect in stderr.decode())
+        self.assertIn(expect, stderr.decode())
 
     HAS_MITOGEN_MAIN = mitogen.core.b(
         textwrap.dedent("""
@@ -93,8 +93,10 @@ def test_self_contained_program(self):
         # Ensure a program composed of a single script can be imported
         # successfully.
         args = [sys.executable, testlib.data_path('self_contained_program.py')]
-        output = testlib.subprocess__check_output(args).decode()
-        self.assertEqual(output, "['__main__', 50]\n")
+        proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        b_stdout, _ = proc.communicate()
+        self.assertEqual(proc.returncode, 0)
+        self.assertEqual(b_stdout.decode(), "['__main__', 50]\n")
 
 
 class BrokenModulesTest(testlib.TestCase):
@@ -179,7 +181,7 @@ def test_forward_to_nonexistent_context(self):
             )
         )
         s = capture.stop()
-        self.assertTrue('dropping forward of' in s)
+        self.assertIn('dropping forward of', s)
 
     def test_stats(self):
         # Forwarding stats broken because forwarding is broken. See #469.
diff --git a/tests/router_test.py b/tests/router_test.py
index 656ca7347..276681540 100644
--- a/tests/router_test.py
+++ b/tests/router_test.py
@@ -71,7 +71,7 @@ def test_bad_auth_id(self):
 
         # Ensure error was logged.
         expect = 'bad auth_id: got %r via' % (self.child2_msg.auth_id,)
-        self.assertTrue(expect in log.stop())
+        self.assertIn(expect, log.stop())
 
     def test_parent_unaware_of_disconnect(self):
         # Parent -> Child A -> Child B. B disconnects concurrent to Parent
@@ -125,7 +125,7 @@ def test_bad_src_id(self):
 
         # Ensure error was lgoged.
         expect = 'bad src_id: got %d via' % (self.child1_msg.src_id,)
-        self.assertTrue(expect in log.stop())
+        self.assertIn(expect, log.stop())
 
 
 class PolicyTest(testlib.RouterMixin, testlib.TestCase):
@@ -165,7 +165,7 @@ def test_refuse_all(self):
         self.sync_with_broker()
 
         # Verify log.
-        self.assertTrue(self.router.refused_msg in log.stop())
+        self.assertIn(self.router.refused_msg, log.stop())
 
         # Verify message was not delivered.
         self.assertTrue(recv.empty())
@@ -202,7 +202,7 @@ def test_shutdown(self):
 
         # Ensure it was logged.
         expect = 'broker crashed'
-        self.assertTrue(expect in log.stop())
+        self.assertIn(expect, log.stop())
 
         self.broker.join()
 
@@ -264,7 +264,7 @@ def test_local_exceeded(self):
         router.broker.defer_sync(lambda: None)
 
         expect = 'message too large (max 4096 bytes)'
-        self.assertTrue(expect in logs.stop())
+        self.assertIn(expect, logs.stop())
 
     def test_local_dead_message(self):
         # Local router should generate dead message when reply_to is set.
@@ -282,7 +282,7 @@ def test_local_dead_message(self):
             lambda: child.call(zlib.crc32, ' '*8192))
         self.assertEqual(e.args[0], expect)
 
-        self.assertTrue(expect in logs.stop())
+        self.assertIn(expect, logs.stop())
 
     def test_remote_dead_message(self):
         # Router should send dead message to original recipient when reply_to
@@ -325,7 +325,7 @@ def test_remote_exceeded(self):
         remote.call(send_n_sized_reply, recv.to_sender(), 128*1024)
 
         expect = 'message too large (max %d bytes)' % (64*1024,)
-        self.assertTrue(expect in logs.stop())
+        self.assertIn(expect, logs.stop())
 
 
 class NoRouteTest(testlib.RouterMixin, testlib.TestCase):
diff --git a/tests/service_test.py b/tests/service_test.py
index 5d08bb474..d22558f10 100644
--- a/tests/service_test.py
+++ b/tests/service_test.py
@@ -83,14 +83,14 @@ def test_parent_can_activate(self):
         l1 = self.router.local()
         counter, id_ = l1.call_service(MyService, 'get_id')
         self.assertEqual(1, counter)
-        self.assertTrue(isinstance(id_, int))
+        self.assertIsInstance(id_, int)
 
     def test_sibling_cannot_activate_framework(self):
         l1 = self.router.local(name='l1')
         l2 = self.router.local(name='l2')
         exc = self.assertRaises(mitogen.core.CallError,
             lambda: l2.call(call_service_in, l1, MyService2.name(), 'get_id'))
-        self.assertTrue(mitogen.core.Router.refused_msg in exc.args[0])
+        self.assertIn(mitogen.core.Router.refused_msg, exc.args[0])
 
     def test_sibling_cannot_activate_service(self):
         l1 = self.router.local()
@@ -104,7 +104,7 @@ def test_sibling_cannot_activate_service(self):
         finally:
             capture.stop()
         msg = mitogen.service.Activator.not_active_msg % (MyService2.name(),)
-        self.assertTrue(msg in exc.args[0])
+        self.assertIn(msg, exc.args[0])
 
     def test_activates_only_once(self):
         l1 = self.router.local()
@@ -138,7 +138,7 @@ def test_sibling_privileged_bad(self):
             u'privileged_op',
             MyService.name(),
         )
-        self.assertTrue(msg in exc.args[0])
+        self.assertIn(msg, exc.args[0])
 
 
 class CloseTest(testlib.RouterMixin, testlib.TestCase):
diff --git a/tests/ssh_test.py b/tests/ssh_test.py
index c1a0b9eb2..429fdb577 100644
--- a/tests/ssh_test.py
+++ b/tests/ssh_test.py
@@ -52,7 +52,7 @@ def test_debug_decoding(self):
             s = capture.stop()
 
         expect = "%s: debug1: Reading configuration data" % (context.name,)
-        self.assertTrue(expect in s)
+        self.assertIn(expect, s)
 
     def test_bash_permission_denied(self):
         # issue #271: only match Permission Denied at start of line.
@@ -161,7 +161,7 @@ def test_accept_enforce_host_keys(self):
             fp.seek(0)
             # Lame test, but we're about to use enforce mode anyway, which
             # verifies the file contents.
-            self.assertTrue(len(fp.read()) > 0)
+            self.assertGreater(len(fp.read()), 0)
 
             context = self.docker_ssh(
                 username='mitogen__has_sudo',
diff --git a/tests/su_test.py b/tests/su_test.py
index d8952b53b..234c509bb 100644
--- a/tests/su_test.py
+++ b/tests/su_test.py
@@ -45,7 +45,7 @@ def test_password_required(self):
         e = self.assertRaises(mitogen.core.StreamError,
             lambda: self.router.su(via=ssh)
         )
-        self.assertTrue(mitogen.su.password_required_msg in str(e))
+        self.assertIn(mitogen.su.password_required_msg, str(e))
 
     def test_password_incorrect(self):
         ssh = self.docker_ssh(
@@ -55,7 +55,7 @@ def test_password_incorrect(self):
         e = self.assertRaises(mitogen.core.StreamError,
             lambda: self.router.su(via=ssh, password='x')
         )
-        self.assertTrue(mitogen.su.password_incorrect_msg in str(e))
+        self.assertIn(mitogen.su.password_incorrect_msg, str(e))
 
     def test_password_okay(self):
         ssh = self.docker_ssh(
diff --git a/tests/sudo_test.py b/tests/sudo_test.py
index 2df05ec74..a8ec1d056 100644
--- a/tests/sudo_test.py
+++ b/tests/sudo_test.py
@@ -73,7 +73,7 @@ def test_tty_preserved(self):
 #         e = self.assertRaises(mitogen.core.StreamError,
 #             lambda: self.router.sudo(via=ssh)
 #         )
-#         self.assertTrue(mitogen.sudo.password_required_msg in str(e))
+#         self.assertIn(mitogen.sudo.password_required_msg, str(e))
 
 #     def test_password_incorrect(self):
 #         ssh = self.docker_ssh(
@@ -85,7 +85,7 @@ def test_tty_preserved(self):
 #         e = self.assertRaises(mitogen.core.StreamError,
 #             lambda: self.router.sudo(via=ssh, password='x')
 #         )
-#         self.assertTrue(mitogen.sudo.password_incorrect_msg in str(e))
+#         self.assertIn(mitogen.sudo.password_incorrect_msg, str(e))
 
 #     def test_password_okay(self):
 #         ssh = self.docker_ssh(
@@ -97,4 +97,4 @@ def test_tty_preserved(self):
 #         e = self.assertRaises(mitogen.core.StreamError,
 #             lambda: self.router.sudo(via=ssh, password='rootpassword')
 #         )
-#         self.assertTrue(mitogen.sudo.password_incorrect_msg in str(e))
+#         self.assertIn(mitogen.sudo.password_incorrect_msg, str(e))
diff --git a/tests/testlib.py b/tests/testlib.py
index 8ab895c49..83629c3d0 100644
--- a/tests/testlib.py
+++ b/tests/testlib.py
@@ -1,18 +1,24 @@
 import errno
+import io
 import logging
 import os
 import random
 import re
-import signal
 import socket
-import subprocess
+import stat
 import sys
 import threading
 import time
 import traceback
 import unittest
 
+try:
+    import configparser
+except ImportError:
+    import ConfigParser as configparser
+
 import psutil
+import subprocess32 as subprocess
 
 import mitogen.core
 import mitogen.fork
@@ -63,36 +69,73 @@
 mitogen.core.LOG.propagate = True
 
 
-def data_path(suffix):
-    path = os.path.join(DATA_DIR, suffix)
-    if path.endswith('.key'):
-        # SSH is funny about private key permissions.
-        os.chmod(path, int('0600', 8))
-    return path
+def base_executable(executable=None):
+    '''Return the path of the Python executable used to create the virtualenv.
+    '''
+    # https://docs.python.org/3/library/venv.html
+    # https://github.com/pypa/virtualenv/blob/main/src/virtualenv/discovery/py_info.py
+    # https://virtualenv.pypa.io/en/16.7.9/reference.html#compatibility-with-the-stdlib-venv-module
+    if executable is None:
+        executable = sys.executable
 
+    if not executable:
+        raise ValueError
 
-def subprocess__check_output(*popenargs, **kwargs):
-    # Missing from 2.6.
-    process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs)
-    output, _ = process.communicate()
-    retcode = process.poll()
-    if retcode:
-        cmd = kwargs.get("args")
-        if cmd is None:
-            cmd = popenargs[0]
-        raise subprocess.CalledProcessError(retcode, cmd)
-    return output
+    try:
+        base_executable = sys._base_executable
+    except AttributeError:
+        base_executable = None
 
+    if base_executable and base_executable != executable:
+        return 'be', base_executable
 
-def Popen__terminate(proc):
-    os.kill(proc.pid, signal.SIGTERM)
+    # Python 2.x only has sys.base_prefix if running outside a virtualenv.
+    try:
+        sys.base_prefix
+    except AttributeError:
+        # Python 2.x outside a virtualenv
+        return executable
+
+    # Python 3.3+ has sys.base_prefix. In a virtualenv it differs to sys.prefix.
+    if sys.base_prefix == sys.prefix:
+        return executable
+
+    while executable.startswith(sys.prefix) and stat.S_ISLNK(os.lstat(executable).st_mode):
+        dirname = os.path.dirname(executable)
+        target = os.path.join(dirname, os.readlink(executable))
+        executable = os.path.abspath(os.path.normpath(target))
+        print(executable)
+
+    if executable.startswith(sys.base_prefix):
+        return executable
+
+    # Virtualenvs record details in pyvenv.cfg
+    parser = configparser.RawConfigParser()
+    with io.open(os.path.join(sys.prefix, 'pyvenv.cfg'), encoding='utf-8') as f:
+        content = u'[virtualenv]\n' + f.read()
+    try:
+        parser.read_string(content)
+    except AttributeError:
+        parser.readfp(io.StringIO(content))
 
+    # virtualenv style pyvenv.cfg includes the base executable.
+    # venv style pyvenv.cfg doesn't.
+    try:
+        return parser.get(u'virtualenv', u'base-executable')
+    except configparser.NoOptionError:
+        pass
+
+    basename = os.path.basename(executable)
+    home = parser.get(u'virtualenv', u'home')
+    return os.path.join(home, basename)
 
-if hasattr(subprocess, 'check_output'):
-    subprocess__check_output = subprocess.check_output
 
-if hasattr(subprocess.Popen, 'terminate'):
-    Popen__terminate = subprocess.Popen.terminate
+def data_path(suffix):
+    path = os.path.join(DATA_DIR, suffix)
+    if path.endswith('.key'):
+        # SSH is funny about private key permissions.
+        os.chmod(path, int('0600', 8))
+    return path
 
 
 def threading__thread_is_alive(thread):
@@ -457,7 +500,7 @@ def get_docker_host():
 
 class DockerizedSshDaemon(object):
     def _get_container_port(self):
-        s = subprocess__check_output(['docker', 'port', self.container_name])
+        s = subprocess.check_output(['docker', 'port', self.container_name])
         for line in s.decode().splitlines():
             m = self.PORT_RE.match(line)
             if not m:
@@ -472,7 +515,7 @@ def _get_container_port(self):
 
     def start_container(self):
         try:
-            subprocess__check_output(['docker', '--version'])
+            subprocess.check_output(['docker', '--version'])
         except Exception:
             raise unittest.SkipTest('Docker binary is unavailable')
 
@@ -486,7 +529,7 @@ def start_container(self):
             '--name', self.container_name,
             self.image,
         ]
-        subprocess__check_output(args)
+        subprocess.check_output(args)
         self._get_container_port()
 
     def __init__(self, mitogen_test_distro=os.environ.get('MITOGEN_TEST_DISTRO', 'debian9')):
@@ -518,7 +561,7 @@ def wait_for_sshd(self):
     def check_processes(self):
         args = ['docker', 'exec', self.container_name, 'ps', '-o', 'comm=']
         counts = {}
-        for comm in subprocess__check_output(args).decode().splitlines():
+        for comm in subprocess.check_output(args).decode().splitlines():
             comm = comm.strip()
             counts[comm] = counts.get(comm, 0) + 1
 
@@ -533,7 +576,7 @@ def check_processes(self):
 
     def close(self):
         args = ['docker', 'rm', '-f', self.container_name]
-        subprocess__check_output(args)
+        subprocess.check_output(args)
 
 
 class BrokerMixin(object):
diff --git a/tests/timer_test.py b/tests/timer_test.py
index ff30c905e..dac4d7df5 100644
--- a/tests/timer_test.py
+++ b/tests/timer_test.py
@@ -87,7 +87,7 @@ def test_same_moment(self):
         timer2 = self.list.schedule(31, lambda: None)
         self.assertEqual(31, timer.when)
         self.assertEqual(31, timer2.when)
-        self.assertTrue(timer is not timer2)
+        self.assertIsNot(timer, timer2)
         self.assertEqual(1, self.list.get_timeout())
 
 
diff --git a/tests/types_test.py b/tests/types_test.py
index 7c212b707..0229b94cc 100644
--- a/tests/types_test.py
+++ b/tests/types_test.py
@@ -90,8 +90,8 @@ def test_empty(self):
         self.assertEqual({}, kw)
         self.assertEqual('Kwargs({})', repr(kw))
         klass, (dct,) = kw.__reduce__()
-        self.assertTrue(klass is self.klass)
-        self.assertTrue(type(dct) is dict)
+        self.assertIs(klass, self.klass)
+        self.assertIs(type(dct), dict)
         self.assertEqual({}, dct)
 
     @unittest.skipIf(condition=(sys.version_info >= (2, 6)),
@@ -108,11 +108,11 @@ def test_unicode_conversion(self):
         self.assertEqual({u'key': 123}, kw)
         self.assertEqual("Kwargs({'key': 123})", repr(kw))
         klass, (dct,) = kw.__reduce__()
-        self.assertTrue(klass is self.klass)
-        self.assertTrue(type(dct) is dict)
+        self.assertIs(klass, self.klass)
+        self.assertIs(type(dct), dict)
         self.assertEqual({u'key': 123}, dct)
         key, = dct
-        self.assertTrue(type(key) is mitogen.core.UnicodeType)
+        self.assertIs(type(key), mitogen.core.UnicodeType)
 
 
 class AdornedUnicode(mitogen.core.UnicodeType):
diff --git a/tests/unix_test.py b/tests/unix_test.py
index aecc62ba2..14fc54ae0 100644
--- a/tests/unix_test.py
+++ b/tests/unix_test.py
@@ -130,7 +130,8 @@ def _test_simple_server(cls, path):
     def test_simple(self):
         path = mitogen.unix.make_socket_path()
         proc = subprocess.Popen(
-            [sys.executable, __file__, 'ClientTest_server', path]
+            [sys.executable, __file__, 'ClientTest_server', path],
+            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
         )
         try:
             self._test_simple_client(path)
@@ -139,7 +140,9 @@ def test_simple(self):
             mitogen.context_id = 0
             mitogen.parent_id = None
             mitogen.parent_ids = []
-        proc.wait()
+        b_stdout, _ = proc.communicate()
+        self.assertEqual(proc.returncode, 0)
+        self.assertEqual(b_stdout.decode(), '')
 
 
 if __name__ == '__main__':