The background memory/skill review thread wrapped its whole body in process-global contextlib.redirect_stdout/stderr(devnull). Those rebind sys.stdout/sys.stderr for the ENTIRE process, so for the full duration of the review (tens of seconds) every other thread — including a gateway event-loop thread driving a Telegram long-poll — also wrote to devnull. Any bare print/sys.stderr.write from those threads during the window was silently lost (#55769 / #55925). Replace the global redirect with thread_scoped_silence(): a per-thread routing proxy installed once as sys.stdout/sys.stderr that sends only the registered (bg-review) thread's writes to devnull and passes every other thread through to the real stream. Depth-counted so nested use composes. Verified: a concurrent thread writing while the bg-review thread is inside the silence window keeps its output on the real stream.
147 lines
4.1 KiB
Python
147 lines
4.1 KiB
Python
"""Tests for agent.thread_scoped_output.thread_scoped_silence.
|
|
|
|
Behaviour contract: a thread inside ``thread_scoped_silence()`` has its
|
|
stdout/stderr routed to devnull, while every OTHER thread keeps writing to the
|
|
real stream — even concurrently, while the first thread is still inside the
|
|
context. This is the property the old process-global
|
|
``contextlib.redirect_stdout(devnull)`` violated (issue #55769 / #55925).
|
|
"""
|
|
|
|
import io
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
from agent.thread_scoped_output import thread_scoped_silence
|
|
|
|
|
|
def _run_with_real_stream(fn):
|
|
"""Bind a StringIO as the real stdout, run fn, return what reached it."""
|
|
real_out = io.StringIO()
|
|
orig = sys.stdout
|
|
sys.stdout = real_out
|
|
try:
|
|
fn()
|
|
finally:
|
|
sys.stdout = orig
|
|
return real_out.getvalue()
|
|
|
|
|
|
def test_current_thread_is_silenced():
|
|
def body():
|
|
with thread_scoped_silence():
|
|
print("dropped")
|
|
print("kept")
|
|
|
|
captured = _run_with_real_stream(body)
|
|
assert "dropped" not in captured
|
|
assert "kept" in captured
|
|
|
|
|
|
def test_concurrent_thread_keeps_output_during_silence_window():
|
|
"""A loud thread writing WHILE another thread is silenced must survive."""
|
|
inside_silence = threading.Event()
|
|
loud_done = threading.Event()
|
|
|
|
def silenced_worker():
|
|
with thread_scoped_silence():
|
|
print("SILENCED")
|
|
inside_silence.set()
|
|
# Hold the silence window until the loud thread has written.
|
|
loud_done.wait(timeout=2.0)
|
|
|
|
def loud_worker():
|
|
inside_silence.wait(timeout=2.0)
|
|
print("LOUD")
|
|
loud_done.set()
|
|
|
|
def body():
|
|
t1 = threading.Thread(target=silenced_worker)
|
|
t2 = threading.Thread(target=loud_worker)
|
|
t1.start()
|
|
t2.start()
|
|
t1.join(timeout=3.0)
|
|
t2.join(timeout=3.0)
|
|
|
|
captured = _run_with_real_stream(body)
|
|
assert "SILENCED" not in captured
|
|
assert "LOUD" in captured
|
|
|
|
|
|
def test_stderr_is_also_routed_per_thread():
|
|
real_err = io.StringIO()
|
|
orig = sys.stderr
|
|
sys.stderr = real_err
|
|
try:
|
|
with thread_scoped_silence():
|
|
sys.stderr.write("err-dropped\n")
|
|
sys.stderr.write("err-kept\n")
|
|
finally:
|
|
sys.stderr = orig
|
|
out = real_err.getvalue()
|
|
assert "err-dropped" not in out
|
|
assert "err-kept" in out
|
|
|
|
|
|
def test_nested_silence_same_thread_composes():
|
|
def body():
|
|
with thread_scoped_silence():
|
|
with thread_scoped_silence():
|
|
print("inner")
|
|
# Still inside the OUTER context — depth-counted, so this thread
|
|
# remains silenced after the inner context exits.
|
|
print("after-inner")
|
|
print("after-outer")
|
|
|
|
captured = _run_with_real_stream(body)
|
|
assert "inner" not in captured
|
|
assert "after-inner" not in captured
|
|
assert "after-outer" in captured
|
|
|
|
|
|
def test_unsilence_cleans_up_after_exit():
|
|
"""After the context exits, the calling thread writes to the real stream."""
|
|
seen = []
|
|
|
|
def body():
|
|
with thread_scoped_silence():
|
|
pass
|
|
print("post")
|
|
seen.append("post")
|
|
|
|
captured = _run_with_real_stream(body)
|
|
assert "post" in captured
|
|
assert seen == ["post"]
|
|
|
|
|
|
def test_many_concurrent_silenced_and_loud_threads():
|
|
"""Stress: interleaved silenced/loud threads keep their respective fates."""
|
|
start = threading.Event()
|
|
results_lock = threading.Lock()
|
|
|
|
def silenced(i):
|
|
start.wait(timeout=2.0)
|
|
with thread_scoped_silence():
|
|
print(f"S{i}")
|
|
time.sleep(0.05)
|
|
|
|
def loud(i):
|
|
start.wait(timeout=2.0)
|
|
time.sleep(0.02)
|
|
print(f"L{i}")
|
|
|
|
def body():
|
|
threads = []
|
|
for i in range(5):
|
|
threads.append(threading.Thread(target=silenced, args=(i,)))
|
|
threads.append(threading.Thread(target=loud, args=(i,)))
|
|
for t in threads:
|
|
t.start()
|
|
start.set()
|
|
for t in threads:
|
|
t.join(timeout=3.0)
|
|
|
|
captured = _run_with_real_stream(body)
|
|
for i in range(5):
|
|
assert f"S{i}" not in captured, f"silenced S{i} leaked"
|
|
assert f"L{i}" in captured, f"loud L{i} swallowed"
|