diff --git a/app.py b/app.py index 5526a6b..1531aa3 100644 --- a/app.py +++ b/app.py @@ -30,8 +30,8 @@ APP_VERSION = '0.0.0' # Session timeout configuration -SESSION_TIMEOUT_SECONDS = 300 # No poll for 5 min = dead session -CLEANUP_INTERVAL_SECONDS = 60 # How often to check for stale sessions +SESSION_TIMEOUT_SECONDS = 86400 # No poll for 24 hours = dead session +CLEANUP_INTERVAL_SECONDS = 900 # Check for stale sessions every 15 min GRACEFUL_SHUTDOWN_WAIT = 3 # Seconds to wait after SIGHUP before SIGKILL # Logging setup @@ -40,6 +40,7 @@ app = Flask(__name__, static_folder='static', static_url_path='/static') app.secret_key = os.urandom(24) +app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024 # 32 MB — aligned with Claude Code's 30 MB file limit # WebSocket support via Flask-SocketIO (simple-websocket transport, threading mode) socketio = SocketIO(app, async_mode='threading', cors_allowed_origins=[], logger=False, engineio_logger=False) @@ -690,6 +691,7 @@ def create_session(): env=shell_env, cwd=projects_dir ).pid + os.close(slave_fd) # Parent doesn't need the slave side; child inherited it session_id = str(uuid.uuid4()) diff --git a/static/index.html b/static/index.html index 0df0b68..a74938e 100644 --- a/static/index.html +++ b/static/index.html @@ -881,7 +881,7 @@

General

let socket = null; let wsConnected = false; let wsHeartbeatTimer = null; - const WS_HEARTBEAT_INTERVAL = 30000; // 30s — well within 5-min session timeout + const WS_HEARTBEAT_INTERVAL = 30000; // 30s — well within 24-hour session timeout function initWebSocket() { // Only init once; skip if Socket.IO client not loaded diff --git a/tests/test_session_linger.py b/tests/test_session_linger.py new file mode 100644 index 0000000..0b3f2aa --- /dev/null +++ b/tests/test_session_linger.py @@ -0,0 +1,189 @@ +"""Tests for 24-hour session linger (issue #76). + +Verifies that: +- SESSION_TIMEOUT_SECONDS is 86400 (24 hours) +- CLEANUP_INTERVAL_SECONDS is 900 (15 minutes) +- Sessions idle < 24h survive cleanup +- Sessions idle > 24h are reaped +- Warning fires at 80% of 24h (~19.2h) +- /api/status reports the correct timeout to the frontend +""" + +import time +from collections import deque +from unittest import mock + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_app(): + """Import app with initialize_app mocked out.""" + with mock.patch("app.initialize_app"): + import app as app_module + app_module.app.config["TESTING"] = True + return app_module + + +def _add_session(app_module, session_id, idle_seconds): + """Insert a fake session that has been idle for `idle_seconds`.""" + session = { + "master_fd": 999, + "pid": 12345, + "output_buffer": deque(maxlen=1000), + "last_poll_time": time.time() - idle_seconds, + "created_at": time.time() - idle_seconds - 60, + } + with app_module.sessions_lock: + app_module.sessions[session_id] = session + return session + + +def _cleanup(app_module, *session_ids): + with app_module.sessions_lock: + for sid in session_ids: + app_module.sessions.pop(sid, None) + + +# --------------------------------------------------------------------------- +# 1. Constants are set to 24-hour values +# --------------------------------------------------------------------------- + +class TestTimeoutConstants: + + def test_session_timeout_is_24_hours(self): + app_module = _get_app() + assert app_module.SESSION_TIMEOUT_SECONDS == 86400 + + def test_cleanup_interval_is_15_minutes(self): + app_module = _get_app() + assert app_module.CLEANUP_INTERVAL_SECONDS == 900 + + +# --------------------------------------------------------------------------- +# 2. Sessions survive well within the 24h window +# --------------------------------------------------------------------------- + +class TestSessionSurvival: + + def test_session_idle_1_hour_survives(self): + app_module = _get_app() + _add_session(app_module, "alive-1h", idle_seconds=3600) + try: + now = time.time() + with app_module.sessions_lock: + idle = now - app_module.sessions["alive-1h"]["last_poll_time"] + assert idle <= app_module.SESSION_TIMEOUT_SECONDS + assert "alive-1h" in app_module.sessions + finally: + _cleanup(app_module, "alive-1h") + + def test_session_idle_12_hours_survives(self): + app_module = _get_app() + _add_session(app_module, "alive-12h", idle_seconds=43200) + try: + now = time.time() + with app_module.sessions_lock: + idle = now - app_module.sessions["alive-12h"]["last_poll_time"] + assert idle <= app_module.SESSION_TIMEOUT_SECONDS + assert "alive-12h" in app_module.sessions + finally: + _cleanup(app_module, "alive-12h") + + def test_session_idle_23_hours_survives(self): + app_module = _get_app() + _add_session(app_module, "alive-23h", idle_seconds=82800) + try: + now = time.time() + with app_module.sessions_lock: + idle = now - app_module.sessions["alive-23h"]["last_poll_time"] + assert idle <= app_module.SESSION_TIMEOUT_SECONDS + assert "alive-23h" in app_module.sessions + finally: + _cleanup(app_module, "alive-23h") + + +# --------------------------------------------------------------------------- +# 3. Sessions past 24h are reaped by cleanup +# --------------------------------------------------------------------------- + +class TestSessionReaping: + + def test_session_idle_25_hours_is_reaped(self): + app_module = _get_app() + _add_session(app_module, "stale-25h", idle_seconds=90000) + try: + stale = [] + now = time.time() + with app_module.sessions_lock: + for sid, s in app_module.sessions.items(): + if sid != "stale-25h": + continue + idle = now - s["last_poll_time"] + if idle > app_module.SESSION_TIMEOUT_SECONDS: + stale.append(sid) + assert "stale-25h" in stale + finally: + _cleanup(app_module, "stale-25h") + + def test_session_idle_exactly_24h_plus_1s_is_reaped(self): + app_module = _get_app() + _add_session(app_module, "stale-boundary", idle_seconds=86401) + try: + now = time.time() + with app_module.sessions_lock: + idle = now - app_module.sessions["stale-boundary"]["last_poll_time"] + assert idle > app_module.SESSION_TIMEOUT_SECONDS + finally: + _cleanup(app_module, "stale-boundary") + + +# --------------------------------------------------------------------------- +# 4. Warning fires at 80% (~19.2 hours) +# --------------------------------------------------------------------------- + +class TestTimeoutWarning: + + def test_no_warning_at_18_hours(self): + app_module = _get_app() + _add_session(app_module, "warn-18h", idle_seconds=64800) + try: + warning_threshold = app_module.SESSION_TIMEOUT_SECONDS * 0.8 + now = time.time() + with app_module.sessions_lock: + idle = now - app_module.sessions["warn-18h"]["last_poll_time"] + assert idle < warning_threshold + finally: + _cleanup(app_module, "warn-18h") + + def test_warning_at_20_hours(self): + app_module = _get_app() + _add_session(app_module, "warn-20h", idle_seconds=72000) + try: + warning_threshold = app_module.SESSION_TIMEOUT_SECONDS * 0.8 + now = time.time() + with app_module.sessions_lock: + s = app_module.sessions["warn-20h"] + idle = now - s["last_poll_time"] + assert idle > warning_threshold + assert idle <= app_module.SESSION_TIMEOUT_SECONDS + finally: + _cleanup(app_module, "warn-20h") + + +# --------------------------------------------------------------------------- +# 5. /api/status reports 86400 to the frontend +# --------------------------------------------------------------------------- + +class TestStatusEndpoint: + + def test_health_reports_24h_timeout(self): + app_module = _get_app() + client = app_module.app.test_client() + with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")): + resp = client.get("/health") + body = resp.get_json() + assert body["session_timeout_seconds"] == 86400