Source code for ralph.mcp.server.exec_sse_streaming

"""SSE streaming core for the exec tool fallback HTTP path."""

from __future__ import annotations

import json
import threading
from typing import TYPE_CHECKING

from ralph.mcp.server._metrics import get_default_metrics

if TYPE_CHECKING:
    from collections.abc import Callable

    from ralph.mcp.protocol.session import McpSession
    from ralph.mcp.server._json_rpc_request import JsonRpcRequest
    from ralph.mcp.server._json_rpc_response import JsonRpcResponse
    from ralph.mcp.server._server_state import ServerState

# Serializes sink-entry swap/clear across concurrent request threads. The
# entry itself is read lock-free (a single atomic attribute load), but the
# clear must be compare-and-swap under this lock or a finished request could
# race a newer owner's swap and stomp it.
_SINK_SWAP_LOCK = threading.Lock()


[docs] def exec_sse_streaming_post( request: JsonRpcRequest, session: McpSession, handle_request: Callable[ [JsonRpcRequest, ServerState], tuple[JsonRpcResponse | None, ServerState] ], state: ServerState, *, write_frame: Callable[[bytes], None], ) -> ServerState: """Stream exec output chunks as SSE notification frames, then write the final response. Installs an atomic (owner thread, sink) entry on the session for the duration of the dispatch; the exec handler captures it once, on this thread, so output chunks flow to THIS connection and no other. The entry is cleared compare-and-swap on exit. Returns the updated ServerState from handle_request (or the original on error). """ new_state = state entry_swapped = False # Chunk frames arrive from the exec subprocess's reader threads while the # final frame is written from the request thread; serialize them so a # straggling chunk cannot interleave bytes mid-frame and corrupt the final # frame (an unparseable final frame leaves the client hanging to -32001). frame_lock = threading.Lock() def _locked_write(frame: bytes) -> None: with frame_lock: write_frame(frame) def _write_notification(event: dict[str, object]) -> None: notification: dict[str, object] = { "jsonrpc": "2.0", "method": "notifications/message", "params": event, } frame = f"event: message\r\ndata: {json.dumps(notification)}\r\n\r\n".encode() _locked_write(frame) my_entry = (threading.get_ident(), _write_notification) # Everything after the SSE headers runs inside this net: an exception that # escapes here kills the request thread AFTER the 200 was sent, so the # client sees a bodyless stream it cannot distinguish from a running call # and hangs until its request timeout (-32001). Every failure — including a # session object missing the streaming surface — must resolve to a final # JSON-RPC frame. try: with _SINK_SWAP_LOCK: session.tool_output_sink_entry = my_entry entry_swapped = True response, new_state = handle_request(request, state) if response is not None: body: dict[str, object] = {"jsonrpc": response.jsonrpc, "id": response.msg_id} if response.result is not None: body["result"] = response.result if response.error is not None: body["error"] = response.error _locked_write(f"event: message\r\ndata: {json.dumps(body)}\r\n\r\n".encode()) get_default_metrics().record_terminal_frame(request.method) except Exception as exc: error_body: dict[str, object] = { "jsonrpc": "2.0", "id": request.msg_id, "error": {"code": -32603, "message": str(exc)}, } _locked_write(f"event: message\r\ndata: {json.dumps(error_body)}\r\n\r\n".encode()) get_default_metrics().record_terminal_frame(request.method) finally: # Compare-and-swap clear: only clear if this request's entry is still # installed. An overlapping request that took ownership mid-dispatch # keeps its own entry (it clears it itself); clearing to None — rather # than restoring a predecessor — guarantees a stale sink bound to a # finished request can never be resurrected. if entry_swapped: with _SINK_SWAP_LOCK: if session.tool_output_sink_entry is my_entry: session.tool_output_sink_entry = None return new_state
__all__ = ["exec_sse_streaming_post"]