From e225128305b8aaf1f5c05ad60171c33b0274e76c Mon Sep 17 00:00:00 2001 From: Huanyi Xie <74809554+xiehuanyi@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:00:17 +0300 Subject: [PATCH 1/3] fix: replace undecodable backend diagnostic bytes --- src/pyproject_api/_via_fresh_subprocess.py | 1 + tests/test_frontend.py | 34 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/pyproject_api/_via_fresh_subprocess.py b/src/pyproject_api/_via_fresh_subprocess.py index 0c52ba3..e07a599 100644 --- a/src/pyproject_api/_via_fresh_subprocess.py +++ b/src/pyproject_api/_via_fresh_subprocess.py @@ -73,6 +73,7 @@ def _send_msg(self, cmd: str, result_file: Path, msg: str) -> Generator[Subproce stderr=PIPE, stdin=PIPE, universal_newlines=True, + errors="replace", cwd=self._root, env=env, ) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 384e277..06f8590 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -77,6 +77,40 @@ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): assert not result.stderr +@pytest.mark.parametrize(("stream", "output"), [("stdout", "out"), ("stderr", "err")]) +def test_backend_output_with_invalid_bytes(stream: str, output: str, local_builder: Callable[[str], Path]) -> None: + tmp_path = local_builder(f""" + import sys + + sys.{stream}.buffer.write(b"before\\xffafter") + sys.{stream}.flush() + + def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + return "demo-1.0-py3-none-any.whl" + """) + # Isolate the frontend so a decoding failure cannot leave pytest polling forever. + script = dedent(""" + import sys + from pathlib import Path + from pyproject_api import SubprocessFrontend + + root = Path(sys.argv[1]) + frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(root)[:-1]) + result = frontend.build_wheel(root / "dist") + print(result.wheel.name) + print("before" + chr(0xFFFD) + "after" in getattr(result, sys.argv[2])) + """) + result = subprocess.run( + [sys.executable, "-X", "utf8", "-c", script, str(tmp_path), output], + capture_output=True, + encoding="utf-8", + timeout=10, + check=True, + ) + assert result.stdout.splitlines() == ["demo-1.0-py3-none-any.whl", "True"] + assert not result.stderr + + def test_large_command_with_failed_backend(local_builder: Callable[[str], Path]) -> None: tmp_path = local_builder('raise RuntimeError("backend import failed")') frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1]) From 67f6c2e3ed1b6d9ee3613c4a3c070677b533952f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 7 Oct 2026 12:59:40 -0700 Subject: [PATCH 2/3] Share isolated build_wheel runner in tests --- tests/test_frontend.py | 100 ++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 06f8590..e42ac1c 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -42,19 +42,8 @@ def test_missing_backend(local_builder: Callable[[str], Path]) -> None: assert "ModuleNotFoundError: No module named " in exc.err -@pytest.mark.parametrize(("stream", "output"), [("stdout", "out"), ("stderr", "err")]) -def test_large_command_with_backend_output(stream: str, output: str, local_builder: Callable[[str], Path]) -> None: - tmp_path = local_builder(f""" - import sys - - sys.{stream}.write("x" * 1048576) - sys.{stream}.flush() - - def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): - assert config_settings == {{"payload": "y" * 1048576}} - return "demo-1.0-py3-none-any.whl" - """) - # Isolate the frontend so a pipe deadlock fails with a timeout instead of hanging pytest. +@pytest.fixture +def isolated_build_wheel() -> Callable[[Path, str, int], tuple[str, str]]: script = dedent(""" import sys from pathlib import Path @@ -62,53 +51,70 @@ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): root = Path(sys.argv[1]) frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(root)[:-1]) - result = frontend.build_wheel(root / "dist", config_settings={"payload": "y" * 1048576}) + result = frontend.build_wheel(root / "dist", config_settings={"payload": "y" * int(sys.argv[3])}) print(result.wheel.name) - print("x" * 1048576 in getattr(result, sys.argv[2])) + print(getattr(result, sys.argv[2]), end="") """) - result = subprocess.run( - [sys.executable, "-c", script, str(tmp_path), output], - capture_output=True, - text=True, - timeout=30, - check=True, - ) - assert result.stdout.splitlines() == ["demo-1.0-py3-none-any.whl", "True"] - assert not result.stderr - - -@pytest.mark.parametrize(("stream", "output"), [("stdout", "out"), ("stderr", "err")]) -def test_backend_output_with_invalid_bytes(stream: str, output: str, local_builder: Callable[[str], Path]) -> None: + + def _f(root: Path, output: str, payload_size: int) -> tuple[str, str]: + # Run the frontend in a child process so a hang fails on the timeout instead of stalling pytest. + result = subprocess.run( + [sys.executable, "-X", "utf8", "-c", script, str(root), output, str(payload_size)], + capture_output=True, + encoding="utf-8", + timeout=30, + check=True, + ) + assert not result.stderr + wheel, captured = result.stdout.split("\n", 1) + return wheel, captured + + return _f + + +@pytest.mark.parametrize( + ("stream", "output"), [pytest.param("stdout", "out", id="stdout"), pytest.param("stderr", "err", id="stderr")] +) +def test_large_command_with_backend_output( + stream: str, + output: str, + local_builder: Callable[[str], Path], + isolated_build_wheel: Callable[[Path, str, int], tuple[str, str]], +) -> None: tmp_path = local_builder(f""" import sys - sys.{stream}.buffer.write(b"before\\xffafter") + sys.{stream}.write("x" * 1048576) sys.{stream}.flush() def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + assert config_settings == {{"payload": "y" * 1048576}} return "demo-1.0-py3-none-any.whl" """) - # Isolate the frontend so a decoding failure cannot leave pytest polling forever. - script = dedent(""" + wheel, captured = isolated_build_wheel(tmp_path, output, 1048576) + assert (wheel, "x" * 1048576 in captured) == ("demo-1.0-py3-none-any.whl", True) + + +@pytest.mark.parametrize( + ("stream", "output"), [pytest.param("stdout", "out", id="stdout"), pytest.param("stderr", "err", id="stderr")] +) +def test_backend_output_with_invalid_bytes( + stream: str, + output: str, + local_builder: Callable[[str], Path], + isolated_build_wheel: Callable[[Path, str, int], tuple[str, str]], +) -> None: + tmp_path = local_builder(f""" import sys - from pathlib import Path - from pyproject_api import SubprocessFrontend - root = Path(sys.argv[1]) - frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(root)[:-1]) - result = frontend.build_wheel(root / "dist") - print(result.wheel.name) - print("before" + chr(0xFFFD) + "after" in getattr(result, sys.argv[2])) + sys.{stream}.buffer.write(b"before\\xffafter") + sys.{stream}.flush() + + def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + return "demo-1.0-py3-none-any.whl" """) - result = subprocess.run( - [sys.executable, "-X", "utf8", "-c", script, str(tmp_path), output], - capture_output=True, - encoding="utf-8", - timeout=10, - check=True, - ) - assert result.stdout.splitlines() == ["demo-1.0-py3-none-any.whl", "True"] - assert not result.stderr + wheel, captured = isolated_build_wheel(tmp_path, output, 0) + assert (wheel, "before\ufffdafter" in captured) == ("demo-1.0-py3-none-any.whl", True) def test_large_command_with_failed_backend(local_builder: Callable[[str], Path]) -> None: From 2388c6ed2378ba9189e9ddd09f97942c1434f96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 7 Oct 2026 13:14:26 -0700 Subject: [PATCH 3/3] Keep undecodable backend bytes as escapes --- src/pyproject_api/_via_fresh_subprocess.py | 2 +- tests/test_frontend.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pyproject_api/_via_fresh_subprocess.py b/src/pyproject_api/_via_fresh_subprocess.py index e07a599..e9a2c29 100644 --- a/src/pyproject_api/_via_fresh_subprocess.py +++ b/src/pyproject_api/_via_fresh_subprocess.py @@ -73,7 +73,7 @@ def _send_msg(self, cmd: str, result_file: Path, msg: str) -> Generator[Subproce stderr=PIPE, stdin=PIPE, universal_newlines=True, - errors="replace", + errors="backslashreplace", cwd=self._root, env=env, ) diff --git a/tests/test_frontend.py b/tests/test_frontend.py index e42ac1c..05ce7cb 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -114,7 +114,7 @@ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): return "demo-1.0-py3-none-any.whl" """) wheel, captured = isolated_build_wheel(tmp_path, output, 0) - assert (wheel, "before\ufffdafter" in captured) == ("demo-1.0-py3-none-any.whl", True) + assert (wheel, "before\\xffafter" in captured) == ("demo-1.0-py3-none-any.whl", True) def test_large_command_with_failed_backend(local_builder: Callable[[str], Path]) -> None: