diff --git a/CHANGELOG.md b/CHANGELOG.md index dda4472..6f244bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.7.2] - 2026-03-02 + +### Fixed + +- 🐳 **PTY device exhaustion** — fixed `OSError: out of pty devices` by closing leaked file descriptors when subprocess creation fails after `pty.openpty()`. Both `PtyRunner` (command execution) and `create_terminal` (interactive sessions) now properly clean up on error paths. +- 🛡️ **Graceful PTY error handling** — `create_terminal` now returns a clear `503` with a descriptive message when the system runs out of PTY devices, instead of an unhandled server error. + +### Added + +- 🔒 **Terminal session limit** — new `OPEN_TERMINAL_MAX_SESSIONS` environment variable (default `16`) caps the number of concurrent interactive terminal sessions. Dead sessions are automatically pruned before the limit is checked. Returns `429` when the limit is reached. + ## [0.7.1] - 2026-03-02 ### Fixed diff --git a/open_terminal/env.py b/open_terminal/env.py index 03e57e1..5bfadcb 100644 --- a/open_terminal/env.py +++ b/open_terminal/env.py @@ -58,3 +58,10 @@ BINARY_FILE_MIME_PREFIXES = [ ).split(",") if p.strip() ] + +MAX_TERMINAL_SESSIONS = int( + os.environ.get( + "OPEN_TERMINAL_MAX_SESSIONS", + config.get("max_terminal_sessions", "16"), + ) +) diff --git a/open_terminal/main.py b/open_terminal/main.py index 905aa11..3592ab1 100644 --- a/open_terminal/main.py +++ b/open_terminal/main.py @@ -24,7 +24,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field from pypdf import PdfReader -from open_terminal.env import API_KEY, BINARY_FILE_MIME_PREFIXES, CORS_ALLOWED_ORIGINS, LOG_DIR +from open_terminal.env import API_KEY, BINARY_FILE_MIME_PREFIXES, CORS_ALLOWED_ORIGINS, LOG_DIR, MAX_TERMINAL_SESSIONS from open_terminal.runner import PipeRunner, ProcessRunner, create_runner try: @@ -69,7 +69,7 @@ async def verify_api_key( app = FastAPI( title="Open Terminal", description="A remote terminal API.", - version="0.7.1", + version="0.7.2", ) app.add_middleware( CORSMiddleware, @@ -1140,6 +1140,7 @@ def _cleanup_session(session_id: str): process.kill() + @app.post("/api/terminals", dependencies=[Depends(verify_api_key)], include_in_schema=False) async def create_terminal(request: Request): """Create a new terminal session and return its ID.""" @@ -1148,20 +1149,44 @@ async def create_terminal(request: Request): {"error": "PTY not available on this platform"}, status_code=503 ) - session_id = str(_uuid.uuid4())[:8] - master_fd, slave_fd = pty.openpty() - fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + # Prune dead sessions before checking limit + dead = [sid for sid, s in _terminal_sessions.items() if s["process"].poll() is not None] + for sid in dead: + _cleanup_session(sid) - shell = os.environ.get("SHELL", "/bin/sh") - process = subprocess.Popen( - [shell], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - cwd=os.getcwd(), - env=os.environ.copy(), - start_new_session=True, - ) + if len(_terminal_sessions) >= MAX_TERMINAL_SESSIONS: + return JSONResponse( + {"error": f"Maximum number of terminal sessions ({MAX_TERMINAL_SESSIONS}) reached"}, + status_code=429, + ) + + session_id = str(_uuid.uuid4())[:8] + + try: + master_fd, slave_fd = pty.openpty() + except OSError: + return JSONResponse( + {"error": "Out of PTY devices — too many active terminals or processes"}, + status_code=503, + ) + + try: + fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + + shell = os.environ.get("SHELL", "/bin/sh") + process = subprocess.Popen( + [shell], + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=os.getcwd(), + env=os.environ.copy(), + start_new_session=True, + ) + except Exception: + os.close(slave_fd) + os.close(master_fd) + raise os.close(slave_fd) # Set non-blocking diff --git a/open_terminal/runner.py b/open_terminal/runner.py index e1b9386..ab07836 100644 --- a/open_terminal/runner.py +++ b/open_terminal/runner.py @@ -51,18 +51,23 @@ class PtyRunner(ProcessRunner): def __init__(self, command: str, cwd: str | None, env: dict | None): master_fd, slave_fd = pty.openpty() - # Set a reasonable default window size (80x24). - fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) - self._process = subprocess.Popen( - command, - shell=True, - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - cwd=cwd, - env=env, - start_new_session=True, - ) + try: + # Set a reasonable default window size (80x24). + fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + self._process = subprocess.Popen( + command, + shell=True, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=cwd, + env=env, + start_new_session=True, + ) + except Exception: + os.close(slave_fd) + os.close(master_fd) + raise os.close(slave_fd) self._master_fd = master_fd diff --git a/pyproject.toml b/pyproject.toml index eb7d4e2..eb602d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-terminal" -version = "0.7.1" +version = "0.7.2" description = "A remote terminal API." readme = "README.md" authors = [