From 84df4451ede720f09b0c7780a3f5f739b2271f83 Mon Sep 17 00:00:00 2001 From: Ben Brown Date: Mon, 1 Apr 2024 11:00:50 -0400 Subject: [PATCH] Terminate the test loop if `shouldfail` or `shouldstop` is set (#1026) --- changelog/1024.bugfix | 2 ++ src/xdist/dsession.py | 14 +++++++++++--- src/xdist/remote.py | 4 ++++ testing/acceptance_test.py | 34 +++++++++++++++++++++++++--------- 4 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 changelog/1024.bugfix diff --git a/changelog/1024.bugfix b/changelog/1024.bugfix new file mode 100644 index 0000000..06ce77f --- /dev/null +++ b/changelog/1024.bugfix @@ -0,0 +1,2 @@ +Added proper handling of ``shouldstop`` (such as set by ``--max-fail``) and ``shouldfail`` conditions in workers. +Previously, a worker might have continued executing further tests before the controller could terminate the session. diff --git a/src/xdist/dsession.py b/src/xdist/dsession.py index 9dcfc2a..bae9279 100644 --- a/src/xdist/dsession.py +++ b/src/xdist/dsession.py @@ -182,9 +182,17 @@ class DSession: self.shouldstop = f"{node} received keyboard-interrupt" self.worker_errordown(node, "keyboard-interrupt") return - if node in self.sched.nodes: - crashitem = self.sched.remove_node(node) - assert not crashitem, (crashitem, node) + shouldfail = node.workeroutput["shouldfail"] + shouldstop = node.workeroutput["shouldstop"] + for shouldx in [shouldfail, shouldstop]: + if shouldx: + if not self.shouldstop: + self.shouldstop = shouldx + break + else: + if node in self.sched.nodes: + crashitem = self.sched.remove_node(node) + assert not crashitem, (crashitem, node) self._active_nodes.remove(node) def worker_internal_error(self, node, formatted_error): diff --git a/src/xdist/remote.py b/src/xdist/remote.py index 2789e0f..cba91bc 100644 --- a/src/xdist/remote.py +++ b/src/xdist/remote.py @@ -103,6 +103,8 @@ class WorkerInteractor: def pytest_sessionfinish(self, exitstatus): # in pytest 5.0+, exitstatus is an IntEnum object self.config.workeroutput["exitstatus"] = int(exitstatus) + self.config.workeroutput["shouldfail"] = self.session.shouldfail + self.config.workeroutput["shouldstop"] = self.session.shouldstop yield self.sendevent("workerfinished", workeroutput=self.config.workeroutput) @@ -155,6 +157,8 @@ class WorkerInteractor: self.nextitem_index = self._get_next_item_index() while self.nextitem_index is not self.SHUTDOWN_MARK: self.run_one_test() + if session.shouldfail or session.shouldstop: + break return True def run_one_test(self): diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 7b83f02..acfa8d4 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -109,12 +109,12 @@ class TestDistribution: ) assert result.ret == 1 - def test_exitfail_waits_for_workers_to_finish( + def test_exitfirst_waits_for_workers_to_finish( self, pytester: pytest.Pytester ) -> None: """The DSession waits for workers before exiting early on failure. - When -x/--exitfail is set, the DSession wait for the workers to finish + When -x/--exitfirst is set, the DSession wait for all workers to finish before raising an Interrupt exception. This prevents reports from the faiing test and other tests from being discarded. """ @@ -138,15 +138,14 @@ class TestDistribution: time.sleep(0.3) """ ) + # Two workers are used result = pytester.runpytest(p1, "-x", "-rA", "-v", "-n2") assert result.ret == 2 - result.stdout.re_match_lines([".*Interrupted: stopping.*[12].*"]) - m = re.search(r"== (\d+) failed, (\d+) passed in ", str(result.stdout)) - assert m - n_failed, n_passed = (int(s) for s in m.groups()) - assert 1 <= n_failed <= 2 - assert 1 <= n_passed <= 3 - assert (n_passed + n_failed) < 6 + # DSession should stop when the first failure is reached. Two failures + # may actually occur, due to timing. + outcomes = result.parseoutcomes() + assert "failed" in outcomes, "Expected at least one failure" + assert 1 <= outcomes["failed"] <= 2, "Expected no more than 2 failures" def test_basetemp_in_subprocesses(self, pytester: pytest.Pytester) -> None: p1 = pytester.makepyfile( @@ -1180,6 +1179,23 @@ def test_internal_error_with_maxfail(pytester: pytest.Pytester) -> None: assert "INTERNALERROR" not in result.stderr.str() +def test_maxfail_causes_early_termination(pytester: pytest.Pytester) -> None: + """ + Ensure subsequent tests on a worker aren't run when using --maxfail (#1024). + """ + pytester.makepyfile( + """ + def test1(): + assert False + + def test2(): + pass + """ + ) + result = pytester.runpytest_subprocess("--maxfail=1", "-n 1") + result.assert_outcomes(failed=1) + + def test_internal_errors_propagate_to_controller(pytester: pytest.Pytester) -> None: pytester.makeconftest( """