Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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())

Expand Down
2 changes: 1 addition & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ <h3>General</h3>
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
Expand Down
189 changes: 189 additions & 0 deletions tests/test_session_linger.py
Original file line number Diff line number Diff line change
@@ -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