Skip to content

Commit 5f3ef0f

Browse files
committed
fix(tools): use explicit waiter for bash timeout/cancel cleanup
Drop gather+shield so a setsid descendant holding a pipe cannot leave process.wait() pending after timeout, and so Python 3.14 does not log CancelledError from shielded futures. Track readers and an explicit waiter task, finish all three on timeout/cancel, and cover the setsid timeout path with a regression test.
1 parent febfd07 commit 5f3ef0f

2 files changed

Lines changed: 136 additions & 51 deletions

File tree

‎src/google/adk/tools/bash_tool.py‎

Lines changed: 46 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,14 @@ async def _drain_stream(
111111
output.append(chunk)
112112

113113

114-
async def _finish_readers(readers: list[asyncio.Task[None]]) -> None:
115-
"""Waits briefly for readers to reach EOF, then cancels any stragglers."""
116-
_, pending = await asyncio.wait(readers, timeout=_DRAIN_GRACE_SECONDS)
114+
async def _finish_tasks(tasks: list[asyncio.Task]) -> None:
115+
"""Waits briefly for tasks to finish, then cancels any stragglers."""
116+
if not tasks:
117+
return
118+
_, pending = await asyncio.wait(tasks, timeout=_DRAIN_GRACE_SECONDS)
117119
for task in pending:
118120
task.cancel()
119-
await asyncio.gather(*readers, return_exceptions=True)
121+
await asyncio.gather(*tasks, return_exceptions=True)
120122

121123

122124
@dataclasses.dataclass(frozen=True)
@@ -321,9 +323,9 @@ async def run_async(
321323

322324
stdout = _BoundedOutput(self._policy.max_output_bytes_per_stream)
323325
stderr = _BoundedOutput(self._policy.max_output_bytes_per_stream)
324-
readers: list[asyncio.Task[None]] = []
326+
readers: list[asyncio.Task] = []
327+
waiter: Optional[asyncio.Task] = None
325328
process: Optional[asyncio.subprocess.Process] = None
326-
completion: Optional[asyncio.Future[Any]] = None
327329
try:
328330
process = await asyncio.create_subprocess_exec(
329331
*shlex.split(command),
@@ -337,32 +339,40 @@ async def run_async(
337339
asyncio.create_task(_drain_stream(process.stdout, stdout)),
338340
asyncio.create_task(_drain_stream(process.stderr, stderr)),
339341
]
340-
341-
completion = asyncio.gather(*readers, process.wait())
342+
# Explicit waiter instead of gather+shield: on 3.14 shield logs
343+
# CancelledError, and a gather's process.wait() can stay pending when
344+
# a setsid descendant keeps a pipe open after timeout cancel.
345+
waiter = asyncio.create_task(process.wait())
346+
tracked = [*readers, waiter]
342347
try:
343-
# Shield so a timeout does not cancel the readers: they must keep
344-
# draining the pipes until EOF for the killed process to be reaped.
345-
await asyncio.wait_for(
346-
asyncio.shield(completion),
348+
_done, pending = await asyncio.wait(
349+
tracked,
347350
timeout=self._policy.timeout_seconds,
351+
return_when=asyncio.ALL_COMPLETED,
348352
)
349-
except asyncio.TimeoutError:
350-
try:
351-
if process.pid:
352-
_kill_process_group(process)
353-
except ProcessLookupError:
354-
pass
355-
await _finish_readers(readers)
356-
await process.wait()
357-
return {
358-
"error": (
359-
f"Command timed out after {self._policy.timeout_seconds}"
360-
" seconds."
361-
),
362-
"stdout": stdout.render("stdout"),
363-
"stderr": stderr.render("stderr"),
364-
"returncode": process.returncode,
365-
}
353+
if pending:
354+
try:
355+
if process.pid:
356+
_kill_process_group(process)
357+
except ProcessLookupError:
358+
pass
359+
await _finish_tasks(tracked)
360+
if process.returncode is None:
361+
try:
362+
await asyncio.wait_for(
363+
process.wait(), timeout=_DRAIN_GRACE_SECONDS
364+
)
365+
except asyncio.TimeoutError:
366+
pass
367+
return {
368+
"error": (
369+
f"Command timed out after {self._policy.timeout_seconds}"
370+
" seconds."
371+
),
372+
"stdout": stdout.render("stdout"),
373+
"stderr": stderr.render("stderr"),
374+
"returncode": process.returncode,
375+
}
366376
finally:
367377
try:
368378
if process.pid:
@@ -382,26 +392,17 @@ async def run_async(
382392
_kill_process_group(process)
383393
except ProcessLookupError:
384394
pass
385-
if completion is not None and not completion.done():
386-
completion.cancel()
387-
for task in readers:
395+
tracked = [t for t in (*readers, waiter) if t is not None]
396+
for task in tracked:
388397
task.cancel()
389-
if readers:
390-
await asyncio.gather(*readers, return_exceptions=True)
391-
if completion is not None:
392-
# Retrieve the cancelled gather's result so asyncio does not report
393-
# "_GatheringFuture exception was never retrieved".
394-
await asyncio.gather(completion, return_exceptions=True)
395-
if process is not None and process.returncode is None:
396-
try:
397-
await asyncio.shield(process.wait())
398-
except Exception: # pylint: disable=broad-except
399-
pass
398+
if tracked:
399+
await asyncio.gather(*tracked, return_exceptions=True)
400400
raise
401401
except Exception as e: # pylint: disable=broad-except
402402
logger.exception("ExecuteBashTool execution failed")
403-
if readers:
404-
await _finish_readers(readers)
403+
tracked = [t for t in (*readers, waiter) if t is not None]
404+
if tracked:
405+
await _finish_tasks(tracked)
405406
return {
406407
"error": f"Execution failed: {str(e)}",
407408
"stdout": stdout.render("stdout"),

‎tests/unittests/tools/test_bash_tool.py‎

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -295,22 +295,36 @@ async def test_nonzero_returncode(self, workspace, tool_context_confirmed):
295295

296296
@pytest.mark.asyncio
297297
async def test_timeout(self, workspace, tool_context_confirmed):
298-
tool = bash_tool.ExecuteBashTool(workspace=workspace)
298+
tool = bash_tool.ExecuteBashTool(
299+
workspace=workspace,
300+
policy=bash_tool.BashToolPolicy(timeout_seconds=0),
301+
)
302+
exit_fut = asyncio.get_running_loop().create_future()
299303
mock_process = mock.AsyncMock()
300304
mock_process.pid = 12345
301305
mock_process.stdout = None
302306
mock_process.stderr = None
307+
mock_process.returncode = None
308+
309+
async def _wait():
310+
if mock_process.returncode is not None:
311+
return mock_process.returncode
312+
return await exit_fut
313+
314+
def _killpg(pid, sig):
315+
mock_process.returncode = -sig
316+
if not exit_fut.done():
317+
exit_fut.set_result(-sig)
318+
319+
mock_process.wait = _wait
303320
with (
304321
mock.patch.object(
305322
asyncio,
306323
"create_subprocess_exec",
307324
autospec=True,
308325
return_value=mock_process,
309326
),
310-
mock.patch.object(
311-
asyncio, "wait_for", autospec=True, side_effect=asyncio.TimeoutError
312-
),
313-
mock.patch("os.killpg") as mock_killpg,
327+
mock.patch("os.killpg", side_effect=_killpg) as mock_killpg,
314328
):
315329
result = await tool.run_async(
316330
args={"command": "python scripts/do_thing.py"},
@@ -469,6 +483,73 @@ async def test_timeout_output_is_bounded_and_process_reaped(
469483
assert len(result["stdout"]) < 300
470484
assert result["returncode"] == -signal.SIGKILL
471485

486+
@pytest.mark.asyncio
487+
async def test_timeout_setsid_child_holding_pipe_cleans_up(
488+
self, workspace, tool_context_confirmed
489+
):
490+
"""Timeout must not leave wait/drain tasks pending when a setsid child holds a pipe.
491+
492+
A descendant that calls setsid() leaves the process group, so killpg does
493+
not close its inherited stdout. Cleanup must cancel the explicit waiter and
494+
readers instead of leaving process.wait() pending inside a gather.
495+
"""
496+
import time
497+
498+
policy = bash_tool.BashToolPolicy(
499+
timeout_seconds=1, max_output_bytes_per_stream=64
500+
)
501+
tool = bash_tool.ExecuteBashTool(workspace=workspace, policy=policy)
502+
(workspace / "setsid_hold.py").write_text(
503+
"import os\n"
504+
"import sys\n"
505+
"import time\n"
506+
"if os.fork() == 0:\n"
507+
" os.setsid()\n"
508+
" while True:\n"
509+
' sys.stdout.buffer.write(b"z" * 1024)\n'
510+
" sys.stdout.buffer.flush()\n"
511+
" time.sleep(0.05)\n"
512+
"else:\n"
513+
" time.sleep(60)\n"
514+
)
515+
loop = asyncio.get_running_loop()
516+
reports = []
517+
previous_handler = loop.get_exception_handler()
518+
loop.set_exception_handler(lambda _, context: reports.append(context))
519+
try:
520+
started = time.monotonic()
521+
result = await tool.run_async(
522+
args={"command": "python3 setsid_hold.py"},
523+
tool_context=tool_context_confirmed,
524+
)
525+
elapsed = time.monotonic() - started
526+
for _ in range(5):
527+
await asyncio.sleep(0)
528+
gc.collect()
529+
await asyncio.sleep(0)
530+
finally:
531+
loop.set_exception_handler(previous_handler)
532+
533+
assert "timed out" in result["error"]
534+
# Must return after timeout + drain grace, not after the child's 60s sleep.
535+
assert elapsed < 5.0
536+
leftover = [
537+
t
538+
for t in asyncio.all_tasks()
539+
if not t.done()
540+
and t is not asyncio.current_task()
541+
and getattr(getattr(t, "get_coro", lambda: None)(), "__name__", None)
542+
in {"_drain_stream", "wait"}
543+
]
544+
assert leftover == []
545+
noisy = [
546+
r
547+
for r in reports
548+
if "never retrieved" in r.get("message", "")
549+
or "shielded" in r.get("message", "").lower()
550+
]
551+
assert noisy == []
552+
472553
def test_rejects_negative_output_limit(self):
473554
with pytest.raises(ValueError, match="max_output_bytes_per_stream"):
474555
bash_tool.BashToolPolicy(max_output_bytes_per_stream=-1)
@@ -574,5 +655,8 @@ async def test_cancellation_retrieves_completion_exception(
574655
finally:
575656
loop.set_exception_handler(previous_handler)
576657
assert [
577-
r for r in reports if "never retrieved" in r.get("message", "")
658+
r
659+
for r in reports
660+
if "never retrieved" in r.get("message", "")
661+
or "shielded" in r.get("message", "").lower()
578662
] == []

0 commit comments

Comments
 (0)