From 126c6cbb0cf3fc372a75d9cc708973f43fad7bd8 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 27 Jun 2026 10:40:54 +0800 Subject: [PATCH] Fix: Handle pytest.exit() from workers gracefully When pytest.exit() is called in a worker test, it raises pytest.exit.Exception (a BaseException subclass). Previously, the worker's runtest loop would exit without marking the current test as complete, causing the controller's scheduler to treat it as a crash. This fix: - Adds a pytest_keyboard_interrupt hook in WorkerInteractor to detect pytest.exit.Exception and store exit info in workeroutput - In DSession.worker_workerfinished, checks for the exit info and handles it gracefully without asserting crashitem - In pytest_runtestloop, re-raises pytest.exit.Exception to preserve the requested return code Fixes #1239 --- .gitignore | 1 + changelog/1239.bugfix.rst | 1 + src/xdist/dsession.py | 18 +++++++++++++++++- src/xdist/remote.py | 9 +++++++++ testing/acceptance_test.py | 22 ++++++++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 changelog/1239.bugfix.rst diff --git a/.gitignore b/.gitignore index fd272f9f..089f556e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ pytestdebug.log .pytest_cache/ .eggs/ .idea/ +venv/ 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( """