elemes/compiler_worker/app.py

275 lines
8.8 KiB
Python

"""Compiler worker — hybrid endpoint server.
Keeps the legacy /execute batch endpoint AND adds interactive PTY session
endpoints powered by SessionManager.
"""
import os
import re
import signal
import subprocess
import tempfile
import uuid
from flask import Flask, jsonify, request
from session_manager import (
SessionManager,
SessionCapacityError,
SessionNotFoundError,
SessionNotRunningError,
SessionValidationError,
)
# ------------------------------------------------------------------
# Legacy batch endpoints (backward compatible)
# ------------------------------------------------------------------
def _kill_proc_group(proc):
"""Terminate a process group: SIGTERM → sleep → SIGKILL."""
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, OSError):
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=1.0)
except Exception:
pass
def run_c_code(code, stdin="", timeout=5):
with tempfile.TemporaryDirectory() as tmpdir:
source_path = os.path.join(tmpdir, "program.c")
exe_path = os.path.join(tmpdir, "program")
with open(source_path, "w") as f:
f.write(code)
# Compile
try:
compile_res = subprocess.run(
["gcc", source_path, "-o", exe_path],
capture_output=True, text=True, timeout=10,
)
if compile_res.returncode != 0:
return {
"success": False,
"output": compile_res.stdout,
"error": compile_res.stderr,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"output": "",
"error": "Compilation timed out",
}
# Run
try:
run_res = subprocess.run(
[exe_path],
input=stdin,
capture_output=True, text=True, timeout=timeout,
)
return {
"success": True,
"output": run_res.stdout,
"error": run_res.stderr,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"output": "",
"error": "Program execution timed out",
}
except Exception as e:
return {"success": False, "output": "", "error": str(e)}
def run_python_code(code, stdin="", timeout=5):
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp:
tmp.write(code.encode("utf-8"))
tmp_path = tmp.name
try:
run_res = subprocess.run(
["python3", tmp_path],
input=stdin,
capture_output=True, text=True, timeout=timeout,
)
return {
"success": True,
"output": run_res.stdout,
"error": run_res.stderr,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"output": "",
"error": "Program execution timed out",
}
except Exception as e:
return {"success": False, "output": "", "error": str(e)}
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
# ------------------------------------------------------------------
# App factory + session manager singleton
# ------------------------------------------------------------------
_session_mgr = None
_app = None
def get_session_mgr():
global _session_mgr
if _session_mgr is None:
_session_mgr = SessionManager()
return _session_mgr
def create_app():
"""Create the Flask application with both legacy and session routes."""
app = Flask(__name__)
register_routes(app)
return app
def register_routes(app):
@app.route("/health", methods=["GET"])
def health():
try:
stats = get_session_mgr().stats()
return jsonify(stats)
except Exception as e:
return jsonify({"error": str(e), "status": "degraded"}), 500
@app.route("/execute", methods=["POST"])
def execute():
data = request.json
code = data.get("code", "")
language = data.get("language", "").lower()
stdin_input = data.get("stdin", "") or ""
if language == "c":
return jsonify(run_c_code(code, stdin_input))
elif language == "python":
return jsonify(run_python_code(code, stdin_input))
else:
return (
jsonify(
{
"success": False,
"output": "",
"error": f"Unsupported language: {language}",
}
),
400,
)
# ------------------------------------------------------------------
# Interactive session endpoints (Tasks 4)
# ------------------------------------------------------------------
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{20,}$")
def _parse_session_id(sid_str):
"""Validate and return opaque session ID string, or None."""
if sid_str and _SESSION_ID_RE.match(sid_str):
return sid_str
return None
@app.route("/sessions", methods=["POST"])
def create_session():
mgr = get_session_mgr()
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"success": False, "error": "invalid JSON body"}), 400
language = body.get("language", "").lower()
files = body.get("files", [])
active_file = body.get("active_file")
stdin_input = body.get("stdin", "") or ""
try:
session = mgr.create(language, files, active_file, stdin_input)
result = mgr.get_delta(session.session_id, cursor=0)
return jsonify(result), 202
except SessionCapacityError as e:
return jsonify({"success": False, "error": str(e)}), 429
except SessionValidationError as e:
return jsonify({"success": False, "error": str(e)}), 400
except Exception as e: # noqa: BLE001
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/sessions/<session_id>", methods=["GET"])
def get_session(session_id):
"""Get delta output from a session using optional cursor query param."""
mgr = get_session_mgr()
try:
cursor_param = request.args.get("cursor", "0")
cursor = max(0, int(cursor_param))
except (TypeError, ValueError):
cursor = 0
try:
result = mgr.get_delta(session_id, cursor=cursor)
return jsonify(result)
except SessionNotFoundError as e:
return jsonify({"success": False, "error": str(e)}), 404
except Exception as e: # noqa: BLE001
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/sessions/<session_id>/input", methods=["POST"])
def send_session_input(session_id):
mgr = get_session_mgr()
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"success": False, "error": "invalid JSON body"}), 400
text = body.get("text", "")
if not isinstance(text, str):
return jsonify({"success": False, "error": "input must be a string"}), 400
try:
mgr.write_input(session_id, text)
# Return latest status after writing input
result = mgr.get_delta(session_id, cursor=0)
return jsonify(result)
except SessionNotFoundError as e:
return jsonify({"success": False, "error": str(e)}), 404
except SessionNotRunningError as e:
return jsonify({"success": False, "error": str(e)}), 409
except Exception as e: # noqa: BLE001
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/sessions/<session_id>", methods=["DELETE"])
def stop_session(session_id):
mgr = get_session_mgr()
try:
summary = mgr.stop(session_id)
result = mgr.get_delta(session_id, cursor=0)
return jsonify(result)
except SessionNotFoundError as e:
return jsonify({"success": False, "error": str(e)}), 404
except Exception as e: # noqa: BLE001
return jsonify({"success": False, "error": str(e)}), 500
# Module-level app reference used by gunicorn
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)