Repository: twisted/twisted

Issue:
Failure: twisted.protocols.amp.UnknownRemoteError: Code<UNKNOWN>: Unknown Error
|[<img alt="exarkun's avatar" src="https://avatars.githubusercontent.com/u/254565?s=50" width="50" height="50">](https://github.com/exarkun)| @exarkun reported|
|-|-|
|Trac ID|trac#10314|
|Type|enhancement|
|Created|2022-03-08 21:28:12Z|

Sometimes an application-level test failure causes `trial -jN ...` to report this error:

```
Traceback (most recent call last):
Failure: twisted.protocols.amp.UnknownRemoteError: Code<UNKNOWN>: Unknown Error
[FAIL]
```

and then immediately exit with no further details.

The test which encountered the error and the details of the error are all lost, making it extremely difficult to diagnose and fix the problem.

`trial -jN ...` should:

* never fail this way
* always report detailed errors in application code


<details><summary>Searchable metadata</summary>

```
trac-id__10314 10314
type__enhancement enhancement
reporter__exarkun exarkun
priority__normal normal
milestone__None None
branch__ 
branch_author__ 
status__new new
resolution__None None
component__trial trial
keywords__None None
time__1646774892879232 1646774892879232
changetime__1646774892879232 1646774892879232
version__None None
owner__None None

```
</details>



Discussion:

Resolving patch:
diff --git a/src/twisted/trial/_dist/managercommands.py b/src/twisted/trial/_dist/managercommands.py
--- a/src/twisted/trial/_dist/managercommands.py
+++ b/src/twisted/trial/_dist/managercommands.py
@@ -7,7 +7,7 @@
 @since: 12.3
 """
 
-from twisted.protocols.amp import Boolean, Command, ListOf, Unicode
+from twisted.protocols.amp import Boolean, Command, Integer, Unicode
 
 NativeString = Unicode
 
@@ -28,9 +28,9 @@ class AddError(Command):
 
     arguments = [
         (b"testName", NativeString()),
-        (b"error", NativeString()),
         (b"errorClass", NativeString()),
-        (b"frames", ListOf(NativeString())),
+        (b"errorStreamId", Integer()),
+        (b"framesStreamId", Integer()),
     ]
     response = [(b"success", Boolean())]
 
@@ -42,9 +42,9 @@ class AddFailure(Command):
 
     arguments = [
         (b"testName", NativeString()),
-        (b"fail", NativeString()),
+        (b"failStreamId", Integer()),
         (b"failClass", NativeString()),
-        (b"frames", ListOf(NativeString())),
+        (b"framesStreamId", Integer()),
     ]
     response = [(b"success", Boolean())]
 
@@ -65,7 +65,7 @@ class AddExpectedFailure(Command):
 
     arguments = [
         (b"testName", NativeString()),
-        (b"error", NativeString()),
+        (b"errorStreamId", Integer()),
         (b"todo", NativeString()),
     ]
     response = [(b"success", Boolean())]
diff --git a/src/twisted/trial/_dist/worker.py b/src/twisted/trial/_dist/worker.py
--- a/src/twisted/trial/_dist/worker.py
+++ b/src/twisted/trial/_dist/worker.py
@@ -11,16 +11,18 @@
 
 import os
 from typing import Awaitable, Callable, Dict, List, Optional, TextIO, TypeVar
+from unittest import TestCase
 
 from zope.interface import implementer
 
 from attrs import frozen
-from typing_extensions import Protocol
+from typing_extensions import Protocol, TypedDict
 
-from twisted.internet.defer import Deferred
+from twisted.internet.defer import Deferred, DeferredList
 from twisted.internet.error import ProcessDone
 from twisted.internet.interfaces import IAddress, ITransport
 from twisted.internet.protocol import ProcessProtocol
+from twisted.logger import Logger
 from twisted.protocols.amp import AMP
 from twisted.python.failure import Failure
 from twisted.python.filepath import FilePath
@@ -34,7 +36,8 @@
 from twisted.trial._dist.workerreporter import WorkerReporter
 from twisted.trial.reporter import TestResult
 from twisted.trial.runner import TestLoader, TrialSuite
-from twisted.trial.unittest import TestCase, Todo
+from twisted.trial.unittest import Todo
+from .stream import StreamOpen, StreamReceiver, StreamWrite
 
 
 @frozen(auto_exc=False)
@@ -42,18 +45,26 @@ class WorkerException(Exception):
     """
     An exception was reported by a test running in a worker process.
 
-    :ivar message: An error message describing the exception.
+    @ivar message: An error message describing the exception.
     """
 
     message: str
 
 
+class RunResult(TypedDict):
+    """
+    Represent the result of a L{workercommands.Run} command.
+    """
+
+    success: bool
+
+
 class Worker(Protocol):
     """
     An object that can run actions.
     """
 
-    async def run(self, case: TestCase, result: TestResult) -> None:
+    async def run(self, case: TestCase, result: TestResult) -> RunResult:
         """
         Run a test case.
         """
@@ -68,20 +79,52 @@ class WorkerProtocol(AMP):
     The worker-side trial distributed protocol.
     """
 
+    logger = Logger()
+
     def __init__(self, forceGarbageCollection=False):
         self._loader = TestLoader()
         self._result = WorkerReporter(self)
         self._forceGarbageCollection = forceGarbageCollection
 
     @workercommands.Run.responder
-    def run(self, testCase):
+    async def run(self, testCase: str) -> RunResult:
         """
         Run a test case by name.
         """
-        case = self._loader.loadByName(testCase)
-        suite = TrialSuite([case], self._forceGarbageCollection)
-        suite.run(self._result)
-        return {"success": True}
+        with self._result.gatherReportingResults() as results:
+            case = self._loader.loadByName(testCase)
+            suite = TrialSuite([case], self._forceGarbageCollection)
+            suite.run(self._result)
+
+        allSucceeded = True
+        for (success, result) in await DeferredList(results, consumeErrors=True):
+            if success:
+                # Nothing to do here, proceed to the next result.
+                continue
+
+            # There was some error reporting a result to the peer.
+            allSucceeded = False
+
+            # We can try to report the error but since something has already
+            # gone wrong we shouldn't be extremely confident that this will
+            # succeed.  So we will also log it (and any errors reporting *it*)
+            # to our local log.
+            self.logger.failure(
+                "Result reporting for {id} failed",
+                failure=result,
+                id=testCase,
+            )
+            try:
+                await self._result.addErrorFallible(testCase, result)
+            except BaseException:
+                # We failed to report the failure to the peer.  It doesn't
+                # seem very likely that reporting this new failure to the peer
+                # will succeed so just log it locally.
+                self.logger.failure(
+                    "Additionally, reporting the reporting failure failed."
+                )
+
+        return {"success": allSucceeded}
 
     @workercommands.Start.responder
     def start(self, directory):
@@ -98,6 +141,19 @@ class LocalWorkerAMP(AMP):
     Local implementation of the manager commands.
     """
 
+    def __init__(self, boxReceiver=None, locator=None):
+        super().__init__(boxReceiver, locator)
+        self._streams = StreamReceiver()
+
+    @StreamOpen.responder
+    def streamOpen(self):
+        return {"streamId": self._streams.open()}
+
+    @StreamWrite.responder
+    def streamWrite(self, streamId, data):
+        self._streams.write(streamId, data)
+        return {}
+
     @managercommands.AddSuccess.responder
     def addSuccess(self, testName):
         """
@@ -137,15 +193,24 @@ def _buildFailure(
     def addError(
         self,
         testName: str,
-        error: str,
         errorClass: str,
-        frames: List[str],
+        errorStreamId: int,
+        framesStreamId: int,
     ) -> Dict[str, bool]:
         """
         Add an error to the reporter.
 
-        :param error: A message describing the error.
+        @param errorStreamId: The identifier of a stream over which the text
+            of this error was previously completely sent to the peer.
+
+        @param framesStreamId: The identifier of a stream over which the lines
+            of the traceback for this error were previously completely sent to
+            the peer.
+
+        @param error: A message describing the error.
         """
+        error = "".join(self._streams.finish(errorStreamId))
+        frames = self._streams.finish(framesStreamId)
         # Wrap the error message in ``WorkerException`` because it is not
         # possible to transfer arbitrary exception values over the AMP
         # connection to the main process but we must give *some* Exception
@@ -158,13 +223,22 @@ def addError(
     def addFailure(
         self,
         testName: str,
-        fail: str,
+        failStreamId: int,
         failClass: str,
-        frames: List[str],
+        framesStreamId: int,
     ) -> Dict[str, bool]:
         """
         Add a failure to the reporter.
+
+        @param failStreamId: The identifier of a stream over which the text of
+            this failure was previously completely sent to the peer.
+
+        @param framesStreamId: The identifier of a stream over which the lines
+            of the traceback for this error were previously completely sent to the
+            peer.
         """
+        fail = "".join(self._streams.finish(failStreamId))
+        frames = self._streams.finish(framesStreamId)
         # See addError for info about use of WorkerException here.
         failure = self._buildFailure(WorkerException(fail), failClass, frames)
         self._result.addFailure(self._testCase, failure)
@@ -180,11 +254,15 @@ def addSkip(self, testName, reason):
 
     @managercommands.AddExpectedFailure.responder
     def addExpectedFailure(
-        self, testName: str, error: str, todo: Optional[None]
+        self, testName: str, errorStreamId: int, todo: Optional[str]
     ) -> Dict[str, bool]:
         """
         Add an expected failure to the reporter.
+
+        @param errorStreamId: The identifier of a stream over which the text
+            of this error was previously completely sent to the peer.
         """
+        error = "".join(self._streams.finish(errorStreamId))
         _todo = Todo("<unknown>" if todo is None else todo)
         self._result.addExpectedFailure(self._testCase, error, _todo)
         return {"success": True}
@@ -206,14 +284,7 @@ def testWrite(self, out):
         self._testStream.flush()
         return {"success": True}
 
-    def _stopTest(self, result):
-        """
-        Stop the current running test case, forwarding the result.
-        """
-        self._result.stopTest(self._testCase)
-        return result
-
-    def run(self, testCase, result):
+    async def run(self, testCase: TestCase, result: TestResult) -> RunResult:
         """
         Run a test.
         """
@@ -221,8 +292,10 @@ def run(self, testCase, result):
         self._result = result
         self._result.startTest(testCase)
         testCaseId = testCase.id()
-        d = self.callRemote(workercommands.Run, testCase=testCaseId)
-        return d.addCallback(self._stopTest)
+        try:
+            return await self.callRemote(workercommands.Run, testCase=testCaseId)  # type: ignore[no-any-return]
+        finally:
+            self._result.stopTest(testCase)
 
     def setTestStream(self, stream):
         """
diff --git a/src/twisted/trial/_dist/workerreporter.py b/src/twisted/trial/_dist/workerreporter.py
--- a/src/twisted/trial/_dist/workerreporter.py
+++ b/src/twisted/trial/_dist/workerreporter.py
@@ -9,12 +9,155 @@
 @since: 12.3
 """
 
-from typing import List
+from types import TracebackType
+from typing import Callable, List, Optional, Sequence, Tuple, Type, TypeVar, Union
+from unittest import TestCase as PyUnitTestCase
 
+from attrs import Factory, define
+from typing_extensions import Literal, TypeAlias
+
+from twisted.internet.defer import Deferred, maybeDeferred
+from twisted.protocols.amp import AMP
 from twisted.python.failure import Failure
 from twisted.python.reflect import qual
 from twisted.trial._dist import managercommands
 from twisted.trial.reporter import TestResult
+from .stream import chunk, stream
+
+T = TypeVar("T")
+ExcInfo: TypeAlias = Tuple[Type[BaseException], BaseException, TracebackType]
+XUnitFailure = Union[ExcInfo, Tuple[None, None, None]]
+TrialFailure = Union[XUnitFailure, Failure]
+
+
+async def addError(
+    amp: AMP, testName: str, errorClass: str, error: str, frames: List[str]
+) -> None:
+    """
+    Send an error to the worker manager over an AMP connection.
+
+    First the pieces which can be large are streamed over the connection.
+    Then, L{managercommands.AddError} is called with the rest of the
+    information and the stream IDs.
+
+    :param amp: The connection to use.
+    :param testName: The name (or ID) of the test the error relates to.
+    :param errorClass: The fully qualified name of the error type.
+    :param error: The string representation of the error.
+    :param frames: The lines of the traceback associated with the error.
+    """
+
+    errorStreamId = await stream(amp, chunk(error, 2 ** 16 - 1))
+    framesStreamId = await stream(amp, iter(frames))
+
+    await amp.callRemote(
+        managercommands.AddError,
+        testName=testName,
+        errorClass=errorClass,
+        errorStreamId=errorStreamId,
+        framesStreamId=framesStreamId,
+    )
+
+
+async def addFailure(
+    amp: AMP, testName: str, fail: str, failClass: str, frames: List[str]
+) -> None:
+    """
+    Like L{addError} but for failures.
+
+    :param amp: See L{addError}
+    :param testName: See L{addError}
+    :param failClass: The fully qualified name of the exception associated
+        with the failure.
+    :param fail: The string representation of the failure.
+    :param frames: The lines of the traceback associated with the error.
+    """
+    failStreamId = await stream(amp, chunk(fail, 2 ** 16 - 1))
+    framesStreamId = await stream(amp, iter(frames))
+
+    await amp.callRemote(
+        managercommands.AddFailure,
+        testName=testName,
+        failClass=failClass,
+        failStreamId=failStreamId,
+        framesStreamId=framesStreamId,
+    )
+
+
+async def addExpectedFailure(amp: AMP, testName: str, error: str, todo: str) -> None:
+    """
+    Like L{addError} but for expected failures.
+
+    :param amp: See L{addError}
+    :param testName: See L{addError}
+    :param error: The string representation of the expected failure.
+    :param todo: The string description of the expectation.
+    """
+    errorStreamId = await stream(amp, chunk(error, 2 ** 16 - 1))
+
+    await amp.callRemote(
+        managercommands.AddExpectedFailure,
+        testName=testName,
+        errorStreamId=errorStreamId,
+        todo=todo,
+    )
+
+
+@define
+class ReportingResults:
+    """
+    A mutable container for the result of sending test results back to the
+    parent process.
+
+    Since it is possible for these sends to fail asynchronously but the
+    L{TestResult} protocol is not well suited for asynchronous result
+    reporting, results are collected on an instance of this class and when the
+    runner believes the test is otherwise complete, it can collect the results
+    and do something with any errors.
+
+    :ivar _reporter: The L{WorkerReporter} this object is associated with.
+        This is the object doing the result reporting.
+
+    :ivar _results: A list of L{Deferred} instances representing the results
+        of reporting operations.  This is expected to grow over the course of
+        the test run and then be inspected by the runner once the test is
+        over.  The public interface to this list is via the context manager
+        interface.
+    """
+
+    _reporter: "WorkerReporter"
+    _results: List[Deferred[object]] = Factory(list)
+
+    def __enter__(self) -> Sequence[Deferred[object]]:
+        """
+        Begin a new reportable context in which results can be collected.
+
+        :return: A sequence which will contain the L{Deferred} instances
+            representing the results of all test result reporting that happens
+            while the context manager is active.  The sequence is extended as
+            the test runs so its value should not be consumed until the test
+            is over.
+        """
+        return self._results
+
+    def __exit__(
+        self,
+        excType: Type[BaseException],
+        excValue: BaseException,
+        excTraceback: TracebackType,
+    ) -> Literal[False]:
+        """
+        End the reportable context.
+        """
+        self._reporter._reporting = None
+        return False
+
+    def record(self, result: Deferred[object]) -> None:
+        """
+        Record a L{Deferred} instance representing one test result reporting
+        operation.
+        """
+        self._results.append(result)
 
 
 class WorkerReporter(TestResult):
@@ -24,20 +167,36 @@ class WorkerReporter(TestResult):
 
     @ivar _DEFAULT_TODO: Default message for expected failures and
         unexpected successes, used only if a C{Todo} is not provided.
+
+    @ivar _reporting: When a "result reporting" context is active, the
+        corresponding context manager.  Otherwise, L{None}.
     """
 
     _DEFAULT_TODO = "Test expected to fail"
 
+    ampProtocol: AMP
+    _reporting: Optional[ReportingResults] = None
+
     def __init__(self, ampProtocol):
         """
         @param ampProtocol: The communication channel with the trial
             distributed manager which collects all test results.
-        @type ampProtocol: C{AMP}
         """
         super().__init__()
         self.ampProtocol = ampProtocol
 
-    def _getFailure(self, error):
+    def gatherReportingResults(self) -> ReportingResults:
+        """
+        Get a "result reporting" context manager.
+
+        In a "result reporting" context, asynchronous test result reporting
+        methods may be used safely.  Their results (in particular, failures)
+        are available from the context manager.
+        """
+        self._reporting = ReportingResults(self)
+        return self._reporting
+
+    def _getFailure(self, error: TrialFailure) -> Failure:
         """
         Convert a C{sys.exc_info()}-style tuple to a L{Failure}, if necessary.
         """
@@ -56,48 +215,85 @@ def _getFrames(self, failure: Failure) -> List[str]:
             frames.extend([frame[0], frame[1], str(frame[2])])
         return frames
 
-    def addSuccess(self, test):
+    def _call(self, f: Callable[[], T]) -> None:
         """
-        Send a success over.
+        Call L{f} if and only if a "result reporting" context is active.
+
+        @param f: A function to call.  Its result is accumulated into the
+            result reporting context.  It may return a L{Deferred} or a
+            coroutine or synchronously raise an exception or return a result
+            value.
+
+        @raise ValueError: If no result reporting context is active.
+        """
+        if self._reporting is not None:
+            self._reporting.record(maybeDeferred(f))
+        else:
+            raise ValueError(
+                "Cannot call command outside of reporting context manager."
+            )
+
+    def addSuccess(self, test: PyUnitTestCase) -> None:
+        """
+        Send a success to the parent process.
+
+        This must be called in context managed by L{gatherReportingResults}.
         """
         super().addSuccess(test)
         testName = test.id()
-        self.ampProtocol.callRemote(managercommands.AddSuccess, testName=testName)
+        self._call(
+            lambda: self.ampProtocol.callRemote(  # type: ignore[no-any-return]
+                managercommands.AddSuccess, testName=testName
+            )
+        )
 
-    def addError(self, test, error):
+    async def addErrorFallible(self, testName: str, errorObj: TrialFailure) -> None:
         """
-        Send an error over.
+        Attempt to report an error to the parent process.
+
+        Unlike L{addError} this can fail asynchronously.  This version is for
+        infrastructure code that can apply its own failure handling.
+
+        @return: A L{Deferred} that fires with the result of the attempt.
         """
-        super().addError(test, error)
-        testName = test.id()
-        failure = self._getFailure(error)
-        error = failure.getErrorMessage()
+        failure = self._getFailure(errorObj)
+        errorStr = failure.getErrorMessage()
         errorClass = qual(failure.type)
         frames = self._getFrames(failure)
-        self.ampProtocol.callRemote(
-            managercommands.AddError,
-            testName=testName,
-            error=error,
-            errorClass=errorClass,
-            frames=frames,
+        await addError(
+            self.ampProtocol,
+            testName,
+            errorClass,
+            errorStr,
+            frames,
         )
 
-    def addFailure(self, test, fail):
+    def addError(self, test: PyUnitTestCase, error: TrialFailure) -> None:
+        """
+        Send an error to the parent process.
+        """
+        super().addError(test, error)
+        testName = test.id()
+        self._call(lambda: self.addErrorFallible(testName, error))
+
+    def addFailure(self, test: PyUnitTestCase, fail: TrialFailure) -> None:
         """
         Send a Failure over.
         """
         super().addFailure(test, fail)
         testName = test.id()
         failure = self._getFailure(fail)
-        fail = failure.getErrorMessage()
+        failureMessage = failure.getErrorMessage()
         failClass = qual(failure.type)
         frames = self._getFrames(failure)
-        self.ampProtocol.callRemote(
-            managercommands.AddFailure,
-            testName=testName,
-            fail=fail,
-            failClass=failClass,
-            frames=frames,
+        self._call(
+            lambda: addFailure(
+                self.ampProtocol,
+                testName,
+                failureMessage,
+                failClass,
+                frames,
+            ),
         )
 
     def addSkip(self, test, reason):
@@ -107,8 +303,10 @@ def addSkip(self, test, reason):
         super().addSkip(test, reason)
         reason = str(reason)
         testName = test.id()
-        self.ampProtocol.callRemote(
-            managercommands.AddSkip, testName=testName, reason=reason
+        self._call(
+            lambda: self.ampProtocol.callRemote(
+                managercommands.AddSkip, testName=testName, reason=reason
+            )
         )
 
     def _getTodoReason(self, todo):
@@ -129,11 +327,13 @@ def addExpectedFailure(self, test, error, todo=None):
         super().addExpectedFailure(test, error, todo)
         errorMessage = error.getErrorMessage()
         testName = test.id()
-        self.ampProtocol.callRemote(
-            managercommands.AddExpectedFailure,
-            testName=testName,
-            error=errorMessage,
-            todo=self._getTodoReason(todo),
+        self._call(
+            lambda: addExpectedFailure(
+                self.ampProtocol,
+                testName=testName,
+                error=errorMessage,
+                todo=self._getTodoReason(todo),
+            )
         )
 
     def addUnexpectedSuccess(self, test, todo=None):
@@ -142,10 +342,12 @@ def addUnexpectedSuccess(self, test, todo=None):
         """
         super().addUnexpectedSuccess(test, todo)
         testName = test.id()
-        self.ampProtocol.callRemote(
-            managercommands.AddUnexpectedSuccess,
-            testName=testName,
-            todo=self._getTodoReason(todo),
+        self._call(
+            lambda: self.ampProtocol.callRemote(
+                managercommands.AddUnexpectedSuccess,
+                testName=testName,
+                todo=self._getTodoReason(todo),
+            )
         )
 
     def printSummary(self):


Test patch:
diff --git a/src/twisted/trial/_dist/test/test_disttrial.py b/src/twisted/trial/_dist/test/test_disttrial.py
--- a/src/twisted/trial/_dist/test/test_disttrial.py
+++ b/src/twisted/trial/_dist/test/test_disttrial.py
@@ -11,6 +11,7 @@
 from io import StringIO
 from os.path import sep
 from typing import Callable, List, Set
+from unittest import TestCase as PyUnitTestCase
 
 from zope.interface import implementer, verify
 
@@ -48,7 +49,7 @@
     iterateWhile,
     sequence,
 )
-from twisted.trial._dist.worker import LocalWorker, Worker, WorkerAction
+from twisted.trial._dist.worker import LocalWorker, RunResult, Worker, WorkerAction
 from twisted.trial.reporter import (
     Reporter,
     TestResult,
@@ -770,11 +771,12 @@ class _LocalWorker:
     somewhere else..
     """
 
-    async def run(self, case: TestCase, result: TestResult) -> None:
+    async def run(self, case: PyUnitTestCase, result: TestResult) -> RunResult:
         """
         Directly run C{case} in the usual way.
         """
         TrialSuite([case]).run(result)
+        return {"success": True}
 
 
 class WorkerBroken(Exception):
@@ -788,7 +790,7 @@ class _BrokenLocalWorker:
     A L{Worker} that always fails to run test cases.
     """
 
-    async def run(self, case: TestCase, result: TestResult) -> None:
+    async def run(self, case: PyUnitTestCase, result: TestResult) -> None:
         """
         Raise an exception instead of running C{case}.
         """
diff --git a/src/twisted/trial/_dist/test/test_worker.py b/src/twisted/trial/_dist/test/test_worker.py
--- a/src/twisted/trial/_dist/test/test_worker.py
+++ b/src/twisted/trial/_dist/test/test_worker.py
@@ -14,8 +14,8 @@
 
 from hamcrest import assert_that, equal_to, has_item, has_length
 
-from twisted.internet.defer import fail
-from twisted.internet.error import ProcessDone
+from twisted.internet.defer import Deferred, fail
+from twisted.internet.error import ConnectionLost, ProcessDone
 from twisted.internet.interfaces import IAddress, ITransport
 from twisted.python.failure import Failure
 from twisted.python.filepath import FilePath
@@ -57,7 +57,9 @@ def test_run(self) -> None:
         Sending the L{workercommands.Run} command to the worker returns a
         response with C{success} sets to C{True}.
         """
-        d = self.server.run(pyunitcases.PyUnitTest("test_pass"), TestResult())
+        d = Deferred.fromCoroutine(
+            self.server.run(pyunitcases.PyUnitTest("test_pass"), TestResult())
+        )
         self.flush()
         self.assertEqual({"success": True}, self.successResultOf(d))
 
@@ -71,6 +73,102 @@ def test_start(self) -> None:
         self.assertNotEqual(os.path.realpath(os.path.curdir), curdir)
 
 
+class WorkerProtocolErrorTests(TestCase):
+    """
+    Tests for L{WorkerProtocol}'s handling of certain errors related to
+    running the tests themselves (i.e., not test errors but test
+    infrastructure/runner errors).
+    """
+
+    def _runErrorTest(
+        self, brokenTestName: str, loggedExceptionType: Type[BaseException]
+    ) -> None:
+        worker, server, pump = connectedServerAndClient(
+            LocalWorkerAMP, WorkerProtocol, greet=False
+        )
+        expectedCase = pyunitcases.BrokenRunInfrastructure(brokenTestName)
+        result = TestResult()
+        Deferred.fromCoroutine(server.run(expectedCase, result))
+        pump.flush()
+        assert_that(result, matches_result(errors=has_length(1)))
+        [(actualCase, errors)] = result.errors
+        assert_that(actualCase, equal_to(expectedCase))
+
+        # Additionally, we expect that the worker protocol logged the failure
+        # once so that it is visible somewhere, even if it cannot deliver it
+        # back to the parent process (which it can in this case).  Since the
+        # worker runs in process with us, that failure is in our log so we can
+        # easily make an assertion about it.  Also, if we don't flush it, the
+        # test fails.  As far as the type goes, we just have to be aware of
+        # the implementation details of `BrokenRunInfrastructure`.
+        assert_that(self.flushLoggedErrors(loggedExceptionType), has_length(1))
+
+    def test_addSuccessError(self) -> None:
+        """
+        If there is an error reporting success then the test run is marked as
+        an error.
+        """
+        self._runErrorTest("test_addSuccess", AttributeError)
+
+    def test_addErrorError(self) -> None:
+        """
+        If there is an error reporting an error then the test run is marked as
+        an error.
+        """
+        self._runErrorTest("test_addError", AttributeError)
+
+    def test_addFailureError(self) -> None:
+        """
+        If there is an error reporting a failure then the test run is marked
+        as an error.
+        """
+        self._runErrorTest("test_addFailure", AttributeError)
+
+    def test_addSkipError(self) -> None:
+        """
+        If there is an error reporting a skip then the test run is marked
+        as an error.
+        """
+        self._runErrorTest("test_addSkip", AttributeError)
+
+    def test_addExpectedFailure(self) -> None:
+        """
+        If there is an error reporting an expected failure then the test
+        run is marked as an error.
+        """
+        self._runErrorTest("test_addExpectedFailure", AttributeError)
+
+    def test_addUnexpectedSuccess(self) -> None:
+        """
+        If there is an error reporting an unexpected ccess then the test
+        run is marked as an error.
+        """
+        self._runErrorTest("test_addUnexpectedSuccess", AttributeError)
+
+    def test_failedFailureReport(self) -> None:
+        """
+        A failure encountered while reporting a reporting failure is logged.
+        """
+        worker, server, pump = connectedServerAndClient(
+            LocalWorkerAMP, WorkerProtocol, greet=False
+        )
+
+        # We can easily break everything by eliminating the worker protocol's
+        # transport.  This prevents it from ever sending anything to the
+        # manager protocol.
+        worker.transport = None
+
+        expectedCase = pyunitcases.PyUnitTest("test_pass")
+        result = TestResult()
+        Deferred.fromCoroutine(server.run(expectedCase, result))
+        pump.flush()
+
+        # There should be two exceptions logged here.  The first is from the
+        # attempt to report the success result.  The second is a report that
+        # the first failed.
+        assert_that(self.flushLoggedErrors(ConnectionLost), has_length(2))
+
+
 class LocalWorkerAMPTests(TestCase):
     """
     Test case for distributed trial's manager-side local worker AMP protocol
@@ -86,7 +184,7 @@ def workerRunTest(
         self, testCase: PyUnitTestCase, makeResult: Type[TestResult] = TestResult
     ) -> TestResult:
         result = makeResult()
-        d = self.managerAMP.run(testCase, result)
+        d = Deferred.fromCoroutine(self.managerAMP.run(testCase, result))
         self.flush()
         self.assertEqual({"success": True}, self.successResultOf(d))
         return result
diff --git a/src/twisted/trial/_dist/test/test_workerreporter.py b/src/twisted/trial/_dist/test/test_workerreporter.py
--- a/src/twisted/trial/_dist/test/test_workerreporter.py
+++ b/src/twisted/trial/_dist/test/test_workerreporter.py
@@ -11,6 +11,7 @@
 from hamcrest import assert_that, equal_to, has_length
 from hamcrest.core.matcher import Matcher
 
+from twisted.internet.defer import Deferred
 from twisted.test.iosim import connectedServerAndClient
 from twisted.trial._dist.worker import LocalWorkerAMP, WorkerProtocol
 from twisted.trial.reporter import TestResult
@@ -27,9 +28,8 @@ def run(case: SynchronousTestCase, target: TestCase) -> TestResult:
     """
     result = TestResult()
     worker, local, pump = connectedServerAndClient(LocalWorkerAMP, WorkerProtocol)
-    d = local.run(target, result)
-    pump.pump()
-    pump.pump()
+    d = Deferred.fromCoroutine(local.run(target, result))
+    pump.flush()
     assert_that(case.successResultOf(d), equal_to({"success": True}))
     return result
 
@@ -46,6 +46,18 @@ def assertTestRun(self, target: TestCase, **expectations: Matcher) -> None:
         """
         assert_that(run(self, target), matches_result(**expectations))
 
+    def test_outsideReportingContext(self) -> None:
+        """
+        L{WorkerReporter}'s implementation of test result methods raise
+        L{ValueError} when called outside of the
+        L{WorkerReporter.gatherReportingResults} context manager.
+        """
+        worker, local, pump = connectedServerAndClient(LocalWorkerAMP, WorkerProtocol)
+
+        case = sample.FooTest("test_foo")
+        with self.assertRaises(ValueError):
+            worker._result.addSuccess(case)
+
     def test_addSuccess(self) -> None:
         """
         L{WorkerReporter} propagates successes.
@@ -60,6 +72,15 @@ def test_addError(self) -> None:
             erroneous.TestAsynchronousFail("test_exception"), errors=has_length(1)
         )
 
+    def test_addErrorGreaterThan64k(self) -> None:
+        """
+        L{WorkerReporter} propagates errors with large string representations.
+        """
+        self.assertTestRun(
+            erroneous.TestAsynchronousFail("test_exceptionGreaterThan64k"),
+            errors=has_length(1),
+        )
+
     def test_addErrorTuple(self) -> None:
         """
         L{WorkerReporter} propagates errors from pyunit's TestCases.
@@ -74,6 +95,15 @@ def test_addFailure(self) -> None:
             erroneous.TestRegularFail("test_fail"), failures=has_length(1)
         )
 
+    def test_addFailureGreaterThan64k(self) -> None:
+        """
+        L{WorkerReporter} propagates test failures with large string representations.
+        """
+        self.assertTestRun(
+            erroneous.TestAsynchronousFail("test_failGreaterThan64k"),
+            failures=has_length(1),
+        )
+
     def test_addFailureTuple(self) -> None:
         """
         L{WorkerReporter} propagates test failures from pyunit's TestCases.
@@ -105,6 +135,15 @@ def test_addExpectedFailure(self) -> None:
             skipping.SynchronousStrictTodo("test_todo1"), expectedFailures=has_length(1)
         )
 
+    def test_addExpectedFailureGreaterThan64k(self) -> None:
+        """
+        WorkerReporter propagates expected failures with large string representations.
+        """
+        self.assertTestRun(
+            skipping.ExpectedFailure("test_expectedFailureGreaterThan64k"),
+            expectedFailures=has_length(1),
+        )
+
     def test_addUnexpectedSuccess(self) -> None:
         """
         L{WorkerReporter} propagates unexpected successes.
diff --git a/src/twisted/trial/_dist/test/test_workertrial.py b/src/twisted/trial/_dist/test/test_workertrial.py
--- a/src/twisted/trial/_dist/test/test_workertrial.py
+++ b/src/twisted/trial/_dist/test/test_workertrial.py
@@ -101,7 +101,9 @@ def test_forwardCommand(self):
         self.readStream = clientTransport.io
         self.readStream.seek(0, 0)
         main(self.fdopen)
-        self.assertIn(b"No module named 'doesntexist'", self.writeStream.getvalue())
+        # Just brazenly encode irrelevant implementation details here, why
+        # not.
+        self.assertIn(b"StreamOpen", self.writeStream.getvalue())
 
     def test_readInterrupted(self):
         """
diff --git a/src/twisted/trial/test/erroneous.py b/src/twisted/trial/test/erroneous.py
--- a/src/twisted/trial/test/erroneous.py
+++ b/src/twisted/trial/test/erroneous.py
@@ -8,6 +8,8 @@
 
 See the L{twisted.trial.test.test_tests} module docstring for details about how
 this code is arranged.
+
+Some of these tests are also used by L{twisted.trial._dist.test}.
 """
 
 
@@ -22,6 +24,21 @@ class FoolishError(Exception):
     pass
 
 
+class LargeError(Exception):
+    """
+    An exception which has a string representation of at least a specified
+    number of characters.
+    """
+
+    def __init__(self, minSize: int) -> None:
+        Exception.__init__(self)
+        self.minSize = minSize
+
+    def __str__(self):
+        large = "x" * self.minSize
+        return f"LargeError<I fail: {large}>"
+
+
 class FailureInSetUpMixin:
     def setUp(self):
         raise FoolishError("I am a broken setUp method")
@@ -109,12 +126,26 @@ def test_fail(self) -> defer.Deferred[None]:
         """
         return deferLater(reactor, 0, self.fail, "I fail later")  # type: ignore[arg-type]
 
-    def test_exception(self):
+    def test_failGreaterThan64k(self) -> defer.Deferred[None]:
+        """
+        A test which fails in the callback of the returned L{defer.Deferred}
+        with a very long string.
+        """
+        return deferLater(reactor, 0, self.fail, "I fail later: " + "x" * 2 ** 16)  # type: ignore[arg-type]
+
+    def test_exception(self) -> None:
         """
         A test which raises an exception synchronously.
         """
         raise Exception(self.text)
 
+    def test_exceptionGreaterThan64k(self) -> None:
+        """
+        A test which raises an exception with a long string representation
+        synchronously.
+        """
+        raise LargeError(2 ** 16)
+
 
 class ErrorTest(unittest.SynchronousTestCase):
     """
diff --git a/src/twisted/trial/test/pyunitcases.py b/src/twisted/trial/test/pyunitcases.py
--- a/src/twisted/trial/test/pyunitcases.py
+++ b/src/twisted/trial/test/pyunitcases.py
@@ -8,6 +8,9 @@ class which are used as data by test cases which are actually part of the
 """
 
 import unittest
+from sys import exc_info
+
+from twisted.python.failure import Failure
 
 
 class PyUnitTest(unittest.TestCase):
@@ -33,3 +36,80 @@ def test_skip(self):
         """
         A test which uses the L{unittest.skip} decorator to cause a skip.
         """
+
+
+class _NonStringId:
+    """
+    A class that looks a little like a TestCase, but not enough so to
+    actually be used as one.  This helps L{BrokenRunInfrastructure} use some
+    interfaces incorrectly to provoke certain failure conditions.
+    """
+
+    def id(self) -> object:
+        return object()
+
+
+class BrokenRunInfrastructure(unittest.TestCase):
+    """
+    A test suite that is broken at the level of integration between
+    L{TestCase.run} and the results object.
+    """
+
+    def run(self, result):
+        """
+        Override the normal C{run} behavior to pass the result object
+        along to the test method.  Each test method needs the result object so
+        that it can implement its particular kind of brokenness.
+        """
+        return getattr(self, self._testMethodName)(result)
+
+    def test_addSuccess(self, result):
+        """
+        Violate the L{TestResult.addSuccess} interface.
+        """
+
+        result.addSuccess(_NonStringId())
+
+    def test_addError(self, result):
+        """
+        Violate the L{TestResult.addError} interface.
+        """
+        try:
+            raise Exception("test_addError")
+        except BaseException:
+            err = exc_info()
+
+        result.addError(_NonStringId(), err)
+
+    def test_addFailure(self, result):
+        """
+        Violate the L{TestResult.addFailure} interface.
+        """
+        try:
+            raise Exception("test_addFailure")
+        except BaseException:
+            err = exc_info()
+
+        result.addFailure(_NonStringId(), err)
+
+    def test_addSkip(self, result):
+        """
+        Violate the L{TestResult.addSkip} interface.
+        """
+        result.addSkip(_NonStringId(), "test_addSkip")
+
+    def test_addExpectedFailure(self, result):
+        """
+        Violate the L{TestResult.addExpectedFailure} interface.
+        """
+        try:
+            raise Exception("test_addExpectedFailure")
+        except BaseException:
+            err = Failure()
+        result.addExpectedFailure(_NonStringId(), err)
+
+    def test_addUnexpectedSuccess(self, result):
+        """
+        Violate the L{TestResult.addUnexpectedSuccess} interface.
+        """
+        result.addUnexpectedSuccess(_NonStringId())
diff --git a/src/twisted/trial/test/skipping.py b/src/twisted/trial/test/skipping.py
--- a/src/twisted/trial/test/skipping.py
+++ b/src/twisted/trial/test/skipping.py
@@ -263,3 +263,18 @@ class SynchronousAddCleanup(AddCleanupMixin, SynchronousTestCase):
 
 class AsynchronousAddCleanup(AddCleanupMixin, TestCase):
     pass
+
+
+class ExpectedFailure(SynchronousTestCase):
+    """
+    Hold a test that has an expected failure with an exception that has a
+    large string representation.
+    """
+
+    def test_expectedFailureGreaterThan64k(self) -> None:
+        """
+        Fail, but expectedly.
+        """
+        raise RuntimeError("x" * (2 ** 16 + 1))
+
+    test_expectedFailureGreaterThan64k.todo = "short todo string"  # type: ignore[attr-defined]