Skip to content

Commit a4cd872

Browse files
committed
test(cli): cover the retry bound and cancellation, refresh a stale comment
The bound and the cancellation behaviour were verified by hand but not pinned by the suite, so both could regress silently. - A reader whose readline() raises without consuming anything must stop after MAX_CONSECUTIVE_READ_ERRORS rather than spinning. This is the case the bound exists for, and it hangs the suite if the bound is removed. - Cancelling the streaming task must raise CancelledError out of it, since the auto-reload path cancels these tasks and swallowing it would hang restarts. Also corrects the outer handler's comment, which said make_logger pins loggers to INFO. That was true when it was written and is no longer, since LOG_LEVEL is now honored. It now records what the handler is for: the escalation path for the bounded re-raise, and why cancellation passes straight through it.
1 parent 35a0e5e commit a4cd872

2 files changed

Lines changed: 57 additions & 3 deletions

File tree

src/agentex/lib/cli/handlers/run_handlers.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,12 @@ async def stream_process_output(process: asyncio.subprocess.Process, prefix: str
297297
if decoded_line: # Only print non-empty lines
298298
console.print(f"[dim]{prefix}:[/dim] {decoded_line}")
299299
except Exception as e:
300-
# Anything reaching here ends the loop, so the child is now at risk of
301-
# blocking on a full pipe. make_logger pins loggers to INFO, so the
302-
# previous debug() here could never be emitted.
300+
# The escalation path, including for the re-raise above. Anything reaching
301+
# here ends the loop, so the child is now at risk of blocking on a full pipe.
302+
# Warning rather than debug: this used to be a debug() that make_logger could
303+
# never emit, which is why three freezes produced no clue.
304+
# CancelledError derives from BaseException, so the auto-reload path that
305+
# cancels these tasks passes straight through and is unaffected.
303306
logger.warning(
304307
f"Output streaming for {prefix} stopped on {e!r}. "
305308
f"Nothing is draining its stdout now, so {prefix} will hang once the pipe fills."

tests/lib/cli/test_run_handlers_streaming.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,57 @@ async def test_large_line_within_the_limit_is_streamed_in_full(
9393
assert out.count(MARKER) == oversized, "the large line was dropped rather than streamed"
9494

9595

96+
class _AlwaysFailingReader:
97+
"""A reader whose readline() raises without consuming anything.
98+
99+
The dangerous shape: skipping it makes no progress, so an unbounded retry
100+
would spin at 100% CPU while still not draining the pipe.
101+
"""
102+
103+
def __init__(self) -> None:
104+
self.attempts = 0
105+
106+
async def readline(self) -> bytes:
107+
self.attempts += 1
108+
raise ValueError("unreadable, and nothing was consumed")
109+
110+
111+
class _FakeProcess:
112+
def __init__(self, stdout: Any) -> None:
113+
self.stdout = stdout
114+
115+
116+
async def test_repeated_unreadable_lines_give_up_instead_of_spinning() -> None:
117+
"""A ValueError that consumes nothing must not loop forever."""
118+
reader = _AlwaysFailingReader()
119+
120+
await asyncio.wait_for(
121+
stream_process_output(_FakeProcess(reader), "TEST"), timeout=30
122+
)
123+
124+
assert reader.attempts == run_handlers.MAX_CONSECUTIVE_READ_ERRORS + 1
125+
126+
127+
async def test_cancellation_is_not_swallowed() -> None:
128+
"""The auto-reload path cancels these tasks, so cancel must propagate.
129+
130+
CancelledError derives from BaseException, so the outer `except Exception`
131+
does not catch it. This pins that, since swallowing it would hang restarts.
132+
"""
133+
134+
class _NeverReturns:
135+
async def readline(self) -> bytes:
136+
await asyncio.sleep(3600)
137+
return b""
138+
139+
task = asyncio.create_task(stream_process_output(_FakeProcess(_NeverReturns()), "TEST"))
140+
await asyncio.sleep(0)
141+
task.cancel()
142+
143+
with pytest.raises(asyncio.CancelledError):
144+
await task
145+
146+
96147
async def test_agent_subprocesses_are_spawned_with_the_larger_limit(
97148
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
98149
) -> None:

0 commit comments

Comments
 (0)