feat: Replace Docker monkey-patch with timeout
CI / unit-tests (push) Successful in 45s
CI / integration-tests (push) Successful in 1m10s
CI / publish (push) Failing after 15s

This commit is contained in:
2026-06-09 10:39:12 +02:00
parent 0b2d2982ab
commit a9b860de13
+24 -44
View File
@@ -247,12 +247,15 @@ class DockerSandbox:
# Command execution # Command execution
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def exec(self, command: str, timeout: int = 120) -> tuple[int, str]: def exec(self, command: str, timeout: int = 120, kill_grace: int = 5) -> tuple[int, str]:
""" """
Run *command* inside the container via the low-level exec API. Run *command* inside the container via the low-level exec API.
Returns ``(exit_code, combined_stdout_stderr)``. Returns ``(exit_code, combined_stdout_stderr)``.
Timeout is enforced via a socket-level timeout on the exec socket.
Exit code 124 indicates a timeout (137 if escalation to SIGKILL was
needed). *command* is passed as a single argv element to ``bash -c``,
so shell metacharacters within it need no extra escaping.
""" """
if self._container is None: if self._container is None:
return 1, "Sandbox container is not running." return 1, "Sandbox container is not running."
@@ -261,60 +264,37 @@ class DockerSandbox:
if self._working_dir is not None: if self._working_dir is not None:
create_kwargs["workdir"] = self._working_dir create_kwargs["workdir"] = self._working_dir
# TODO(fragile): timeout enforcement relies on private docker-py internals wrapped = [
# (frames_iter, demux_adaptor, consume_socket_output from docker.utils.socket) "timeout",
# and monkey-patches select.select / select.poll for the duration of the read "--kill-after",
# — not thread-safe if multiple exec() calls run concurrently. Replace when f"{kill_grace}s",
# docker-py adds native per-call timeout support. f"{timeout}s",
# See https://github.com/docker/docker-py/issues/2651 "bash",
# "-c",
# On Linux docker-py uses select.poll (not select.select), so both are patched. command,
]
try: try:
exec_id = self._client.api.exec_create( exec_id = self._client.api.exec_create(
self._container.id, self._container.id,
["bash", "-c", command], wrapped,
stdout=True, stdout=True,
stderr=True, stderr=True,
**create_kwargs, **create_kwargs,
) )
sock = self._client.api.exec_start(exec_id["Id"], socket=True) sock = self._client.api.exec_start(exec_id["Id"], socket=True)
sock._sock.settimeout(timeout)
timeout_ms = timeout * 1000 gen = (demux_adaptor(*frame) for frame in frames_iter(sock, tty=False))
stdout, stderr = consume_socket_output(gen, demux=True)
class _PollWithTimeout:
def __init__(self):
self._inner = _original_poll()
def register(self, fd, eventmask):
return self._inner.register(fd, eventmask)
def poll(self, *args):
result = self._inner.poll(timeout_ms)
if not result:
raise _socket.timeout(f"timed out after {timeout}s")
return result
with ExitStack() as stack:
stack.enter_context(patch.object(
_select, "select",
new=lambda rlist, wlist, xlist: _original_select(
rlist, wlist, xlist, timeout
),
))
if _original_poll is not None:
stack.enter_context(
patch.object(_select, "poll", new=_PollWithTimeout)
)
gen = (demux_adaptor(*frame) for frame in frames_iter(sock, tty=False))
stdout, stderr = consume_socket_output(gen, demux=True)
exit_code = self._client.api.exec_inspect(exec_id["Id"])["ExitCode"] exit_code = self._client.api.exec_inspect(exec_id["Id"])["ExitCode"]
if exit_code is None: if exit_code is None:
exit_code = 0 exit_code = 0
output = (stdout or b"") + (stderr or b"")
return exit_code, output.decode("utf-8", errors="replace") output = ((stdout or b"") + (stderr or b"")).decode("utf-8", errors="replace")
except _socket.timeout:
return 124, f"Command timed out after {timeout}s" if exit_code in (124, 137):
return exit_code, output + f"\n[command timed out after {timeout}s]"
return exit_code, output
except Exception as exc: except Exception as exc:
return 1, f"Error running command in container: {exc}" return 1, f"Error running command in container: {exc}"