diff --git a/changelog/1239.bugfix.rst b/changelog/1239.bugfix.rst new file mode 100644 index 00000000..7a7a7e3c --- /dev/null +++ b/changelog/1239.bugfix.rst @@ -0,0 +1 @@ +Handle ``pytest.exit()`` raised in a worker without reporting it as an internal error. diff --git a/src/xdist/dsession.py b/src/xdist/dsession.py index 41e62d46..d8e9fd7c 100644 --- a/src/xdist/dsession.py +++ b/src/xdist/dsession.py @@ -59,6 +59,7 @@ def __init__(self, config: pytest.Config) -> None: self._active_nodes: set[WorkerController] = set() self._failed_nodes_count = 0 self._max_worker_restart = get_default_max_worker_restart(self.config) + self._worker_exit: tuple[str, int | None] | None = None # summary message to print at the end of the session self._summary_report: str | None = None self.terminal = config.pluginmanager.getplugin("terminalreporter") @@ -140,12 +141,16 @@ def pytest_runtestloop(self) -> bool: assert self.sched is not None self.shouldstop = False - pending_exception = None + pending_exception: BaseException | None = None while not self.session_finished: self.loop_once() if self.shouldstop: self.triggershutdown() pending_exception = Interrupted(str(self.shouldstop)) + if self._worker_exit is not None: + reason, returncode = self._worker_exit + self.triggershutdown() + pending_exception = pytest.exit.Exception(reason, returncode) if pending_exception: raise pending_exception return True @@ -206,6 +211,17 @@ def worker_workerfinished(self, node: WorkerController) -> None: workerready before shutdown was triggered. """ self.config.hook.pytest_testnodedown(node=node, error=None) + if "exitreason" in node.workeroutput: + assert self.sched is not None + if node in self.sched.nodes: + self.sched.remove_node(node) + self._worker_exit = ( + node.workeroutput["exitreason"], + node.workeroutput["exitreturncode"], + ) + self._active_nodes.remove(node) + return + if node.workeroutput["exitstatus"] == 2: # keyboard-interrupt self.shouldstop = f"{node} received keyboard-interrupt" self.worker_errordown(node, "keyboard-interrupt") diff --git a/src/xdist/remote.py b/src/xdist/remote.py index 409b90b0..34e75ab5 100644 --- a/src/xdist/remote.py +++ b/src/xdist/remote.py @@ -134,6 +134,15 @@ def pytest_internalerror(self, excrepr: object) -> None: self.log("IERROR>", line) interactor.sendevent("internal_error", formatted_error=formatted_error) + @pytest.hookimpl + def pytest_keyboard_interrupt( + self, excinfo: pytest.ExceptionInfo[BaseException] + ) -> None: + if isinstance(excinfo.value, pytest.exit.Exception): + workeroutput: dict[str, Any] = self.config.workeroutput # type: ignore[attr-defined] + workeroutput["exitreturncode"] = excinfo.value.returncode + workeroutput["exitreason"] = excinfo.value.msg + @pytest.hookimpl def pytest_sessionstart(self, session: pytest.Session) -> None: self.session = session diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 24611832..4d8bd712 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -919,6 +919,28 @@ def test_func(tmp_path): class TestNodeFailure: + @pytest.mark.parametrize("returncode", [0, 1]) + def test_pytest_exit_is_not_reported_as_internal_error( + self, pytester: pytest.Pytester, returncode: int + ) -> None: + f = pytester.makepyfile( + f""" + import time + import pytest + + @pytest.mark.parametrize("i", range(20)) + def test_me(i): + if i == 12: + pytest.exit("Something", {returncode}) + time.sleep(0.01) + """ + ) + res = pytester.runpytest(f, "-n4") + assert res.ret == returncode + assert "INTERNALERROR" not in res.stdout.str() + assert "received keyboard-interrupt" not in res.stdout.str() + res.stdout.fnmatch_lines(["* _pytest.outcomes.Exit: Something *"]) + def test_load_single(self, pytester: pytest.Pytester) -> None: f = pytester.makepyfile( """