Skip to content
94 changes: 82 additions & 12 deletions src/openenv/core/env_server/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ def __init__(
self._session_executors: Dict[str, ThreadPoolExecutor] = {}
self._session_stacks: Dict[str, AsyncExitStack] = {}
self._session_info: Dict[str, SessionInfo] = {}
self._session_websocket_attachments: set[str] = set()
self._session_pending_closes: set[str] = set()
self._session_lock = asyncio.Lock()

# Create thread pool for running sync code in async context
Expand Down Expand Up @@ -451,6 +453,8 @@ async def _destroy_session(self, session_id: str) -> None:
executor = self._session_executors.pop(session_id, None)
stack = self._session_stacks.pop(session_id, None)
self._session_info.pop(session_id, None)
self._session_websocket_attachments.discard(session_id)
self._session_pending_closes.discard(session_id)

await self._cleanup_session_resources(env, executor, stack)

Expand Down Expand Up @@ -522,7 +526,10 @@ async def _reap_idle_sessions(self) -> None:
stale_ids: list[str] = []
async with self._session_lock:
for sid, info in self._session_info.items():
if now - info.last_activity_at > timeout:
if (
sid not in self._session_websocket_attachments
and now - info.last_activity_at > timeout
):
stale_ids.append(sid)
for sid in stale_ids:
# Re-check under lock: activity may have arrived since
Expand All @@ -532,7 +539,11 @@ async def _reap_idle_sessions(self) -> None:
now = time.time()
async with self._session_lock:
info = self._session_info.get(sid)
if info is None or (now - info.last_activity_at) <= timeout:
if (
info is None
or sid in self._session_websocket_attachments
or (now - info.last_activity_at) <= timeout
):
continue
await self._destroy_session(sid)
except asyncio.CancelledError:
Expand Down Expand Up @@ -804,15 +815,33 @@ async def mcp_handler(
)

async with self._session_lock:
env = self._sessions.pop(target_session_id, _MISSING)
if env is not _MISSING:
if target_session_id in self._session_websocket_attachments:
env = _MISSING
attached = True
self._session_pending_closes.add(target_session_id)
executor = None
stack = None
else:
attached = False
env = self._sessions.pop(target_session_id, _MISSING)
if not attached and env is not _MISSING:
executor = self._session_executors.pop(target_session_id, None)
stack = self._session_stacks.pop(target_session_id, None)
self._session_info.pop(target_session_id, None)
else:
elif not attached:
executor = None
stack = None

if attached:
return JsonRpcResponse.success(
result={
"session_id": target_session_id,
"closed": False,
"closing": True,
},
request_id=request_id,
)

if env is _MISSING:
return JsonRpcResponse.error_response(
JsonRpcErrorCode.INVALID_PARAMS,
Expand Down Expand Up @@ -1487,18 +1516,52 @@ async def websocket_endpoint(websocket: WebSocket):
"""
WebSocket endpoint for persistent environment sessions.

Each WebSocket connection gets its own environment instance. The client sends
WSResetMessage, WSStepMessage, WSStateMessage, or WSCloseMessage; the server
responds with WSObservationResponse, WSStateResponse, or WSErrorResponse.
By default, each WebSocket connection gets its own environment instance.
A client can instead attach to an existing HTTP MCP session by passing its
session ID in the query string. The client sends WSResetMessage,
WSStepMessage, WSStateMessage, or WSCloseMessage; the server responds with
WSObservationResponse, WSStateResponse, or WSErrorResponse.
"""
await websocket.accept()

session_id = None
session_env = None
owns_session = False
attached_session = False

try:
# Create session with dedicated environment
session_id, session_env = await self._create_session()
requested_session_id = websocket.query_params.get("session_id")
if requested_session_id:
async with self._session_lock:
attached_env = self._sessions.get(
requested_session_id, _MISSING
)
if attached_env is _MISSING:
raise RuntimeError(
f"Unknown session_id: {requested_session_id}"
)
if attached_env is None:
raise RuntimeError(
f"Session {requested_session_id} is still initializing"
)
if requested_session_id in self._session_pending_closes:
raise RuntimeError(
f"Session {requested_session_id} is closing"
)
if requested_session_id in self._session_websocket_attachments:
raise RuntimeError(
f"Session {requested_session_id} already has "
"an attached WebSocket"
)
self._session_websocket_attachments.add(requested_session_id)
session_id = requested_session_id
session_env = attached_env
attached_session = True
self._update_session_activity(session_id)
else:
session_id, session_env = await self._create_session()
owns_session = True

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Owned WebSockets skip exclusive attach tracking

Medium Severity

Plain /ws connections create a session but never join _session_websocket_attachments. The new attach guard only rejects IDs already in that set, so a second socket with session_id can share a WebSocket-owned environment. When the owner disconnects, _destroy_session still runs and tears that environment down under the attached client.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d7c9a7e. Configure here.


if session_env is None:
raise RuntimeError(
"Session environment not initialized for websocket"
Expand All @@ -1509,7 +1572,7 @@ async def websocket_endpoint(websocket: WebSocket):

async with AsyncExitStack() as stack:
mcp_session_factory = getattr(session_env, "mcp_session", None)
if callable(mcp_session_factory):
if owns_session and callable(mcp_session_factory):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The owns_session and gate is correct — it avoids re-entering mcp_session on the attach path, since the HTTP session's _create_session() already holds it open for the session's lifetime.

Follow-up (pre-existing, non-blocking): for the owns_session path, _create_session() also enters mcp_session and stores it in _session_stacks[session_id] (since #432), so this handler-level re-entry now opens a second concurrent mcp_session context on the same env. Since _create_session now owns that lifecycle for both owned and attached sessions, this re-entry looks redundant — consider dropping it so owned sessions don't double-enter mcp_session.

mcp_session_cm = cast(
AsyncContextManager[Any], mcp_session_factory()
)
Expand Down Expand Up @@ -1688,7 +1751,14 @@ async def websocket_endpoint(websocket: WebSocket):
)
await websocket.send_text(error_resp.model_dump_json())
finally:
if session_id:
if attached_session and session_id:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking robustness note. On an abnormal WebSocket drop (e.g. a crashed production client), this branch detaches (discard) but does not destroy the session — a crashed client never sent openenv/session/close, so the session isn't in _session_pending_closes. The shared, HTTP-owned env then stays alive and holds a slot against MAX_CONCURRENT_ENVS, reclaimable only by the idle reaper, which is a no-op when session_timeout is None (the default ConcurrencyConfig).

This matches the pre-existing persistent-HTTP-/mcp-session behavior, but note the behavior change vs. before this PR: a production /ws drop used to reclaim its own dedicated session immediately; now the attached /ws no longer owns the env, so a crash leaks the slot until server restart unless an idle session_timeout is configured. Worth confirming that's acceptable for the intended deployments (or documenting that production servers should set a session_timeout).

# Do not await the lock before releasing ownership: a task
# cancelled while waiting would leave this session
# permanently exempt from idle reaping.
self._session_websocket_attachments.discard(session_id)
if session_id in self._session_pending_closes:
await asyncio.shield(self._destroy_session(session_id))
elif owns_session and session_id:
await self._destroy_session(session_id)
try:
await websocket.close()
Expand Down
109 changes: 73 additions & 36 deletions src/openenv/core/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@

import asyncio
from typing import Any, Dict, List, Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

from pydantic import ConfigDict

Expand Down Expand Up @@ -156,6 +157,7 @@ def __init__(
self._tools_cache: Optional[List[Tool]] = None
self.use_production_mode = self._mode == "production"
self._production_session_id: Optional[str] = None
self._production_connect_lock = asyncio.Lock()
self._production_session_lock = asyncio.Lock()
self._jsonrpc_request_id = 0
self._http_client: Optional[Any] = None # lazily-created httpx.AsyncClient
Expand All @@ -166,11 +168,19 @@ def _next_request_id(self) -> int:
return self._jsonrpc_request_id

def _production_mcp_url(self) -> str:
"""Build HTTP MCP endpoint URL from the client's websocket URL."""
url = self._ws_url.replace("ws://", "http://").replace("wss://", "https://")
if url.endswith("/ws"):
url = url[: -len("/ws")]
return url.rstrip("/") + "/mcp"
"""Build the HTTP MCP endpoint URL from the stable base URL."""
if self._base_url is None:
raise RuntimeError("MCP client is not connected to a server.")
parts = urlsplit(self._base_url)
scheme = {"ws": "http", "wss": "https"}.get(parts.scheme, parts.scheme)
return urlunsplit(
parts._replace(
scheme=scheme,
path=parts.path.rstrip("/") + "/mcp",
query="",
fragment="",
)
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schemeless base URLs break MCP requests

Low Severity

_production_mcp_url now builds the /mcp endpoint from raw _base_url instead of the scheme-normalized _ws_url. A schemeless host such as localhost:8000 is parsed as a URL scheme, so tool and session calls go to an invalid URL while the WebSocket still connects via convert_to_ws_url.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d7c9a7e. Configure here.


async def _get_http_client(self) -> Any:
"""Return a shared httpx.AsyncClient, creating one lazily."""
Expand Down Expand Up @@ -202,19 +212,35 @@ async def _connect_async(self) -> EnvClient:
"""
Establish connection to the server.

In production mode (`use_production_mode=True`), open the WebSocket used
by `reset` / `step` / `state` and create a persistent HTTP MCP session
for `list_tools` / `call_tool`. Tool calls bypass `step()` over `/mcp`,
but the Gym lifecycle still requires `/ws` until production routing
covers those methods end-to-end.
In production mode (use_production_mode=True), creates an HTTP MCP session
and connects the WebSocket using that session ID so that WebSocket (reset/step/state)
and HTTP MCP (list_tools/call_tool) share the exact same server-side environment session.
"""
if getattr(self, "use_production_mode", False):
try:
await super()._connect_async()
await self._ensure_production_session()
except Exception:
await self.close()
raise
async with self._production_connect_lock:
try:
self._start_provider_if_needed()
session_id = await self._ensure_production_session()
original_ws_url = self._ws_url
if original_ws_url is None:
raise RuntimeError("MCP client has no WebSocket URL.")

parts = urlsplit(original_ws_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["session_id"] = session_id
self._ws_url = urlunsplit(parts._replace(query=urlencode(query)))
try:
await super()._connect_async()
finally:
self._ws_url = original_ws_url
except BaseException:
# CancelledError is a BaseException: cleanup must still run
# after session create so capacity is not leaked.
try:
await asyncio.shield(self.close())
except Exception:
pass
raise
return self

return await super()._connect_async()
Expand Down Expand Up @@ -365,33 +391,44 @@ async def _close_async(self) -> None:
Close client resources.

In production MCP mode, this also closes the server-side persistent
MCP session (best effort) before closing websocket/provider resources.
MCP session (best effort) after detaching the WebSocket and before
closing HTTP/provider resources.

Override `_close_async` rather than `close` so sync teardown
(`SyncEnvClient.close`, sync `__exit__`, and `_dispatch`) still cleans
up the HTTP MCP session.
"""
if self._production_session_id is not None:
try:
# The WebSocket shares the HTTP-created session. Detach it first so
# the server's ownership guard permits the explicit session close.
await self._disconnect_async()
finally:
try:
await self._production_mcp_request(
"openenv/session/close",
{"session_id": self._production_session_id},
)
except Exception:
# Best effort cleanup - do not mask normal close behavior
pass
if self._production_session_id is not None:
try:
await self._production_mcp_request(
"openenv/session/close",
{"session_id": self._production_session_id},
)
except Exception:
# Best effort cleanup - do not mask normal close behavior
pass
finally:
self._production_session_id = None
finally:
self._production_session_id = None

if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception:
pass
finally:
self._http_client = None

await super()._close_async()
try:
if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
# Best effort — do not mask provider/websocket teardown
pass
finally:
self._http_client = None
finally:
# This is intentionally inside the outer finally so
# cancellation cannot skip provider teardown.
await super()._close_async()


class MCPToolClient(MCPClientBase):
Expand Down
Loading