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}" + )