From 9b4413d6e695e66b95863596a57ec420d36d95dc Mon Sep 17 00:00:00 2001 From: Raul Moldovan Date: Thu, 11 Jun 2026 10:35:12 +0300 Subject: [PATCH 1/2] fix: stop logger teardown from closing process stdout setup_logger wraps sys.stdout.buffer in a new TextIOWrapper on every call. After clean_logger removes the RichHandler, the wrapper becomes unreachable and its garbage collection closes the underlying buffer, i.e. the process's real stdout. Any later setup_logger call in the same process (e.g. the organize stage after the setup stage) then raises 'ValueError: I/O operation on closed file'. Share a single module-level console that is created once and never collected, so stdout stays open across logger setup/teardown cycles. --- launch/utilities/logger.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/launch/utilities/logger.py b/launch/utilities/logger.py index 8bb9590..5c35882 100644 --- a/launch/utilities/logger.py +++ b/launch/utilities/logger.py @@ -9,6 +9,25 @@ import io, sys from rich.console import Console +# A fresh TextIOWrapper around sys.stdout.buffer per setup_logger call closes +# the underlying buffer (= process stdout) once the wrapper is garbage +# collected after clean_logger removes the handler. The next setup_logger call +# in the same process (e.g. the organize stage after the setup stage) then +# fails with "ValueError: I/O operation on closed file". Share a single, +# never-collected console instead. +_shared_console: Console | None = None + + +def _get_shared_console() -> Console: + global _shared_console + if _shared_console is None: + # Wrap stdout in a UTF-8 TextIOWrapper; replace any bad chars defensively + utf8_stdout = io.TextIOWrapper( + sys.stdout.buffer, encoding="utf-8", errors="replace" + ) + _shared_console = Console(file=utf8_stdout, soft_wrap=True) + return _shared_console + def setup_logger(instance_id: str, log_file: Path | list[Path], printing: bool = True) -> logging.Logger: """ @@ -43,10 +62,7 @@ def setup_logger(instance_id: str, log_file: Path | list[Path], printing: bool = # logger.addHandler(ch) # add rich handler if printing: - # Wrap stdout in a UTF-8 TextIOWrapper; replace any bad chars defensively - utf8_stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") - console = Console(file=utf8_stdout, soft_wrap=True) - rh = RichHandler(console=console, rich_tracebacks=True, show_path=False) + rh = RichHandler(console=_get_shared_console(), rich_tracebacks=True, show_path=False) rh.setLevel(logging.INFO) logger.addHandler(rh) return logger From bf8571a938229fec29fe06bdc79bd92c0f97772d Mon Sep 17 00:00:00 2001 From: njukenanli Date: Sun, 14 Jun 2026 19:19:07 +0800 Subject: [PATCH 2/2] add reproduction test for issue#30 --- launch/utilities/logger.py | 14 ++------ tests/logger_test.py | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 tests/logger_test.py diff --git a/launch/utilities/logger.py b/launch/utilities/logger.py index 5c35882..3efcfad 100644 --- a/launch/utilities/logger.py +++ b/launch/utilities/logger.py @@ -1,23 +1,15 @@ """ Logging utilities for launch operations with file and console output. """ +import io, sys import logging from pathlib import Path - from rich.logging import RichHandler - -import io, sys from rich.console import Console -# A fresh TextIOWrapper around sys.stdout.buffer per setup_logger call closes -# the underlying buffer (= process stdout) once the wrapper is garbage -# collected after clean_logger removes the handler. The next setup_logger call -# in the same process (e.g. the organize stage after the setup stage) then -# fails with "ValueError: I/O operation on closed file". Share a single, -# never-collected console instead. -_shared_console: Console | None = None - +# https://github.com/microsoft/RepoLaunch/pull/31 +_shared_console: Console | None = None def _get_shared_console() -> Console: global _shared_console if _shared_console is None: diff --git a/tests/logger_test.py b/tests/logger_test.py new file mode 100644 index 0000000..d39c2d2 --- /dev/null +++ b/tests/logger_test.py @@ -0,0 +1,72 @@ +import gc +import io +import sys + +from launch.utilities import logger as logger_module + + +class FakeStdout: + def __init__(self): + self.buffer = io.BytesIO() + self.encoding = "utf-8" + self.errors = "replace" + + def write(self, text: str) -> int: + return len(text) + + def flush(self) -> None: + pass + + def isatty(self) -> bool: + return False + + +def _drop_shared_console_without_closing_buffer() -> None: + console = getattr(logger_module, "_shared_console", None) + stream = getattr(console, "file", None) + if stream is not None and hasattr(stream, "detach"): + try: + stream.detach() + except (ValueError, OSError, io.UnsupportedOperation): + pass + + if hasattr(logger_module, "_shared_console"): + logger_module._shared_console = None + + +def test_repeated_console_logger_setup_keeps_stdout_buffer_open( + tmp_path, monkeypatch +): + ''' + Issue #30 + FAIL_TO_PASS at PR#31 + ''' + logger_name = "test_repeated_console_logger_setup" + fake_stdout = FakeStdout() + + _drop_shared_console_without_closing_buffer() + monkeypatch.setattr(sys, "stdout", fake_stdout) + + try: + setup_logger = logger_module.setup_logger( + logger_name, tmp_path / "setup.log", printing=True + ) + setup_logger.info("setup stage") + logger_module.clean_logger(setup_logger) + del setup_logger + gc.collect() + + assert not fake_stdout.buffer.closed + + organize_logger = logger_module.setup_logger( + logger_name, tmp_path / "organize.log", printing=True + ) + organize_logger.info("organize stage") + logger_module.clean_logger(organize_logger) + del organize_logger + gc.collect() + + assert not fake_stdout.buffer.closed + finally: + logger_module.clean_logger(logger_name) + _drop_shared_console_without_closing_buffer()