"""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 \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 \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 \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")