From ff54f93397a443298dcd8bd250965b35d7b0699e Mon Sep 17 00:00:00 2001 From: fusion44 Date: Fri, 3 Jul 2026 19:59:57 +0200 Subject: [PATCH] fix(api): catch subprocess timeouts in exec_bash_command 'from redis.asyncio import ... TimeoutError' shadowed the builtin TimeoutError. redis's TimeoutError is a RedisError subclass, not a builtin subclass, so the 'except TimeoutError' guarding asyncio.wait_for never matched: timed-out commands fell through to the generic handler, the child process was never terminated (leak), and the caller got a misleading 'unable to execute' error instead of a timeout. Co-Authored-By: Claude Fable 5 --- app/api/utils.py | 4 +++- tests/test_exec_bash_timeout.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/test_exec_bash_timeout.py diff --git a/app/api/utils.py b/app/api/utils.py index 0199343..45bb568 100644 --- a/app/api/utils.py +++ b/app/api/utils.py @@ -9,7 +9,9 @@ from typing import Any, Dict, Optional from fastapi.encoders import jsonable_encoder from loguru import logger -from redis.asyncio import Redis, TimeoutError +# NB: do not import redis's TimeoutError here — it would shadow the builtin +# and asyncio.wait_for's builtin TimeoutError would never be caught below. +from redis.asyncio import Redis from app.api.error_report.report import Report from app.api.models import ProcessResult diff --git a/tests/test_exec_bash_timeout.py b/tests/test_exec_bash_timeout.py new file mode 100644 index 0000000..8538e1d --- /dev/null +++ b/tests/test_exec_bash_timeout.py @@ -0,0 +1,23 @@ +""" +Regression test: exec_bash_command must actually catch the timeout raised +by asyncio.wait_for. + +`from redis.asyncio import ... TimeoutError` used to shadow the builtin, so +`except TimeoutError` never matched asyncio's builtin TimeoutError. Timed-out +commands fell through to the generic handler, the child process was never +terminated, and the caller got a misleading "unable to execute" error. +""" + +from app.api.utils import exec_bash_command +from app.external.result_type.src.result.result import Err + + +async def test_exec_bash_command_reports_timeout(): + # `bash -c 'sleep 5'` with a 0.5s budget must time out + res = await exec_bash_command("-c 'sleep 5'", timeout=0.5) + + assert isinstance(res, Err), "a timed-out command must return an Err" + message = res.err_value.format_verbose().lower() + assert "timed out" in message, ( + f"expected a timeout error, got: {message}" + )