From dccabdfc9706475d9f3b52f1df128f4bf167eb5f Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:23:58 +0000 Subject: [PATCH 1/2] Escape unencodable characters in the stdout wrapper (SR-5) A cp1252 stdout raised UnicodeEncodeError on U+FFFD. Retry with backslashreplace in the stream encoding; representable text is unchanged. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/stdout-unencodable-escape.md | 4 ++++ src/chock/pipe.py | 4 ++++ tests/test_pipe_tolerance.py | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 changelog.d/stdout-unencodable-escape.md 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..e8b8f24e 100644 --- a/src/chock/pipe.py +++ b/src/chock/pipe.py @@ -24,6 +24,10 @@ def write(self, text: str) -> int: return len(text) try: return self._stream.write(text) + except UnicodeEncodeError: + encoding = getattr(self._stream, "encoding", None) or "ascii" + safe = text.encode(encoding, "backslashreplace").decode(encoding) + return self._stream.write(safe) 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..9eccf112 100644 --- a/tests/test_pipe_tolerance.py +++ b/tests/test_pipe_tolerance.py @@ -117,3 +117,25 @@ 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") From 975abccfdf401705e4c3298929d7b081ddc2c043 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:26:06 +0000 Subject: [PATCH 2/2] Tolerate a broken pipe on the escaped retry (SR-5) Both writes now go through the same OSError handling, so a reader that vanishes during the backslashreplace retry no longer raises past the wrapper. Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- src/chock/pipe.py | 10 +++++----- tests/test_pipe_tolerance.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/chock/pipe.py b/src/chock/pipe.py index e8b8f24e..b5b06606 100644 --- a/src/chock/pipe.py +++ b/src/chock/pipe.py @@ -23,11 +23,11 @@ def write(self, text: str) -> int: if self.closed_by_reader: return len(text) try: - return self._stream.write(text) - except UnicodeEncodeError: - encoding = getattr(self._stream, "encoding", None) or "ascii" - safe = text.encode(encoding, "backslashreplace").decode(encoding) - return self._stream.write(safe) + 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 9eccf112..8aabe271 100644 --- a/tests/test_pipe_tolerance.py +++ b/tests/test_pipe_tolerance.py @@ -139,3 +139,19 @@ def test_representable_text_is_written_unchanged() -> None: 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