diff --git a/docs/changelog.d/+interrupted-backend.bugfix.rst b/docs/changelog.d/+interrupted-backend.bugfix.rst new file mode 100644 index 0000000..e3b668e --- /dev/null +++ b/docs/changelog.d/+interrupted-backend.bugfix.rst @@ -0,0 +1 @@ +Stop the backend process and join its output collection thread when a frontend call is interrupted. diff --git a/src/pyproject_api/_via_fresh_subprocess.py b/src/pyproject_api/_via_fresh_subprocess.py index e9a2c29..49236d8 100644 --- a/src/pyproject_api/_via_fresh_subprocess.py +++ b/src/pyproject_api/_via_fresh_subprocess.py @@ -5,7 +5,7 @@ from contextlib import contextmanager from subprocess import PIPE, Popen # ruff:ignore[suspicious-subprocess-import] from threading import Thread -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Final, cast from ._frontend import CmdStatus, Frontend @@ -77,7 +77,12 @@ def _send_msg(self, cmd: str, result_file: Path, msg: str) -> Generator[Subproce cwd=self._root, env=env, ) - yield SubprocessCmdStatus(process, f"{os.linesep}{msg}{os.linesep}") + status: Final = SubprocessCmdStatus(process, f"{os.linesep}{msg}{os.linesep}") + try: + yield status + finally: + process.kill() + status.join() def send_cmd(self, cmd: str, **kwargs: Any) -> tuple[Any, str, str]: """ diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 8410d90..5ad3475 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -10,6 +10,7 @@ import pytest from packaging.requirements import Requirement +from pyproject_api import _via_fresh_subprocess from pyproject_api._frontend import BackendFailed from pyproject_api._via_fresh_subprocess import SubprocessFrontend @@ -161,6 +162,39 @@ def test_large_command_with_failed_backend(local_builder: Callable[[str], Path]) assert "RuntimeError: backend import failed" in context.value.err +@pytest.mark.parametrize( + "error_type", + [ + pytest.param(KeyboardInterrupt, id="keyboard-interrupt"), + pytest.param(SystemExit, id="system-exit"), + pytest.param(RuntimeError, id="runtime-error"), + ], +) +def test_interrupted_frontend_cleans_up_backend( + error_type: type[BaseException], local_builder: Callable[[str], Path], mocker: pytest_mock.MockerFixture +) -> None: + root: Final = local_builder(""" + import time + + def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + time.sleep(60) + return "demo-1.0-py3-none-any.whl" + """) + status_factory: Final = mocker.spy(_via_fresh_subprocess, "SubprocessCmdStatus") + mocker.patch("pyproject_api._frontend.sleep", side_effect=error_type("build interrupted")) + + try: + with pytest.raises(error_type): + SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(root)[:-1]).build_wheel(root / "dist") + + assert not status_factory.spy_return.is_alive() + finally: + # Keep a regression from leaving the backend and its non-daemon thread running. + status: Final = status_factory.spy_return + status.process.kill() + status.join(timeout=10) + + @pytest.mark.parametrize("cmd", ["build_wheel", "build_sdist"]) def test_missing_required_cmd(cmd: str, local_builder: Callable[[str], Path]) -> None: tmp_path = local_builder("")