diff --git a/src/lfx/src/lfx/log/__init__.py b/src/lfx/src/lfx/log/__init__.py index 68fc5ba4e7..e6d1cee3ad 100644 --- a/src/lfx/src/lfx/log/__init__.py +++ b/src/lfx/src/lfx/log/__init__.py @@ -1,5 +1,8 @@ """Logging module for lfx package.""" +from lfx.log._streams import make_streams_resilient from lfx.log.logger import configure, logger +make_streams_resilient() + __all__ = ["configure", "logger"] diff --git a/src/lfx/src/lfx/log/_streams.py b/src/lfx/src/lfx/log/_streams.py new file mode 100644 index 0000000000..8cd6299aa0 --- /dev/null +++ b/src/lfx/src/lfx/log/_streams.py @@ -0,0 +1,29 @@ +"""Make process stdout/stderr tolerate characters their console codec cannot encode.""" + +import contextlib +import sys +from typing import Any + + +def _reconfigure(stream: Any) -> None: + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + return + with contextlib.suppress(Exception): + reconfigure(errors="backslashreplace") + + +def make_streams_resilient() -> None: + """Escape unencodable characters on write instead of raising. + + Windows consoles default to a legacy code page (cp1252) whose codec raises + UnicodeEncodeError on the box-drawing glyphs structlog's ConsoleRenderer + emits when formatting a traceback. That second exception is raised inside + the logging call itself, so it masks the original error and aborts the + request that logged it. ``errors="backslashreplace"`` keeps the stream + encoding but escapes anything the code page can't represent, so logging + never crashes the caller. The underlying streams are reconfigured too + because colorama wraps those same objects, regardless of wrap order. + """ + for stream in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__): + _reconfigure(stream) diff --git a/src/lfx/tests/unit/log/__init__.py b/src/lfx/tests/unit/log/__init__.py new file mode 100644 index 0000000000..96d4bfc78b --- /dev/null +++ b/src/lfx/tests/unit/log/__init__.py @@ -0,0 +1 @@ +"""Test suite for LFX logging.""" diff --git a/src/lfx/tests/unit/log/test_streams.py b/src/lfx/tests/unit/log/test_streams.py new file mode 100644 index 0000000000..b833e841f0 --- /dev/null +++ b/src/lfx/tests/unit/log/test_streams.py @@ -0,0 +1,61 @@ +"""Regression tests for stdout/stderr encoding resilience on Windows consoles.""" + +import importlib +import io + +import structlog +from lfx.log._streams import make_streams_resilient + + +def _cp1252_stream() -> tuple[io.TextIOWrapper, io.BytesIO]: + """A text stream that behaves like a strict Windows cp1252 console.""" + raw = io.BytesIO() + return io.TextIOWrapper(raw, encoding="cp1252"), raw + + +def test_make_streams_resilient_sets_backslashreplace(monkeypatch): + out, _ = _cp1252_stream() + err, _ = _cp1252_stream() + monkeypatch.setattr("sys.stdout", out) + monkeypatch.setattr("sys.stderr", err) + + make_streams_resilient() + + assert out.errors == "backslashreplace" + assert err.errors == "backslashreplace" + + +def test_logging_exception_to_cp1252_stdout_does_not_crash(monkeypatch): + """A logged traceback must never raise UnicodeEncodeError on a cp1252 console. + + structlog's ConsoleRenderer emits box-drawing glyphs that cp1252 cannot + encode; without the resilience shim the encode error is raised inside the + logging call, masking the original error and aborting the request. + """ + stream, raw = _cp1252_stream() + monkeypatch.setattr("sys.stdout", stream) + + make_streams_resilient() + + logmod = importlib.import_module("lfx.log.logger") + logmod.configure(log_level="ERROR", cache=False) + log = structlog.get_logger("test") + + msg = "THIS IS A TEST ERROR MESSAGE" + try: + raise ValueError(msg) + except ValueError: + log.exception("boom") + + stream.flush() + rendered = raw.getvalue().decode("cp1252", errors="replace") + assert "THIS IS A TEST ERROR MESSAGE" in rendered + + +def test_reconfigure_is_safe_when_stream_is_none(monkeypatch): + monkeypatch.setattr("sys.stdout", None) + monkeypatch.setattr("sys.stderr", None) + monkeypatch.setattr("sys.__stdout__", None) + monkeypatch.setattr("sys.__stderr__", None) + + make_streams_resilient()