438 lines
15 KiB
Python
438 lines
15 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Async Allocation RPC Server - Single-threaded event loop with explicit state machine.
|
|
|
|
State transitions are explicit and validated by the GMSLocalFSM class.
|
|
Operations are checked against state/mode permissions before operation.
|
|
|
|
State Machine (see locking.py for full diagram):
|
|
EMPTY: No connections, not committed
|
|
RW: Writer connected (exclusive)
|
|
COMMITTED: No connections, committed (weights valid)
|
|
RO: Reader(s) connected (shared)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from typing import ClassVar, Optional
|
|
|
|
from gpu_memory_service.common.protocol.messages import (
|
|
AllocateRequest,
|
|
ClearAllRequest,
|
|
CommitRequest,
|
|
CommitResponse,
|
|
ErrorResponse,
|
|
ExportRequest,
|
|
FreeRequest,
|
|
GetAllocationRequest,
|
|
GetAllocationStateRequest,
|
|
GetLockStateRequest,
|
|
GetStateHashRequest,
|
|
HandshakeRequest,
|
|
HandshakeResponse,
|
|
ListAllocationsRequest,
|
|
MetadataDeleteRequest,
|
|
MetadataGetRequest,
|
|
MetadataListRequest,
|
|
MetadataPutRequest,
|
|
)
|
|
from gpu_memory_service.common.protocol.wire import recv_message, send_message
|
|
from gpu_memory_service.common.types import (
|
|
GrantedLockType,
|
|
RequestedLockType,
|
|
ServerState,
|
|
StateEvent,
|
|
)
|
|
|
|
from .handler import RequestHandler
|
|
from .locking import Connection, GMSLocalFSM
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GMSRPCServer:
|
|
"""GPU Memory Service RPC Server.
|
|
|
|
Async single-threaded server using GMSLocalFSM for explicit state transitions
|
|
and operation validation. All state mutations happen through the state machine's
|
|
transition() method.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
socket_path: str,
|
|
device: int = 0,
|
|
):
|
|
self.socket_path = socket_path
|
|
self.device = device
|
|
|
|
# Request handler (business logic)
|
|
self._handler = RequestHandler(device)
|
|
|
|
# State machine - handles all state transitions and permission checks
|
|
self._sm = GMSLocalFSM(on_rw_abort=self._handler.on_rw_abort)
|
|
self._waiting_writers: int = 0
|
|
|
|
# Async waiting for lock acquisition
|
|
self._condition = asyncio.Condition()
|
|
self._shutdown = False
|
|
|
|
# Session ID generation
|
|
self._next_session_id: int = 0
|
|
|
|
# Server state
|
|
self._server: Optional[asyncio.Server] = None
|
|
self._running: bool = False
|
|
|
|
logger.info(f"GMSRPCServer initialized: device={device}")
|
|
|
|
# ==================== State Properties ====================
|
|
|
|
@property
|
|
def state(self) -> ServerState:
|
|
"""Current server state (delegated to state machine)."""
|
|
return self._sm.state
|
|
|
|
@property
|
|
def granularity(self) -> int:
|
|
return self._handler.granularity
|
|
|
|
def is_ready(self) -> bool:
|
|
"""Ready = committed and no RW connection."""
|
|
return self._sm.committed and self._sm.rw_conn is None
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
"""Whether the server is running."""
|
|
return self._running
|
|
|
|
def _generate_session_id(self) -> str:
|
|
self._next_session_id += 1
|
|
return f"session_{self._next_session_id}"
|
|
|
|
# ==================== Connection Lifecycle ====================
|
|
|
|
async def _handle_connection(
|
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
) -> None:
|
|
"""Handle a connection from accept to close."""
|
|
session_id = self._generate_session_id()
|
|
conn: Optional[Connection] = None
|
|
|
|
try:
|
|
conn = await self._do_handshake(reader, writer, session_id)
|
|
if conn is None:
|
|
return
|
|
await self._request_loop(conn)
|
|
except ConnectionResetError:
|
|
logger.debug(f"Connection reset: {session_id}")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception(f"Connection error: {session_id}")
|
|
finally:
|
|
await self._cleanup_connection(conn)
|
|
|
|
async def _do_handshake(
|
|
self,
|
|
reader: asyncio.StreamReader,
|
|
writer: asyncio.StreamWriter,
|
|
session_id: str,
|
|
) -> Optional[Connection]:
|
|
"""Perform handshake and acquire lock via state machine transition."""
|
|
try:
|
|
# Server never receives FDs from clients, so no need for raw_sock
|
|
msg, _, recv_buffer = await recv_message(reader, bytearray())
|
|
except Exception:
|
|
logger.exception("Handshake recv error")
|
|
return None
|
|
|
|
if not isinstance(msg, HandshakeRequest):
|
|
await send_message(writer, ErrorResponse(error="Expected HandshakeRequest"))
|
|
writer.close()
|
|
return None
|
|
|
|
# Acquire lock (blocks until available or timeout)
|
|
# Returns the actual granted mode (may differ from requested for rw_or_ro)
|
|
granted_mode = await self._acquire_lock(msg.lock_type, msg.timeout_ms)
|
|
if granted_mode is None:
|
|
await send_message(
|
|
writer, HandshakeResponse(success=False, committed=self._sm.committed)
|
|
)
|
|
writer.close()
|
|
return None
|
|
|
|
conn = Connection(
|
|
reader=reader,
|
|
writer=writer,
|
|
mode=granted_mode,
|
|
session_id=session_id,
|
|
recv_buffer=recv_buffer,
|
|
)
|
|
|
|
# State transition: connect
|
|
event = (
|
|
StateEvent.RW_CONNECT
|
|
if granted_mode == GrantedLockType.RW
|
|
else StateEvent.RO_CONNECT
|
|
)
|
|
self._sm.transition(event, conn)
|
|
|
|
await send_message(
|
|
writer,
|
|
HandshakeResponse(
|
|
success=True,
|
|
committed=self._sm.committed,
|
|
granted_lock_type=granted_mode,
|
|
),
|
|
)
|
|
return conn
|
|
|
|
async def _acquire_lock(
|
|
self,
|
|
mode: RequestedLockType,
|
|
timeout_ms: Optional[int],
|
|
) -> Optional[GrantedLockType]:
|
|
"""Wait until lock can be acquired (uses state machine predicates).
|
|
|
|
Returns the granted lock type, or None if failed/timeout.
|
|
For rw_or_ro mode, returns RW if available immediately, else waits for RO.
|
|
"""
|
|
timeout = timeout_ms / 1000 if timeout_ms is not None else None
|
|
|
|
if mode == RequestedLockType.RW:
|
|
self._waiting_writers += 1
|
|
try:
|
|
async with self._condition:
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._condition.wait_for(
|
|
lambda: self._shutdown or self._sm.can_acquire_rw()
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
return None if self._shutdown else GrantedLockType.RW
|
|
except asyncio.TimeoutError:
|
|
return None
|
|
finally:
|
|
self._waiting_writers -= 1
|
|
|
|
elif mode == RequestedLockType.RO:
|
|
async with self._condition:
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._condition.wait_for(
|
|
lambda: self._shutdown
|
|
or self._sm.can_acquire_ro(self._waiting_writers)
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
return None if self._shutdown else GrantedLockType.RO
|
|
except asyncio.TimeoutError:
|
|
return None
|
|
|
|
elif mode == RequestedLockType.RW_OR_RO:
|
|
# Auto mode: try RW if available immediately AND no committed weights,
|
|
# otherwise wait for RO (to import existing weights)
|
|
async with self._condition:
|
|
# Check if RW is available AND no committed weights exist
|
|
# If weights are already committed, prefer RO to import them
|
|
if self._sm.can_acquire_rw() and not self._sm.committed:
|
|
return GrantedLockType.RW
|
|
|
|
# Either RW not available OR weights already committed - wait for RO
|
|
if self._sm.committed:
|
|
logger.info(
|
|
"RW_OR_RO: Weights already committed, preferring RO to import"
|
|
)
|
|
else:
|
|
logger.info(
|
|
"RW_OR_RO: RW not available (another writer active), "
|
|
"falling back to RO"
|
|
)
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._condition.wait_for(
|
|
lambda: self._shutdown
|
|
or self._sm.can_acquire_ro(self._waiting_writers)
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
return None if self._shutdown else GrantedLockType.RO
|
|
except asyncio.TimeoutError:
|
|
return None
|
|
return None
|
|
|
|
async def _cleanup_connection(self, conn: Optional[Connection]) -> None:
|
|
"""Clean up after connection closes via state machine transition."""
|
|
if conn is None:
|
|
return
|
|
|
|
# State transition: disconnect
|
|
if conn.mode == GrantedLockType.RW:
|
|
if self._sm.rw_conn is conn and not self._sm.committed:
|
|
# RW abort - state machine callback handles cleanup
|
|
self._sm.transition(StateEvent.RW_ABORT, conn)
|
|
elif self._sm.rw_conn is conn:
|
|
# Already committed, no transition needed (commit already did it)
|
|
pass
|
|
else:
|
|
if conn in self._sm.ro_conns:
|
|
self._sm.transition(StateEvent.RO_DISCONNECT, conn)
|
|
|
|
await conn.close()
|
|
async with self._condition:
|
|
self._condition.notify_all()
|
|
|
|
# ==================== Request Handling ====================
|
|
|
|
async def _request_loop(self, conn: Connection) -> None:
|
|
"""Process requests until close or commit."""
|
|
while self._running:
|
|
try:
|
|
# Server never receives FDs from clients, so no need for raw_socket
|
|
msg, _, conn.recv_buffer = await recv_message(
|
|
conn.reader, conn.recv_buffer
|
|
)
|
|
except ConnectionResetError:
|
|
return
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.exception("Recv error")
|
|
return
|
|
|
|
if msg is None:
|
|
continue
|
|
|
|
try:
|
|
response, fd, should_close = await self._dispatch(conn, msg)
|
|
if response is not None:
|
|
try:
|
|
await send_message(conn.writer, response, fd)
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
if should_close:
|
|
return
|
|
except Exception as e:
|
|
logger.exception("Request error")
|
|
await send_message(conn.writer, ErrorResponse(error=str(e)))
|
|
|
|
# Dispatch table: message type -> handler method name
|
|
# Handlers take (msg) and return response. Special cases handled separately.
|
|
_HANDLERS: ClassVar[dict[type, str]] = {
|
|
AllocateRequest: "handle_allocate",
|
|
GetAllocationRequest: "handle_get_allocation",
|
|
ListAllocationsRequest: "handle_list_allocations",
|
|
FreeRequest: "handle_free",
|
|
MetadataPutRequest: "handle_metadata_put",
|
|
MetadataGetRequest: "handle_metadata_get",
|
|
MetadataDeleteRequest: "handle_metadata_delete",
|
|
MetadataListRequest: "handle_metadata_list",
|
|
}
|
|
|
|
async def _dispatch(self, conn: Connection, msg) -> tuple[object, int, bool]:
|
|
"""Dispatch request to handler. Returns (response, fd, should_close)."""
|
|
msg_type = type(msg)
|
|
self._sm.check_operation(msg_type, conn)
|
|
|
|
# Special cases
|
|
if msg_type is CommitRequest:
|
|
return await self._handle_commit(conn)
|
|
|
|
if msg_type is GetLockStateRequest:
|
|
return (
|
|
self._handler.handle_get_lock_state(
|
|
self._sm.rw_conn is not None,
|
|
self._sm.ro_count,
|
|
self._waiting_writers,
|
|
self._sm.committed,
|
|
),
|
|
-1,
|
|
False,
|
|
)
|
|
|
|
if msg_type is GetAllocationStateRequest:
|
|
return self._handler.handle_get_allocation_state(), -1, False
|
|
|
|
if msg_type is ExportRequest:
|
|
response, fd = self._handler.handle_export(msg.allocation_id)
|
|
return response, fd, False
|
|
|
|
if msg_type is ClearAllRequest:
|
|
return self._handler.handle_clear_all(), -1, False
|
|
|
|
if msg_type is GetStateHashRequest:
|
|
return self._handler.handle_get_memory_layout_hash(), -1, False
|
|
|
|
# Standard dispatch: handler takes msg, returns response
|
|
handler_name = self._HANDLERS.get(msg_type)
|
|
if handler_name:
|
|
handler = getattr(self._handler, handler_name)
|
|
return handler(msg), -1, False
|
|
|
|
raise ValueError(f"Unknown request: {msg_type.__name__}")
|
|
|
|
async def _handle_commit(self, conn: Connection) -> tuple[object, int, bool]:
|
|
"""Handle commit via state machine transition - atomic with disconnect."""
|
|
self._handler.on_commit()
|
|
self._sm.transition(StateEvent.RW_COMMIT, conn)
|
|
|
|
await send_message(conn.writer, CommitResponse(success=True))
|
|
await conn.close()
|
|
|
|
async with self._condition:
|
|
self._condition.notify_all()
|
|
|
|
return None, -1, True
|
|
|
|
# ==================== Server Lifecycle ====================
|
|
|
|
async def start(self) -> None:
|
|
if os.path.exists(self.socket_path):
|
|
os.unlink(self.socket_path)
|
|
|
|
self._server = await asyncio.start_unix_server(
|
|
self._handle_connection, path=self.socket_path
|
|
)
|
|
self._running = True
|
|
logger.info(f"Server started: {self.socket_path}")
|
|
|
|
async def stop(self) -> None:
|
|
self._running = False
|
|
self._shutdown = True
|
|
async with self._condition:
|
|
self._condition.notify_all()
|
|
|
|
if self._server:
|
|
self._server.close()
|
|
await self._server.wait_closed()
|
|
self._server = None
|
|
|
|
# Close connections (bypassing state machine - this is shutdown)
|
|
if self._sm.rw_conn:
|
|
await self._sm.rw_conn.close()
|
|
|
|
for conn in list(self._sm.ro_conns):
|
|
await conn.close()
|
|
|
|
self._handler.on_shutdown()
|
|
|
|
if os.path.exists(self.socket_path):
|
|
os.unlink(self.socket_path)
|
|
|
|
logger.info("Server stopped")
|
|
|
|
async def serve_forever(self) -> None:
|
|
await self.start()
|
|
try:
|
|
while self._running:
|
|
await asyncio.sleep(1)
|
|
finally:
|
|
await self.stop()
|