diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e90de..93f3c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Fixed + +- The Docker backend no longer reports an exit code 124 or 137 before the time limit as a + timeout. Such a result now keeps its real exit code, stdout and stderr, plus a note with + the elapsed time. A 137 before the limit usually means an outside SIGKILL, for example the + kernel out-of-memory killer, and a 124 can come from a nested `timeout` in the command. +- Error results from code execution tools now include the stdout of the command when it is + not empty, so the partial output of a timed-out command reaches the model. + ## v0.2.1 (2026-10-22) ### Changed diff --git a/src/stirrup/tools/code_backends/base.py b/src/stirrup/tools/code_backends/base.py index a829d72..422566d 100644 --- a/src/stirrup/tools/code_backends/base.py +++ b/src/stirrup/tools/code_backends/base.py @@ -393,6 +393,8 @@ def format_result(result: CommandResult) -> ToolResult[ToolUseCountMetadata]: f"\n{result.error_kind}" f"\n
{truncate_msg(result.stderr, MAX_LENGTH_SHELL_STDERR)}
" ) + if result.stdout: + content += f"\n{truncate_msg(result.stdout, MAX_LENGTH_SHELL_STDOUT)}" if result.advice: content += f"\n{result.advice}" content += "\n" diff --git a/src/stirrup/tools/code_backends/docker.py b/src/stirrup/tools/code_backends/docker.py index 1067dcb..2439926 100644 --- a/src/stirrup/tools/code_backends/docker.py +++ b/src/stirrup/tools/code_backends/docker.py @@ -2,11 +2,13 @@ import contextlib import hashlib +import math import os import shlex import shutil import tempfile from pathlib import Path, PurePosixPath +from time import perf_counter from typing import ClassVar, Self import anyio @@ -19,7 +21,7 @@ import docker from docker.client import DockerClient from docker.errors import APIError, BuildError, ImageNotFound, NotFound - from docker.models.containers import Container + from docker.models.containers import Container, ExecResult except ImportError as e: raise ImportError( "Requires installation of the docker extra. Install with (for example): `uv pip install stirrup[docker]` or `uv add stirrup[docker]`", @@ -619,8 +621,13 @@ async def _run_command_unchecked( # It runs the command in its own process group and sends SIGKILL to the # whole group after --kill-after, so descendants can't leak as orphans # parented to container PID 1 when the client gives up (moby/moby#9098). - # Exit 124 == SIGTERM expiry, 137 == --kill-after SIGKILL; both map to - # error_kind="timeout" for the caller. + # Exit 124 == SIGTERM expiry at the limit. timeout(1) also exits 137 when + # the child dies by SIGKILL after the TERM at the limit (it keeps the + # child's status, and --kill-after sends SIGKILL at limit + 5s). So the + # threshold below is the limit itself, not limit + kill-after. A 124 or + # 137 before the limit is not a timeout. A 124 can come from the command + # itself (e.g. its own nested `timeout`). A 137 usually means a SIGKILL + # from elsewhere, possibly the kernel out-of-memory killer. if argv is not None: exec_cmd = ["timeout", "--kill-after=5s", f"{timeout}s", *argv] else: @@ -630,19 +637,25 @@ async def _run_command_unchecked( try: # Outer fail_after is a safety net for a stalled docker socket; the # in-container timeout(1) should fire first in the normal case. - with fail_after(timeout + 10): - exec_result = await to_thread.run_sync( - lambda: container.exec_run( - cmd=exec_cmd, - workdir=self._working_dir, - demux=True, # Separate stdout/stderr - ) + # The clock runs in the worker thread around exec_run only, so the + # wait for a free thread is not counted. Docker API overhead is + # counted, which can only push a result towards "timeout". + def timed_exec_run() -> tuple[ExecResult, float]: + start_time = perf_counter() + result = container.exec_run( + cmd=exec_cmd, + workdir=self._working_dir, + demux=True, # Separate stdout/stderr ) + return result, perf_counter() - start_time + + with fail_after(timeout + 10): + exec_result, elapsed = await to_thread.run_sync(timed_exec_run) exit_code = exec_result.exit_code stdout_bytes, stderr_bytes = exec_result.output - if exit_code in (124, 137): + if exit_code in (124, 137) and elapsed >= timeout: logger.warning("Command timed out after %d seconds: %s", timeout, cmd[:100]) partial_stderr = (stderr_bytes or b"").decode("utf-8", errors="replace") timeout_msg = f"Command timed out after {timeout} seconds" @@ -656,6 +669,16 @@ async def _run_command_unchecked( stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace") + if exit_code in (124, 137): + # Round down, so the shown time stays below the limit (29.996 -> "29.99", not "30.00"). + shown = f"{math.floor(elapsed * 100) / 100:.2f}" + early_msg = f"Exit code {exit_code} after {shown} seconds, before the {timeout} second limit. " + ( + "The limit did not stop this command." + if exit_code == 124 + else "This usually means the process was killed with SIGKILL, possibly by the out-of-memory killer." + ) + stderr_text = f"{stderr_text}\n{early_msg}" if stderr_text else early_msg + # Heuristic: exit 127 + a "timeout: ... not found" message from the # shell means coreutils timeout(1) is absent from the base image, so # the wrapper command never ran. Surface a clear pointer instead of diff --git a/tests/test_docker_execution.py b/tests/test_docker_execution.py index b3daaf7..4f34e64 100644 --- a/tests/test_docker_execution.py +++ b/tests/test_docker_execution.py @@ -2,6 +2,7 @@ from __future__ import annotations +import itertools import shlex from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -13,7 +14,7 @@ from docker.errors import APIError, ImageNotFound -from stirrup.tools.code_backends.base import SHELL_TIMEOUT, CommandResult +from stirrup.tools.code_backends.base import SHELL_TIMEOUT, CommandResult, format_result from stirrup.tools.code_backends.docker import ( DEFAULT_WORKING_DIR, DockerCodeExecToolProvider, @@ -141,10 +142,11 @@ async def test_run_command_wraps_with_timeout(self, mock_docker_client: MagicMoc async def test_run_command_timeout_exit_codes_map_to_timeout( self, mock_docker_client: MagicMock, tmp_path: Path ) -> None: - """Exit 124 (SIGTERM expiry) and 137 (--kill-after SIGKILL) both surface as timeouts.""" + """Exit 124 or 137 at or past the limit surfaces as a timeout.""" provider = DockerCodeExecToolProvider.from_image("python:3.12-slim", temp_base_dir=tmp_path) - for exit_code in (124, 137): + # Elapsed equal to the 1 second limit is the boundary, and it is a timeout. + for exit_code, elapsed in ((124, 1.0), (124, 1.5), (137, 6.0), (137, 1.0)): mock_exec_result = MagicMock() mock_exec_result.exit_code = exit_code mock_exec_result.output = (b"partial-stdout", b"partial-stderr") @@ -153,18 +155,60 @@ async def test_run_command_timeout_exit_codes_map_to_timeout( with ( patch("stirrup.tools.code_backends.docker.docker.from_env", return_value=mock_docker_client), patch("stirrup.tools.code_backends.docker.to_thread") as mock_to_thread, + patch( + "stirrup.tools.code_backends.docker.perf_counter", + side_effect=itertools.chain([0.0, elapsed], itertools.repeat(elapsed)), + ), ): mock_to_thread.run_sync = AsyncMock(side_effect=lambda fn, *args: fn(*args) if not args else fn) async with provider as _: result = await provider.run_command("sleep 999", timeout=1) - assert result.error_kind == "timeout", f"exit {exit_code} should map to timeout" + case = f"exit {exit_code} after {elapsed}s" + assert result.error_kind == "timeout", f"{case} should map to timeout" assert result.exit_code == exit_code assert "partial-stdout" in result.stdout assert "partial-stderr" in result.stderr assert "timed out" in result.stderr.lower() + async def test_run_command_early_exit_124_or_137_is_not_timeout( + self, mock_docker_client: MagicMock, tmp_path: Path + ) -> None: + """Exit 124 or 137 before the limit is not a timeout (e.g. a nested `timeout`, or the OOM killer).""" + provider = DockerCodeExecToolProvider.from_image("python:3.12-slim", temp_base_dir=tmp_path) + + for exit_code, explanation in ( + (124, "the limit did not stop this command."), + (137, "this usually means the process was killed with sigkill"), + ): + mock_exec_result = MagicMock() + mock_exec_result.exit_code = exit_code + mock_exec_result.output = (b"partial-stdout", b"partial-stderr") + mock_docker_client.containers.run.return_value.exec_run = MagicMock(return_value=mock_exec_result) + + with ( + patch("stirrup.tools.code_backends.docker.docker.from_env", return_value=mock_docker_client), + patch("stirrup.tools.code_backends.docker.to_thread") as mock_to_thread, + patch( + "stirrup.tools.code_backends.docker.perf_counter", + side_effect=itertools.chain([0.0, 2.0], itertools.repeat(2.0)), + ), + ): + mock_to_thread.run_sync = AsyncMock(side_effect=lambda fn, *args: fn(*args) if not args else fn) + + async with provider as _: + result = await provider.run_command("timeout 2 sleep 60", timeout=30) + + stderr = result.stderr.lower() + assert result.error_kind is None, f"exit {exit_code} before the limit is not a timeout" + assert result.exit_code == exit_code + assert "partial-stdout" in result.stdout + assert "partial-stderr" in stderr + assert f"exit code {exit_code} after 2.00 seconds, before the 30 second limit" in stderr + assert explanation in stderr + assert "timed out" not in stderr + async def test_run_command_missing_timeout_binary_advice( self, mock_docker_client: MagicMock, tmp_path: Path ) -> None: @@ -443,3 +487,22 @@ def test_image_prep_lock_is_shared_per_image_name(self) -> None: assert lock_a1 is lock_a2 assert lock_a1 is not lock_b + + +def test_format_result_error_branch_stdout() -> None: + """Error results show non-empty stdout. With empty stdout the text is unchanged.""" + with_stdout = format_result( + CommandResult(exit_code=124, stdout="partial", stderr="Command timed out after 3 seconds", error_kind="timeout") + ) + assert "\npartial" in with_stdout.content + assert "" not in with_stdout.content + + without_stdout = format_result( + CommandResult(exit_code=124, stdout="", stderr="Command timed out after 3 seconds", error_kind="timeout") + ) + assert without_stdout.content == ( + "" + "\ntimeout" + "\n
Command timed out after 3 seconds
" + "\n
" + )