From 8041ced70736eef853b7e9cb2e4fc1b202bed150 Mon Sep 17 00:00:00 2001 From: Sathish Gangichetty Date: Tue, 14 Apr 2026 06:42:46 -0400 Subject: [PATCH 01/21] feat: prompt users to reuse existing sessions before creating new ones (#118) Prevent OOM crashes on 6GB Databricks Apps containers by reducing unnecessary session accumulation. Users now see their active sessions and can reuse one instead of blindly spawning a new one each time. - Add session-aware prompt on page load, new tab, and split pane - Add MAX_CONCURRENT_SESSIONS backend cap (env var, default 5) - Add session count badge on the toolbar sessions button - Add 8 tests covering the session limit enforcement --- app.py | 6 + app.yaml | 2 + static/index.html | 105 ++++++++++++++++-- tests/test_session_limit.py | 215 ++++++++++++++++++++++++++++++++++++ 4 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 tests/test_session_limit.py diff --git a/app.py b/app.py index 514c4bc..004eb39 100644 --- a/app.py +++ b/app.py @@ -43,6 +43,7 @@ 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 +MAX_CONCURRENT_SESSIONS = int(os.environ.get("MAX_CONCURRENT_SESSIONS", "5")) # Logging setup logging.basicConfig(level=logging.INFO) @@ -956,6 +957,11 @@ def configure_pat(): @app.route("/api/session", methods=["POST"]) def create_session(): """Create a new terminal session.""" + with sessions_lock: + active = sum(1 for s in sessions.values() if not s.get("exited", False)) + if active >= MAX_CONCURRENT_SESSIONS: + return jsonify({"error": f"Maximum {MAX_CONCURRENT_SESSIONS} concurrent sessions reached. Close an existing session first."}), 429 + data = request.get_json(silent=True) or {} label = data.get("label", "") try: diff --git a/app.yaml b/app.yaml index e6bb8cd..5596e08 100644 --- a/app.yaml +++ b/app.yaml @@ -12,3 +12,5 @@ env: value: databricks-gpt-5-3-codex - name: CLAUDE_CODE_DISABLE_AUTO_MEMORY value: 0 + - name: MAX_CONCURRENT_SESSIONS + value: "5" diff --git a/static/index.html b/static/index.html index bb7eecf..eaa9999 100644 --- a/static/index.html +++ b/static/index.html @@ -142,6 +142,14 @@ border-color: rgba(100,150,255,0.25); box-shadow: 0 0 8px rgba(100,150,255,0.1); } + #sessions-btn { position: relative; } + #session-count-badge { + position: absolute; top: 2px; right: 2px; + background: #e74c3c; color: #fff; font-size: 10px; font-weight: bold; + min-width: 16px; height: 16px; line-height: 16px; + border-radius: 8px; text-align: center; padding: 0 3px; + display: none; + } #toolbar .font-size-row { display: flex; align-items: center; gap: 4px; } @@ -310,7 +318,7 @@ 🎤 - + @@ -1257,6 +1265,84 @@

General

return Math.floor(seconds / 3600) + 'h'; } + // ── Session Creation Prompt ─────────────────────────────────── + async function promptExistingSessions(term, sessions) { + // Lightweight prompt shown before creating a new session when others exist. + // Returns { action: 'reuse', sessionId } or { action: 'new' } + return new Promise((resolve) => { + term.write('\x1b[2J\x1b[H'); // clear + term.write('\r\n'); + const n = sessions.length; + term.write('\x1b[1;36m You have ' + n + ' active session' + (n > 1 ? 's' : '') + ':\x1b[0m\r\n\r\n'); + + const attachedIds = new Set(getAllPanes().map(p => p.sessionId).filter(Boolean)); + sessions.forEach((s, i) => { + const name = (s.label || s.process || 'bash').padEnd(14); + const proc = s.label ? ' \x1b[90m[' + (s.process || 'bash') + ']\x1b[0m' : ''; + const ago = _formatAge(s.created_at); + const idle = s.idle_seconds > 60 ? ', idle ' + _formatDuration(s.idle_seconds) : ''; + const open = attachedIds.has(s.session_id) ? ' \x1b[1;33m(open)\x1b[0m' : ''; + term.write(' \x1b[1;32m' + (i + 1) + '\x1b[0m '); + term.write('\x1b[1;37m' + name + '\x1b[0m'); + term.write('\x1b[90m(' + ago + idle + ')\x1b[0m' + proc + open + '\r\n'); + }); + + term.write('\r\n \x1b[1;33mn\x1b[0m New session\r\n'); + term.write('\r\n\x1b[90m Select:\x1b[0m '); + + const disposable = term.onData(data => { + const num = parseInt(data); + if (num >= 1 && num <= sessions.length) { + const picked = sessions[num - 1]; + const openPanes = getAllPanes().filter(p => p.sessionId === picked.session_id); + if (openPanes.length > 0) { + term.write(data + '\r\n\x1b[1;33m Already open in another pane. Pick another:\x1b[0m '); + return; + } + disposable.dispose(); + resolve({ action: 'reuse', sessionId: picked.session_id }); + } else if (data === 'n' || data === 'N') { + disposable.dispose(); + term.write('\r\n'); + resolve({ action: 'new' }); + } + }); + }); + } + + async function getOrPromptSession(term, label, skipPrompt) { + // Check for existing sessions and prompt user before creating a new one. + if (!skipPrompt) { + try { + const resp = await fetch('/api/sessions'); + const existing = (await resp.json()).filter(s => !s.exited); + if (existing.length > 0) { + const choice = await promptExistingSessions(term, existing); + if (choice.action === 'reuse') { + await _doAttach(term, choice.sessionId); + return { sid: choice.sessionId, reattached: true }; + } + } + } catch (e) { /* session list failed — just create new */ } + } + const sid = await createSession(label); + return { sid, reattached: false }; + } + + async function updateSessionBadge() { + try { + const resp = await fetch('/api/sessions'); + const active = (await resp.json()).filter(s => !s.exited); + const badge = document.getElementById('session-count-badge'); + if (active.length > 0) { + badge.textContent = active.length; + badge.style.display = ''; + } else { + badge.style.display = 'none'; + } + } catch (e) { /* ignore */ } + } + // ── Pane Management ──────────────────────────────────────────── async function createPane(tab, opts = {}) { const id = 'pane-' + (++paneIdCounter); @@ -1421,11 +1507,9 @@

General

} } } - var sid = await createSession(tab.label); - var reattached = false; + var { sid, reattached } = await getOrPromptSession(term, tab.label, opts.skipPrompt); } else if (!opts.newSession) { - // PAT is valid, initial page load — always create a fresh session. - // Reattaching to a previous session is intentional (Ctrl+Shift+S). + // PAT is valid, initial page load — check for existing sessions first. const setupResp2 = await fetch('/api/setup-status'); const setupData2 = await setupResp2.json(); if (setupData2.status !== 'complete' && setupData2.status !== 'error') { @@ -1444,12 +1528,10 @@

General

} } } - var sid = await createSession(tab.label); - var reattached = false; + var { sid, reattached } = await getOrPromptSession(term, tab.label, opts.skipPrompt); } else { - // Split pane or new tab — always create fresh session - var sid = await createSession(tab.label); - var reattached = false; + // Split pane or new tab — check for existing sessions first + var { sid, reattached } = await getOrPromptSession(term, tab.label, opts.skipPrompt); } if (!reattached) { @@ -1496,6 +1578,7 @@

General

tab.panes.push(pane); focusPane(id); + updateSessionBadge(); return pane; } @@ -1859,6 +1942,7 @@

General

} else { pollWorker.postMessage({ type: 'start_poll', paneId: pane.id, sessionId: sid }); } + updateSessionBadge(); }); // ── Toast Notification ────────────────────────────────────────── @@ -1991,6 +2075,7 @@

General

initWebSocket(); await createTab(); + updateSessionBadge(); status.textContent = 'Connected!'; setTimeout(() => { status.style.display = 'none'; }, 1000); diff --git a/tests/test_session_limit.py b/tests/test_session_limit.py new file mode 100644 index 0000000..f67a887 --- /dev/null +++ b/tests/test_session_limit.py @@ -0,0 +1,215 @@ +"""Tests for MAX_CONCURRENT_SESSIONS cap (issue #118). + +Verifies that: +- MAX_CONCURRENT_SESSIONS defaults to 5 +- Session creation succeeds when under the limit +- Session creation returns 429 when at the limit +- Exited sessions don't count toward the limit +- The 429 response includes an informative error message +""" + +import threading +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, exited=False): + """Insert a fake session into the sessions dict.""" + session = { + "master_fd": 999, + "pid": 12345, + "output_buffer": deque(maxlen=1000), + "lock": threading.Lock(), + "last_poll_time": time.time(), + "created_at": time.time(), + "label": session_id, + } + if exited: + session["exited"] = True + 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. Default constant value +# --------------------------------------------------------------------------- + +class TestSessionLimitConstant: + + def test_default_max_concurrent_sessions_is_5(self): + app_module = _get_app() + assert app_module.MAX_CONCURRENT_SESSIONS == 5 + + def test_max_concurrent_sessions_reads_from_env(self): + with mock.patch.dict("os.environ", {"MAX_CONCURRENT_SESSIONS": "3"}): + result = int(__import__("os").environ.get("MAX_CONCURRENT_SESSIONS", "5")) + assert result == 3 + + +# --------------------------------------------------------------------------- +# 2. Session creation under the limit succeeds +# --------------------------------------------------------------------------- + +class TestSessionCreationUnderLimit: + + def test_create_session_with_zero_active(self): + app_module = _get_app() + client = app_module.app.test_client() + # Mock out pty, subprocess, and threading to avoid real PTY creation + with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")), \ + mock.patch("pty.openpty", return_value=(10, 11)), \ + mock.patch("subprocess.Popen") as mock_popen, \ + mock.patch("os.close"), \ + mock.patch("threading.Thread") as mock_thread: + mock_popen.return_value.pid = 99999 + mock_thread.return_value.start = mock.Mock() + resp = client.post("/api/session", json={"label": "test"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "session_id" in data + # Cleanup the created session + _cleanup(app_module, data["session_id"]) + + def test_create_session_with_some_active(self): + app_module = _get_app() + # Add 3 sessions (under default limit of 5) + sids = [f"existing-{i}" for i in range(3)] + try: + for sid in sids: + _add_session(app_module, sid) + client = app_module.app.test_client() + with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")), \ + mock.patch("pty.openpty", return_value=(10, 11)), \ + mock.patch("subprocess.Popen") as mock_popen, \ + mock.patch("os.close"), \ + mock.patch("threading.Thread") as mock_thread: + mock_popen.return_value.pid = 99999 + mock_thread.return_value.start = mock.Mock() + resp = client.post("/api/session", json={"label": "test"}) + assert resp.status_code == 200 + data = resp.get_json() + assert "session_id" in data + sids.append(data["session_id"]) + finally: + _cleanup(app_module, *sids) + + +# --------------------------------------------------------------------------- +# 3. Session creation at/over the limit returns 429 +# --------------------------------------------------------------------------- + +class TestSessionCreationAtLimit: + + def test_create_session_at_limit_returns_429(self): + app_module = _get_app() + limit = app_module.MAX_CONCURRENT_SESSIONS + sids = [f"full-{i}" for i in range(limit)] + try: + for sid in sids: + _add_session(app_module, sid) + client = app_module.app.test_client() + with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")): + resp = client.post("/api/session", json={"label": "one-too-many"}) + assert resp.status_code == 429 + data = resp.get_json() + assert "error" in data + assert "Maximum" in data["error"] + finally: + _cleanup(app_module, *sids) + + def test_429_error_message_includes_limit(self): + app_module = _get_app() + limit = app_module.MAX_CONCURRENT_SESSIONS + sids = [f"msg-{i}" for i in range(limit)] + try: + for sid in sids: + _add_session(app_module, sid) + client = app_module.app.test_client() + with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")): + resp = client.post("/api/session", json={}) + data = resp.get_json() + assert str(limit) in data["error"] + assert "Close an existing session" in data["error"] + finally: + _cleanup(app_module, *sids) + + +# --------------------------------------------------------------------------- +# 4. Exited sessions don't count toward the limit +# --------------------------------------------------------------------------- + +class TestExitedSessionsExcluded: + + def test_exited_sessions_not_counted(self): + app_module = _get_app() + limit = app_module.MAX_CONCURRENT_SESSIONS + # Fill with exited sessions — these should NOT block creation + sids = [f"exited-{i}" for i in range(limit)] + try: + for sid in sids: + _add_session(app_module, sid, exited=True) + client = app_module.app.test_client() + with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")), \ + mock.patch("pty.openpty", return_value=(10, 11)), \ + mock.patch("subprocess.Popen") as mock_popen, \ + mock.patch("os.close"), \ + mock.patch("threading.Thread") as mock_thread: + mock_popen.return_value.pid = 99999 + mock_thread.return_value.start = mock.Mock() + resp = client.post("/api/session", json={"label": "after-exited"}) + assert resp.status_code == 200 + data = resp.get_json() + sids.append(data["session_id"]) + finally: + _cleanup(app_module, *sids) + + def test_mix_of_active_and_exited(self): + app_module = _get_app() + limit = app_module.MAX_CONCURRENT_SESSIONS + sids = [] + try: + # Add limit-1 active + limit exited = only active ones count + for i in range(limit - 1): + sid = f"active-{i}" + _add_session(app_module, sid, exited=False) + sids.append(sid) + for i in range(limit): + sid = f"dead-{i}" + _add_session(app_module, sid, exited=True) + sids.append(sid) + client = app_module.app.test_client() + with mock.patch.object(app_module, "check_authorization", return_value=(True, "test-user")), \ + mock.patch("pty.openpty", return_value=(10, 11)), \ + mock.patch("subprocess.Popen") as mock_popen, \ + mock.patch("os.close"), \ + mock.patch("threading.Thread") as mock_thread: + mock_popen.return_value.pid = 99999 + mock_thread.return_value.start = mock.Mock() + resp = client.post("/api/session", json={"label": "still-room"}) + assert resp.status_code == 200 + data = resp.get_json() + sids.append(data["session_id"]) + finally: + _cleanup(app_module, *sids) From 7e591fb2e927a2ed010367d37fe222b6d3fb372c Mon Sep 17 00:00:00 2001 From: Sathish Gangichetty Date: Tue, 14 Apr 2026 07:15:56 -0400 Subject: [PATCH 02/21] fix: add xterm.js ClipboardAddon for OSC 52 clipboard support Claude Code uses OSC 52 escape sequences for clipboard operations. Without the ClipboardAddon, xterm.js silently ignores these sequences, making copy-paste inside Claude Code sessions non-functional. - Add addon-clipboard.js to static/lib/ - Load and initialize ClipboardAddon in createPane() - Add 8 tests verifying addon presence and integration --- static/index.html | 4 ++ static/lib/addon-clipboard.js | 2 + tests/test_clipboard_addon.py | 72 +++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 static/lib/addon-clipboard.js create mode 100644 tests/test_clipboard_addon.py diff --git a/static/index.html b/static/index.html index eaa9999..b81cdba 100644 --- a/static/index.html +++ b/static/index.html @@ -391,6 +391,7 @@

General

+