diff --git a/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md b/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md index 89cb46d005a9..3b63dc81ae57 100644 --- a/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md +++ b/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## 1.15.0 (Unreleased) + +### Bugs Fixed + +- Prevent recursive stdout/stderr forwarding when NodeLogManager is nested, avoiding RecursionError in concurrent evaluation runs. + ## 1.14.0 (2026-01-05) ### Bugs Fixed diff --git a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/_common/_logging.py b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/_common/_logging.py index 9d6a5507aaf9..9851b7e6136a 100644 --- a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/_common/_logging.py +++ b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/_common/_logging.py @@ -171,8 +171,14 @@ class NodeLogManager: """ def __init__(self, record_datetime: bool = True): - self.stdout_logger = NodeLogWriter(sys.stdout, record_datetime) - self.stderr_logger = NodeLogWriter(sys.stderr, record_datetime, is_stderr=True) + if isinstance(sys.stdout, NodeLogWriter): + self.stdout_logger = sys.stdout + else: + self.stdout_logger = NodeLogWriter(sys.stdout, record_datetime) + if isinstance(sys.stderr, NodeLogWriter): + self.stderr_logger = sys.stderr + else: + self.stderr_logger = NodeLogWriter(sys.stderr, record_datetime, is_stderr=True) def __enter__(self) -> "NodeLogManager": """Replace sys.stdout and sys.stderr with NodeLogWriter.""" @@ -215,6 +221,7 @@ def __init__(self, prev_stdout: Union[TextIOBase, Any], record_datetime: bool = self._prev_out: Union[TextIOBase, Any] = prev_stdout self._record_datetime: bool = record_datetime self._is_stderr: bool = is_stderr + self._fallback_out: Optional[TextIOBase] = sys.__stderr__ if is_stderr else sys.__stdout__ def set_node_info(self, run_id: str, node_name: str, line_number: int) -> None: """Set node info to a context variable. @@ -252,7 +259,8 @@ def write(self, s: str) -> int: log_info: Optional[NodeInfo] = self._context.get() s = scrub_credentials(s) # Remove credential from string. if log_info is None: - return self._prev_out.write(s) + out = self._resolve_prev_out() + return out.write(s) if out is not None else 0 else: self._write_to_flow_log(log_info, s) stdout: Optional[StringIO] = self.run_id_to_stdout.get(log_info.run_id) @@ -270,12 +278,25 @@ def flush(self): """Override TextIO's flush method.""" node_info: Optional[NodeInfo] = self._context.get() if node_info is None: - self._prev_out.flush() + out = self._resolve_prev_out() + if out is not None: + out.flush() else: string_io = self.run_id_to_stdout.get(node_info.run_id) if string_io is not None: string_io.flush() + def _resolve_prev_out(self) -> Optional[TextIOBase]: + current = self._prev_out + visited: Set[int] = set() + while isinstance(current, NodeLogWriter): + current_id = id(current) + if current_id in visited: + return self._fallback_out + visited.add(current_id) + current = current._prev_out # pylint: disable=protected-access + return current if current is not None else self._fallback_out + def _write_to_flow_log(self, log_info: NodeInfo, s: str): """Save stdout log to flow_logger and stderr log to logger.""" # If user uses "print('log message.')" to log, then diff --git a/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_logging.py b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_logging.py new file mode 100644 index 000000000000..d190cfe97780 --- /dev/null +++ b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_logging.py @@ -0,0 +1,42 @@ +import sys +from contextlib import ExitStack +from io import StringIO + +import pytest + +from azure.ai.evaluation._legacy._common._logging import NodeLogManager, NodeLogWriter + + +@pytest.mark.unittest +def test_node_log_writer_unwraps_nested_prev_out(monkeypatch): + base_out = StringIO() + inner = NodeLogWriter(base_out) + outer = NodeLogWriter(inner) + + outer.write("hello") + + assert base_out.getvalue() == "hello" + + +@pytest.mark.unittest +def test_nested_node_log_manager_does_not_recurse(monkeypatch): + base_out = StringIO() + base_err = StringIO() + monkeypatch.setattr(sys, "stdout", base_out) + monkeypatch.setattr(sys, "stderr", base_err) + monkeypatch.setattr(sys, "__stdout__", base_out) + monkeypatch.setattr(sys, "__stderr__", base_err) + + original_limit = sys.getrecursionlimit() + sys.setrecursionlimit(300) + try: + with ExitStack() as stack: + for _ in range(500): + stack.enter_context(NodeLogManager()) + print("nested") + sys.stderr.write("stderr") + finally: + sys.setrecursionlimit(original_limit) + + assert "nested" in base_out.getvalue() + assert "stderr" in base_err.getvalue()