From e8baab99bf50fc53b70816e451f39c87d5c29a62 Mon Sep 17 00:00:00 2001 From: linhongkuan Date: Thu, 25 Jun 2026 01:51:57 +0800 Subject: [PATCH] Propagate worker usage errors to controller --- changelog/109.bugfix.rst | 3 +++ src/xdist/dsession.py | 11 +++++++++++ src/xdist/remote.py | 11 +++++++++-- testing/acceptance_test.py | 6 ++++++ 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 changelog/109.bugfix.rst diff --git a/changelog/109.bugfix.rst b/changelog/109.bugfix.rst new file mode 100644 index 00000000..cdefe835 --- /dev/null +++ b/changelog/109.bugfix.rst @@ -0,0 +1,3 @@ +Propagate usage errors raised during worker collection, such as missing requested +paths, back to the controller so pytest reports the original error message and +exit code. diff --git a/src/xdist/dsession.py b/src/xdist/dsession.py index 41e62d46..600cb859 100644 --- a/src/xdist/dsession.py +++ b/src/xdist/dsession.py @@ -206,6 +206,17 @@ def worker_workerfinished(self, node: WorkerController) -> None: workerready before shutdown was triggered. """ self.config.hook.pytest_testnodedown(node=node, error=None) + if node.workeroutput["exitstatus"] == int(pytest.ExitCode.USAGE_ERROR): + self._active_nodes.remove(node) + if not self.shuttingdown: + self.triggershutdown() + + usage_error = node.workeroutput.get("usage_error") + if usage_error: + raise pytest.UsageError(*usage_error) + + raise pytest.UsageError(f"{node} exited with a usage error") + 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..ad082376 100644 --- a/src/xdist/remote.py +++ b/src/xdist/remote.py @@ -150,9 +150,16 @@ def pytest_sessionfinish(self, exitstatus: int) -> Generator[None, object, None] yield self.sendevent("workerfinished", workeroutput=workeroutput) - @pytest.hookimpl - def pytest_collection(self) -> None: + @pytest.hookimpl(hookwrapper=True) + def pytest_collection(self) -> Generator[None, object, None]: self.sendevent("collectionstart") + outcome: Any = yield + if outcome.excinfo is not None and isinstance( + outcome.excinfo[1], pytest.UsageError + ): + exc = outcome.excinfo[1] + workeroutput: dict[str, Any] = self.config.workeroutput # type: ignore[attr-defined] + workeroutput["usage_error"] = tuple(str(arg) for arg in exc.args) def handle_command( self, command: tuple[str, dict[str, Any]] | Literal[Marker.SHUTDOWN] diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 24611832..c5f6b663 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -77,6 +77,12 @@ def test_import(): result2 = pytester.runpytest(p1, "-n1") assert len(result1.stdout.lines) == len(result2.stdout.lines) + def test_missing_path_usage_error(self, pytester: pytest.Pytester) -> None: + result = pytester.runpytest("-n2", "MISSING") + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines(["ERROR: file or directory not found: MISSING"]) + def test_n1_skip(self, pytester: pytest.Pytester) -> None: p1 = pytester.makepyfile( """