From ee034f44e9e7a9aa37fd518472ee62f765fda357 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 15:58:52 -0300 Subject: [PATCH] feat(devops): set docker hard stop timeout to 12s (#35) --- backend/src/simples_backend/config.py | 2 + backend/src/simples_backend/routes/run_ws.py | 2 +- .../services/execution_strategy.py | 20 ++- backend/tests/test_execution_strategy.py | 136 ++++++++---------- docker-compose.yml | 1 + 5 files changed, 78 insertions(+), 83 deletions(-) diff --git a/backend/src/simples_backend/config.py b/backend/src/simples_backend/config.py index fcbe940..7237925 100644 --- a/backend/src/simples_backend/config.py +++ b/backend/src/simples_backend/config.py @@ -13,6 +13,7 @@ class Settings: compile_timeout_s: int = 15 max_code_kb: int = 64 sandbox_image: str = "simples-runner:latest" + stop_timeout_s: int = 12 _REQUIRED_ENV_VARS = ("SUPABASE_URL", "JWT_SECRET") @@ -44,4 +45,5 @@ def load_settings(environ: Mapping[str, str] | None = None) -> Settings: compile_timeout_s=_int_env(env, "COMPILE_TIMEOUT_S", 15), max_code_kb=_int_env(env, "MAX_CODE_KB", 64), sandbox_image=env.get("SANDBOX_IMAGE", "simples-runner:latest"), + stop_timeout_s=_int_env(env, "STOP_TIMEOUT_S", 12), ) diff --git a/backend/src/simples_backend/routes/run_ws.py b/backend/src/simples_backend/routes/run_ws.py index edb93b6..1d84276 100644 --- a/backend/src/simples_backend/routes/run_ws.py +++ b/backend/src/simples_backend/routes/run_ws.py @@ -66,7 +66,7 @@ def handle_compile_and_run(ws, code: str, settings: Settings) -> ConnectionState _send(ws, {"type": "exec_started"}) - strategy = PtyExecutionStrategy(image=settings.sandbox_image) + strategy = PtyExecutionStrategy(image=settings.sandbox_image, stop_timeout_s=settings.stop_timeout_s) result = strategy.execute(tmpdir, ws, settings.exec_timeout_s) if not result.timed_out: diff --git a/backend/src/simples_backend/services/execution_strategy.py b/backend/src/simples_backend/services/execution_strategy.py index 67a4a7c..d2a6120 100644 --- a/backend/src/simples_backend/services/execution_strategy.py +++ b/backend/src/simples_backend/services/execution_strategy.py @@ -27,14 +27,13 @@ def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: class PtyExecutionStrategy(ExecutionStrategy): - def __init__(self, image: str = "simples-runner:latest"): + def __init__(self, image: str = "simples-runner:latest", stop_timeout_s: int = 12): self.image = image + self.stop_timeout_s = stop_timeout_s self.client = docker.from_env() def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: - container = self.client.containers.create( - image=self.image, - command=["/usr/bin/qemu-i386-static", "/sandbox/programa"], + host_config = self.client.api.create_host_config( network_mode="none", mem_limit="128m", memswap_limit="128m", @@ -42,12 +41,19 @@ def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: pids_limit=64, read_only=False, tmpfs={"/tmp": "size=8m"}, - user="65534:65534", cap_drop=["ALL"], + ) + container_id = self.client.api.create_container( + image=self.image, + command=["/usr/bin/qemu-i386-static", "/sandbox/programa"], + user="65534:65534", stdin_open=True, tty=True, detach=True, - ) + host_config=host_config, + stop_timeout=self.stop_timeout_s, + )["Id"] + container = self.client.containers.get(container_id) binary_path = os.path.join(binary_dir, "programa") buf = io.BytesIO() @@ -127,7 +133,7 @@ def _reader() -> None: elapsed = time.monotonic() - start if elapsed > timeout_s: try: - container.stop(timeout=10) + container.stop(timeout=self.stop_timeout_s) except Exception: pass timed_out = True diff --git a/backend/tests/test_execution_strategy.py b/backend/tests/test_execution_strategy.py index 79a9f69..7b1dc09 100644 --- a/backend/tests/test_execution_strategy.py +++ b/backend/tests/test_execution_strategy.py @@ -59,20 +59,33 @@ def test_init_custom_image(self, mock_docker): strategy = PtyExecutionStrategy(image="custom:tag") assert strategy.image == "custom:tag" - @patch("simples_backend.services.execution_strategy.docker") - def test_execute_creates_container_with_correct_params(self, mock_docker, binary_dir): - mock_client = MagicMock() - mock_docker.from_env.return_value = mock_client + def _setup_mocks(self, mock_client, binary_dir, recv_side_effect=None, wait_status=0, ws_receive=None): mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container mock_sock = MagicMock() mock_sock._sock = MagicMock() mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.recv.side_effect = [b""] mock_sock._sock.setblocking = MagicMock() - mock_container.wait.return_value = {"StatusCode": 0} + + if recv_side_effect is not None: + mock_sock._sock.recv.side_effect = recv_side_effect + else: + mock_sock._sock.recv.side_effect = [b""] + + mock_container.wait.return_value = {"StatusCode": wait_status} mock_ws = MagicMock() - mock_ws.receive.return_value = None + mock_ws.receive.return_value = ws_receive + + mock_client.api.create_host_config.return_value = {"NetworkMode": "none"} + mock_client.api.create_container.return_value = {"Id": "fake-container-id"} + mock_client.containers.get.return_value = mock_container + + return mock_container, mock_sock, mock_ws + + @patch("simples_backend.services.execution_strategy.docker") + def test_execute_creates_container_with_correct_params(self, mock_docker, binary_dir): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container, mock_sock, mock_ws = self._setup_mocks(mock_client, binary_dir) strategy = PtyExecutionStrategy() result = strategy.execute(binary_dir, mock_ws, timeout_s=10) @@ -82,9 +95,7 @@ def test_execute_creates_container_with_correct_params(self, mock_docker, binary assert result.duration_ms >= 0 assert result.timed_out is False - mock_client.containers.create.assert_called_once_with( - image="simples-runner:latest", - command=["/usr/bin/qemu-i386-static", "/sandbox/programa"], + mock_client.api.create_host_config.assert_called_once_with( network_mode="none", mem_limit="128m", memswap_limit="128m", @@ -92,27 +103,25 @@ def test_execute_creates_container_with_correct_params(self, mock_docker, binary pids_limit=64, read_only=False, tmpfs={"/tmp": "size=8m"}, - user="65534:65534", cap_drop=["ALL"], + ) + mock_client.api.create_container.assert_called_once_with( + image="simples-runner:latest", + command=["/usr/bin/qemu-i386-static", "/sandbox/programa"], + user="65534:65534", stdin_open=True, tty=True, detach=True, + host_config={"NetworkMode": "none"}, + stop_timeout=12, ) + mock_client.containers.get.assert_called_once_with("fake-container-id") @patch("simples_backend.services.execution_strategy.docker") def test_execute_returns_result_with_exit_code_and_duration(self, mock_docker, binary_dir): mock_client = MagicMock() mock_docker.from_env.return_value = mock_client - mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container - mock_sock = MagicMock() - mock_sock._sock = MagicMock() - mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.recv.side_effect = [b""] - mock_sock._sock.setblocking = MagicMock() - mock_container.wait.return_value = {"StatusCode": 42} - mock_ws = MagicMock() - mock_ws.receive.return_value = None + mock_container, mock_sock, mock_ws = self._setup_mocks(mock_client, binary_dir, wait_status=42) strategy = PtyExecutionStrategy() result = strategy.execute(binary_dir, mock_ws, timeout_s=10) @@ -124,20 +133,14 @@ def test_execute_returns_result_with_exit_code_and_duration(self, mock_docker, b def test_stdout_forwarded_to_ws(self, mock_docker, binary_dir): mock_client = MagicMock() mock_docker.from_env.return_value = mock_client - mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container - mock_sock = MagicMock() - mock_sock._sock = MagicMock() - mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.recv.side_effect = [ - _docker_frame(1, b"line1\n"), - _docker_frame(1, b"line2\n"), - b"", - ] - mock_sock._sock.setblocking = MagicMock() - mock_container.wait.return_value = {"StatusCode": 0} - mock_ws = MagicMock() - mock_ws.receive.return_value = None + mock_container, mock_sock, mock_ws = self._setup_mocks( + mock_client, binary_dir, + recv_side_effect=[ + _docker_frame(1, b"line1\n"), + _docker_frame(1, b"line2\n"), + b"", + ], + ) strategy = PtyExecutionStrategy() strategy.execute(binary_dir, mock_ws, timeout_s=10) @@ -155,16 +158,7 @@ def test_stdout_forwarded_to_ws(self, mock_docker, binary_dir): def test_container_removed_after_execution(self, mock_docker, binary_dir): mock_client = MagicMock() mock_docker.from_env.return_value = mock_client - mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container - mock_sock = MagicMock() - mock_sock._sock = MagicMock() - mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.recv.side_effect = [b""] - mock_sock._sock.setblocking = MagicMock() - mock_container.wait.return_value = {"StatusCode": 0} - mock_ws = MagicMock() - mock_ws.receive.return_value = None + mock_container, mock_sock, mock_ws = self._setup_mocks(mock_client, binary_dir) strategy = PtyExecutionStrategy() strategy.execute(binary_dir, mock_ws, timeout_s=10) @@ -175,12 +169,11 @@ def test_container_removed_after_execution(self, mock_docker, binary_dir): def test_stdin_forwarded_to_container(self, mock_docker, binary_dir): mock_client = MagicMock() mock_docker.from_env.return_value = mock_client - mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container - mock_sock = MagicMock() - mock_sock._sock = MagicMock() - mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.setblocking = MagicMock() + mock_container, mock_sock, mock_ws = self._setup_mocks( + mock_client, binary_dir, + recv_side_effect=[], + ws_receive=None, + ) reader_block = threading.Event() recv_calls = iter([_docker_frame(1, b"prompt> ")]) @@ -193,7 +186,6 @@ def mock_recv(size): return b"" mock_sock._sock.recv = MagicMock(side_effect=mock_recv) - mock_container.wait.return_value = {"StatusCode": 0} mock_ws = MagicMock() @@ -211,12 +203,11 @@ def mock_recv(size): def test_stop_kills_container(self, mock_docker, binary_dir): mock_client = MagicMock() mock_docker.from_env.return_value = mock_client - mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container - mock_sock = MagicMock() - mock_sock._sock = MagicMock() - mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.setblocking = MagicMock() + mock_container, mock_sock, mock_ws = self._setup_mocks( + mock_client, binary_dir, + recv_side_effect=[], + ws_receive=None, + ) reader_block = threading.Event() recv_calls = iter([_docker_frame(1, b"output\n")]) @@ -229,7 +220,6 @@ def mock_recv(size): return b"" mock_sock._sock.recv = MagicMock(side_effect=mock_recv) - mock_container.wait.return_value = {"StatusCode": 137} mock_ws = MagicMock() @@ -247,12 +237,11 @@ def mock_recv(size): def test_ping_pong(self, mock_docker, binary_dir): mock_client = MagicMock() mock_docker.from_env.return_value = mock_client - mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container - mock_sock = MagicMock() - mock_sock._sock = MagicMock() - mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.setblocking = MagicMock() + mock_container, mock_sock, mock_ws = self._setup_mocks( + mock_client, binary_dir, + recv_side_effect=[], + ws_receive=None, + ) reader_block = threading.Event() recv_calls = iter([_docker_frame(1, b"line\n")]) @@ -265,7 +254,6 @@ def mock_recv(size): return b"" mock_sock._sock.recv = MagicMock(side_effect=mock_recv) - mock_container.wait.return_value = {"StatusCode": 0} mock_ws = MagicMock() @@ -288,12 +276,11 @@ def mock_recv(size): def test_timeout_triggers_kill_and_returns_timed_out(self, mock_docker, binary_dir): mock_client = MagicMock() mock_docker.from_env.return_value = mock_client - mock_container = MagicMock() - mock_client.containers.create.return_value = mock_container - mock_sock = MagicMock() - mock_sock._sock = MagicMock() - mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.setblocking = MagicMock() + mock_container, mock_sock, mock_ws = self._setup_mocks( + mock_client, binary_dir, + recv_side_effect=[], + ws_receive=None, + ) reader_block = threading.Event() @@ -302,7 +289,6 @@ def mock_recv(size): return b"" mock_sock._sock.recv = MagicMock(side_effect=mock_recv) - mock_container.wait.return_value = {"StatusCode": 137} mock_ws = MagicMock() @@ -314,7 +300,7 @@ def mock_recv(size): result = strategy.execute(binary_dir, mock_ws, timeout_s=0.05) assert result.timed_out is True - mock_container.stop.assert_called_once_with(timeout=10) + mock_container.stop.assert_called_once_with(timeout=12) timeout_messages = [ json.loads(call[0][0]) diff --git a/docker-compose.yml b/docker-compose.yml index f8fd0fc..c0e05f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: - COMPILE_TIMEOUT_S=${COMPILE_TIMEOUT_S:-15} - MAX_CODE_KB=${MAX_CODE_KB:-64} - SANDBOX_IMAGE=simples-runner:latest + - STOP_TIMEOUT_S=${STOP_TIMEOUT_S:-12} - LOG_LEVEL=INFO volumes: - /var/run/docker.sock:/var/run/docker.sock