Skip to content

Commit 5890e8c

Browse files
Aladexshahargl
andauthored
fix: use local StringIO reference to prevent stderr race condition in IOHandler._render (#6080)
Co-authored-by: Shahar Glazner <shaharglazner@gmail.com>
1 parent c07a490 commit 5890e8c

2 files changed

Lines changed: 55 additions & 7 deletions

File tree

keep/iohandler/iohandler.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -434,14 +434,16 @@ def _render(self, key: str, safe=False, default="", additional_context=None):
434434
if additional_context:
435435
context.update(additional_context)
436436

437-
# TODO: protect from multithreaded where another thread will print to stderr, but thats a very rare case and we shouldn't care much
437+
stderr_capture = io.StringIO()
438438
original_stderr = sys.stderr
439-
sys.stderr = io.StringIO()
440-
rendered = self.render_recursively(key, context)
441-
# chevron.render will escape the quotes, we need to unescape them
442-
rendered = rendered.replace("&quot;", '"')
443-
stderr_output = sys.stderr.getvalue()
444-
sys.stderr = original_stderr
439+
sys.stderr = stderr_capture
440+
try:
441+
rendered = self.render_recursively(key, context)
442+
# chevron.render will escape the quotes, we need to unescape them
443+
rendered = rendered.replace("&quot;", '"')
444+
stderr_output = stderr_capture.getvalue()
445+
finally:
446+
sys.stderr = original_stderr
445447
# If render should failed if value does not exists
446448
if safe and "Could not find key" in stderr_output:
447449
# if more than one keys missing, pretiffy the error

tests/test_iohandler.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"""
44

55
import datetime
6+
import threading
7+
import time
68
from unittest.mock import patch
79

810
import pytest
@@ -977,3 +979,47 @@ def test_render_with_consts(context_manager):
977979
assert (
978980
result == expected_result
979981
), f"Expected '{expected_result}', but got '{result}'"
982+
983+
984+
def test_concurrent_render_no_stderr_race(context_manager):
985+
"""Test that concurrent render calls don't cause a race condition on sys.stderr.
986+
987+
Before the fix, multiple threads calling render() simultaneously could hit a race
988+
where one thread restores sys.stderr to the original TextIOWrapper while another
989+
thread tries to call .getvalue() on sys.stderr, causing:
990+
AttributeError: '_io.TextIOWrapper' object has no attribute 'getvalue'
991+
992+
The fix uses a local StringIO reference instead of reading from sys.stderr.
993+
See: https://github.com/keephq/keep/issues/6079
994+
"""
995+
iohandler = IOHandler(context_manager)
996+
errors = []
997+
barrier = threading.Barrier(10)
998+
999+
# Add a small delay inside render_recursively to force thread interleaving,
1000+
# making the race condition deterministic rather than timing-dependent.
1001+
original_render = iohandler.render_recursively
1002+
1003+
def slow_render(key, context):
1004+
result = original_render(key, context)
1005+
time.sleep(0.001)
1006+
return result
1007+
1008+
iohandler.render_recursively = slow_render
1009+
1010+
def render_template(thread_id):
1011+
try:
1012+
barrier.wait(timeout=5)
1013+
for _ in range(20):
1014+
result = iohandler.render(f"hello from thread {thread_id}")
1015+
assert result == f"hello from thread {thread_id}"
1016+
except Exception as e:
1017+
errors.append(e)
1018+
1019+
threads = [threading.Thread(target=render_template, args=(i,)) for i in range(10)]
1020+
for t in threads:
1021+
t.start()
1022+
for t in threads:
1023+
t.join(timeout=30)
1024+
1025+
assert not errors, f"Concurrent render raised errors: {errors}"

0 commit comments

Comments
 (0)