Skip to content
Merged
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 docs/changelog.d/+interrupted-backend.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stop the backend process and join its output collection thread when a frontend call is interrupted.
9 changes: 7 additions & 2 deletions src/pyproject_api/_via_fresh_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
"""
Expand Down
34 changes: 34 additions & 0 deletions tests/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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("")
Expand Down
Loading