Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sdk/evaluation/azure-ai-evaluation/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()