From eb39472e221013db5ce85cbce549b68a27bb0b4f Mon Sep 17 00:00:00 2001 From: Luca Samuel dos Santos Date: Sat, 13 Jun 2026 15:21:57 -0300 Subject: [PATCH 01/30] feat(backend): implement PtyExecutionStrategy (#29) --- backend/src/simples_backend/routes/run_ws.py | 104 +----- .../services/execution_strategy.py | 156 +++++++++ backend/tests/test_execution_strategy.py | 299 ++++++++++++++++++ backend/tests/test_run_ws.py | 52 ++- 4 files changed, 488 insertions(+), 123 deletions(-) create mode 100644 backend/src/simples_backend/services/execution_strategy.py create mode 100644 backend/tests/test_execution_strategy.py diff --git a/backend/src/simples_backend/routes/run_ws.py b/backend/src/simples_backend/routes/run_ws.py index c0ac0a6..033d212 100644 --- a/backend/src/simples_backend/routes/run_ws.py +++ b/backend/src/simples_backend/routes/run_ws.py @@ -1,12 +1,7 @@ from __future__ import annotations import json -import signal -import subprocess import tempfile -import threading -import time -from queue import Empty, Queue from flask import request from flask_sock import Sock @@ -15,10 +10,9 @@ from ..config import Settings from ..services.compiler_service import CompilerError, compile_simples from ..services.execution_service import ExecutionError, assemble_nasm, link_object +from ..services.execution_strategy import PtyExecutionStrategy MAX_CODE_BYTES = 64 * 1024 -STDIN_MAX_BYTES = 4 * 1024 -WS_POLL_S = 0.05 def extract_jwt_from_ws() -> str: @@ -62,94 +56,14 @@ def handle_compile_and_run(ws, code: str, settings: Settings) -> None: _send(ws, {"type": "exec_started"}) - process = subprocess.Popen( - [bin_path], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=tmpdir, - ) - - output_queue: Queue = Queue() - stop_event = threading.Event() - - def _reader() -> None: - try: - for line in iter(process.stdout.readline, b""): - if stop_event.is_set(): - break - output_queue.put(("stdout", line)) - for line in iter(process.stderr.readline, b""): - if stop_event.is_set(): - break - output_queue.put(("stderr", line)) - finally: - process.wait() - output_queue.put(("exit", process.returncode)) - - reader_thread = threading.Thread(target=_reader, daemon=True) - reader_thread.start() - - start_time = time.monotonic() - exec_timeout = settings.exec_timeout_s - - while True: - try: - kind, data = output_queue.get_nowait() - if kind == "exit": - duration = int((time.monotonic() - start_time) * 1000) - _send(ws, {"type": "exit", "code": data, "duration_ms": duration}) - break - _send(ws, {"type": kind, "data": data.decode(errors="replace")}) - continue - except Empty: - pass - - if time.monotonic() - start_time > exec_timeout: - stop_event.set() - process.terminate() - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - _send(ws, {"type": "timeout", "limit_s": exec_timeout}) - break - - try: - raw = ws.receive(timeout=WS_POLL_S) - if raw is None: - break - msg = json.loads(raw) - t = msg.get("type", "") - if t == "stdin": - data = msg.get("data", "") - if data and len(data.encode("utf-8")) <= STDIN_MAX_BYTES: - process.stdin.write(data.encode("utf-8")) - process.stdin.flush() - elif t == "stop": - stop_event.set() - process.terminate() - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - _send(ws, {"type": "exit", "code": -signal.SIGTERM}) - break - elif t == "ping": - _send(ws, {"type": "pong"}) - except json.JSONDecodeError: - pass - - if process.poll() is None: - stop_event.set() - process.terminate() - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - process.kill() - process.wait() + strategy = PtyExecutionStrategy(image=settings.sandbox_image) + result = strategy.execute(tmpdir, ws, settings.exec_timeout_s) + + _send(ws, { + "type": "exit", + "code": result.exit_code, + "duration_ms": result.duration_ms, + }) except CompilerError as e: if e.phase is not None: diff --git a/backend/src/simples_backend/services/execution_strategy.py b/backend/src/simples_backend/services/execution_strategy.py new file mode 100644 index 0000000..96b33a9 --- /dev/null +++ b/backend/src/simples_backend/services/execution_strategy.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import threading +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from queue import Empty, Queue + +import docker + + +@dataclass +class ExecutionResult: + exit_code: int + duration_ms: int + timed_out: bool + + +class ExecutionStrategy(ABC): + @abstractmethod + def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: + ... + + +class PtyExecutionStrategy(ExecutionStrategy): + def __init__(self, image: str = "simples-runner:latest"): + self.image = image + self.client = docker.from_env() + + def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: + container = self.client.containers.run( + image=self.image, + command=["/usr/bin/qemu-i386-static", "/sandbox/programa"], + volumes={binary_dir: {"bind": "/sandbox", "mode": "ro"}}, + network_mode="none", + mem_limit="128m", + memswap_limit="128m", + cpu_quota=50000, + pids_limit=64, + read_only=True, + tmpfs={"/tmp": "size=8m"}, + user="65534:65534", + cap_drop=["ALL"], + stdin_open=True, + tty=True, + detach=True, + ) + + sock = container.attach_socket( + params={"stdin": 1, "stdout": 1, "stderr": 1, "stream": 1} + ) + sock._sock.setblocking(False) + + output_queue: Queue[tuple[str, object]] = Queue() + stop_event = threading.Event() + start = time.monotonic() + timed_out = False + + def _send(msg: dict) -> None: + try: + ws.send(json.dumps(msg, ensure_ascii=False)) + except Exception: + pass + + def _reader() -> None: + try: + while not stop_event.is_set(): + try: + data = sock._sock.recv(4096) + if not data: + break + output_queue.put(("stdout", data)) + except BlockingIOError: + time.sleep(0.01) + except Exception: + pass + finally: + output_queue.put(("_exit", None)) + + reader_thread = threading.Thread(target=_reader, daemon=True) + reader_thread.start() + + try: + while True: + try: + kind, data = output_queue.get_nowait() + if kind == "_exit": + break + _send({ + "type": "stdout", + "data": data.decode("utf-8", errors="replace"), + }) + continue + except Empty: + pass + + elapsed = time.monotonic() - start + if elapsed > timeout_s: + try: + container.kill(signal="SIGTERM") + time.sleep(1) + container.kill(signal="SIGKILL") + except Exception: + pass + timed_out = True + _send({"type": "timeout", "limit_s": timeout_s}) + break + + try: + raw = ws.receive(timeout=0.05) + except Exception: + break + if raw is None: + break + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + + t = msg.get("type", "") + if t == "stdin": + data = msg.get("data", "") + if data: + sock._sock.sendall(data.encode("utf-8")) + elif t == "stop": + try: + container.kill(signal="SIGTERM") + except Exception: + pass + break + elif t == "ping": + _send({"type": "pong"}) + finally: + stop_event.set() + reader_thread.join(timeout=2) + + try: + result = container.wait(timeout=5) + exit_code = result["StatusCode"] + except Exception: + exit_code = -1 + + duration_ms = int((time.monotonic() - start) * 1000) + + try: + container.remove(force=True) + except Exception: + pass + + return ExecutionResult( + exit_code=exit_code, + duration_ms=duration_ms, + timed_out=timed_out, + ) diff --git a/backend/tests/test_execution_strategy.py b/backend/tests/test_execution_strategy.py new file mode 100644 index 0000000..cc181b0 --- /dev/null +++ b/backend/tests/test_execution_strategy.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import json +import threading +import time +from unittest.mock import MagicMock, patch + +import pytest + +from simples_backend.services.execution_strategy import ( + ExecutionResult, + ExecutionStrategy, + PtyExecutionStrategy, +) + + +class TestExecutionResult: + def test_dataclass_fields(self): + result = ExecutionResult(exit_code=0, duration_ms=100, timed_out=False) + assert result.exit_code == 0 + assert result.duration_ms == 100 + assert result.timed_out is False + + def test_timed_out_true(self): + result = ExecutionResult(exit_code=-1, duration_ms=5000, timed_out=True) + assert result.timed_out is True + + +class TestExecutionStrategyABC: + def test_cannot_instantiate(self): + with pytest.raises(TypeError): + ExecutionStrategy() + + +class TestPtyExecutionStrategy: + @patch("simples_backend.services.execution_strategy.docker") + def test_init_default_image(self, mock_docker): + strategy = PtyExecutionStrategy() + assert strategy.image == "simples-runner:latest" + mock_docker.from_env.assert_called_once() + + @patch("simples_backend.services.execution_strategy.docker") + 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): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.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 + + strategy = PtyExecutionStrategy() + result = strategy.execute("/tmp/test", mock_ws, timeout_s=10) + + assert isinstance(result, ExecutionResult) + assert result.exit_code == 0 + assert result.duration_ms >= 0 + assert result.timed_out is False + + mock_client.containers.run.assert_called_once_with( + image="simples-runner:latest", + command=["/usr/bin/qemu-i386-static", "/sandbox/programa"], + volumes={"/tmp/test": {"bind": "/sandbox", "mode": "ro"}}, + network_mode="none", + mem_limit="128m", + memswap_limit="128m", + cpu_quota=50000, + pids_limit=64, + read_only=True, + tmpfs={"/tmp": "size=8m"}, + user="65534:65534", + cap_drop=["ALL"], + stdin_open=True, + tty=True, + detach=True, + ) + + @patch("simples_backend.services.execution_strategy.docker") + def test_execute_returns_result_with_exit_code_and_duration(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.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 + + strategy = PtyExecutionStrategy() + result = strategy.execute("/tmp/test", mock_ws, timeout_s=10) + + assert result.exit_code == 42 + assert result.duration_ms >= 0 + + @patch("simples_backend.services.execution_strategy.docker") + def test_stdout_forwarded_to_ws(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.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"line1\n", 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 + + strategy = PtyExecutionStrategy() + strategy.execute("/tmp/test", mock_ws, timeout_s=10) + + stdout_messages = [ + json.loads(call[0][0]) + for call in mock_ws.send.call_args_list + if json.loads(call[0][0]).get("type") == "stdout" + ] + assert len(stdout_messages) >= 2 + assert stdout_messages[0]["data"] == "line1\n" + assert stdout_messages[1]["data"] == "line2\n" + + @patch("simples_backend.services.execution_strategy.docker") + def test_container_removed_after_execution(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.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 + + strategy = PtyExecutionStrategy() + strategy.execute("/tmp/test", mock_ws, timeout_s=10) + + mock_container.remove.assert_called_once_with(force=True) + + @patch("simples_backend.services.execution_strategy.docker") + def test_stdin_forwarded_to_container(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.return_value = mock_container + mock_sock = MagicMock() + mock_sock._sock = MagicMock() + mock_container.attach_socket.return_value = mock_sock + mock_sock._sock.setblocking = MagicMock() + + reader_block = threading.Event() + recv_calls = iter([b"prompt> "]) + + def mock_recv(size): + try: + return next(recv_calls) + except StopIteration: + reader_block.wait(timeout=10) + return b"" + + mock_sock._sock.recv = MagicMock(side_effect=mock_recv) + + mock_container.wait.return_value = {"StatusCode": 0} + + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + json.dumps({"type": "stdin", "data": "42\n"}), + None, + ] + + strategy = PtyExecutionStrategy() + strategy.execute("/tmp/test", mock_ws, timeout_s=10) + + mock_sock._sock.sendall.assert_called() + + @patch("simples_backend.services.execution_strategy.docker") + def test_stop_kills_container(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.return_value = mock_container + mock_sock = MagicMock() + mock_sock._sock = MagicMock() + mock_container.attach_socket.return_value = mock_sock + mock_sock._sock.setblocking = MagicMock() + + reader_block = threading.Event() + recv_calls = iter([b"output\n"]) + + def mock_recv(size): + try: + return next(recv_calls) + except StopIteration: + reader_block.wait(timeout=10) + return b"" + + mock_sock._sock.recv = MagicMock(side_effect=mock_recv) + + mock_container.wait.return_value = {"StatusCode": 137} + + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + json.dumps({"type": "stop"}), + None, + ] + + strategy = PtyExecutionStrategy() + result = strategy.execute("/tmp/test", mock_ws, timeout_s=10) + + mock_container.kill.assert_any_call(signal="SIGTERM") + + @patch("simples_backend.services.execution_strategy.docker") + def test_ping_pong(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.return_value = mock_container + mock_sock = MagicMock() + mock_sock._sock = MagicMock() + mock_container.attach_socket.return_value = mock_sock + mock_sock._sock.setblocking = MagicMock() + + reader_block = threading.Event() + recv_calls = iter([b"line\n"]) + + def mock_recv(size): + try: + return next(recv_calls) + except StopIteration: + reader_block.wait(timeout=10) + return b"" + + mock_sock._sock.recv = MagicMock(side_effect=mock_recv) + + mock_container.wait.return_value = {"StatusCode": 0} + + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + json.dumps({"type": "ping"}), + None, + ] + + strategy = PtyExecutionStrategy() + strategy.execute("/tmp/test", mock_ws, timeout_s=10) + + pong_messages = [ + json.loads(call[0][0]) + for call in mock_ws.send.call_args_list + if json.loads(call[0][0]).get("type") == "pong" + ] + assert len(pong_messages) >= 1 + + @patch("simples_backend.services.execution_strategy.docker") + def test_timeout_triggers_kill_and_returns_timed_out(self, mock_docker): + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.return_value = mock_container + mock_sock = MagicMock() + mock_sock._sock = MagicMock() + mock_container.attach_socket.return_value = mock_sock + mock_sock._sock.setblocking = MagicMock() + + reader_block = threading.Event() + + def mock_recv(size): + reader_block.wait(timeout=10) + return b"" + + mock_sock._sock.recv = MagicMock(side_effect=mock_recv) + + mock_container.wait.return_value = {"StatusCode": 137} + + mock_ws = MagicMock() + mock_ws.receive.side_effect = lambda timeout=None: json.dumps( + {"type": "ping"} + ) + + strategy = PtyExecutionStrategy() + result = strategy.execute("/tmp/test", mock_ws, timeout_s=0.05) + + assert result.timed_out is True + mock_container.kill.assert_any_call(signal="SIGTERM") diff --git a/backend/tests/test_run_ws.py b/backend/tests/test_run_ws.py index 5d262cf..338533c 100644 --- a/backend/tests/test_run_ws.py +++ b/backend/tests/test_run_ws.py @@ -10,6 +10,7 @@ from simples_backend.auth import AuthError from simples_backend.config import Settings from simples_backend.routes.run_ws import extract_jwt_from_ws, handle_ws_connection +from simples_backend.services.execution_strategy import ExecutionResult TEST_SECRET = "0123456789abcdef0123456789abcdef" TEST_SETTINGS = Settings( @@ -113,13 +114,14 @@ def test_compile_success_flow(self, mock_compile): mock_asm.return_value = "/tmp/programa.o" with patch("simples_backend.routes.run_ws.link_object") as mock_link: mock_link.return_value = "/tmp/programa" - with patch("simples_backend.routes.run_ws.subprocess.Popen") as mock_popen: - mock_proc = MagicMock() - mock_proc.stdout.readline.side_effect = [b"", b""] - mock_proc.stderr.readline.side_effect = [b"", b""] - mock_proc.poll.side_effect = [None, None, 0] - mock_proc.returncode = 0 - mock_popen.return_value = mock_proc + with patch( + "simples_backend.routes.run_ws.PtyExecutionStrategy" + ) as mock_strategy_cls: + mock_strategy = MagicMock() + mock_strategy_cls.return_value = mock_strategy + mock_strategy.execute.return_value = ExecutionResult( + exit_code=0, duration_ms=50, timed_out=False + ) handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) @@ -130,6 +132,9 @@ def test_compile_success_flow(self, mock_compile): assert "exec_started" in types assert "exit" in types + exit_msg = next(m for m in sent if m.get("type") == "exit") + assert exit_msg["code"] == 0 + @patch("simples_backend.routes.run_ws.compile_simples") def test_compile_error_flow(self, mock_compile): from simples_backend.services.compiler_service import CompilerError @@ -172,8 +177,6 @@ def test_assemble_error(self, mock_compile): @patch("simples_backend.routes.run_ws.compile_simples") def test_stdin_and_stop(self, mock_compile): - import threading - mock_compile.return_value = "section .text\n global _start\n_start:\n mov eax, 1\n xor ebx, ebx\n int 0x80\n" mock_ws = MagicMock() @@ -181,22 +184,14 @@ def test_stdin_and_stop(self, mock_compile): mock_asm.return_value = "/tmp/programa.o" with patch("simples_backend.routes.run_ws.link_object") as mock_link: mock_link.return_value = "/tmp/programa" - with patch("simples_backend.routes.run_ws.subprocess.Popen") as mock_popen: - mock_proc = MagicMock() - - call_count = [0] - block_forever = threading.Event() - - def blocking_readline(): - call_count[0] += 1 - if call_count[0] <= 1: - return b"output line\n" - block_forever.wait() - return b"" - - mock_proc.stdout.readline.side_effect = blocking_readline - mock_proc.stderr.readline.side_effect = blocking_readline - mock_popen.return_value = mock_proc + with patch( + "simples_backend.routes.run_ws.PtyExecutionStrategy" + ) as mock_strategy_cls: + mock_strategy = MagicMock() + mock_strategy_cls.return_value = mock_strategy + mock_strategy.execute.return_value = ExecutionResult( + exit_code=0, duration_ms=100, timed_out=False + ) mock_ws.receive.side_effect = [ json.dumps({"type": "compile_and_run", "code": "programa test\ninicio\nfim"}), @@ -207,8 +202,9 @@ def blocking_readline(): handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) - mock_proc.stdin.write.assert_called() - mock_proc.terminate.assert_called() sent = [json.loads(call[0][0]) for call in mock_ws.send.call_args_list] types = [m.get("type") for m in sent] - assert "stdout" in types + assert "compile_started" in types + assert "asm_generated" in types + assert "exec_started" in types + assert "exit" in types From 15616c634c142946fdab92282076624a9b9d1414 Mon Sep 17 00:00:00 2001 From: Luca Samuel dos Santos Date: Sat, 13 Jun 2026 16:03:18 -0300 Subject: [PATCH 02/30] feat(backend): bridge websocket and pty streams (#30) --- backend/tests/test_run_ws.py | 57 ++++++++++++++++++--------- frontend/src/hooks/useRunWebSocket.ts | 7 ++-- frontend/src/pages/AppShell.tsx | 2 +- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/backend/tests/test_run_ws.py b/backend/tests/test_run_ws.py index 338533c..59159d5 100644 --- a/backend/tests/test_run_ws.py +++ b/backend/tests/test_run_ws.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import threading from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch @@ -176,31 +177,44 @@ def test_assemble_error(self, mock_compile): assert any(m.get("type") == "assemble_error" for m in sent) @patch("simples_backend.routes.run_ws.compile_simples") - def test_stdin_and_stop(self, mock_compile): + @patch("simples_backend.services.execution_strategy.docker") + def test_stdin_and_stop_through_bridge(self, mock_docker, mock_compile): mock_compile.return_value = "section .text\n global _start\n_start:\n mov eax, 1\n xor ebx, ebx\n int 0x80\n" + mock_client = MagicMock() + mock_docker.from_env.return_value = mock_client + mock_container = MagicMock() + mock_client.containers.run.return_value = mock_container + mock_sock = MagicMock() + mock_sock._sock = MagicMock() + mock_sock._sock.setblocking = MagicMock() + mock_container.attach_socket.return_value = mock_sock + mock_container.wait.return_value = {"StatusCode": 0} + + reader_block = threading.Event() + recv_calls = iter([b"prompt> ", b"result\n"]) + + def mock_recv(size): + try: + return next(recv_calls) + except StopIteration: + reader_block.wait(timeout=10) + return b"" + + mock_sock._sock.recv = MagicMock(side_effect=mock_recv) + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + json.dumps({"type": "compile_and_run", "code": "programa test\ninicio\nfim"}), + json.dumps({"type": "stdin", "data": "42\n"}), + json.dumps({"type": "stop"}), + None, + ] with patch("simples_backend.routes.run_ws.assemble_nasm") as mock_asm: mock_asm.return_value = "/tmp/programa.o" with patch("simples_backend.routes.run_ws.link_object") as mock_link: mock_link.return_value = "/tmp/programa" - with patch( - "simples_backend.routes.run_ws.PtyExecutionStrategy" - ) as mock_strategy_cls: - mock_strategy = MagicMock() - mock_strategy_cls.return_value = mock_strategy - mock_strategy.execute.return_value = ExecutionResult( - exit_code=0, duration_ms=100, timed_out=False - ) - - mock_ws.receive.side_effect = [ - json.dumps({"type": "compile_and_run", "code": "programa test\ninicio\nfim"}), - json.dumps({"type": "stdin", "data": "42"}), - json.dumps({"type": "stop"}), - None, - ] - - handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) + handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) sent = [json.loads(call[0][0]) for call in mock_ws.send.call_args_list] types = [m.get("type") for m in sent] @@ -208,3 +222,10 @@ def test_stdin_and_stop(self, mock_compile): assert "asm_generated" in types assert "exec_started" in types assert "exit" in types + + stdout_msgs = [m for m in sent if m.get("type") == "stdout"] + assert len(stdout_msgs) >= 2 + assert "prompt" in stdout_msgs[0].get("data", "") + + mock_sock._sock.sendall.assert_called_with(b"42\n") + mock_container.kill.assert_any_call(signal="SIGTERM") diff --git a/frontend/src/hooks/useRunWebSocket.ts b/frontend/src/hooks/useRunWebSocket.ts index dcca479..2323d92 100644 --- a/frontend/src/hooks/useRunWebSocket.ts +++ b/frontend/src/hooks/useRunWebSocket.ts @@ -21,7 +21,7 @@ interface RunCallbacks { onExecStarted?: () => void } -export function useRunWebSocket(callbacks: RunCallbacks) { +export function useRunWebSocket(callbacks: RunCallbacks, token: string) { const wsRef = useRef(null) const [state, setState] = useState('idle') @@ -49,6 +49,7 @@ export function useRunWebSocket(callbacks: RunCallbacks) { }, []) const start = useCallback((code: string) => { + if (!token) return close() const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' @@ -56,7 +57,7 @@ export function useRunWebSocket(callbacks: RunCallbacks) { const url = `${protocol}//${host}/ws/run` setState('connecting') - const ws = new WebSocket(url) + const ws = new WebSocket(url, ['bearer.' + token]) ws.onopen = () => { ws.send(JSON.stringify({ type: 'compile_and_run', code })) @@ -146,7 +147,7 @@ export function useRunWebSocket(callbacks: RunCallbacks) { } wsRef.current = ws - }, [callbacks, close]) + }, [callbacks, close, token]) useEffect(() => { return () => { diff --git a/frontend/src/pages/AppShell.tsx b/frontend/src/pages/AppShell.tsx index 048899d..58ae106 100644 --- a/frontend/src/pages/AppShell.tsx +++ b/frontend/src/pages/AppShell.tsx @@ -83,7 +83,7 @@ export function AppShell() { onInternalError: handleInternalError, onCompileStarted: handleCompileStarted, onExecStarted: handleExecStarted, - }) + }, session?.access_token ?? '') const isRunning = ws.state !== 'idle' From a03038ad0f6e099bf6cec910c472b7c513d812b6 Mon Sep 17 00:00:00 2001 From: Luca Samuel dos Santos Date: Sat, 13 Jun 2026 16:36:19 -0300 Subject: [PATCH 03/30] =?UTF-8?q?ajuste=20no=20docker-compose=20para=20sub?= =?UTF-8?q?ir=20direto=20com=20o=20.env=20no=20diret=C3=B3rio=20raiz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ docker-compose.yml | 3 +++ frontend/Dockerfile | 3 +++ 3 files changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 82107d4..260dffc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ # Python __pycache__/ *.pyc +*.egg-info/ + +# Vite +frontend/.vite/deps/ diff --git a/docker-compose.yml b/docker-compose.yml index 6c3308e..0c511d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: build: context: ./frontend dockerfile: Dockerfile + args: + SUPABASE_URL: ${SUPABASE_URL} + SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} expose: - "80" environment: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index fb89abc..6395dca 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,7 @@ FROM node:20-alpine AS builder +ARG SUPABASE_URL +ARG SUPABASE_ANON_KEY +ENV SUPABASE_URL=$SUPABASE_URL SUPABASE_ANON_KEY=$SUPABASE_ANON_KEY WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci From 1c58e16ecfd1c557691159ca8789ebba1536cebc Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Sun, 14 Jun 2026 23:39:16 -0300 Subject: [PATCH 04/30] feat(backend): bridge websocket and pty streams Fixes for issue #30: - Change gunicorn worker from GeventWebSocketWorker to gevent (flask-sock's simple-websocket was incompatible with gevent-websocket) - Use container.create() + put_archive() instead of bind mount (bind mount fails between sibling containers) - Start reader thread before container.start() to avoid race condition (fast programs finish before reader starts) - Remove read_only=True to allow put_archive to write binary - Remove tmpfs on /sandbox (tmpfs hid the uploaded binary) --- backend/Dockerfile | 3 ++- .../services/execution_strategy.py | 22 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c4612c2..c5b9c2b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -24,8 +24,9 @@ RUN pip install --break-system-packages --no-cache-dir -r requirements.txt COPY backend/pyproject.toml backend/wsgi.py ./ COPY backend/src/ src/ +RUN pip install --break-system-packages --no-cache-dir -e . EXPOSE 5000 -CMD ["gunicorn", "-k", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", \ +CMD ["gunicorn", "-k", "gevent", \ "-w", "4", "-b", "0.0.0.0:5000", "wsgi:app"] diff --git a/backend/src/simples_backend/services/execution_strategy.py b/backend/src/simples_backend/services/execution_strategy.py index 96b33a9..69eed8e 100644 --- a/backend/src/simples_backend/services/execution_strategy.py +++ b/backend/src/simples_backend/services/execution_strategy.py @@ -1,6 +1,9 @@ from __future__ import annotations +import io import json +import os +import tarfile import threading import time from abc import ABC, abstractmethod @@ -29,16 +32,15 @@ def __init__(self, image: str = "simples-runner:latest"): self.client = docker.from_env() def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: - container = self.client.containers.run( + container = self.client.containers.create( image=self.image, command=["/usr/bin/qemu-i386-static", "/sandbox/programa"], - volumes={binary_dir: {"bind": "/sandbox", "mode": "ro"}}, network_mode="none", mem_limit="128m", memswap_limit="128m", cpu_quota=50000, pids_limit=64, - read_only=True, + read_only=False, tmpfs={"/tmp": "size=8m"}, user="65534:65534", cap_drop=["ALL"], @@ -47,6 +49,15 @@ def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: detach=True, ) + binary_path = os.path.join(binary_dir, "programa") + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + info = tar.gettarinfo(binary_path, arcname="programa") + info.mode = 0o755 + with open(binary_path, "rb") as f: + tar.addfile(info, f) + container.put_archive("/sandbox", buf.getvalue()) + sock = container.attach_socket( params={"stdin": 1, "stdout": 1, "stderr": 1, "stream": 1} ) @@ -54,7 +65,6 @@ def execute(self, binary_dir: str, ws, timeout_s: int) -> ExecutionResult: output_queue: Queue[tuple[str, object]] = Queue() stop_event = threading.Event() - start = time.monotonic() timed_out = False def _send(msg: dict) -> None: @@ -81,6 +91,10 @@ def _reader() -> None: reader_thread = threading.Thread(target=_reader, daemon=True) reader_thread.start() + container.start() + + start = time.monotonic() + try: while True: try: From 606bb9d33592d0a042e048406057469682ce8b52 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Sun, 14 Jun 2026 23:56:06 -0300 Subject: [PATCH 05/30] fix(nginx): forward Sec-WebSocket-Protocol header to backend The frontend sends the Supabase JWT token via WebSocket subprotocol (bearer.) which is transmitted in the Sec-WebSocket-Protocol header. Without this header being forwarded, the backend cannot authenticate the WebSocket connection and closes it with 'missing_bearer_token'. --- nginx/nginx.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/nginx/nginx.conf b/nginx/nginx.conf index d37d10e..45da05f 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -41,6 +41,7 @@ http { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; + proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol; proxy_read_timeout 600s; proxy_send_timeout 600s; } From 06f43dd0ab7a381264a607419a893d8b4c2407ed Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 11:23:34 -0300 Subject: [PATCH 06/30] fix(auth): support ES256 Supabase tokens Supabase access_tokens use ES256 (ECDSA) algorithm, not HS256. - Add ES256 to allowed algorithms in verify_supabase_jwt() - Add cryptography dependency required for ES256 support --- backend/requirements.txt | 1 + backend/src/simples_backend/auth.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index e86d46a..9dc5b6f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ flask-sock>=0.3.0 gevent>=23.0.0 gevent-websocket>=0.10.1 docker>=7.0.0 +cryptography>=42.0.0 diff --git a/backend/src/simples_backend/auth.py b/backend/src/simples_backend/auth.py index 599dd63..c57d884 100644 --- a/backend/src/simples_backend/auth.py +++ b/backend/src/simples_backend/auth.py @@ -37,7 +37,7 @@ def verify_supabase_jwt(token: str, secret: str) -> Identity: claims = jwt.decode( token, secret, - algorithms=["HS256"], + algorithms=["HS256", "ES256"], options={"require": ["exp", "sub"]}, ) except jwt.ExpiredSignatureError as exc: From 21a112a5bb3f0ff1e1d48d9c17787b98bf3c1ddd Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 11:27:24 -0300 Subject: [PATCH 07/30] fix(auth): use JWKS to verify ES256 Supabase tokens Supabase access_tokens use ES256 (ECDSA) which requires a public key, not a symmetric secret. This fix: - Detects the token algorithm (ES256 vs HS256) - For ES256: fetches the public key from Supabase JWKS endpoint and verifies with it - For HS256: verifies with the JWT secret as before - Caches the JWKS client to avoid repeated fetches --- backend/src/simples_backend/auth.py | 45 ++++++++++++++++---- backend/src/simples_backend/routes/run_ws.py | 2 +- test_subprotocol.py | 40 +++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 test_subprotocol.py diff --git a/backend/src/simples_backend/auth.py b/backend/src/simples_backend/auth.py index c57d884..6a29fa0 100644 --- a/backend/src/simples_backend/auth.py +++ b/backend/src/simples_backend/auth.py @@ -32,14 +32,41 @@ def extract_bearer_token(authorization_header: str | None) -> str: return parts[1] -def verify_supabase_jwt(token: str, secret: str) -> Identity: +_jwks_client: jwt.PyJWKClient | None = None + + +def _get_jwks_client(supabase_url: str) -> jwt.PyJWKClient: + global _jwks_client + if _jwks_client is None: + base = supabase_url.rstrip("/") + if "/rest/v1" in base: + base = base.split("/rest/v1")[0] + jwks_url = f"{base}/auth/v1/.well-known/jwks.json" + _jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True) + return _jwks_client + + +def verify_supabase_jwt(token: str, secret: str, supabase_url: str = "") -> Identity: try: - claims = jwt.decode( - token, - secret, - algorithms=["HS256", "ES256"], - options={"require": ["exp", "sub"]}, - ) + header = jwt.get_unverified_header(token) + alg = header.get("alg", "") + + if alg == "ES256": + jwks_client = _get_jwks_client(supabase_url) + signing_key = jwks_client.get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["ES256"], + options={"require": ["exp", "sub"]}, + ) + else: + claims = jwt.decode( + token, + secret, + algorithms=["HS256"], + options={"require": ["exp", "sub"]}, + ) except jwt.ExpiredSignatureError as exc: raise AuthError("expired_token") from exc except jwt.InvalidTokenError as exc: @@ -61,12 +88,12 @@ def verify_supabase_jwt(token: str, secret: str) -> Identity: F = TypeVar("F", bound=Callable[..., Any]) -def verify_jwt(secret: str) -> Callable[[F], F]: +def verify_jwt(secret: str, supabase_url: str = "") -> Callable[[F], F]: def decorator(func: F) -> F: @wraps(func) def wrapper(*args: Any, **kwargs: Any): token = extract_bearer_token(request.headers.get("Authorization")) - g.identity = verify_supabase_jwt(token, secret) + g.identity = verify_supabase_jwt(token, secret, supabase_url) return func(*args, **kwargs) return cast(F, wrapper) diff --git a/backend/src/simples_backend/routes/run_ws.py b/backend/src/simples_backend/routes/run_ws.py index 033d212..7e1daae 100644 --- a/backend/src/simples_backend/routes/run_ws.py +++ b/backend/src/simples_backend/routes/run_ws.py @@ -84,7 +84,7 @@ def handle_ws_connection(ws, settings: Settings, identity: dict | None = None) - if identity is None: try: jwt_token = extract_jwt_from_ws() - identity = verify_supabase_jwt(jwt_token, settings.supabase_jwt_secret) + identity = verify_supabase_jwt(jwt_token, settings.supabase_jwt_secret, settings.supabase_url) except AuthError as e: _send(ws, {"type": "internal_error", "message": e.code}) try: diff --git a/test_subprotocol.py b/test_subprotocol.py new file mode 100644 index 0000000..b02c47e --- /dev/null +++ b/test_subprotocol.py @@ -0,0 +1,40 @@ +import asyncio, websockets, json, jwt +from datetime import datetime, timedelta, timezone + +JWT_KEY = "62235bb1-5579-481d-9e00-c08b1d651edd" +token = jwt.encode( + {"sub": "test", "email": "t@t.com", + "exp": datetime.now(timezone.utc) + timedelta(hours=1)}, + JWT_KEY, algorithm="HS256" +) + +async def test(): + uri = "ws://nginx/ws/run" + async with websockets.connect(uri, subprotocols=["bearer." + token], max_size=2**20, open_timeout=10) as ws: + print("Connected via subprotocol!") + code = "programa demo\ninicio\n escreval 42;\nfim" + await ws.send(json.dumps({"type": "compile_and_run", "code": code})) + for _ in range(20): + try: + r = await asyncio.wait_for(ws.recv(), timeout=3) + m = json.loads(r) + t = m.get("type", "") + if t == "stdout": + d = m.get("data", "") + print(f"STDOUT: {repr(d)}") + elif t == "exit": + print(f"EXIT: code={m.get('code')}") + break + elif t == "internal_error": + print(f"ERROR: {m.get('message')}") + break + elif t in ("compile_error", "assemble_error", "link_error"): + print(f"COMPILE ERROR: {m.get('message') or m.get('stderr','')}") + break + else: + print(f"EVENT: {t}") + except asyncio.TimeoutError: + print("TIMEOUT") + break + +asyncio.run(test()) From 4e5fcc9530999dd79bf9ebb86c38cc8781f35006 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 11:32:54 -0300 Subject: [PATCH 08/30] fix(config): rename env var SUPABASE_JWT_SECRET -> JWT_SECRET --- backend/src/simples_backend/config.py | 4 ++-- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/simples_backend/config.py b/backend/src/simples_backend/config.py index fc70a29..fcbe940 100644 --- a/backend/src/simples_backend/config.py +++ b/backend/src/simples_backend/config.py @@ -15,7 +15,7 @@ class Settings: sandbox_image: str = "simples-runner:latest" -_REQUIRED_ENV_VARS = ("SUPABASE_URL", "SUPABASE_JWT_SECRET") +_REQUIRED_ENV_VARS = ("SUPABASE_URL", "JWT_SECRET") def _int_env(env: Mapping[str, str], key: str, default: int) -> int: @@ -39,7 +39,7 @@ def load_settings(environ: Mapping[str, str] | None = None) -> Settings: return Settings( supabase_url=env["SUPABASE_URL"], - supabase_jwt_secret=env["SUPABASE_JWT_SECRET"], + supabase_jwt_secret=env["JWT_SECRET"], exec_timeout_s=_int_env(env, "EXEC_TIMEOUT_S", 10), compile_timeout_s=_int_env(env, "COMPILE_TIMEOUT_S", 15), max_code_kb=_int_env(env, "MAX_CODE_KB", 64), diff --git a/docker-compose.yml b/docker-compose.yml index 0c511d4..f8fd0fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: - "5000" environment: - SUPABASE_URL=${SUPABASE_URL} - - SUPABASE_JWT_SECRET=${JWT_KEY} + - JWT_SECRET=${JWT_KEY} - EXEC_TIMEOUT_S=${EXEC_TIMEOUT_S:-10} - COMPILE_TIMEOUT_S=${COMPILE_TIMEOUT_S:-15} - MAX_CODE_KB=${MAX_CODE_KB:-64} From b37787ba43c65c8bbb0467a8efe18066eadfd456 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 11:38:18 -0300 Subject: [PATCH 09/30] fix(ws): accept client subprotocol in WebSocket handshake Browsers expect the server to respond with a Sec-WebSocket-Protocol header when the client sends one. simple-websocket's default choose_subprotocol returns None when no subprotocols are configured, causing the browser to fire onerror. Fix: monkey-patch choose_subprotocol to accept the first subprotocol the client sends, so the 101 response includes the expected header. --- backend/src/simples_backend/app.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/simples_backend/app.py b/backend/src/simples_backend/app.py index c2776e0..957578c 100644 --- a/backend/src/simples_backend/app.py +++ b/backend/src/simples_backend/app.py @@ -12,6 +12,12 @@ register_run_ws, ) +import simple_websocket.ws as _sws +_original_choose = _sws.Server.choose_subprotocol +_sws.Server.choose_subprotocol = lambda self, req: ( + req.subprotocols[0] if req.subprotocols else None +) + def create_app(settings: Settings | None = None) -> Flask: """Flask application factory.""" From 5fd25d8018b4f8dd5d76d8fac50aa29b8d7064f9 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 11:51:39 -0300 Subject: [PATCH 10/30] fix(auth): robust JWKS with ES256/HS256 fallback + logging - Replace PyJWKClient with manual JWKS fetch + EC key construction - Fallback to HS256 if ES256+JWKS fails - Log JWT alg, kid, and any verification errors - Add logging.basicConfig in create_app --- backend/src/simples_backend/app.py | 3 ++ backend/src/simples_backend/auth.py | 72 +++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/backend/src/simples_backend/app.py b/backend/src/simples_backend/app.py index 957578c..513b74a 100644 --- a/backend/src/simples_backend/app.py +++ b/backend/src/simples_backend/app.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from flask import Flask, jsonify from flask_sock import Sock @@ -20,6 +22,7 @@ def create_app(settings: Settings | None = None) -> Flask: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s") """Flask application factory.""" app = Flask(__name__) diff --git a/backend/src/simples_backend/auth.py b/backend/src/simples_backend/auth.py index 6a29fa0..9d59578 100644 --- a/backend/src/simples_backend/auth.py +++ b/backend/src/simples_backend/auth.py @@ -1,11 +1,18 @@ from __future__ import annotations +import base64 +import json +import logging +import urllib.request from functools import wraps from typing import Any, Callable, TypeVar, TypedDict, cast import jwt +from cryptography.hazmat.primitives.asymmetric import ec from flask import g, request +logger = logging.getLogger(__name__) + class AuthError(Exception): def __init__(self, code: str): @@ -32,44 +39,79 @@ def extract_bearer_token(authorization_header: str | None) -> str: return parts[1] -_jwks_client: jwt.PyJWKClient | None = None +_jwks_keys: list[dict] | None = None + + +def _b64_decode(value: str) -> bytes: + padding = 4 - len(value) % 4 + if padding != 4: + value += "=" * padding + return base64.urlsafe_b64decode(value) -def _get_jwks_client(supabase_url: str) -> jwt.PyJWKClient: - global _jwks_client - if _jwks_client is None: +def _get_jwks_keys(supabase_url: str) -> list[dict]: + global _jwks_keys + if _jwks_keys is None: base = supabase_url.rstrip("/") if "/rest/v1" in base: base = base.split("/rest/v1")[0] jwks_url = f"{base}/auth/v1/.well-known/jwks.json" - _jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True) - return _jwks_client + logger.info("fetching JWKS from %s", jwks_url) + resp = urllib.request.urlopen(jwks_url, timeout=10) + data = json.loads(resp.read()) + _jwks_keys = data.get("keys", []) + logger.info("JWKS loaded: %d keys", len(_jwks_keys)) + return _jwks_keys + + +def _build_ec_key(key_data: dict): + x_bytes = _b64_decode(key_data["x"]) + y_bytes = _b64_decode(key_data["y"]) + x_int = int.from_bytes(x_bytes, "big") + y_int = int.from_bytes(y_bytes, "big") + return ec.EllipticCurvePublicNumbers(x_int, y_int, ec.SECP256R1()).public_key() def verify_supabase_jwt(token: str, secret: str, supabase_url: str = "") -> Identity: try: header = jwt.get_unverified_header(token) alg = header.get("alg", "") + kid = header.get("kid", "") + logger.info("JWT alg=%s kid=%s", alg, kid) + + claims = None if alg == "ES256": - jwks_client = _get_jwks_client(supabase_url) - signing_key = jwks_client.get_signing_key_from_jwt(token) - claims = jwt.decode( - token, - signing_key.key, - algorithms=["ES256"], - options={"require": ["exp", "sub"]}, - ) - else: + try: + keys = _get_jwks_keys(supabase_url) + match = next((k for k in keys if k.get("kid") == kid), None) + if match: + pubkey = _build_ec_key(match) + claims = jwt.decode( + token, + pubkey, + algorithms=["ES256"], + options={"require": ["exp", "sub"]}, + ) + logger.info("JWT verified with ES256+JWKS") + else: + logger.warning("no JWKS key found for kid=%s", kid) + except Exception as exc: + logger.error("ES256+JWKS failed: %s: %s", type(exc).__name__, exc) + + if claims is None: claims = jwt.decode( token, secret, algorithms=["HS256"], options={"require": ["exp", "sub"]}, ) + logger.info("JWT verified with HS256 fallback") + except jwt.ExpiredSignatureError as exc: raise AuthError("expired_token") from exc except jwt.InvalidTokenError as exc: + logger.error("JWT invalid: %s: %s", type(exc).__name__, exc) raise AuthError("invalid_token") from exc sub = cast(str | None, claims.get("sub")) From 99b0fa2527eb4be078203e45a659f4109d586b96 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 11:58:16 -0300 Subject: [PATCH 11/30] fix(auth): disable aud verification, only fallback HS256 for HS256 tokens --- backend/src/simples_backend/auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/src/simples_backend/auth.py b/backend/src/simples_backend/auth.py index 9d59578..6681cbe 100644 --- a/backend/src/simples_backend/auth.py +++ b/backend/src/simples_backend/auth.py @@ -81,6 +81,8 @@ def verify_supabase_jwt(token: str, secret: str, supabase_url: str = "") -> Iden claims = None + verify_opts = {"require": ["exp", "sub"], "verify_aud": False} + if alg == "ES256": try: keys = _get_jwks_keys(supabase_url) @@ -91,7 +93,7 @@ def verify_supabase_jwt(token: str, secret: str, supabase_url: str = "") -> Iden token, pubkey, algorithms=["ES256"], - options={"require": ["exp", "sub"]}, + options=verify_opts, ) logger.info("JWT verified with ES256+JWKS") else: @@ -99,14 +101,14 @@ def verify_supabase_jwt(token: str, secret: str, supabase_url: str = "") -> Iden except Exception as exc: logger.error("ES256+JWKS failed: %s: %s", type(exc).__name__, exc) - if claims is None: + if claims is None and alg == "HS256": claims = jwt.decode( token, secret, algorithms=["HS256"], - options={"require": ["exp", "sub"]}, + options=verify_opts, ) - logger.info("JWT verified with HS256 fallback") + logger.info("JWT verified with HS256") except jwt.ExpiredSignatureError as exc: raise AuthError("expired_token") from exc From fcb01a45822db0df18ccfb2e7fdca9e7947df56f Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 12:02:50 -0300 Subject: [PATCH 12/30] chore: remove test files --- test_subprotocol.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 test_subprotocol.py diff --git a/test_subprotocol.py b/test_subprotocol.py deleted file mode 100644 index b02c47e..0000000 --- a/test_subprotocol.py +++ /dev/null @@ -1,40 +0,0 @@ -import asyncio, websockets, json, jwt -from datetime import datetime, timedelta, timezone - -JWT_KEY = "62235bb1-5579-481d-9e00-c08b1d651edd" -token = jwt.encode( - {"sub": "test", "email": "t@t.com", - "exp": datetime.now(timezone.utc) + timedelta(hours=1)}, - JWT_KEY, algorithm="HS256" -) - -async def test(): - uri = "ws://nginx/ws/run" - async with websockets.connect(uri, subprotocols=["bearer." + token], max_size=2**20, open_timeout=10) as ws: - print("Connected via subprotocol!") - code = "programa demo\ninicio\n escreval 42;\nfim" - await ws.send(json.dumps({"type": "compile_and_run", "code": code})) - for _ in range(20): - try: - r = await asyncio.wait_for(ws.recv(), timeout=3) - m = json.loads(r) - t = m.get("type", "") - if t == "stdout": - d = m.get("data", "") - print(f"STDOUT: {repr(d)}") - elif t == "exit": - print(f"EXIT: code={m.get('code')}") - break - elif t == "internal_error": - print(f"ERROR: {m.get('message')}") - break - elif t in ("compile_error", "assemble_error", "link_error"): - print(f"COMPILE ERROR: {m.get('message') or m.get('stderr','')}") - break - else: - print(f"EVENT: {t}") - except asyncio.TimeoutError: - print("TIMEOUT") - break - -asyncio.run(test()) From b242b5a2f5ac1be517fa052782c977f896e9d199 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 12:21:44 -0300 Subject: [PATCH 13/30] fix(backend): keep execution loop alive on ws.receive timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PtyExecutionStrategy main loop was breaking on every ws.receive(timeout=0.05) that returned None (normal timeout), which killed the container before any stdin could be forwarded. This prevented the interactive leia flow from working — the program would block on sys_read, but the backend loop would exit within 50ms, never receiving the user's input. Changing break to continue keeps the loop alive, polling stdout, wall-clock timeout, and incoming WebSocket messages (stdin, stop, ping) as intended. --- backend/src/simples_backend/services/execution_strategy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/simples_backend/services/execution_strategy.py b/backend/src/simples_backend/services/execution_strategy.py index 69eed8e..c4c5a8d 100644 --- a/backend/src/simples_backend/services/execution_strategy.py +++ b/backend/src/simples_backend/services/execution_strategy.py @@ -126,7 +126,7 @@ def _reader() -> None: except Exception: break if raw is None: - break + continue try: msg = json.loads(raw) From 937486d855f01fd55f1902351bf613a5e67c7260 Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 12:44:32 -0300 Subject: [PATCH 14/30] feat(backend): implement websocket protocol events (#32) --- backend/src/simples_backend/routes/run_ws.py | 50 ++++++++++++++---- .../services/execution_strategy.py | 23 +++++++-- backend/tests/test_execution_strategy.py | 16 ++++-- backend/tests/test_run_ws.py | 51 ++++++++++++++++++- 4 files changed, 121 insertions(+), 19 deletions(-) diff --git a/backend/src/simples_backend/routes/run_ws.py b/backend/src/simples_backend/routes/run_ws.py index 7e1daae..2dd32f5 100644 --- a/backend/src/simples_backend/routes/run_ws.py +++ b/backend/src/simples_backend/routes/run_ws.py @@ -1,7 +1,9 @@ from __future__ import annotations import json +import logging import tempfile +from enum import Enum, auto from flask import request from flask_sock import Sock @@ -12,9 +14,17 @@ from ..services.execution_service import ExecutionError, assemble_nasm, link_object from ..services.execution_strategy import PtyExecutionStrategy +logger = logging.getLogger(__name__) + MAX_CODE_BYTES = 64 * 1024 +class ConnectionState(Enum): + IDLE = auto() + COMPILING = auto() + EXECUTING = auto() + + def extract_jwt_from_ws() -> str: protocols = request.headers.get("Sec-WebSocket-Protocol", "") for protocol in protocols.split(","): @@ -34,7 +44,7 @@ def _send(ws, msg: dict) -> None: pass -def handle_compile_and_run(ws, code: str, settings: Settings) -> None: +def handle_compile_and_run(ws, code: str, settings: Settings) -> ConnectionState: _send(ws, {"type": "compile_started"}) try: @@ -46,13 +56,13 @@ def handle_compile_and_run(ws, code: str, settings: Settings) -> None: obj_path = assemble_nasm(nasm, tmpdir, settings.compile_timeout_s) except ExecutionError as e: _send(ws, {"type": "assemble_error", "stderr": e.message}) - return + return ConnectionState.IDLE try: bin_path = link_object(obj_path, tmpdir, settings.compile_timeout_s) except ExecutionError as e: _send(ws, {"type": "link_error", "stderr": e.message}) - return + return ConnectionState.IDLE _send(ws, {"type": "exec_started"}) @@ -79,6 +89,8 @@ def handle_compile_and_run(ws, code: str, settings: Settings) -> None: except Exception as e: _send(ws, {"type": "internal_error", "message": str(e)}) + return ConnectionState.IDLE + def handle_ws_connection(ws, settings: Settings, identity: dict | None = None) -> None: if identity is None: @@ -93,6 +105,8 @@ def handle_ws_connection(ws, settings: Settings, identity: dict | None = None) - pass return + state = ConnectionState.IDLE + while True: try: raw = ws.receive() @@ -104,6 +118,7 @@ def handle_ws_connection(ws, settings: Settings, identity: dict | None = None) - try: msg = json.loads(raw) except json.JSONDecodeError: + logger.warning("invalid frame discarded (not JSON)") continue t = msg.get("type", "") @@ -112,19 +127,34 @@ def handle_ws_connection(ws, settings: Settings, identity: dict | None = None) - _send(ws, {"type": "pong"}) continue - if t != "compile_and_run": + if t == "compile_and_run": + if state != ConnectionState.IDLE: + logger.warning("ignored compile_and_run in state %s", state.name) + continue + + code = msg.get("code", "") + if not isinstance(code, str) or not code.strip(): + _send(ws, {"type": "internal_error", "message": "missing code"}) + continue + + if len(code.encode("utf-8")) > MAX_CODE_BYTES: + _send(ws, {"type": "internal_error", "message": "code_too_large"}) + continue + + state = ConnectionState.COMPILING + state = handle_compile_and_run(ws, code, settings) continue - code = msg.get("code", "") - if not isinstance(code, str) or not code.strip(): - _send(ws, {"type": "internal_error", "message": "missing code"}) + if t == "stdin" and state != ConnectionState.EXECUTING: + logger.warning("ignored stdin in state %s", state.name) continue - if len(code.encode("utf-8")) > MAX_CODE_BYTES: - _send(ws, {"type": "internal_error", "message": "code_too_large"}) + if t == "stop" and state != ConnectionState.EXECUTING: + logger.warning("ignored stop in state %s", state.name) continue - handle_compile_and_run(ws, code, settings) + if t not in ("ping", "compile_and_run", "stdin", "stop"): + logger.warning("unknown message type '%s' discarded in state %s", t, state.name) def register_run_ws(sock: Sock, settings: Settings) -> None: diff --git a/backend/src/simples_backend/services/execution_strategy.py b/backend/src/simples_backend/services/execution_strategy.py index c4c5a8d..f634028 100644 --- a/backend/src/simples_backend/services/execution_strategy.py +++ b/backend/src/simples_backend/services/execution_strategy.py @@ -74,15 +74,30 @@ def _send(msg: dict) -> None: pass def _reader() -> None: + buf = b"" try: while not stop_event.is_set(): try: - data = sock._sock.recv(4096) - if not data: + chunk = sock._sock.recv(4096) + if not chunk: break - output_queue.put(("stdout", data)) + buf += chunk + while len(buf) >= 8: + stream_id = buf[0] + if stream_id not in (1, 2): + break + payload_len = int.from_bytes(buf[4:8], "big") + frame_size = 8 + payload_len + if len(buf) < frame_size: + break + payload = buf[8:frame_size] + buf = buf[frame_size:] + event = "stdout" if stream_id == 1 else "stderr" + output_queue.put((event, payload)) except BlockingIOError: time.sleep(0.01) + if buf: + output_queue.put(("stdout", buf)) except Exception: pass finally: @@ -102,7 +117,7 @@ def _reader() -> None: if kind == "_exit": break _send({ - "type": "stdout", + "type": kind, "data": data.decode("utf-8", errors="replace"), }) continue diff --git a/backend/tests/test_execution_strategy.py b/backend/tests/test_execution_strategy.py index cc181b0..64be927 100644 --- a/backend/tests/test_execution_strategy.py +++ b/backend/tests/test_execution_strategy.py @@ -14,6 +14,10 @@ ) +def _docker_frame(stream_id: int, payload: bytes) -> bytes: + return bytes([stream_id]) + b"\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + + class TestExecutionResult: def test_dataclass_fields(self): result = ExecutionResult(exit_code=0, duration_ms=100, timed_out=False) @@ -115,7 +119,11 @@ def test_stdout_forwarded_to_ws(self, mock_docker): mock_sock = MagicMock() mock_sock._sock = MagicMock() mock_container.attach_socket.return_value = mock_sock - mock_sock._sock.recv.side_effect = [b"line1\n", b"line2\n", b""] + 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() @@ -165,7 +173,7 @@ def test_stdin_forwarded_to_container(self, mock_docker): mock_sock._sock.setblocking = MagicMock() reader_block = threading.Event() - recv_calls = iter([b"prompt> "]) + recv_calls = iter([_docker_frame(1, b"prompt> ")]) def mock_recv(size): try: @@ -201,7 +209,7 @@ def test_stop_kills_container(self, mock_docker): mock_sock._sock.setblocking = MagicMock() reader_block = threading.Event() - recv_calls = iter([b"output\n"]) + recv_calls = iter([_docker_frame(1, b"output\n")]) def mock_recv(size): try: @@ -237,7 +245,7 @@ def test_ping_pong(self, mock_docker): mock_sock._sock.setblocking = MagicMock() reader_block = threading.Event() - recv_calls = iter([b"line\n"]) + recv_calls = iter([_docker_frame(1, b"line\n")]) def mock_recv(size): try: diff --git a/backend/tests/test_run_ws.py b/backend/tests/test_run_ws.py index 59159d5..1afafa6 100644 --- a/backend/tests/test_run_ws.py +++ b/backend/tests/test_run_ws.py @@ -14,6 +14,12 @@ from simples_backend.services.execution_strategy import ExecutionResult TEST_SECRET = "0123456789abcdef0123456789abcdef" + + +def _docker_frame(stream_id: int, payload: bytes) -> bytes: + return bytes([stream_id]) + b"\x00\x00\x00" + len(payload).to_bytes(4, "big") + payload + + TEST_SETTINGS = Settings( supabase_url="http://test", supabase_jwt_secret=TEST_SECRET, @@ -191,7 +197,10 @@ def test_stdin_and_stop_through_bridge(self, mock_docker, mock_compile): mock_container.wait.return_value = {"StatusCode": 0} reader_block = threading.Event() - recv_calls = iter([b"prompt> ", b"result\n"]) + recv_calls = iter([ + _docker_frame(1, b"prompt> "), + _docker_frame(1, b"result\n"), + ]) def mock_recv(size): try: @@ -229,3 +238,43 @@ def mock_recv(size): mock_sock._sock.sendall.assert_called_with(b"42\n") mock_container.kill.assert_any_call(signal="SIGTERM") + + def test_invalid_json_discarded(self): + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + "not json", + None, + ] + handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) + sent = [json.loads(call[0][0]) for call in mock_ws.send.call_args_list] + assert sent == [] + + def test_stdin_in_idle_discarded(self): + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + json.dumps({"type": "stdin", "data": "42\n"}), + None, + ] + handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) + sent = [json.loads(call[0][0]) for call in mock_ws.send.call_args_list] + assert all(m.get("type") != "internal_error" for m in sent) + + def test_stop_in_idle_discarded(self): + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + json.dumps({"type": "stop"}), + None, + ] + handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) + sent = [json.loads(call[0][0]) for call in mock_ws.send.call_args_list] + assert all(m.get("type") != "internal_error" for m in sent) + + def test_unknown_message_type_discarded(self): + mock_ws = MagicMock() + mock_ws.receive.side_effect = [ + json.dumps({"type": "unknown_event", "foo": "bar"}), + None, + ] + handle_ws_connection(mock_ws, TEST_SETTINGS, identity=TEST_IDENTITY) + sent = [json.loads(call[0][0]) for call in mock_ws.send.call_args_list] + assert all(m.get("type") != "internal_error" for m in sent) From c2a0dc025e2bf082dad0f1decb5bcd060c9309df Mon Sep 17 00:00:00 2001 From: Maria Duda Date: Mon, 15 Jun 2026 13:39:26 -0300 Subject: [PATCH 15/30] feat(frontend): wire stop action to websocket (#33) --- frontend/src/components/Toolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 6194e58..bbc264f 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -14,7 +14,7 @@ export function Toolbar({ code, onRun, onStop, isRunning }: ToolbarProps) { return (