Skip to content

Commit 13e7849

Browse files
cristipufuclaude
andcommitted
fix: use detach() to preserve stdout buffer on interceptor teardown
When stdout uses non-UTF-8 encoding (e.g. cp1252 on Windows piped mode), the interceptor creates a TextIOWrapper around sys.stdout.buffer. Using close() on teardown destroyed the shared buffer, causing ValueError in subsequent stdout writes (e.g. click.echo()). - Replace close() with detach() to disconnect the wrapper without closing the underlying buffer - Reorder teardown: detach utf8_stdout before closing the handler, so StreamHandler.close() cannot cascade a close through the wrapper - Add del to guard against double-teardown (detach is not idempotent) - Bump version to 0.9.2 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0fb9be2 commit 13e7849

File tree

4 files changed

+169
-5
lines changed

4 files changed

+169
-5
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.9.1"
3+
version = "0.9.2"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/runtime/logging/_interceptor.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,13 @@ def teardown(self) -> None:
233233
if handler not in self.root_logger.handlers:
234234
self.root_logger.addHandler(handler)
235235

236+
if hasattr(self, "utf8_stdout"):
237+
self.utf8_stdout.detach()
238+
del self.utf8_stdout
239+
236240
if self._owns_handler:
237241
self.log_handler.close()
238242

239-
if hasattr(self, "utf8_stdout"):
240-
self.utf8_stdout.close()
241-
242243
# Only restore streams if we redirected them
243244
if self.original_stdout and self.original_stderr:
244245
sys.stdout = self.original_stdout

tests/test_interceptor.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Tests for UiPathRuntimeLogsInterceptor teardown with non-UTF-8 stdout."""
2+
3+
import io
4+
import logging
5+
import sys
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
from uipath.runtime.logging._interceptor import UiPathRuntimeLogsInterceptor
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def _isolate_logging():
15+
"""Save and restore logging state so tests don't leak into each other."""
16+
root = logging.getLogger()
17+
original_level = root.level
18+
original_handlers = list(root.handlers)
19+
original_stdout = sys.stdout
20+
original_stderr = sys.stderr
21+
yield
22+
root.setLevel(original_level)
23+
root.handlers = original_handlers
24+
sys.stdout = original_stdout
25+
sys.stderr = original_stderr
26+
logging.disable(logging.NOTSET)
27+
28+
29+
def _make_cp1252_stdout() -> io.TextIOWrapper:
30+
"""Create a TextIOWrapper that mimics Windows cp1252 piped stdout."""
31+
raw_buffer = io.BytesIO()
32+
return io.TextIOWrapper(raw_buffer, encoding="cp1252", line_buffering=True)
33+
34+
35+
class TestInterceptorTeardownPreservesBuffer:
36+
"""Verify that teardown does not destroy the underlying stdout buffer."""
37+
38+
def test_buffer_usable_after_teardown(self):
39+
"""After setup+teardown the original stdout buffer must still be writable."""
40+
fake_stdout = _make_cp1252_stdout()
41+
42+
with patch.object(sys, "stdout", fake_stdout), patch.object(
43+
sys, "stderr", fake_stdout
44+
):
45+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
46+
47+
# The wrapper should have been created because encoding is cp1252
48+
assert hasattr(interceptor, "utf8_stdout")
49+
50+
interceptor.setup()
51+
interceptor.teardown()
52+
53+
# The underlying buffer must still be open and writable
54+
assert not fake_stdout.buffer.closed
55+
fake_stdout.buffer.write(b"still alive")
56+
57+
def test_no_valueerror_writing_after_teardown(self):
58+
"""Writing to the original stdout after teardown must not raise ValueError."""
59+
fake_stdout = _make_cp1252_stdout()
60+
61+
with patch.object(sys, "stdout", fake_stdout), patch.object(
62+
sys, "stderr", fake_stdout
63+
):
64+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
65+
interceptor.setup()
66+
interceptor.teardown()
67+
68+
# This simulates what click.echo() does — write to the restored stdout
69+
fake_stdout.write("no crash")
70+
fake_stdout.flush()
71+
72+
def test_utf8_stdout_not_created_for_utf8_encoding(self):
73+
"""When stdout is already UTF-8, no wrapper should be created."""
74+
utf8_stdout = io.TextIOWrapper(
75+
io.BytesIO(), encoding="utf-8", line_buffering=True
76+
)
77+
78+
with patch.object(sys, "stdout", utf8_stdout), patch.object(
79+
sys, "stderr", utf8_stdout
80+
):
81+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
82+
83+
assert not hasattr(interceptor, "utf8_stdout")
84+
85+
def test_utf8_stdout_attr_removed_after_teardown(self):
86+
"""After teardown, the utf8_stdout attribute should be deleted (double-teardown guard)."""
87+
fake_stdout = _make_cp1252_stdout()
88+
89+
with patch.object(sys, "stdout", fake_stdout), patch.object(
90+
sys, "stderr", fake_stdout
91+
):
92+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
93+
interceptor.setup()
94+
interceptor.teardown()
95+
96+
assert not hasattr(interceptor, "utf8_stdout")
97+
98+
def test_double_teardown_does_not_raise(self):
99+
"""Calling teardown twice must not raise (guarded by del)."""
100+
fake_stdout = _make_cp1252_stdout()
101+
102+
with patch.object(sys, "stdout", fake_stdout), patch.object(
103+
sys, "stderr", fake_stdout
104+
):
105+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
106+
interceptor.setup()
107+
interceptor.teardown()
108+
# Second teardown should be safe
109+
interceptor.teardown()
110+
111+
112+
class TestInterceptorTeardownOrder:
113+
"""Verify that detach happens before handler close."""
114+
115+
def test_detach_called_before_handler_close(self):
116+
"""utf8_stdout.detach() must execute before log_handler.close()."""
117+
fake_stdout = _make_cp1252_stdout()
118+
call_order: list[str] = []
119+
120+
with patch.object(sys, "stdout", fake_stdout), patch.object(
121+
sys, "stderr", fake_stdout
122+
):
123+
interceptor = UiPathRuntimeLogsInterceptor(job_id=None)
124+
assert hasattr(interceptor, "utf8_stdout")
125+
126+
# Wrap detach and close to record call order
127+
original_detach = interceptor.utf8_stdout.detach
128+
original_close = interceptor.log_handler.close
129+
130+
def tracked_detach():
131+
call_order.append("detach")
132+
return original_detach()
133+
134+
def tracked_close():
135+
call_order.append("handler_close")
136+
return original_close()
137+
138+
with patch.object(
139+
interceptor.utf8_stdout, "detach", tracked_detach
140+
), patch.object(interceptor.log_handler, "close", tracked_close):
141+
interceptor.setup()
142+
interceptor.teardown()
143+
144+
assert "detach" in call_order
145+
assert "handler_close" in call_order
146+
assert call_order.index("detach") < call_order.index("handler_close")
147+
148+
149+
class TestInterceptorWithJobId:
150+
"""When job_id is set, a file handler is used — no utf8_stdout wrapper."""
151+
152+
def test_no_utf8_wrapper_with_job_id(self, tmp_path):
153+
"""File-based handler path should never create utf8_stdout."""
154+
fake_stdout = _make_cp1252_stdout()
155+
156+
with patch.object(sys, "stdout", fake_stdout), patch.object(
157+
sys, "stderr", fake_stdout
158+
):
159+
interceptor = UiPathRuntimeLogsInterceptor(
160+
job_id="job-123", dir=str(tmp_path), file="test.log"
161+
)
162+
163+
assert not hasattr(interceptor, "utf8_stdout")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)