elemes/compiler_worker/tests/test_session_manager.py

436 lines
15 KiB
Python

"""Tests for session_manager.py — Tasks 1-3 of Interactive Playground A1.
Covers:
- Task 1: InteractiveSession model, bounded output buffer, capacity, stop idempotency,
retention cleanup, sweeper daemon thread.
- Task 2: PTY process lifecycle for Python (prompt, input, two sequential prompts).
- Task 3: PTY process lifecycle for C + compile queue (validation, gcc, semaphore).
"""
import os
import re
import sys
import time
import threading
import types
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
import session_manager as sm # noqa: E402
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
def wait_for(mgr, session_id, check, timeout=12.0):
"""Poll get_delta() accumulating output via cursor until check(state, got)."""
got = ""
cursor = 0
state = None
deadline = time.time() + timeout
while time.time() < deadline:
try:
state = mgr.get_delta(session_id, cursor=cursor)
except sm.SessionNotFoundError:
time.sleep(0.03)
continue
got += state["output"]
cursor = state["cursor"]
if check(state, got):
return state, got
time.sleep(0.03)
raise TimeoutError(
f"condition not met within {timeout}s; status={state and state.get('status')}, "
f"exit_code={state and state.get('exit_code')}, got={got!r}"
)
@pytest.fixture
def mgr():
"""Factory for SessionManager instances; shutdown() in teardown."""
managers = []
def make(**kwargs):
kwargs.setdefault("autostart_sweeper", False)
m = sm.SessionManager(**kwargs)
managers.append(m)
return m
yield make
for m in managers:
m.shutdown()
PY_PROMPT_CODE = 'nama = input("Siapa nama kamu? ")\nprint(f"Halo, {nama}!")\n'
C_PROMPT_CODE = (
'#include <stdio.h>\n'
'int main() {\n'
' char nama[32];\n'
' printf("Nama: ");\n'
' fflush(stdout);\n'
' if (scanf("%31s", nama) != 1) return 1;\n'
' printf("Halo, %s!\\n", nama);\n'
' return 0;\n'
'}\n'
)
# --------------------------------------------------------------------------
# Task 1 — Session model
# --------------------------------------------------------------------------
def test_session_id_unique_and_opaque(mgr):
m = mgr(max_sessions=10)
s1 = m.create("python", [{"name": "main.py", "content": "print(1)"}])
s2 = m.create("python", [{"name": "main.py", "content": "print(2)"}])
assert s1.session_id != s2.session_id
assert len(s1.session_id) >= 40 # secrets.token_urlsafe(32)
assert re.fullmatch(r"[A-Za-z0-9_-]+", s1.session_id) # opaque, URL-safe only
def test_delta_cursor_does_not_repeat_data(mgr):
m = mgr()
s = m.create(
"python",
[{"name": "main.py", "content": 'print("baris-1")\nprint("baris-2")\nprint("baris-3")'}],
)
st, got = wait_for(m, s.session_id, lambda st2, g: "baris-3" in g)
assert st["status"] == "exited"
d0 = m.get_delta(s.session_id, cursor=0)
assert "baris-1" in d0["output"] and "baris-3" in d0["output"]
assert d0["cursor"] == d0["base_cursor"] + len(d0["output"].encode("utf-8"))
# Same cursor → no repetition.
d1 = m.get_delta(s.session_id, cursor=d0["cursor"])
assert d1["output"] == ""
assert d1["cursor"] == d0["cursor"]
# Walking cursors accumulates exactly once.
acc = ""
cur = 0
while cur < d0["cursor"]:
chunk = m.get_delta(s.session_id, cursor=cur)
assert chunk["cursor"] > cur # progress guaranteed
acc += chunk["output"]
cur = chunk["cursor"]
assert acc == d0["output"]
def test_bounded_buffer_evicts_oldest_and_marks_truncated(mgr):
m = mgr(output_limit_bytes=600)
s = m.create("python", [{"name": "main.py", "content": "import time\ntime.sleep(2)"}])
wait_for(m, s.session_id, lambda st, g: st["status"] == "running")
for _ in range(3):
m._append_output(s, "A" * 400)
d = m.get_delta(s.session_id, cursor=0)
assert d["base_cursor"] > 0 # oldest data evicted
assert d["truncated"] is True # cursor 0 fell behind base
assert len(d["output"].encode("utf-8")) <= 600
# Catch-up from base → no truncation, no repetition.
d2 = m.get_delta(s.session_id, cursor=d["base_cursor"])
assert d2["output"] == d["output"]
assert d2["truncated"] is False
m.stop(s.session_id)
def test_max_sessions_rejects_51st(mgr):
m = mgr(max_sessions=50)
for i in range(50):
m.create("python", [{"name": "main.py", "content": f"print({i})"}])
with pytest.raises(sm.SessionCapacityError) as ei:
m.create("python", [{"name": "main.py", "content": "print(1)"}])
assert "50" in str(ei.value)
assert m.stats()["active_sessions"] == 50
def test_stop_idempotent(mgr):
m = mgr()
s = m.create("python", [{"name": "main.py", "content": "import time\ntime.sleep(30)"}])
wait_for(m, s.session_id, lambda st, g: st["status"] == "running")
r1 = m.stop(s.session_id)
assert r1["status"] == "stopped"
r2 = m.stop(s.session_id) # second stop: same result, no exception
assert r2["status"] == "stopped"
assert r2["session_id"] == s.session_id
d = m.get_delta(s.session_id, cursor=0)
assert d["status"] == "stopped"
assert d["exit_code"] is not None and d["exit_code"] < 0 # killed by signal
def test_terminal_session_cleaned_after_retention(mgr):
m = mgr(terminal_retention=0.1)
s = m.create("python", [{"name": "main.py", "content": 'print("selesai")'}])
wait_for(m, s.session_id, lambda st, g: st["status"] == "exited")
with m._lock:
s.last_activity_at = time.time() - 5
n = m.cleanup_expired()
assert n >= 1
with pytest.raises(sm.SessionNotFoundError):
m.get_delta(s.session_id, cursor=0)
def test_sweeper_is_daemon_and_cleans_automatically():
m = sm.SessionManager(terminal_retention=0.15, sweeper_interval=0.05)
try:
assert any(
t.name == "session-sweeper" and t.daemon for t in threading.enumerate()
)
s = m.create("python", [{"name": "main.py", "content": 'print("x")'}])
deadline = time.time() + 8
gone = False
while time.time() < deadline:
try:
st = m.get_delta(s.session_id, cursor=0)
if st["status"] == "exited":
time.sleep(0.05)
continue
except sm.SessionNotFoundError:
gone = True
break
time.sleep(0.02)
assert gone, "sweeper thread did not remove the terminal session"
finally:
m.shutdown()
# --------------------------------------------------------------------------
# Task 2 — PTY lifecycle for Python
# --------------------------------------------------------------------------
def test_python_prompt_appears_without_stdin(mgr):
m = mgr()
s = m.create("python", [{"name": "main.py", "content": PY_PROMPT_CODE}])
st, got = wait_for(m, s.session_id, lambda st2, g: "Siapa nama kamu?" in g)
assert st["status"] == "running"
assert "EOFError" not in got
def test_python_input_budi_produces_halo_no_eoferror(mgr):
m = mgr()
s = m.create("python", [{"name": "main.py", "content": PY_PROMPT_CODE}])
st, got = wait_for(m, s.session_id, lambda st2, g: "Siapa nama kamu?" in g)
m.write_input(s.session_id, "Budi\n")
st, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] == "exited")
assert "Halo, Budi!" in got
assert "EOFError" not in got
assert st["exit_code"] == 0
def test_python_two_prompts_sequential_single_process(mgr):
m = mgr()
code = 'a = input("Nama: ")\nb = input("Umur: ")\nprint(f"{a} berumur {b} tahun")\n'
s = m.create("python", [{"name": "main.py", "content": code}])
st, got = wait_for(m, s.session_id, lambda st2, g: "Nama:" in g)
assert st["status"] == "running"
m.write_input(s.session_id, "Budi\n")
st, got = wait_for(m, s.session_id, lambda st2, g: "Umur:" in g)
assert st["status"] == "running"
m.write_input(s.session_id, "17\n")
st, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] == "exited")
assert "Budi berumur 17 tahun" in got
assert "Nama:" in got and "Umur:" in got # same process, one output stream
assert "EOFError" not in got
assert st["exit_code"] == 0
def test_prefilled_stdin_written_after_spawn(mgr):
m = mgr()
s = m.create(
"python",
[{"name": "main.py", "content": PY_PROMPT_CODE}],
stdin="Budi\n",
)
st, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] == "exited")
assert "Halo, Budi!" in got
assert "EOFError" not in got
# --------------------------------------------------------------------------
# Task 3 — PTY lifecycle for C + compile queue
# --------------------------------------------------------------------------
def test_c_prompt_appears_before_input_without_newline(mgr):
m = mgr()
s = m.create("c", [{"name": "main.c", "content": C_PROMPT_CODE}])
st, got = wait_for(m, s.session_id, lambda st2, g: "Nama:" in g)
assert st["status"] == "running" # prompt flushed, still waiting for input
def test_c_input_budi_produces_halo(mgr):
m = mgr()
s = m.create("c", [{"name": "main.c", "content": C_PROMPT_CODE}])
wait_for(m, s.session_id, lambda st, g: "Nama:" in g)
m.write_input(s.session_id, "Budi\n")
st, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] == "exited")
assert "Halo, Budi!" in got
assert st["exit_code"] == 0
def test_c_main_includes_header(mgr):
m = mgr()
files = [
{
"name": "main.c",
"content": (
'#include <stdio.h>\n#include "foo.h"\n'
"int main() { printf(\"Angka: %d\\n\", tambah(2, 3)); return 0; }\n"
),
},
{
"name": "foo.h",
"content": (
"#ifndef FOO_H\n#define FOO_H\n"
"static inline int tambah(int a, int b) { return a + b; }\n"
"#endif\n"
),
},
]
s = m.create("c", files)
st, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] == "exited")
assert "Angka: 5" in got
def test_c_two_source_files_linked(mgr):
m = mgr()
files = [
{
"name": "main.c",
"content": (
'#include <stdio.h>\nint dua_kali(int x);\n'
"int main() { printf(\"Hasil: %d\\n\", dua_kali(21)); return 0; }\n"
),
},
{"name": "helper.c", "content": "int dua_kali(int x) { return x * 2; }\n"},
]
s = m.create("c", files)
st, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] == "exited")
assert "Hasil: 42" in got
@pytest.mark.parametrize(
"bad_name",
["../evil.c", "dir/evil.c", "a\\b.c", "..", "sub/main.c", "/abs/main.c"],
)
def test_c_validation_rejects_non_basename(mgr, bad_name):
m = mgr()
with pytest.raises(sm.SessionValidationError):
m.create("c", [{"name": bad_name, "content": "int main(){return 0;}"}])
def test_c_validation_rejects_duplicate_case_insensitive(mgr):
m = mgr()
files = [
{"name": "main.c", "content": "int main(){return 0;}"},
{"name": "MAIN.C", "content": "int main(){return 0;}"},
]
with pytest.raises(sm.SessionValidationError, match="[Dd]uplicate"):
m.create("c", files)
def test_c_validation_rejects_wrong_extension(mgr):
m = mgr()
with pytest.raises(sm.SessionValidationError):
m.create("c", [{"name": "main.py", "content": "x"}])
def test_c_validation_rejects_too_many_files(mgr):
m = mgr(max_files=20)
files = [{"name": f"f{i:02d}.c", "content": "int main(){return 0;}"} for i in range(21)]
with pytest.raises(sm.SessionValidationError):
m.create("c", files)
def test_c_validation_rejects_oversize_source(mgr):
m = mgr(max_source_bytes=262144)
with pytest.raises(sm.SessionValidationError):
m.create("c", [{"name": "big.c", "content": "x" * 300000}])
def test_c_validation_rejects_no_c_files(mgr):
m = mgr()
with pytest.raises(sm.SessionValidationError, match="no .c"):
m.create("c", [{"name": "only.h", "content": "int x;"}])
def test_c_compile_error_sets_error_status_and_does_not_spawn(mgr):
m = mgr()
s = m.create("c", [{"name": "main.c", "content": "int main() { syntax error here }\n"}])
st, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] == "error")
assert "error" in got.lower()
assert st["error"] == "compilation failed"
assert st["exit_code"] != 0
assert s.process is None # never spawned
def test_compile_semaphore_never_exceeds_max_compiles(mgr, monkeypatch):
m = mgr(max_compiles=2, queue_timeout=15)
release = threading.Event()
lock = threading.Lock()
active, peak = [0], [0]
def fake_run(cmd, **kw):
with lock:
active[0] += 1
peak[0] = max(peak[0], active[0])
try:
release.wait(10)
finally:
with lock:
active[0] -= 1
# Provide a real executable so the PTY spawn succeeds.
exe = os.path.join(kw.get("cwd", "/tmp"), "program")
with open(exe, "w") as fh:
fh.write("#!/bin/sh\necho compiled-ok\n")
os.chmod(exe, 0o755)
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(sm.subprocess, "run", fake_run)
sessions, errors = [], []
def do_create():
try:
sessions.append(m.create("c", [{"name": "main.c", "content": "int main(){return 0;}"}]))
except Exception as e: # noqa: BLE001
errors.append(e)
threads = [threading.Thread(target=do_create) for _ in range(6)]
for t in threads:
t.start()
st = m.stats()
deadline = time.time() + 5
while time.time() < deadline:
st = m.stats()
if st["queued"] + st["compiling"] == 6:
break
time.sleep(0.05)
assert st["queued"] + st["compiling"] == 6 # all queued/compiling, none running
assert st["compiling"] <= 2 # status queued → compiling respects semaphore
release.set()
for t in threads:
t.join(15)
assert not errors
assert peak[0] <= 2 # gcc never ran more than MAX_COMPILES concurrently
for s in sessions:
d, got = wait_for(m, s.session_id, lambda st2, g: st2["status"] in ("exited", "error"))
assert "compiled-ok" in got, f"session {s.session_id}: {got!r}"
def test_write_input_requires_running(mgr):
m = mgr()
s = m.create("python", [{"name": "main.py", "content": 'print("x")'}])
wait_for(m, s.session_id, lambda st, g: st["status"] == "exited")
with pytest.raises(sm.SessionNotRunningError):
m.write_input(s.session_id, "Budi\n")