groot_deployment / Isaac-GR00T /gr00t /policy /server_client.py
Timsty's picture
Add files using upload-large-folder tool
b6a9d87 verified
Raw History Blame Contribute Delete
15.6 kB
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from dataclasses import dataclass
import functools
import io
import json
from typing import Any, Callable
import msgpack
import msgpack_numpy as mnp
import numpy as np
import zmq
from gr00t.data.types import ModalityConfig
from gr00t.data.utils import to_json_serializable
from .policy import BasePolicy
class MsgSerializer:
"""msgpack_numpy serializer with a hard ``allow_pickle=False`` boundary.
Implementation note: msgpack_numpy's ``Packer``/``Unpacker`` wire any
user-provided ``default``/``object_hook`` *behind* their own
``mnp.encode``/``mnp.decode`` (``functools.partial(encode, chain=user_fn)``).
That means an object-dtype ndarray is serialised by ``mnp.encode`` (and a
forged ``{nd: True, kind: 'O', ...}`` payload is fed to ``pickle.loads`` by
``mnp.decode``) *before* a hook installed via ``mnp.packb``/``mnp.unpackb``
can intervene. We therefore drive ``msgpack`` directly and chain
``_safe_{encode,decode} → mnp.{encode,decode} → custom`` ourselves, so the
refusal runs first.
"""
@staticmethod
def to_bytes(data: Any) -> bytes:
default = functools.partial(MsgSerializer._safe_encode, chain=MsgSerializer._encode_custom)
return msgpack.packb(data, default=default)
@staticmethod
def from_bytes(data: bytes) -> Any:
object_hook = functools.partial(
MsgSerializer._safe_decode, chain=MsgSerializer._decode_custom
)
return msgpack.unpackb(data, object_hook=object_hook, raw=False)
@staticmethod
def _safe_encode(obj, chain=None):
# Refuse object-dtype ndarrays before mnp.encode would emit a
# ``{nd: True, kind: 'O', data: pickle.dumps(arr)}`` envelope, which
# silently re-enables the arbitrary-code surface that the previous
# ``np.save(..., allow_pickle=False)`` path explicitly forbade.
if isinstance(obj, np.ndarray):
if obj.dtype.kind == "O":
raise TypeError(
f"Refusing to encode object-dtype ndarray (shape={obj.shape}); "
f"msgpack_numpy would invoke pickle. Convert to a concrete "
f"numeric dtype before sending."
)
return mnp.encode(obj, chain=chain)
@staticmethod
def _safe_decode(obj, chain=None):
if isinstance(obj, dict):
marker = obj.get("__ndarray_class__", obj.get(b"__ndarray_class__"))
if marker:
payload = obj.get("as_npy", obj.get(b"as_npy"))
if payload is None:
raise ValueError(
"Malformed ndarray payload: marker present but 'as_npy' missing"
)
return np.load(io.BytesIO(payload), allow_pickle=False)
# Refuse object-dtype ndarray payloads before mnp.decode would call
# ``pickle.loads`` on attacker-controlled bytes. Check both bytes- and
# str-encoded keys, and accept any truthy ``nd`` value (not just
# boolean ``True``) so a forged ``{nd: 1, kind: 'O', ...}`` payload
# can't sidestep the guard via msgpack's int-vs-bool type codes.
# msgpack_numpy 0.4.8's own check is ``obj[b'nd'] is True``, so the
# described variants don't actually reach pickle.loads today, but
# MsgSerializer enforces the contract at this boundary regardless of
# mnp's wire / identity-check conventions.
if isinstance(obj, dict):
nd_val = obj.get(b"nd", obj.get("nd"))
kind_val = obj.get(b"kind", obj.get("kind"))
if nd_val and kind_val in (b"O", "O"):
raise ValueError(
"Refusing to decode object-dtype ndarray payload (pickle-bearing); "
"the allow_pickle=False contract is enforced by MsgSerializer."
)
return mnp.decode(obj, chain=chain)
@staticmethod
def _encode_custom(obj):
if isinstance(obj, ModalityConfig):
return {"__ModalityConfig__": True, "as_json": to_json_serializable(obj)}
return obj
@staticmethod
def _decode_custom(obj):
if not isinstance(obj, dict):
return obj
# If the ModalityConfig marker is present but 'as_json' is missing,
# raise instead of returning a half-broken dict.
has_modality_marker = (
"__ModalityConfig__" in obj
or b"__ModalityConfig__" in obj
or "__ModalityConfig_class__" in obj
or b"__ModalityConfig_class__" in obj
)
if has_modality_marker:
key = next((k for k in ("as_json", b"as_json") if k in obj), None)
if key is None:
raise ValueError(
f"Malformed ModalityConfig payload: marker present but "
f"'as_json' missing. keys={sorted(repr(k) for k in obj.keys())}"
)
payload = obj[key]
if isinstance(payload, bytes):
payload = payload.decode()
if isinstance(payload, str):
payload = json.loads(payload)
return ModalityConfig(**payload)
return obj
@dataclass
class EndpointHandler:
handler: Callable
requires_input: bool = True
class PolicyServer:
"""
An inference server that spin up a ZeroMQ socket and listen for incoming requests.
Can add custom endpoints by calling `register_endpoint`.
"""
# Bounded linger (ms) on close so a pending reply (e.g. the `kill` ack)
# flushes instead of being dropped, while still releasing the port promptly.
_CLOSE_LINGER_MS = 1000
def __init__(
self,
policy: BasePolicy,
host: str = "*",
port: int = 5555,
api_token: str = None,
):
self.policy = policy
self.host = host
self.port = port
self.running = True
self._closed = False
self.context = zmq.Context()
self.socket = self.context.socket(zmq.REP)
self.socket.bind(f"tcp://{host}:{port}")
self._endpoints: dict[str, EndpointHandler] = {}
self.api_token = api_token
# Register the ping endpoint by default
self.register_endpoint("ping", self._handle_ping, requires_input=False)
self.register_endpoint("kill", self._kill_server, requires_input=False)
self.register_endpoint("get_action", self.policy.get_action)
self.register_endpoint("reset", self.policy.reset)
self.register_endpoint(
"get_modality_config",
getattr(self.policy, "get_modality_config", lambda: {}),
requires_input=False,
)
def _kill_server(self):
"""Stop the run loop. Does not release the socket / context — use ``close()``."""
self.running = False
def close(self) -> None:
"""Release the bound socket and ZMQ context. Idempotent."""
if getattr(self, "_closed", True):
return
self._closed = True
self.running = False
socket = getattr(self, "socket", None)
if socket is not None:
try:
socket.close(linger=self._CLOSE_LINGER_MS)
except Exception:
pass
context = getattr(self, "context", None)
if context is not None:
try:
context.term()
except Exception:
pass
def __enter__(self):
# Reached only after __init__ completed socket.bind(), so the socket is
# guaranteed live here — safe to announce readiness.
print(f"\n✓ Server ready — listening on {self.host}:{self.port}\n")
return self
def __exit__(self, exc_type, exc, tb):
self.close()
def _handle_ping(self) -> dict:
"""
Simple ping handler that returns a success message.
"""
return {"status": "ok", "message": "Server is running"}
def register_endpoint(self, name: str, handler: Callable, requires_input: bool = True):
"""
Register a new endpoint to the server.
Args:
name: The name of the endpoint.
handler: The handler function that will be called when the endpoint is hit.
requires_input: Whether the handler requires input data.
"""
self._endpoints[name] = EndpointHandler(handler, requires_input)
def _validate_token(self, request: dict) -> bool:
"""
Validate the API token in the request.
"""
if self.api_token is None:
return True # No token required
return request.get("api_token") == self.api_token
def run(self):
addr = self.socket.getsockopt_string(zmq.LAST_ENDPOINT)
print(f"Server is ready and listening on {addr}")
while self.running:
try:
message = self.socket.recv()
request = MsgSerializer.from_bytes(message)
# Validate token before processing request
if not self._validate_token(request):
self.socket.send(
MsgSerializer.to_bytes({"error": "Unauthorized: Invalid API token"})
)
continue
endpoint = request.get("endpoint", "get_action")
if endpoint not in self._endpoints:
raise ValueError(f"Unknown endpoint: {endpoint}")
handler = self._endpoints[endpoint]
result = (
handler.handler(**request.get("data", {}))
if handler.requires_input
else handler.handler()
)
self.socket.send(MsgSerializer.to_bytes(result))
except Exception as e:
print(f"Error in server: {e}")
import traceback
print(traceback.format_exc())
self.socket.send(MsgSerializer.to_bytes({"error": str(e)}))
@staticmethod
def start_server(policy: BasePolicy, port: int, host: str = "*", api_token: str = None):
with PolicyServer(policy, host=host, port=port, api_token=api_token) as server:
server.run()
class PolicyClient(BasePolicy):
def __init__(
self,
host: str = "localhost",
port: int = 5555,
timeout_ms: int = 15000,
api_token: str = None,
strict: bool = False,
):
super().__init__(strict=strict)
self._closed = False
self.context = zmq.Context()
self.host = host
self.port = port
self.timeout_ms = timeout_ms
self.api_token = api_token
self._init_socket()
def _init_socket(self):
"""Initialize or reinitialize the socket with current settings"""
self.socket = self.context.socket(zmq.REQ)
self.socket.setsockopt(zmq.RCVTIMEO, self.timeout_ms)
self.socket.setsockopt(zmq.SNDTIMEO, self.timeout_ms)
self.socket.connect(f"tcp://{self.host}:{self.port}")
def ping(self) -> bool:
try:
self.call_endpoint("ping", requires_input=False)
return True
except zmq.error.ZMQError:
self._init_socket() # Recreate socket for next attempt
return False
def kill_server(self):
"""
Kill the server.
"""
self.call_endpoint("kill", requires_input=False)
def call_endpoint(
self, endpoint: str, data: dict | None = None, requires_input: bool = True
) -> Any:
"""
Call an endpoint on the server.
Args:
endpoint: The name of the endpoint.
data: The input data for the endpoint.
requires_input: Whether the endpoint requires input data.
"""
request: dict = {"endpoint": endpoint}
if requires_input:
request["data"] = data
if self.api_token:
request["api_token"] = self.api_token
try:
self.socket.send(MsgSerializer.to_bytes(request))
message = self.socket.recv()
except zmq.error.Again:
# Timeout — REQ socket is now in an invalid state (waiting for a
# reply that will never arrive). Recreate it so the next call can
# send again, then re-raise so the caller knows this request failed.
self._init_socket()
raise
if message == b"ERROR":
raise RuntimeError("Server error. Make sure we are running the correct policy server.")
response = MsgSerializer.from_bytes(message)
if isinstance(response, dict) and "error" in response:
raise RuntimeError(f"Server error: {response['error']}")
return response
def close(self) -> None:
"""Release the REQ socket and ZMQ context. Idempotent."""
if getattr(self, "_closed", True):
return
self._closed = True
socket = getattr(self, "socket", None)
if socket is not None:
try:
socket.close(linger=0)
except Exception:
pass
context = getattr(self, "context", None)
if context is not None:
try:
context.term()
except Exception:
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
def __del__(self):
# Best-effort GC fallback. ``__del__`` can fire during interpreter
# shutdown after module-level names (``zmq``, our own attributes)
# have been torn down; raising here is just noise on stderr.
try:
self.close()
except Exception:
pass
def _get_action(
self, observation: dict[str, Any], options: dict[str, Any] | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
response = self.call_endpoint(
"get_action", {"observation": observation, "options": options}
)
return tuple(response) # Convert list (from msgpack) to tuple of (action, info)
def reset(self, options: dict[str, Any] | None = None) -> dict[str, Any]:
return self.call_endpoint("reset", {"options": options})
def get_modality_config(self) -> dict[str, ModalityConfig]:
return self.call_endpoint("get_modality_config", requires_input=False)
def check_observation(self, observation: dict[str, Any]) -> None:
raise NotImplementedError(
"check_observation is not implemented. Please use `strict=False` to disable strict mode or implement this method in the subclass."
)
def check_action(self, action: dict[str, Any]) -> None:
raise NotImplementedError(
"check_action is not implemented. Please use `strict=False` to disable strict mode or implement this method in the subclass."
)