feat: multi-session terminal backend core (PR 1)

- Add TerminalSessionModel DB table with instance_id FK, name, status,
  created_at, last_activity_at, closed_at columns
- Add Alembic migration for terminal_sessions table
- Refactor TerminalManager to use composite key (instance_id, session_id)
  supporting up to 5 concurrent sessions per instance
- Add create_session, get_session, get_sessions_for_instance, close_session
- Preserve get_or_create_session for backward compatibility (default session)
- Fix attach_websocket to only close sockets within same session
- Add name (auto-generated 'Session N') and status tracking to TerminalSession
- Add 7 unit tests for multi-session logic

Quality gates: pytest (7 new passed, 174 total passed, 51 pre-existing failures)
This commit is contained in:
2026-05-28 11:38:22 +02:00
parent 22474cdba5
commit b55300ff6f
7 changed files with 652 additions and 71 deletions
@@ -0,0 +1,61 @@
"""add terminal_sessions table
Revision ID: 2026_05_28_add_terminal_sessions
Revises: 20260527_160017_add_pi_agent
Create Date: 2026-05-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_28_add_terminal_sessions"
down_revision: Union[str, None] = "20260527_160017_add_pi_agent"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"terminal_sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("instance_id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
onupdate=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["instance_id"], ["tool_instances.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_terminal_sessions_instance_id"),
"terminal_sessions",
["instance_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_terminal_sessions_instance_id"),
table_name="terminal_sessions",
)
op.drop_table("terminal_sessions")
+6 -1
View File
@@ -24,6 +24,7 @@ from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router from src.api.user_config import router as user_config_router
from src.api.users import router as users_router from src.api.users import router as users_router
from src.config import Settings from src.config import Settings
from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database from src.database import init_database
from src.logging_config import ( from src.logging_config import (
ExceptionLoggingMiddleware, ExceptionLoggingMiddleware,
@@ -66,7 +67,9 @@ def _sanitize_validation_errors(errors):
"type": error.get("type"), "type": error.get("type"),
"loc": error.get("loc"), "loc": error.get("loc"),
"msg": error.get("msg"), "msg": error.get("msg"),
"input": str(error.get("input")) if error.get("input") is not None else None, "input": str(error.get("input"))
if error.get("input") is not None
else None,
} }
# Convert ctx to safe format # Convert ctx to safe format
ctx = error.get("ctx") ctx = error.get("ctx")
@@ -110,10 +113,12 @@ async def on_startup():
if not db_ready: if not db_ready:
logger.error("Database initialization failed. Shutting down.") logger.error("Database initialization failed. Shutting down.")
import sys import sys
sys.exit(1) sys.exit(1)
logger.info("Startup complete.") logger.info("Startup complete.")
app.include_router(health_router) app.include_router(health_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
+15 -1
View File
@@ -4,9 +4,23 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_instance import ToolInstance from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
from src.models.user_config import UserConfig from src.models.user_config import UserConfig
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] __all__ = [
"Base",
"ConfigFolder",
"ConfigProfile",
"ConfigProfileInclude",
"GitRepository",
"Project",
"SSHKey",
"TerminalSessionModel",
"ToolInstance",
"ToolType",
"User",
"UserConfig",
]
+37
View File
@@ -0,0 +1,37 @@
"""Terminal session database model."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Database model for terminal session metadata."""
__tablename__ = "terminal_sessions"
instance_id: Mapped[uuid.UUID] = mapped_column(
UUID(),
ForeignKey("tool_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="active",
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
+263 -34
View File
@@ -3,20 +3,37 @@
import asyncio import asyncio
import logging import logging
import uuid import uuid
from datetime import datetime, timezone
from fastapi import WebSocket from fastapi import WebSocket
from src.database import SessionLocal
from src.models.terminal_session import TerminalSessionModel
from src.services.terminal_session import TerminalSession from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MaxSessionsExceededError(Exception):
"""Raised when the maximum number of terminal sessions per instance is reached."""
def __init__(self, instance_id: str, max_sessions: int = 5) -> None:
self.instance_id = instance_id
self.max_sessions = max_sessions
super().__init__(
f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}"
)
class TerminalManager: class TerminalManager:
"""Manages active terminal sessions with persistence support.""" """Manages active terminal sessions with persistence support."""
# Maximum sessions per tool instance
MAX_SESSIONS_PER_INSTANCE = 5
def __init__(self) -> None: def __init__(self) -> None:
# Track sessions by instance_id for persistence # Track sessions by (instance_id, session_id) for multi-session support
self._sessions: dict[str, TerminalSession] = {} self._sessions: dict[tuple[str, str], TerminalSession] = {}
self._idle_check_task: asyncio.Task | None = None self._idle_check_task: asyncio.Task | None = None
self._start_idle_check() self._start_idle_check()
@@ -42,16 +59,131 @@ class TerminalManager:
async def _cleanup_idle_sessions(self) -> None: async def _cleanup_idle_sessions(self) -> None:
"""Clean up sessions that have been idle for too long.""" """Clean up sessions that have been idle for too long."""
idle_sessions = [] idle_keys = []
for instance_id, session in list(self._sessions.items()): for (instance_id, session_id), session in list(self._sessions.items()):
if session.is_idle(): if session.is_idle():
idle_sessions.append(instance_id) idle_keys.append((instance_id, session_id))
for instance_id in idle_sessions: for key in idle_keys:
logger.info("Cleaning up idle terminal session for instance %s", instance_id) instance_id, session_id = key
session = self._sessions.pop(instance_id, None) logger.info(
"Cleaning up idle terminal session %s for instance %s",
session_id,
instance_id,
)
session = self._sessions.pop(key, None)
if session: if session:
await session.close() await session.close()
# Update DB status fire-and-forget
asyncio.create_task(self._mark_closed_in_db(session_id))
async def _insert_db_session_row(
self,
session_id: str,
instance_id: uuid.UUID,
name: str,
) -> None:
"""Insert a TerminalSessionModel row into the database."""
try:
async with SessionLocal() as db_session:
db_row = TerminalSessionModel(
id=uuid.UUID(session_id),
instance_id=instance_id,
name=name,
status="active",
created_at=datetime.now(timezone.utc),
last_activity_at=datetime.now(timezone.utc),
)
db_session.add(db_row)
await db_session.commit()
logger.debug(
"Inserted terminal session row %s for instance %s",
session_id,
instance_id,
)
except Exception as exc:
logger.error("Failed to insert terminal session row: %s", exc)
async def _mark_closed_in_db(self, session_id: str) -> None:
"""Mark a terminal session as closed in the database."""
try:
async with SessionLocal() as db_session:
db_row = await db_session.get(
TerminalSessionModel, uuid.UUID(session_id)
)
if db_row:
db_row.status = "closed"
db_row.closed_at = datetime.now(timezone.utc)
await db_session.commit()
logger.debug(
"Marked terminal session %s as closed in DB", session_id
)
except Exception as exc:
logger.error("Failed to mark terminal session as closed in DB: %s", exc)
def _count_sessions_for_instance(self, instance_id_str: str) -> int:
"""Count active in-memory sessions for a given instance."""
return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str)
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
name: str | None = None,
) -> TerminalSession:
"""Create a new terminal session for an instance.
Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance.
Inserts a DB row fire-and-forget.
Args:
instance_id: UUID of the tool instance.
container_id: Docker container ID.
startup_command: Optional startup command to run.
name: Optional session name (auto-generated if omitted).
Returns:
The newly created TerminalSession.
Raises:
MaxSessionsExceededError: If the instance already has max sessions.
"""
instance_id_str = str(instance_id)
if (
self._count_sessions_for_instance(instance_id_str)
>= self.MAX_SESSIONS_PER_INSTANCE
):
raise MaxSessionsExceededError(
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE
)
session_id = str(uuid.uuid4())
session = TerminalSession(
session_id=session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name=name,
)
await session.start(startup_command=startup_command)
key = (instance_id_str, session_id)
self._sessions[key] = session
# Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(session_id, instance_id, session.name)
)
logger.info(
"Created terminal session %s for instance %s (name=%s)",
session_id,
instance_id,
session.name,
)
return session
async def get_or_create_session( async def get_or_create_session(
self, self,
@@ -59,49 +191,116 @@ class TerminalManager:
container_id: str, container_id: str,
startup_command: str | None = None, startup_command: str | None = None,
) -> TerminalSession: ) -> TerminalSession:
"""Get existing session or create a new one.""" """Get existing session or create a new one.
Backward-compatible alias that uses 'default' as the session_id.
"""
# Ensure idle check is running (lazy start) # Ensure idle check is running (lazy start)
self._start_idle_check() self._start_idle_check()
instance_id_str = str(instance_id) instance_id_str = str(instance_id)
key = (instance_id_str, "default")
# Check for existing session # Check for existing default session
if instance_id_str in self._sessions: if key in self._sessions:
session = self._sessions[instance_id_str] session = self._sessions[key]
# Check if session is still alive # Check if session is still alive
if session.is_alive(): if session.is_alive():
logger.debug("Reattaching to existing terminal session for instance %s", instance_id) logger.debug(
"Reattaching to existing terminal session for instance %s",
instance_id,
)
return session return session
else: else:
# Session died, clean it up # Session died, clean it up
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id) logger.debug(
"Existing session for instance %s is dead, cleaning up",
instance_id,
)
await session.close() await session.close()
del self._sessions[instance_id_str] del self._sessions[key]
# Create new session # Create new default session
logger.info("Creating new terminal session for instance %s", instance_id) logger.info(
"Creating new default terminal session for instance %s", instance_id
)
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command) session = TerminalSession(
session_id=session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name="Session 1",
)
await session.start(startup_command=startup_command) await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session self._sessions[key] = session
# Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(session_id, instance_id, session.name)
)
return session return session
def get_session(
self,
instance_id: str,
session_id: str,
) -> TerminalSession | None:
"""Lookup a session by composite key."""
return self._sessions.get((instance_id, session_id))
def get_sessions_for_instance(
self,
instance_id: str,
) -> list[TerminalSession]:
"""Return all in-memory sessions for a given instance."""
return [
session
for (iid, _sid), session in self._sessions.items()
if iid == instance_id
]
async def close_session(
self,
instance_id: str,
session_id: str,
) -> None:
"""Close a specific session and update its DB status."""
key = (instance_id, session_id)
session = self._sessions.pop(key, None)
if session:
await session.close()
# Fire-and-forget DB update
asyncio.create_task(self._mark_closed_in_db(session_id))
logger.info(
"Closed terminal session %s for instance %s",
session_id,
instance_id,
)
async def attach_websocket( async def attach_websocket(
self, self,
session: TerminalSession, session: TerminalSession,
websocket: WebSocket, websocket: WebSocket,
) -> None: ) -> None:
"""Attach a WebSocket to an existing session.""" """Attach a WebSocket to an existing session.
# Handle concurrent connections - close existing ones
Closes existing WebSocket connections only for this specific session.
"""
# Handle concurrent connections - close existing ones within the same session
if session.has_websockets(): if session.has_websockets():
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id) logger.debug(
"Closing existing WebSocket connections for session %s (instance %s)",
session.session_id,
session.instance_id,
)
for ws in list(session._websockets): for ws in list(session._websockets):
try: try:
await ws.close(code=4000, reason="New connection established") await ws.close(code=4000, reason="New connection established")
except Exception: except Exception:
pass pass # noqa: S110
session._websockets.clear() session._websockets.clear()
# Attach new WebSocket # Attach new WebSocket
@@ -113,7 +312,7 @@ class TerminalManager:
try: try:
await websocket.send_bytes(buffer) await websocket.send_bytes(buffer)
except Exception: except Exception:
pass pass # noqa: S110
async def detach_websocket( async def detach_websocket(
self, self,
@@ -128,23 +327,53 @@ class TerminalManager:
instance_id: uuid.UUID, instance_id: uuid.UUID,
container_id: str, container_id: str,
startup_command: str | None = None, startup_command: str | None = None,
session_id: str | None = None,
) -> TerminalSession: ) -> TerminalSession:
"""Reset a session by killing it and creating a new one.""" """Reset a session by killing it and creating a new one.
Args:
instance_id: UUID of the tool instance.
container_id: Docker container ID.
startup_command: Optional startup command.
session_id: Specific session to reset. If None, resets the default session.
Returns:
The newly created TerminalSession.
"""
instance_id_str = str(instance_id) instance_id_str = str(instance_id)
target_session_id = session_id or "default"
key = (instance_id_str, target_session_id)
# Close existing session if any # Close existing session if any
if instance_id_str in self._sessions: if key in self._sessions:
logger.debug("Resetting terminal session for instance %s", instance_id) logger.debug(
old_session = self._sessions.pop(instance_id_str) "Resetting terminal session %s for instance %s",
target_session_id,
instance_id,
)
old_session = self._sessions.pop(key)
await old_session.close() await old_session.close()
# Fire-and-forget DB update for old session
asyncio.create_task(self._mark_closed_in_db(old_session.session_id))
# Create new session # Create new session preserving the same session_id slot
session_id = str(uuid.uuid4()) new_session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command) new_session = TerminalSession(
await session.start(startup_command=startup_command) session_id=new_session_id,
self._sessions[instance_id_str] = session instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name="Session 1" if target_session_id == "default" else None,
)
await new_session.start(startup_command=startup_command)
self._sessions[key] = new_session
return session # Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(new_session_id, instance_id, new_session.name)
)
return new_session
async def close_all(self) -> None: async def close_all(self) -> None:
"""Close all active sessions.""" """Close all active sessions."""
+39 -7
View File
@@ -29,7 +29,17 @@ class TerminalSession:
# Idle timeout in seconds (30 minutes) # Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60 IDLE_TIMEOUT = 30 * 60
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None: # Session number counter per instance_id for auto-naming
_instance_counters: dict[str, int] = {}
def __init__(
self,
session_id: str,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
name: str | None = None,
) -> None:
self.session_id = session_id self.session_id = session_id
self.instance_id = instance_id self.instance_id = instance_id
self.container_id = container_id self.container_id = container_id
@@ -53,6 +63,17 @@ class TerminalSession:
self._cols = 80 self._cols = 80
self._rows = 24 self._rows = 24
# Session metadata
self.name = name or self._generate_name(str(instance_id))
self.status: str = "active"
@classmethod
def _generate_name(cls, instance_id: str) -> str:
"""Generate an auto-incremented session name for the instance."""
count = cls._instance_counters.get(instance_id, 0) + 1
cls._instance_counters[instance_id] = count
return f"Session {count}"
async def start(self, startup_command: str | None = None) -> None: async def start(self, startup_command: str | None = None) -> None:
"""Start the docker exec process with a shell using a PTY.""" """Start the docker exec process with a shell using a PTY."""
# Create a pseudo-terminal on the host # Create a pseudo-terminal on the host
@@ -60,12 +81,16 @@ class TerminalSession:
# Set the terminal size initially # Set the terminal size initially
self._set_terminal_size(self._cols, self._rows) self._set_terminal_size(self._cols, self._rows)
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}") logger.debug(
f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
)
# Build the shell command # Build the shell command
if startup_command: if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il' shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}") logger.debug(
f"Using startup command for session {self.session_id}: {startup_command}"
)
else: else:
shell_cmd = "bash -il" shell_cmd = "bash -il"
@@ -99,7 +124,7 @@ class TerminalSession:
return return
# TIOCSWINSZ = 0x5414 on Linux # TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414 TIOCSWINSZ = 0x5414
size = struct.pack('HHHH', rows, cols, 0, 0) size = struct.pack("HHHH", rows, cols, 0, 0)
try: try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size) fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})") logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
@@ -170,14 +195,19 @@ class TerminalSession:
if self.process and self.process.pid: if self.process and self.process.pid:
try: try:
os.kill(self.process.pid, signal.SIGWINCH) os.kill(self.process.pid, signal.SIGWINCH)
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}") logger.debug(
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
)
except ProcessLookupError: except ProcessLookupError:
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}") logger.warning(
f"docker exec process {self.process.pid} not found for session {self.session_id}"
)
except Exception as e: except Exception as e:
logger.warning(f"Failed to send SIGWINCH: {e}") logger.warning(f"Failed to send SIGWINCH: {e}")
async def reset(self) -> None: async def reset(self) -> None:
"""Reset the session by killing the process and clearing state.""" """Reset the session by killing the process and clearing state."""
self.status = "resetting"
await self.close() await self.close()
self._closed = False self._closed = False
self._output_buffer.clear() self._output_buffer.clear()
@@ -186,18 +216,20 @@ class TerminalSession:
self.process = None self.process = None
self._master_fd = None self._master_fd = None
self._slave_fd = None self._slave_fd = None
self.status = "active"
async def close(self) -> None: async def close(self) -> None:
"""Close the session and cleanup.""" """Close the session and cleanup."""
if self._closed: if self._closed:
return return
self._closed = True self._closed = True
self.status = "closed"
if self._master_fd is not None: if self._master_fd is not None:
try: try:
os.close(self._master_fd) os.close(self._master_fd)
except OSError: except OSError:
pass pass # noqa: S110
self._master_fd = None self._master_fd = None
if self.process is not None: if self.process is not None:
@@ -0,0 +1,203 @@
"""Unit tests for TerminalManager multi-session support."""
import asyncio
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from src.services.terminal_manager import MaxSessionsExceededError, TerminalManager
from src.services.terminal_session import TerminalSession
@pytest.fixture
def manager() -> TerminalManager:
"""Provide a fresh TerminalManager instance for each test."""
tm = TerminalManager()
# Cancel the background idle check to avoid side effects
if tm._idle_check_task and not tm._idle_check_task.done():
tm._idle_check_task.cancel()
return tm
@pytest.fixture
def mock_terminal_session(monkeypatch) -> None:
"""Monkeypatch TerminalSession.start and is_alive for unit tests."""
async def fake_start(self, startup_command=None):
self.last_activity = __import__("time").time()
monkeypatch.setattr(TerminalSession, "start", fake_start)
monkeypatch.setattr(TerminalSession, "is_alive", lambda self: True)
@pytest.fixture
def instance_id() -> uuid.UUID:
return uuid.uuid4()
class FakeWebSocket:
"""Minimal fake WebSocket for testing attach/detach behavior."""
def __init__(self, name: str = "ws") -> None:
self.name = name
self.closed = False
self.close_code: int | None = None
self.close_reason: str | None = None
self._sent: list[bytes] = []
async def close(self, code: int = 1000, reason: str = "") -> None:
self.closed = True
self.close_code = code
self.close_reason = reason
async def send_bytes(self, data: bytes) -> None:
self._sent.append(data)
@pytest.mark.asyncio
async def test_create_session_increases_count(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Creating sessions increments the per-instance count."""
assert len(manager.get_sessions_for_instance(str(instance_id))) == 0
session1 = await manager.create_session(instance_id, "container-1")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 1
assert session1.session_id in [
s.session_id for s in manager.get_sessions_for_instance(str(instance_id))
]
session2 = await manager.create_session(instance_id, "container-1")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 2
# Verify sessions are distinct
assert session1.session_id != session2.session_id
@pytest.mark.asyncio
async def test_create_session_enforces_max_5(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""The 6th session creation raises MaxSessionsExceededError."""
for i in range(5):
await manager.create_session(instance_id, f"container-{i}")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 5
with pytest.raises(MaxSessionsExceededError):
await manager.create_session(instance_id, "container-overflow")
@pytest.mark.asyncio
async def test_get_sessions_for_instance_filters_by_instance(
manager: TerminalManager,
mock_terminal_session,
) -> None:
"""get_sessions_for_instance returns only sessions for the requested instance."""
instance_a = uuid.uuid4()
instance_b = uuid.uuid4()
await manager.create_session(instance_a, "container-a")
await manager.create_session(instance_a, "container-a2")
await manager.create_session(instance_b, "container-b")
assert len(manager.get_sessions_for_instance(str(instance_a))) == 2
assert len(manager.get_sessions_for_instance(str(instance_b))) == 1
@pytest.mark.asyncio
async def test_close_session_removes_from_dict(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""close_session removes the key from _sessions and marks DB closed."""
session = await manager.create_session(instance_id, "container-1")
session_id = session.session_id
assert manager.get_session(str(instance_id), session_id) is not None
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
await manager.close_session(str(instance_id), session_id)
# Give the fire-and-forget task a chance to be scheduled
await asyncio.sleep(0)
assert manager.get_session(str(instance_id), session_id) is None
mock_mark.assert_called_once_with(session_id)
@pytest.mark.asyncio
async def test_attach_websocket_only_closes_same_session(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Attaching to session A must not close WebSockets on session B."""
session_a = await manager.create_session(instance_id, "container-1")
session_b = await manager.create_session(instance_id, "container-1")
ws_a1 = FakeWebSocket("ws-a1")
ws_b1 = FakeWebSocket("ws-b1")
# Manually attach websockets (simulate prior connections)
session_a.attach_websocket(ws_a1)
session_b.attach_websocket(ws_b1)
# Now attach a new websocket to session_a
ws_a2 = FakeWebSocket("ws-a2")
await manager.attach_websocket(session_a, ws_a2)
# ws_a1 should have been closed because it's on the same session
assert ws_a1.closed is True
# ws_b1 should NOT have been closed because it's on a different session
assert ws_b1.closed is False
# ws_a2 should be attached and receive buffer
assert ws_a2 in session_a._websockets
@pytest.mark.asyncio
async def test_default_session_keyed_separately(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Default session uses 'default' session_id and does not collide with named sessions."""
default_session = await manager.get_or_create_session(instance_id, "container-1")
explicit_session = await manager.create_session(instance_id, "container-1")
# Both should exist
assert manager.get_session(str(instance_id), "default") is default_session
assert (
manager.get_session(str(instance_id), explicit_session.session_id)
is explicit_session
)
# They should be different objects
assert default_session.session_id != explicit_session.session_id
@pytest.mark.asyncio
async def test_idle_cleanup_updates_db_status(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Idle cleanup removes sessions from dict and calls DB update."""
session = await manager.create_session(instance_id, "container-1")
session_id = session.session_id
# Make session appear idle (no websockets, old last_activity)
session.last_activity = 0
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
await manager._cleanup_idle_sessions()
assert manager.get_session(str(instance_id), session_id) is None
mock_mark.assert_called_once_with(session_id)