fix: PTY device exhaustion and session limit (0.7.2)

- Close leaked PTY file descriptors when subprocess creation fails
- Return 503 with descriptive message when PTY devices are exhausted
- Add MAX_TERMINAL_SESSIONS (default 16, env OPEN_TERMINAL_MAX_SESSIONS)
- Prune dead sessions before checking the session limit
- Bump version to 0.7.2
This commit is contained in:
Timothy Jaeryang Baek 2026-03-02 15:43:10 -06:00
parent 80c3aee5fb
commit 840f538d79
5 changed files with 76 additions and 28 deletions

View file

@ -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

View file

@ -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"),
)
)

View file

@ -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

View file

@ -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

View file

@ -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 = [