Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/1239.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Handle ``pytest.exit()`` raised in a worker without reporting it as an internal error.
18 changes: 17 additions & 1 deletion src/xdist/dsession.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions src/xdist/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down