diff --git a/changelog.d/stdout-unencodable-escape.md b/changelog.d/stdout-unencodable-escape.md new file mode 100644 index 00000000..6193a92f --- /dev/null +++ b/changelog.d/stdout-unencodable-escape.md @@ -0,0 +1,4 @@ +- **Command output no longer crashes on a character the stream cannot encode.** `chock check` on a + Windows runner (stdout cp1252) raised `UnicodeEncodeError` on U+FFFD while printing eval results. The + stdout wrapper now retries with `backslashreplace` in the stream's encoding; text the encoding can + represent prints unchanged. diff --git a/src/chock/pipe.py b/src/chock/pipe.py index 69ba15a9..b5b06606 100644 --- a/src/chock/pipe.py +++ b/src/chock/pipe.py @@ -23,7 +23,11 @@ def write(self, text: str) -> int: if self.closed_by_reader: return len(text) try: - return self._stream.write(text) + try: + return self._stream.write(text) + except UnicodeEncodeError: + encoding = getattr(self._stream, "encoding", None) or "ascii" + return self._stream.write(text.encode(encoding, "backslashreplace").decode(encoding)) except OSError as exc: if not _is_broken_pipe(exc): raise diff --git a/tests/test_pipe_tolerance.py b/tests/test_pipe_tolerance.py index 0061133e..8aabe271 100644 --- a/tests/test_pipe_tolerance.py +++ b/tests/test_pipe_tolerance.py @@ -117,3 +117,41 @@ def test_recompile_finishes_its_bookkeeping_when_the_reader_closes_early(tmp_pat f"just installed now fail every commit:\n{output}" ) assert "index_freshness" not in output, f"INDEX.md was left stale by the truncated run:\n{output}" + + +def test_unencodable_character_is_escaped_not_raised() -> None: + """IR-F18: U+FFFD on a cp1252 stream (Windows console) must not crash the command.""" + raw = io.BytesIO() + stream = io.TextIOWrapper(raw, encoding="cp1252", errors="strict") + writer = PipeTolerantWriter(stream) + + writer.write("ok � end") + writer.flush() + + assert raw.getvalue() == b"ok \\ufffd end" + + +def test_representable_text_is_written_unchanged() -> None: + raw = io.BytesIO() + writer = PipeTolerantWriter(io.TextIOWrapper(raw, encoding="cp1252", errors="strict")) + + writer.write("café €") + writer.flush() + + assert raw.getvalue() == "café €".encode("cp1252") + + +def test_broken_pipe_on_the_escaped_retry_is_tolerated() -> None: + class _Cp1252ThenClosed(io.StringIO): + encoding = "cp1252" + + def write(self, text: str) -> int: + if "�" in text: + raise UnicodeEncodeError("charmap", text, 0, 1, "undefined") + raise BrokenPipeError(32, "Broken pipe") + + writer = PipeTolerantWriter(_Cp1252ThenClosed()) + + writer.write("ok � end") + + assert writer.closed_by_reader